openvibers/openvibe.host Commits
Stage B tenant static hosting: the Host API service on port 4910
Changes
nnew file mode 100644@@@ -0,0 +1,82 @@++# ═══════════════════════════════════════════════════════════════++# OpenVibe.Host Stage B (tenant static hosting): environment (names only; never commit values)++# Copy to .env for development. Production: /etc/openvibe/host.env (0600).++# Stage A (the ovhost CLI) reads no environment beyond OVHOST_INVENTORY.++# ═══════════════════════════════════════════════════════════════++++# Server++PORT=4910++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++++# 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)++HOST_SITES_DOMAIN=openvibe.host++++# SQLite database and content-addressed objects (the production unit sets both under++# /var/lib/openvibe-host-api)++HOST_DB_PATH=./data/host.db++HOST_STORAGE_DIR=./data/objects++++# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# OAuth client `host` (redirect https://openvibe.host/auth/callback). The same client is Host's++# service principal svc:host for client-credentials grants (see README, "Grants").++OV_OAUTH_CLIENT_ID=host++OV_OAUTH_CLIENT_SECRET=++#OV_OAUTH_REDIRECT_URI=++# Cookies: Secure (and __Host- prefixed) by default in production; false for plain-http development++#COOKIE_SECURE=false++# Signs the dashboard form tokens (CSRF). Unset: random per process.++HOST_FORM_SECRET=++++# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set++#EVENTS_URL=http://127.0.0.1:4300++#EVENTS_RELAY_INTERVAL_MS=2000++++# Largest request body for one deploy (bytes; nginx allows 110m on the upload routes)++#HOST_MAX_UPLOAD_BYTES=104857600++++# Per-project quota defaults (a staff override per project wins). Production projects:++#HOST_QUOTA_STORAGE_BYTES=1073741824++#HOST_QUOTA_DEPLOYS_PER_DAY=50++#HOST_QUOTA_MAX_FILES=10000++#HOST_QUOTA_MAX_FILE_BYTES=26214400++#HOST_QUOTA_SITES=10++#HOST_QUOTA_CUSTOM_DOMAINS=5++# Sandbox projects:++#HOST_SANDBOX_QUOTA_STORAGE_BYTES=104857600++#HOST_SANDBOX_QUOTA_DEPLOYS_PER_DAY=20++#HOST_SANDBOX_QUOTA_MAX_FILES=2000++#HOST_SANDBOX_QUOTA_MAX_FILE_BYTES=10485760++#HOST_SANDBOX_QUOTA_SITES=3++#HOST_SANDBOX_QUOTA_CUSTOM_DOMAINS=0++++# Custom domains: what the CNAME points at (default <site>.<HOST_SITES_DOMAIN>), origin addresses++# shown for apex domains, resolvers for TXT checks, timings++#HOST_CNAME_TARGET=++#HOST_ORIGIN_IPV4=++#HOST_ORIGIN_IPV6=++#HOST_DNS_SERVERS=1.1.1.1,8.8.8.8++#HOST_DOMAIN_RECHECK_MS=600000++#HOST_DOMAIN_PENDING_DAYS=7++#HOST_DOMAIN_LAPSE_DAYS=7++# Extra domains nobody may claim, and extra reserved site names (comma-separated)++#HOST_RESERVED_DOMAINS=++#HOST_RESERVED_SITE_NAMES=++++# Background worker (domain checks, orphan-object sweep, outbox prune); off: HOST_WORKER=off++#HOST_WORKER=on++#HOST_WORKER_ID=++++# Browser origins that may call /api/v1 with a Bearer Network JWT (never cookies)++#API_CORS_ORIGINS=https://openvibe.network,https://openvibe.codes++++# release.json (openvibe-shared/release): the deployed commit when git is not available++#RELEASE_COMMIT=++#MIN_CLIENT_RELEASE=@@@ -10,3 +10,5 @@ host.json*.key.env.env.*++data/++!.env.examplennew file mode 100644@@@ -0,0 +1,28 @@++{++ "id": "host.deploy.create",++ "version": "1.0",++ "owner": "host",++ "status": "active",++ "visibility": "first-party",++ "description": "Upload an immutable static deploy (tar/tar.gz or multipart files; validated for path traversal, links, special and hidden files, server-side code, file types and quotas; never executed), list deploys and their upload logs, activate a deploy and roll a site back to any earlier ready deploy (one atomic pointer switch). Also judged by the acting principal's project role (deployer or above); an app principal added to a project as deployer can deploy from CI.",++ "permissions": [++ "host:deploy"++ ],++ "resourceConstraints": [++ "project"++ ],++ "quotaClass": "host-deploy",++ "events": [++ "host.deploy.created",++ "host.deploy.activated",++ "host.deploy.failed"++ ],++ "implementedBy": [++ "GET /api/v1/sites/:id/deploys",++ "POST /api/v1/sites/:id/deploys",++ "GET /api/v1/deploys/:id",++ "GET /api/v1/deploys/:id/log",++ "POST /api/v1/deploys/:id/activate",++ "POST /api/v1/sites/:id/rollback"++ ]++}nnew file mode 100644@@@ -0,0 +1,24 @@++{++ "id": "host.domain.manage",++ "version": "1.0",++ "owner": "host",++ "status": "active",++ "visibility": "first-party",++ "description": "Add a custom domain to a static site (never an OpenVibe domain; not in sandbox projects), read its DNS TXT verification and routing records, trigger a verification check, and remove it. A custom domain is served only after its TXT record is verified; TLS certificates are issued by an operator and never pass through this capability.",++ "permissions": [++ "host:write"++ ],++ "resourceConstraints": [++ "project"++ ],++ "quotaClass": "host-manage",++ "events": [++ "host.domain.verified"++ ],++ "implementedBy": [++ "GET /api/v1/sites/:id/domains",++ "POST /api/v1/sites/:id/domains",++ "POST /api/v1/domains/:id/verify",++ "DELETE /api/v1/domains/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,31 @@++{++ "id": "host.site.manage",++ "version": "1.0",++ "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.",++ "permissions": [++ "host:write"++ ],++ "resourceConstraints": [++ "project"++ ],++ "quotaClass": "host-manage",++ "events": [],++ "implementedBy": [++ "GET /api/v1/projects",++ "POST /api/v1/projects",++ "GET /api/v1/projects/:id",++ "DELETE /api/v1/projects/:id",++ "GET /api/v1/projects/:id/quota",++ "PUT /api/v1/projects/:id/quota",++ "PUT /api/v1/projects/:id/members/:principal",++ "DELETE /api/v1/projects/:id/members/:principal",++ "GET /api/v1/projects/:id/sites",++ "POST /api/v1/projects/:id/sites",++ "GET /api/v1/sites/:id",++ "DELETE /api/v1/sites/:id",++ "DELETE /api/v1/deploys/:id"++ ]++}@@@ -1,18 +1,32 @@{-- "version": "0.1.0",++ "version": "0.2.0","contractRanges": {-- "openvibe-contracts": ">=0.7.0 <1.0.0"++ "openvibe-contracts": ">=0.10.0 <1.0.0"},"id": "host","name": "OpenVibe.Host","status": "alpha","repository": "OpenVibers/OpenVibe.Host",-- "domains": [],-- "capabilities": [],-- "eventsProduced": [],++ "domains": [++ "openvibe.host"++ ],++ "publicOrigin": "https://openvibe.host",++ "health": "/api/health",++ "ready": "/api/ready",++ "capabilities": [++ "host.site.manage",++ "host.deploy.create",++ "host.domain.manage"++ ],++ "eventsProduced": [++ "host.deploy.created",++ "host.deploy.activated",++ "host.deploy.failed",++ "host.domain.verified"++ ],"eventsConsumed": [],"namespacesOwned": ["host.*"],-- "notes": "Stage A operator plane only: the ovhost CLI, run by an operator on the host (validate, plan, deploy with protected-session guards, automatic rollback, status, certificate inventory, vhost rendering, config snapshots, SQLite backups). No server and no public API yet; port 4910 is reserved for the later operator API. Stages B (tenant static hosting) and C (sandboxed user code) are not started."++ "notes": "Stage A: the ovhost operator CLI (no server). Stage B: the Host API service on port 4910 (unit openvibe-host.service, state /var/lib/openvibe-host-api): tenant projects keyed by project id (prj_, network_project_id once Network has projects, ADR-014), static sites served by Host header at <site>.openvibe.host (a registrable domain separate from every other OpenVibe domain) and on TXT-verified custom domains, immutable content-addressed deploys with rollback, quotas enforced here, upload logs. Consumes OpenVibe.Network (SSO, JWKS, client-credentials) and publishes through OpenVibe.Events (events.event.publish). Stage C (sandboxed user code) is not started."}@@@ -1,22 +1,45 @@{"name": "openvibe-host",-- "version": "0.1.0",++ "version": "0.2.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-host",-- "version": "0.1.0",++ "version": "0.2.0","license": "AGPL-3.0-only","dependencies": {"better-sqlite3": "^11.10.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.10.0"++ "busboy": "^1.6.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"},"bin": {"ovhost": "bin/ovhost"},++ "devDependencies": {++ "jsonwebtoken": "^9.0.2"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },"engines": {-- "node": ">=20.0.0"++ "node": ">= 0.6"}},"node_modules/ajv": {@@@ -52,6 +75,12 @@}}},++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },"node_modules/base64-js": {"version": "1.5.1","resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",@@@ -103,6 +132,30 @@"readable-stream": "^3.4.0"}},++ "node_modules/body-parser": {++ "version": "1.20.8",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },"node_modules/buffer": {"version": "5.7.1","resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",@@@ -127,12 +180,125 @@"ieee754": "^1.1.13"}},++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/busboy": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",++ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",++ "dependencies": {++ "streamsearch": "^1.1.0"++ },++ "engines": {++ "node": ">=10.16.0"++ }++ },++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },"node_modules/chownr": {"version": "1.1.4","resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz","integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/content-type": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie": {++ "version": "0.7.2",++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie-parser": {++ "version": "1.4.7",++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",++ "license": "MIT",++ "dependencies": {++ "cookie": "0.7.2",++ "cookie-signature": "1.0.6"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",++ "license": "MIT"++ },++ "node_modules/debug": {++ "version": "2.6.9",++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",++ "license": "MIT",++ "dependencies": {++ "ms": "2.0.0"++ }++ },"node_modules/decompress-response": {"version": "6.0.0","resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",@@@ -157,6 +323,25 @@"node": ">=4.0.0"}},++ "node_modules/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },"node_modules/detect-libc": {"version": "2.1.2","resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",@@@ -166,6 +351,56 @@"node": ">=8"}},++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },"node_modules/end-of-stream": {"version": "1.4.5","resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",@@@ -175,6 +410,51 @@"once": "^1.4.0"}},++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },"node_modules/expand-template": {"version": "2.0.3","resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",@@@ -184,6 +464,67 @@"node": ">=6"}},++ "node_modules/express": {++ "version": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/express-rate-limit": {++ "version": "7.5.1",++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",++ "license": "MIT",++ "engines": {++ "node": ">= 16"++ },++ "funding": {++ "url": "https://github.com/sponsors/express-rate-limit"++ },++ "peerDependencies": {++ "express": ">= 4.11"++ }++ },"node_modules/fast-deep-equal": {"version": "3.1.3","resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",@@@ -212,18 +553,168 @@"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==","license": "MIT"},++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },"node_modules/fs-constants": {"version": "1.0.0","resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz","integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==","license": "MIT"},++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },"node_modules/github-from-package": {"version": "0.0.0","resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz","integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==","license": "MIT"},++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/ieee754": {"version": "1.2.1","resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",@@@ -256,12 +747,181 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },"node_modules/json-schema-traverse": {"version": "1.0.0","resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz","integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==","license": "MIT"},++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },"node_modules/mimic-response": {"version": "3.1.0","resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",@@@ -289,12 +949,27 @@"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==","license": "MIT"},++ "node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "license": "MIT"++ },"node_modules/napi-build-utils": {"version": "2.0.0","resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz","integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==","license": "MIT"},++ "node_modules/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },"node_modules/node-abi": {"version": "3.96.0","resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",@@@ -307,6 +982,30 @@"node": ">=10"}},++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.1",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },"node_modules/once": {"version": "1.4.0","resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",@@@ -317,9 +1016,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.10.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.10.0",-- "integrity": "sha512-Wbl2Lgb/7L4BxZvha91Q+sTxebaN0swOHfaIHnQoILZBIsEpqq2HvE2klJIuPefttZA/11jmUR4nCeQX2FI0yQ==",++ "version": "0.19.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",++ "integrity": "sha512-5DjPvXIfc68wZhFXgKlCzDarbbFD5dUV4yYQIKEK1qD+VIuR+4twbON1brdyM9rYZyRyYClPigAqReocmRGytg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -332,6 +1031,39 @@"node": ">=20"}},++ "node_modules/openvibe-sdk": {++ "version": "0.2.2",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-shared": {++ "version": "1.3.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",++ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -359,6 +1091,23 @@"node": ">=10"}},++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },"node_modules/pump": {"version": "3.0.4","resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",@@@ -369,6 +1118,46 @@"once": "^1.3.1"}},++ "node_modules/qs": {++ "version": "6.16.0",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },"node_modules/rc": {"version": "1.2.8","resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",@@@ -427,6 +1216,12 @@],"license": "MIT"},++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },"node_modules/semver": {"version": "7.8.5","resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",@@@ -439,6 +1234,129 @@"node": ">=10"}},++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },"node_modules/simple-concat": {"version": "1.0.1","resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",@@@ -484,6 +1402,23 @@"simple-concat": "^1.0.0"}},++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/streamsearch": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",++ "engines": {++ "node": ">=10.0.0"++ }++ },"node_modules/string_decoder": {"version": "1.3.0","resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",@@@ -530,6 +1465,15 @@"node": ">=6"}},++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },"node_modules/tunnel-agent": {"version": "0.6.0","resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",@@@ -542,12 +1486,52 @@"node": "*"}},++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },"node_modules/util-deprecate": {"version": "1.0.2","resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz","integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==","license": "MIT"},++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },"node_modules/wrappy": {"version": "1.0.2","resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",@@@ -1,24 +1,43 @@{"name": "openvibe-host",-- "version": "0.1.0",++ "version": "0.2.0","private": true,-- "description": "OpenVibe.Host Stage A: the operator plane. The ovhost CLI validates, deploys, rolls back, snapshots and backs up the services on the OpenVibe host, and encodes the rules that keep live streams and recordings alive through a deploy.",++ "description": "OpenVibe.Host: the operator plane (Stage A, the ovhost CLI) and tenant static hosting (Stage B, the Host API service on port 4910): immutable content-addressed deploys, per-site domains, quotas and rollback.","main": "lib/index.js","bin": {"ovhost": "bin/ovhost"},"scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js","test": "node test/run.js"},-- "keywords": ["openvibe", "deploy", "systemd", "nginx", "operator"],++ "keywords": [++ "openvibe",++ "deploy",++ "systemd",++ "nginx",++ "operator",++ "static-hosting"++ ],"author": "OpenVibers","license": "AGPL-3.0-only","repository": "github:OpenVibers/OpenVibe.Host","dependencies": {"better-sqlite3": "^11.10.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.10.0"++ "busboy": "^1.6.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"},"engines": {-- "node": ">=20.0.0"++ "node": ">=22.0.0"++ },++ "devDependencies": {++ "jsonwebtoken": "^9.0.2"}}nnew file mode 100644@@@ -0,0 +1,32 @@++/* OpenVibe.Host dashboard. Colours follow the shared theme tokens when theme-loader.js has run. */++:root { --hs-bg: var(--bg-primary, #0a0f1c); --hs-fg: var(--text-primary, #e6edf7); --hs-muted: var(--text-secondary, #9aa7bd);++ --hs-card: var(--bg-secondary, #111a2c); --hs-line: rgba(255,255,255,.12); --hs-accent: var(--accent, #60a5fa);++ --hs-ok: #34d399; --hs-warn: #fbbf24; --hs-bad: #f87171; }++body { margin: 0; background: var(--hs-bg); color: var(--hs-fg); font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }++a { color: var(--hs-accent); }++.skip { position: absolute; left: -9999px; } .skip:focus { left: 1rem; top: 1rem; }++.account-bar { max-width: 72rem; margin: 0 auto; padding: .5rem 1rem; text-align: right; color: var(--hs-muted); font-size: 14px; }++.page { max-width: 72rem; margin: 0 auto; padding: 0 1rem 3rem; }++h1 { font-size: 1.7rem; margin: .5rem 0 .25rem; } h2 { font-size: 1.2rem; margin-top: 2rem; } h3 { font-size: 1rem; }++.meta, .muted, .crumbs { color: var(--hs-muted); } .crumbs { margin: 0; font-size: 14px; }++code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; overflow-wrap: anywhere; }++table { width: 100%; border-collapse: collapse; margin: .5rem 0 1rem; font-size: 14px; }++th, td { text-align: left; padding: .4rem .5rem; border-bottom: 1px solid var(--hs-line); vertical-align: top; }++tr.active td { background: rgba(96,165,250,.08); }++form { margin: .5rem 0 1rem; display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: end; }++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); }++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); }++.notice { padding: .6rem .8rem; border-radius: 6px; background: var(--hs-card); border-left: 4px solid var(--hs-accent); }++.notice-error { border-left-color: var(--hs-bad); }++.ok { color: var(--hs-ok); } .warn { color: var(--hs-warn); }++ul.log { list-style: none; padding: 0; font-family: ui-monospace, monospace; font-size: 13px; background: var(--hs-card); border-radius: 8px; padding: .75rem; }++ul.log .level { display: inline-block; width: 3.5em; color: var(--hs-muted); } .log-error { color: var(--hs-bad); } .log-warn { color: var(--hs-warn); }++ul.domains { padding-left: 1.2rem; } ul.domains li { margin-bottom: 1rem; }++.intro { max-width: 44rem; }++@media (max-width: 40rem) { table { display: block; overflow-x: auto; } .account-bar { text-align: left; } }nnew file mode 100644@@@ -0,0 +1,171 @@++'use strict';++++/**++ * OpenVibe.Host Stage B: Express app factory. server/index.js listens and starts the workers;++ * tests build their own instance with a temp database and storage, an injectable clock, a DNS++ * resolver table and a mock Network.++ *++ * Every request is dispatched on its Host header FIRST:++ *++ * <site>.<sitesDomain> / verified custom domain → tenant static files (http/tenant.js) and++ * nothing else: no API, no auth, no cookies++ * the dashboard host (BASE_URL) and loopback → dashboard pages, /api/v1, /auth, /api/ready,++ * /release.json, /metrics (loopback only)++ * anything else → 404 unknown host++ */++const path = require('path');++const express = require('express');++const cookieParser = require('cookie-parser');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const sharedMetrics = require('openvibe-shared/metrics');++++const configLib = require('./config');++const { openStore } = require('./db');++const { createBlobStore } = require('./storage');++const { createHostOutbox } = require('./events/outbox');++const { createAccess } = require('./domain/access');++const { createProjects } = require('./domain/projects');++const { createSites } = require('./domain/sites');++const { createDeploys } = require('./domain/deploys');++const { createDomains } = require('./domain/domains');++const { createAuthClient, createAuthRoutes } = require('./auth/sso');++const { createViewerResolver } = require('./auth/viewer');++const { createTenantServer } = require('./http/tenant');++const { createApi } = require('./http/api');++const { createDashboard } = require('./http/dashboard');++const { createHostReadiness } = require('./observability');++const { createWorker } = require('./worker');++const { renderPage, assetVersion } = require('./render/layout');++const pages = require('./render/pages');++++const PUBLIC_DIR = path.join(__dirname, '..', 'public');++const VERSION = require('../package.json').version;++++const DASHBOARD_CSP = {++ 'default-src': ["'self'"],++ // The shared chrome (theme-loader, navbar) comes from the Network; the inline init is ours.++ 'script-src': ["'self'", "'unsafe-inline'", 'https://openvibe.network'],++ 'style-src': ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],++ 'font-src': ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],++ 'img-src': ["'self'", 'data:', 'https:'],++ 'connect-src': ["'self'", 'https://openvibe.network'],++ 'frame-src': ["'self'", 'https://openvibe.network'],++ 'frame-ancestors': ["'none'"],++ 'object-src': ["'none'"],++ 'base-uri': ["'self'"],++ 'form-action': ["'self'", 'https://openvibe.network'],++};++const DASHBOARD_CSP_HEADER = Object.entries(DASHBOARD_CSP).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ');++++/**++ * opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object),++ * resolver ({ resolveTxt }), log++ */++function createApp(opts = {}) {++ const config = opts.config || configLib.load();++ const log = opts.log || console;++ const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });++ const blobs = createBlobStore(opts.storageDir || config.storageDir);++ require('./http/errors').setLogger(log);++++ 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 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 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 app = express();++ app.disable('x-powered-by');++ app.disable('etag');++ app.set('trust proxy', config.trustProxy);++++ const release = require('openvibe-shared/release').createRelease({ service: 'host', root: path.join(__dirname, '..') });++ const registry = sharedMetrics.createRegistry();++ const httpMetrics = sharedMetrics.httpMetrics(registry, { normalize: (req) => (req.hostTarget === 'site' ? 'tenant_site' : req.hostTarget === 'unknown' ? 'unknown_host' : null) });++ const proc = sharedMetrics.processMetrics(registry);++ sharedMetrics.releaseInfo(registry, { service: 'host', release: release.release });++ registry.gauge({++ name: 'host_sites', help: 'Active tenant sites',++ collect: () => [{ labels: {}, value: store.db.prepare("SELECT COUNT(*) AS n FROM host_sites WHERE status = 'active'").get().n }],++ });++ ctx.stopMetrics = proc.stop;++ app.locals.metrics = registry;++ app.locals.ctx = ctx;++++ app.use(httpMetrics.middleware);++++ // ── Host dispatch: tenant sites never reach anything below ──++ app.use((req, res, next) => {++ const target = tenant.resolve(req.headers.host);++ req.hostTarget = target.kind;++ if (target.kind === 'dashboard') return next();++ if (target.kind === 'site') return tenant.handle(req, res, target.site);++ return tenant.unknownHost(req, res);++ });++++ app.use(contracts.http.middleware());++ app.use((req, res, next) => {++ res.setHeader('Content-Security-Policy', DASHBOARD_CSP_HEADER);++ res.setHeader('X-Content-Type-Options', 'nosniff');++ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');++ res.setHeader('X-Frame-Options', 'DENY');++ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');++ next();++ });++ app.use(cookieParser());++++ // ── Machine endpoints ───────────────────────────────────++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-host', version: VERSION }));++ app.get('/release.json', release.handler);++ 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'));++++ // ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));++ app.use('/auth', createAuthRoutes(config, auth));++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'host', service: 'host', host: 'openvibe.host', name: 'OpenVibe.Host', profile: 'ugc' })); }++++ // ── Static assets (content-hashed ?v= → immutable) ──────++ app.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false, etag: true,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');++ },++ }));++++ // ── API ─────────────────────────────────────────────────++ app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));++++ // ── Dashboard ───────────────────────────────────────────++ app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));++ app.use((req, res) => {++ res.set('Cache-Control', 'private, no-store');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });++ res.status(404).type('html').send(renderPage({ title: 'Not found', body: pages.errorPage({ status: 404, message: 'There is no page at this address.' }), viewer: req.viewer, config, path: req.originalUrl }));++ });++++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ log.error('[Host]', err && err.stack ? err.stack : err);++ if (res.headersSent) return;++ res.set('Cache-Control', 'private, no-store');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++module.exports = { createApp, DASHBOARD_CSP_HEADER };nnew file mode 100644@@@ -0,0 +1,160 @@++'use strict';++/**++ * Reading an uploaded deploy: a tar or tar.gz archive (ustar, pax and GNU long names), or a set++ * of multipart files. Both end as the same list of { path, data } entries, checked by validate.js.++ *++ * The reader never touches the filesystem: entries are parsed from a Buffer and nothing is++ * extracted. Symbolic links, hard links, devices, FIFOs, sparse and other special entries are++ * refused (the whole deploy fails), never skipped silently. Decompression is capped++ * (maxOutputLength), so a gzip bomb stops at the limit instead of filling memory.++ */++const zlib = require('zlib');++++class ArchiveError extends Error {++ constructor(code, message, status = 422) { super(message); this.code = code; this.status = status; }++}++++const BLOCK = 512;++++function str(buf, start, len) {++ const s = buf.subarray(start, start + len);++ const z = s.indexOf(0);++ return (z >= 0 ? s.subarray(0, z) : s).toString('utf8');++}++++function octal(buf, start, len, what) {++ if (buf[start] & 0x80) throw new ArchiveError('archive.unsupported', `${what} uses base-256 encoding (entries over 8 GB are not supported)`);++ const s = str(buf, start, len).trim();++ if (!s) return 0;++ if (!/^[0-7]+$/.test(s)) throw new ArchiveError('archive.corrupt', `${what} is not an octal number`);++ return parseInt(s, 8);++}++++function checksumOk(header) {++ const stored = octal(header, 148, 8, 'header checksum');++ let sum = 0;++ for (let i = 0; i < BLOCK; i++) sum += (i >= 148 && i < 156) ? 32 : header[i];++ return sum === stored;++}++++function parsePax(data) {++ const out = {};++ let off = 0;++ const text = data.toString('utf8');++ while (off < text.length) {++ const sp = text.indexOf(' ', off);++ if (sp < 0) break;++ const len = parseInt(text.slice(off, sp), 10);++ if (!Number.isFinite(len) || len <= 0) throw new ArchiveError('archive.corrupt', 'malformed pax header');++ const record = text.slice(sp + 1, off + len - 1);++ const eq = record.indexOf('=');++ if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);++ off += len;++ }++ return out;++}++++const TYPE_NAMES = { 1: 'a hard link', 2: 'a symbolic link', 3: 'a character device', 4: 'a block device', 6: 'a FIFO', 7: 'a contiguous file', K: 'a GNU long link name', S: 'a sparse file', V: 'a volume header', M: 'a multi-volume entry', N: 'an old GNU long name' };++++/**++ * buf: the uploaded bytes (gzip or plain tar). limits: { maxTotalBytes, maxFiles, maxFileBytes }.++ * -> { entries: [{ path, data }], directories: n, compressed: bool }++ */++function readArchive(buf, limits) {++ let tar = buf;++ const compressed = buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b;++ if (compressed) {++ const cap = limits.maxTotalBytes + (limits.maxFiles + 16) * 3 * BLOCK + 1024 * 1024;++ try {++ tar = zlib.gunzipSync(buf, { maxOutputLength: cap });++ } catch (err) {++ if (err && (err.code === 'ERR_BUFFER_TOO_LARGE' || err instanceof RangeError)) {++ throw new ArchiveError('deploy.too_large', 'the archive expands beyond the size this project may deploy', 413);++ }++ throw new ArchiveError('archive.corrupt', 'not a valid gzip stream');++ }++ }++ if (tar.length < BLOCK) throw new ArchiveError('archive.corrupt', 'not a tar archive (too short)');++++ const entries = [];++ let directories = 0;++ let total = 0;++ let off = 0;++ let pending = {}; // pax / GNU long-name overrides for the next entry++ let sawEnd = false;++ while (off + BLOCK <= tar.length) {++ const header = tar.subarray(off, off + BLOCK);++ if (header.every((b) => b === 0)) { sawEnd = true; break; }++ if (!checksumOk(header)) throw new ArchiveError('archive.corrupt', `bad header checksum at byte ${off}`);++ const type = String.fromCharCode(header[156] || 48); // NUL means a regular file++ const size = octal(header, 124, 12, 'entry size');++ const magic = str(header, 257, 6);++ let name = str(header, 0, 100);++ if (magic.startsWith('ustar')) {++ const prefix = str(header, 345, 155);++ if (prefix) name = `${prefix}/${name}`;++ }++ const dataStart = off + BLOCK;++ const dataEnd = dataStart + size;++ if (dataEnd > tar.length) throw new ArchiveError('archive.corrupt', 'truncated archive');++ const data = tar.subarray(dataStart, dataEnd);++ off = dataStart + Math.ceil(size / BLOCK) * BLOCK;++++ if (type === 'x') { Object.assign(pending, parsePax(data)); continue; }++ if (type === 'g') continue; // global pax header: nothing we use++ if (type === 'L') { pending.path = str(data, 0, data.length); continue; }++ if (pending.path != null) name = pending.path;++ if (pending.linkpath != null && type !== '1' && type !== '2') throw new ArchiveError('deploy.link_refused', `"${name}" carries a link target`);++ pending = {};++++ if (type === '1' || type === '2') throw new ArchiveError('deploy.link_refused', `"${name}" is ${TYPE_NAMES[type]}: links are refused (a deploy holds regular files only)`);++ if (type === '5') { directories++; continue; }++ if (type !== '0') throw new ArchiveError('deploy.special_file_refused', `"${name}" is ${TYPE_NAMES[type] || `an entry of type "${type}"`}: only regular files are accepted`);++++ // A regular file.++ if (entries.length >= limits.maxFiles) throw new ArchiveError('quota.max_files', `more than ${limits.maxFiles} files`, 413);++ if (size > limits.maxFileBytes) throw new ArchiveError('quota.max_file_bytes', `"${name}" is ${size} bytes; the limit per file is ${limits.maxFileBytes}`, 413);++ total += size;++ if (total > limits.maxTotalBytes) throw new ArchiveError('deploy.too_large', `the files add up to more than ${limits.maxTotalBytes} bytes`, 413);++ entries.push({ path: name, data: Buffer.from(data) });++ }++ if (!sawEnd && off < tar.length) throw new ArchiveError('archive.corrupt', 'trailing garbage after the last entry');++ return { entries, directories, compressed };++}++++/**++ * Normalise archive/multipart names before validation: tar's "./" prefix and directory-style++ * names. Anything else (absolute paths, "..", backslashes) is left for the validator to refuse.++ */++function normaliseName(name) {++ let n = String(name);++ while (n.startsWith('./')) n = n.slice(2);++ return n;++}++++/**++ * Deploy from a subdirectory: keep entries under `root/`, strip the prefix, report what was++ * dropped. root must itself be a clean relative path.++ */++function selectRoot(entries, root) {++ if (!root) return { entries, dropped: 0 };++ const prefix = `${root.replace(/\/+$/, '')}/`;++ const kept = [];++ let dropped = 0;++ for (const e of entries) {++ if (e.path.startsWith(prefix)) kept.push({ ...e, path: e.path.slice(prefix.length) });++ else dropped++;++ }++ return { entries: kept, dropped };++}++++/** A browser folder upload names every file "<folder>/…": strip that single shared top folder. */++function stripSharedTop(entries) {++ if (!entries.length) return { entries, stripped: null };++ const firsts = new Set(entries.map((e) => (e.path.includes('/') ? e.path.split('/')[0] : null)));++ if (firsts.size !== 1 || firsts.has(null)) return { entries, stripped: null };++ const top = [...firsts][0];++ return { entries: entries.map((e) => ({ ...e, path: e.path.slice(top.length + 1) })), stripped: top };++}++++module.exports = { readArchive, normaliseName, selectRoot, stripSharedTop, ArchiveError };nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * The rules every file path in a deploy obeys, and the content types Host serves.++ *++ * Paths are relative, '/'-separated, ASCII, and made of plain segments: no '.', '..', empty++ * segment, backslash, NUL or control character, and no hidden segment except the leading++ * `.well-known/`. The same rules are applied to request paths after percent-decoding, and serving++ * looks paths up in the deploy's manifest (a database table) — never on the filesystem.++ *++ * Content types come from an allowlist keyed by extension. Nothing is ever executed: Stage B has no++ * build step and no server-side code, and files that are server-side code (php, cgi, asp, jsp,++ * shtml, …) are refused rather than served as text, so nobody deploys them believing they run.++ */++++const MAX_PATH = 1024;++const MAX_SEGMENT = 255;++const MAX_DEPTH = 32;++const SEGMENT_RE = /^[A-Za-z0-9._@+~()!,= -]+$/;++++class PathError extends Error {++ constructor(code, message) { super(message); this.code = code; }++}++++/** -> normalized path, or throws PathError. `allowDir` accepts a trailing '/'. */++function checkPath(input) {++ if (typeof input !== 'string' || !input) throw new PathError('path.empty', 'empty path');++ if (input.length > MAX_PATH) throw new PathError('path.too_long', `path longer than ${MAX_PATH} characters`);++ // eslint-disable-next-line no-control-regex++ if (/[\u0000-\u001f\u007f]/.test(input)) throw new PathError('path.control_character', 'path contains a control character');++ if (input.includes('\\')) throw new PathError('path.backslash', 'path contains a backslash');++ if (/[^ -~]/.test(input)) throw new PathError('path.non_ascii', 'path contains a non-ASCII character');++ if (input.startsWith('/')) throw new PathError('path.absolute', 'absolute path');++ const segments = input.split('/');++ if (segments.length > MAX_DEPTH) throw new PathError('path.too_deep', `more than ${MAX_DEPTH} levels`);++ segments.forEach((s, i) => {++ if (s === '' ) throw new PathError('path.empty_segment', 'empty path segment ("//" or a trailing "/")');++ if (s === '.' || s === '..') throw new PathError('path.traversal', `"${s}" segment`);++ if (s.length > MAX_SEGMENT) throw new PathError('path.too_long', 'path segment longer than 255 characters');++ if (!SEGMENT_RE.test(s)) throw new PathError('path.invalid_character', `segment "${s}" has a character outside [A-Za-z0-9._@+~()!,= -]`);++ if (s.startsWith('.') && !(i === 0 && s === '.well-known' && segments.length > 1)) {++ throw new PathError('path.hidden', `hidden file or directory "${s}" (only .well-known/ is published)`);++ }++ if (s.endsWith(' ') || s.startsWith(' ')) throw new PathError('path.invalid_character', `segment "${s}" starts or ends with a space`);++ });++ return input;++}++++// Server-side code and credentials: refused with an explicit reason.++const REFUSED = new Map([++ ...['php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'cgi', 'fcgi', 'pl', 'pm', 'py', 'pyc', 'rb', 'sh', 'bash', 'zsh', 'ksh', 'csh',++ 'asp', 'aspx', 'ascx', 'ashx', 'asmx', 'jsp', 'jspx', 'cfm', 'cfc', 'shtml', 'shtm', 'stm', 'ssi', 'lua', 'ps1', 'psm1', 'bat', 'cmd',++ 'exe', 'dll', 'so', 'dylib', 'bin', 'elf', 'jar', 'war', 'ear', 'class', 'htaccess', 'htpasswd']++ .map((e) => [e, 'server-side code or an executable: Host serves static files only and never runs anything']),++ ...['pem', 'key', 'p12', 'pfx', 'jks', 'keystore', 'env', 'npmrc', 'pgpass', 'netrc', 'kdbx', 'sqlite', 'sqlite3', 'db']++ .map((e) => [e, 'looks like a credential or a database: never publish secrets in a static site']),++]);++++const TYPES = {++ html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',++ css: 'text/css; charset=utf-8',++ js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', cjs: 'text/javascript; charset=utf-8',++ json: 'application/json; charset=utf-8', map: 'application/json; charset=utf-8',++ webmanifest: 'application/manifest+json; charset=utf-8',++ txt: 'text/plain; charset=utf-8', md: 'text/markdown; charset=utf-8', csv: 'text/csv; charset=utf-8',++ xml: 'application/xml; charset=utf-8', rss: 'application/rss+xml; charset=utf-8', atom: 'application/atom+xml; charset=utf-8',++ vtt: 'text/vtt; charset=utf-8',++ svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',++ avif: 'image/avif', ico: 'image/x-icon', bmp: 'image/bmp',++ woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',++ mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', m4v: 'video/mp4',++ mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', m4a: 'audio/mp4', flac: 'audio/flac',++ pdf: 'application/pdf', wasm: 'application/wasm',++ glb: 'model/gltf-binary', gltf: 'model/gltf+json',++ zip: 'application/zip',++};++// Extension-less files (CNAME, LICENSE, robots-like files) are served as plain text.++const NO_EXTENSION = 'text/plain; charset=utf-8';++++/** -> { contentType } or { refused: reason } */++function contentTypeFor(p) {++ const base = p.slice(p.lastIndexOf('/') + 1);++ const dot = base.lastIndexOf('.');++ if (dot <= 0) return { contentType: NO_EXTENSION };++ const ext = base.slice(dot + 1).toLowerCase();++ if (REFUSED.has(ext)) return { refused: REFUSED.get(ext), code: 'deploy.file_refused' };++ if (TYPES[ext]) return { contentType: TYPES[ext] };++ return { refused: `".${ext}" is not an allowed file type`, code: 'deploy.type_not_allowed' };++}++++/**++ * Fingerprinted asset names (bundler output) are cached as immutable: a hash-like segment of 8–64++ * characters with at least one digit right before the extension (app.3f2a9c1b.js, index-BdK3x9aQ.js).++ */++function isHashedAsset(p) {++ const m = /[.-]([A-Za-z0-9_]{8,64})\.[A-Za-z0-9]+$/.exec(p);++ return Boolean(m && /[0-9]/.test(m[1]) && /[A-Za-z]/.test(m[1]) && !/\.html?$/i.test(p));++}++++module.exports = { checkPath, contentTypeFor, isHashedAsset, PathError, TYPES, REFUSED, MAX_PATH };nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++/**++ * Turn uploaded entries into an immutable deploy manifest, or explain every reason it cannot be one.++ *++ * manifest = { schema: 'host.deploy-manifest@1', file_count, total_bytes,++ * files: [{ path, sha256, size, content_type }] } (sorted by path)++ * manifest_sha256 = sha256 of that JSON: identical content gives an identical digest.++ *++ * Every problem is collected (up to 50) so the upload log lists them all at once.++ */++const crypto = require('crypto');++const { checkPath, contentTypeFor, PathError } = require('./paths');++const { normaliseName } = require('./archive');++++const MAX_PROBLEMS = 50;++++function sha256(data) {++ return crypto.createHash('sha256').update(data).digest('hex');++}++++/**++ * entries: [{ path, data }]. limits: { maxFiles, maxFileBytes, maxTotalBytes }.++ * -> { ok: true, manifest, manifestSha256, files, warnings } | { ok: false, code, problems, warnings }++ */++function buildManifest(entries, limits) {++ const problems = [];++ const warnings = [];++ const files = [];++ const seen = new Set();++ let total = 0;++ const problem = (code, message) => { if (problems.length < MAX_PROBLEMS) problems.push({ code, message }); };++++ if (!entries.length) problem('deploy.empty', 'the upload contains no files');++ if (entries.length > limits.maxFiles) problem('quota.max_files', `${entries.length} files; this project may deploy at most ${limits.maxFiles}`);++++ for (const e of entries) {++ const raw = normaliseName(e.path);++ let p;++ try { p = checkPath(raw); } catch (err) {++ if (err instanceof PathError) { problem(err.code === 'path.traversal' || err.code === 'path.absolute' ? 'deploy.path_traversal' : 'deploy.invalid_path', `${JSON.stringify(String(e.path).slice(0, 200))}: ${err.message}`); continue; }++ throw err;++ }++ if (seen.has(p)) { problem('deploy.duplicate_path', `"${p}" appears twice`); continue; }++ seen.add(p);++ const t = contentTypeFor(p);++ if (t.refused) { problem(t.code, `"${p}": ${t.refused}`); continue; }++ if (e.data.length > limits.maxFileBytes) { problem('quota.max_file_bytes', `"${p}" is ${e.data.length} bytes; the limit per file is ${limits.maxFileBytes}`); continue; }++ total += e.data.length;++ files.push({ path: p, sha256: sha256(e.data), size: e.data.length, content_type: t.contentType, data: e.data });++ }++ if (total > limits.maxTotalBytes) problem('deploy.too_large', `the files add up to ${total} bytes; at most ${limits.maxTotalBytes} are allowed`);++++ // A path cannot be both a file and a directory ("a" and "a/b.html").++ for (const p of seen) {++ const parts = p.split('/');++ for (let i = 1; i < parts.length; i++) {++ const dir = parts.slice(0, i).join('/');++ if (seen.has(dir)) problem('deploy.path_conflict', `"${dir}" is both a file and a directory`);++ }++ }++++ if (problems.length) {++ const first = problems[0].code;++ return { ok: false, code: first, problems, warnings };++ }++ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));++ if (!seen.has('index.html')) warnings.push('there is no index.html at the root: the site\'s home page will be the 404 page');++ const manifest = {++ schema: 'host.deploy-manifest@1',++ file_count: files.length,++ total_bytes: total,++ files: files.map(({ path, sha256: h, size, content_type }) => ({ path, sha256: h, size, content_type })),++ };++ const json = JSON.stringify(manifest);++ return { ok: true, manifest, manifestJson: json, manifestSha256: sha256(json), files, warnings };++}++++module.exports = { buildManifest, sha256 };nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++++/**++ * Capability checks for service tokens (audience openvibe.host), including the ids Host introduces++ * before the contracts library knows them.++ *++ * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its++ * manifests yet. Host's ids are proposed in docs/capabilities-proposal/ for the next contracts++ * release; until then a grant is decided locally with the library's own matching rule (the exact++ * id, or a `prefix.*` grant covering it). An id the library does know always goes through the++ * library, so the day the release lands nothing changes here.++ *++ * 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++ *++ * 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.++ */++const { capabilities } = require('openvibe-contracts');++++const CAPABILITIES = Object.freeze({++ SITE_MANAGE: 'host.site.manage',++ DEPLOY_CREATE: 'host.deploy.create',++ DOMAIN_MANAGE: 'host.domain.manage',++});++const PROPOSED = new Set(Object.values(CAPABILITIES));++++/** → { allowed, code, reason } like capabilities.check(). */++function checkCapability(claims, capabilityId) {++ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {++ return capabilities.grants(claims && claims.cap, capabilityId)++ ? { allowed: true, code: null, reason: null }++ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };++ }++ return capabilities.check(claims, capabilityId);++}++++module.exports = { CAPABILITIES, PROPOSED, checkCapability };nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++++/**++ * Dashboard form protection. SameSite=Lax is NOT enough here: tenant sites on <site>.openvibe.host++ * are the same site as openvibe.host, so their pages could submit forms to the dashboard with the++ * session cookie attached. Every dashboard POST therefore needs both:++ *++ * 1. an Origin (or, without one, a Referer) equal to the dashboard's own origin, and++ * 2. a form token: an HMAC of the signed-in subject under HOST_FORM_SECRET (a random per-process++ * key when unset, so forms opened before a restart must be reloaded).++ */++const crypto = require('crypto');++++const fallback = crypto.randomBytes(32).toString('hex');++++function csrfToken(config, viewer) {++ if (!viewer || !viewer.subject) return '';++ return crypto.createHmac('sha256', config.formSecret || fallback).update(`host-form:${viewer.subject}`).digest('base64url').slice(0, 32);++}++++function checkCsrf(config, viewer, token) {++ const expected = csrfToken(config, viewer);++ if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;++ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));++}++++/** The request came from a dashboard page (not from a tenant page or another site). */++function sameOrigin(config, req) {++ const expected = new URL(config.baseUrl).origin;++ const origin = req.get('origin');++ if (origin) return origin === expected;++ const referer = req.get('referer');++ if (!referer) return false;++ try { return new URL(referer).origin === expected; } catch { return false; }++}++++module.exports = { csrfToken, checkCsrf, sameOrigin };nnew file mode 100644@@@ -0,0 +1,427 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// openvibe.host dashboard — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)++//++// The session layer OpenVibe.Community and OpenVibe.Blog use, with Host's client id (OAuth++// client `host`, redirect https://openvibe.host/auth/callback), and one Host-specific change:++//++// Tenant sites are served on SUBDOMAINS of openvibe.host (<site>.openvibe.host). Any tenant page++// can set a cookie for Domain=openvibe.host, so a plain cookie on the dashboard could be planted++// by a tenant (session fixation, login CSRF). Every cookie Host trusts therefore uses the++// `__Host-` prefix when cookies are Secure: the browser only accepts such a cookie from++// openvibe.host itself, with Path=/ and no Domain. The plain `ov_token` cookie is still written++// because the shared navbar reads it to show who is signed in, but the server never trusts it.++// The API (/api/v1) ignores cookies entirely: Bearer tokens only.++//++// GET /auth/login → redirect to Network /oauth/authorize (?silent=1: prompt=none)++// GET /auth/callback → server-side code exchange, set cookies++// POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)++// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest++// GET /auth/me → offline-verify the session, return profile++// POST /auth/refresh → rotate tokens via refresh_token grant++// ═══════════════════════════════════════════════════════════════++++const express = require('express');++const crypto = require('crypto');++const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');++++const ACCESS_COOKIE = 'ov_token'; // display only (navbar.js); never trusted by the server++const HINT_COOKIE = 'ov_sso_hint';++++/** Cookies the server trusts: `__Host-` prefixed whenever cookies are Secure. */++function cookieNames(secure) {++ const p = secure ? '__Host-' : '';++ return {++ session: `${p}ov_host_session`,++ refresh: `${p}ov_host_refresh`,++ state: `${p}ov_host_oauth_state`,++ next: `${p}ov_host_oauth_next`,++ silent: `${p}ov_host_oauth_silent`,++ };++}++++/**++ * Create the auth client + JWKS fetcher shared by the whole app.++ * Verification is OFFLINE: we cache the Network's RS256 public key from++ * GET /api/.well-known/jwks and verify JWTs locally on every request.++ */++function createAuthClient(config) {++ const client = new OpenVibeAuthClient({++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ redirectUri: config.oauth.redirectUri,++ publicKey: null, // filled by ensureKey()++ authBase: config.networkUrl,++ internalBase: config.networkInternalUrl,++ });++++ let lastFetch = 0;++ let inflight = null;++ async function ensureKey() {++ if (client.publicKey) return client.publicKey;++ // A request that arrives while the key is being fetched waits for that fetch rather than++ // being treated as signed out.++ if (inflight) return inflight;++ // Don't hammer the Network if it's down — retry at most every 30s++ if (Date.now() - lastFetch < 30_000) return null;++ lastFetch = Date.now();++ inflight = fetchKey().finally(() => { inflight = null; });++ return inflight;++ }++ async function fetchKey() {++ for (const base of [config.networkInternalUrl, config.networkUrl]) {++ if (!base) continue;++ try {++ const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });++ if (!res.ok) continue;++ const jwks = await res.json();++ if (jwks.public_key) {++ client.publicKey = jwks.public_key;++ console.log(`[Host] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);++ return client.publicKey;++ }++ } catch (err) {++ console.warn(`[Host auth] JWKS fetch failed from ${base}: ${err.message}`);++ }++ }++ return null;++ }++++ /** Offline JWT verification. Returns decoded claims or null. */++ async function verify(token) {++ if (!token) return null;++ await ensureKey();++ return client.verifyToken(token);++ }++++ // Warm the key cache at boot (non-fatal if the Network is down)++ ensureKey().catch(() => {});++++ return { client, ensureKey, verify };++}++++/** The dashboard session: only the (prefixed) session cookie, never ov_token. */++function sessionToken(req, config) {++ return req.cookies?.[cookieNames(config.cookies.secure).session] || null;++}++++/** API callers: only the Authorization header. */++function bearerToken(req) {++ const h = String(req.headers.authorization || '');++ return h.startsWith('Bearer ') ? h.slice(7).trim() || null : null;++}++++/** Strip registered claims — what the site shows as "the user". */++function claimsToUser(claims) {++ if (!claims) return null;++ const { iat, exp, aud, iss, nbf, jti, ...user } = claims;++ return user;++}++++/**++ * Decode a JWT's payload WITHOUT checking its signature. Only for reading claims++ * we cross-check locally (the FedCM nonce) before the Network — which does verify++ * the signature — sees the assertion. Never use it to trust an identity.++ */++function decodeJwtPayload(token) {++ if (typeof token !== 'string') return null;++ const parts = token.split('.');++ if (parts.length !== 3) return null;++ try {++ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));++ return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;++ } catch { return null; }++}++++/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */++function fedcmNonceMatches(token, nonce) {++ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;++ const claims = decodeJwtPayload(token);++ if (!claims || typeof claims.nonce !== 'string') return false;++ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);++ return a.length === b.length && crypto.timingSafeEqual(a, b);++}++++/**++ * Only allow same-site relative paths, this site's own https origin, or an++ * https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.++ */++function sanitizeNext(next, config) {++ if (!next || typeof next !== 'string') return '/';++ if (/^\/(?!\/|\\)/.test(next)) return next; // relative path, not protocol-relative++ try {++ const u = new URL(next);++ if (u.protocol !== 'https:') return '/';++ const allowed = [config.baseUrl, config.networkUrl]++ .map((b) => { try { return new URL(b).hostname; } catch { return null; } })++ .filter(Boolean);++ if (allowed.includes(u.hostname)) return u.toString();++ } catch { /* fall through */ }++ return '/';++}++++/** Append ?key=value to a same-site path or absolute URL, keeping any existing query/hash. */++function withParam(target, key, value) {++ const hashAt = target.indexOf('#');++ const hash = hashAt >= 0 ? target.slice(hashAt) : '';++ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;++ const sep = base.includes('?') ? '&' : '?';++ return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;++}++++/**++ * Build the Network authorize URL for a login. Exported (and pure) so tests can++ * cover the silent-login shape without a browser.++ */++function buildAuthorizeUrl(config, auth, { silent = false } = {}) {++ const { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);++ if (!silent) return { url, state };++ // ?silent=1: continue as the account openvibe.network already knows (no chooser); if the++ // network has no session either, it bounces back with error=login_required and we stay quiet.++ const u = new URL(url);++ u.searchParams.set('prompt', 'none');++ return { url: u.toString(), state };++}++++function createAuthRoutes(config, auth) {++ const router = express.Router();++++ const C = cookieNames(config.cookies.secure);++ const STATE_COOKIE = C.state;++ const NEXT_COOKIE = C.next;++ const SILENT_COOKIE = C.silent;++ const REFRESH_COOKIE = C.refresh;++++ const accessCookieOpts = () => ({++ sameSite: 'lax',++ secure: config.cookies.secure,++ httpOnly: false, // JS-readable by design: the shared navbar reads it (display only)++ path: '/',++ maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT++ });++++ const sessionCookieOpts = () => ({++ sameSite: 'lax',++ secure: config.cookies.secure,++ httpOnly: true,++ path: '/', // __Host- requires Path=/ and no Domain++ maxAge: 24 * 60 * 60 * 1000,++ });++++ const refreshCookieOpts = () => ({ ...sessionCookieOpts(), maxAge: 30 * 24 * 60 * 60 * 1000 });++++ const flowCookieOpts = () => ({ ...sessionCookieOpts(), maxAge: 10 * 60 * 1000 });++++ const hintCookieOpts = () => ({++ sameSite: 'lax',++ secure: config.cookies.secure,++ httpOnly: false, // navbar.js reads it to decide whether a silent login is worth trying++ path: '/',++ maxAge: 365 * 24 * 60 * 60 * 1000,++ });++++ function setSessionCookies(res, accessToken, refreshToken) {++ res.cookie(C.session, accessToken, sessionCookieOpts());++ res.cookie(ACCESS_COOKIE, accessToken, accessCookieOpts());++ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());++ res.cookie(HINT_COOKIE, 'account', hintCookieOpts());++ }++++ function clearSessionCookies(res) {++ res.clearCookie(C.session, { ...sessionCookieOpts(), maxAge: undefined });++ res.clearCookie(ACCESS_COOKIE, { ...accessCookieOpts(), maxAge: undefined });++ res.clearCookie(REFRESH_COOKIE, { ...refreshCookieOpts(), maxAge: undefined });++ }++++ function clearFlowCookies(res) {++ for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { ...flowCookieOpts(), maxAge: undefined });++ }++++ /** Exchange at the Network — internal URL first, public as fallback. */++ async function tokenGrant(body) {++ let lastErr = null;++ for (const base of [config.networkInternalUrl, config.networkUrl]) {++ if (!base) continue;++ try {++ const res = await fetch(`${base}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({++ client_id: config.oauth.clientId,++ client_secret: config.oauth.clientSecret,++ ...body,++ }),++ signal: AbortSignal.timeout(10_000),++ });++ const data = await res.json().catch(() => ({}));++ if (!res.ok) {++ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);++ err.status = res.status;++ err.error = data.error || 'invalid_grant';++ err.error_description = data.error_description || null;++ throw err;++ }++ return data;++ } catch (err) {++ lastErr = err;++ // 4xx = the grant itself is bad; retrying against another base won't help++ if (err.status && err.status < 500) throw err;++ }++ }++ throw lastErr || new Error('Network unreachable');++ }++++ // ── GET /auth/login ──────────────────────────────────────++ router.get('/login', async (req, res) => {++ const silent = !!req.query.silent && req.query.silent !== '0';++ if (silent) {++ // Already signed in here? Then the silent round trip through the Network would++ // only hand back the session we have: go straight to where the caller wanted.++ const existing = req.cookies?.[C.session];++ if (existing && await auth.verify(existing)) {++ clearFlowCookies(res);++ return res.redirect(sanitizeNext(req.query.next, config));++ }++ }++ const { url, state } = buildAuthorizeUrl(config, auth, { silent });++ res.cookie(STATE_COOKIE, state, flowCookieOpts());++ const next = sanitizeNext(req.query.next, config);++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());++ else res.clearCookie(NEXT_COOKIE, { ...flowCookieOpts(), maxAge: undefined });++ if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());++ else res.clearCookie(SILENT_COOKIE, { ...flowCookieOpts(), maxAge: undefined });++ res.redirect(url);++ });++++ // ── GET /auth/callback ───────────────────────────────────++ router.get('/callback', async (req, res) => {++ const { code, state, error } = req.query;++ const next = sanitizeNext(req.cookies?.[NEXT_COOKIE], config);++ const silent = req.cookies?.[SILENT_COOKIE] === '1';++++ if (error) {++ clearFlowCookies(res);++ // Silent sign-in found no network session — go back quietly as a guest. The++ // ?sso=none marker stops navbar.js from trying again on that page.++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));++ return res.redirect(withParam('/', 'auth_error', String(error)));++ }++ if (!code) return res.status(400).send('Missing authorization code');++++ const expectedState = req.cookies?.[STATE_COOKIE];++ clearFlowCookies(res);++ // The state cookie is required, not just compared when present: skipping the check when++ // the cookie is missing would let a crafted link sign a visitor into someone else's account.++ if (!expectedState || !state || !crypto.timingSafeEqual(++ Buffer.from(String(state).padEnd(64).slice(0, 64)),++ Buffer.from(String(expectedState).padEnd(64).slice(0, 64))++ )) {++ return res.status(400).send('OAuth state mismatch — please try signing in again.');++ }++++ try {++ const data = await tokenGrant({++ grant_type: 'authorization_code',++ redirect_uri: config.oauth.redirectUri,++ code,++ });++ setSessionCookies(res, data.access_token, data.refresh_token);++ return res.redirect(next);++ } catch (err) {++ console.error('[Host auth] Code exchange failed:', err.message);++ return res.status(502).send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');++ }++ });++++ // ── POST /auth/fedcm ─────────────────────────────────────++ // The shared navbar obtained a FedCM assertion from openvibe.network (the++ // browser's own account chooser) and posts it same-origin with the nonce it++ // put in the FedCM request. We only pre-check the nonce; the Network verifies++ // the assertion's signature when it swaps it for tokens.++ const fedcmBody = express.json({ limit: '16kb', type: 'application/json' });++ const fedcmBodyError = (err, _req, res, next) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : next());++ router.post('/fedcm', fedcmBody, fedcmBodyError, async (req, res) => {++ if (!req.is('application/json')) return res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' });++ const { token, nonce } = req.body || {};++ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {++ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });++ }++ if (!fedcmNonceMatches(token, nonce)) {++ return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });++ }++ try {++ const data = await tokenGrant({++ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',++ assertion: token,++ });++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });++ setSessionCookies(res, data.access_token, data.refresh_token);++ const user = data.user || claimsToUser(await auth.verify(data.access_token));++ return res.json({ ok: true, user });++ } catch (err) {++ if (err.status && err.status < 500) {++ return res.status(401).json({ error: err.error || 'invalid_grant', error_description: err.error_description || err.message });++ }++ console.error('[Host auth] FedCM exchange failed:', err.message);++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });++ }++ });++++ // ── GET /auth/logout ─────────────────────────────────────++ router.get('/logout', async (req, res) => {++ // Best-effort refresh revocation. The Network's rotating refresh tokens++ // self-invalidate, so a failure here is harmless.++ const refresh = req.cookies?.[REFRESH_COOKIE];++ if (refresh) {++ try {++ await fetch(`${config.networkInternalUrl}/oauth/revoke`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({++ client_id: config.oauth.clientId,++ client_secret: config.oauth.clientSecret,++ token: refresh,++ }),++ signal: AbortSignal.timeout(3000),++ });++ } catch { /* optional */ }++ }++ clearSessionCookies(res);++ res.cookie(HINT_COOKIE, 'guest', hintCookieOpts());++ res.redirect(sanitizeNext(req.query.next, config));++ });++++ // ── GET /auth/me ─────────────────────────────────────────++ router.get('/me', async (req, res) => {++ const token = sessionToken(req, config);++ if (!token) return res.status(401).json({ error: 'Not authenticated' });++ const claims = await auth.verify(token);++ if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });++ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });++ });++++ // ── POST /auth/refresh ───────────────────────────────────++ // Small endpoint the shared navbar can call when ov_token expires.++ router.post('/refresh', async (req, res) => {++ const refresh = req.cookies?.[REFRESH_COOKIE];++ if (!refresh) return res.status(401).json({ error: 'No refresh token' });++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });++ setSessionCookies(res, data.access_token, data.refresh_token);++ const claims = await auth.verify(data.access_token);++ return res.json({ token: data.access_token, user: claimsToUser(claims) });++ } catch (err) {++ if (err.status && err.status < 500) {++ clearSessionCookies(res);++ return res.status(401).json({ error: 'Refresh token rejected — please sign in again' });++ }++ console.error('[Host auth] Refresh failed:', err.message);++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });++ }++ });++++ return router;++}++++module.exports = { createAuthClient, createAuthRoutes, sessionToken, bearerToken, cookieNames, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++++/**++ * Who is calling, resolved once per request into `req.viewer`:++ *++ * { kind: 'anonymous' }++ * { kind: 'user', subject: 'usr_…', staff, user }++ * API: a Network user JWT in `Authorization: Bearer` (cookies are ignored on /api/v1).++ * Dashboard: the __Host- session cookie (never the navbar's ov_token, which a tenant++ * subdomain could plant). role 'admin' makes the viewer staff.++ * { kind: 'service', service: 'svc:codes' | 'app:app_…', claims, subject, env }++ * A Network client-credentials token for audience openvibe.host. A first-party service++ * (svc:) may name the person it acts for with X-OV-Subject; an app (app:) always acts as++ * itself. `env` is the token's environment claim ("sandbox" is refused on production++ * projects).++ *++ * Identity never comes from a request body or query. A bad token is refused (problem+json),++ * never downgraded to anonymous.++ */++const contracts = require('openvibe-contracts');++const { sessionToken, bearerToken, claimsToUser, decodeJwtPayload } = require('./sso');++const { checkCapability } = require('./capabilities');++++const { ids, serviceAuth, http } = contracts;++const STAFF_ROLES = new Set(['admin']);++const PRINCIPAL_SUB = /^(svc|app|mod):/;++const AUDIENCE = 'openvibe.host';++++class ViewerError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false });++++function createViewerResolver({ auth, config }) {++ async function fromServiceToken(req, token) {++ const publicKey = await auth.ensureKey();++ if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });++ if (!r.ok) throw new ViewerError(401, r.code, r.reason);++ const sub = r.claims.sub;++ if (sub.startsWith('mod:')) throw new ViewerError(403, 'principal.not_allowed', 'mods cannot manage hosted sites');++ const subjectHeader = req.get('x-ov-subject');++ let subject = null;++ if (subjectHeader) {++ if (!sub.startsWith('svc:')) throw new ViewerError(400, 'subject.not_delegable', 'only first-party services may act for a person (X-OV-Subject); an app acts as itself');++ if (!ids.isSubjectId('user', subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');++ subject = subjectHeader;++ }++ const env = r.claims.env || r.claims.environment || null;++ return { kind: 'service', service: sub, claims: r.claims, subject, env: env === 'sandbox' ? 'sandbox' : env, staff: false };++ }++++ async function fromUserToken(token) {++ const claims = await auth.verify(token);++ if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ if (!subject) return null;++ return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), user: claimsToUser(claims) };++ }++++ async function resolveApi(req) {++ const token = bearerToken(req);++ if (!token) return ANONYMOUS;++ const payload = decodeJwtPayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) return fromServiceToken(req, token);++ const v = await fromUserToken(token);++ if (!v) throw new ViewerError(401, 'auth.invalid_token', 'the bearer token is not a valid OpenVibe token');++ return v;++ }++++ async function resolveDashboard(req) {++ const token = sessionToken(req, config);++ if (!token) return ANONYMOUS;++ return (await fromUserToken(token)) || ANONYMOUS;++ }++++ function middleware(mode) {++ return async (req, res, next) => {++ try {++ req.viewer = mode === 'api' ? await resolveApi(req) : await resolveDashboard(req);++ next();++ } catch (err) {++ if (!(err instanceof ViewerError)) return next(err);++ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });++ }++ };++ }++++ return { resolveApi, resolveDashboard, middleware };++}++++/**++ * Route guard: a service token must hold `cap`. Browsers and anonymous callers pass here and are++ * judged by project membership in the domain layer. Each route checks exactly one capability.++ */++function guard(cap) {++ return (req, res, next) => {++ const v = req.viewer;++ if (!v || v.kind !== 'service') return next();++ const c = checkCapability(v.claims, cap);++ if (c.allowed) return next();++ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });++ };++}++++module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError, STAFF_ROLES, AUDIENCE };nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++++/**++ * OpenVibe.Host Stage B (tenant static hosting) configuration. Every value comes from the++ * environment (production: /etc/openvibe/host.env, see .env.example). Only environment variable++ * NAMES appear in code and docs; secret values are never logged or returned.++ *++ * load(env) is pure so tests can build a config without touching process.env.++ */++require('dotenv').config();++++const trim = (s) => String(s || '').replace(/\/+$/, '');++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);++const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);++const MiB = 1024 * 1024;++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 4910);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.host' : `http://localhost:${port}`));++ const dashboardHost = new URL(baseUrl).hostname.toLowerCase();++ // Tenant sites are served at <site>.<sitesDomain>. It must be a registrable domain of its own,++ // never a subdomain of openvibe.network / openvibe.live (their cookies must never reach tenants).++ const sitesDomain = String(env.HOST_SITES_DOMAIN || 'openvibe.host').toLowerCase().replace(/^\.+|\.+$/g, '');++++ const quota = (prefix, defaults) => ({++ storageBytes: int(env[`${prefix}STORAGE_BYTES`], defaults.storageBytes),++ deploysPerDay: int(env[`${prefix}DEPLOYS_PER_DAY`], defaults.deploysPerDay),++ maxFiles: int(env[`${prefix}MAX_FILES`], defaults.maxFiles),++ maxFileBytes: int(env[`${prefix}MAX_FILE_BYTES`], defaults.maxFileBytes),++ sites: int(env[`${prefix}SITES`], defaults.sites),++ customDomains: int(env[`${prefix}CUSTOM_DOMAINS`], defaults.customDomains),++ });++++ return {++ service: 'host',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ dashboardHost,++ sitesDomain,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++++ dbPath: env.HOST_DB_PATH || './data/host.db',++ // Content-addressed deploy objects, one directory per project (tenancy keyed by project id).++ storageDir: env.HOST_STORAGE_DIR || './data/objects',++++ // OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.++ networkUrl: trim(env.OV_NETWORK_URL || 'https://openvibe.network'),++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'host',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: 'profile theme',++ },++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++ // Signs the dashboard form tokens (CSRF). Unset: a random per-process key.++ formSecret: env.HOST_FORM_SECRET || '',++++ // OpenVibe.Events: the outbox relay runs only when EVENTS_URL and the client secret are set.++ events: {++ url: trim(env.EVENTS_URL || ''),++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),++ },++++ uploads: {++ // Largest request body accepted for one deploy (the compressed archive or all multipart parts).++ maxUploadBytes: int(env.HOST_MAX_UPLOAD_BYTES, 100 * MiB),++ },++++ // Per-project quotas (a row in host_quotas overrides them per project; only staff set it).++ quotas: {++ production: quota('HOST_QUOTA_', { storageBytes: 1024 * MiB, deploysPerDay: 50, maxFiles: 10000, maxFileBytes: 25 * MiB, sites: 10, customDomains: 5 }),++ sandbox: quota('HOST_SANDBOX_QUOTA_', { storageBytes: 100 * MiB, deploysPerDay: 20, maxFiles: 2000, maxFileBytes: 10 * MiB, sites: 3, customDomains: 0 }),++ },++++ domains: {++ // What a custom domain's CNAME points at. Default: the site's own <site>.<sitesDomain>.++ cnameTarget: String(env.HOST_CNAME_TARGET || '').toLowerCase() || null,++ originIpv4: env.HOST_ORIGIN_IPV4 || null,++ originIpv6: env.HOST_ORIGIN_IPV6 || null,++ dnsServers: list(env.HOST_DNS_SERVERS),++ recheckIntervalMs: int(env.HOST_DOMAIN_RECHECK_MS, 10 * 60 * 1000),++ pendingDays: int(env.HOST_DOMAIN_PENDING_DAYS, 7),++ lapseDays: int(env.HOST_DOMAIN_LAPSE_DAYS, 7),++ extraReserved: list(env.HOST_RESERVED_DOMAINS).map((d) => d.toLowerCase()),++ },++++ sites: { extraReservedNames: list(env.HOST_RESERVED_SITE_NAMES).map((d) => d.toLowerCase()) },++++ worker: {++ enabled: env.HOST_WORKER !== 'off',++ id: env.HOST_WORKER_ID || `host-${process.pid}`,++ },++++ // Browser origins that may call /api/v1 with a Bearer Network JWT (never cookies).++ apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network,https://openvibe.codes'),++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,187 @@++'use strict';++++/**++ * Host's own SQLite database (WAL), created on boot, idempotently. Nothing here is shared with++ * another service.++ *++ * host_projects tenant projects: owner subject (usr_…), environment (production|sandbox),++ * network_project_id once OpenVibe.Network has projects (ADR-014)++ * host_project_members principals with a role on a project: usr_… or app:app_…/svc:… principals++ * host_quotas per-project overrides of the configured quota defaults (staff only)++ * host_sites a site = a name (<name>.openvibe.host) + the pointer to its active deploy++ * host_deploys IMMUTABLE artifacts: manifest (sha256 per file), totals, state++ * host_deploy_files path → sha256/size/content type per deploy (immutable)++ * host_blobs which content-addressed objects a project stores (storage accounting)++ * 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)++ * event_outbox openvibe-sdk transactional outbox++ *++ * Immutability is enforced by the database itself: triggers refuse any UPDATE of a deploy's++ * artifact columns or of its file rows, and file rows can only be removed once the deploy is deleted.++ */++const fs = require('fs');++const path = require('path');++const Database = require('better-sqlite3');++++const SCHEMA = `++CREATE TABLE IF NOT EXISTS host_projects (++ id TEXT PRIMARY KEY, -- prj_<ULID>++ network_project_id TEXT, -- OpenVibe.Network project id (ADR-014), when it exists++ owner_subject TEXT NOT NULL, -- usr_…++ name TEXT NOT NULL,++ environment TEXT NOT NULL DEFAULT 'production' CHECK (environment IN ('production','sandbox')),++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ deleted_at INTEGER++);++CREATE UNIQUE INDEX IF NOT EXISTS host_projects_network ON host_projects (network_project_id) WHERE network_project_id IS NOT NULL AND status = 'active';++CREATE INDEX IF NOT EXISTS host_projects_owner ON host_projects (owner_subject, status);++++CREATE TABLE IF NOT EXISTS host_project_members (++ project_id TEXT NOT NULL REFERENCES host_projects(id),++ principal TEXT NOT NULL, -- usr_… | app:app_… | svc:<id>++ role TEXT NOT NULL CHECK (role IN ('owner','maintainer','deployer')),++ added_by TEXT,++ created_at INTEGER NOT NULL,++ PRIMARY KEY (project_id, principal)++);++CREATE INDEX IF NOT EXISTS host_project_members_principal ON host_project_members (principal);++++CREATE TABLE IF NOT EXISTS host_quotas (++ project_id TEXT PRIMARY KEY REFERENCES host_projects(id),++ storage_bytes INTEGER,++ deploys_per_day INTEGER,++ max_files INTEGER,++ max_file_bytes INTEGER,++ sites INTEGER,++ custom_domains INTEGER,++ updated_by TEXT,++ updated_at INTEGER NOT NULL++);++++CREATE TABLE IF NOT EXISTS host_sites (++ id TEXT PRIMARY KEY, -- site_<ULID>++ project_id TEXT NOT NULL REFERENCES host_projects(id),++ name TEXT NOT NULL, -- the <name>.openvibe.host label++ active_deploy_id TEXT,++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),++ created_by TEXT NOT NULL,++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ deleted_at INTEGER++);++CREATE UNIQUE INDEX IF NOT EXISTS host_sites_name ON host_sites (name) WHERE status = 'active';++CREATE INDEX IF NOT EXISTS host_sites_project ON host_sites (project_id, status);++++CREATE TABLE IF NOT EXISTS host_deploys (++ id TEXT PRIMARY KEY, -- dpl_<ULID>++ project_id TEXT NOT NULL REFERENCES host_projects(id),++ site_id TEXT NOT NULL REFERENCES host_sites(id),++ state TEXT NOT NULL CHECK (state IN ('ready','failed','deleted')),++ source TEXT NOT NULL CHECK (source IN ('archive','files')),++ manifest TEXT, -- JSON host.deploy-manifest@1 (NULL when failed)++ manifest_sha256 TEXT,++ file_count INTEGER NOT NULL DEFAULT 0,++ total_bytes INTEGER NOT NULL DEFAULT 0,++ new_bytes INTEGER NOT NULL DEFAULT 0, -- bytes this deploy added to the project's storage++ failure_code TEXT,++ created_by TEXT NOT NULL,++ created_at INTEGER NOT NULL,++ deleted_at INTEGER++);++CREATE INDEX IF NOT EXISTS host_deploys_site ON host_deploys (site_id, created_at);++CREATE INDEX IF NOT EXISTS host_deploys_project_day ON host_deploys (project_id, created_at);++++CREATE TABLE IF NOT EXISTS host_deploy_files (++ deploy_id TEXT NOT NULL REFERENCES host_deploys(id),++ path TEXT NOT NULL,++ sha256 TEXT NOT NULL,++ size INTEGER NOT NULL,++ content_type TEXT NOT NULL,++ PRIMARY KEY (deploy_id, path)++) WITHOUT ROWID;++CREATE INDEX IF NOT EXISTS host_deploy_files_sha ON host_deploy_files (sha256);++++CREATE TABLE IF NOT EXISTS host_blobs (++ project_id TEXT NOT NULL,++ sha256 TEXT NOT NULL,++ size INTEGER NOT NULL,++ created_at INTEGER NOT NULL,++ PRIMARY KEY (project_id, sha256)++) WITHOUT ROWID;++++CREATE TABLE IF NOT EXISTS host_activations (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ site_id TEXT NOT NULL REFERENCES host_sites(id),++ deploy_id TEXT NOT NULL,++ previous_deploy_id TEXT,++ kind TEXT NOT NULL CHECK (kind IN ('activate','rollback')),++ actor TEXT NOT NULL,++ created_at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS host_activations_site ON host_activations (site_id, id);++++CREATE TABLE IF NOT EXISTS host_deploy_logs (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ deploy_id TEXT NOT NULL,++ level TEXT NOT NULL CHECK (level IN ('info','warn','error')),++ message TEXT NOT NULL,++ created_at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS host_deploy_logs_deploy ON host_deploy_logs (deploy_id, id);++++CREATE TABLE IF NOT EXISTS host_domains (++ id TEXT PRIMARY KEY, -- dom_<ULID>++ project_id TEXT NOT NULL REFERENCES host_projects(id),++ site_id TEXT NOT NULL REFERENCES host_sites(id),++ hostname TEXT NOT NULL,++ kind TEXT NOT NULL CHECK (kind IN ('default','custom')),++ status TEXT NOT NULL CHECK (status IN ('pending','verified','failed','lapsed')),++ token TEXT, -- the TXT verification value (custom only; public in DNS, not a secret)++ created_by TEXT NOT NULL,++ created_at INTEGER NOT NULL,++ verified_at INTEGER,++ last_checked_at INTEGER,++ last_error TEXT,++ check_count INTEGER NOT NULL DEFAULT 0,++ record_missing_since INTEGER -- verified domain whose TXT record disappeared++);++CREATE UNIQUE INDEX IF NOT EXISTS host_domains_verified ON host_domains (hostname) WHERE status = 'verified';++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);++++-- 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++BEGIN SELECT RAISE(ABORT, 'host: deploy artifacts are immutable'); END;++CREATE TRIGGER IF NOT EXISTS host_deploys_no_undelete++BEFORE UPDATE OF state ON host_deploys WHEN OLD.state = 'deleted' OR (OLD.state = 'failed' AND NEW.state = 'ready')++BEGIN SELECT RAISE(ABORT, 'host: a deleted or failed deploy cannot become ready'); END;++CREATE TRIGGER IF NOT EXISTS host_deploy_files_immutable++BEFORE UPDATE ON host_deploy_files++BEGIN SELECT RAISE(ABORT, 'host: deploy files are immutable'); END;++CREATE TRIGGER IF NOT EXISTS host_deploy_files_delete_only_deleted++BEFORE DELETE ON host_deploy_files WHEN (SELECT state FROM host_deploys WHERE id = OLD.deploy_id) <> 'deleted'++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'];++++function openStore(dbPath, { now = () => Date.now() } = {}) {++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });++ const db = new Database(dbPath);++ db.pragma('journal_mode = WAL');++ db.pragma('foreign_keys = ON');++ db.pragma('busy_timeout = 5000');++ db.exec(SCHEMA);++ return {++ db,++ now,++ tx: (fn) => db.transaction(fn)(),++ close: () => db.close(),++ };++}++++module.exports = { openStore, CHARTER_TABLES };nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++/**++ * Who may do what on a project.++ *++ * principal a browser user: its usr_ subject. A first-party service token (svc:…): the person++ * named by X-OV-Subject, else the service itself. An app token (app:app_…): the app++ * itself — apps are principals (ADR-014) and never act as a person here.++ * roles owner > maintainer > deployer, per project (host_project_members).++ * deployer upload deploys, list them and their logs, activate, roll back++ * maintainer + sites, custom domains, delete deploys++ * owner + delete the project, manage members++ * staff Network admins: read everything, delete sites/projects/domains, set quotas; they++ * cannot publish into a tenant's site.++ *++ * A caller who is not a member gets 404 for the project and everything in it (existence is not++ * revealed). A sandbox token (claim env = "sandbox") is refused on production projects.++ */++const { ApiError } = require('../http/errors');++++const RANK = { deployer: 1, maintainer: 2, owner: 3 };++const NEED = { read: 1, deploy: 1, maintain: 2, own: 3 };++++function principalOf(viewer) {++ if (!viewer) return null;++ if (viewer.kind === 'user') return viewer.subject || null;++ if (viewer.kind === 'service') {++ if (viewer.subject && /^svc:/.test(viewer.service)) return viewer.subject;++ return viewer.service;++ }++ return null;++}++++/** The person accountable for an action: a usr_ subject when there is one. */++function actorOf(viewer) {++ return principalOf(viewer) || 'anonymous';++}++++function actorRef(viewer) {++ const p = principalOf(viewer);++ if (p && p.startsWith('usr_')) return { type: 'user', id: p };++ if (p && p.startsWith('app:')) return { type: 'app', id: p.slice(4) };++ if (p && p.startsWith('svc:')) return { type: 'service', id: p.slice(4) };++ return { type: 'service', id: 'host' };++}++++function createAccess({ store }) {++ const { db } = store;++ const memberStmt = db.prepare('SELECT role FROM host_project_members WHERE project_id = ? AND principal = ?');++++ function roleOf(project, viewer) {++ const p = principalOf(viewer);++ if (!p) return null;++ const row = memberStmt.get(project.id, p);++ return row ? row.role : null;++ }++++ /**++ * Throws unless the viewer may act on the project at `need` (read|deploy|maintain|own|staff).++ * `notFound` is the problem thrown when the viewer has no business knowing the resource exists.++ */++ function authorize(project, viewer, need, notFound = new ApiError(404, 'project.not_found', 'no such project')) {++ if (!project || project.status !== 'active') throw notFound;++ if (!viewer || viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');++ if (viewer.kind === 'service' && viewer.env === 'sandbox' && project.environment !== 'sandbox') {++ throw new ApiError(403, 'environment.sandbox_token', 'a sandbox credential cannot act on a production project');++ }++ if (need === 'staff') {++ if (viewer.staff) return 'staff';++ throw new ApiError(403, 'auth.staff_only', 'only OpenVibe staff can change quotas');++ }++ const role = roleOf(project, viewer);++ if (!role) {++ if (viewer.staff && need !== 'deploy') return 'staff';++ throw notFound;++ }++ if (RANK[role] < NEED[need]) throw new ApiError(403, 'project.role_insufficient', `this needs the ${need === 'own' ? 'owner' : need === 'maintain' ? 'maintainer' : 'deployer'} role; you are ${role}`);++ return role;++ }++++ return { authorize, roleOf };++}++++module.exports = { createAccess, principalOf, actorOf, actorRef, RANK };nnew file mode 100644@@@ -0,0 +1,260 @@++'use strict';++/**++ * Deploys: immutable artifacts, the upload/validation log, activation and rollback.++ *++ * upload entries → validate (paths, types, sizes, quotas) → objects written to the project's++ * content-addressed store → ONE transaction: deploy row + file rows + blob rows + log +++ * host.deploy.created (+ activation when asked). A refused upload is recorded as a++ * failed deploy with its log and host.deploy.failed, so the owner can see why.++ * activate the site's active_deploy_id pointer changes in one transaction together with the++ * activation record and host.deploy.activated. A request reads the pointer once and then++ * only immutable rows, so it sees the old deploy or the new one, never a mix.++ * 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.++ *++ * There is no build: Stage B serves the uploaded files as they are, and nothing is ever executed.++ */++const { newId, isId } = require('../ids');++const { ApiError } = require('../http/errors');++const { buildManifest } = require('../artifacts/validate');++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 }) {++ const { db } = store;++ const q = {++ byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),++ forSite: db.prepare("SELECT * FROM host_deploys WHERE site_id = ? AND state <> 'deleted' ORDER BY created_at DESC, rowid DESC LIMIT ?"),++ insert: db.prepare(`INSERT INTO host_deploys (id, project_id, site_id, state, source, manifest, manifest_sha256, file_count, total_bytes, new_bytes, failure_code, created_by, created_at)++ VALUES (@id, @project_id, @site_id, @state, @source, @manifest, @manifest_sha256, @file_count, @total_bytes, @new_bytes, @failure_code, @created_by, @created_at)`),++ insertFile: db.prepare('INSERT INTO host_deploy_files (deploy_id, path, sha256, size, content_type) VALUES (?, ?, ?, ?, ?)'),++ insertBlob: db.prepare('INSERT OR IGNORE INTO host_blobs (project_id, sha256, size, created_at) VALUES (?, ?, ?, ?)'),++ hasBlob: db.prepare('SELECT 1 FROM host_blobs WHERE project_id = ? AND sha256 = ?'),++ storageUsed: db.prepare('SELECT COALESCE(SUM(size), 0) AS n FROM host_blobs WHERE project_id = ?'),++ deploysSince: db.prepare('SELECT COUNT(*) AS n FROM host_deploys WHERE project_id = ? AND created_at > ?'),++ logLine: db.prepare('INSERT INTO host_deploy_logs (deploy_id, level, message, created_at) VALUES (?, ?, ?, ?)'),++ logFor: db.prepare('SELECT level, message, created_at FROM host_deploy_logs WHERE deploy_id = ? ORDER BY id'),++ files: db.prepare('SELECT path, sha256, size, content_type FROM host_deploy_files WHERE deploy_id = ? ORDER BY path'),++ setPointer: db.prepare('UPDATE host_sites SET active_deploy_id = ?, updated_at = ? WHERE id = ? AND status = \'active\' AND active_deploy_id IS ?'),++ 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 = ?"),++ dropFiles: db.prepare('DELETE FROM host_deploy_files WHERE deploy_id = ?'),++ orphans: db.prepare(`SELECT sha256 FROM host_blobs b WHERE b.project_id = ? AND NOT EXISTS (++ SELECT 1 FROM host_deploy_files f JOIN host_deploys d ON d.id = f.deploy_id++ WHERE d.project_id = b.project_id AND f.sha256 = b.sha256)`),++ dropBlob: db.prepare('DELETE FROM host_blobs WHERE project_id = ? AND sha256 = ?'),++ };++++ function get(id) {++ return isId('deploy', id) ? q.byId.get(id) || null : null;++ }++++ /** The deploy, its site and project, authorized at `need`; 404 for anyone without access. */++ function load(viewer, id, need) {++ const deploy = get(id);++ const notFound = new ApiError(404, 'deploy.not_found', 'no such deploy');++ if (!deploy || deploy.state === 'deleted') throw notFound;++ const site = sites.get(deploy.site_id);++ if (!site || site.status !== 'active') throw notFound;++ const project = projects.get(deploy.project_id);++ access.authorize(project, viewer, need, notFound);++ return { deploy, site, project };++ }++++ /** Limits for an upload into this project (the archive reader and the validator share them). */++ function limitsFor(project) {++ const quota = projects.quotaOf(project);++ return { maxFiles: quota.maxFiles, maxFileBytes: quota.maxFileBytes, maxTotalBytes: quota.storageBytes, quota };++ }++++ /** 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');++ const limits = limitsFor(project);++ const recent = q.deploysSince.get(project.id, store.now() - DAY).n;++ if (recent >= limits.quota.deploysPerDay) {++ throw new ApiError(429, 'quota.deploys_per_day', `this project has made ${recent} deploys in the last 24 hours; the limit is ${limits.quota.deploysPerDay}`);++ }++ return { site, project, limits };++ }++++ function writeLog(deployId, lines, now) {++ for (const l of lines) q.logLine.run(deployId, l.level, String(l.message).slice(0, 2000), now);++ }++++ /** A refused upload becomes a failed deploy (with its log) and host.deploy.failed. */++ function recordFailure(viewer, { site, project }, { source, code, problems = [], notes = [], traceparent }) {++ const id = newId('deploy', store.now());++ const now = store.now();++ const lines = [++ ...notes.map((m) => ({ level: 'info', message: m })),++ ...problems.map((p) => ({ level: 'error', message: `${p.code}: ${p.message}` })),++ { level: 'error', message: `deploy refused (${code}); nothing was stored and the active deploy is unchanged` },++ ];++ store.tx(() => {++ q.insert.run({ id, project_id: project.id, site_id: site.id, state: 'failed', source, manifest: null, manifest_sha256: null, file_count: 0, total_bytes: 0, new_bytes: 0, failure_code: code, created_by: principalOf(viewer), created_at: now });++ writeLog(id, lines, now);++ outbox.emit({++ event_type: 'host.deploy.failed', actor: actorRef(viewer), visibility: 'internal', priority: 'low',++ subject: { type: 'deploy', id },++ payload: { project_id: project.id, site_id: site.id, site: site.name, code, problems: problems.slice(0, 10).map((p) => p.code) },++ }, { traceparent });++ });++ outbox.kick();++ return { deploy: get(id), log: q.logFor.all(id) };++ }++++ /**++ * 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.++ */++ function create(viewer, ctx, entries, { source, activate = false, notes = [], traceparent } = {}) {++ const { site, project, limits } = ctx;++ const fail = (status, code, problems) => {++ const r = recordFailure(viewer, ctx, { source, 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}`) });++ };++++ const built = buildManifest(entries, limits);++ if (!built.ok) fail(/^quota\.|deploy\.too_large/.test(built.code) ? 413 : 422, built.code, built.problems);++++ // Storage quota: only bytes this project does not store yet count.++ const fresh = new Map();++ for (const f of built.files) if (!q.hasBlob.get(project.id, f.sha256) && !fresh.has(f.sha256)) fresh.set(f.sha256, f);++ const newBytes = [...fresh.values()].reduce((n, f) => n + f.size, 0);++ const used = q.storageUsed.get(project.id).n;++ if (used + newBytes > limits.quota.storageBytes) {++ fail(413, 'quota.storage', [{ code: 'quota.storage', message: `this deploy adds ${fmtBytes(newBytes)}; the project stores ${fmtBytes(used)} of ${fmtBytes(limits.quota.storageBytes)}` }]);++ }++++ // Objects first (idempotent, content-addressed); rows only after they are durable.++ for (const f of fresh.values()) blobs.put(project.id, f.sha256, f.data);++++ const id = newId('deploy', store.now());++ const now = store.now();++ const who = principalOf(viewer);++ const lines = [++ ...notes.map((m) => ({ level: 'info', message: m })),++ { level: 'info', message: `validated ${built.manifest.file_count} files, ${fmtBytes(built.manifest.total_bytes)} (paths, file types, sizes)` },++ ...built.warnings.map((m) => ({ level: 'warn', message: m })),++ { level: 'info', message: `stored ${fresh.size} new objects (${fmtBytes(newBytes)}); ${built.files.length - fresh.size} already stored for this project` },++ { level: 'info', message: `manifest sha256 ${built.manifestSha256}` },++ { level: 'info', message: 'no build step: Stage B serves the uploaded files as they are; nothing was executed' },++ ];++ let activated = null;++ store.tx(() => {++ 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 });++ for (const f of built.files) q.insertFile.run(id, f.path, f.sha256, f.size, f.content_type);++ for (const f of fresh.values()) q.insertBlob.run(project.id, f.sha256, f.size, now);++ 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 },++ }, { traceparent });++ if (activate) {++ activated = switchPointer(viewer, site, get(id), 'activate', { traceparent });++ lines.push({ level: 'info', message: 'activated: the site now serves this deploy' });++ } else {++ lines.push({ level: 'info', message: 'ready; not active until you activate it' });++ }++ writeLog(id, lines, now);++ });++ outbox.kick();++ return { deploy: get(id), log: q.logFor.all(id), activated };++ }++++ /** Inside a transaction. Compare-and-set on the pointer read in the same transaction. */++ function switchPointer(viewer, site, deploy, kind, { expectedActive, traceparent } = {}) {++ const current = db.prepare('SELECT active_deploy_id, status FROM host_sites WHERE id = ?').get(site.id);++ if (!current || current.status !== 'active') throw new ApiError(404, 'site.not_found', 'no such site');++ if (deploy.state !== 'ready' || deploy.site_id !== site.id) throw new ApiError(409, 'deploy.not_ready', 'only a ready deploy of this site can be activated');++ if (expectedActive !== undefined && (expectedActive || null) !== current.active_deploy_id) {++ throw new ApiError(409, 'site.active_changed', 'the active deploy changed since you looked', { active_deploy_id: current.active_deploy_id });++ }++ if (current.active_deploy_id === deploy.id) return { changed: false, deploy_id: deploy.id, previous_deploy_id: deploy.id };++ const now = store.now();++ const r = 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');++ q.activation.run(site.id, deploy.id, current.active_deploy_id, kind, principalOf(viewer) || 'host', now);++ outbox.emit({++ event_type: 'host.deploy.activated', actor: actorRef(viewer), visibility: 'internal', priority: 'low',++ subject: { type: 'deploy', id: deploy.id },++ payload: { project_id: site.project_id, site_id: site.id, site: site.name, deploy_id: deploy.id, previous_deploy_id: current.active_deploy_id, rollback: kind === 'rollback' },++ }, { traceparent });++ return { changed: true, deploy_id: deploy.id, previous_deploy_id: current.active_deploy_id };++ }++++ function activate(viewer, id, { expectedActive, traceparent } = {}) {++ const { deploy, site } = load(viewer, id, 'deploy');++ const out = store.tx(() => switchPointer(viewer, site, deploy, 'activate', { expectedActive, traceparent }));++ outbox.kick();++ return out;++ }++++ /** 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');++ 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;++ if (deployId) {++ target = get(deployId);++ if (!target || target.site_id !== site.id || target.state === 'deleted') throw new ApiError(404, 'deploy.not_found', 'no such deploy on this site');++ } else {++ for (const a of q.activations.all(site.id, 200)) {++ for (const cand of [a.previous_deploy_id, a.deploy_id]) {++ if (!cand || cand === current) continue;++ const d = get(cand);++ if (d && d.state === 'ready' && d.site_id === site.id) { target = d; break; }++ }++ if (target) break;++ }++ if (!target) throw new ApiError(409, 'deploy.no_previous', 'there is no previous deploy to roll back to');++ }++ return switchPointer(viewer, site, target, 'rollback', { expectedActive, traceparent });++ });++ outbox.kick();++ return out;++ }++++ /** Inside a transaction: drop blob rows no deploy of the project references. -> freed shas */++ function collectGarbage(projectId) {++ const freed = q.orphans.all(projectId).map((r) => r.sha256);++ for (const sha of freed) q.dropBlob.run(projectId, sha);++ return freed;++ }++++ function unlinkBlobs(projectId, shas) {++ for (const sha of shas) {++ try { blobs.remove(projectId, sha); } catch (err) { log.warn('[Host] could not remove object:', err.message); }++ }++ }++++ function remove(viewer, id) {++ const { deploy, site, project } = load(viewer, id, 'maintain');++ let freed = [];++ store.tx(() => {++ const active = db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id;++ if (active === deploy.id) throw new ApiError(409, 'deploy.active', 'this deploy is being served: activate another one first');++ q.markDeleted.run(store.now(), deploy.id);++ q.dropFiles.run(deploy.id);++ freed = collectGarbage(project.id);++ });++ unlinkBlobs(project.id, freed);++ return { deleted: true, objects_removed: freed.length };++ }++++ return {++ get, load, precheck, create, recordFailure, activate, rollback, remove, collectGarbage, unlinkBlobs, limitsFor,++ list: (siteId, limit = 100) => q.forSite.all(siteId, Math.min(Math.max(limit, 1), 200)),++ logOf: (id) => q.logFor.all(id),++ filesOf: (id) => q.files.all(id),++ activationsOf: (siteId, limit = 50) => q.activations.all(siteId, limit),++ };++}++++module.exports = { createDeploys };nnew file mode 100644@@@ -0,0 +1,222 @@++'use strict';++/**++ * Domains: every site answers at its default <name>.<sitesDomain>; a custom domain answers only++ * once its owner has proven control with a DNS TXT record:++ *++ * _openvibe-host.<hostname> TXT "openvibe-host-verification=<token>"++ *++ * The resolver is injectable (tests use a table; production uses node:dns with a timeout). A++ * verified hostname belongs to exactly one site (unique index), first-party domains and the sites++ * domain itself can never be claimed, and sandbox projects cannot add custom domains.++ *++ * Verified custom domains are re-checked daily: when the TXT record has been gone for++ * HOST_DOMAIN_LAPSE_DAYS the domain lapses and stops being served (whoever controls the name now++ * may claim it). Pending domains that never verify fail after HOST_DOMAIN_PENDING_DAYS.++ *++ * TLS for custom domains is an operator step (ovhost nginx tenants + certbot); no certificate or++ * key ever passes through this API.++ */++const crypto = require('crypto');++const dns = require('dns');++const contracts = require('openvibe-contracts');++const { newId, isId } = require('../ids');++const { ApiError } = require('../http/errors');++const { principalOf, actorRef } = require('./access');++++const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';++const HOSTNAME_RE = new RegExp(`^(?:${LABEL}\\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$`);++const CHALLENGE_PREFIX = '_openvibe-host';++const TXT_PREFIX = 'openvibe-host-verification=';++const DAY = 24 * 3600 * 1000;++const MIN_RECHECK_MS = 5000;++++function isHostname(h) {++ return typeof h === 'string' && h.length <= 253 && HOSTNAME_RE.test(h);++}++++/** First-party domains from the released service manifests, plus configured extras. */++function firstPartyDomains(config) {++ const out = new Set(['openvibe.network', 'openvibe.live', 'openvibe.media', 'openvibe.community', 'openvibe.tools', config.sitesDomain, config.dashboardHost]);++ try {++ const m = contracts.services.manifests;++ for (const s of Array.isArray(m) ? m : Object.values(m || {})) for (const d of s.domains || []) out.add(String(d).toLowerCase());++ } catch { /* the defaults above still apply */ }++ for (const d of config.domains.extraReserved) out.add(d);++ return [...out].filter(Boolean);++}++++function defaultResolver(servers) {++ const r = new dns.promises.Resolver({ timeout: 5000, tries: 2 });++ if (servers && servers.length) r.setServers(servers);++ return { resolveTxt: (name) => r.resolveTxt(name) };++}++++function createDomains({ store, config, access, projects, sites, outbox, resolver = null, log = console }) {++ const { db } = store;++ const dnsResolver = resolver || defaultResolver(config.domains.dnsServers);++ const reserved = firstPartyDomains(config);++ const q = {++ byId: db.prepare('SELECT * FROM host_domains WHERE id = ?'),++ forSite: db.prepare("SELECT * FROM host_domains WHERE site_id = ? ORDER BY kind DESC, created_at, rowid"),++ verifiedByHost: db.prepare("SELECT * FROM host_domains WHERE hostname = ? AND status = 'verified'"),++ onSite: db.prepare('SELECT * FROM host_domains WHERE site_id = ? AND hostname = ?'),++ insert: db.prepare(`INSERT INTO host_domains (id, project_id, site_id, hostname, kind, status, token, created_by, created_at)++ VALUES (?, ?, ?, ?, 'custom', 'pending', ?, ?, ?)`),++ checked: db.prepare('UPDATE host_domains SET last_checked_at = ?, last_error = ?, check_count = check_count + 1 WHERE id = ?'),++ verified: db.prepare("UPDATE host_domains SET status = 'verified', verified_at = ?, last_checked_at = ?, last_error = NULL, check_count = check_count + 1, record_missing_since = NULL WHERE id = ? AND status IN ('pending','failed','lapsed')"),++ setStatus: db.prepare('UPDATE host_domains SET status = ?, last_error = ? WHERE id = ?'),++ missing: db.prepare('UPDATE host_domains SET record_missing_since = COALESCE(record_missing_since, ?), last_checked_at = ?, last_error = ?, check_count = check_count + 1 WHERE id = ?'),++ present: db.prepare('UPDATE host_domains SET record_missing_since = NULL, last_checked_at = ?, last_error = NULL, check_count = check_count + 1 WHERE id = ?'),++ remove: db.prepare('DELETE FROM host_domains WHERE id = ?'),++ duePending: db.prepare("SELECT * FROM host_domains WHERE kind = 'custom' AND status = 'pending' AND COALESCE(last_checked_at, 0) < ? ORDER BY COALESCE(last_checked_at, 0) LIMIT ?"),++ dueVerified: db.prepare("SELECT * FROM host_domains WHERE kind = 'custom' AND status = 'verified' AND COALESCE(last_checked_at, 0) < ? ORDER BY COALESCE(last_checked_at, 0) LIMIT ?"),++ };++++ const challengeName = (hostname) => `${CHALLENGE_PREFIX}.${hostname}`;++++ function load(viewer, id, need) {++ const domain = isId('domain', id) ? q.byId.get(id) : null;++ const notFound = new ApiError(404, 'domain.not_found', 'no such domain');++ if (!domain) throw notFound;++ const site = sites.get(domain.site_id);++ if (!site || site.status !== 'active') throw notFound;++ access.authorize(projects.get(domain.project_id), viewer, need, notFound);++ return { domain, site };++ }++++ function checkHostname(raw) {++ const hostname = String(raw || '').trim().toLowerCase().replace(/\.$/, '');++ if (!isHostname(hostname)) throw new ApiError(422, 'domain.invalid_hostname', 'a custom domain is a fully qualified host name such as www.example.org');++ // Anything that reads as an OpenVibe property (openvibe.<tld>, any subdomain) is refused too.++ if (/(^|\.)openvibe\.[a-z]+$/.test(hostname)) throw new ApiError(422, 'domain.reserved', `${hostname} is an OpenVibe domain and cannot be used as a custom domain`);++ for (const r of reserved) {++ if (hostname === r || hostname.endsWith(`.${r}`)) throw new ApiError(422, 'domain.reserved', `${hostname} is an OpenVibe domain and cannot be used as a custom domain`);++ }++ return hostname;++ }++++ /** What the owner must publish: shown by the API and the dashboard. */++ function instructions(domain, site) {++ if (domain.kind === 'default') return null;++ const target = config.domains.cnameTarget || sites.defaultHostname(site.name);++ const routing = [{ type: 'CNAME', name: domain.hostname, value: target, note: 'for a subdomain such as www.example.org' }];++ if (config.domains.originIpv4) routing.push({ type: 'A', name: domain.hostname, value: config.domains.originIpv4, note: 'for an apex domain (or use your DNS provider\'s ALIAS/ANAME to the CNAME target)' });++ if (config.domains.originIpv6) routing.push({ type: 'AAAA', name: domain.hostname, value: config.domains.originIpv6, note: 'for an apex domain' });++ return {++ verification: { type: 'TXT', name: challengeName(domain.hostname), value: `${TXT_PREFIX}${domain.token}`, note: 'keep this record: Host re-checks it daily' },++ routing,++ tls: 'after verification an operator issues the certificate (Let\'s Encrypt); HTTPS starts working once it is installed',++ };++ }++++ function add(viewer, siteId, input = {}) {++ const { site, project } = sites.load(viewer, siteId, 'maintain');++ const hostname = checkHostname(input.hostname);++ if (project.environment === 'sandbox') throw new ApiError(403, 'domain.sandbox', 'sandbox projects are served on their default domain only');++ const quota = projects.quotaOf(project);++ if (projects.usageOf(project).customDomains >= quota.customDomains) throw new ApiError(429, 'quota.custom_domains', `this project may have ${quota.customDomains} custom domains`);++ if (q.onSite.get(site.id, hostname)) throw new ApiError(409, 'domain.exists', `${hostname} is already on this site`);++ if (q.verifiedByHost.get(hostname)) throw new ApiError(409, 'domain.taken', `${hostname} is already verified for another site`);++ const id = newId('domain', store.now());++ const token = crypto.randomBytes(20).toString('hex');++ q.insert.run(id, project.id, site.id, hostname, token, principalOf(viewer), store.now());++ const domain = q.byId.get(id);++ return { domain, site };++ }++++ async function lookup(hostname) {++ try {++ const records = await dnsResolver.resolveTxt(challengeName(hostname));++ return { ok: true, values: (records || []).map((chunks) => (Array.isArray(chunks) ? chunks.join('') : String(chunks))) };++ } catch (err) {++ const code = err && err.code;++ if (code === 'ENOTFOUND' || code === 'ENODATA' || code === 'NXDOMAIN') return { ok: true, values: [] };++ return { ok: false, error: `DNS lookup failed (${code || 'error'})` };++ }++ }++++ /** Check the TXT record now. -> the domain row after the check. */++ async function verifyDomain(domain, viewer = null, { traceparent } = {}) {++ if (domain.kind !== 'custom') return domain;++ if (domain.status === 'verified') return domain;++ const now = store.now();++ if (domain.last_checked_at && now - domain.last_checked_at < MIN_RECHECK_MS) return domain;++ const r = await lookup(domain.hostname);++ const expected = `${TXT_PREFIX}${domain.token}`;++ if (!r.ok) { q.checked.run(store.now(), r.error, domain.id); return q.byId.get(domain.id); }++ if (!r.values.includes(expected)) {++ q.checked.run(store.now(), r.values.length ? `${challengeName(domain.hostname)} has TXT records, but not the expected value` : `no TXT record at ${challengeName(domain.hostname)} yet`, domain.id);++ return q.byId.get(domain.id);++ }++ try {++ store.tx(() => {++ if (q.verifiedByHost.get(domain.hostname)) throw new ApiError(409, 'domain.taken', `${domain.hostname} was verified for another site first`);++ const site = sites.get(domain.site_id);++ if (!site || site.status !== 'active') throw new ApiError(404, 'domain.not_found', 'no such domain');++ if (q.verified.run(store.now(), store.now(), domain.id).changes !== 1) return;++ outbox.emit({++ event_type: 'host.domain.verified', actor: viewer ? actorRef(viewer) : { type: 'service', id: 'host' }, visibility: 'internal', priority: 'low',++ subject: { type: 'domain', id: domain.id },++ payload: { project_id: domain.project_id, site_id: domain.site_id, site: site.name, hostname: domain.hostname },++ }, { traceparent });++ });++ } catch (err) {++ if (err instanceof ApiError && err.code === 'domain.taken') { q.setStatus.run('failed', err.message, domain.id); return q.byId.get(domain.id); }++ throw err;++ }++ outbox.kick();++ return q.byId.get(domain.id);++ }++++ async function verify(viewer, id, opts = {}) {++ const { domain } = load(viewer, id, 'maintain');++ return verifyDomain(domain, viewer, opts);++ }++++ function remove(viewer, id) {++ const { domain } = load(viewer, id, 'maintain');++ if (domain.kind === 'default') throw new ApiError(409, 'domain.default', 'the default domain goes away only with the site');++ q.remove.run(domain.id);++ return { deleted: true };++ }++++ /** Worker pass: pending domains (every interval), verified domains (daily). */++ async function recheck({ limit = 50 } = {}) {++ const now = store.now();++ const summary = { verified: 0, failed: 0, lapsed: 0, checked: 0 };++ for (const d of q.duePending.all(now - config.domains.recheckIntervalMs + 1000, limit)) {++ if (now - d.created_at > config.domains.pendingDays * DAY) {++ q.setStatus.run('failed', `not verified within ${config.domains.pendingDays} days`, d.id);++ summary.failed++;++ continue;++ }++ const after = await verifyDomain(d);++ summary.checked++;++ if (after.status === 'verified') summary.verified++;++ }++ for (const d of q.dueVerified.all(now - DAY, limit)) {++ const r = await lookup(d.hostname);++ summary.checked++;++ if (!r.ok) { q.checked.run(store.now(), r.error, d.id); continue; }++ if (r.values.includes(`${TXT_PREFIX}${d.token}`)) { q.present.run(store.now(), d.id); continue; }++ q.missing.run(store.now(), store.now(), `the TXT record at ${challengeName(d.hostname)} is gone`, d.id);++ const row = q.byId.get(d.id);++ if (row.record_missing_since && store.now() - row.record_missing_since > config.domains.lapseDays * DAY) {++ q.setStatus.run('lapsed', `the TXT record has been gone for more than ${config.domains.lapseDays} days; the domain is no longer served`, d.id);++ summary.lapsed++;++ log.warn(`[Host] custom domain ${d.hostname} lapsed (TXT record gone)`);++ }++ }++ return summary;++ }++++ return {++ load, add, verify, verifyDomain, remove, recheck, instructions, checkHostname,++ listForSite: (siteId) => q.forSite.all(siteId),++ get: (id) => (isId('domain', id) ? q.byId.get(id) || null : null),++ verifiedCustom: () => db.prepare("SELECT hostname, site_id FROM host_domains WHERE kind = 'custom' AND status = 'verified' ORDER BY hostname").all(),++ challengeName, TXT_PREFIX,++ };++}++++module.exports = { createDomains, isHostname, firstPartyDomains, CHALLENGE_PREFIX, TXT_PREFIX };nnew file mode 100644@@@ -0,0 +1,165 @@++'use strict';++/**++ * Projects, members and quotas.++ *++ * ADR-014: projects are owned by OpenVibe.Network. Network has no projects yet, so a project is++ * created here by its owner (a usr_ subject) and gets a prj_ id; `network_project_id` records the++ * Network project once it exists, and every Host resource is keyed by the project id so one++ * revocation reaches all of it. Quotas are enforced here, by the owning service.++ */++const { ids } = require('openvibe-contracts');++const { newId, isId } = require('../ids');++const { ApiError } = require('../http/errors');++const { principalOf } = require('./access');++++const NAME_MAX = 80;++const NETWORK_PROJECT_RE = /^[A-Za-z0-9_:-]{3,80}$/;++const PRINCIPAL_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|app:app_[0-9A-HJKMNP-TV-Z]{26}|svc:[a-z][a-z0-9-]{1,39})$/;++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 }) {++ const { db } = store;++ const q = {++ byId: db.prepare('SELECT * FROM host_projects WHERE id = ?'),++ forPrincipal: db.prepare(`SELECT p.*, m.role FROM host_projects p JOIN host_project_members m ON m.project_id = p.id++ WHERE m.principal = ? AND p.status = 'active' ORDER BY p.created_at DESC, p.rowid DESC LIMIT 200`),++ all: db.prepare("SELECT p.*, NULL AS role FROM host_projects p WHERE p.status = 'active' ORDER BY p.created_at DESC, p.rowid DESC LIMIT 200"),++ ownedCount: db.prepare("SELECT COUNT(*) AS n FROM host_projects WHERE owner_subject = ? AND status = 'active'"),++ insert: db.prepare(`INSERT INTO host_projects (id, network_project_id, owner_subject, name, environment, status, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`),++ addMember: db.prepare(`INSERT INTO host_project_members (project_id, principal, role, added_by, created_at) VALUES (?, ?, ?, ?, ?)++ ON CONFLICT (project_id, principal) DO UPDATE SET role = excluded.role`),++ removeMember: db.prepare('DELETE FROM host_project_members WHERE project_id = ? AND principal = ?'),++ members: db.prepare('SELECT principal, role, added_by, created_at FROM host_project_members WHERE project_id = ? ORDER BY created_at'),++ quotaRow: db.prepare('SELECT * FROM host_quotas WHERE project_id = ?'),++ storageUsed: db.prepare('SELECT COALESCE(SUM(size), 0) AS n, COUNT(*) AS c FROM host_blobs WHERE project_id = ?'),++ deploysSince: db.prepare('SELECT COUNT(*) AS n FROM host_deploys WHERE project_id = ? AND created_at > ?'),++ siteCount: db.prepare("SELECT COUNT(*) AS n FROM host_sites WHERE project_id = ? AND status = 'active'"),++ customDomainCount: db.prepare("SELECT COUNT(*) AS n FROM host_domains WHERE project_id = ? AND kind = 'custom' AND status IN ('pending','verified')"),++ markDeleted: db.prepare("UPDATE host_projects SET status = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?"),++ };++++ function get(id) {++ return isId('project', id) ? q.byId.get(id) || null : null;++ }++++ /** The effective quota: the project's override where set, else the environment's default. */++ function quotaOf(project) {++ const defaults = config.quotas[project.environment] || config.quotas.production;++ const row = q.quotaRow.get(project.id) || {};++ const out = {};++ for (const [col, key] of Object.entries(QUOTA_FIELDS)) out[key] = row[col] != null ? row[col] : defaults[key];++ return out;++ }++++ function usageOf(project) {++ const s = q.storageUsed.get(project.id);++ return {++ storageBytes: s.n,++ objects: s.c,++ deploysLast24h: q.deploysSince.get(project.id, store.now() - DAY).n,++ sites: q.siteCount.get(project.id).n,++ customDomains: q.customDomainCount.get(project.id).n,++ };++ }++++ function create(viewer, input = {}) {++ const owner = principalOf(viewer);++ if (!owner || !ids.isSubjectId('user', owner)) {++ throw new ApiError(403, 'project.owner_must_be_user', 'a project is owned by a person (usr_…): an app or service must name the owner with X-OV-Subject');++ }++ const name = String(input.name || '').trim();++ if (!name || name.length > NAME_MAX) throw new ApiError(422, 'project.invalid_name', `a project needs a name of 1–${NAME_MAX} characters`);++ const environment = input.environment == null ? 'production' : String(input.environment);++ if (!['production', 'sandbox'].includes(environment)) throw new ApiError(422, 'project.invalid_environment', 'environment is production or sandbox');++ if (viewer.kind === 'service' && viewer.env === 'sandbox' && environment !== 'sandbox') {++ throw new ApiError(403, 'environment.sandbox_token', 'a sandbox credential can only create sandbox projects');++ }++ let networkProjectId = null;++ if (input.network_project_id != null && input.network_project_id !== '') {++ networkProjectId = String(input.network_project_id);++ if (!NETWORK_PROJECT_RE.test(networkProjectId)) throw new ApiError(422, 'project.invalid_network_project_id', 'network_project_id is not a project id');++ }++ if (q.ownedCount.get(owner).n >= 50) throw new ApiError(429, 'quota.projects', 'you already own 50 projects');++ const id = newId('project', store.now());++ const now = store.now();++ try {++ store.tx(() => {++ q.insert.run(id, networkProjectId, owner, name, environment, now, now);++ q.addMember.run(id, owner, 'owner', owner, now);++ });++ } catch (err) {++ if (/UNIQUE/.test(err.message)) throw new ApiError(409, 'project.network_project_taken', 'that Network project already has a Host project');++ throw err;++ }++ return get(id);++ }++++ function listFor(viewer) {++ if (viewer.kind === 'user' && viewer.staff && viewer.allProjects) return q.all.all();++ const p = principalOf(viewer);++ return p ? q.forPrincipal.all(p) : [];++ }++++ function members(project) {++ return q.members.all(project.id);++ }++++ function setMember(viewer, project, principal, role) {++ access.authorize(project, viewer, 'own');++ if (!PRINCIPAL_RE.test(String(principal))) throw new ApiError(422, 'member.invalid_principal', 'a member is a usr_ subject, an app:app_ principal or a svc: principal');++ if (!['maintainer', 'deployer', 'owner'].includes(role)) throw new ApiError(422, 'member.invalid_role', 'role is owner, maintainer or deployer');++ if (role === 'owner' && !principal.startsWith('usr_')) throw new ApiError(422, 'member.invalid_role', 'only a person can be an owner');++ if (principal === project.owner_subject && role !== 'owner') throw new ApiError(409, 'member.owner_fixed', 'the project owner stays owner');++ q.addMember.run(project.id, principal, role, principalOf(viewer), store.now());++ return members(project);++ }++++ function removeMember(viewer, project, principal) {++ access.authorize(project, viewer, 'own');++ if (principal === project.owner_subject) throw new ApiError(409, 'member.owner_fixed', 'the project owner cannot be removed');++ q.removeMember.run(project.id, principal);++ return members(project);++ }++++ function setQuota(viewer, project, input = {}) {++ access.authorize(project, viewer, 'staff');++ const row = q.quotaRow.get(project.id) || {};++ const next = {};++ for (const col of Object.keys(QUOTA_FIELDS)) {++ if (!(col in input)) { next[col] = row[col] != null ? row[col] : null; continue; }++ const v = input[col];++ if (v === null) { next[col] = null; continue; }++ if (!Number.isSafeInteger(v) || v < 0) throw new ApiError(422, 'quota.invalid', `${col} must be a non-negative integer or null (the default)`);++ next[col] = v;++ }++ db.prepare(`INSERT INTO host_quotas (project_id, storage_bytes, deploys_per_day, max_files, max_file_bytes, sites, custom_domains, updated_by, updated_at)++ VALUES (@project_id, @storage_bytes, @deploys_per_day, @max_files, @max_file_bytes, @sites, @custom_domains, @updated_by, @updated_at)++ ON CONFLICT (project_id) DO UPDATE SET storage_bytes = excluded.storage_bytes, deploys_per_day = excluded.deploys_per_day,++ max_files = excluded.max_files, max_file_bytes = excluded.max_file_bytes, sites = excluded.sites,++ custom_domains = excluded.custom_domains, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)++ .run({ project_id: project.id, ...next, updated_by: principalOf(viewer), updated_at: store.now() });++ return quotaOf(project);++ }++++ /** 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');++ const now = store.now();++ store.tx(() => {++ for (const s of sites.listForProject(project.id)) sites.markDeleted(s, now);++ db.prepare("UPDATE host_deploys SET state = 'deleted', deleted_at = ? WHERE project_id = ? AND state <> 'deleted'").run(now, project.id);++ db.prepare("DELETE FROM host_deploy_files WHERE deploy_id IN (SELECT id FROM host_deploys WHERE project_id = ?)").run(project.id);++ db.prepare('DELETE FROM host_blobs WHERE project_id = ?').run(project.id);++ db.prepare('DELETE FROM host_domains WHERE project_id = ?').run(project.id);++ q.markDeleted.run(now, now, project.id);++ });++ try { blobs.removeProject(project.id); } catch (err) { log.warn('[Host] could not remove project objects:', err.message); }++ return { deleted: true };++ }++++ return { get, create, listFor, members, setMember, removeMember, quotaOf, usageOf, setQuota, remove, QUOTA_FIELDS };++}++++module.exports = { createProjects };nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++/**++ * Sites: a name (served at <name>.<sitesDomain>) and the pointer to the deploy it serves.++ *++ * Names are one DNS label: 3–40 characters of a–z, 0–9 and single hyphens. Names that could be++ * mistaken for the platform (www, api, admin, openvibe, …) are reserved. A deleted site's name is++ * held for 30 days so nobody else can pick it up and serve content on links that still point there.++ */++const { newId, isId } = require('../ids');++const { ApiError } = require('../http/errors');++const { principalOf } = require('./access');++++const NAME_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,39}$/;++const RESERVED = new Set([++ 'www', 'api', 'app', 'apps', 'admin', 'administrator', 'root', 'dashboard', 'console', 'host', 'hosting', 'static', 'assets', 'cdn',++ 'edge', 'origin', 'mail', 'email', 'smtp', 'imap', 'pop', 'ftp', 'ssh', 'vpn', 'ns', 'ns1', 'ns2', 'dns', 'mx', 'status', 'metrics',++ 'health', 'auth', 'login', 'signin', 'sso', 'oauth', 'account', 'accounts', 'billing', 'pay', 'payments', 'wallet', 'support', 'help',++ 'docs', 'blog', 'news', 'security', 'abuse', 'postmaster', 'hostmaster', 'webmaster', 'openvibe', 'openvibers', 'network', 'live',++ 'media', 'community', 'events', 'chat', 'tools', 'codes', 'sites', 'sandbox', 'staging', 'test', 'internal', 'localhost', 'wpad',++ 'autoconfig', 'autodiscover', 'isatap', 'acme', 'well-known',++]);++const HOLD_MS = 30 * 24 * 3600 * 1000;++++function createSites({ store, config, access, projects }) {++ const { db } = store;++ const reserved = new Set([...RESERVED, ...config.sites.extraReservedNames]);++ const q = {++ byId: db.prepare('SELECT * FROM host_sites WHERE id = ?'),++ byName: db.prepare("SELECT * FROM host_sites WHERE name = ? AND status = 'active'"),++ held: db.prepare("SELECT project_id FROM host_sites WHERE name = ? AND status = 'deleted' AND deleted_at > ? ORDER BY deleted_at DESC LIMIT 1"),++ forProject: db.prepare("SELECT * FROM host_sites WHERE project_id = ? AND status = 'active' ORDER BY created_at, rowid"),++ insert: db.prepare(`INSERT INTO host_sites (id, project_id, name, active_deploy_id, status, created_by, created_at, updated_at)++ VALUES (?, ?, ?, NULL, 'active', ?, ?, ?)`),++ insertDefaultDomain: db.prepare(`INSERT INTO host_domains (id, project_id, site_id, hostname, kind, status, token, created_by, created_at, verified_at)++ VALUES (?, ?, ?, ?, 'default', 'verified', NULL, ?, ?, ?)`),++ markDeleted: db.prepare("UPDATE host_sites SET status = 'deleted', active_deploy_id = NULL, deleted_at = ?, updated_at = ? WHERE id = ?"),++ };++++ const defaultHostname = (name) => `${name}.${config.sitesDomain}`;++++ function get(id) {++ return isId('site', id) ? q.byId.get(id) || null : null;++ }++++ /** The site and its project, authorized at `need`; 404 for anyone without access. */++ function load(viewer, id, need) {++ const site = get(id);++ const notFound = new ApiError(404, 'site.not_found', 'no such site');++ if (!site || site.status !== 'active') throw notFound;++ const project = projects.get(site.project_id);++ const role = access.authorize(project, viewer, need, notFound);++ return { site, project, role };++ }++++ function checkName(name) {++ if (typeof name !== 'string' || !NAME_RE.test(name)) {++ throw new ApiError(422, 'site.invalid_name', 'a site name is 3–40 characters: a–z, 0–9 and single hyphens, starting and ending with a letter or digit');++ }++ if (reserved.has(name) || /^xn--/.test(name) || /^openvibe/.test(name)) throw new ApiError(422, 'site.name_reserved', `"${name}" is reserved`);++ }++++ function create(viewer, project, input = {}) {++ access.authorize(project, viewer, 'maintain');++ const name = String(input.name || '').trim().toLowerCase();++ checkName(name);++ const quota = projects.quotaOf(project);++ if (projects.usageOf(project).sites >= quota.sites) throw new ApiError(429, 'quota.sites', `this project may have ${quota.sites} sites`);++ if (q.byName.get(name)) throw new ApiError(409, 'site.name_taken', `${defaultHostname(name)} is taken`);++ const held = q.held.get(name, store.now() - HOLD_MS);++ if (held && held.project_id !== project.id) throw new ApiError(409, 'site.name_held', `${defaultHostname(name)} was deleted recently and is held for 30 days`);++ const id = newId('site', store.now());++ const now = store.now();++ const who = principalOf(viewer);++ try {++ store.tx(() => {++ q.insert.run(id, project.id, name, who, now, now);++ q.insertDefaultDomain.run(newId('domain', now), project.id, id, defaultHostname(name), who, now, now);++ });++ } catch (err) {++ if (/UNIQUE/.test(err.message)) throw new ApiError(409, 'site.name_taken', `${defaultHostname(name)} is taken`);++ throw err;++ }++ return get(id);++ }++++ function listForProject(projectId) {++ return q.forProject.all(projectId);++ }++++ /** Inside a transaction: the site stops serving immediately; deploys and domains go with it. */++ function markDeleted(site, now) {++ q.markDeleted.run(now, now, site.id);++ db.prepare("UPDATE host_deploys SET state = 'deleted', deleted_at = ? WHERE site_id = ? AND state <> 'deleted'").run(now, site.id);++ db.prepare('DELETE FROM host_deploy_files WHERE deploy_id IN (SELECT id FROM host_deploys WHERE site_id = ?)').run(site.id);++ db.prepare('DELETE FROM host_domains WHERE site_id = ?').run(site.id);++ }++++ function remove(viewer, id, { deploys }) {++ const { site, project } = load(viewer, id, 'maintain');++ const now = store.now();++ let freed = [];++ store.tx(() => {++ markDeleted(site, now);++ freed = deploys.collectGarbage(project.id);++ });++ deploys.unlinkBlobs(project.id, freed);++ return { deleted: true };++ }++++ return { get, load, create, listForProject, markDeleted, remove, defaultHostname, checkName, byName: (n) => q.byName.get(n) || null };++}++++module.exports = { createSites, RESERVED, NAME_RE };nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++++/**++ * Host → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).++ *++ * host.deploy.created an upload became an immutable, ready deploy++ * host.deploy.activated a site's active deploy changed (payload.rollback marks a rollback)++ * host.deploy.failed an upload was refused (payload.code, problem codes; no file contents)++ * host.domain.verified a custom domain's TXT record was found; the domain is now served++ *++ * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only++ * if its change committed. The relay publishes with Host's service token (events.event.publish,++ * audience openvibe.events) only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise++ * rows wait in event_outbox and /api/ready reports the relay as off.++ */++const { createClient } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++++const EVENT_TYPES = Object.freeze(['host.deploy.created', 'host.deploy.activated', 'host.deploy.failed', 'host.domain.verified']);++++function createHostOutbox({ db, config, fetchImpl, now, log = console }) {++ const enabled = Boolean(config.events.url && config.oauth.clientSecret);++ const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0 };++ if (fetchImpl) clientOpts.fetch = fetchImpl;++ if (enabled) {++ clientOpts.tokenProvider = createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),++ });++ } else {++ clientOpts.getToken = async () => { throw new Error('events relay disabled (Events URL or service credentials not configured)'); };++ }++ const events = createEventsClient(createClient(clientOpts), { source: 'host' });++ let lastError = null;++ const outbox = createOutbox(db, {++ events,++ intervalMs: config.events.intervalMs,++ now,++ onError: (err) => {++ const msg = err && err.message;++ if (msg !== lastError) log.warn('[Host] event publish failed (will retry):', msg);++ lastError = msg;++ },++ });++ outbox.ensureSchema();++++ /** Inside the caller's transaction. Returns the complete envelope (with its event_id). */++ function emit(envelope, { traceparent } = {}) {++ if (!EVENT_TYPES.includes(envelope.event_type)) throw new Error(`undeclared event type ${envelope.event_type}`);++ return outbox.enqueue(envelope, { traceparent });++ }++++ return {++ emit,++ outbox,++ enabled,++ start() { if (enabled) outbox.start(); },++ stop: () => outbox.stop(),++ kick() { if (enabled) outbox.kick(); },++ status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),++ };++}++++module.exports = { createHostOutbox, EVENT_TYPES };nnew file mode 100644@@@ -0,0 +1,187 @@++'use strict';++/**++ * /api/v1 — Host's API for people (a Network user JWT as a Bearer token) and principals (Network++ * client-credentials tokens for audience openvibe.host). Cookies are ignored here: tenant pages++ * on <site>.openvibe.host are same-site with openvibe.host, so cookie-authenticated API calls could++ * be forged from any tenant page.++ *++ * 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++ * 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++ * domains GET/POST /sites/:id/domains · POST /domains/:id/verify++ * DELETE /domains/:id host.domain.manage++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { run, jsonBody, privateNoStore, sendError, ApiError } = require('./errors');++const { guard } = require('../auth/viewer');++const { readUpload, UploadError } = require('./upload');++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 router = express.Router();++++ // CORS for first-party browser origins presenting a Bearer token (never credentials/cookies).++ router.use((req, res, next) => {++ const origin = req.get('origin');++ if (origin && config.apiCorsOrigins.includes(origin)) {++ res.set('Access-Control-Allow-Origin', origin);++ res.vary('Origin');++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');++ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');++ res.set('Access-Control-Max-Age', '600');++ }++ if (req.method === 'OPTIONS') return res.status(204).end();++ privateNoStore(res);++ next();++ });++ router.use(viewers.middleware('api'));++++ const siteUrl = (hostname) => {++ 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 tp = (req) => (req.ov ? req.ov.traceparent : undefined);++ const loadProject = (req, need) => {++ const project = projects.get(req.params.id);++ const role = ctx.access.authorize(project, req.viewer, need);++ return { project, role };++ };++++ // ── 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)) };++ }));++ 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');++ const p = projects.create(req.viewer, req.body || {});++ return { project: projectOut(p, 'owner') };++ }, 201));++ router.get('/projects/:id', guard('host.site.manage'), run((req) => {++ const { project, role } = loadProject(req, 'read');++ return { project: projectOut(project, role), sites: sites.listForProject(project.id).map(siteOut), members: projects.members(project).map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };++ }));++ router.delete('/projects/:id', guard('host.site.manage'), run((req) => {++ const { project } = loadProject(req, 'read');++ return projects.remove(req.viewer, project, { sites });++ }));++ router.get('/projects/:id/quota', guard('host.site.manage'), run((req) => {++ const { project } = loadProject(req, 'read');++ return { quota: out.quotaOut(projects.quotaOf(project)), usage: out.usageOut(projects.usageOf(project)) };++ }));++ router.put('/projects/:id/quota', guard('host.site.manage'), jsonBody, run((req) => {++ const project = projects.get(req.params.id);++ const q = projects.setQuota(req.viewer, project, req.body || {});++ return { quota: out.quotaOut(q), usage: out.usageOut(projects.usageOf(project)) };++ }));++ router.put('/projects/:id/members/:principal', guard('host.site.manage'), jsonBody, run((req) => {++ const { project } = loadProject(req, 'read');++ const list = projects.setMember(req.viewer, project, req.params.principal, String((req.body || {}).role || ''));++ return { members: list.map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };++ }));++ router.delete('/projects/:id/members/:principal', guard('host.site.manage'), run((req) => {++ const { project } = loadProject(req, 'read');++ const list = projects.removeMember(req.viewer, project, req.params.principal);++ return { members: list.map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };++ }));++++ // ── Sites ───────────────────────────────────────────────++ router.get('/projects/:id/sites', guard('host.site.manage'), run((req) => {++ const { project } = loadProject(req, 'read');++ return { sites: sites.listForProject(project.id).map(siteOut) };++ }));++ router.post('/projects/:id/sites', guard('host.site.manage'), jsonBody, run((req) => {++ const project = projects.get(req.params.id);++ ctx.access.authorize(project, req.viewer, 'read');++ return { site: siteOut(sites.create(req.viewer, project, req.body || {})) };++ }, 201));++ router.get('/sites/:id', guard('host.site.manage'), run((req) => {++ const { site } = sites.load(req.viewer, req.params.id, 'read');++ return { site: siteOut(site), domains: domains.listForSite(site.id).map((d) => out.domain(d, domains.instructions(d, site))) };++ }));++ router.delete('/sites/:id', guard('host.site.manage'), run((req) => sites.remove(req.viewer, req.params.id, { deploys })));++++ // ── Deploys ─────────────────────────────────────────────++ router.get('/sites/:id/deploys', guard('host.deploy.create'), run((req) => {++ const { site } = sites.load(req.viewer, req.params.id, 'deploy');++ 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 })),++ activations: deploys.activationsOf(site.id, 20).map(out.activation),++ };++ }));++++ router.post('/sites/:id/deploys', guard('host.deploy.create'), async (req, res) => {++ try {++ const pre = deploys.precheck(req.viewer, req.params.id);++ let up;++ try {++ up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, limits: pre.limits });++ } catch (err) {++ if (!(err instanceof UploadError)) throw err;++ if (err.closeConnection) res.set('Connection', 'close');++ const multipart = String(req.headers['content-type'] || '').startsWith('multipart/');++ const r = 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 = deploys.create(req.viewer, pre, up.entries, { source: up.source, activate, notes: up.notes, traceparent: tp(req) });++ 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.++ if (!req.complete) res.set('Connection', 'close');++ if (!res.headersSent) sendError(res, req, err);++ }++ });++++ router.get('/deploys/:id', guard('host.deploy.create'), run((req) => {++ const { deploy, site } = deploys.load(req.viewer, req.params.id, 'deploy');++ 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');++ 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) => {++ const body = req.body || {};++ const r = deploys.activate(req.viewer, req.params.id, { expectedActive: 'expected_active' in body ? body.expected_active : undefined, traceparent: tp(req) });++ return { active_deploy_id: r.deploy_id, previous_deploy_id: r.previous_deploy_id, changed: r.changed };++ }));++ router.post('/sites/:id/rollback', guard('host.deploy.create'), jsonBody, run((req) => {++ const body = req.body || {};++ const r = deploys.rollback(req.viewer, req.params.id, { deployId: body.deploy_id || null, expectedActive: 'expected_active' in body ? body.expected_active : undefined, traceparent: tp(req) });++ return { active_deploy_id: r.deploy_id, previous_deploy_id: r.previous_deploy_id, changed: r.changed };++ }));++ router.delete('/deploys/:id', guard('host.site.manage'), run((req) => deploys.remove(req.viewer, req.params.id)));++++ // ── Domains ─────────────────────────────────────────────++ router.get('/sites/:id/domains', guard('host.domain.manage'), run((req) => {++ const { site } = sites.load(req.viewer, req.params.id, 'read');++ return { domains: domains.listForSite(site.id).map((d) => out.domain(d, domains.instructions(d, site))) };++ }));++ router.post('/sites/:id/domains', guard('host.domain.manage'), jsonBody, run((req) => {++ const { domain, site } = domains.add(req.viewer, req.params.id, req.body || {});++ return { domain: out.domain(domain, domains.instructions(domain, site)) };++ }, 201));++ router.post('/domains/:id/verify', guard('host.domain.manage'), run(async (req) => {++ const d = await domains.verify(req.viewer, req.params.id, { traceparent: tp(req) });++ const site = sites.get(d.site_id);++ return { domain: out.domain(d, domains.instructions(d, site)) };++ }));++ router.delete('/domains/:id', guard('host.domain.manage'), run((req) => domains.remove(req.viewer, req.params.id)));++++ 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;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,192 @@++'use strict';++/**++ * The dashboard: server-rendered pages for projects, sites, deploys (with rollback), domains++ * (with DNS instructions), quotas and upload logs. Every action is a POST form that must come from++ * the dashboard's own origin and carry the form token (see auth/forms.js for why SameSite is not++ * enough on openvibe.host). Every response is private and never cached by a shared cache.++ */++const express = require('express');++const { renderPage } = require('../render/layout');++const pages = require('../render/pages');++const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');++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 router = express.Router();++ const urlencoded = express.urlencoded({ extended: false, limit: '16kb' });++++ const siteUrl = (s) => {++ const u = new URL(config.baseUrl);++ return `${u.protocol}//${sites.defaultHostname(s.name)}${u.port ? `:${u.port}` : ''}`;++ };++++ 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 fail(req, res, err) {++ if (err instanceof ApiError || (err && Number.isInteger(err.status) && err.code)) {++ if (err.status === 401) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ return page(req, res, err.status, 'Error', pages.errorPage({ status: err.status, message: err.message }));++ }++ log.error('[Host dashboard]', err && err.stack ? err.stack : err);++ return page(req, res, 500, 'Error', pages.errorPage({ status: 500, message: 'Something went wrong on our side. Try again in a moment.' }));++ }++++ const view = (fn) => async (req, res) => {++ try { await fn(req, res); } catch (err) { fail(req, res, err); }++ };++++ /** POST guard: signed in, same origin, valid form token (urlencoded forms). */++ const action = (fn) => [urlencoded, async (req, res) => {++ try {++ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');++ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) throw new ApiError(403, 'form.invalid', 'this form expired or did not come from this dashboard; reload the page and try again');++ const to = await fn(req, res);++ if (to && !res.headersSent) res.redirect(303, to);++ } catch (err) { fail(req, res, err); }++ }];++++ const notice = (req) => {++ const n = req.query.notice;++ return typeof n === 'string' && n.length < 300 ? { kind: req.query.kind === 'error' ? 'error' : 'info', text: n } : 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());++ page(req, res, 200, 'Projects', pages.home({ projects: projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer) }), notice(req));++ }));++++ router.get('/projects/:id', view((req, res) => {++ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');++ const project = projects.get(req.params.id);++ const role = access.authorize(project, req.viewer, 'read');++ 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));++ }));++++ router.get('/sites/:id', view((req, res) => {++ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');++ const { site, project, role } = sites.load(req.viewer, req.params.id, 'read');++ page(req, res, 200, site.name, pages.site({++ 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));++ }));++++ 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');++ 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));++ }));++++ // ── Actions ─────────────────────────────────────────────++ router.post('/projects', action((req) => {++ const p = projects.create(req.viewer, { name: req.body.name, environment: req.body.environment });++ return back(`/projects/${p.id}`, 'Project created.');++ }));++ router.post('/projects/:id/sites', action((req) => {++ const project = projects.get(req.params.id);++ access.authorize(project, req.viewer, 'read');++ const s = sites.create(req.viewer, project, { name: req.body.name });++ return back(`/sites/${s.id}`, `Site created: ${siteUrl(s)}`);++ }));++ router.post('/projects/:id/members', action((req) => {++ const project = projects.get(req.params.id);++ access.authorize(project, req.viewer, 'read');++ projects.setMember(req.viewer, project, String(req.body.principal || '').trim(), String(req.body.role || ''));++ return back(`/projects/${project.id}`, 'Member saved.');++ }));++ router.post('/projects/:id/members/remove', action((req) => {++ const project = projects.get(req.params.id);++ access.authorize(project, req.viewer, 'read');++ projects.removeMember(req.viewer, project, String(req.body.principal || ''));++ return back(`/projects/${project.id}`, 'Member removed.');++ }));++ router.post('/projects/:id/delete', action((req) => {++ const project = projects.get(req.params.id);++ access.authorize(project, req.viewer, 'read');++ if (req.body.confirm !== 'yes') throw new ApiError(422, 'form.confirm', 'tick the confirmation box to delete');++ projects.remove(req.viewer, project, { sites });++ return back('/', 'Project deleted.');++ }));++ router.post('/sites/:id/domains', action((req) => {++ const { domain } = 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.`);++ }));++ router.post('/sites/:id/rollback', action((req) => {++ const r = deploys.rollback(req.viewer, req.params.id, { expectedActive: req.body.expected_active || null, traceparent: req.ov && req.ov.traceparent });++ return back(`/sites/${req.params.id}`, r.changed ? `Rolled back: ${r.deploy_id} is active.` : 'Nothing changed.');++ }));++ router.post('/sites/:id/delete', action((req) => {++ const { site } = sites.load(req.viewer, req.params.id, 'maintain');++ if (req.body.confirm !== 'yes') throw new ApiError(422, 'form.confirm', 'tick the confirmation box to delete');++ sites.remove(req.viewer, site.id, { deploys });++ return back(`/projects/${site.project_id}`, `${site.name} deleted.`);++ }));++ router.post('/deploys/:id/activate', action((req) => {++ const { site } = deploys.load(req.viewer, req.params.id, 'deploy');++ const r = deploys.activate(req.viewer, req.params.id, { expectedActive: req.body.expected_active || null, traceparent: req.ov && req.ov.traceparent });++ return back(`/sites/${site.id}`, r.changed ? `${r.deploy_id} is active.` : 'That deploy was already active.');++ }));++ router.post('/deploys/:id/delete', action((req) => {++ const { site } = deploys.load(req.viewer, req.params.id, 'maintain');++ deploys.remove(req.viewer, req.params.id);++ return back(`/sites/${site.id}`, 'Deploy deleted.');++ }));++ router.post('/domains/:id/verify', action(async (req) => {++ const d = await domains.verify(req.viewer, req.params.id, { traceparent: req.ov && req.ov.traceparent });++ return back(`/sites/${d.site_id}`, d.status === 'verified' ? `${d.hostname} is verified and served.` : `${d.hostname}: ${d.last_error || d.status}`, d.status === 'verified' ? null : 'error');++ }));++ router.post('/domains/:id/delete', action((req) => {++ const { domain } = domains.load(req.viewer, req.params.id, 'maintain');++ domains.remove(req.viewer, domain.id);++ return back(`/sites/${domain.site_id}`, `${domain.hostname} removed.`);++ }));++++ // Upload: multipart; the form token is checked after the body is parsed (Origin before).++ router.post('/sites/:id/deploys', async (req, res) => {++ 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);++ let up;++ try {++ up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, limits: pre.limits });++ } catch (err) {++ if (!(err instanceof UploadError)) throw err;++ if (err.closeConnection) res.set('Connection', 'close');++ if (!checkCsrf(config, req.viewer, err.fields && err.fields.csrf) && !err.closeConnection) throw new ApiError(403, 'form.invalid', 'this form expired; reload the page and try again');++ const r = deploys.recordFailure(req.viewer, pre, { source: err.source || 'files', code: err.code, problems: [{ code: err.code, message: err.message }], traceparent: req.ov && req.ov.traceparent });++ return res.redirect(303, back(`/deploys/${r.deploy.id}`, `Upload refused: ${err.message}`, 'error'));++ }++ 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 = 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.'));++ } 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;++ }++ } catch (err) {++ if (!req.complete) res.set('Connection', 'close');++ fail(req, res, err);++ }++ });++++ return router;++}++++module.exports = { createDashboard };nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++++/**++ * Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),++ * and the small request helpers every router shares.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++++/** A refusal with a stable problem code (e.g. 404 'site.not_found'). */++class ApiError extends Error {++ constructor(status, code, detail, extra) {++ super(detail || code);++ this.name = 'ApiError';++ this.status = status;++ this.code = code;++ this.extra = extra || null;++ }++}++++let logger = console;++/** The app's logger (tests pass a quiet one). */++function setLogger(l) { logger = l || console; }++++function sendError(res, req, err, log = logger) {++ if (err instanceof ApiError || (err && Number.isInteger(err.status) && typeof err.code === 'string' && err.name !== 'OpenVibeError')) {++ return contracts.http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov, extra: err.extra || undefined });++ }++ log.error('[Host API]', err && err.stack ? err.stack : err);++ return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++}++++/** Wrap a JSON handler: its return value is the body; errors become problems. */++function run(fn, status = 200) {++ return async (req, res) => {++ try {++ const out = await fn(req, res);++ if (out === undefined || res.headersSent) return;++ res.status(typeof status === 'function' ? status(out) : status).json(out);++ } catch (err) {++ if (res.headersSent) return;++ sendError(res, req, err);++ }++ };++}++++const jsonParser = express.json({ limit: '64kb' });++/** JSON body parser whose syntax errors are problems too. */++function jsonBody(req, res, next) {++ jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));++}++++/** Private, per-viewer responses: never stored by a shared cache. */++function privateNoStore(res) {++ res.set('Cache-Control', 'private, no-store');++ res.vary('Cookie');++ res.vary('Authorization');++}++++module.exports = { ApiError, sendError, setLogger, run, jsonBody, privateNoStore };nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++/**++ * Every API and dashboard response is built from these allowlists, never from raw rows. Host++ * stores no tenant secrets in Stage B (no environment variables, no build secrets, no deploy keys),++ * and nothing here exposes Host's own configuration. The one value that looks like a token, a++ * custom domain's TXT verification value, is public by design (it is published in DNS) and is++ * only shown to the project's members.++ */++const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());++++function project(p, { role = null, quota = null, usage = null } = {}) {++ return {++ id: p.id,++ name: p.name,++ environment: p.environment,++ network_project_id: p.network_project_id || null,++ owner: p.owner_subject,++ role: role || p.role || null,++ created_at: iso(p.created_at),++ ...(quota ? { quota: quotaOut(quota) } : {}),++ ...(usage ? { usage: usageOut(usage) } : {}),++ };++}++++function quotaOut(q) {++ return {++ storage_bytes: q.storageBytes, deploys_per_day: q.deploysPerDay, max_files: q.maxFiles,++ max_file_bytes: q.maxFileBytes, sites: q.sites, custom_domains: q.customDomains,++ };++}++++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 }) {++ return {++ id: s.id,++ project_id: s.project_id,++ name: s.name,++ hostname,++ url,++ active_deploy_id: s.active_deploy_id || null,++ created_at: iso(s.created_at),++ updated_at: iso(s.updated_at),++ };++}++++function deploy(d, { active = false, files = null, log = null } = {}) {++ return {++ id: d.id,++ site_id: d.site_id,++ project_id: d.project_id,++ state: d.state,++ active,++ source: d.source,++ file_count: d.file_count,++ total_bytes: d.total_bytes,++ new_bytes: d.new_bytes,++ manifest_sha256: d.manifest_sha256 || null,++ failure_code: d.failure_code || null,++ created_by: d.created_by,++ created_at: iso(d.created_at),++ ...(files ? { files: files.map((f) => ({ path: f.path, sha256: f.sha256, size: f.size, content_type: f.content_type })) } : {}),++ ...(log ? { log: log.map(logLine) } : {}),++ };++}++++function logLine(l) {++ return { level: l.level, message: l.message, at: iso(l.created_at) };++}++++function domain(d, instructions = null) {++ return {++ id: d.id,++ site_id: d.site_id,++ hostname: d.hostname,++ kind: d.kind,++ status: d.status,++ served: d.status === 'verified',++ verified_at: iso(d.verified_at),++ last_checked_at: iso(d.last_checked_at),++ last_error: d.last_error || null,++ ...(instructions ? { instructions } : {}),++ };++}++++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 };nnew file mode 100644@@@ -0,0 +1,220 @@++'use strict';++/**++ * Serving tenant sites by Host header.++ *++ * <name>.<sitesDomain> the site called <name> (one label; anything deeper is unknown)++ * <verified custom domain> the site it was verified for (pending, failed and lapsed: unknown)++ * anything else 404 "unknown host" with no tenant content++ *++ * A request resolves its site ONCE from the Host header, reads the site's active deploy pointer++ * ONCE, and from then on reads only immutable rows of that deploy (host_deploy_files). The file's++ * bytes come from <storage>/projects/<that site's project>/<sha256>: the path is built from the++ * project id and the manifest's hash, never from the URL, so no URL, encoding or Host trick can++ * reach another project's objects.++ *++ * 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.++ */++const fs = require('fs');++const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');++const { isHostname } = require('../domain/domains');++++const TENANT_CSP = [++ "default-src 'self'",++ "script-src 'self'",++ "style-src 'self' 'unsafe-inline'",++ "img-src 'self' data: https:",++ "font-src 'self' data:",++ "media-src 'self' https:",++ "connect-src 'self'",++ "object-src 'none'",++ "base-uri 'self'",++ "form-action 'self'",++ "frame-ancestors 'self'",++ 'upgrade-insecure-requests',++].join('; ');++++const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);++++/** 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;++ let h = raw.trim().toLowerCase();++ if (h.startsWith('[')) {++ const end = h.indexOf(']');++ return end > 0 ? h.slice(0, end + 1) : null;++ }++ const colon = h.indexOf(':');++ if (colon >= 0) {++ if (!/^\d{1,5}$/.test(h.slice(colon + 1))) return null;++ h = h.slice(0, colon);++ }++ if (h.endsWith('.')) h = h.slice(0, -1);++ if (!h || !/^[a-z0-9.-]+$/.test(h)) return null;++ return h;++}++++function baseHeaders(res, { sandbox = false } = {}) {++ res.setHeader('X-Content-Type-Options', 'nosniff');++ res.setHeader('Content-Security-Policy', TENANT_CSP);++ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');++ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');++ res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()');++ res.setHeader('X-Served-By', 'OpenVibe.Host');++ if (sandbox) res.setHeader('X-Robots-Tag', 'noindex, nofollow');++}++++function plain(res, status, text, extra = {}) {++ res.statusCode = status;++ res.setHeader('Content-Type', 'text/plain; charset=utf-8');++ res.setHeader('Cache-Control', 'no-store');++ res.setHeader('X-Content-Type-Options', 'nosniff');++ for (const [k, v] of Object.entries(extra)) res.setHeader(k, v);++ res.end(text);++}++++function createTenantServer({ store, config, blobs, 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'"),++ siteByCustom: db.prepare(`SELECT s.*, p.environment FROM host_domains d JOIN host_sites s ON s.id = d.site_id JOIN host_projects p ON p.id = s.project_id++ WHERE d.hostname = ? AND d.kind = 'custom' AND d.status = 'verified' AND s.status = 'active' AND p.status = 'active'`),++ file: db.prepare(`SELECT f.sha256, f.size, f.content_type, d.project_id FROM host_deploy_files f JOIN host_deploys d ON d.id = f.deploy_id++ WHERE f.deploy_id = ? AND f.path = ? AND d.state = 'ready'`),++ };++ const suffix = `.${config.sitesDomain}`;++++ /**++ * -> { kind: 'dashboard' } | { kind: 'site', site } | { kind: 'unknown', host }++ */++ function resolve(rawHost) {++ const host = normaliseHost(rawHost);++ if (!host) return { kind: 'unknown', host: null };++ if (host === config.dashboardHost || LOOPBACK_HOSTS.has(host)) return { kind: 'dashboard' };++ if (host.endsWith(suffix)) {++ const label = host.slice(0, -suffix.length);++ if (!label || label.includes('.')) return { kind: 'unknown', host };++ const site = q.siteByName.get(label);++ return site ? { kind: 'site', site, host } : { kind: 'unknown', host };++ }++ if (!isHostname(host)) return { kind: 'unknown', host };++ const site = q.siteByCustom.get(host);++ return site ? { kind: 'site', site, host } : { kind: 'unknown', host };++ }++++ /** Raw request path → candidate manifest paths, or null when the path is not a plain path. */++ function candidates(rawPath) {++ if (!rawPath.startsWith('/')) return null;++ const trailing = rawPath.length > 1 && rawPath.endsWith('/');++ const inner = rawPath.slice(1, trailing ? -1 : undefined);++ if (!inner) return { list: ['index.html'], dir: true, path: '' };++ const segments = inner.split('/');++ const decoded = [];++ for (const seg of segments) {++ if (/%2f|%5c|%00/i.test(seg)) return null; // an encoded separator or NUL is never a path++ let d;++ try { d = decodeURIComponent(seg); } catch { return null; }++ decoded.push(d);++ }++ const p = decoded.join('/');++ try { checkPath(p); } catch (err) { if (err instanceof PathError) return null; throw err; }++ if (trailing) return { list: [`${p}/index.html`], dir: true, path: p };++ return { list: [p, `${p}.html`], dirIndex: `${p}/index.html`, dir: false, path: p };++ }++++ function send(req, res, site, row, { status = 200, cache }) {++ 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);++ const inm = req.headers['if-none-match'];++ if (status === 200 && inm && inm.split(',').map((s) => s.trim().replace(/^W\//, '')).includes(etag)) {++ res.statusCode = 304;++ return res.end();++ }++ let start = 0;++ let end = row.size - 1;++ const range = status === 200 ? req.headers.range : null;++ if (range && (!req.headers['if-range'] || req.headers['if-range'] === etag)) {++ const m = /^bytes=(\d*)-(\d*)$/.exec(String(range).trim());++ if (!m || (!m[1] && !m[2])) { res.statusCode = 416; res.setHeader('Content-Range', `bytes */${row.size}`); return res.end(); }++ if (m[1]) { start = Number(m[1]); end = m[2] ? Math.min(Number(m[2]), row.size - 1) : row.size - 1; } else { start = Math.max(row.size - Number(m[2]), 0); }++ if (start > end || start >= row.size) { res.statusCode = 416; res.setHeader('Content-Range', `bytes */${row.size}`); return res.end(); }++ res.statusCode = 206;++ res.setHeader('Content-Range', `bytes ${start}-${end}/${row.size}`);++ } else {++ res.statusCode = status;++ }++ res.setHeader('Content-Length', row.size === 0 ? 0 : end - start + 1);++ if (req.method === 'HEAD' || row.size === 0) return res.end();++ const file = blobs.pathFor(row.project_id, row.sha256);++ const stream = fs.createReadStream(file, { start, end });++ stream.on('error', (err) => {++ log.error('[Host] object missing or unreadable:', row.project_id, row.sha256, err.code || err.message);++ if (!res.headersSent) { res.removeHeader('Content-Length'); plain(res, 500, 'This file could not be read. Try again later.'); } else res.destroy();++ });++ stream.pipe(res);++ }++++ function notFound(req, res, site, reason) {++ const page = site.active_deploy_id ? 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' });++ res.statusCode = 404;++ res.setHeader('Content-Type', 'text/html; charset=utf-8');++ res.setHeader('Cache-Control', 'no-cache');++ const body = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not found</title>`++ + '<style>body{font:16px/1.5 system-ui,sans-serif;max-width:36rem;margin:12vh auto;padding:0 1rem;color:#1f2937}small{color:#6b7280}</style></head>'++ + `<body><h1>Not found</h1><p>${reason === 'no-deploy' ? 'This site has not published anything yet.' : 'There is no page at this address.'}</p><small>Hosted on OpenVibe.Host</small></body></html>`;++ if (req.method === 'HEAD') return res.end();++ return res.end(body);++ }++++ /** Serves one request for a resolved site. */++ function handle(req, res, site) {++ 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' });++ }++ // Absolute-form request targets must agree with the Host header (or they would pick the site).++ let target = req.url || '/';++ if (!target.startsWith('/')) {++ let u;++ try { u = new URL(target); } catch { return plain(res, 400, 'Bad request target.'); }++ if (normaliseHost(u.host) !== normaliseHost(req.headers.host)) return plain(res, 400, 'The request target and Host header disagree.');++ target = `${u.pathname}${u.search}`;++ }++ const q0 = target.indexOf('?');++ const rawPath = q0 >= 0 ? target.slice(0, q0) : target;++ const search = q0 >= 0 ? target.slice(q0) : '';++ if (!site.active_deploy_id) return notFound(req, res, site, 'no-deploy');++ const c = candidates(rawPath);++ if (!c) return notFound(req, res, site, 'invalid');++ const deployId = site.active_deploy_id;++ 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 });++ }++ }++ if (!c.dir && c.dirIndex && q.file.get(deployId, c.dirIndex)) {++ // /docs → /docs/ so relative links inside docs/index.html resolve; built from the raw path.++ res.statusCode = 301;++ res.setHeader('Location', `${rawPath}/${search}`);++ res.setHeader('Cache-Control', 'no-cache');++ return res.end();++ }++ return notFound(req, res, site, 'missing');++ }++++ function unknownHost(req, res) {++ return plain(res, 404, 'Unknown host: no site is served at this address.');++ }++++ return { resolve, handle, unknownHost, normaliseHost, candidates, TENANT_CSP };++}++++module.exports = { createTenantServer, normaliseHost, TENANT_CSP };nnew file mode 100644@@@ -0,0 +1,175 @@++'use strict';++/**++ * Reading a deploy upload from a request, with every size limit applied while streaming:++ *++ * Content-Type: application/gzip | application/x-gzip | application/x-tar | application/octet-stream++ * the body is a tar or tar.gz archive++ * Content-Type: multipart/form-data++ * archive=<one .tar/.tar.gz file> or++ * files=<file>… (the part's filename is the path in the site, e.g. "css/site.css";++ * a browser folder upload sends "<folder>/css/site.css" and strip=folder++ * removes that shared first segment)++ * optional fields: activate=1, root=<subdirectory to deploy>, strip=folder, csrf (dashboard)++ *++ * Query parameters activate=1 and root=… work for both forms.++ * -> { source: 'archive'|'files', entries: [{ path, data }], fields, notes: [] }++ */++const Busboy = require('busboy');++const { readArchive, normaliseName, selectRoot, stripSharedTop, ArchiveError } = require('../artifacts/archive');++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 fmt = (n) => `${(n / 1048576).toFixed(1)} MiB`;++++class UploadError extends Error {++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }++}++++/** Oversized bodies are not drained: the response closes the connection instead. */++function tooLarge(max) {++ const e = new UploadError(413, 'upload.too_large', `the upload is larger than ${fmt(max)}`);++ e.closeConnection = true;++ return e;++}++++function readRaw(req, max) {++ return new Promise((resolve, reject) => {++ const chunks = [];++ let size = 0;++ let failed = false;++ req.on('data', (c) => {++ if (failed) return;++ size += c.length;++ if (size > max) { failed = true; req.pause(); reject(tooLarge(max)); return; }++ chunks.push(c);++ });++ req.on('end', () => { if (!failed) resolve(Buffer.concat(chunks)); });++ req.on('error', (err) => { if (!failed) { failed = true; reject(err); } });++ });++}++++function readMultipart(req, { maxUploadBytes, limits }) {++ return new Promise((resolve, reject) => {++ let bb;++ try {++ bb = Busboy({++ headers: req.headers,++ preservePath: true,++ limits: { fileSize: Math.max(limits.maxFileBytes, maxUploadBytes) + 1, files: limits.maxFiles + 1, fields: 20, fieldSize: 2048, parts: limits.maxFiles + 25, headerPairs: 50 },++ });++ } catch (err) {++ reject(new UploadError(400, 'upload.invalid_multipart', err.message));++ return;++ }++ const fields = {};++ const files = [];++ let archive = null;++ let total = 0;++ let failure = null;++ const fail = (e) => { if (!failure) failure = e; };++++ bb.on('field', (name, value) => { if (FIELD_NAMES.has(name)) fields[name] = value; });++ bb.on('file', (name, stream, info) => {++ const chunks = [];++ let size = 0;++ stream.on('data', (c) => {++ if (failure) return;++ size += c.length;++ total += c.length;++ if (total > maxUploadBytes) {++ fail(tooLarge(maxUploadBytes));++ req.unpipe(bb);++ req.pause();++ resolve({ failure, fields });++ return;++ }++ chunks.push(c);++ });++ stream.on('limit', () => fail(tooLarge(maxUploadBytes)));++ stream.on('end', () => {++ if (failure) return;++ const data = Buffer.concat(chunks);++ if (name === 'archive') {++ if (archive) fail(new UploadError(422, 'upload.invalid', 'send one archive'));++ archive = data;++ } else if (name === 'files' || name === 'file') {++ if (!info.filename) return; // an empty <input type=file>++ files.push({ path: String(info.filename), data });++ } else {++ fail(new UploadError(422, 'upload.invalid', `unexpected file field "${name}" (use "archive" or "files")`));++ }++ });++ });++ bb.on('filesLimit', () => fail(new UploadError(413, 'quota.max_files', `more than ${limits.maxFiles} files`)));++ bb.on('partsLimit', () => fail(new UploadError(413, 'quota.max_files', 'too many parts')));++ bb.on('error', (err) => { fail(new UploadError(400, 'upload.invalid_multipart', err.message)); resolve({ failure, fields }); });++ bb.on('close', () => {++ if (failure) return resolve({ failure, fields });++ if (archive && files.length) return resolve({ failure: new UploadError(422, 'upload.invalid', 'send either an archive or files, not both'), fields });++ resolve({ fields, archive, files });++ });++ req.pipe(bb);++ });++}++++/**++ * Throws UploadError for problems with the request itself; everything else (the archive's++ * contents) is reported by the validator.++ */++async function readUpload(req, { maxUploadBytes, limits }) {++ const len = Number(req.headers['content-length']);++ if (Number.isFinite(len) && len > maxUploadBytes) throw tooLarge(maxUploadBytes);++ const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();++ const notes = [];++ let source;++ let fields = {};++ let entries;++ let rawArchive = null;++ if (type === 'multipart/form-data') {++ const r = await readMultipart(req, { maxUploadBytes, limits });++ fields = r.fields || {};++ if (r.failure) { r.failure.fields = fields; throw r.failure; }++ if (r.archive) { rawArchive = r.archive; } else {++ source = 'files';++ entries = r.files.map((f) => ({ path: normaliseName(f.path), data: f.data }));++ notes.push(`received ${entries.length} files as multipart form data`);++ if ((fields.strip || req.query.strip) === 'folder') {++ const s = stripSharedTop(entries);++ entries = s.entries;++ if (s.stripped) notes.push(`removed the uploaded folder name "${s.stripped}/" from every path`);++ }++ }++ } else if (ARCHIVE_TYPES.has(type)) {++ rawArchive = await readRaw(req, maxUploadBytes);++ } else {++ req.resume(); // bodies of an unsupported type are small or refused by nginx; drain for keep-alive++ throw new UploadError(415, 'upload.unsupported_type', 'send a tar or tar.gz archive (application/gzip) or multipart/form-data');++ }++++ if (rawArchive) {++ source = 'archive';++ let r;++ try { r = readArchive(rawArchive, limits); } catch (err) {++ if (err instanceof ArchiveError) { const e = new UploadError(err.status, err.code, err.message); e.fields = fields; e.source = 'archive'; throw e; }++ throw err;++ }++ entries = r.entries.map((e) => ({ path: normaliseName(e.path), data: e.data }));++ notes.push(`received a ${r.compressed ? 'tar.gz' : 'tar'} archive of ${(rawArchive.length / 1024).toFixed(1)} KiB: ${entries.length} files, ${r.directories} directories`);++ }++++ const root = fields.root || req.query.root;++ if (root) {++ let clean;++ try { clean = checkPath(String(root).replace(/^\.\/+/, '').replace(/\/+$/, '')); } catch (err) {++ if (err instanceof PathError) { const e = new UploadError(422, 'upload.invalid_root', `root: ${err.message}`); e.fields = fields; e.source = source; throw e; }++ throw err;++ }++ const s = selectRoot(entries, clean);++ entries = s.entries;++ notes.push(`deploying from "${clean}/": ${entries.length} files kept, ${s.dropped} outside it ignored`);++ }++ return { source, entries, fields, notes };++}++++module.exports = { readUpload, UploadError };nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++/**++ * Host's own identifiers: <prefix>_<ULID> (the contracts ULID, so they sort by creation time).++ *++ * prj_ project (until OpenVibe.Network has projects; then it references network_project_id)++ * site_ site dpl_ deploy dom_ domain++ */++const { ids } = require('openvibe-contracts');++++const PREFIX = { project: 'prj', site: 'site', deploy: 'dpl', domain: 'dom' };++const ULID = '[0-9A-HJKMNP-TV-Z]{26}';++const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));++++function newId(kind, now) {++ return `${PREFIX[kind]}_${ids.ulid(now)}`;++}++++function isId(kind, value) {++ return typeof value === 'string' && RE[kind].test(value);++}++++module.exports = { newId, isId, PREFIX };nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++++/**++ * OpenVibe.Host Stage B — process entry. `node server/index.js`++ * Listens on PORT (4910) behind nginx (deploy/nginx). Serves the dashboard and API on BASE_URL's++ * host and tenant sites on <site>.<HOST_SITES_DOMAIN> and verified custom domains. Starts the++ * outbox relay (when EVENTS_URL and the client secret are set) and the worker (domain checks,++ * object sweep).++ */++const { createApp } = require('./app');++++const { app, ctx } = createApp();++const { config } = ctx;++++const server = app.listen(config.port, config.host, () => {++ console.log(`[Host] ${config.nodeEnv} on http://${config.host}:${config.port} → dashboard ${config.baseUrl}, sites *.${config.sitesDomain} (db ${config.dbPath}, objects ${ctx.blobs.root})`);++ console.log(`[Host] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}`);++});++server.keepAliveTimeout = 65_000;++server.requestTimeout = 10 * 60_000; // large uploads on slow links++ctx.outbox.start();++ctx.worker.start();++++function shutdown(signal) {++ console.log(`[Host] ${signal}: closing`);++ ctx.worker.stop();++ server.close(async () => {++ try { await ctx.outbox.stop(); } catch { /* best effort */ }++ try { ctx.stopMetrics(); } catch { /* best effort */ }++ try { ctx.store.close(); } catch { /* already closed */ }++ process.exit(0);++ });++ setTimeout(() => process.exit(0), 5000).unref();++}++process.on('SIGTERM', () => shutdown('SIGTERM'));++process.on('SIGINT', () => shutdown('SIGINT'));nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).++ *++ * db required a real query on Host's SQLite (the charter tables answer)++ * storage required the object store directory is writable (uploads and serving need it)++ * network_jwks optional the Network signing key has loaded; without it tenant sites still++ * serve, but nobody can sign in and service tokens are refused (503)++ * events_relay optional the outbox relay is configured and has no rejected rows++ * domain_checks optional no custom domain is stuck: pending checks are running++ */++const { createReadiness } = require('openvibe-shared/ready');++const { CHARTER_TABLES } = require('./db');++++function createHostReadiness({ store, blobs, auth, outbox, release = null }) {++ const { db } = store;++ return createReadiness({++ service: 'host',++ release,++ checks: [++ {++ name: 'db', required: true,++ check: () => {++ const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));++ const missing = CHARTER_TABLES.filter((t) => !names.has(t));++ return missing.length ? `missing ${missing.join(', ')}` : true;++ },++ },++ { name: 'storage', required: true, check: () => blobs.writable() },++ {++ name: 'network_jwks', required: false,++ check: () => {++ if (auth.client.publicKey) return true;++ auth.ensureKey().catch(() => {});++ return 'Network signing key not loaded yet: sign-in and service calls are unavailable';++ },++ },++ {++ name: 'events_relay', required: false,++ check: () => {++ const s = outbox.status();++ if (!s.enabled) return `relay off (Events URL or service credentials not configured); ${s.pending} events waiting`;++ if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;++ return { ok: true, detail: { pending: s.pending } };++ },++ },++ {++ name: 'domain_checks', required: false,++ check: () => {++ const stale = db.prepare("SELECT COUNT(*) AS n FROM host_domains WHERE kind = 'custom' AND status = 'pending' AND COALESCE(last_checked_at, created_at) < ?").get(store.now() - 3600 * 1000).n;++ return stale ? `${stale} pending custom domains not checked in the last hour (is the worker on?)` : true;++ },++ },++ ],++ });++}++++module.exports = { createHostReadiness };nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++++/**++ * Dashboard page shell. Every page is server-rendered and complete without JavaScript (all actions++ * are plain forms); the shared chrome (navbar.js + theme-loader.js from the Network) is progressive.++ * Dashboard pages are private: noindex, private caching.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const appIcon = require('openvibe-shared/app-icon');++const chrome = require('openvibe-shared/chrome-ssr');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Host';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++const hashes = new Map();++function assetVersion(rel) {++ if (hashes.has(rel)) return hashes.get(rel);++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }++ hashes.set(rel, v);++ return v;++}++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;++++/** o: title, body (HTML), viewer, config, path, notice { kind, text } */++function renderPage(o) {++ const viewer = o.viewer || { kind: 'anonymous' };++ const signedIn = viewer.kind === 'user';++ const loginNext = encodeURIComponent(o.path || '/');++ const nav = {++ service: 'host',++ apiBase: NETWORK_URL,++ links: [{ label: 'Projects', href: '/' }],++ history: { type: 'page', title: o.title || SITE_NAME },++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: `/auth/login?next=${loginNext}`,++ };++ const who = signedIn ? esc((viewer.user && (viewer.user.display_name || viewer.user.username)) || viewer.subject) : '';++ const account = signedIn++ ? `Signed in as ${who} · <a href="/auth/logout?next=%2F">Sign out</a>`++ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ const notice = o.notice ? `<p class="notice notice-${esc(o.notice.kind || 'info')}" role="status">${esc(o.notice.text)}</p>` : '';++ return `<!DOCTYPE html>++<html lang="en">++<head>++<meta charset="utf-8">++<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'}">++<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')}">++<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>++<script src="${NETWORK_URL}/shared/navbar.js" defer></script>++</head>++<body>++<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: [{ label: 'Projects', href: '/' }] })}++<div class="account-bar" role="navigation" aria-label="Account">${account}</div>++<main id="main" class="page">++${notice}++${o.body || ''}++</main>++${chrome.footer({ service: 'host', variant: 'full' })}++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the chrome is optional */ }++});++</script>++</body>++</html>`;++}++++module.exports = { renderPage, esc, asset, assetVersion, SITE_NAME, NETWORK_URL };nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++/**++ * Dashboard page bodies. Every value is escaped; every action is a POST form carrying the form++ * token, so the dashboard works without JavaScript.++ */++const { esc } = require('./layout');++++const bytes = (n) => (n >= 1073741824 ? `${(n / 1073741824).toFixed(2)} GiB` : n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);++const when = (ms) => (ms ? `<time datetime="${new Date(ms).toISOString()}">${esc(new Date(ms).toISOString().replace('T', ' ').slice(0, 16))} UTC</time>` : '');++const short = (id) => esc(String(id || '').slice(0, 12));++++function form(action, csrf, inner, { cls = '', confirm = null, enctype = null } = {}) {++ return `<form method="post" action="${esc(action)}"${cls ? ` class="${cls}"` : ''}${enctype ? ` enctype="${enctype}"` : ''}>`++ + `<input type="hidden" name="csrf" value="${esc(csrf)}">${inner}`++ + `${confirm ? `<label class="confirm"><input type="checkbox" name="confirm" value="yes" required> ${esc(confirm)}</label>` : ''}</form>`;++}++++function signedOut() {++ return `<section class="intro">++<h1>OpenVibe.Host</h1>++<p>Static site hosting for OpenVibe projects. Upload a folder or a <code>.tar.gz</code> of HTML, CSS, JavaScript, images and fonts;++Host keeps every deploy as an immutable, content-addressed artifact, serves the active one at++<code><site>.openvibe.host</code> (and on custom domains you verify), and lets you roll back to any earlier deploy in one step.</p>++<p><strong>Alpha.</strong> Static files only: there is no build step and no server-side code, and nothing you upload is ever executed.++Hosted bots, mods and apps (Stage C) are not available.</p>++<p><a class="button" href="/auth/login?next=%2F">Sign in with OpenVibe</a></p>++</section>`;++}++++function home({ projects, csrf }) {++ const rows = projects.map((p) => `<tr><td><a href="/projects/${esc(p.id)}">${esc(p.name)}</a></td><td>${esc(p.environment)}</td><td>${esc(p.role || '')}</td><td>${when(p.created_at)}</td></tr>`).join('');++ return `<h1>Your projects</h1>++${projects.length ? `<table><thead><tr><th>Project</th><th>Environment</th><th>Your role</th><th>Created</th></tr></thead><tbody>${rows}</tbody></table>` : '<p>You have no projects yet.</p>'}++<h2>New project</h2>++${form('/projects', csrf, `<label>Name <input name="name" required maxlength="80"></label>++<label>Environment <select name="environment"><option value="production">production</option><option value="sandbox">sandbox (smaller quotas, noindex, no custom domains)</option></select></label>++<button type="submit">Create project</button>`)}`;++}++++function quotaTable(quota, usage) {++ const row = (label, used, limit, fmt = (x) => esc(x)) => `<tr><th scope="row">${label}</th><td>${fmt(used)}</td><td>${fmt(limit)}</td></tr>`;++ return `<table class="quota"><thead><tr><th></th><th>Used</th><th>Limit</th></tr></thead><tbody>++${row('Storage', usage.storageBytes, quota.storageBytes, bytes)}++${row('Deploys in the last 24 h', usage.deploysLast24h, quota.deploysPerDay)}++${row('Sites', usage.sites, quota.sites)}++${row('Custom domains', usage.customDomains, quota.customDomains)}++<tr><th scope="row">Files per deploy</th><td></td><td>${esc(quota.maxFiles)}</td></tr>++<tr><th scope="row">Largest file</th><td></td><td>${bytes(quota.maxFileBytes)}</td></tr>++</tbody></table>`;++}++++function project({ project: p, role, sites, quota, usage, members, csrf, siteUrl }) {++ const siteRows = sites.map((s) => `<tr><td><a href="/sites/${esc(s.id)}">${esc(s.name)}</a></td><td><a href="${esc(siteUrl(s))}" rel="noopener">${esc(siteUrl(s))}</a></td><td>${s.active_deploy_id ? `<code>${short(s.active_deploy_id)}</code>` : '<em>nothing published</em>'}</td></tr>`).join('');++ const memberRows = members.map((m) => `<tr><td><code>${esc(m.principal)}</code></td><td>${esc(m.role)}</td><td>${role === 'owner' && m.principal !== p.owner_subject++ ? form(`/projects/${p.id}/members/remove`, csrf, `<input type="hidden" name="principal" value="${esc(m.principal)}"><button type="submit" class="link">Remove</button>`, { cls: 'inline' }) : ''}</td></tr>`).join('');++ const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';++ return `<p class="crumbs"><a href="/">Projects</a> ›</p>++<h1>${esc(p.name)}</h1>++<p class="meta"><code>${esc(p.id)}</code> · ${esc(p.environment)} · your role: ${esc(role)}${p.network_project_id ? ` · Network project <code>${esc(p.network_project_id)}</code>` : ''}</p>++<h2>Sites</h2>++${sites.length ? `<table><thead><tr><th>Site</th><th>Address</th><th>Active deploy</th></tr></thead><tbody>${siteRows}</tbody></table>` : '<p>No sites yet.</p>'}++${canMaintain ? form(`/projects/${p.id}/sites`, csrf, `<label>New site name <input name="name" required pattern="[a-z0-9-]{3,40}" maxlength="40" placeholder="my-site"></label><button type="submit">Create site</button>`) : ''}++<h2>Quotas</h2>++${quotaTable(quota, usage)}++<h2>Members</h2>++<table><thead><tr><th>Principal</th><th>Role</th><th></th></tr></thead><tbody>${memberRows}</tbody></table>++${role === 'owner' ? form(`/projects/${p.id}/members`, csrf, `<label>Principal <input name="principal" required placeholder="usr_… or app:app_…"></label>++<label>Role <select name="role"><option value="deployer">deployer</option><option value="maintainer">maintainer</option></select></label><button type="submit">Add or change</button>`) : ''}++${role === 'owner' || role === 'staff' ? `<h2>Delete project</h2>${form(`/projects/${p.id}/delete`, csrf, '<button type="submit" class="danger">Delete this project, its sites and every deploy</button>', { confirm: 'I understand every site stops at once' })}` : ''}`;++}++++function domainBlock(d, instructions, csrf) {++ const status = d.status === 'verified' ? '<span class="ok">verified: served</span>' : `<span class="warn">${esc(d.status)}: not served</span>`;++ let body = `<li><strong>${esc(d.hostname)}</strong> · ${esc(d.kind)} · ${status}${d.last_error ? ` · <span class="muted">${esc(d.last_error)}</span>` : ''}`;++ if (instructions) {++ body += `<details${d.status === 'verified' ? '' : ' open'}><summary>DNS records</summary><table class="dns"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>`++ + `<tr><td>${esc(instructions.verification.type)}</td><td><code>${esc(instructions.verification.name)}</code></td><td><code>${esc(instructions.verification.value)}</code></td><td>${esc(instructions.verification.note)}</td></tr>`++ + instructions.routing.map((r) => `<tr><td>${esc(r.type)}</td><td><code>${esc(r.name)}</code></td><td><code>${esc(r.value)}</code></td><td>${esc(r.note)}</td></tr>`).join('')++ + `</tbody></table><p class="muted">${esc(instructions.tls)}</p></details>`;++ if (d.status !== 'verified') body += form(`/domains/${d.id}/verify`, csrf, '<button type="submit">Check DNS now</button>', { cls: 'inline' });++ body += form(`/domains/${d.id}/delete`, csrf, '<button type="submit" class="link danger">Remove</button>', { cls: 'inline' });++ }++ return `${body}</li>`;++}++++function site({ site: s, project: p, role, deploys, activations, domains, csrf, url }) {++ const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';++ const canDeploy = role !== 'staff';++ const rows = deploys.map((d) => {++ const active = d.id === s.active_deploy_id;++ const actions = [];++ 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>`;++ }).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>++<h1>${esc(s.name)}</h1>++<p class="meta"><a href="${esc(url)}" rel="noopener">${esc(url)}</a> · <code>${esc(s.id)}</code></p>++${canDeploy ? `<h2>Upload a deploy</h2>++<p class="muted">Static files only: HTML, CSS, JavaScript, JSON, images, fonts, audio, video, PDF, WebAssembly. No build runs and nothing is executed. Hidden files (except <code>.well-known/</code>), links and server-side code are refused.</p>++${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>++<button type="submit">Upload</button>`, { enctype: 'multipart/form-data' })}` : ''}++<h2>Deploys</h2>++${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>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` })}` : ''}`;++}++++function deploy({ deploy: d, site: s, project: p, files, log, active }) {++ const logLines = log.map((l) => `<li class="log-${esc(l.level)}"><span class="level">${esc(l.level)}</span> ${esc(l.message)}</li>`).join('');++ const fileRows = files.slice(0, 2000).map((f) => `<tr><td><code>${esc(f.path)}</code></td><td>${esc(f.content_type)}</td><td>${bytes(f.size)}</td><td><code title="${esc(f.sha256)}">${esc(f.sha256.slice(0, 12))}</code></td></tr>`).join('');++ return `<p class="crumbs"><a href="/">Projects</a> › <a href="/projects/${esc(p.id)}">${esc(p.name)}</a> › <a href="/sites/${esc(s.id)}">${esc(s.name)}</a> ›</p>++<h1>Deploy <code>${short(d.id)}</code></h1>++<p class="meta">${esc(d.state)}${active ? ' · <strong>active</strong>' : ''} · ${esc(d.file_count)} files · ${bytes(d.total_bytes)} · ${when(d.created_at)} · by <code>${esc(d.created_by)}</code>${d.manifest_sha256 ? ` · manifest <code>${esc(d.manifest_sha256.slice(0, 16))}</code>` : ''}</p>++<h2>Upload log</h2>++<ul class="log">${logLines}</ul>++${files.length ? `<h2>Files</h2><table><thead><tr><th>Path</th><th>Type</th><th>Size</th><th>sha256</th></tr></thead><tbody>${fileRows}</tbody></table>${files.length > 2000 ? `<p>…and ${files.length - 2000} more.</p>` : ''}` : ''}`;++}++++function errorPage({ status, message }) {++ return `<h1>${status === 404 ? 'Not found' : status === 403 ? 'Not allowed' : status === 401 ? 'Sign in first' : 'Something went wrong'}</h1><p>${esc(message)}</p><p><a href="/">Back to your projects</a></p>`;++}++++module.exports = { signedOut, home, project, site, deploy, errorPage, bytes };nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++/**++ * Content-addressed object store for deploy files, one directory per project:++ *++ * <root>/projects/<prj_…>/<aa>/<sha256> the bytes (read-only files, 0644)++ * <root>/tmp/ writes land here, then rename() into place++ *++ * Every path is built from a validated project id and a validated lowercase sha256 hex string, never++ * from a URL or an archive entry name, so no request can name a file outside its project's++ * directory. The same bytes uploaded by two projects are stored twice: storage is accounted and++ * deleted per project (ADR-014: tenancy keyed by project id).++ *++ * Stage B keeps objects on local disk. The roadmap prefers OpenVibe.Media "where practical"; that++ * adapter does not exist yet (see README).++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const { isId } = require('./ids');++++const SHA_RE = /^[0-9a-f]{64}$/;++++function createBlobStore(root) {++ const base = path.resolve(root);++ const projectsDir = path.join(base, 'projects');++ const tmpDir = path.join(base, 'tmp');++ fs.mkdirSync(projectsDir, { recursive: true });++ fs.mkdirSync(tmpDir, { recursive: true });++++ function projectDir(projectId) {++ if (!isId('project', projectId)) throw new TypeError('invalid project id');++ return path.join(projectsDir, projectId);++ }++++ function pathFor(projectId, sha256) {++ if (!SHA_RE.test(String(sha256))) throw new TypeError('invalid sha256');++ return path.join(projectDir(projectId), sha256.slice(0, 2), sha256);++ }++++ /** Store bytes whose sha256 the caller computed; verified again here. Idempotent. */++ function put(projectId, sha256, data) {++ const dest = pathFor(projectId, sha256);++ const actual = crypto.createHash('sha256').update(data).digest('hex');++ if (actual !== sha256) throw new Error('blob digest mismatch');++ try {++ const st = fs.statSync(dest);++ if (st.isFile() && st.size === data.length) return false;++ } catch { /* not there yet */ }++ fs.mkdirSync(path.dirname(dest), { recursive: true });++ const tmp = path.join(tmpDir, `${crypto.randomBytes(12).toString('hex')}.part`);++ const fd = fs.openSync(tmp, 'wx', 0o644);++ try {++ fs.writeSync(fd, data);++ fs.fsyncSync(fd);++ } finally { fs.closeSync(fd); }++ fs.renameSync(tmp, dest);++ return true;++ }++++ function exists(projectId, sha256) {++ try { return fs.statSync(pathFor(projectId, sha256)).isFile(); } catch { return false; }++ }++++ function remove(projectId, sha256) {++ try { fs.unlinkSync(pathFor(projectId, sha256)); return true; } catch { return false; }++ }++++ function removeProject(projectId) {++ fs.rmSync(projectDir(projectId), { recursive: true, force: true });++ }++++ /** Every (project, sha) on disk: for the orphan sweep. */++ function* list() {++ let projects = [];++ try { projects = fs.readdirSync(projectsDir); } catch { return; }++ for (const p of projects) {++ if (!isId('project', p)) continue;++ let shards = [];++ try { shards = fs.readdirSync(path.join(projectsDir, p)); } catch { continue; }++ for (const s of shards) {++ let files = [];++ try { files = fs.readdirSync(path.join(projectsDir, p, s)); } catch { continue; }++ for (const f of files) if (SHA_RE.test(f)) yield { projectId: p, sha256: f, file: path.join(projectsDir, p, s, f) };++ }++ }++ }++++ /** Remove leftovers of interrupted writes. */++ function sweepTmp(olderThanMs = 3600 * 1000) {++ let n = 0;++ for (const f of fs.readdirSync(tmpDir)) {++ const p = path.join(tmpDir, f);++ try { if (Date.now() - fs.statSync(p).mtimeMs > olderThanMs) { fs.unlinkSync(p); n++; } } catch { /* gone */ }++ }++ return n;++ }++++ function writable() {++ fs.accessSync(tmpDir, fs.constants.W_OK);++ fs.accessSync(projectsDir, fs.constants.W_OK);++ return true;++ }++++ return { root: base, pathFor, put, exists, remove, removeProject, list, sweepTmp, writable };++}++++module.exports = { createBlobStore, SHA_RE };nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++/**++ * Background work in the Host process (a few timers; nothing here executes tenant content):++ *++ * domain checks every HOST_DOMAIN_RECHECK_MS: pending custom domains are checked again++ * (they fail after HOST_DOMAIN_PENDING_DAYS); verified ones are re-checked daily++ * and lapse when their TXT record has been gone for HOST_DOMAIN_LAPSE_DAYS++ * object sweep hourly: objects on disk that no deploy row references (an upload that failed++ * after writing objects) and interrupted temp files are removed after an hour++ * outbox prune daily: sent events older than seven days++ */++const fs = require('fs');++++function createWorker({ config, store, domains, blobs, outbox, log = console }) {++ const timers = [];++ let checking = false;++++ async function domainTick() {++ if (checking) return null;++ checking = true;++ try {++ const s = await domains.recheck();++ if (s.verified) outbox.kick();++ return s;++ } catch (err) {++ log.error('[Host] domain checks failed:', err.message);++ return null;++ } finally { checking = false; }++ }++++ /** Remove objects no row references, once they are older than `graceMs`. */++ function sweepObjects({ graceMs = 3600 * 1000 } = {}) {++ const known = store.db.prepare('SELECT 1 FROM host_blobs WHERE project_id = ? AND sha256 = ?');++ let removed = 0;++ for (const o of blobs.list()) {++ if (known.get(o.projectId, o.sha256)) continue;++ try {++ if (Date.now() - fs.statSync(o.file).mtimeMs < graceMs) continue;++ fs.unlinkSync(o.file);++ removed++;++ } catch { /* raced with a write or already gone */ }++ }++ removed += blobs.sweepTmp(graceMs);++ return removed;++ }++++ return {++ domainTick,++ sweepObjects,++ start() {++ if (!config.worker.enabled) return;++ const every = (ms, fn) => { const t = setInterval(fn, ms); t.unref(); timers.push(t); };++ every(config.domains.recheckIntervalMs, domainTick);++ every(3600 * 1000, () => { try { sweepObjects(); } catch (err) { log.warn('[Host] object sweep failed:', err.message); } });++ every(24 * 3600 * 1000, () => { try { outbox.outbox.prune(); } catch (err) { log.warn('[Host] outbox prune failed:', err.message); } });++ setTimeout(domainTick, 5000).unref();++ },++ stop() { for (const t of timers) clearInterval(t); timers.length = 0; },++ };++}++++module.exports = { createWorker };@@@ -33,7 +33,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, [], 'Stage A exposes no API, so it enforces no capability');++ 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');}),test('as root, an inventory that is not root-owned or is writable by others is refused', async () => {nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++/**++ * The proposals the lead releases in the next openvibe-contracts version are valid, match what the++ * code enforces and emits, and do not collide with released ids; and the outbox relay really++ * delivers Host's events to OpenVibe.Events with a Network service token.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./stageb/boot');++const { listen } = require('./stageb/mocks');++const { PROPOSED } = require('../server/auth/capabilities');++const { EVENT_TYPES } = require('../server/events/outbox');++++const DIR = path.join(__dirname, '..', 'docs', 'capabilities-proposal');++++(async () => {++ const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'));++ const caps = files.map((f) => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')));++ const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'service-manifest-proposal.json'), 'utf8'));++++ await check('every capability proposal is a valid capabilities.capability@1 with 3 segments, owned by host', async () => {++ for (const c of caps) {++ const v = contracts.validate('capabilities.capability@1', c);++ assert.ok(v.valid, `${c.id}: ${JSON.stringify(v.errors)}`);++ assert.strictEqual(c.owner, 'host');++ assert.strictEqual(c.id.split('.').length, 3);++ assert.strictEqual(`${c.id}.json`, files[caps.indexOf(c)]);++ assert.ok(!contracts.capabilities.get(c.id) || contracts.capabilities.get(c.id).owner === 'host', `${c.id} collides with a released capability`);++ }++ });++++ await check('the proposals are exactly the capabilities the routes guard, and every route guards one', async () => {++ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());++ assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());++ const api = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');++ const routes = [...api.matchAll(/router\.(get|post|put|delete)\('([^']+)', guard\('([a-z.]+)'\)/g)].map((m) => ({ method: m[1].toUpperCase(), route: `/api/v1${m[2]}`, cap: m[3] }));++ const all = [...api.matchAll(/router\.(get|post|put|delete)\('/g)].length;++ assert.strictEqual(routes.length, all, 'every API route has exactly one capability guard');++ for (const r of routes) {++ const cap = caps.find((c) => c.id === r.cap);++ assert.ok(cap, r.cap);++ assert.ok(cap.implementedBy.includes(`${r.method} ${r.route}`), `${r.method} ${r.route} missing from ${r.cap}.implementedBy`);++ }++ });++++ await check('the service manifest proposal is a valid registry.service-manifest@1 declaring every emitted event', async () => {++ const v = contracts.validate('registry.service-manifest@1', manifest);++ assert.ok(v.valid, JSON.stringify(v.errors));++ assert.strictEqual(manifest.id, 'host');++ assert.deepStrictEqual([...manifest.eventsProduced].sort(), [...EVENT_TYPES].sort());++ for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), e);++ const src = ['server/domain/deploys.js', 'server/domain/domains.js'].map((f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8')).join('\n');++ for (const m of src.matchAll(/event_type: '([a-z_.]+)'/g)) assert.ok(manifest.eventsProduced.includes(m[1]), m[1]);++ });++++ await check('the outbox relay publishes host.* events to OpenVibe.Events with a Network service token', async () => {++ const received = [];++ let network;++ const events = await listen((req, raw, json) => {++ if (req.url !== '/api/v1/events' || req.method !== 'POST') return json(404, {});++ const token = String(req.headers.authorization || '').slice(7);++ const v = contracts.serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.events' });++ if (!v.ok || !v.claims.cap.includes('events.event.publish') || v.claims.sub !== 'svc:host') return json(403, { code: 'capability.denied' });++ const body = JSON.parse(raw);++ const list = body.events || [body];++ for (const e of list) { const val = contracts.validate('events.event-envelope@1', e); if (!val.valid) return json(422, { errors: val.errors }); received.push(e); }++ const results = list.map((e, i) => ({ event_id: e.event_id, seq: received.length - list.length + i + 1, duplicate: false }));++ return json(200, body.events ? { results } : results[0]);++ });++ const t = await boot({ env: { EVENTS_URL: events.url } });++ network = t.network;++ const alice = t.user('alice');++ const p = await t.project(alice, 'E');++ const s = await t.site(alice, p.id, 'events-site');++ await t.deploy(alice, s.id, { 'index.html': 'x' });++ await t.upload(alice, s.id, { 'bad.php': 'x' });++ await t.ctx.outbox.outbox.flush();++ assert.deepStrictEqual(received.map((e) => e.event_type).sort(), ['host.deploy.activated', 'host.deploy.created', 'host.deploy.failed']);++ assert.ok(received.every((e) => e.source === 'host'));++ assert.strictEqual(t.ctx.outbox.status().pending, 0);++ await t.close();++ await events.close();++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,141 @@++'use strict';++/**++ * Custom domains are served only after DNS TXT verification, belong to one site, can never be an++ * OpenVibe domain, lapse when the proof disappears, and never carry certificates through the API.++ */++const assert = require('assert');++const { boot, check, done } = require('./stageb/boot');++++const DAY = 24 * 3600 * 1000;++++(async () => {++ const t = await boot();++ 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 sa = await t.site(alice, pa.id, 'alice-site');++ const sb = await t.site(bob, pb.id, 'bob-site');++ await t.deploy(alice, sa.id, { 'index.html': 'ALICE' });++ await t.deploy(bob, sb.id, { 'index.html': 'BOB' });++ const HOST = 'www.alice-example.org';++ let dom;++++ await check('adding a custom domain returns the TXT and routing records; it is NOT served yet', async () => {++ const r = await t.api('POST', `/api/v1/sites/${sa.id}/domains`, { as: alice, json: { hostname: 'WWW.Alice-Example.org.' } });++ assert.strictEqual(r.status, 201, r.text);++ dom = r.json().domain;++ assert.strictEqual(dom.hostname, HOST);++ assert.strictEqual(dom.status, 'pending');++ assert.strictEqual(dom.served, false);++ assert.strictEqual(dom.instructions.verification.type, 'TXT');++ assert.strictEqual(dom.instructions.verification.name, `_openvibe-host.${HOST}`);++ assert.match(dom.instructions.verification.value, /^openvibe-host-verification=[0-9a-f]{40}$/);++ assert.deepStrictEqual(dom.instructions.routing[0], { type: 'CNAME', name: HOST, value: 'alice-site.openvibe.host', note: 'for a subdomain such as www.example.org' });++ assert.ok(!/BEGIN|privkey|fullchain/.test(r.text), 'no certificate material');++ const g = await t.get(HOST, '/');++ assert.strictEqual(g.status, 404);++ assert.match(g.text, /Unknown host/);++ });++++ await check('verification: missing record and a wrong value keep it pending (and unserved)', async () => {++ let r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });++ assert.strictEqual(r.json().domain.status, 'pending');++ assert.match(r.json().domain.last_error, /no TXT record/);++ t.clock.advance(10_000);++ t.dns.set(`_openvibe-host.${HOST}`, ['openvibe-host-verification=wrong']);++ r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });++ assert.strictEqual(r.json().domain.status, 'pending');++ assert.match(r.json().domain.last_error, /not the expected value/);++ t.clock.advance(10_000);++ t.dns.set('__fail__', true);++ r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });++ assert.match(r.json().domain.last_error, /DNS lookup failed \(ETIMEOUT\)/);++ t.dns.delete('__fail__');++ assert.strictEqual((await t.get(HOST, '/')).status, 404);++ });++++ await check('another tenant can claim the same name while pending, but only the first verified proof wins', async () => {++ const b = await t.api('POST', `/api/v1/sites/${sb.id}/domains`, { as: bob, json: { hostname: HOST } });++ assert.strictEqual(b.status, 201);++ t.clock.advance(10_000);++ t.dns.set(`_openvibe-host.${HOST}`, [dom.instructions.verification.value, 'v=spf1 -all']);++ const r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });++ assert.strictEqual(r.json().domain.status, 'verified');++ assert.strictEqual(r.json().domain.served, true);++ const ev = t.events('host.domain.verified');++ assert.strictEqual(ev.length, 1);++ assert.deepStrictEqual(ev[0].payload, { project_id: pa.id, site_id: sa.id, site: 'alice-site', hostname: HOST });++ const g = await t.get(HOST, '/');++ assert.strictEqual(g.text, 'ALICE');++ assert.match(g.headers['content-security-policy'], /default-src 'self'/);++ // Bob publishes his own proof afterwards: too late.++ t.dns.set(`_openvibe-host.${HOST}`, [b.json().domain.instructions.verification.value]);++ const bv = await t.api('POST', `/api/v1/domains/${b.json().domain.id}/verify`, { as: bob });++ assert.strictEqual(bv.json().domain.status, 'failed');++ assert.match(bv.json().domain.last_error, /verified for another site first/);++ assert.strictEqual((await t.get(HOST, '/')).text, 'ALICE');++ const again = await t.api('POST', `/api/v1/sites/${sb.id}/domains`, { as: bob, json: { hostname: 'apex-free.example.org' } });++ assert.strictEqual(again.status, 201);++ const taken = await t.api('POST', `/api/v1/projects/${pb.id}/sites`, { as: bob, json: { name: 'bob-two' } });++ const dup = await t.api('POST', `/api/v1/sites/${taken.json().site.id}/domains`, { as: bob, json: { hostname: HOST } });++ assert.strictEqual(dup.status, 409);++ assert.strictEqual(dup.json().code, 'domain.taken');++ });++++ await check('OpenVibe domains, the sites domain and non-hostnames can never be claimed', async () => {++ for (const h of ['openvibe.live', 'evil.openvibe.network', 'x.openvibe.host', 'openvibe.host', 'openvibe.xyz', 'a.b.openvibe.media', 'events.openvibe.network', 'openre.stream', 'play.openvibe.games',++ 'localhost', '127.0.0.1', 'example', '-bad.example.org', 'a_b.example.org', 'ex ample.org', 'xn--.example.org']) {++ const r = await t.api('POST', `/api/v1/sites/${sa.id}/domains`, { as: alice, json: { hostname: h } });++ assert.strictEqual(r.status, 422, `${h} → ${r.status} ${r.text}`);++ }++ });++++ await check('the default domain cannot be removed; removing a custom domain stops serving it at once', async () => {++ const list = (await t.api('GET', `/api/v1/sites/${sa.id}/domains`, { as: alice })).json().domains;++ const def = list.find((d) => d.kind === 'default');++ assert.strictEqual((await t.api('DELETE', `/api/v1/domains/${def.id}`, { as: alice })).status, 409);++ const d2 = await t.api('POST', `/api/v1/sites/${sa.id}/domains`, { as: alice, json: { hostname: 'docs.alice-example.org' } });++ const d2v = d2.json().domain;++ t.dns.set(d2v.instructions.verification.name, [d2v.instructions.verification.value]);++ assert.strictEqual((await t.api('POST', `/api/v1/domains/${d2v.id}/verify`, { as: alice })).json().domain.status, 'verified');++ assert.strictEqual((await t.get('docs.alice-example.org', '/')).text, 'ALICE');++ assert.strictEqual((await t.api('DELETE', `/api/v1/domains/${d2v.id}`, { as: alice })).status, 200);++ assert.strictEqual((await t.get('docs.alice-example.org', '/')).status, 404);++ });++++ await check('worker: pending domains verify in the background and fail after the pending window', async () => {++ const p = await t.api('POST', `/api/v1/sites/${sb.id}/domains`, { as: bob, json: { hostname: 'late.example.org' } });++ const pd = p.json().domain;++ t.clock.advance(11 * 60 * 1000);++ t.dns.set(pd.instructions.verification.name, [pd.instructions.verification.value]);++ const s = await t.ctx.worker.domainTick();++ assert.ok(s.verified >= 1);++ assert.strictEqual((await t.get('late.example.org', '/')).text, 'BOB');++ const never = (await t.api('POST', `/api/v1/sites/${sb.id}/domains`, { as: bob, json: { hostname: 'never.example.org' } })).json().domain;++ t.clock.advance(8 * DAY);++ await t.ctx.worker.domainTick();++ const after = t.ctx.domains.get(never.id);++ assert.strictEqual(after.status, 'failed');++ });++++ await check('worker: a verified domain whose TXT record is gone for the lapse window stops being served', async () => {++ t.dns.delete(`_openvibe-host.${HOST}`);++ t.clock.advance(DAY + 1000);++ await t.ctx.worker.domainTick();++ assert.strictEqual(t.ctx.domains.get(dom.id).status, 'verified', 'still served during the grace period');++ assert.ok(t.ctx.domains.get(dom.id).record_missing_since);++ for (let i = 0; i < 8; i++) { t.clock.advance(DAY + 1000); await t.ctx.worker.domainTick(); }++ assert.strictEqual(t.ctx.domains.get(dom.id).status, 'lapsed');++ assert.strictEqual((await t.get(HOST, '/')).status, 404);++ });++++ await check('deleting a site stops its custom domains', async () => {++ assert.strictEqual((await t.get('late.example.org', '/')).text, 'BOB');++ await t.api('DELETE', `/api/v1/sites/${sb.id}`, { as: bob });++ assert.strictEqual((await t.get('late.example.org', '/')).status, 404);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,140 @@++'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.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./stageb/boot');++++(async () => {++ const t = await boot();++ 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');++ const SHARED = 'same bytes in both projects';++ const da = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA HOME', 'shared.txt': SHARED });++ const db = await t.deploy(bob, beta.id, { 'index.html': 'BETA HOME', 'secret.html': 'BETA SECRET', 'dir/inner.html': 'BETA INNER', 'shared.txt': SHARED });++ const betaFiles = (await t.api('GET', `/api/v1/deploys/${db.id}`, { as: bob })).json().deploy.files;++ const secretSha = betaFiles.find((f) => f.path === 'secret.html').sha256;++ const leaks = (r) => /BETA/.test(r.text);++++ await check('each site serves only its own deploy', async () => {++ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).text, 'ALPHA HOME');++ assert.strictEqual((await t.get('beta.openvibe.host', '/')).text, 'BETA HOME');++ assert.strictEqual((await t.get('alpha.openvibe.host', '/secret.html')).status, 404);++ });++++ await check('path tricks on alpha never reach beta\'s files or anyone\'s object store', async () => {++ const targets = [++ '/../secret.html', '/../beta/secret.html', '/%2e%2e/secret.html', '/%2E%2E%2Fsecret.html', '/..%2fsecret.html', '/..%2Fbeta%2Fsecret.html',++ '/.%2e/secret.html', '/%252e%252e/secret.html', '//beta.openvibe.host/secret.html', '/./secret.html', '/dir/../secret.html',++ '/%00secret.html', '/secret.html%00.html', '/..\\secret.html', '/%5c..%5csecret.html', '/.well-known/../secret.html',++ `/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`, `/${secretSha}`, `/objects/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`,++ `/../../objects/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`, `/%2e%2e/%2e%2e/objects/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`,++ '/dir/inner.html', '/host.db', '/../host.db', '/%2e%2e/host.db',++ ];++ for (const p of targets) {++ const r = await t.get('alpha.openvibe.host', p);++ assert.ok(!leaks(r), `${p} leaked: ${r.status} ${r.text.slice(0, 80)}`);++ assert.ok([400, 404].includes(r.status), `${p} → ${r.status}`);++ assert.ok(!r.body.includes(Buffer.from('SQLite format')), `${p} returned the database`);++ }++ });++++ await check('Host header tricks resolve to exactly one site or to "unknown host"', async () => {++ const cases = [++ ['ALPHA.OPENVIBE.HOST', 'ALPHA HOME'], ['alpha.openvibe.host.', 'ALPHA HOME'], ['alpha.openvibe.host:443', 'ALPHA HOME'],++ ['beta.alpha.openvibe.host', null], ['alpha.beta.openvibe.host', null], ['alpha.openvibe.host.evil.example', null],++ ['[email protected]', null], ['alpha.openvibe.host:80:beta', null], ['alpha.openvibe.host/beta', null],++ ['beta', null], ['openvibe.host.beta', null], ['-beta.openvibe.host', null], ['%62eta.openvibe.host', null], ['', null],++ ];++ for (const [host, want] of cases) {++ const r = await t.get(host, '/secret.html');++ assert.ok(!leaks(r), `Host ${JSON.stringify(host)} leaked beta`);++ if (want) { const home = await t.get(host, '/'); assert.strictEqual(home.text, want, host); }++ }++ const dash = await t.get('openvibe.host', '/secret.html');++ assert.ok(!leaks(dash), 'the dashboard host does not serve tenant files');++ });++++ await check('an absolute-form request target must match the Host header', async () => {++ const r = await t.get('alpha.openvibe.host', 'http://beta.openvibe.host/secret.html');++ assert.strictEqual(r.status, 400);++ assert.ok(!leaks(r));++ const ok = await t.get('beta.openvibe.host', 'http://beta.openvibe.host/secret.html');++ assert.strictEqual(ok.text, 'BETA SECRET', 'a consistent absolute-form request is fine');++ });++++ await check('tenant hosts never reach the API, auth, metrics or readiness', async () => {++ for (const p of ['/api/v1/projects', '/api/ready', '/metrics', '/auth/login', '/release.json', '/css/host.css']) {++ const r = await t.get('alpha.openvibe.host', p, { as: bob });++ assert.strictEqual(r.status, 404, p);++ assert.ok(!/projects|"ready"|http_requests_total|oauth/.test(r.text), `${p} leaked: ${r.text.slice(0, 100)}`);++ }++ });++++ await check('identical bytes are stored per project: deleting one project\'s copy never breaks the other', async () => {++ const sha = betaFiles.find((f) => f.path === 'shared.txt').sha256;++ const root = path.join(t.dir, 'objects', 'projects');++ assert.ok(fs.existsSync(path.join(root, pa.id, sha.slice(0, 2), sha)));++ assert.ok(fs.existsSync(path.join(root, pb.id, sha.slice(0, 2), sha)));++ const d2 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA 2' });++ assert.ok(d2.active);++ const del = await t.api('DELETE', `/api/v1/deploys/${da.id}`, { as: alice });++ assert.strictEqual(del.status, 200, del.text);++ assert.ok(!fs.existsSync(path.join(root, pa.id, sha.slice(0, 2), sha)), 'alice\'s copy is gone');++ assert.strictEqual((await t.get('beta.openvibe.host', '/shared.txt')).text, SHARED, 'bob\'s copy still serves');++ });++++ await check('the API hides other tenants\' projects, sites, deploys and domains (404, not 403)', async () => {++ const dom = await t.api('POST', `/api/v1/sites/${beta.id}/domains`, { as: bob, json: { hostname: 'www.beta-example.org' } });++ const domainId = dom.json().domain.id;++ const probes = [++ ['GET', `/api/v1/projects/${pb.id}`], ['GET', `/api/v1/projects/${pb.id}/sites`], ['GET', `/api/v1/projects/${pb.id}/quota`],++ ['POST', `/api/v1/projects/${pb.id}/sites`, { name: 'hijack' }], ['DELETE', `/api/v1/projects/${pb.id}`],++ ['GET', `/api/v1/sites/${beta.id}`], ['DELETE', `/api/v1/sites/${beta.id}`], ['GET', `/api/v1/sites/${beta.id}/deploys`],++ ['POST', `/api/v1/sites/${beta.id}/rollback`, {}], ['GET', `/api/v1/deploys/${db.id}`], ['GET', `/api/v1/deploys/${db.id}/log`],++ ['POST', `/api/v1/deploys/${db.id}/activate`, {}], ['DELETE', `/api/v1/deploys/${db.id}`], ['GET', `/api/v1/sites/${beta.id}/domains`],++ ['POST', `/api/v1/sites/${beta.id}/domains`, { hostname: 'x.example.org' }], ['POST', `/api/v1/domains/${domainId}/verify`], ['DELETE', `/api/v1/domains/${domainId}`],++ ['PUT', `/api/v1/projects/${pb.id}/members/${alice.subject}`, { role: 'owner' }],++ ];++ for (const [method, p, json] of probes) {++ const r = await t.api(method, p, { as: alice, json });++ assert.strictEqual(r.status, 404, `${method} ${p} → ${r.status} ${r.text}`);++ assert.ok(!/BETA|beta-example/.test(r.text));++ }++ const up = await t.upload(alice, beta.id, { 'index.html': 'PWNED' });++ assert.strictEqual(up.status, 404);++ assert.strictEqual((await t.get('beta.openvibe.host', '/')).text, 'BETA HOME');++ const list = (await t.api('GET', '/api/v1/projects', { as: alice })).json().projects.map((p) => p.id);++ assert.deepStrictEqual(list, [pa.id]);++ });++++ await check('a tenant cannot point its own site at another tenant\'s deploy', async () => {++ const r = await t.api('POST', `/api/v1/sites/${alpha.id}/rollback`, { as: alice, json: { deploy_id: db.id } });++ assert.strictEqual(r.status, 404);++ assert.ok(!leaks(await t.get('alpha.openvibe.host', '/')));++ });++++ await check('the API ignores cookies: a session cookie alone is anonymous (tenant pages are same-site)', async () => {++ const r = await t.api('POST', '/api/v1/projects', { session: alice, json: { name: 'csrf' } });++ assert.strictEqual(r.status, 401);++ const g = await t.api('GET', `/api/v1/projects/${pa.id}`, { session: alice });++ assert.strictEqual(g.status, 401);++ });++++ await check('a garbage bearer token is refused, not treated as anonymous', async () => {++ const r = await t.api('GET', '/api/v1/projects', { as: 'not-a-jwt' });++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.headers['content-type'], 'application/problem+json');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,191 @@++'use strict';++/**++ * Stage B end to end: project → site → deploy (tar.gz and multipart) → served by Host header with++ * the right headers → activate / roll back → logs and events.++ */++const assert = require('assert');++const { boot, check, done } = require('./stageb/boot');++++(async () => {++ const t = await boot();++ const alice = t.user('alice');++ let project, site, d1, d2;++++ await check('a signed-in person creates a project (prj_ id, owner, production quotas) and a site with its default domain', async () => {++ project = await t.project(alice, 'Alice site');++ assert.match(project.id, /^prj_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(project.owner, alice.subject);++ assert.strictEqual(project.environment, 'production');++ assert.strictEqual(project.role, 'owner');++ assert.strictEqual(project.quota.deploys_per_day, 50);++ site = await t.site(alice, project.id, 'alice');++ assert.match(site.id, /^site_/);++ assert.strictEqual(site.hostname, 'alice.openvibe.host');++ assert.strictEqual(site.url, 'https://alice.openvibe.host');++ const r = await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice });++ assert.deepStrictEqual(r.json().domains.map((d) => [d.hostname, d.kind, d.status]), [['alice.openvibe.host', 'default', 'verified']]);++ });++++ await check('before any deploy the site answers with its own 404', async () => {++ const r = await t.get('alice.openvibe.host', '/');++ assert.strictEqual(r.status, 404);++ assert.match(r.text, /has not published anything yet/);++ assert.strictEqual(r.headers['x-content-type-options'], 'nosniff');++ });++++ await check('a tar.gz deploy is validated, stored content-addressed, activated and served with correct headers', async () => {++ const r = await t.upload(alice, site.id, {++ 'index.html': '<!doctype html><h1>v1</h1>', 'about/index.html': '<p>about</p>', 'assets/app.3f2a9c1b.js': 'console.log(1)',++ 'style.css': 'body{}', 'img/logo.svg': '<svg xmlns="http://www.w3.org/2000/svg"/>', '404.html': '<h1>custom 404 v1</h1>', 'CNAME': 'alice.example',++ });++ assert.strictEqual(r.status, 201, r.text);++ const body = r.json();++ d1 = body.deploy;++ assert.match(d1.id, /^dpl_/);++ assert.strictEqual(d1.state, 'ready');++ assert.strictEqual(d1.active, true);++ assert.strictEqual(body.activated, true);++ assert.strictEqual(d1.file_count, 7);++ assert.match(d1.manifest_sha256, /^[0-9a-f]{64}$/);++ assert.ok(d1.log.some((l) => /nothing was executed/.test(l.message)));++ const page = await t.get('alice.openvibe.host', '/');++ assert.strictEqual(page.status, 200);++ assert.strictEqual(page.text, '<!doctype html><h1>v1</h1>');++ assert.strictEqual(page.headers['content-type'], 'text/html; charset=utf-8');++ assert.strictEqual(page.headers['x-content-type-options'], 'nosniff');++ assert.match(page.headers['content-security-policy'], /default-src 'self'; script-src 'self'/);++ assert.match(page.headers['content-security-policy'], /object-src 'none'/);++ assert.strictEqual(page.headers['cache-control'], 'public, max-age=0, must-revalidate');++ assert.match(page.headers.etag, /^"[0-9a-f]{64}"$/);++ assert.strictEqual(page.headers['set-cookie'], undefined, 'tenant responses never set cookies');++ assert.strictEqual(page.headers['x-openvibe-deploy'], d1.id);++ const js = await t.get('alice.openvibe.host', '/assets/app.3f2a9c1b.js');++ assert.strictEqual(js.headers['content-type'], 'text/javascript; charset=utf-8');++ assert.strictEqual(js.headers['cache-control'], 'public, max-age=31536000, immutable');++ const svg = await t.get('alice.openvibe.host', '/img/logo.svg');++ assert.strictEqual(svg.headers['content-type'], 'image/svg+xml');++ const cname = await t.get('alice.openvibe.host', '/CNAME');++ assert.strictEqual(cname.headers['content-type'], 'text/plain; charset=utf-8');++ });++++ await check('ETag revalidation (304), HEAD, byte ranges, directory index and redirect, custom 404, 405', async () => {++ const first = await t.get('alice.openvibe.host', '/style.css');++ const again = await t.get('alice.openvibe.host', '/style.css', { headers: { 'if-none-match': first.headers.etag } });++ assert.strictEqual(again.status, 304);++ assert.strictEqual(again.body.length, 0);++ const head = await t.get('alice.openvibe.host', '/style.css', { method: 'HEAD' });++ assert.strictEqual(head.status, 200);++ assert.strictEqual(head.headers['content-length'], '6');++ const range = await t.get('alice.openvibe.host', '/style.css', { headers: { range: 'bytes=0-3' } });++ assert.strictEqual(range.status, 206);++ assert.strictEqual(range.text, 'body');++ assert.strictEqual(range.headers['content-range'], 'bytes 0-3/6');++ const bad = await t.get('alice.openvibe.host', '/style.css', { headers: { range: 'bytes=10-20' } });++ assert.strictEqual(bad.status, 416);++ const redirect = await t.get('alice.openvibe.host', '/about?x=1');++ assert.strictEqual(redirect.status, 301);++ assert.strictEqual(redirect.headers.location, '/about/?x=1');++ const about = await t.get('alice.openvibe.host', '/about/');++ assert.strictEqual(about.text, '<p>about</p>');++ const missing = await t.get('alice.openvibe.host', '/nope.html');++ assert.strictEqual(missing.status, 404);++ assert.strictEqual(missing.text, '<h1>custom 404 v1</h1>', 'the deploy\'s own 404.html');++ assert.match(missing.headers['content-security-policy'], /default-src 'self'/);++ const post = await t.get('alice.openvibe.host', '/', { method: 'POST', body: 'x' });++ assert.strictEqual(post.status, 405);++ assert.strictEqual(post.headers.allow, 'GET, HEAD');++ });++++ await check('a second deploy (not activated) leaves the site unchanged; activate switches; rollback goes back', async () => {++ d2 = await t.deploy(alice, site.id, { 'index.html': '<h1>v2</h1>', '404.html': 'v2 404' }, { activate: false });++ assert.strictEqual(d2.active, false);++ assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<!doctype html><h1>v1</h1>');++ const a = await t.api('POST', `/api/v1/deploys/${d2.id}/activate`, { as: alice, json: {} });++ assert.strictEqual(a.status, 200, a.text);++ assert.deepStrictEqual(a.json(), { active_deploy_id: d2.id, previous_deploy_id: d1.id, changed: true });++ assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<h1>v2</h1>');++ assert.strictEqual((await t.get('alice.openvibe.host', '/style.css')).status, 404, 'files of the old deploy are gone from the site');++ const rb = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: {} });++ assert.strictEqual(rb.status, 200, rb.text);++ assert.strictEqual(rb.json().active_deploy_id, d1.id);++ assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<!doctype html><h1>v1</h1>');++ const rb2 = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: { deploy_id: d2.id } });++ assert.strictEqual(rb2.json().active_deploy_id, d2.id);++ const list = (await t.api('GET', `/api/v1/sites/${site.id}/deploys`, { as: alice })).json();++ assert.strictEqual(list.active_deploy_id, d2.id);++ assert.deepStrictEqual(list.deploys.map((d) => d.id), [d2.id, d1.id]);++ assert.deepStrictEqual(list.activations.map((a2) => a2.kind), ['rollback', 'rollback', 'activate', 'activate']);++ });++++ await check('multipart files upload with a browser folder name stripped, and a subfolder root', async () => {++ const boundary = '----hostboundary';++ const part = (name, filename, content) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"${filename ? `; filename="${filename}"` : ''}\r\n${filename ? 'Content-Type: application/octet-stream\r\n' : ''}\r\n${content}\r\n`;++ const body = part('strip', null, 'folder') + part('files', 'mysite/index.html', '<h1>v3</h1>') + part('files', 'mysite/css/a.css', 'a{}') + `--${boundary}--\r\n`;++ const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys?activate=1`, { as: alice, body, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().deploy.source, 'files');++ assert.ok(r.json().deploy.log.some((l) => /removed the uploaded folder name "mysite\/"/.test(l.message)));++ assert.strictEqual((await t.get('alice.openvibe.host', '/css/a.css')).text, 'a{}');++ const rooted = await t.upload(alice, site.id, { 'README.md': 'x', 'dist/index.html': '<h1>dist</h1>' }, { query: 'root=dist' });++ assert.strictEqual(rooted.status, 201, rooted.text);++ assert.strictEqual(rooted.json().deploy.file_count, 1);++ assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<h1>dist</h1>');++ });++++ await check('the deploy record: files with sha256 and type, and the upload log', async () => {++ const r = await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: alice });++ const files = r.json().deploy.files;++ assert.ok(files.find((f) => f.path === 'about/index.html' && /^[0-9a-f]{64}$/.test(f.sha256) && f.content_type === 'text/html; charset=utf-8'));++ const log = await t.api('GET', `/api/v1/deploys/${d1.id}/log`, { as: alice });++ assert.ok(log.json().log.some((l) => /validated 7 files/.test(l.message)));++ });++++ await check('events: created/activated in the outbox with valid envelopes and no file contents', async () => {++ const contracts = require('openvibe-contracts');++ const created = t.events('host.deploy.created');++ const activated = t.events('host.deploy.activated');++ assert.ok(created.length >= 4);++ assert.ok(activated.some((e) => e.payload.rollback === true));++ for (const e of [...created, ...activated]) {++ const v = contracts.validate('events.event-envelope@1', e);++ assert.ok(v.valid, JSON.stringify(v.errors));++ assert.strictEqual(e.source, 'host');++ assert.deepStrictEqual(e.actor, { type: 'user', id: alice.subject });++ assert.ok(!JSON.stringify(e).includes('<h1>'));++ }++ });++++ await check('deleting: the active deploy is refused; an old one is removed with its unshared objects', async () => {++ const list = (await t.api('GET', `/api/v1/sites/${site.id}/deploys`, { as: alice })).json();++ const active = list.active_deploy_id;++ const refused = await t.api('DELETE', `/api/v1/deploys/${active}`, { as: alice });++ assert.strictEqual(refused.status, 409);++ assert.strictEqual(refused.json().code, 'deploy.active');++ const before = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;++ const del = await t.api('DELETE', `/api/v1/deploys/${d1.id}`, { as: alice });++ assert.strictEqual(del.status, 200, del.text);++ assert.ok(del.json().objects_removed > 0);++ const after = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;++ assert.ok(after < before);++ assert.strictEqual((await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: alice })).status, 404);++ const rb = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: { deploy_id: d1.id } });++ assert.strictEqual(rb.status, 404, 'a deleted deploy cannot come back');++ });++++ await check('deleting the site stops serving at once and frees the name only for its own project for 30 days', async () => {++ const del = await t.api('DELETE', `/api/v1/sites/${site.id}`, { as: alice });++ assert.strictEqual(del.status, 200, del.text);++ assert.strictEqual((await t.get('alice.openvibe.host', '/')).status, 404);++ assert.match((await t.get('alice.openvibe.host', '/')).text, /Unknown host/);++ const bob = t.user('bob');++ const bp = await t.project(bob, 'Bob');++ const taken = await t.api('POST', `/api/v1/projects/${bp.id}/sites`, { as: bob, json: { name: 'alice' } });++ assert.strictEqual(taken.status, 409);++ assert.strictEqual(taken.json().code, 'site.name_held');++ const mine = await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: alice, json: { name: 'alice' } });++ assert.strictEqual(mine.status, 201);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,193 @@++'use strict';++/**++ * Quotas are enforced by Host (ADR-014) and principals are judged by capability AND project role:++ * deploys per day, storage (deduplicated), files per deploy, file size, upload size, sites, custom++ * domains, sandbox limits; service tokens, app principals, sandbox tokens, staff.++ */++const assert = require('assert');++const { boot, check, done } = require('./stageb/boot');++const { site: siteTar } = require('./stageb/tar');++++const DAY = 24 * 3600 * 1000;++++(async () => {++ const t = await boot({ env: { HOST_MAX_UPLOAD_BYTES: String(64 * 1024) } });++ const alice = t.user('alice');++ const staff = t.user('root', { role: 'admin' });++ const project = await t.project(alice, 'Q');++ const site = await t.site(alice, project.id, 'quota-site');++ const setQuota = (q) => t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: staff, json: q });++++ await check('only staff change quotas; the owner reads them', async () => {++ const mine = await t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: alice, json: { deploys_per_day: 1000 } });++ assert.strictEqual(mine.status, 403);++ assert.strictEqual(mine.json().code, 'auth.staff_only');++ const r = await setQuota({ deploys_per_day: 3 });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().quota.deploys_per_day, 3);++ const bad = await setQuota({ max_files: -1 });++ assert.strictEqual(bad.status, 422);++ });++++ await check('deploys per day: refused before the body is read, counted over a rolling 24 hours', async () => {++ for (let i = 0; i < 3; i++) await t.deploy(alice, site.id, { 'index.html': `v${i}` });++ const r = await t.upload(alice, site.id, { 'index.html': 'v4' });++ assert.strictEqual(r.status, 429);++ assert.strictEqual(r.json().code, 'quota.deploys_per_day');++ assert.strictEqual((await t.get('quota-site.openvibe.host', '/')).text, 'v2');++ t.clock.advance(DAY + 1000);++ assert.strictEqual((await t.upload(alice, site.id, { 'index.html': 'v5' })).status, 201);++ await setQuota({ deploys_per_day: null });++ });++++ await check('storage quota counts only new bytes (identical content is stored once per project)', async () => {++ const big = 'x'.repeat(3000);++ await setQuota({ storage_bytes: 7000 });++ const used0 = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;++ const first = await t.upload(alice, site.id, { 'index.html': big });++ assert.strictEqual(first.status, 201, first.text);++ assert.strictEqual(first.json().deploy.new_bytes, 3000);++ const same = await t.upload(alice, site.id, { 'index.html': big, 'copy.html': big });++ assert.strictEqual(same.status, 201, same.text);++ assert.strictEqual(same.json().deploy.new_bytes, 0, 'already stored');++ const over = await t.upload(alice, site.id, { 'index.html': 'y'.repeat(4100) });++ assert.strictEqual(over.status, 413);++ assert.strictEqual(over.json().code, 'quota.storage');++ const tooBig = await t.upload(alice, site.id, { 'a.html': big, 'b.html': big, 'c.html': big });++ assert.strictEqual(tooBig.status, 413, 'a deploy (before deduplication) is never larger than the storage quota');++ assert.strictEqual(tooBig.json().code, 'deploy.too_large');++ const used1 = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;++ assert.strictEqual(used1 - used0, 3000);++ await setQuota({ storage_bytes: null });++ });++++ await check('files per deploy and bytes per file', async () => {++ await setQuota({ max_files: 2, max_file_bytes: 100 });++ const many = await t.upload(alice, site.id, { 'a.html': '1', 'b.html': '2', 'c.html': '3' });++ assert.strictEqual(many.status, 413);++ assert.strictEqual(many.json().code, 'quota.max_files');++ const large = await t.upload(alice, site.id, { 'a.html': 'z'.repeat(101) });++ assert.strictEqual(large.status, 413);++ assert.strictEqual(large.json().code, 'quota.max_file_bytes');++ await setQuota({ max_files: null, max_file_bytes: null });++ });++++ await check('request size: a body over HOST_MAX_UPLOAD_BYTES is refused (413) and the connection closed', async () => {++ const crypto = require('crypto');++ const noise = crypto.randomBytes(80 * 1024); // incompressible++ const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body: siteTar({ 'blob.bin.png': noise }), headers: { 'content-type': 'application/gzip' } });++ assert.strictEqual(r.status, 413);++ assert.strictEqual(r.json().code, 'upload.too_large');++ assert.strictEqual(r.headers.connection, 'close');++ });++++ await check('sites per project and custom domains per project', async () => {++ await setQuota({ sites: 2, custom_domains: 1 });++ await t.site(alice, project.id, 'second-site');++ const third = await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: alice, json: { name: 'third-site' } });++ assert.strictEqual(third.status, 429);++ assert.strictEqual(third.json().code, 'quota.sites');++ assert.strictEqual((await t.api('POST', `/api/v1/sites/${site.id}/domains`, { as: alice, json: { hostname: 'one.example.org' } })).status, 201);++ const two = await t.api('POST', `/api/v1/sites/${site.id}/domains`, { as: alice, json: { hostname: 'two.example.org' } });++ assert.strictEqual(two.status, 429);++ assert.strictEqual(two.json().code, 'quota.custom_domains');++ });++++ await check('site names: one DNS label, reserved names refused, unique', async () => {++ for (const name of ['ab', 'www', 'api', 'openvibe-news', 'bad_name', 'UPPER', '-lead', 'trail-', 'a--b', 'xn--80ak6aa92e', 'x'.repeat(41), 'a.b']) {++ const r = await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: alice, json: { name } });++ assert.ok([422, 429].includes(r.status), `${name} → ${r.status}`);++ }++ });++++ await check('sandbox projects: smaller quotas, no custom domains, noindex', async () => {++ const sb = await t.project(alice, 'Sandbox', { environment: 'sandbox' });++ assert.strictEqual(sb.environment, 'sandbox');++ assert.strictEqual(sb.quota.custom_domains, 0);++ assert.ok(sb.quota.storage_bytes < project.quota.storage_bytes);++ const s = await t.site(alice, sb.id, 'sandbox-alice');++ const d = await t.api('POST', `/api/v1/sites/${s.id}/domains`, { as: alice, json: { hostname: 'sb.example.org' } });++ assert.strictEqual(d.status, 403);++ await t.deploy(alice, s.id, { 'index.html': 'sb' });++ assert.strictEqual((await t.get('sandbox-alice.openvibe.host', '/')).headers['x-robots-tag'], 'noindex, nofollow');++ });++++ // ── Principals ──────────────────────────────────────────++ const ALL = ['host.site.manage', 'host.deploy.create', 'host.domain.manage'];++++ await check('service tokens: audience and capability are checked per route', async () => {++ const wrongAud = t.network.sign({ sub: 'svc:codes', aud: ['openvibe.blog'], cap: ALL });++ assert.strictEqual((await t.api('GET', '/api/v1/projects', { as: wrongAud })).status, 401);++ const noCap = t.network.serviceToken('codes', ['host.deploy.create']);++ const r = await t.api('POST', '/api/v1/projects', { as: noCap, json: { name: 'x' }, headers: { 'x-ov-subject': alice.subject } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'capability.denied');++ const family = t.network.serviceToken('codes', ['host.*']);++ const ok = await t.api('GET', `/api/v1/projects/${project.id}`, { as: family, headers: { 'x-ov-subject': alice.subject } });++ assert.strictEqual(ok.status, 200, 'a host.* grant covers the family');++ });++++ await check('a first-party service acts for a person (X-OV-Subject) and is judged by that person\'s role', async () => {++ const codes = t.network.serviceToken('codes', ALL);++ const created = await t.api('POST', '/api/v1/projects', { as: codes, json: { name: 'via codes', network_project_id: 'nprj_123' }, headers: { 'x-ov-subject': alice.subject } });++ assert.strictEqual(created.status, 201, created.text);++ assert.strictEqual(created.json().project.owner, alice.subject);++ assert.strictEqual(created.json().project.network_project_id, 'nprj_123');++ const dup = await t.api('POST', '/api/v1/projects', { as: codes, json: { name: 'dup', network_project_id: 'nprj_123' }, headers: { 'x-ov-subject': alice.subject } });++ assert.strictEqual(dup.status, 409);++ const noPerson = await t.api('POST', '/api/v1/projects', { as: codes, json: { name: 'orphan' } });++ assert.strictEqual(noPerson.status, 403, 'a project is owned by a person');++ const stranger = t.user('mallory');++ const other = await t.api('GET', `/api/v1/projects/${project.id}`, { as: codes, headers: { 'x-ov-subject': stranger.subject } });++ assert.strictEqual(other.status, 404, 'the capability alone does not open someone else\'s project');++ });++++ await check('apps are principals: a deployer app deploys, cannot manage, cannot act as a person', async () => {++ const { ids } = require('openvibe-contracts');++ const appId = ids.newId('app');++ const ci = t.network.appToken(appId, ALL);++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${site.id}/deploys`, { as: ci })).status, 404, 'not a member yet');++ const add = await t.api('PUT', `/api/v1/projects/${project.id}/members/app:${appId}`, { as: alice, json: { role: 'deployer' } });++ assert.strictEqual(add.status, 200, add.text);++ const up = await t.api('POST', `/api/v1/sites/${site.id}/deploys?activate=1`, { as: ci, body: siteTar({ 'index.html': 'from CI' }), headers: { 'content-type': 'application/gzip' } });++ assert.strictEqual(up.status, 201, up.text);++ assert.strictEqual(up.json().deploy.created_by, `app:${appId}`);++ assert.deepStrictEqual(t.events('host.deploy.activated').pop().actor, { type: 'app', id: appId });++ assert.strictEqual((await t.get('quota-site.openvibe.host', '/')).text, 'from CI');++ const mk = await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: ci, json: { name: 'ci-site' } });++ assert.strictEqual(mk.status, 403);++ assert.strictEqual(mk.json().code, 'project.role_insufficient');++ const imp = await t.api('GET', `/api/v1/projects/${project.id}`, { as: ci, headers: { 'x-ov-subject': alice.subject } });++ assert.strictEqual(imp.status, 400);++ assert.strictEqual(imp.json().code, 'subject.not_delegable');++ const owner = await t.api('PUT', `/api/v1/projects/${project.id}/members/app:${appId}`, { as: alice, json: { role: 'owner' } });++ assert.strictEqual(owner.status, 422, 'only a person can own a project');++ });++++ await check('a sandbox credential is refused on a production project', async () => {++ const sandboxTok = t.network.serviceToken('codes', ALL, { env: 'sandbox' });++ const r = await t.api('GET', `/api/v1/projects/${project.id}`, { as: sandboxTok, headers: { 'x-ov-subject': alice.subject } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'environment.sandbox_token');++ const p = await t.api('POST', '/api/v1/projects', { as: sandboxTok, json: { name: 'prod?' }, headers: { 'x-ov-subject': alice.subject } });++ assert.strictEqual(p.status, 403);++ const sb = await t.api('POST', '/api/v1/projects', { as: sandboxTok, json: { name: 'sb', environment: 'sandbox' }, headers: { 'x-ov-subject': alice.subject } });++ assert.strictEqual(sb.status, 201);++ });++++ await check('mods cannot manage hosted sites', async () => {++ const { ids } = require('openvibe-contracts');++ const mod = t.network.sign({ sub: `mod:${ids.newId('mod')}`, actor_type: 'mod', aud: ['openvibe.host'], cap: ALL });++ assert.strictEqual((await t.api('GET', '/api/v1/projects', { as: mod })).status, 403);++ });++++ await check('staff read and delete for abuse handling but cannot publish into a tenant site', async () => {++ assert.strictEqual((await t.api('GET', `/api/v1/projects/${project.id}`, { as: staff })).status, 200);++ const up = await t.upload(staff, site.id, { 'index.html': 'staff content' });++ assert.strictEqual(up.status, 404);++ assert.notStrictEqual((await t.get('quota-site.openvibe.host', '/')).text, 'staff content');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++/**++ * Activation and rollback are one atomic pointer switch: under concurrent traffic every response is++ * one whole deploy (never a mix, never an error); a failure inside the switch changes nothing;++ * artifacts are immutable at the database level; the pointer survives a restart.++ */++const assert = require('assert');++const { boot, check, done } = require('./stageb/boot');++++(async () => {++ const t = await boot();++ const alice = t.user('alice');++ const project = await t.project(alice, 'R');++ const site = await t.site(alice, project.id, 'flip');++ const v1 = await t.deploy(alice, site.id, { 'index.html': 'version-1', 'v.txt': '1' });++ const v2 = await t.deploy(alice, site.id, { 'index.html': 'version-2', 'v.txt': '2', 'only-v2.txt': 'yes' }, { activate: false });++ const version = { [v1.id]: '1', [v2.id]: '2' };++++ await check('under concurrent traffic every response is exactly one deploy while the pointer flips 40 times', async () => {++ let flips = 0;++ let stop = false;++ const flipper = (async () => {++ while (!stop && flips < 40) {++ const target = flips % 2 === 0 ? v2.id : v1.id;++ const r = await t.api('POST', `/api/v1/deploys/${target}/activate`, { as: alice, json: {} });++ assert.strictEqual(r.status, 200, r.text);++ flips++;++ }++ })();++ const seen = { 1: 0, 2: 0 };++ const readers = Array.from({ length: 8 }, async () => {++ for (let i = 0; i < 40; i++) {++ const p = i % 2 ? '/v.txt' : '/';++ const r = await t.get('flip.openvibe.host', p);++ assert.strictEqual(r.status, 200, `${p}: ${r.status}`);++ const v = version[r.headers['x-openvibe-deploy']];++ assert.ok(v, 'the response names a deploy of this site');++ assert.strictEqual(p === '/' ? r.text : `version-${r.text}`, `version-${v}`, 'the bytes belong to the deploy the response names');++ seen[v]++;++ }++ });++ await Promise.all(readers);++ stop = true;++ await flipper;++ assert.ok(flips >= 10, `flipped ${flips} times`);++ assert.ok(seen[1] + seen[2] === 320);++ });++++ await check('a failure inside the switch leaves the pointer, history and outbox untouched', async () => {++ await t.api('POST', `/api/v1/deploys/${v1.id}/activate`, { as: alice, json: {} });++ const db = t.ctx.store.db;++ const before = { active: db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id, acts: db.prepare('SELECT COUNT(*) AS n FROM host_activations').get().n, events: db.prepare('SELECT COUNT(*) AS n FROM event_outbox').get().n };++ const emit = t.ctx.outbox.emit;++ t.ctx.outbox.emit = () => { throw new Error('simulated crash after the pointer moved'); };++ const r = await t.api('POST', `/api/v1/deploys/${v2.id}/activate`, { as: alice, json: {} });++ t.ctx.outbox.emit = emit;++ assert.strictEqual(r.status, 500);++ assert.strictEqual(db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id, before.active);++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM host_activations').get().n, before.acts);++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM event_outbox').get().n, before.events);++ assert.strictEqual((await t.get('flip.openvibe.host', '/')).text, 'version-1');++ });++++ await check('expected_active guards against racing operators (409 with the current pointer)', async () => {++ const r = await t.api('POST', `/api/v1/deploys/${v2.id}/activate`, { as: alice, json: { expected_active: v2.id } });++ assert.strictEqual(r.status, 409);++ assert.strictEqual(r.json().code, 'site.active_changed');++ assert.strictEqual(r.json().active_deploy_id, v1.id);++ const ok = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: { expected_active: v1.id } });++ assert.strictEqual(ok.status, 200);++ assert.strictEqual(ok.json().active_deploy_id, v2.id);++ });++++ await check('rollback with no earlier deploy says so; a failed deploy cannot be activated', async () => {++ const s2 = await t.site(alice, project.id, 'single');++ await t.deploy(alice, s2.id, { 'index.html': 'only' });++ const r = await t.api('POST', `/api/v1/sites/${s2.id}/rollback`, { as: alice, json: {} });++ assert.strictEqual(r.status, 409);++ assert.strictEqual(r.json().code, 'deploy.no_previous');++ const bad = await t.upload(alice, s2.id, { 'x.php': '<?php' });++ const id = bad.json().deploy_id;++ const act = await t.api('POST', `/api/v1/deploys/${id}/activate`, { as: alice, json: {} });++ assert.strictEqual(act.status, 409);++ assert.strictEqual(act.json().code, 'deploy.not_ready');++ });++++ await check('artifacts are immutable in the database itself', async () => {++ const db = t.ctx.store.db;++ assert.throws(() => db.prepare("UPDATE host_deploys SET manifest = '{}' WHERE id = ?").run(v1.id), /immutable/);++ assert.throws(() => db.prepare('UPDATE host_deploys SET total_bytes = 1 WHERE id = ?').run(v1.id), /immutable/);++ assert.throws(() => db.prepare("UPDATE host_deploy_files SET sha256 = 'x' WHERE deploy_id = ?").run(v1.id), /immutable/);++ assert.throws(() => db.prepare('DELETE FROM host_deploy_files WHERE deploy_id = ?').run(v1.id), /live deploy/);++ db.prepare("UPDATE host_deploys SET state = 'deleted' WHERE id = (SELECT id FROM host_deploys WHERE state = 'failed' LIMIT 1)").run();++ assert.throws(() => db.prepare("UPDATE host_deploys SET state = 'ready' WHERE state = 'deleted'").run(), /cannot become ready/);++ });++++ await check('the active pointer survives a restart', async () => {++ await t.restart();++ assert.strictEqual((await t.get('flip.openvibe.host', '/')).text, 'version-2');++ assert.strictEqual((await t.get('flip.openvibe.host', '/only-v2.txt')).text, 'yes');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,142 @@++'use strict';++/**++ * "Deployment secrets never appear in public project state" (Wave 21 exit criterion), and the++ * server-rendered dashboard: shared chrome, forms without JavaScript, CSRF that holds even though++ * tenant sites are same-site, planted cookies ignored, readiness/release/metrics.++ */++const assert = require('assert');++const { boot, check, done, SECRET } = require('./stageb/boot');++const { tarball } = require('./stageb/tar');++const { csrfToken } = require('../server/auth/forms');++++const ORIGIN = 'https://openvibe.host';++++(async () => {++ const t = await boot();++ const alice = t.user('alice');++ const staff = t.user('ops', { role: 'admin' });++ const bodies = [];++ const keep = (r) => { bodies.push(r.text); return r; };++++ await check('no project, site, deploy, domain, quota or log response carries a secret or environment value', async () => {++ const p = keep(await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'Secrets' } })).json().project;++ const s = keep(await t.api('POST', `/api/v1/projects/${p.id}/sites`, { as: alice, json: { name: 'secret-test' } })).json().site;++ const d = keep(await t.upload(alice, s.id, { 'index.html': 'hi', 'app.9f8e7d6c.js': 'x' })).json().deploy;++ const bad = keep(await t.api('POST', `/api/v1/sites/${s.id}/deploys`, { as: alice, body: tarball([{ name: 'index.html', content: 'x' }, { name: '.env', content: 'STRIPE_SECRET_KEY=sk_live_TOPSECRET123\nDATABASE_URL=postgres://u:p@h/db' }]), headers: { 'content-type': 'application/gzip' } }));++ assert.strictEqual(bad.status, 422);++ keep(await t.api('POST', `/api/v1/sites/${s.id}/domains`, { as: alice, json: { hostname: 'www.secret-example.org' } }));++ for (const pth of [`/api/v1/projects`, `/api/v1/projects/${p.id}`, `/api/v1/projects/${p.id}/quota`, `/api/v1/sites/${s.id}`, `/api/v1/sites/${s.id}/deploys`,++ `/api/v1/deploys/${d.id}`, `/api/v1/deploys/${d.id}/log`, `/api/v1/deploys/${bad.json().deploy_id}/log`, `/api/v1/sites/${s.id}/domains`]) {++ keep(await t.api('GET', pth, { as: alice }));++ }++ keep(await t.api('GET', `/api/v1/projects/${p.id}`, { as: staff }));++ for (const pth of ['/api/ready', '/release.json', '/api/health']) keep(await t.api('GET', pth));++ keep(await t.api('GET', '/', { session: alice }));++ keep(await t.api('GET', `/projects/${p.id}`, { session: alice }));++ keep(await t.api('GET', `/sites/${s.id}`, { session: alice }));++ keep(await t.api('GET', `/deploys/${d.id}`, { session: alice }));++ keep(await t.api('GET', `/deploys/${bad.json().deploy_id}`, { session: alice }));++ const all = bodies.join('\n');++ for (const needle of [SECRET, 'test-form-secret', 'sk_live_TOPSECRET123', 'postgres://', 'client_secret', 'OV_OAUTH_CLIENT_SECRET', 'HOST_FORM_SECRET', 'BEGIN PRIVATE', 'privkey', 'password']) {++ assert.ok(!all.includes(needle), `a response contains ${needle}`);++ }++ const deployKeys = Object.keys(d).sort();++ assert.deepStrictEqual(deployKeys, ['active', 'created_at', 'created_by', 'failure_code', 'file_count', 'id', 'log', 'manifest_sha256', 'new_bytes', 'project_id', 'site_id', 'source', 'state', 'total_bytes'].sort());++ const events = JSON.stringify(t.events());++ assert.ok(!events.includes('sk_live') && !events.includes(SECRET));++ });++++ await check('the signed-out home page is server-rendered with the shared chrome and useful without JavaScript', async () => {++ const r = await t.api('GET', '/');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<script src="https:\/\/openvibe\.network\/shared\/navbar\.js" defer>/);++ assert.match(r.text, /<noscript><nav aria-label="Site"/);++ assert.match(r.text, /id="ov-footer"/);++ assert.match(r.text, /Sign in with OpenVibe/);++ assert.match(r.text, /Stage C\) are not available/);++ 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">/);++ });++++ await check('a planted ov_token cookie (e.g. set by a tenant subdomain) is not a session', async () => {++ const r = await t.api('GET', '/', { headers: { cookie: `ov_token=${t.network.userToken(alice)}` } });++ assert.match(r.text, /Sign in with OpenVibe/);++ const me = await t.api('GET', '/auth/me', { headers: { cookie: `ov_token=${t.network.userToken(alice)}` } });++ assert.strictEqual(me.status, 401);++ });++++ let projectId, siteId;++ await check('forms: create a project and a site (Origin + form token), then upload a folder and see the log', async () => {++ const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });++ const p = await t.api('POST', '/projects', { session: alice, form: { csrf, name: 'Dash project', environment: 'production' }, headers: { origin: ORIGIN } });++ assert.strictEqual(p.status, 303, p.text);++ projectId = p.headers.location.match(/\/projects\/(prj_[0-9A-Z]+)/)[1];++ const page = await t.api('GET', `/projects/${projectId}`, { session: alice });++ assert.match(page.text, /Dash project/);++ assert.match(page.text, /Deploys in the last 24 h/);++ const s = await t.api('POST', `/projects/${projectId}/sites`, { session: alice, form: { csrf, name: 'dash-site' }, headers: { origin: ORIGIN } });++ assert.strictEqual(s.status, 303, s.text);++ siteId = s.headers.location.match(/\/sites\/(site_[0-9A-Z]+)/)[1];++ const boundary = 'dashBOUNDARY';++ const part = (name, filename, content) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"${filename ? `; filename="${filename}"` : ''}\r\n\r\n${content}\r\n`;++ const body = part('csrf', null, csrf) + part('strip', null, 'folder') + part('activate', null, '1') + part('files', 'site/index.html', 'DASH OK') + `--${boundary}--\r\n`;++ const up = await t.api('POST', `/sites/${siteId}/deploys`, { session: alice, body, headers: { origin: ORIGIN, 'content-type': `multipart/form-data; boundary=${boundary}` } });++ assert.strictEqual(up.status, 303, up.text);++ assert.match(up.headers.location, /^\/deploys\/dpl_/);++ const log = await t.api('GET', up.headers.location.split('?')[0], { session: alice });++ assert.match(log.text, /Upload log/);++ assert.match(log.text, /nothing was executed/);++ assert.strictEqual((await t.get('dash-site.openvibe.host', '/')).text, 'DASH OK');++ const sitePage = await t.api('GET', `/sites/${siteId}`, { session: alice });++ assert.match(sitePage.text, /Upload a deploy/);++ assert.match(sitePage.text, /webkitdirectory/);++ 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/);++ });++++ await check('forms: domain instructions, rollback, and refusal of cross-origin posts even with a valid token', async () => {++ const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });++ const d = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { csrf, hostname: 'www.dash-example.org' }, headers: { origin: ORIGIN } });++ assert.strictEqual(d.status, 303, d.text);++ const page = await t.api('GET', `/sites/${siteId}`, { session: alice });++ assert.match(page.text, /_openvibe-host\.www\.dash-example\.org/);++ assert.match(page.text, /openvibe-host-verification=[0-9a-f]{40}/);++ assert.match(page.text, /pending: not served/);++ const tenant = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { csrf, hostname: 'evil.example.org' }, headers: { origin: 'https://attacker.openvibe.host' } });++ assert.strictEqual(tenant.status, 403, 'a tenant page on the same site cannot drive the dashboard');++ const noToken = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { hostname: 'evil.example.org' }, headers: { origin: ORIGIN } });++ assert.strictEqual(noToken.status, 403);++ const noOrigin = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { csrf, hostname: 'evil.example.org' } });++ assert.strictEqual(noOrigin.status, 403);++ const anon = await t.api('POST', '/projects', { form: { name: 'x' }, headers: { origin: ORIGIN } });++ assert.strictEqual(anon.status, 303);++ assert.match(anon.headers.location, /^\/auth\/login/);++ });++++ await check('machine endpoints: /api/ready is truthful, /release.json, /metrics only for direct loopback', async () => {++ const ready = await t.api('GET', '/api/ready');++ assert.strictEqual(ready.status, 200, ready.text);++ const body = ready.json();++ assert.strictEqual(body.ready, true);++ const names = body.checks.map ? body.checks.map((c) => c.name) : Object.keys(body.checks);++ for (const n of ['db', 'storage', 'network_jwks', 'events_relay', 'domain_checks']) assert.ok(names.includes(n), n);++ assert.match(ready.text, /relay off/);++ const rel = await t.api('GET', '/release.json');++ assert.strictEqual(rel.json().service, 'host');++ const m = await t.api('GET', '/metrics');++ assert.strictEqual(m.status, 200);++ assert.match(m.text, /http_requests_total\{method="GET",route="tenant_site"/);++ assert.match(m.text, /host_sites \d+/);++ const proxied = await t.api('GET', '/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });++ assert.strictEqual(proxied.status, 404);++ const unknown = await t.get('nothing.example.net', '/');++ assert.strictEqual(unknown.status, 404);++ assert.match(unknown.text, /Unknown host/);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,129 @@++'use strict';++/**++ * Upload validation: path traversal, links and special files, hidden files, server-side code,++ * credentials, bad archives and gzip bombs are refused as a whole. A refused upload is a failed++ * deploy with a log and host.deploy.failed; nothing is stored and the active deploy is unchanged.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const zlib = require('zlib');++const { boot, check, done } = require('./stageb/boot');++const { tarball } = require('./stageb/tar');++++(async () => {++ const t = await boot();++ const alice = t.user('alice');++ const project = await t.project(alice, 'A');++ const site = await t.site(alice, project.id, 'uploads');++ const good = await t.deploy(alice, site.id, { 'index.html': 'GOOD' });++ const projectDir = path.join(t.dir, 'objects', 'projects', project.id);++ const countObjects = () => fs.readdirSync(projectDir).flatMap((d) => fs.readdirSync(path.join(projectDir, d))).length;++ let baseline = countObjects();++++ async function refused(entries, code, { gzip = true, raw = null, contentType } = {}) {++ const before = t.events('host.deploy.failed').length;++ const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys?activate=1`, { as: alice, body: raw || tarball(entries, { gzip }), headers: { 'content-type': contentType || 'application/gzip' } });++ assert.ok([413, 422].includes(r.status), `${code}: status ${r.status} ${r.text}`);++ const body = r.json();++ assert.strictEqual(body.code, code, `expected ${code}, got ${body.code}: ${body.detail}`);++ assert.match(body.deploy_id, /^dpl_/);++ assert.ok(body.log.some((l) => /deploy refused/.test(l)));++ const failed = (await t.api('GET', `/api/v1/deploys/${body.deploy_id}`, { as: alice })).json().deploy;++ assert.strictEqual(failed.state, 'failed');++ assert.strictEqual(failed.failure_code, code);++ assert.strictEqual(failed.file_count, 0);++ assert.strictEqual(t.events('host.deploy.failed').length, before + 1);++ assert.strictEqual((await t.get('uploads.openvibe.host', '/')).text, 'GOOD', 'the active deploy is unchanged');++ assert.strictEqual(countObjects(), baseline, 'nothing was stored');++ return body;++ }++++ await check('path traversal in names: "..", absolute, embedded, backslash, pax and GNU long-name overrides', async () => {++ await refused([{ name: '../evil.html', content: 'x' }], 'deploy.path_traversal');++ await refused([{ name: '/etc/passwd', content: 'x' }], 'deploy.path_traversal');++ await refused([{ name: 'a/../../b.html', content: 'x' }], 'deploy.path_traversal');++ await refused([{ name: 'ok.html', content: 'x' }, { name: 'sub/./x.html', content: 'x' }], 'deploy.path_traversal');++ await refused([{ name: '..\\..\\evil.html', content: 'x' }], 'deploy.invalid_path');++ await refused([{ name: 'innocent.html', content: 'x', pax: { path: '../../etc/cron.d/x.html' } }], 'deploy.path_traversal');++ await refused([{ name: 'innocent.html', content: 'x', longName: `${'a'.repeat(120)}/../../../evil.html` }], 'deploy.path_traversal');++ await refused([{ name: 'a//b.html', content: 'x' }], 'deploy.invalid_path');++ await refused([{ name: 'café.html', content: 'x' }], 'deploy.invalid_path');++ });++++ await check('symbolic links, hard links, devices and FIFOs are refused, never followed or skipped', async () => {++ await refused([{ name: 'index.html', content: 'x' }, { name: 'passwd.html', type: '2', linkname: '/etc/passwd' }], 'deploy.link_refused');++ await refused([{ name: 'up.html', type: '2', linkname: '../../host.db' }], 'deploy.link_refused');++ await refused([{ name: 'index.html', content: 'x' }, { name: 'hard.html', type: '1', linkname: 'index.html' }], 'deploy.link_refused');++ await refused([{ name: 'tty.html', type: '3' }], 'deploy.special_file_refused');++ await refused([{ name: 'disk.html', type: '4' }], 'deploy.special_file_refused');++ await refused([{ name: 'pipe.html', type: '6' }], 'deploy.special_file_refused');++ await refused([{ name: 'x.html', content: 'x', pax: { linkpath: '/etc/passwd' } }], 'deploy.link_refused');++ });++++ await check('hidden files, server-side code, credentials and unknown types are refused with a reason', async () => {++ const env = await refused([{ name: 'index.html', content: 'x' }, { name: '.env', content: 'API_KEY=sk_live_should_never_be_served' }], 'deploy.invalid_path');++ assert.match(env.detail, /hidden file/);++ await refused([{ name: '.git/config', content: '[core]' }], 'deploy.invalid_path');++ const php = await refused([{ name: 'index.php', content: '<?php system($_GET["c"]); ?>' }], 'deploy.file_refused');++ assert.match(php.detail, /never runs anything/);++ await refused([{ name: 'cgi-bin/run.cgi', content: '#!/bin/sh' }], 'deploy.file_refused');++ await refused([{ name: 'page.shtml', content: '<!--#exec cmd="id" -->' }], 'deploy.file_refused');++ await refused([{ name: 'deploy.sh', content: 'rm -rf /' }], 'deploy.file_refused');++ await refused([{ name: 'server.key', content: '-----BEGIN PRIVATE KEY-----' }], 'deploy.file_refused');++ await refused([{ name: 'config.env', content: 'SECRET=1' }], 'deploy.file_refused');++ await refused([{ name: 'tool.exe', content: 'MZ' }], 'deploy.file_refused');++ await refused([{ name: 'thing.xyz', content: '?' }], 'deploy.type_not_allowed');++ const ok = await t.upload(alice, site.id, { '.well-known/security.txt': 'Contact: mailto:[email protected]', 'index.html': 'GOOD2' }, { activate: false });++ assert.strictEqual(ok.status, 201, '.well-known/ is published');++ baseline = countObjects();++ });++++ await check('duplicates, file/directory conflicts, empty uploads and broken archives', async () => {++ await refused([{ name: 'a.html', content: '1' }, { name: './a.html', content: '2' }], 'deploy.duplicate_path');++ await refused([{ name: 'docs', content: '1' }, { name: 'docs/index.html', content: '2' }], 'deploy.path_conflict');++ await refused([], 'deploy.empty');++ await refused(null, 'archive.corrupt', { raw: Buffer.from('this is not an archive at all, not even close'.repeat(20)) });++ await refused(null, 'archive.corrupt', { raw: Buffer.concat([Buffer.from([0x1f, 0x8b]), Buffer.from('garbage')]) });++ const tar = tarball([{ name: 'a.html', content: 'x'.repeat(2000) }], { gzip: false });++ await refused(null, 'archive.corrupt', { raw: zlib.gzipSync(tar.subarray(0, 1200)) });++ const corrupt = Buffer.from(tarball([{ name: 'a.html', content: 'x' }], { gzip: false }));++ corrupt[10] ^= 0xff; // breaks the header checksum++ await refused(null, 'archive.corrupt', { raw: corrupt, contentType: 'application/x-tar' });++ });++++ await check('a gzip bomb stops at the project limit instead of filling memory', async () => {++ await t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: t.user('staff', { role: 'admin' }), json: { storage_bytes: 1024 * 1024 } });++ const bomb = tarball([{ name: 'zeros.txt', content: Buffer.alloc(40 * 1024 * 1024, 0) }]);++ assert.ok(bomb.length < 100 * 1024, 'a small upload');++ const body = await refused(null, 'deploy.too_large', { raw: bomb });++ assert.match(body.detail, /expands beyond|add up to/);++ await t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: t.user('staff2', { role: 'admin' }), json: { storage_bytes: null } });++ });++++ await check('multipart filenames go through the same rules', async () => {++ const boundary = 'xxBOUNDARYxx';++ const part = (filename, content) => `--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="${filename}"\r\nContent-Type: text/html\r\n\r\n${content}\r\n`;++ for (const [name, code] of [['../../escape.html', 'deploy.path_traversal'], ['/etc/x.html', 'deploy.path_traversal'], ['.htaccess', 'deploy.invalid_path'], ['shell.php', 'deploy.file_refused']]) {++ const body = part('index.html', 'x') + part(name, 'y') + `--${boundary}--\r\n`;++ const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } });++ assert.strictEqual(r.status, 422, `${name}: ${r.text}`);++ assert.strictEqual(r.json().code, code, name);++ }++ const wrong = await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body: 'x', headers: { 'content-type': 'text/plain' } });++ assert.strictEqual(wrong.status, 415);++ });++++ await check('the failed deploys and their logs are visible to the owner; the good deploy is still active', async () => {++ const list = (await t.api('GET', `/api/v1/sites/${site.id}/deploys?limit=100`, { as: alice })).json();++ assert.strictEqual(list.active_deploy_id, good.id);++ assert.ok(list.deploys.filter((d) => d.state === 'failed').length >= 25);++ const one = list.deploys.find((d) => d.failure_code === 'deploy.link_refused');++ const log = (await t.api('GET', `/api/v1/deploys/${one.id}/log`, { as: alice })).json().log;++ assert.ok(log.some((l) => l.level === 'error' && /link/.test(l.message)));++ for (const e of t.events('host.deploy.failed')) assert.ok(!JSON.stringify(e).includes('sk_live'), 'no file contents in events');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,141 @@++'use strict';++/**++ * Boots Host Stage B on a temp database and object store, with a controllable clock, a DNS table++ * for custom-domain checks and a mock Network, and returns a raw HTTP client that can send any Host++ * header and any request target (encoded traversal, absolute-form, …).++ *++ * const t = await boot();++ * await t.api('POST', '/api/v1/projects', { as: user, json: { name: 'x' } })++ * await t.get('mysite.openvibe.host', '/index.html')++ * t.dns.set('_openvibe-host.www.example.org', ['openvibe-host-verification=…'])++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { startNetwork } = require('./mocks');++const { site: siteTar } = require('./tar');++++function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {++ let t = start;++ return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };++}++++const SECRET = 'shh-host-secret-value';++const DASHBOARD = 'openvibe.host';++++async function boot(opts = {}) {++ const network = await startNetwork();++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-host-test-'));++ const clock = opts.clock || makeClock();++ const dns = new Map();++ const dnsCalls = [];++ const resolver = {++ async resolveTxt(name) {++ dnsCalls.push(name);++ if (dns.get('__fail__')) { const e = new Error('timeout'); e.code = 'ETIMEOUT'; throw e; }++ if (!dns.has(name)) { const e = new Error(`queryTxt ENOTFOUND ${name}`); e.code = 'ENOTFOUND'; throw e; }++ return dns.get(name).map((v) => [v]);++ },++ };++ const env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: `https://${DASHBOARD}`, TRUST_PROXY: '1',++ HOST_DB_PATH: path.join(dir, 'host.db'), HOST_STORAGE_DIR: path.join(dir, 'objects'),++ HOST_SITES_DOMAIN: 'openvibe.host',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'host', OV_OAUTH_CLIENT_SECRET: SECRET, COOKIE_SECURE: 'false',++ HOST_FORM_SECRET: 'test-form-secret', HOST_WORKER: 'off',++ ...(opts.env || {}),++ };++ const configLib = require('../../server/config');++ const { createApp } = require('../../server/app');++ const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };++++ let server = null;++ let built = null;++ const t = { network, clock, dns, dnsCalls, dir, env, SECRET };++++ async function start() {++ const config = configLib.load(env);++ built = createApp({ config, now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl });++ await built.ctx.auth.ensureKey();++ server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ t.port = server.address().port;++ t.app = built.app;++ t.ctx = built.ctx;++ t.config = config;++ }++ async function stop() {++ if (server) { server.closeAllConnections(); await new Promise((r) => server.close(r)); }++ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.stopMetrics(); built.ctx.store.close(); }++ server = null; built = null;++ }++++ /** Raw request. o: method, host, path, headers, body (Buffer|string), as (user|token), session (user) */++ function request(o) {++ return new Promise((resolve, reject) => {++ const headers = { ...(o.headers || {}) };++ if (o.host !== undefined) headers.host = o.host;++ if (o.as && typeof o.as === 'object') headers.authorization = `Bearer ${network.userToken(o.as)}`;++ if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;++ if (o.session) headers.cookie = [headers.cookie, `ov_host_session=${network.userToken(o.session)}`].filter(Boolean).join('; ');++ let body = o.body;++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ if (body != null && headers['content-length'] == null) headers['content-length'] = Buffer.byteLength(body);++ const req = http.request({ host: '127.0.0.1', port: t.port, method: o.method || 'GET', path: o.path, headers, setHost: o.host === undefined }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => {++ const buf = Buffer.concat(chunks);++ const text = buf.toString('utf8');++ resolve({ status: res.statusCode, headers: res.headers, body: buf, text, json() { return JSON.parse(text); } });++ });++ });++ req.on('error', reject);++ if (body != null) req.write(body);++ req.end();++ });++ }++++ t.request = request;++ t.api = (method, p, o = {}) => request({ ...o, method, host: DASHBOARD, path: p });++ t.get = (host, p, o = {}) => request({ ...o, method: o.method || 'GET', host, path: p });++++ /** Outbox rows as parsed envelopes. */++ t.events = (type = null) => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type);++++ t.user = (name, extra) => network.addUser(name, extra);++ t.project = async (user, name = 'Project', extra = {}) => {++ const r = await t.api('POST', '/api/v1/projects', { as: user, json: { name, ...extra } });++ if (r.status !== 201) throw new Error(`project: ${r.status} ${r.text}`);++ return r.json().project;++ };++ t.site = async (user, projectId, name) => {++ const r = await t.api('POST', `/api/v1/projects/${projectId}/sites`, { as: user, json: { name } });++ if (r.status !== 201) throw new Error(`site: ${r.status} ${r.text}`);++ return r.json().site;++ };++ /** Upload { path: content } as a tar.gz; returns the response. */++ t.upload = (user, siteId, files, { activate = true, query = '', gzip = true, raw = null, contentType = 'application/gzip' } = {}) => t.api('POST', `/api/v1/sites/${siteId}/deploys${activate ? '?activate=1' : '?'}${query ? `&${query}` : ''}`, {++ as: user, body: raw || siteTar(files, { gzip }), headers: { 'content-type': contentType },++ });++ t.deploy = async (user, siteId, files, o) => {++ const r = await t.upload(user, siteId, files, o);++ if (r.status !== 201) throw new Error(`deploy: ${r.status} ${r.text}`);++ return r.json().deploy;++ };++++ t.restart = async () => { await stop(); await start(); };++ t.close = async () => { await stop(); await network.close(); fs.rmSync(dir, { recursive: true, force: true }); };++ await start();++ return t;++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done, makeClock, DASHBOARD, SECRET };nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * In-process stand-in for OpenVibe.Network with a real RS256 key pair: JWKS and /oauth/token++ * (client_credentials → service tokens with the requested scope as capabilities).++ * userToken()/serviceToken()/appToken() mint what browsers, services and apps present to Host.++ */++const http = require('http');++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const { serviceAuth, ids } = require('openvibe-contracts');++++function listen(handler) {++ return new Promise((resolve) => {++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks).toString('utf8');++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ handler(req, raw, json, res);++ });++ });++ server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) }));++ });++}++++async function startNetwork() {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ let issuer = null;++ const srv = await listen((req, raw, json) => {++ if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });++ if (req.url === '/oauth/token' && req.method === 'POST') {++ let body = {};++ if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));++ else { try { body = JSON.parse(raw); } catch { /* */ } }++ if (body.client_secret !== 'shh-host-secret-value') return json(401, { error: 'invalid_client' });++ if (body.grant_type === 'client_credentials') {++ let scope = body.scope;++ if (scope && typeof scope === 'object') scope = Object.values(scope).join(' ');++ const cap = String(scope || '').split(/\s+/).filter(Boolean);++ return json(200, { access_token: sign({ sub: `svc:${body.client_id}`, actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });++ }++ return json(400, { error: 'unsupported_grant_type' });++ }++ return json(404, { error: 'not found' });++ });++ issuer = srv.url;++ function sign({ sub, actor_type = 'service', aud, cap, ...extra }) {++ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...extra }, privatePem);++ }++ function addUser(username, extra = {}) {++ return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };++ }++ function userToken(u) {++ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });++ }++ function serviceToken(client, cap, extra = {}) {++ return sign({ sub: `svc:${client}`, aud: ['openvibe.host'], cap, ...extra });++ }++ function appToken(appId, cap, extra = {}) {++ return sign({ sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.host'], cap, ...extra });++ }++ return { ...srv, publicPem, addUser, userToken, serviceToken, appToken, sign };++}++++module.exports = { startNetwork, listen };nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++/**++ * A test-only tar writer that can produce what a hostile uploader would send: symlinks, hard links,++ * devices, FIFOs, pax path overrides, GNU long names, absolute and ".." names.++ *++ * tarball([{ name, content, type: '0'|'1'|'2'|'3'|'5'|'6'|'x'|'L', linkname, pax: { path } }], { gzip })++ */++const zlib = require('zlib');++++function header(name, size, type = '0', linkname = '') {++ const buf = Buffer.alloc(512, 0);++ const nameBuf = Buffer.from(name, 'utf8');++ nameBuf.copy(buf, 0, 0, Math.min(nameBuf.length, 100));++ buf.write('0000644\0', 100);++ buf.write('0001750\0', 108);++ buf.write('0001750\0', 116);++ buf.write(`${size.toString(8).padStart(11, '0')}\0`, 124);++ buf.write(`${Math.floor(Date.parse('2026-09-22T12:00:00Z') / 1000).toString(8).padStart(11, '0')}\0`, 136);++ buf.write(' ', 148);++ buf.write(type, 156);++ if (linkname) buf.write(linkname, 157);++ buf.write('ustar\0', 257);++ buf.write('00', 263);++ let sum = 0;++ for (const b of buf) sum += b;++ buf.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148);++ return buf;++}++++function block(data) {++ const pad = (512 - (data.length % 512)) % 512;++ return pad ? [data, Buffer.alloc(pad, 0)] : [data];++}++++function paxRecord(key, value) {++ const body = ` ${key}=${value}\n`;++ let len = body.length + 1;++ while (String(len).length + body.length !== len) len = String(len).length + body.length;++ return `${len}${body}`;++}++++function tarball(entries, { gzip = true } = {}) {++ const parts = [];++ for (const e of entries) {++ if (e.pax) {++ const data = Buffer.from(Object.entries(e.pax).map(([k, v]) => paxRecord(k, v)).join(''), 'utf8');++ parts.push(header('PaxHeader', data.length, 'x'), ...block(data));++ }++ if (e.longName) {++ const data = Buffer.from(`${e.longName}\0`, 'utf8');++ parts.push(header('././@LongLink', data.length, 'L'), ...block(data));++ }++ const type = e.type || '0';++ const data = type === '0' ? (Buffer.isBuffer(e.content) ? e.content : Buffer.from(String(e.content == null ? '' : e.content), 'utf8')) : Buffer.alloc(0);++ parts.push(header(e.name, data.length, type, e.linkname || ''), ...block(data));++ }++ parts.push(Buffer.alloc(1024, 0));++ const tar = Buffer.concat(parts);++ return gzip ? zlib.gzipSync(tar) : tar;++}++++/** A small static site: { path: content }. */++function site(files, opts) {++ return tarball(Object.entries(files).map(([name, content]) => ({ name, content })), opts);++}++++module.exports = { tarball, site };
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+# ═══════════════════════════════════════════════════════════════
+# OpenVibe.Host Stage B (tenant static hosting): environment (names only; never commit values)
+# Copy to .env for development. Production: /etc/openvibe/host.env (0600).
+# Stage A (the ovhost CLI) reads no environment beyond OVHOST_INVENTORY.
+# ═══════════════════════════════════════════════════════════════
+
+# Server
+PORT=4910
+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
+
+# 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)
+HOST_SITES_DOMAIN=openvibe.host
+
+# SQLite database and content-addressed objects (the production unit sets both under
+# /var/lib/openvibe-host-api)
+HOST_DB_PATH=./data/host.db
+HOST_STORAGE_DIR=./data/objects
+
+# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# OAuth client `host` (redirect https://openvibe.host/auth/callback). The same client is Host's
+# service principal svc:host for client-credentials grants (see README, "Grants").
+OV_OAUTH_CLIENT_ID=host
+OV_OAUTH_CLIENT_SECRET=
+#OV_OAUTH_REDIRECT_URI=
+# Cookies: Secure (and __Host- prefixed) by default in production; false for plain-http development
+#COOKIE_SECURE=false
+# Signs the dashboard form tokens (CSRF). Unset: random per process.
+HOST_FORM_SECRET=
+
+# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set
+#EVENTS_URL=http://127.0.0.1:4300
+#EVENTS_RELAY_INTERVAL_MS=2000
+
+# Largest request body for one deploy (bytes; nginx allows 110m on the upload routes)
+#HOST_MAX_UPLOAD_BYTES=104857600
+
+# Per-project quota defaults (a staff override per project wins). Production projects:
+#HOST_QUOTA_STORAGE_BYTES=1073741824
+#HOST_QUOTA_DEPLOYS_PER_DAY=50
+#HOST_QUOTA_MAX_FILES=10000
+#HOST_QUOTA_MAX_FILE_BYTES=26214400
+#HOST_QUOTA_SITES=10
+#HOST_QUOTA_CUSTOM_DOMAINS=5
+# Sandbox projects:
+#HOST_SANDBOX_QUOTA_STORAGE_BYTES=104857600
+#HOST_SANDBOX_QUOTA_DEPLOYS_PER_DAY=20
+#HOST_SANDBOX_QUOTA_MAX_FILES=2000
+#HOST_SANDBOX_QUOTA_MAX_FILE_BYTES=10485760
+#HOST_SANDBOX_QUOTA_SITES=3
+#HOST_SANDBOX_QUOTA_CUSTOM_DOMAINS=0
+
+# Custom domains: what the CNAME points at (default <site>.<HOST_SITES_DOMAIN>), origin addresses
+# shown for apex domains, resolvers for TXT checks, timings
+#HOST_CNAME_TARGET=
+#HOST_ORIGIN_IPV4=
+#HOST_ORIGIN_IPV6=
+#HOST_DNS_SERVERS=1.1.1.1,8.8.8.8
+#HOST_DOMAIN_RECHECK_MS=600000
+#HOST_DOMAIN_PENDING_DAYS=7
+#HOST_DOMAIN_LAPSE_DAYS=7
+# Extra domains nobody may claim, and extra reserved site names (comma-separated)
+#HOST_RESERVED_DOMAINS=
+#HOST_RESERVED_SITE_NAMES=
+
+# Background worker (domain checks, orphan-object sweep, outbox prune); off: HOST_WORKER=off
+#HOST_WORKER=on
+#HOST_WORKER_ID=
+
+# Browser origins that may call /api/v1 with a Bearer Network JWT (never cookies)
+#API_CORS_ORIGINS=https://openvibe.network,https://openvibe.codes
+
+# release.json (openvibe-shared/release): the deployed commit when git is not available
+#RELEASE_COMMIT=
+#MIN_CLIENT_RELEASE=
@@ -10,3 +10,5 @@ host.json
@@ -10,3 +10,5 @@ host.json
*.key
*.key
.env
.env
.env.*
.env.*
+data/
+!.env.example
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+{
+ "id": "host.deploy.create",
+ "version": "1.0",
+ "owner": "host",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Upload an immutable static deploy (tar/tar.gz or multipart files; validated for path traversal, links, special and hidden files, server-side code, file types and quotas; never executed), list deploys and their upload logs, activate a deploy and roll a site back to any earlier ready deploy (one atomic pointer switch). Also judged by the acting principal's project role (deployer or above); an app principal added to a project as deployer can deploy from CI.",
+ "permissions": [
+ "host:deploy"
+ ],
+ "resourceConstraints": [
+ "project"
+ ],
+ "quotaClass": "host-deploy",
+ "events": [
+ "host.deploy.created",
+ "host.deploy.activated",
+ "host.deploy.failed"
+ ],
+ "implementedBy": [
+ "GET /api/v1/sites/:id/deploys",
+ "POST /api/v1/sites/:id/deploys",
+ "GET /api/v1/deploys/:id",
+ "GET /api/v1/deploys/:id/log",
+ "POST /api/v1/deploys/:id/activate",
+ "POST /api/v1/sites/:id/rollback"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "id": "host.domain.manage",
+ "version": "1.0",
+ "owner": "host",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Add a custom domain to a static site (never an OpenVibe domain; not in sandbox projects), read its DNS TXT verification and routing records, trigger a verification check, and remove it. A custom domain is served only after its TXT record is verified; TLS certificates are issued by an operator and never pass through this capability.",
+ "permissions": [
+ "host:write"
+ ],
+ "resourceConstraints": [
+ "project"
+ ],
+ "quotaClass": "host-manage",
+ "events": [
+ "host.domain.verified"
+ ],
+ "implementedBy": [
+ "GET /api/v1/sites/:id/domains",
+ "POST /api/v1/sites/:id/domains",
+ "POST /api/v1/domains/:id/verify",
+ "DELETE /api/v1/domains/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+{
+ "id": "host.site.manage",
+ "version": "1.0",
+ "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.",
+ "permissions": [
+ "host:write"
+ ],
+ "resourceConstraints": [
+ "project"
+ ],
+ "quotaClass": "host-manage",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/projects",
+ "POST /api/v1/projects",
+ "GET /api/v1/projects/:id",
+ "DELETE /api/v1/projects/:id",
+ "GET /api/v1/projects/:id/quota",
+ "PUT /api/v1/projects/:id/quota",
+ "PUT /api/v1/projects/:id/members/:principal",
+ "DELETE /api/v1/projects/:id/members/:principal",
+ "GET /api/v1/projects/:id/sites",
+ "POST /api/v1/projects/:id/sites",
+ "GET /api/v1/sites/:id",
+ "DELETE /api/v1/sites/:id",
+ "DELETE /api/v1/deploys/:id"
+ ]
+}
@@ -1,18 +1,32 @@
@@ -1,18 +1,32 @@
{
{
- "version": "0.1.0",
+ "version": "0.2.0",
"contractRanges": {
"contractRanges": {
- "openvibe-contracts": ">=0.7.0 <1.0.0"
+ "openvibe-contracts": ">=0.10.0 <1.0.0"
},
},
"id": "host",
"id": "host",
"name": "OpenVibe.Host",
"name": "OpenVibe.Host",
"status": "alpha",
"status": "alpha",
"repository": "OpenVibers/OpenVibe.Host",
"repository": "OpenVibers/OpenVibe.Host",
- "domains": [],
- "capabilities": [],
- "eventsProduced": [],
+ "domains": [
+ "openvibe.host"
+ ],
+ "publicOrigin": "https://openvibe.host",
+ "health": "/api/health",
+ "ready": "/api/ready",
+ "capabilities": [
+ "host.site.manage",
+ "host.deploy.create",
+ "host.domain.manage"
+ ],
+ "eventsProduced": [
+ "host.deploy.created",
+ "host.deploy.activated",
+ "host.deploy.failed",
+ "host.domain.verified"
+ ],
"eventsConsumed": [],
"eventsConsumed": [],
"namespacesOwned": [
"namespacesOwned": [
"host.*"
"host.*"
],
],
- "notes": "Stage A operator plane only: the ovhost CLI, run by an operator on the host (validate, plan, deploy with protected-session guards, automatic rollback, status, certificate inventory, vhost rendering, config snapshots, SQLite backups). No server and no public API yet; port 4910 is reserved for the later operator API. Stages B (tenant static hosting) and C (sandboxed user code) are not started."
+ "notes": "Stage A: the ovhost operator CLI (no server). Stage B: the Host API service on port 4910 (unit openvibe-host.service, state /var/lib/openvibe-host-api): tenant projects keyed by project id (prj_, network_project_id once Network has projects, ADR-014), static sites served by Host header at <site>.openvibe.host (a registrable domain separate from every other OpenVibe domain) and on TXT-verified custom domains, immutable content-addressed deploys with rollback, quotas enforced here, upload logs. Consumes OpenVibe.Network (SSO, JWKS, client-credentials) and publishes through OpenVibe.Events (events.event.publish). Stage C (sandboxed user code) is not started."
}
}
@@ -1,22 +1,45 @@
@@ -1,22 +1,45 @@
{
{
"name": "openvibe-host",
"name": "openvibe-host",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-host",
"name": "openvibe-host",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.10.0"
+ "busboy": "^1.6.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
"bin": {
"bin": {
"ovhost": "bin/ovhost"
"ovhost": "bin/ovhost"
},
},
+ "devDependencies": {
+ "jsonwebtoken": "^9.0.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
"engines": {
"engines": {
- "node": ">=20.0.0"
+ "node": ">= 0.6"
}
}
},
},
"node_modules/ajv": {
"node_modules/ajv": {
@@ -52,6 +75,12 @@
@@ -52,6 +75,12 @@
}
}
}
}
},
},
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
"node_modules/base64-js": {
"node_modules/base64-js": {
"version": "1.5.1",
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -103,6 +132,30 @@
@@ -103,6 +132,30 @@
"readable-stream": "^3.4.0"
"readable-stream": "^3.4.0"
}
}
},
},
+ "node_modules/body-parser": {
+ "version": "1.20.8",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
+ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
"node_modules/buffer": {
"node_modules/buffer": {
"version": "5.7.1",
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -127,12 +180,125 @@
@@ -127,12 +180,125 @@
"ieee754": "^1.1.13"
"ieee754": "^1.1.13"
}
}
},
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/chownr": {
"node_modules/chownr": {
"version": "1.1.4",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
"node_modules/decompress-response": {
"node_modules/decompress-response": {
"version": "6.0.0",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -157,6 +323,25 @@
@@ -157,6 +323,25 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
"node_modules/detect-libc": {
"node_modules/detect-libc": {
"version": "2.1.2",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -166,6 +351,56 @@
@@ -166,6 +351,56 @@
"node": ">=8"
"node": ">=8"
}
}
},
},
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/end-of-stream": {
"node_modules/end-of-stream": {
"version": "1.4.5",
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -175,6 +410,51 @@
@@ -175,6 +410,51 @@
"once": "^1.4.0"
"once": "^1.4.0"
}
}
},
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/expand-template": {
"node_modules/expand-template": {
"version": "2.0.3",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -184,6 +464,67 @@
@@ -184,6 +464,67 @@
"node": ">=6"
"node": ">=6"
}
}
},
},
+ "node_modules/express": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
"node_modules/fast-deep-equal": {
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -212,18 +553,168 @@
@@ -212,18 +553,168 @@
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/fs-constants": {
"node_modules/fs-constants": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/github-from-package": {
"node_modules/github-from-package": {
"version": "0.0.0",
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ieee754": {
"node_modules/ieee754": {
"version": "1.2.1",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -256,12 +747,181 @@
@@ -256,12 +747,181 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/json-schema-traverse": {
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/mimic-response": {
"node_modules/mimic-response": {
"version": "3.1.0",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@@ -289,12 +949,27 @@
@@ -289,12 +949,27 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
"node_modules/napi-build-utils": {
"node_modules/napi-build-utils": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/node-abi": {
"node_modules/node-abi": {
"version": "3.96.0",
"version": "3.96.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
@@ -307,6 +982,30 @@
@@ -307,6 +982,30 @@
"node": ">=10"
"node": ">=10"
}
}
},
},
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/once": {
"node_modules/once": {
"version": "1.4.0",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -317,9 +1016,9 @@
@@ -317,9 +1016,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.10.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.10.0",
- "integrity": "sha512-Wbl2Lgb/7L4BxZvha91Q+sTxebaN0swOHfaIHnQoILZBIsEpqq2HvE2klJIuPefttZA/11jmUR4nCeQX2FI0yQ==",
+ "version": "0.19.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
+ "integrity": "sha512-5DjPvXIfc68wZhFXgKlCzDarbbFD5dUV4yYQIKEK1qD+VIuR+4twbON1brdyM9rYZyRyYClPigAqReocmRGytg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -332,6 +1031,39 @@
@@ -332,6 +1031,39 @@
"node": ">=20"
"node": ">=20"
}
}
},
},
+ "node_modules/openvibe-sdk": {
+ "version": "0.2.2",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "1.3.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
+ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -359,6 +1091,23 @@
@@ -359,6 +1091,23 @@
"node": ">=10"
"node": ">=10"
}
}
},
},
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/pump": {
"node_modules/pump": {
"version": "3.0.4",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -369,6 +1118,46 @@
@@ -369,6 +1118,46 @@
"once": "^1.3.1"
"once": "^1.3.1"
}
}
},
},
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/rc": {
"node_modules/rc": {
"version": "1.2.8",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -427,6 +1216,12 @@
@@ -427,6 +1216,12 @@
],
],
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
"node_modules/semver": {
"node_modules/semver": {
"version": "7.8.5",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -439,6 +1234,129 @@
@@ -439,6 +1234,129 @@
"node": ">=10"
"node": ">=10"
}
}
},
},
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/simple-concat": {
"node_modules/simple-concat": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -484,6 +1402,23 @@
@@ -484,6 +1402,23 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/string_decoder": {
"node_modules/string_decoder": {
"version": "1.3.0",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -530,6 +1465,15 @@
@@ -530,6 +1465,15 @@
"node": ">=6"
"node": ">=6"
}
}
},
},
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/tunnel-agent": {
"node_modules/tunnel-agent": {
"version": "0.6.0",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -542,12 +1486,52 @@
@@ -542,12 +1486,52 @@
"node": "*"
"node": "*"
}
}
},
},
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/util-deprecate": {
"node_modules/util-deprecate": {
"version": "1.0.2",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/wrappy": {
"node_modules/wrappy": {
"version": "1.0.2",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1,24 +1,43 @@
@@ -1,24 +1,43 @@
{
{
"name": "openvibe-host",
"name": "openvibe-host",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
- "description": "OpenVibe.Host Stage A: the operator plane. The ovhost CLI validates, deploys, rolls back, snapshots and backs up the services on the OpenVibe host, and encodes the rules that keep live streams and recordings alive through a deploy.",
+ "description": "OpenVibe.Host: the operator plane (Stage A, the ovhost CLI) and tenant static hosting (Stage B, the Host API service on port 4910): immutable content-addressed deploys, per-site domains, quotas and rollback.",
"main": "lib/index.js",
"main": "lib/index.js",
"bin": {
"bin": {
"ovhost": "bin/ovhost"
"ovhost": "bin/ovhost"
},
},
"scripts": {
"scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js"
"test": "node test/run.js"
},
},
- "keywords": ["openvibe", "deploy", "systemd", "nginx", "operator"],
+ "keywords": [
+ "openvibe",
+ "deploy",
+ "systemd",
+ "nginx",
+ "operator",
+ "static-hosting"
+ ],
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Host",
"repository": "github:OpenVibers/OpenVibe.Host",
"dependencies": {
"dependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.10.0"
+ "busboy": "^1.6.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
"engines": {
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "jsonwebtoken": "^9.0.2"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+/* OpenVibe.Host dashboard. Colours follow the shared theme tokens when theme-loader.js has run. */
+:root { --hs-bg: var(--bg-primary, #0a0f1c); --hs-fg: var(--text-primary, #e6edf7); --hs-muted: var(--text-secondary, #9aa7bd);
+ --hs-card: var(--bg-secondary, #111a2c); --hs-line: rgba(255,255,255,.12); --hs-accent: var(--accent, #60a5fa);
+ --hs-ok: #34d399; --hs-warn: #fbbf24; --hs-bad: #f87171; }
+body { margin: 0; background: var(--hs-bg); color: var(--hs-fg); font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
+a { color: var(--hs-accent); }
+.skip { position: absolute; left: -9999px; } .skip:focus { left: 1rem; top: 1rem; }
+.account-bar { max-width: 72rem; margin: 0 auto; padding: .5rem 1rem; text-align: right; color: var(--hs-muted); font-size: 14px; }
+.page { max-width: 72rem; margin: 0 auto; padding: 0 1rem 3rem; }
+h1 { font-size: 1.7rem; margin: .5rem 0 .25rem; } h2 { font-size: 1.2rem; margin-top: 2rem; } h3 { font-size: 1rem; }
+.meta, .muted, .crumbs { color: var(--hs-muted); } .crumbs { margin: 0; font-size: 14px; }
+code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; overflow-wrap: anywhere; }
+table { width: 100%; border-collapse: collapse; margin: .5rem 0 1rem; font-size: 14px; }
+th, td { text-align: left; padding: .4rem .5rem; border-bottom: 1px solid var(--hs-line); vertical-align: top; }
+tr.active td { background: rgba(96,165,250,.08); }
+form { margin: .5rem 0 1rem; display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: end; }
+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); }
+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); }
+.notice { padding: .6rem .8rem; border-radius: 6px; background: var(--hs-card); border-left: 4px solid var(--hs-accent); }
+.notice-error { border-left-color: var(--hs-bad); }
+.ok { color: var(--hs-ok); } .warn { color: var(--hs-warn); }
+ul.log { list-style: none; padding: 0; font-family: ui-monospace, monospace; font-size: 13px; background: var(--hs-card); border-radius: 8px; padding: .75rem; }
+ul.log .level { display: inline-block; width: 3.5em; color: var(--hs-muted); } .log-error { color: var(--hs-bad); } .log-warn { color: var(--hs-warn); }
+ul.domains { padding-left: 1.2rem; } ul.domains li { margin-bottom: 1rem; }
+.intro { max-width: 44rem; }
+@media (max-width: 40rem) { table { display: block; overflow-x: auto; } .account-bar { text-align: left; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+'use strict';
+
+/**
+ * OpenVibe.Host Stage B: Express app factory. server/index.js listens and starts the workers;
+ * tests build their own instance with a temp database and storage, an injectable clock, a DNS
+ * resolver table and a mock Network.
+ *
+ * Every request is dispatched on its Host header FIRST:
+ *
+ * <site>.<sitesDomain> / verified custom domain → tenant static files (http/tenant.js) and
+ * nothing else: no API, no auth, no cookies
+ * the dashboard host (BASE_URL) and loopback → dashboard pages, /api/v1, /auth, /api/ready,
+ * /release.json, /metrics (loopback only)
+ * anything else → 404 unknown host
+ */
+const path = require('path');
+const express = require('express');
+const cookieParser = require('cookie-parser');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const sharedMetrics = require('openvibe-shared/metrics');
+
+const configLib = require('./config');
+const { openStore } = require('./db');
+const { createBlobStore } = require('./storage');
+const { createHostOutbox } = require('./events/outbox');
+const { createAccess } = require('./domain/access');
+const { createProjects } = require('./domain/projects');
+const { createSites } = require('./domain/sites');
+const { createDeploys } = require('./domain/deploys');
+const { createDomains } = require('./domain/domains');
+const { createAuthClient, createAuthRoutes } = require('./auth/sso');
+const { createViewerResolver } = require('./auth/viewer');
+const { createTenantServer } = require('./http/tenant');
+const { createApi } = require('./http/api');
+const { createDashboard } = require('./http/dashboard');
+const { createHostReadiness } = require('./observability');
+const { createWorker } = require('./worker');
+const { renderPage, assetVersion } = require('./render/layout');
+const pages = require('./render/pages');
+
+const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+const VERSION = require('../package.json').version;
+
+const DASHBOARD_CSP = {
+ 'default-src': ["'self'"],
+ // The shared chrome (theme-loader, navbar) comes from the Network; the inline init is ours.
+ 'script-src': ["'self'", "'unsafe-inline'", 'https://openvibe.network'],
+ 'style-src': ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
+ 'font-src': ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
+ 'img-src': ["'self'", 'data:', 'https:'],
+ 'connect-src': ["'self'", 'https://openvibe.network'],
+ 'frame-src': ["'self'", 'https://openvibe.network'],
+ 'frame-ancestors': ["'none'"],
+ 'object-src': ["'none'"],
+ 'base-uri': ["'self'"],
+ 'form-action': ["'self'", 'https://openvibe.network'],
+};
+const DASHBOARD_CSP_HEADER = Object.entries(DASHBOARD_CSP).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ');
+
+/**
+ * opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object),
+ * resolver ({ resolveTxt }), log
+ */
+function createApp(opts = {}) {
+ const config = opts.config || configLib.load();
+ const log = opts.log || console;
+ const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
+ const blobs = createBlobStore(opts.storageDir || config.storageDir);
+ require('./http/errors').setLogger(log);
+
+ 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 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 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 app = express();
+ app.disable('x-powered-by');
+ app.disable('etag');
+ app.set('trust proxy', config.trustProxy);
+
+ const release = require('openvibe-shared/release').createRelease({ service: 'host', root: path.join(__dirname, '..') });
+ const registry = sharedMetrics.createRegistry();
+ const httpMetrics = sharedMetrics.httpMetrics(registry, { normalize: (req) => (req.hostTarget === 'site' ? 'tenant_site' : req.hostTarget === 'unknown' ? 'unknown_host' : null) });
+ const proc = sharedMetrics.processMetrics(registry);
+ sharedMetrics.releaseInfo(registry, { service: 'host', release: release.release });
+ registry.gauge({
+ name: 'host_sites', help: 'Active tenant sites',
+ collect: () => [{ labels: {}, value: store.db.prepare("SELECT COUNT(*) AS n FROM host_sites WHERE status = 'active'").get().n }],
+ });
+ ctx.stopMetrics = proc.stop;
+ app.locals.metrics = registry;
+ app.locals.ctx = ctx;
+
+ app.use(httpMetrics.middleware);
+
+ // ── Host dispatch: tenant sites never reach anything below ──
+ app.use((req, res, next) => {
+ const target = tenant.resolve(req.headers.host);
+ req.hostTarget = target.kind;
+ if (target.kind === 'dashboard') return next();
+ if (target.kind === 'site') return tenant.handle(req, res, target.site);
+ return tenant.unknownHost(req, res);
+ });
+
+ app.use(contracts.http.middleware());
+ app.use((req, res, next) => {
+ res.setHeader('Content-Security-Policy', DASHBOARD_CSP_HEADER);
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
+ res.setHeader('X-Frame-Options', 'DENY');
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
+ next();
+ });
+ app.use(cookieParser());
+
+ // ── Machine endpoints ───────────────────────────────────
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-host', version: VERSION }));
+ app.get('/release.json', release.handler);
+ 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'));
+
+ // ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
+ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
+ app.use('/auth', createAuthRoutes(config, auth));
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'host', service: 'host', host: 'openvibe.host', name: 'OpenVibe.Host', profile: 'ugc' })); }
+
+ // ── Static assets (content-hashed ?v= → immutable) ──────
+ app.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false, etag: true,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
+ },
+ }));
+
+ // ── API ─────────────────────────────────────────────────
+ app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
+
+ // ── Dashboard ───────────────────────────────────────────
+ app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
+ app.use((req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });
+ res.status(404).type('html').send(renderPage({ title: 'Not found', body: pages.errorPage({ status: 404, message: 'There is no page at this address.' }), viewer: req.viewer, config, path: req.originalUrl }));
+ });
+
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ log.error('[Host]', err && err.stack ? err.stack : err);
+ if (res.headersSent) return;
+ res.set('Cache-Control', 'private, no-store');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+module.exports = { createApp, DASHBOARD_CSP_HEADER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+'use strict';
+/**
+ * Reading an uploaded deploy: a tar or tar.gz archive (ustar, pax and GNU long names), or a set
+ * of multipart files. Both end as the same list of { path, data } entries, checked by validate.js.
+ *
+ * The reader never touches the filesystem: entries are parsed from a Buffer and nothing is
+ * extracted. Symbolic links, hard links, devices, FIFOs, sparse and other special entries are
+ * refused (the whole deploy fails), never skipped silently. Decompression is capped
+ * (maxOutputLength), so a gzip bomb stops at the limit instead of filling memory.
+ */
+const zlib = require('zlib');
+
+class ArchiveError extends Error {
+ constructor(code, message, status = 422) { super(message); this.code = code; this.status = status; }
+}
+
+const BLOCK = 512;
+
+function str(buf, start, len) {
+ const s = buf.subarray(start, start + len);
+ const z = s.indexOf(0);
+ return (z >= 0 ? s.subarray(0, z) : s).toString('utf8');
+}
+
+function octal(buf, start, len, what) {
+ if (buf[start] & 0x80) throw new ArchiveError('archive.unsupported', `${what} uses base-256 encoding (entries over 8 GB are not supported)`);
+ const s = str(buf, start, len).trim();
+ if (!s) return 0;
+ if (!/^[0-7]+$/.test(s)) throw new ArchiveError('archive.corrupt', `${what} is not an octal number`);
+ return parseInt(s, 8);
+}
+
+function checksumOk(header) {
+ const stored = octal(header, 148, 8, 'header checksum');
+ let sum = 0;
+ for (let i = 0; i < BLOCK; i++) sum += (i >= 148 && i < 156) ? 32 : header[i];
+ return sum === stored;
+}
+
+function parsePax(data) {
+ const out = {};
+ let off = 0;
+ const text = data.toString('utf8');
+ while (off < text.length) {
+ const sp = text.indexOf(' ', off);
+ if (sp < 0) break;
+ const len = parseInt(text.slice(off, sp), 10);
+ if (!Number.isFinite(len) || len <= 0) throw new ArchiveError('archive.corrupt', 'malformed pax header');
+ const record = text.slice(sp + 1, off + len - 1);
+ const eq = record.indexOf('=');
+ if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
+ off += len;
+ }
+ return out;
+}
+
+const TYPE_NAMES = { 1: 'a hard link', 2: 'a symbolic link', 3: 'a character device', 4: 'a block device', 6: 'a FIFO', 7: 'a contiguous file', K: 'a GNU long link name', S: 'a sparse file', V: 'a volume header', M: 'a multi-volume entry', N: 'an old GNU long name' };
+
+/**
+ * buf: the uploaded bytes (gzip or plain tar). limits: { maxTotalBytes, maxFiles, maxFileBytes }.
+ * -> { entries: [{ path, data }], directories: n, compressed: bool }
+ */
+function readArchive(buf, limits) {
+ let tar = buf;
+ const compressed = buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b;
+ if (compressed) {
+ const cap = limits.maxTotalBytes + (limits.maxFiles + 16) * 3 * BLOCK + 1024 * 1024;
+ try {
+ tar = zlib.gunzipSync(buf, { maxOutputLength: cap });
+ } catch (err) {
+ if (err && (err.code === 'ERR_BUFFER_TOO_LARGE' || err instanceof RangeError)) {
+ throw new ArchiveError('deploy.too_large', 'the archive expands beyond the size this project may deploy', 413);
+ }
+ throw new ArchiveError('archive.corrupt', 'not a valid gzip stream');
+ }
+ }
+ if (tar.length < BLOCK) throw new ArchiveError('archive.corrupt', 'not a tar archive (too short)');
+
+ const entries = [];
+ let directories = 0;
+ let total = 0;
+ let off = 0;
+ let pending = {}; // pax / GNU long-name overrides for the next entry
+ let sawEnd = false;
+ while (off + BLOCK <= tar.length) {
+ const header = tar.subarray(off, off + BLOCK);
+ if (header.every((b) => b === 0)) { sawEnd = true; break; }
+ if (!checksumOk(header)) throw new ArchiveError('archive.corrupt', `bad header checksum at byte ${off}`);
+ const type = String.fromCharCode(header[156] || 48); // NUL means a regular file
+ const size = octal(header, 124, 12, 'entry size');
+ const magic = str(header, 257, 6);
+ let name = str(header, 0, 100);
+ if (magic.startsWith('ustar')) {
+ const prefix = str(header, 345, 155);
+ if (prefix) name = `${prefix}/${name}`;
+ }
+ const dataStart = off + BLOCK;
+ const dataEnd = dataStart + size;
+ if (dataEnd > tar.length) throw new ArchiveError('archive.corrupt', 'truncated archive');
+ const data = tar.subarray(dataStart, dataEnd);
+ off = dataStart + Math.ceil(size / BLOCK) * BLOCK;
+
+ if (type === 'x') { Object.assign(pending, parsePax(data)); continue; }
+ if (type === 'g') continue; // global pax header: nothing we use
+ if (type === 'L') { pending.path = str(data, 0, data.length); continue; }
+ if (pending.path != null) name = pending.path;
+ if (pending.linkpath != null && type !== '1' && type !== '2') throw new ArchiveError('deploy.link_refused', `"${name}" carries a link target`);
+ pending = {};
+
+ if (type === '1' || type === '2') throw new ArchiveError('deploy.link_refused', `"${name}" is ${TYPE_NAMES[type]}: links are refused (a deploy holds regular files only)`);
+ if (type === '5') { directories++; continue; }
+ if (type !== '0') throw new ArchiveError('deploy.special_file_refused', `"${name}" is ${TYPE_NAMES[type] || `an entry of type "${type}"`}: only regular files are accepted`);
+
+ // A regular file.
+ if (entries.length >= limits.maxFiles) throw new ArchiveError('quota.max_files', `more than ${limits.maxFiles} files`, 413);
+ if (size > limits.maxFileBytes) throw new ArchiveError('quota.max_file_bytes', `"${name}" is ${size} bytes; the limit per file is ${limits.maxFileBytes}`, 413);
+ total += size;
+ if (total > limits.maxTotalBytes) throw new ArchiveError('deploy.too_large', `the files add up to more than ${limits.maxTotalBytes} bytes`, 413);
+ entries.push({ path: name, data: Buffer.from(data) });
+ }
+ if (!sawEnd && off < tar.length) throw new ArchiveError('archive.corrupt', 'trailing garbage after the last entry');
+ return { entries, directories, compressed };
+}
+
+/**
+ * Normalise archive/multipart names before validation: tar's "./" prefix and directory-style
+ * names. Anything else (absolute paths, "..", backslashes) is left for the validator to refuse.
+ */
+function normaliseName(name) {
+ let n = String(name);
+ while (n.startsWith('./')) n = n.slice(2);
+ return n;
+}
+
+/**
+ * Deploy from a subdirectory: keep entries under `root/`, strip the prefix, report what was
+ * dropped. root must itself be a clean relative path.
+ */
+function selectRoot(entries, root) {
+ if (!root) return { entries, dropped: 0 };
+ const prefix = `${root.replace(/\/+$/, '')}/`;
+ const kept = [];
+ let dropped = 0;
+ for (const e of entries) {
+ if (e.path.startsWith(prefix)) kept.push({ ...e, path: e.path.slice(prefix.length) });
+ else dropped++;
+ }
+ return { entries: kept, dropped };
+}
+
+/** A browser folder upload names every file "<folder>/…": strip that single shared top folder. */
+function stripSharedTop(entries) {
+ if (!entries.length) return { entries, stripped: null };
+ const firsts = new Set(entries.map((e) => (e.path.includes('/') ? e.path.split('/')[0] : null)));
+ if (firsts.size !== 1 || firsts.has(null)) return { entries, stripped: null };
+ const top = [...firsts][0];
+ return { entries: entries.map((e) => ({ ...e, path: e.path.slice(top.length + 1) })), stripped: top };
+}
+
+module.exports = { readArchive, normaliseName, selectRoot, stripSharedTop, ArchiveError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * The rules every file path in a deploy obeys, and the content types Host serves.
+ *
+ * Paths are relative, '/'-separated, ASCII, and made of plain segments: no '.', '..', empty
+ * segment, backslash, NUL or control character, and no hidden segment except the leading
+ * `.well-known/`. The same rules are applied to request paths after percent-decoding, and serving
+ * looks paths up in the deploy's manifest (a database table) — never on the filesystem.
+ *
+ * Content types come from an allowlist keyed by extension. Nothing is ever executed: Stage B has no
+ * build step and no server-side code, and files that are server-side code (php, cgi, asp, jsp,
+ * shtml, …) are refused rather than served as text, so nobody deploys them believing they run.
+ */
+
+const MAX_PATH = 1024;
+const MAX_SEGMENT = 255;
+const MAX_DEPTH = 32;
+const SEGMENT_RE = /^[A-Za-z0-9._@+~()!,= -]+$/;
+
+class PathError extends Error {
+ constructor(code, message) { super(message); this.code = code; }
+}
+
+/** -> normalized path, or throws PathError. `allowDir` accepts a trailing '/'. */
+function checkPath(input) {
+ if (typeof input !== 'string' || !input) throw new PathError('path.empty', 'empty path');
+ if (input.length > MAX_PATH) throw new PathError('path.too_long', `path longer than ${MAX_PATH} characters`);
+ // eslint-disable-next-line no-control-regex
+ if (/[\u0000-\u001f\u007f]/.test(input)) throw new PathError('path.control_character', 'path contains a control character');
+ if (input.includes('\\')) throw new PathError('path.backslash', 'path contains a backslash');
+ if (/[^ -~]/.test(input)) throw new PathError('path.non_ascii', 'path contains a non-ASCII character');
+ if (input.startsWith('/')) throw new PathError('path.absolute', 'absolute path');
+ const segments = input.split('/');
+ if (segments.length > MAX_DEPTH) throw new PathError('path.too_deep', `more than ${MAX_DEPTH} levels`);
+ segments.forEach((s, i) => {
+ if (s === '' ) throw new PathError('path.empty_segment', 'empty path segment ("//" or a trailing "/")');
+ if (s === '.' || s === '..') throw new PathError('path.traversal', `"${s}" segment`);
+ if (s.length > MAX_SEGMENT) throw new PathError('path.too_long', 'path segment longer than 255 characters');
+ if (!SEGMENT_RE.test(s)) throw new PathError('path.invalid_character', `segment "${s}" has a character outside [A-Za-z0-9._@+~()!,= -]`);
+ if (s.startsWith('.') && !(i === 0 && s === '.well-known' && segments.length > 1)) {
+ throw new PathError('path.hidden', `hidden file or directory "${s}" (only .well-known/ is published)`);
+ }
+ if (s.endsWith(' ') || s.startsWith(' ')) throw new PathError('path.invalid_character', `segment "${s}" starts or ends with a space`);
+ });
+ return input;
+}
+
+// Server-side code and credentials: refused with an explicit reason.
+const REFUSED = new Map([
+ ...['php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'cgi', 'fcgi', 'pl', 'pm', 'py', 'pyc', 'rb', 'sh', 'bash', 'zsh', 'ksh', 'csh',
+ 'asp', 'aspx', 'ascx', 'ashx', 'asmx', 'jsp', 'jspx', 'cfm', 'cfc', 'shtml', 'shtm', 'stm', 'ssi', 'lua', 'ps1', 'psm1', 'bat', 'cmd',
+ 'exe', 'dll', 'so', 'dylib', 'bin', 'elf', 'jar', 'war', 'ear', 'class', 'htaccess', 'htpasswd']
+ .map((e) => [e, 'server-side code or an executable: Host serves static files only and never runs anything']),
+ ...['pem', 'key', 'p12', 'pfx', 'jks', 'keystore', 'env', 'npmrc', 'pgpass', 'netrc', 'kdbx', 'sqlite', 'sqlite3', 'db']
+ .map((e) => [e, 'looks like a credential or a database: never publish secrets in a static site']),
+]);
+
+const TYPES = {
+ html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
+ css: 'text/css; charset=utf-8',
+ js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', cjs: 'text/javascript; charset=utf-8',
+ json: 'application/json; charset=utf-8', map: 'application/json; charset=utf-8',
+ webmanifest: 'application/manifest+json; charset=utf-8',
+ txt: 'text/plain; charset=utf-8', md: 'text/markdown; charset=utf-8', csv: 'text/csv; charset=utf-8',
+ xml: 'application/xml; charset=utf-8', rss: 'application/rss+xml; charset=utf-8', atom: 'application/atom+xml; charset=utf-8',
+ vtt: 'text/vtt; charset=utf-8',
+ svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
+ avif: 'image/avif', ico: 'image/x-icon', bmp: 'image/bmp',
+ woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
+ mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', m4v: 'video/mp4',
+ mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', m4a: 'audio/mp4', flac: 'audio/flac',
+ pdf: 'application/pdf', wasm: 'application/wasm',
+ glb: 'model/gltf-binary', gltf: 'model/gltf+json',
+ zip: 'application/zip',
+};
+// Extension-less files (CNAME, LICENSE, robots-like files) are served as plain text.
+const NO_EXTENSION = 'text/plain; charset=utf-8';
+
+/** -> { contentType } or { refused: reason } */
+function contentTypeFor(p) {
+ const base = p.slice(p.lastIndexOf('/') + 1);
+ const dot = base.lastIndexOf('.');
+ if (dot <= 0) return { contentType: NO_EXTENSION };
+ const ext = base.slice(dot + 1).toLowerCase();
+ if (REFUSED.has(ext)) return { refused: REFUSED.get(ext), code: 'deploy.file_refused' };
+ if (TYPES[ext]) return { contentType: TYPES[ext] };
+ return { refused: `".${ext}" is not an allowed file type`, code: 'deploy.type_not_allowed' };
+}
+
+/**
+ * Fingerprinted asset names (bundler output) are cached as immutable: a hash-like segment of 8–64
+ * characters with at least one digit right before the extension (app.3f2a9c1b.js, index-BdK3x9aQ.js).
+ */
+function isHashedAsset(p) {
+ const m = /[.-]([A-Za-z0-9_]{8,64})\.[A-Za-z0-9]+$/.exec(p);
+ return Boolean(m && /[0-9]/.test(m[1]) && /[A-Za-z]/.test(m[1]) && !/\.html?$/i.test(p));
+}
+
+module.exports = { checkPath, contentTypeFor, isHashedAsset, PathError, TYPES, REFUSED, MAX_PATH };
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+/**
+ * Turn uploaded entries into an immutable deploy manifest, or explain every reason it cannot be one.
+ *
+ * manifest = { schema: 'host.deploy-manifest@1', file_count, total_bytes,
+ * files: [{ path, sha256, size, content_type }] } (sorted by path)
+ * manifest_sha256 = sha256 of that JSON: identical content gives an identical digest.
+ *
+ * Every problem is collected (up to 50) so the upload log lists them all at once.
+ */
+const crypto = require('crypto');
+const { checkPath, contentTypeFor, PathError } = require('./paths');
+const { normaliseName } = require('./archive');
+
+const MAX_PROBLEMS = 50;
+
+function sha256(data) {
+ return crypto.createHash('sha256').update(data).digest('hex');
+}
+
+/**
+ * entries: [{ path, data }]. limits: { maxFiles, maxFileBytes, maxTotalBytes }.
+ * -> { ok: true, manifest, manifestSha256, files, warnings } | { ok: false, code, problems, warnings }
+ */
+function buildManifest(entries, limits) {
+ const problems = [];
+ const warnings = [];
+ const files = [];
+ const seen = new Set();
+ let total = 0;
+ const problem = (code, message) => { if (problems.length < MAX_PROBLEMS) problems.push({ code, message }); };
+
+ if (!entries.length) problem('deploy.empty', 'the upload contains no files');
+ if (entries.length > limits.maxFiles) problem('quota.max_files', `${entries.length} files; this project may deploy at most ${limits.maxFiles}`);
+
+ for (const e of entries) {
+ const raw = normaliseName(e.path);
+ let p;
+ try { p = checkPath(raw); } catch (err) {
+ if (err instanceof PathError) { problem(err.code === 'path.traversal' || err.code === 'path.absolute' ? 'deploy.path_traversal' : 'deploy.invalid_path', `${JSON.stringify(String(e.path).slice(0, 200))}: ${err.message}`); continue; }
+ throw err;
+ }
+ if (seen.has(p)) { problem('deploy.duplicate_path', `"${p}" appears twice`); continue; }
+ seen.add(p);
+ const t = contentTypeFor(p);
+ if (t.refused) { problem(t.code, `"${p}": ${t.refused}`); continue; }
+ if (e.data.length > limits.maxFileBytes) { problem('quota.max_file_bytes', `"${p}" is ${e.data.length} bytes; the limit per file is ${limits.maxFileBytes}`); continue; }
+ total += e.data.length;
+ files.push({ path: p, sha256: sha256(e.data), size: e.data.length, content_type: t.contentType, data: e.data });
+ }
+ if (total > limits.maxTotalBytes) problem('deploy.too_large', `the files add up to ${total} bytes; at most ${limits.maxTotalBytes} are allowed`);
+
+ // A path cannot be both a file and a directory ("a" and "a/b.html").
+ for (const p of seen) {
+ const parts = p.split('/');
+ for (let i = 1; i < parts.length; i++) {
+ const dir = parts.slice(0, i).join('/');
+ if (seen.has(dir)) problem('deploy.path_conflict', `"${dir}" is both a file and a directory`);
+ }
+ }
+
+ if (problems.length) {
+ const first = problems[0].code;
+ return { ok: false, code: first, problems, warnings };
+ }
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
+ if (!seen.has('index.html')) warnings.push('there is no index.html at the root: the site\'s home page will be the 404 page');
+ const manifest = {
+ schema: 'host.deploy-manifest@1',
+ file_count: files.length,
+ total_bytes: total,
+ files: files.map(({ path, sha256: h, size, content_type }) => ({ path, sha256: h, size, content_type })),
+ };
+ const json = JSON.stringify(manifest);
+ return { ok: true, manifest, manifestJson: json, manifestSha256: sha256(json), files, warnings };
+}
+
+module.exports = { buildManifest, sha256 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+
+/**
+ * Capability checks for service tokens (audience openvibe.host), including the ids Host introduces
+ * before the contracts library knows them.
+ *
+ * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its
+ * manifests yet. Host's ids are proposed in docs/capabilities-proposal/ for the next contracts
+ * release; until then a grant is decided locally with the library's own matching rule (the exact
+ * id, or a `prefix.*` grant covering it). An id the library does know always goes through the
+ * library, so the day the release lands nothing changes here.
+ *
+ * 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
+ *
+ * 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.
+ */
+const { capabilities } = require('openvibe-contracts');
+
+const CAPABILITIES = Object.freeze({
+ SITE_MANAGE: 'host.site.manage',
+ DEPLOY_CREATE: 'host.deploy.create',
+ DOMAIN_MANAGE: 'host.domain.manage',
+});
+const PROPOSED = new Set(Object.values(CAPABILITIES));
+
+/** → { allowed, code, reason } like capabilities.check(). */
+function checkCapability(claims, capabilityId) {
+ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
+ return capabilities.grants(claims && claims.cap, capabilityId)
+ ? { allowed: true, code: null, reason: null }
+ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
+ }
+ return capabilities.check(claims, capabilityId);
+}
+
+module.exports = { CAPABILITIES, PROPOSED, checkCapability };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+
+/**
+ * Dashboard form protection. SameSite=Lax is NOT enough here: tenant sites on <site>.openvibe.host
+ * are the same site as openvibe.host, so their pages could submit forms to the dashboard with the
+ * session cookie attached. Every dashboard POST therefore needs both:
+ *
+ * 1. an Origin (or, without one, a Referer) equal to the dashboard's own origin, and
+ * 2. a form token: an HMAC of the signed-in subject under HOST_FORM_SECRET (a random per-process
+ * key when unset, so forms opened before a restart must be reloaded).
+ */
+const crypto = require('crypto');
+
+const fallback = crypto.randomBytes(32).toString('hex');
+
+function csrfToken(config, viewer) {
+ if (!viewer || !viewer.subject) return '';
+ return crypto.createHmac('sha256', config.formSecret || fallback).update(`host-form:${viewer.subject}`).digest('base64url').slice(0, 32);
+}
+
+function checkCsrf(config, viewer, token) {
+ const expected = csrfToken(config, viewer);
+ if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;
+ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
+}
+
+/** The request came from a dashboard page (not from a tenant page or another site). */
+function sameOrigin(config, req) {
+ const expected = new URL(config.baseUrl).origin;
+ const origin = req.get('origin');
+ if (origin) return origin === expected;
+ const referer = req.get('referer');
+ if (!referer) return false;
+ try { return new URL(referer).origin === expected; } catch { return false; }
+}
+
+module.exports = { csrfToken, checkCsrf, sameOrigin };
new file mode 100644
new file mode 100644
@@ -0,0 +1,427 @@
@@ -0,0 +1,427 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// openvibe.host dashboard — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)
+//
+// The session layer OpenVibe.Community and OpenVibe.Blog use, with Host's client id (OAuth
+// client `host`, redirect https://openvibe.host/auth/callback), and one Host-specific change:
+//
+// Tenant sites are served on SUBDOMAINS of openvibe.host (<site>.openvibe.host). Any tenant page
+// can set a cookie for Domain=openvibe.host, so a plain cookie on the dashboard could be planted
+// by a tenant (session fixation, login CSRF). Every cookie Host trusts therefore uses the
+// `__Host-` prefix when cookies are Secure: the browser only accepts such a cookie from
+// openvibe.host itself, with Path=/ and no Domain. The plain `ov_token` cookie is still written
+// because the shared navbar reads it to show who is signed in, but the server never trusts it.
+// The API (/api/v1) ignores cookies entirely: Bearer tokens only.
+//
+// GET /auth/login → redirect to Network /oauth/authorize (?silent=1: prompt=none)
+// GET /auth/callback → server-side code exchange, set cookies
+// POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)
+// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest
+// GET /auth/me → offline-verify the session, return profile
+// POST /auth/refresh → rotate tokens via refresh_token grant
+// ═══════════════════════════════════════════════════════════════
+
+const express = require('express');
+const crypto = require('crypto');
+const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+
+const ACCESS_COOKIE = 'ov_token'; // display only (navbar.js); never trusted by the server
+const HINT_COOKIE = 'ov_sso_hint';
+
+/** Cookies the server trusts: `__Host-` prefixed whenever cookies are Secure. */
+function cookieNames(secure) {
+ const p = secure ? '__Host-' : '';
+ return {
+ session: `${p}ov_host_session`,
+ refresh: `${p}ov_host_refresh`,
+ state: `${p}ov_host_oauth_state`,
+ next: `${p}ov_host_oauth_next`,
+ silent: `${p}ov_host_oauth_silent`,
+ };
+}
+
+/**
+ * Create the auth client + JWKS fetcher shared by the whole app.
+ * Verification is OFFLINE: we cache the Network's RS256 public key from
+ * GET /api/.well-known/jwks and verify JWTs locally on every request.
+ */
+function createAuthClient(config) {
+ const client = new OpenVibeAuthClient({
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ redirectUri: config.oauth.redirectUri,
+ publicKey: null, // filled by ensureKey()
+ authBase: config.networkUrl,
+ internalBase: config.networkInternalUrl,
+ });
+
+ let lastFetch = 0;
+ let inflight = null;
+ async function ensureKey() {
+ if (client.publicKey) return client.publicKey;
+ // A request that arrives while the key is being fetched waits for that fetch rather than
+ // being treated as signed out.
+ if (inflight) return inflight;
+ // Don't hammer the Network if it's down — retry at most every 30s
+ if (Date.now() - lastFetch < 30_000) return null;
+ lastFetch = Date.now();
+ inflight = fetchKey().finally(() => { inflight = null; });
+ return inflight;
+ }
+ async function fetchKey() {
+ for (const base of [config.networkInternalUrl, config.networkUrl]) {
+ if (!base) continue;
+ try {
+ const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
+ if (!res.ok) continue;
+ const jwks = await res.json();
+ if (jwks.public_key) {
+ client.publicKey = jwks.public_key;
+ console.log(`[Host] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
+ return client.publicKey;
+ }
+ } catch (err) {
+ console.warn(`[Host auth] JWKS fetch failed from ${base}: ${err.message}`);
+ }
+ }
+ return null;
+ }
+
+ /** Offline JWT verification. Returns decoded claims or null. */
+ async function verify(token) {
+ if (!token) return null;
+ await ensureKey();
+ return client.verifyToken(token);
+ }
+
+ // Warm the key cache at boot (non-fatal if the Network is down)
+ ensureKey().catch(() => {});
+
+ return { client, ensureKey, verify };
+}
+
+/** The dashboard session: only the (prefixed) session cookie, never ov_token. */
+function sessionToken(req, config) {
+ return req.cookies?.[cookieNames(config.cookies.secure).session] || null;
+}
+
+/** API callers: only the Authorization header. */
+function bearerToken(req) {
+ const h = String(req.headers.authorization || '');
+ return h.startsWith('Bearer ') ? h.slice(7).trim() || null : null;
+}
+
+/** Strip registered claims — what the site shows as "the user". */
+function claimsToUser(claims) {
+ if (!claims) return null;
+ const { iat, exp, aud, iss, nbf, jti, ...user } = claims;
+ return user;
+}
+
+/**
+ * Decode a JWT's payload WITHOUT checking its signature. Only for reading claims
+ * we cross-check locally (the FedCM nonce) before the Network — which does verify
+ * the signature — sees the assertion. Never use it to trust an identity.
+ */
+function decodeJwtPayload(token) {
+ if (typeof token !== 'string') return null;
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ try {
+ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
+ return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;
+ } catch { return null; }
+}
+
+/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */
+function fedcmNonceMatches(token, nonce) {
+ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;
+ const claims = decodeJwtPayload(token);
+ if (!claims || typeof claims.nonce !== 'string') return false;
+ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+/**
+ * Only allow same-site relative paths, this site's own https origin, or an
+ * https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.
+ */
+function sanitizeNext(next, config) {
+ if (!next || typeof next !== 'string') return '/';
+ if (/^\/(?!\/|\\)/.test(next)) return next; // relative path, not protocol-relative
+ try {
+ const u = new URL(next);
+ if (u.protocol !== 'https:') return '/';
+ const allowed = [config.baseUrl, config.networkUrl]
+ .map((b) => { try { return new URL(b).hostname; } catch { return null; } })
+ .filter(Boolean);
+ if (allowed.includes(u.hostname)) return u.toString();
+ } catch { /* fall through */ }
+ return '/';
+}
+
+/** Append ?key=value to a same-site path or absolute URL, keeping any existing query/hash. */
+function withParam(target, key, value) {
+ const hashAt = target.indexOf('#');
+ const hash = hashAt >= 0 ? target.slice(hashAt) : '';
+ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;
+ const sep = base.includes('?') ? '&' : '?';
+ return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
+}
+
+/**
+ * Build the Network authorize URL for a login. Exported (and pure) so tests can
+ * cover the silent-login shape without a browser.
+ */
+function buildAuthorizeUrl(config, auth, { silent = false } = {}) {
+ const { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);
+ if (!silent) return { url, state };
+ // ?silent=1: continue as the account openvibe.network already knows (no chooser); if the
+ // network has no session either, it bounces back with error=login_required and we stay quiet.
+ const u = new URL(url);
+ u.searchParams.set('prompt', 'none');
+ return { url: u.toString(), state };
+}
+
+function createAuthRoutes(config, auth) {
+ const router = express.Router();
+
+ const C = cookieNames(config.cookies.secure);
+ const STATE_COOKIE = C.state;
+ const NEXT_COOKIE = C.next;
+ const SILENT_COOKIE = C.silent;
+ const REFRESH_COOKIE = C.refresh;
+
+ const accessCookieOpts = () => ({
+ sameSite: 'lax',
+ secure: config.cookies.secure,
+ httpOnly: false, // JS-readable by design: the shared navbar reads it (display only)
+ path: '/',
+ maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT
+ });
+
+ const sessionCookieOpts = () => ({
+ sameSite: 'lax',
+ secure: config.cookies.secure,
+ httpOnly: true,
+ path: '/', // __Host- requires Path=/ and no Domain
+ maxAge: 24 * 60 * 60 * 1000,
+ });
+
+ const refreshCookieOpts = () => ({ ...sessionCookieOpts(), maxAge: 30 * 24 * 60 * 60 * 1000 });
+
+ const flowCookieOpts = () => ({ ...sessionCookieOpts(), maxAge: 10 * 60 * 1000 });
+
+ const hintCookieOpts = () => ({
+ sameSite: 'lax',
+ secure: config.cookies.secure,
+ httpOnly: false, // navbar.js reads it to decide whether a silent login is worth trying
+ path: '/',
+ maxAge: 365 * 24 * 60 * 60 * 1000,
+ });
+
+ function setSessionCookies(res, accessToken, refreshToken) {
+ res.cookie(C.session, accessToken, sessionCookieOpts());
+ res.cookie(ACCESS_COOKIE, accessToken, accessCookieOpts());
+ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());
+ res.cookie(HINT_COOKIE, 'account', hintCookieOpts());
+ }
+
+ function clearSessionCookies(res) {
+ res.clearCookie(C.session, { ...sessionCookieOpts(), maxAge: undefined });
+ res.clearCookie(ACCESS_COOKIE, { ...accessCookieOpts(), maxAge: undefined });
+ res.clearCookie(REFRESH_COOKIE, { ...refreshCookieOpts(), maxAge: undefined });
+ }
+
+ function clearFlowCookies(res) {
+ for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { ...flowCookieOpts(), maxAge: undefined });
+ }
+
+ /** Exchange at the Network — internal URL first, public as fallback. */
+ async function tokenGrant(body) {
+ let lastErr = null;
+ for (const base of [config.networkInternalUrl, config.networkUrl]) {
+ if (!base) continue;
+ try {
+ const res = await fetch(`${base}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ client_id: config.oauth.clientId,
+ client_secret: config.oauth.clientSecret,
+ ...body,
+ }),
+ signal: AbortSignal.timeout(10_000),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
+ err.status = res.status;
+ err.error = data.error || 'invalid_grant';
+ err.error_description = data.error_description || null;
+ throw err;
+ }
+ return data;
+ } catch (err) {
+ lastErr = err;
+ // 4xx = the grant itself is bad; retrying against another base won't help
+ if (err.status && err.status < 500) throw err;
+ }
+ }
+ throw lastErr || new Error('Network unreachable');
+ }
+
+ // ── GET /auth/login ──────────────────────────────────────
+ router.get('/login', async (req, res) => {
+ const silent = !!req.query.silent && req.query.silent !== '0';
+ if (silent) {
+ // Already signed in here? Then the silent round trip through the Network would
+ // only hand back the session we have: go straight to where the caller wanted.
+ const existing = req.cookies?.[C.session];
+ if (existing && await auth.verify(existing)) {
+ clearFlowCookies(res);
+ return res.redirect(sanitizeNext(req.query.next, config));
+ }
+ }
+ const { url, state } = buildAuthorizeUrl(config, auth, { silent });
+ res.cookie(STATE_COOKIE, state, flowCookieOpts());
+ const next = sanitizeNext(req.query.next, config);
+ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());
+ else res.clearCookie(NEXT_COOKIE, { ...flowCookieOpts(), maxAge: undefined });
+ if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());
+ else res.clearCookie(SILENT_COOKIE, { ...flowCookieOpts(), maxAge: undefined });
+ res.redirect(url);
+ });
+
+ // ── GET /auth/callback ───────────────────────────────────
+ router.get('/callback', async (req, res) => {
+ const { code, state, error } = req.query;
+ const next = sanitizeNext(req.cookies?.[NEXT_COOKIE], config);
+ const silent = req.cookies?.[SILENT_COOKIE] === '1';
+
+ if (error) {
+ clearFlowCookies(res);
+ // Silent sign-in found no network session — go back quietly as a guest. The
+ // ?sso=none marker stops navbar.js from trying again on that page.
+ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
+ return res.redirect(withParam('/', 'auth_error', String(error)));
+ }
+ if (!code) return res.status(400).send('Missing authorization code');
+
+ const expectedState = req.cookies?.[STATE_COOKIE];
+ clearFlowCookies(res);
+ // The state cookie is required, not just compared when present: skipping the check when
+ // the cookie is missing would let a crafted link sign a visitor into someone else's account.
+ if (!expectedState || !state || !crypto.timingSafeEqual(
+ Buffer.from(String(state).padEnd(64).slice(0, 64)),
+ Buffer.from(String(expectedState).padEnd(64).slice(0, 64))
+ )) {
+ return res.status(400).send('OAuth state mismatch — please try signing in again.');
+ }
+
+ try {
+ const data = await tokenGrant({
+ grant_type: 'authorization_code',
+ redirect_uri: config.oauth.redirectUri,
+ code,
+ });
+ setSessionCookies(res, data.access_token, data.refresh_token);
+ return res.redirect(next);
+ } catch (err) {
+ console.error('[Host auth] Code exchange failed:', err.message);
+ return res.status(502).send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
+ }
+ });
+
+ // ── POST /auth/fedcm ─────────────────────────────────────
+ // The shared navbar obtained a FedCM assertion from openvibe.network (the
+ // browser's own account chooser) and posts it same-origin with the nonce it
+ // put in the FedCM request. We only pre-check the nonce; the Network verifies
+ // the assertion's signature when it swaps it for tokens.
+ const fedcmBody = express.json({ limit: '16kb', type: 'application/json' });
+ const fedcmBodyError = (err, _req, res, next) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : next());
+ router.post('/fedcm', fedcmBody, fedcmBodyError, async (req, res) => {
+ if (!req.is('application/json')) return res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' });
+ const { token, nonce } = req.body || {};
+ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {
+ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });
+ }
+ if (!fedcmNonceMatches(token, nonce)) {
+ return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
+ }
+ try {
+ const data = await tokenGrant({
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ assertion: token,
+ });
+ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
+ setSessionCookies(res, data.access_token, data.refresh_token);
+ const user = data.user || claimsToUser(await auth.verify(data.access_token));
+ return res.json({ ok: true, user });
+ } catch (err) {
+ if (err.status && err.status < 500) {
+ return res.status(401).json({ error: err.error || 'invalid_grant', error_description: err.error_description || err.message });
+ }
+ console.error('[Host auth] FedCM exchange failed:', err.message);
+ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ // ── GET /auth/logout ─────────────────────────────────────
+ router.get('/logout', async (req, res) => {
+ // Best-effort refresh revocation. The Network's rotating refresh tokens
+ // self-invalidate, so a failure here is harmless.
+ const refresh = req.cookies?.[REFRESH_COOKIE];
+ if (refresh) {
+ try {
+ await fetch(`${config.networkInternalUrl}/oauth/revoke`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ client_id: config.oauth.clientId,
+ client_secret: config.oauth.clientSecret,
+ token: refresh,
+ }),
+ signal: AbortSignal.timeout(3000),
+ });
+ } catch { /* optional */ }
+ }
+ clearSessionCookies(res);
+ res.cookie(HINT_COOKIE, 'guest', hintCookieOpts());
+ res.redirect(sanitizeNext(req.query.next, config));
+ });
+
+ // ── GET /auth/me ─────────────────────────────────────────
+ router.get('/me', async (req, res) => {
+ const token = sessionToken(req, config);
+ if (!token) return res.status(401).json({ error: 'Not authenticated' });
+ const claims = await auth.verify(token);
+ if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });
+ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
+ });
+
+ // ── POST /auth/refresh ───────────────────────────────────
+ // Small endpoint the shared navbar can call when ov_token expires.
+ router.post('/refresh', async (req, res) => {
+ const refresh = req.cookies?.[REFRESH_COOKIE];
+ if (!refresh) return res.status(401).json({ error: 'No refresh token' });
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
+ setSessionCookies(res, data.access_token, data.refresh_token);
+ const claims = await auth.verify(data.access_token);
+ return res.json({ token: data.access_token, user: claimsToUser(claims) });
+ } catch (err) {
+ if (err.status && err.status < 500) {
+ clearSessionCookies(res);
+ return res.status(401).json({ error: 'Refresh token rejected — please sign in again' });
+ }
+ console.error('[Host auth] Refresh failed:', err.message);
+ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createAuthClient, createAuthRoutes, sessionToken, bearerToken, cookieNames, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+
+/**
+ * Who is calling, resolved once per request into `req.viewer`:
+ *
+ * { kind: 'anonymous' }
+ * { kind: 'user', subject: 'usr_…', staff, user }
+ * API: a Network user JWT in `Authorization: Bearer` (cookies are ignored on /api/v1).
+ * Dashboard: the __Host- session cookie (never the navbar's ov_token, which a tenant
+ * subdomain could plant). role 'admin' makes the viewer staff.
+ * { kind: 'service', service: 'svc:codes' | 'app:app_…', claims, subject, env }
+ * A Network client-credentials token for audience openvibe.host. A first-party service
+ * (svc:) may name the person it acts for with X-OV-Subject; an app (app:) always acts as
+ * itself. `env` is the token's environment claim ("sandbox" is refused on production
+ * projects).
+ *
+ * Identity never comes from a request body or query. A bad token is refused (problem+json),
+ * never downgraded to anonymous.
+ */
+const contracts = require('openvibe-contracts');
+const { sessionToken, bearerToken, claimsToUser, decodeJwtPayload } = require('./sso');
+const { checkCapability } = require('./capabilities');
+
+const { ids, serviceAuth, http } = contracts;
+const STAFF_ROLES = new Set(['admin']);
+const PRINCIPAL_SUB = /^(svc|app|mod):/;
+const AUDIENCE = 'openvibe.host';
+
+class ViewerError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false });
+
+function createViewerResolver({ auth, config }) {
+ async function fromServiceToken(req, token) {
+ const publicKey = await auth.ensureKey();
+ if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
+ if (!r.ok) throw new ViewerError(401, r.code, r.reason);
+ const sub = r.claims.sub;
+ if (sub.startsWith('mod:')) throw new ViewerError(403, 'principal.not_allowed', 'mods cannot manage hosted sites');
+ const subjectHeader = req.get('x-ov-subject');
+ let subject = null;
+ if (subjectHeader) {
+ if (!sub.startsWith('svc:')) throw new ViewerError(400, 'subject.not_delegable', 'only first-party services may act for a person (X-OV-Subject); an app acts as itself');
+ if (!ids.isSubjectId('user', subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
+ subject = subjectHeader;
+ }
+ const env = r.claims.env || r.claims.environment || null;
+ return { kind: 'service', service: sub, claims: r.claims, subject, env: env === 'sandbox' ? 'sandbox' : env, staff: false };
+ }
+
+ async function fromUserToken(token) {
+ const claims = await auth.verify(token);
+ if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
+ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ if (!subject) return null;
+ return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), user: claimsToUser(claims) };
+ }
+
+ async function resolveApi(req) {
+ const token = bearerToken(req);
+ if (!token) return ANONYMOUS;
+ const payload = decodeJwtPayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) return fromServiceToken(req, token);
+ const v = await fromUserToken(token);
+ if (!v) throw new ViewerError(401, 'auth.invalid_token', 'the bearer token is not a valid OpenVibe token');
+ return v;
+ }
+
+ async function resolveDashboard(req) {
+ const token = sessionToken(req, config);
+ if (!token) return ANONYMOUS;
+ return (await fromUserToken(token)) || ANONYMOUS;
+ }
+
+ function middleware(mode) {
+ return async (req, res, next) => {
+ try {
+ req.viewer = mode === 'api' ? await resolveApi(req) : await resolveDashboard(req);
+ next();
+ } catch (err) {
+ if (!(err instanceof ViewerError)) return next(err);
+ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
+ }
+ };
+ }
+
+ return { resolveApi, resolveDashboard, middleware };
+}
+
+/**
+ * Route guard: a service token must hold `cap`. Browsers and anonymous callers pass here and are
+ * judged by project membership in the domain layer. Each route checks exactly one capability.
+ */
+function guard(cap) {
+ return (req, res, next) => {
+ const v = req.viewer;
+ if (!v || v.kind !== 'service') return next();
+ const c = checkCapability(v.claims, cap);
+ if (c.allowed) return next();
+ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
+ };
+}
+
+module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError, STAFF_ROLES, AUDIENCE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+
+/**
+ * OpenVibe.Host Stage B (tenant static hosting) configuration. Every value comes from the
+ * environment (production: /etc/openvibe/host.env, see .env.example). Only environment variable
+ * NAMES appear in code and docs; secret values are never logged or returned.
+ *
+ * load(env) is pure so tests can build a config without touching process.env.
+ */
+require('dotenv').config();
+
+const trim = (s) => String(s || '').replace(/\/+$/, '');
+const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
+const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
+const MiB = 1024 * 1024;
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 4910);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.host' : `http://localhost:${port}`));
+ const dashboardHost = new URL(baseUrl).hostname.toLowerCase();
+ // Tenant sites are served at <site>.<sitesDomain>. It must be a registrable domain of its own,
+ // never a subdomain of openvibe.network / openvibe.live (their cookies must never reach tenants).
+ const sitesDomain = String(env.HOST_SITES_DOMAIN || 'openvibe.host').toLowerCase().replace(/^\.+|\.+$/g, '');
+
+ const quota = (prefix, defaults) => ({
+ storageBytes: int(env[`${prefix}STORAGE_BYTES`], defaults.storageBytes),
+ deploysPerDay: int(env[`${prefix}DEPLOYS_PER_DAY`], defaults.deploysPerDay),
+ maxFiles: int(env[`${prefix}MAX_FILES`], defaults.maxFiles),
+ maxFileBytes: int(env[`${prefix}MAX_FILE_BYTES`], defaults.maxFileBytes),
+ sites: int(env[`${prefix}SITES`], defaults.sites),
+ customDomains: int(env[`${prefix}CUSTOM_DOMAINS`], defaults.customDomains),
+ });
+
+ return {
+ service: 'host',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ dashboardHost,
+ sitesDomain,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+
+ dbPath: env.HOST_DB_PATH || './data/host.db',
+ // Content-addressed deploy objects, one directory per project (tenancy keyed by project id).
+ storageDir: env.HOST_STORAGE_DIR || './data/objects',
+
+ // OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
+ networkUrl: trim(env.OV_NETWORK_URL || 'https://openvibe.network'),
+ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'host',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: 'profile theme',
+ },
+ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+ // Signs the dashboard form tokens (CSRF). Unset: a random per-process key.
+ formSecret: env.HOST_FORM_SECRET || '',
+
+ // OpenVibe.Events: the outbox relay runs only when EVENTS_URL and the client secret are set.
+ events: {
+ url: trim(env.EVENTS_URL || ''),
+ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
+ },
+
+ uploads: {
+ // Largest request body accepted for one deploy (the compressed archive or all multipart parts).
+ maxUploadBytes: int(env.HOST_MAX_UPLOAD_BYTES, 100 * MiB),
+ },
+
+ // Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
+ quotas: {
+ production: quota('HOST_QUOTA_', { storageBytes: 1024 * MiB, deploysPerDay: 50, maxFiles: 10000, maxFileBytes: 25 * MiB, sites: 10, customDomains: 5 }),
+ sandbox: quota('HOST_SANDBOX_QUOTA_', { storageBytes: 100 * MiB, deploysPerDay: 20, maxFiles: 2000, maxFileBytes: 10 * MiB, sites: 3, customDomains: 0 }),
+ },
+
+ domains: {
+ // What a custom domain's CNAME points at. Default: the site's own <site>.<sitesDomain>.
+ cnameTarget: String(env.HOST_CNAME_TARGET || '').toLowerCase() || null,
+ originIpv4: env.HOST_ORIGIN_IPV4 || null,
+ originIpv6: env.HOST_ORIGIN_IPV6 || null,
+ dnsServers: list(env.HOST_DNS_SERVERS),
+ recheckIntervalMs: int(env.HOST_DOMAIN_RECHECK_MS, 10 * 60 * 1000),
+ pendingDays: int(env.HOST_DOMAIN_PENDING_DAYS, 7),
+ lapseDays: int(env.HOST_DOMAIN_LAPSE_DAYS, 7),
+ extraReserved: list(env.HOST_RESERVED_DOMAINS).map((d) => d.toLowerCase()),
+ },
+
+ sites: { extraReservedNames: list(env.HOST_RESERVED_SITE_NAMES).map((d) => d.toLowerCase()) },
+
+ worker: {
+ enabled: env.HOST_WORKER !== 'off',
+ id: env.HOST_WORKER_ID || `host-${process.pid}`,
+ },
+
+ // Browser origins that may call /api/v1 with a Bearer Network JWT (never cookies).
+ apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network,https://openvibe.codes'),
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
+'use strict';
+
+/**
+ * Host's own SQLite database (WAL), created on boot, idempotently. Nothing here is shared with
+ * another service.
+ *
+ * host_projects tenant projects: owner subject (usr_…), environment (production|sandbox),
+ * network_project_id once OpenVibe.Network has projects (ADR-014)
+ * host_project_members principals with a role on a project: usr_… or app:app_…/svc:… principals
+ * host_quotas per-project overrides of the configured quota defaults (staff only)
+ * host_sites a site = a name (<name>.openvibe.host) + the pointer to its active deploy
+ * host_deploys IMMUTABLE artifacts: manifest (sha256 per file), totals, state
+ * host_deploy_files path → sha256/size/content type per deploy (immutable)
+ * host_blobs which content-addressed objects a project stores (storage accounting)
+ * 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)
+ * event_outbox openvibe-sdk transactional outbox
+ *
+ * Immutability is enforced by the database itself: triggers refuse any UPDATE of a deploy's
+ * artifact columns or of its file rows, and file rows can only be removed once the deploy is deleted.
+ */
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS host_projects (
+ id TEXT PRIMARY KEY, -- prj_<ULID>
+ network_project_id TEXT, -- OpenVibe.Network project id (ADR-014), when it exists
+ owner_subject TEXT NOT NULL, -- usr_…
+ name TEXT NOT NULL,
+ environment TEXT NOT NULL DEFAULT 'production' CHECK (environment IN ('production','sandbox')),
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ deleted_at INTEGER
+);
+CREATE UNIQUE INDEX IF NOT EXISTS host_projects_network ON host_projects (network_project_id) WHERE network_project_id IS NOT NULL AND status = 'active';
+CREATE INDEX IF NOT EXISTS host_projects_owner ON host_projects (owner_subject, status);
+
+CREATE TABLE IF NOT EXISTS host_project_members (
+ project_id TEXT NOT NULL REFERENCES host_projects(id),
+ principal TEXT NOT NULL, -- usr_… | app:app_… | svc:<id>
+ role TEXT NOT NULL CHECK (role IN ('owner','maintainer','deployer')),
+ added_by TEXT,
+ created_at INTEGER NOT NULL,
+ PRIMARY KEY (project_id, principal)
+);
+CREATE INDEX IF NOT EXISTS host_project_members_principal ON host_project_members (principal);
+
+CREATE TABLE IF NOT EXISTS host_quotas (
+ project_id TEXT PRIMARY KEY REFERENCES host_projects(id),
+ storage_bytes INTEGER,
+ deploys_per_day INTEGER,
+ max_files INTEGER,
+ max_file_bytes INTEGER,
+ sites INTEGER,
+ custom_domains INTEGER,
+ updated_by TEXT,
+ updated_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS host_sites (
+ id TEXT PRIMARY KEY, -- site_<ULID>
+ project_id TEXT NOT NULL REFERENCES host_projects(id),
+ name TEXT NOT NULL, -- the <name>.openvibe.host label
+ active_deploy_id TEXT,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ deleted_at INTEGER
+);
+CREATE UNIQUE INDEX IF NOT EXISTS host_sites_name ON host_sites (name) WHERE status = 'active';
+CREATE INDEX IF NOT EXISTS host_sites_project ON host_sites (project_id, status);
+
+CREATE TABLE IF NOT EXISTS host_deploys (
+ id TEXT PRIMARY KEY, -- dpl_<ULID>
+ project_id TEXT NOT NULL REFERENCES host_projects(id),
+ site_id TEXT NOT NULL REFERENCES host_sites(id),
+ state TEXT NOT NULL CHECK (state IN ('ready','failed','deleted')),
+ source TEXT NOT NULL CHECK (source IN ('archive','files')),
+ manifest TEXT, -- JSON host.deploy-manifest@1 (NULL when failed)
+ manifest_sha256 TEXT,
+ file_count INTEGER NOT NULL DEFAULT 0,
+ total_bytes INTEGER NOT NULL DEFAULT 0,
+ new_bytes INTEGER NOT NULL DEFAULT 0, -- bytes this deploy added to the project's storage
+ failure_code TEXT,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ deleted_at INTEGER
+);
+CREATE INDEX IF NOT EXISTS host_deploys_site ON host_deploys (site_id, created_at);
+CREATE INDEX IF NOT EXISTS host_deploys_project_day ON host_deploys (project_id, created_at);
+
+CREATE TABLE IF NOT EXISTS host_deploy_files (
+ deploy_id TEXT NOT NULL REFERENCES host_deploys(id),
+ path TEXT NOT NULL,
+ sha256 TEXT NOT NULL,
+ size INTEGER NOT NULL,
+ content_type TEXT NOT NULL,
+ PRIMARY KEY (deploy_id, path)
+) WITHOUT ROWID;
+CREATE INDEX IF NOT EXISTS host_deploy_files_sha ON host_deploy_files (sha256);
+
+CREATE TABLE IF NOT EXISTS host_blobs (
+ project_id TEXT NOT NULL,
+ sha256 TEXT NOT NULL,
+ size INTEGER NOT NULL,
+ created_at INTEGER NOT NULL,
+ PRIMARY KEY (project_id, sha256)
+) WITHOUT ROWID;
+
+CREATE TABLE IF NOT EXISTS host_activations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ site_id TEXT NOT NULL REFERENCES host_sites(id),
+ deploy_id TEXT NOT NULL,
+ previous_deploy_id TEXT,
+ kind TEXT NOT NULL CHECK (kind IN ('activate','rollback')),
+ actor TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS host_activations_site ON host_activations (site_id, id);
+
+CREATE TABLE IF NOT EXISTS host_deploy_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ deploy_id TEXT NOT NULL,
+ level TEXT NOT NULL CHECK (level IN ('info','warn','error')),
+ message TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS host_deploy_logs_deploy ON host_deploy_logs (deploy_id, id);
+
+CREATE TABLE IF NOT EXISTS host_domains (
+ id TEXT PRIMARY KEY, -- dom_<ULID>
+ project_id TEXT NOT NULL REFERENCES host_projects(id),
+ site_id TEXT NOT NULL REFERENCES host_sites(id),
+ hostname TEXT NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('default','custom')),
+ status TEXT NOT NULL CHECK (status IN ('pending','verified','failed','lapsed')),
+ token TEXT, -- the TXT verification value (custom only; public in DNS, not a secret)
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ verified_at INTEGER,
+ last_checked_at INTEGER,
+ last_error TEXT,
+ check_count INTEGER NOT NULL DEFAULT 0,
+ record_missing_since INTEGER -- verified domain whose TXT record disappeared
+);
+CREATE UNIQUE INDEX IF NOT EXISTS host_domains_verified ON host_domains (hostname) WHERE status = 'verified';
+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);
+
+-- 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
+BEGIN SELECT RAISE(ABORT, 'host: deploy artifacts are immutable'); END;
+CREATE TRIGGER IF NOT EXISTS host_deploys_no_undelete
+BEFORE UPDATE OF state ON host_deploys WHEN OLD.state = 'deleted' OR (OLD.state = 'failed' AND NEW.state = 'ready')
+BEGIN SELECT RAISE(ABORT, 'host: a deleted or failed deploy cannot become ready'); END;
+CREATE TRIGGER IF NOT EXISTS host_deploy_files_immutable
+BEFORE UPDATE ON host_deploy_files
+BEGIN SELECT RAISE(ABORT, 'host: deploy files are immutable'); END;
+CREATE TRIGGER IF NOT EXISTS host_deploy_files_delete_only_deleted
+BEFORE DELETE ON host_deploy_files WHEN (SELECT state FROM host_deploys WHERE id = OLD.deploy_id) <> 'deleted'
+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'];
+
+function openStore(dbPath, { now = () => Date.now() } = {}) {
+ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
+ const db = new Database(dbPath);
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+ db.pragma('busy_timeout = 5000');
+ db.exec(SCHEMA);
+ return {
+ db,
+ now,
+ tx: (fn) => db.transaction(fn)(),
+ close: () => db.close(),
+ };
+}
+
+module.exports = { openStore, CHARTER_TABLES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+/**
+ * Who may do what on a project.
+ *
+ * principal a browser user: its usr_ subject. A first-party service token (svc:…): the person
+ * named by X-OV-Subject, else the service itself. An app token (app:app_…): the app
+ * itself — apps are principals (ADR-014) and never act as a person here.
+ * roles owner > maintainer > deployer, per project (host_project_members).
+ * deployer upload deploys, list them and their logs, activate, roll back
+ * maintainer + sites, custom domains, delete deploys
+ * owner + delete the project, manage members
+ * staff Network admins: read everything, delete sites/projects/domains, set quotas; they
+ * cannot publish into a tenant's site.
+ *
+ * A caller who is not a member gets 404 for the project and everything in it (existence is not
+ * revealed). A sandbox token (claim env = "sandbox") is refused on production projects.
+ */
+const { ApiError } = require('../http/errors');
+
+const RANK = { deployer: 1, maintainer: 2, owner: 3 };
+const NEED = { read: 1, deploy: 1, maintain: 2, own: 3 };
+
+function principalOf(viewer) {
+ if (!viewer) return null;
+ if (viewer.kind === 'user') return viewer.subject || null;
+ if (viewer.kind === 'service') {
+ if (viewer.subject && /^svc:/.test(viewer.service)) return viewer.subject;
+ return viewer.service;
+ }
+ return null;
+}
+
+/** The person accountable for an action: a usr_ subject when there is one. */
+function actorOf(viewer) {
+ return principalOf(viewer) || 'anonymous';
+}
+
+function actorRef(viewer) {
+ const p = principalOf(viewer);
+ if (p && p.startsWith('usr_')) return { type: 'user', id: p };
+ if (p && p.startsWith('app:')) return { type: 'app', id: p.slice(4) };
+ if (p && p.startsWith('svc:')) return { type: 'service', id: p.slice(4) };
+ return { type: 'service', id: 'host' };
+}
+
+function createAccess({ store }) {
+ const { db } = store;
+ const memberStmt = db.prepare('SELECT role FROM host_project_members WHERE project_id = ? AND principal = ?');
+
+ function roleOf(project, viewer) {
+ const p = principalOf(viewer);
+ if (!p) return null;
+ const row = memberStmt.get(project.id, p);
+ return row ? row.role : null;
+ }
+
+ /**
+ * Throws unless the viewer may act on the project at `need` (read|deploy|maintain|own|staff).
+ * `notFound` is the problem thrown when the viewer has no business knowing the resource exists.
+ */
+ function authorize(project, viewer, need, notFound = new ApiError(404, 'project.not_found', 'no such project')) {
+ if (!project || project.status !== 'active') throw notFound;
+ if (!viewer || viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');
+ if (viewer.kind === 'service' && viewer.env === 'sandbox' && project.environment !== 'sandbox') {
+ throw new ApiError(403, 'environment.sandbox_token', 'a sandbox credential cannot act on a production project');
+ }
+ if (need === 'staff') {
+ if (viewer.staff) return 'staff';
+ throw new ApiError(403, 'auth.staff_only', 'only OpenVibe staff can change quotas');
+ }
+ const role = roleOf(project, viewer);
+ if (!role) {
+ if (viewer.staff && need !== 'deploy') return 'staff';
+ throw notFound;
+ }
+ if (RANK[role] < NEED[need]) throw new ApiError(403, 'project.role_insufficient', `this needs the ${need === 'own' ? 'owner' : need === 'maintain' ? 'maintainer' : 'deployer'} role; you are ${role}`);
+ return role;
+ }
+
+ return { authorize, roleOf };
+}
+
+module.exports = { createAccess, principalOf, actorOf, actorRef, RANK };
new file mode 100644
new file mode 100644
@@ -0,0 +1,260 @@
@@ -0,0 +1,260 @@
+'use strict';
+/**
+ * Deploys: immutable artifacts, the upload/validation log, activation and rollback.
+ *
+ * upload entries → validate (paths, types, sizes, quotas) → objects written to the project's
+ * content-addressed store → ONE transaction: deploy row + file rows + blob rows + log +
+ * host.deploy.created (+ activation when asked). A refused upload is recorded as a
+ * failed deploy with its log and host.deploy.failed, so the owner can see why.
+ * activate the site's active_deploy_id pointer changes in one transaction together with the
+ * activation record and host.deploy.activated. A request reads the pointer once and then
+ * only immutable rows, so it sees the old deploy or the new one, never a mix.
+ * 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.
+ *
+ * There is no build: Stage B serves the uploaded files as they are, and nothing is ever executed.
+ */
+const { newId, isId } = require('../ids');
+const { ApiError } = require('../http/errors');
+const { buildManifest } = require('../artifacts/validate');
+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 }) {
+ const { db } = store;
+ const q = {
+ byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),
+ forSite: db.prepare("SELECT * FROM host_deploys WHERE site_id = ? AND state <> 'deleted' ORDER BY created_at DESC, rowid DESC LIMIT ?"),
+ insert: db.prepare(`INSERT INTO host_deploys (id, project_id, site_id, state, source, manifest, manifest_sha256, file_count, total_bytes, new_bytes, failure_code, created_by, created_at)
+ VALUES (@id, @project_id, @site_id, @state, @source, @manifest, @manifest_sha256, @file_count, @total_bytes, @new_bytes, @failure_code, @created_by, @created_at)`),
+ insertFile: db.prepare('INSERT INTO host_deploy_files (deploy_id, path, sha256, size, content_type) VALUES (?, ?, ?, ?, ?)'),
+ insertBlob: db.prepare('INSERT OR IGNORE INTO host_blobs (project_id, sha256, size, created_at) VALUES (?, ?, ?, ?)'),
+ hasBlob: db.prepare('SELECT 1 FROM host_blobs WHERE project_id = ? AND sha256 = ?'),
+ storageUsed: db.prepare('SELECT COALESCE(SUM(size), 0) AS n FROM host_blobs WHERE project_id = ?'),
+ deploysSince: db.prepare('SELECT COUNT(*) AS n FROM host_deploys WHERE project_id = ? AND created_at > ?'),
+ logLine: db.prepare('INSERT INTO host_deploy_logs (deploy_id, level, message, created_at) VALUES (?, ?, ?, ?)'),
+ logFor: db.prepare('SELECT level, message, created_at FROM host_deploy_logs WHERE deploy_id = ? ORDER BY id'),
+ files: db.prepare('SELECT path, sha256, size, content_type FROM host_deploy_files WHERE deploy_id = ? ORDER BY path'),
+ setPointer: db.prepare('UPDATE host_sites SET active_deploy_id = ?, updated_at = ? WHERE id = ? AND status = \'active\' AND active_deploy_id IS ?'),
+ 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 = ?"),
+ dropFiles: db.prepare('DELETE FROM host_deploy_files WHERE deploy_id = ?'),
+ orphans: db.prepare(`SELECT sha256 FROM host_blobs b WHERE b.project_id = ? AND NOT EXISTS (
+ SELECT 1 FROM host_deploy_files f JOIN host_deploys d ON d.id = f.deploy_id
+ WHERE d.project_id = b.project_id AND f.sha256 = b.sha256)`),
+ dropBlob: db.prepare('DELETE FROM host_blobs WHERE project_id = ? AND sha256 = ?'),
+ };
+
+ function get(id) {
+ return isId('deploy', id) ? q.byId.get(id) || null : null;
+ }
+
+ /** The deploy, its site and project, authorized at `need`; 404 for anyone without access. */
+ function load(viewer, id, need) {
+ const deploy = get(id);
+ const notFound = new ApiError(404, 'deploy.not_found', 'no such deploy');
+ if (!deploy || deploy.state === 'deleted') throw notFound;
+ const site = sites.get(deploy.site_id);
+ if (!site || site.status !== 'active') throw notFound;
+ const project = projects.get(deploy.project_id);
+ access.authorize(project, viewer, need, notFound);
+ return { deploy, site, project };
+ }
+
+ /** Limits for an upload into this project (the archive reader and the validator share them). */
+ function limitsFor(project) {
+ const quota = projects.quotaOf(project);
+ return { maxFiles: quota.maxFiles, maxFileBytes: quota.maxFileBytes, maxTotalBytes: quota.storageBytes, quota };
+ }
+
+ /** 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');
+ const limits = limitsFor(project);
+ const recent = q.deploysSince.get(project.id, store.now() - DAY).n;
+ if (recent >= limits.quota.deploysPerDay) {
+ throw new ApiError(429, 'quota.deploys_per_day', `this project has made ${recent} deploys in the last 24 hours; the limit is ${limits.quota.deploysPerDay}`);
+ }
+ return { site, project, limits };
+ }
+
+ function writeLog(deployId, lines, now) {
+ for (const l of lines) q.logLine.run(deployId, l.level, String(l.message).slice(0, 2000), now);
+ }
+
+ /** A refused upload becomes a failed deploy (with its log) and host.deploy.failed. */
+ function recordFailure(viewer, { site, project }, { source, code, problems = [], notes = [], traceparent }) {
+ const id = newId('deploy', store.now());
+ const now = store.now();
+ const lines = [
+ ...notes.map((m) => ({ level: 'info', message: m })),
+ ...problems.map((p) => ({ level: 'error', message: `${p.code}: ${p.message}` })),
+ { level: 'error', message: `deploy refused (${code}); nothing was stored and the active deploy is unchanged` },
+ ];
+ store.tx(() => {
+ q.insert.run({ id, project_id: project.id, site_id: site.id, state: 'failed', source, manifest: null, manifest_sha256: null, file_count: 0, total_bytes: 0, new_bytes: 0, failure_code: code, created_by: principalOf(viewer), created_at: now });
+ writeLog(id, lines, now);
+ outbox.emit({
+ event_type: 'host.deploy.failed', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
+ subject: { type: 'deploy', id },
+ payload: { project_id: project.id, site_id: site.id, site: site.name, code, problems: problems.slice(0, 10).map((p) => p.code) },
+ }, { traceparent });
+ });
+ outbox.kick();
+ return { deploy: get(id), log: q.logFor.all(id) };
+ }
+
+ /**
+ * 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.
+ */
+ function create(viewer, ctx, entries, { source, activate = false, notes = [], traceparent } = {}) {
+ const { site, project, limits } = ctx;
+ const fail = (status, code, problems) => {
+ const r = recordFailure(viewer, ctx, { source, 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}`) });
+ };
+
+ const built = buildManifest(entries, limits);
+ if (!built.ok) fail(/^quota\.|deploy\.too_large/.test(built.code) ? 413 : 422, built.code, built.problems);
+
+ // Storage quota: only bytes this project does not store yet count.
+ const fresh = new Map();
+ for (const f of built.files) if (!q.hasBlob.get(project.id, f.sha256) && !fresh.has(f.sha256)) fresh.set(f.sha256, f);
+ const newBytes = [...fresh.values()].reduce((n, f) => n + f.size, 0);
+ const used = q.storageUsed.get(project.id).n;
+ if (used + newBytes > limits.quota.storageBytes) {
+ fail(413, 'quota.storage', [{ code: 'quota.storage', message: `this deploy adds ${fmtBytes(newBytes)}; the project stores ${fmtBytes(used)} of ${fmtBytes(limits.quota.storageBytes)}` }]);
+ }
+
+ // Objects first (idempotent, content-addressed); rows only after they are durable.
+ for (const f of fresh.values()) blobs.put(project.id, f.sha256, f.data);
+
+ const id = newId('deploy', store.now());
+ const now = store.now();
+ const who = principalOf(viewer);
+ const lines = [
+ ...notes.map((m) => ({ level: 'info', message: m })),
+ { level: 'info', message: `validated ${built.manifest.file_count} files, ${fmtBytes(built.manifest.total_bytes)} (paths, file types, sizes)` },
+ ...built.warnings.map((m) => ({ level: 'warn', message: m })),
+ { level: 'info', message: `stored ${fresh.size} new objects (${fmtBytes(newBytes)}); ${built.files.length - fresh.size} already stored for this project` },
+ { level: 'info', message: `manifest sha256 ${built.manifestSha256}` },
+ { level: 'info', message: 'no build step: Stage B serves the uploaded files as they are; nothing was executed' },
+ ];
+ let activated = null;
+ store.tx(() => {
+ 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 });
+ for (const f of built.files) q.insertFile.run(id, f.path, f.sha256, f.size, f.content_type);
+ for (const f of fresh.values()) q.insertBlob.run(project.id, f.sha256, f.size, now);
+ 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 },
+ }, { traceparent });
+ if (activate) {
+ activated = switchPointer(viewer, site, get(id), 'activate', { traceparent });
+ lines.push({ level: 'info', message: 'activated: the site now serves this deploy' });
+ } else {
+ lines.push({ level: 'info', message: 'ready; not active until you activate it' });
+ }
+ writeLog(id, lines, now);
+ });
+ outbox.kick();
+ return { deploy: get(id), log: q.logFor.all(id), activated };
+ }
+
+ /** Inside a transaction. Compare-and-set on the pointer read in the same transaction. */
+ function switchPointer(viewer, site, deploy, kind, { expectedActive, traceparent } = {}) {
+ const current = db.prepare('SELECT active_deploy_id, status FROM host_sites WHERE id = ?').get(site.id);
+ if (!current || current.status !== 'active') throw new ApiError(404, 'site.not_found', 'no such site');
+ if (deploy.state !== 'ready' || deploy.site_id !== site.id) throw new ApiError(409, 'deploy.not_ready', 'only a ready deploy of this site can be activated');
+ if (expectedActive !== undefined && (expectedActive || null) !== current.active_deploy_id) {
+ throw new ApiError(409, 'site.active_changed', 'the active deploy changed since you looked', { active_deploy_id: current.active_deploy_id });
+ }
+ if (current.active_deploy_id === deploy.id) return { changed: false, deploy_id: deploy.id, previous_deploy_id: deploy.id };
+ const now = store.now();
+ const r = 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');
+ q.activation.run(site.id, deploy.id, current.active_deploy_id, kind, principalOf(viewer) || 'host', now);
+ outbox.emit({
+ event_type: 'host.deploy.activated', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
+ subject: { type: 'deploy', id: deploy.id },
+ payload: { project_id: site.project_id, site_id: site.id, site: site.name, deploy_id: deploy.id, previous_deploy_id: current.active_deploy_id, rollback: kind === 'rollback' },
+ }, { traceparent });
+ return { changed: true, deploy_id: deploy.id, previous_deploy_id: current.active_deploy_id };
+ }
+
+ function activate(viewer, id, { expectedActive, traceparent } = {}) {
+ const { deploy, site } = load(viewer, id, 'deploy');
+ const out = store.tx(() => switchPointer(viewer, site, deploy, 'activate', { expectedActive, traceparent }));
+ outbox.kick();
+ return out;
+ }
+
+ /** 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');
+ 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;
+ if (deployId) {
+ target = get(deployId);
+ if (!target || target.site_id !== site.id || target.state === 'deleted') throw new ApiError(404, 'deploy.not_found', 'no such deploy on this site');
+ } else {
+ for (const a of q.activations.all(site.id, 200)) {
+ for (const cand of [a.previous_deploy_id, a.deploy_id]) {
+ if (!cand || cand === current) continue;
+ const d = get(cand);
+ if (d && d.state === 'ready' && d.site_id === site.id) { target = d; break; }
+ }
+ if (target) break;
+ }
+ if (!target) throw new ApiError(409, 'deploy.no_previous', 'there is no previous deploy to roll back to');
+ }
+ return switchPointer(viewer, site, target, 'rollback', { expectedActive, traceparent });
+ });
+ outbox.kick();
+ return out;
+ }
+
+ /** Inside a transaction: drop blob rows no deploy of the project references. -> freed shas */
+ function collectGarbage(projectId) {
+ const freed = q.orphans.all(projectId).map((r) => r.sha256);
+ for (const sha of freed) q.dropBlob.run(projectId, sha);
+ return freed;
+ }
+
+ function unlinkBlobs(projectId, shas) {
+ for (const sha of shas) {
+ try { blobs.remove(projectId, sha); } catch (err) { log.warn('[Host] could not remove object:', err.message); }
+ }
+ }
+
+ function remove(viewer, id) {
+ const { deploy, site, project } = load(viewer, id, 'maintain');
+ let freed = [];
+ store.tx(() => {
+ const active = db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id;
+ if (active === deploy.id) throw new ApiError(409, 'deploy.active', 'this deploy is being served: activate another one first');
+ q.markDeleted.run(store.now(), deploy.id);
+ q.dropFiles.run(deploy.id);
+ freed = collectGarbage(project.id);
+ });
+ unlinkBlobs(project.id, freed);
+ return { deleted: true, objects_removed: freed.length };
+ }
+
+ return {
+ get, load, precheck, create, recordFailure, activate, rollback, remove, collectGarbage, unlinkBlobs, limitsFor,
+ list: (siteId, limit = 100) => q.forSite.all(siteId, Math.min(Math.max(limit, 1), 200)),
+ logOf: (id) => q.logFor.all(id),
+ filesOf: (id) => q.files.all(id),
+ activationsOf: (siteId, limit = 50) => q.activations.all(siteId, limit),
+ };
+}
+
+module.exports = { createDeploys };
new file mode 100644
new file mode 100644
@@ -0,0 +1,222 @@
@@ -0,0 +1,222 @@
+'use strict';
+/**
+ * Domains: every site answers at its default <name>.<sitesDomain>; a custom domain answers only
+ * once its owner has proven control with a DNS TXT record:
+ *
+ * _openvibe-host.<hostname> TXT "openvibe-host-verification=<token>"
+ *
+ * The resolver is injectable (tests use a table; production uses node:dns with a timeout). A
+ * verified hostname belongs to exactly one site (unique index), first-party domains and the sites
+ * domain itself can never be claimed, and sandbox projects cannot add custom domains.
+ *
+ * Verified custom domains are re-checked daily: when the TXT record has been gone for
+ * HOST_DOMAIN_LAPSE_DAYS the domain lapses and stops being served (whoever controls the name now
+ * may claim it). Pending domains that never verify fail after HOST_DOMAIN_PENDING_DAYS.
+ *
+ * TLS for custom domains is an operator step (ovhost nginx tenants + certbot); no certificate or
+ * key ever passes through this API.
+ */
+const crypto = require('crypto');
+const dns = require('dns');
+const contracts = require('openvibe-contracts');
+const { newId, isId } = require('../ids');
+const { ApiError } = require('../http/errors');
+const { principalOf, actorRef } = require('./access');
+
+const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
+const HOSTNAME_RE = new RegExp(`^(?:${LABEL}\\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$`);
+const CHALLENGE_PREFIX = '_openvibe-host';
+const TXT_PREFIX = 'openvibe-host-verification=';
+const DAY = 24 * 3600 * 1000;
+const MIN_RECHECK_MS = 5000;
+
+function isHostname(h) {
+ return typeof h === 'string' && h.length <= 253 && HOSTNAME_RE.test(h);
+}
+
+/** First-party domains from the released service manifests, plus configured extras. */
+function firstPartyDomains(config) {
+ const out = new Set(['openvibe.network', 'openvibe.live', 'openvibe.media', 'openvibe.community', 'openvibe.tools', config.sitesDomain, config.dashboardHost]);
+ try {
+ const m = contracts.services.manifests;
+ for (const s of Array.isArray(m) ? m : Object.values(m || {})) for (const d of s.domains || []) out.add(String(d).toLowerCase());
+ } catch { /* the defaults above still apply */ }
+ for (const d of config.domains.extraReserved) out.add(d);
+ return [...out].filter(Boolean);
+}
+
+function defaultResolver(servers) {
+ const r = new dns.promises.Resolver({ timeout: 5000, tries: 2 });
+ if (servers && servers.length) r.setServers(servers);
+ return { resolveTxt: (name) => r.resolveTxt(name) };
+}
+
+function createDomains({ store, config, access, projects, sites, outbox, resolver = null, log = console }) {
+ const { db } = store;
+ const dnsResolver = resolver || defaultResolver(config.domains.dnsServers);
+ const reserved = firstPartyDomains(config);
+ const q = {
+ byId: db.prepare('SELECT * FROM host_domains WHERE id = ?'),
+ forSite: db.prepare("SELECT * FROM host_domains WHERE site_id = ? ORDER BY kind DESC, created_at, rowid"),
+ verifiedByHost: db.prepare("SELECT * FROM host_domains WHERE hostname = ? AND status = 'verified'"),
+ onSite: db.prepare('SELECT * FROM host_domains WHERE site_id = ? AND hostname = ?'),
+ insert: db.prepare(`INSERT INTO host_domains (id, project_id, site_id, hostname, kind, status, token, created_by, created_at)
+ VALUES (?, ?, ?, ?, 'custom', 'pending', ?, ?, ?)`),
+ checked: db.prepare('UPDATE host_domains SET last_checked_at = ?, last_error = ?, check_count = check_count + 1 WHERE id = ?'),
+ verified: db.prepare("UPDATE host_domains SET status = 'verified', verified_at = ?, last_checked_at = ?, last_error = NULL, check_count = check_count + 1, record_missing_since = NULL WHERE id = ? AND status IN ('pending','failed','lapsed')"),
+ setStatus: db.prepare('UPDATE host_domains SET status = ?, last_error = ? WHERE id = ?'),
+ missing: db.prepare('UPDATE host_domains SET record_missing_since = COALESCE(record_missing_since, ?), last_checked_at = ?, last_error = ?, check_count = check_count + 1 WHERE id = ?'),
+ present: db.prepare('UPDATE host_domains SET record_missing_since = NULL, last_checked_at = ?, last_error = NULL, check_count = check_count + 1 WHERE id = ?'),
+ remove: db.prepare('DELETE FROM host_domains WHERE id = ?'),
+ duePending: db.prepare("SELECT * FROM host_domains WHERE kind = 'custom' AND status = 'pending' AND COALESCE(last_checked_at, 0) < ? ORDER BY COALESCE(last_checked_at, 0) LIMIT ?"),
+ dueVerified: db.prepare("SELECT * FROM host_domains WHERE kind = 'custom' AND status = 'verified' AND COALESCE(last_checked_at, 0) < ? ORDER BY COALESCE(last_checked_at, 0) LIMIT ?"),
+ };
+
+ const challengeName = (hostname) => `${CHALLENGE_PREFIX}.${hostname}`;
+
+ function load(viewer, id, need) {
+ const domain = isId('domain', id) ? q.byId.get(id) : null;
+ const notFound = new ApiError(404, 'domain.not_found', 'no such domain');
+ if (!domain) throw notFound;
+ const site = sites.get(domain.site_id);
+ if (!site || site.status !== 'active') throw notFound;
+ access.authorize(projects.get(domain.project_id), viewer, need, notFound);
+ return { domain, site };
+ }
+
+ function checkHostname(raw) {
+ const hostname = String(raw || '').trim().toLowerCase().replace(/\.$/, '');
+ if (!isHostname(hostname)) throw new ApiError(422, 'domain.invalid_hostname', 'a custom domain is a fully qualified host name such as www.example.org');
+ // Anything that reads as an OpenVibe property (openvibe.<tld>, any subdomain) is refused too.
+ if (/(^|\.)openvibe\.[a-z]+$/.test(hostname)) throw new ApiError(422, 'domain.reserved', `${hostname} is an OpenVibe domain and cannot be used as a custom domain`);
+ for (const r of reserved) {
+ if (hostname === r || hostname.endsWith(`.${r}`)) throw new ApiError(422, 'domain.reserved', `${hostname} is an OpenVibe domain and cannot be used as a custom domain`);
+ }
+ return hostname;
+ }
+
+ /** What the owner must publish: shown by the API and the dashboard. */
+ function instructions(domain, site) {
+ if (domain.kind === 'default') return null;
+ const target = config.domains.cnameTarget || sites.defaultHostname(site.name);
+ const routing = [{ type: 'CNAME', name: domain.hostname, value: target, note: 'for a subdomain such as www.example.org' }];
+ if (config.domains.originIpv4) routing.push({ type: 'A', name: domain.hostname, value: config.domains.originIpv4, note: 'for an apex domain (or use your DNS provider\'s ALIAS/ANAME to the CNAME target)' });
+ if (config.domains.originIpv6) routing.push({ type: 'AAAA', name: domain.hostname, value: config.domains.originIpv6, note: 'for an apex domain' });
+ return {
+ verification: { type: 'TXT', name: challengeName(domain.hostname), value: `${TXT_PREFIX}${domain.token}`, note: 'keep this record: Host re-checks it daily' },
+ routing,
+ tls: 'after verification an operator issues the certificate (Let\'s Encrypt); HTTPS starts working once it is installed',
+ };
+ }
+
+ function add(viewer, siteId, input = {}) {
+ const { site, project } = sites.load(viewer, siteId, 'maintain');
+ const hostname = checkHostname(input.hostname);
+ if (project.environment === 'sandbox') throw new ApiError(403, 'domain.sandbox', 'sandbox projects are served on their default domain only');
+ const quota = projects.quotaOf(project);
+ if (projects.usageOf(project).customDomains >= quota.customDomains) throw new ApiError(429, 'quota.custom_domains', `this project may have ${quota.customDomains} custom domains`);
+ if (q.onSite.get(site.id, hostname)) throw new ApiError(409, 'domain.exists', `${hostname} is already on this site`);
+ if (q.verifiedByHost.get(hostname)) throw new ApiError(409, 'domain.taken', `${hostname} is already verified for another site`);
+ const id = newId('domain', store.now());
+ const token = crypto.randomBytes(20).toString('hex');
+ q.insert.run(id, project.id, site.id, hostname, token, principalOf(viewer), store.now());
+ const domain = q.byId.get(id);
+ return { domain, site };
+ }
+
+ async function lookup(hostname) {
+ try {
+ const records = await dnsResolver.resolveTxt(challengeName(hostname));
+ return { ok: true, values: (records || []).map((chunks) => (Array.isArray(chunks) ? chunks.join('') : String(chunks))) };
+ } catch (err) {
+ const code = err && err.code;
+ if (code === 'ENOTFOUND' || code === 'ENODATA' || code === 'NXDOMAIN') return { ok: true, values: [] };
+ return { ok: false, error: `DNS lookup failed (${code || 'error'})` };
+ }
+ }
+
+ /** Check the TXT record now. -> the domain row after the check. */
+ async function verifyDomain(domain, viewer = null, { traceparent } = {}) {
+ if (domain.kind !== 'custom') return domain;
+ if (domain.status === 'verified') return domain;
+ const now = store.now();
+ if (domain.last_checked_at && now - domain.last_checked_at < MIN_RECHECK_MS) return domain;
+ const r = await lookup(domain.hostname);
+ const expected = `${TXT_PREFIX}${domain.token}`;
+ if (!r.ok) { q.checked.run(store.now(), r.error, domain.id); return q.byId.get(domain.id); }
+ if (!r.values.includes(expected)) {
+ q.checked.run(store.now(), r.values.length ? `${challengeName(domain.hostname)} has TXT records, but not the expected value` : `no TXT record at ${challengeName(domain.hostname)} yet`, domain.id);
+ return q.byId.get(domain.id);
+ }
+ try {
+ store.tx(() => {
+ if (q.verifiedByHost.get(domain.hostname)) throw new ApiError(409, 'domain.taken', `${domain.hostname} was verified for another site first`);
+ const site = sites.get(domain.site_id);
+ if (!site || site.status !== 'active') throw new ApiError(404, 'domain.not_found', 'no such domain');
+ if (q.verified.run(store.now(), store.now(), domain.id).changes !== 1) return;
+ outbox.emit({
+ event_type: 'host.domain.verified', actor: viewer ? actorRef(viewer) : { type: 'service', id: 'host' }, visibility: 'internal', priority: 'low',
+ subject: { type: 'domain', id: domain.id },
+ payload: { project_id: domain.project_id, site_id: domain.site_id, site: site.name, hostname: domain.hostname },
+ }, { traceparent });
+ });
+ } catch (err) {
+ if (err instanceof ApiError && err.code === 'domain.taken') { q.setStatus.run('failed', err.message, domain.id); return q.byId.get(domain.id); }
+ throw err;
+ }
+ outbox.kick();
+ return q.byId.get(domain.id);
+ }
+
+ async function verify(viewer, id, opts = {}) {
+ const { domain } = load(viewer, id, 'maintain');
+ return verifyDomain(domain, viewer, opts);
+ }
+
+ function remove(viewer, id) {
+ const { domain } = load(viewer, id, 'maintain');
+ if (domain.kind === 'default') throw new ApiError(409, 'domain.default', 'the default domain goes away only with the site');
+ q.remove.run(domain.id);
+ return { deleted: true };
+ }
+
+ /** Worker pass: pending domains (every interval), verified domains (daily). */
+ async function recheck({ limit = 50 } = {}) {
+ const now = store.now();
+ const summary = { verified: 0, failed: 0, lapsed: 0, checked: 0 };
+ for (const d of q.duePending.all(now - config.domains.recheckIntervalMs + 1000, limit)) {
+ if (now - d.created_at > config.domains.pendingDays * DAY) {
+ q.setStatus.run('failed', `not verified within ${config.domains.pendingDays} days`, d.id);
+ summary.failed++;
+ continue;
+ }
+ const after = await verifyDomain(d);
+ summary.checked++;
+ if (after.status === 'verified') summary.verified++;
+ }
+ for (const d of q.dueVerified.all(now - DAY, limit)) {
+ const r = await lookup(d.hostname);
+ summary.checked++;
+ if (!r.ok) { q.checked.run(store.now(), r.error, d.id); continue; }
+ if (r.values.includes(`${TXT_PREFIX}${d.token}`)) { q.present.run(store.now(), d.id); continue; }
+ q.missing.run(store.now(), store.now(), `the TXT record at ${challengeName(d.hostname)} is gone`, d.id);
+ const row = q.byId.get(d.id);
+ if (row.record_missing_since && store.now() - row.record_missing_since > config.domains.lapseDays * DAY) {
+ q.setStatus.run('lapsed', `the TXT record has been gone for more than ${config.domains.lapseDays} days; the domain is no longer served`, d.id);
+ summary.lapsed++;
+ log.warn(`[Host] custom domain ${d.hostname} lapsed (TXT record gone)`);
+ }
+ }
+ return summary;
+ }
+
+ return {
+ load, add, verify, verifyDomain, remove, recheck, instructions, checkHostname,
+ listForSite: (siteId) => q.forSite.all(siteId),
+ get: (id) => (isId('domain', id) ? q.byId.get(id) || null : null),
+ verifiedCustom: () => db.prepare("SELECT hostname, site_id FROM host_domains WHERE kind = 'custom' AND status = 'verified' ORDER BY hostname").all(),
+ challengeName, TXT_PREFIX,
+ };
+}
+
+module.exports = { createDomains, isHostname, firstPartyDomains, CHALLENGE_PREFIX, TXT_PREFIX };
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
+'use strict';
+/**
+ * Projects, members and quotas.
+ *
+ * ADR-014: projects are owned by OpenVibe.Network. Network has no projects yet, so a project is
+ * created here by its owner (a usr_ subject) and gets a prj_ id; `network_project_id` records the
+ * Network project once it exists, and every Host resource is keyed by the project id so one
+ * revocation reaches all of it. Quotas are enforced here, by the owning service.
+ */
+const { ids } = require('openvibe-contracts');
+const { newId, isId } = require('../ids');
+const { ApiError } = require('../http/errors');
+const { principalOf } = require('./access');
+
+const NAME_MAX = 80;
+const NETWORK_PROJECT_RE = /^[A-Za-z0-9_:-]{3,80}$/;
+const PRINCIPAL_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|app:app_[0-9A-HJKMNP-TV-Z]{26}|svc:[a-z][a-z0-9-]{1,39})$/;
+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 }) {
+ const { db } = store;
+ const q = {
+ byId: db.prepare('SELECT * FROM host_projects WHERE id = ?'),
+ forPrincipal: db.prepare(`SELECT p.*, m.role FROM host_projects p JOIN host_project_members m ON m.project_id = p.id
+ WHERE m.principal = ? AND p.status = 'active' ORDER BY p.created_at DESC, p.rowid DESC LIMIT 200`),
+ all: db.prepare("SELECT p.*, NULL AS role FROM host_projects p WHERE p.status = 'active' ORDER BY p.created_at DESC, p.rowid DESC LIMIT 200"),
+ ownedCount: db.prepare("SELECT COUNT(*) AS n FROM host_projects WHERE owner_subject = ? AND status = 'active'"),
+ insert: db.prepare(`INSERT INTO host_projects (id, network_project_id, owner_subject, name, environment, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`),
+ addMember: db.prepare(`INSERT INTO host_project_members (project_id, principal, role, added_by, created_at) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (project_id, principal) DO UPDATE SET role = excluded.role`),
+ removeMember: db.prepare('DELETE FROM host_project_members WHERE project_id = ? AND principal = ?'),
+ members: db.prepare('SELECT principal, role, added_by, created_at FROM host_project_members WHERE project_id = ? ORDER BY created_at'),
+ quotaRow: db.prepare('SELECT * FROM host_quotas WHERE project_id = ?'),
+ storageUsed: db.prepare('SELECT COALESCE(SUM(size), 0) AS n, COUNT(*) AS c FROM host_blobs WHERE project_id = ?'),
+ deploysSince: db.prepare('SELECT COUNT(*) AS n FROM host_deploys WHERE project_id = ? AND created_at > ?'),
+ siteCount: db.prepare("SELECT COUNT(*) AS n FROM host_sites WHERE project_id = ? AND status = 'active'"),
+ customDomainCount: db.prepare("SELECT COUNT(*) AS n FROM host_domains WHERE project_id = ? AND kind = 'custom' AND status IN ('pending','verified')"),
+ markDeleted: db.prepare("UPDATE host_projects SET status = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?"),
+ };
+
+ function get(id) {
+ return isId('project', id) ? q.byId.get(id) || null : null;
+ }
+
+ /** The effective quota: the project's override where set, else the environment's default. */
+ function quotaOf(project) {
+ const defaults = config.quotas[project.environment] || config.quotas.production;
+ const row = q.quotaRow.get(project.id) || {};
+ const out = {};
+ for (const [col, key] of Object.entries(QUOTA_FIELDS)) out[key] = row[col] != null ? row[col] : defaults[key];
+ return out;
+ }
+
+ function usageOf(project) {
+ const s = q.storageUsed.get(project.id);
+ return {
+ storageBytes: s.n,
+ objects: s.c,
+ deploysLast24h: q.deploysSince.get(project.id, store.now() - DAY).n,
+ sites: q.siteCount.get(project.id).n,
+ customDomains: q.customDomainCount.get(project.id).n,
+ };
+ }
+
+ function create(viewer, input = {}) {
+ const owner = principalOf(viewer);
+ if (!owner || !ids.isSubjectId('user', owner)) {
+ throw new ApiError(403, 'project.owner_must_be_user', 'a project is owned by a person (usr_…): an app or service must name the owner with X-OV-Subject');
+ }
+ const name = String(input.name || '').trim();
+ if (!name || name.length > NAME_MAX) throw new ApiError(422, 'project.invalid_name', `a project needs a name of 1–${NAME_MAX} characters`);
+ const environment = input.environment == null ? 'production' : String(input.environment);
+ if (!['production', 'sandbox'].includes(environment)) throw new ApiError(422, 'project.invalid_environment', 'environment is production or sandbox');
+ if (viewer.kind === 'service' && viewer.env === 'sandbox' && environment !== 'sandbox') {
+ throw new ApiError(403, 'environment.sandbox_token', 'a sandbox credential can only create sandbox projects');
+ }
+ let networkProjectId = null;
+ if (input.network_project_id != null && input.network_project_id !== '') {
+ networkProjectId = String(input.network_project_id);
+ if (!NETWORK_PROJECT_RE.test(networkProjectId)) throw new ApiError(422, 'project.invalid_network_project_id', 'network_project_id is not a project id');
+ }
+ if (q.ownedCount.get(owner).n >= 50) throw new ApiError(429, 'quota.projects', 'you already own 50 projects');
+ const id = newId('project', store.now());
+ const now = store.now();
+ try {
+ store.tx(() => {
+ q.insert.run(id, networkProjectId, owner, name, environment, now, now);
+ q.addMember.run(id, owner, 'owner', owner, now);
+ });
+ } catch (err) {
+ if (/UNIQUE/.test(err.message)) throw new ApiError(409, 'project.network_project_taken', 'that Network project already has a Host project');
+ throw err;
+ }
+ return get(id);
+ }
+
+ function listFor(viewer) {
+ if (viewer.kind === 'user' && viewer.staff && viewer.allProjects) return q.all.all();
+ const p = principalOf(viewer);
+ return p ? q.forPrincipal.all(p) : [];
+ }
+
+ function members(project) {
+ return q.members.all(project.id);
+ }
+
+ function setMember(viewer, project, principal, role) {
+ access.authorize(project, viewer, 'own');
+ if (!PRINCIPAL_RE.test(String(principal))) throw new ApiError(422, 'member.invalid_principal', 'a member is a usr_ subject, an app:app_ principal or a svc: principal');
+ if (!['maintainer', 'deployer', 'owner'].includes(role)) throw new ApiError(422, 'member.invalid_role', 'role is owner, maintainer or deployer');
+ if (role === 'owner' && !principal.startsWith('usr_')) throw new ApiError(422, 'member.invalid_role', 'only a person can be an owner');
+ if (principal === project.owner_subject && role !== 'owner') throw new ApiError(409, 'member.owner_fixed', 'the project owner stays owner');
+ q.addMember.run(project.id, principal, role, principalOf(viewer), store.now());
+ return members(project);
+ }
+
+ function removeMember(viewer, project, principal) {
+ access.authorize(project, viewer, 'own');
+ if (principal === project.owner_subject) throw new ApiError(409, 'member.owner_fixed', 'the project owner cannot be removed');
+ q.removeMember.run(project.id, principal);
+ return members(project);
+ }
+
+ function setQuota(viewer, project, input = {}) {
+ access.authorize(project, viewer, 'staff');
+ const row = q.quotaRow.get(project.id) || {};
+ const next = {};
+ for (const col of Object.keys(QUOTA_FIELDS)) {
+ if (!(col in input)) { next[col] = row[col] != null ? row[col] : null; continue; }
+ const v = input[col];
+ if (v === null) { next[col] = null; continue; }
+ if (!Number.isSafeInteger(v) || v < 0) throw new ApiError(422, 'quota.invalid', `${col} must be a non-negative integer or null (the default)`);
+ next[col] = v;
+ }
+ db.prepare(`INSERT INTO host_quotas (project_id, storage_bytes, deploys_per_day, max_files, max_file_bytes, sites, custom_domains, updated_by, updated_at)
+ VALUES (@project_id, @storage_bytes, @deploys_per_day, @max_files, @max_file_bytes, @sites, @custom_domains, @updated_by, @updated_at)
+ ON CONFLICT (project_id) DO UPDATE SET storage_bytes = excluded.storage_bytes, deploys_per_day = excluded.deploys_per_day,
+ max_files = excluded.max_files, max_file_bytes = excluded.max_file_bytes, sites = excluded.sites,
+ custom_domains = excluded.custom_domains, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
+ .run({ project_id: project.id, ...next, updated_by: principalOf(viewer), updated_at: store.now() });
+ return quotaOf(project);
+ }
+
+ /** 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');
+ const now = store.now();
+ store.tx(() => {
+ for (const s of sites.listForProject(project.id)) sites.markDeleted(s, now);
+ db.prepare("UPDATE host_deploys SET state = 'deleted', deleted_at = ? WHERE project_id = ? AND state <> 'deleted'").run(now, project.id);
+ db.prepare("DELETE FROM host_deploy_files WHERE deploy_id IN (SELECT id FROM host_deploys WHERE project_id = ?)").run(project.id);
+ db.prepare('DELETE FROM host_blobs WHERE project_id = ?').run(project.id);
+ db.prepare('DELETE FROM host_domains WHERE project_id = ?').run(project.id);
+ q.markDeleted.run(now, now, project.id);
+ });
+ try { blobs.removeProject(project.id); } catch (err) { log.warn('[Host] could not remove project objects:', err.message); }
+ return { deleted: true };
+ }
+
+ return { get, create, listFor, members, setMember, removeMember, quotaOf, usageOf, setQuota, remove, QUOTA_FIELDS };
+}
+
+module.exports = { createProjects };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+/**
+ * Sites: a name (served at <name>.<sitesDomain>) and the pointer to the deploy it serves.
+ *
+ * Names are one DNS label: 3–40 characters of a–z, 0–9 and single hyphens. Names that could be
+ * mistaken for the platform (www, api, admin, openvibe, …) are reserved. A deleted site's name is
+ * held for 30 days so nobody else can pick it up and serve content on links that still point there.
+ */
+const { newId, isId } = require('../ids');
+const { ApiError } = require('../http/errors');
+const { principalOf } = require('./access');
+
+const NAME_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,39}$/;
+const RESERVED = new Set([
+ 'www', 'api', 'app', 'apps', 'admin', 'administrator', 'root', 'dashboard', 'console', 'host', 'hosting', 'static', 'assets', 'cdn',
+ 'edge', 'origin', 'mail', 'email', 'smtp', 'imap', 'pop', 'ftp', 'ssh', 'vpn', 'ns', 'ns1', 'ns2', 'dns', 'mx', 'status', 'metrics',
+ 'health', 'auth', 'login', 'signin', 'sso', 'oauth', 'account', 'accounts', 'billing', 'pay', 'payments', 'wallet', 'support', 'help',
+ 'docs', 'blog', 'news', 'security', 'abuse', 'postmaster', 'hostmaster', 'webmaster', 'openvibe', 'openvibers', 'network', 'live',
+ 'media', 'community', 'events', 'chat', 'tools', 'codes', 'sites', 'sandbox', 'staging', 'test', 'internal', 'localhost', 'wpad',
+ 'autoconfig', 'autodiscover', 'isatap', 'acme', 'well-known',
+]);
+const HOLD_MS = 30 * 24 * 3600 * 1000;
+
+function createSites({ store, config, access, projects }) {
+ const { db } = store;
+ const reserved = new Set([...RESERVED, ...config.sites.extraReservedNames]);
+ const q = {
+ byId: db.prepare('SELECT * FROM host_sites WHERE id = ?'),
+ byName: db.prepare("SELECT * FROM host_sites WHERE name = ? AND status = 'active'"),
+ held: db.prepare("SELECT project_id FROM host_sites WHERE name = ? AND status = 'deleted' AND deleted_at > ? ORDER BY deleted_at DESC LIMIT 1"),
+ forProject: db.prepare("SELECT * FROM host_sites WHERE project_id = ? AND status = 'active' ORDER BY created_at, rowid"),
+ insert: db.prepare(`INSERT INTO host_sites (id, project_id, name, active_deploy_id, status, created_by, created_at, updated_at)
+ VALUES (?, ?, ?, NULL, 'active', ?, ?, ?)`),
+ insertDefaultDomain: db.prepare(`INSERT INTO host_domains (id, project_id, site_id, hostname, kind, status, token, created_by, created_at, verified_at)
+ VALUES (?, ?, ?, ?, 'default', 'verified', NULL, ?, ?, ?)`),
+ markDeleted: db.prepare("UPDATE host_sites SET status = 'deleted', active_deploy_id = NULL, deleted_at = ?, updated_at = ? WHERE id = ?"),
+ };
+
+ const defaultHostname = (name) => `${name}.${config.sitesDomain}`;
+
+ function get(id) {
+ return isId('site', id) ? q.byId.get(id) || null : null;
+ }
+
+ /** The site and its project, authorized at `need`; 404 for anyone without access. */
+ function load(viewer, id, need) {
+ const site = get(id);
+ const notFound = new ApiError(404, 'site.not_found', 'no such site');
+ if (!site || site.status !== 'active') throw notFound;
+ const project = projects.get(site.project_id);
+ const role = access.authorize(project, viewer, need, notFound);
+ return { site, project, role };
+ }
+
+ function checkName(name) {
+ if (typeof name !== 'string' || !NAME_RE.test(name)) {
+ throw new ApiError(422, 'site.invalid_name', 'a site name is 3–40 characters: a–z, 0–9 and single hyphens, starting and ending with a letter or digit');
+ }
+ if (reserved.has(name) || /^xn--/.test(name) || /^openvibe/.test(name)) throw new ApiError(422, 'site.name_reserved', `"${name}" is reserved`);
+ }
+
+ function create(viewer, project, input = {}) {
+ access.authorize(project, viewer, 'maintain');
+ const name = String(input.name || '').trim().toLowerCase();
+ checkName(name);
+ const quota = projects.quotaOf(project);
+ if (projects.usageOf(project).sites >= quota.sites) throw new ApiError(429, 'quota.sites', `this project may have ${quota.sites} sites`);
+ if (q.byName.get(name)) throw new ApiError(409, 'site.name_taken', `${defaultHostname(name)} is taken`);
+ const held = q.held.get(name, store.now() - HOLD_MS);
+ if (held && held.project_id !== project.id) throw new ApiError(409, 'site.name_held', `${defaultHostname(name)} was deleted recently and is held for 30 days`);
+ const id = newId('site', store.now());
+ const now = store.now();
+ const who = principalOf(viewer);
+ try {
+ store.tx(() => {
+ q.insert.run(id, project.id, name, who, now, now);
+ q.insertDefaultDomain.run(newId('domain', now), project.id, id, defaultHostname(name), who, now, now);
+ });
+ } catch (err) {
+ if (/UNIQUE/.test(err.message)) throw new ApiError(409, 'site.name_taken', `${defaultHostname(name)} is taken`);
+ throw err;
+ }
+ return get(id);
+ }
+
+ function listForProject(projectId) {
+ return q.forProject.all(projectId);
+ }
+
+ /** Inside a transaction: the site stops serving immediately; deploys and domains go with it. */
+ function markDeleted(site, now) {
+ q.markDeleted.run(now, now, site.id);
+ db.prepare("UPDATE host_deploys SET state = 'deleted', deleted_at = ? WHERE site_id = ? AND state <> 'deleted'").run(now, site.id);
+ db.prepare('DELETE FROM host_deploy_files WHERE deploy_id IN (SELECT id FROM host_deploys WHERE site_id = ?)').run(site.id);
+ db.prepare('DELETE FROM host_domains WHERE site_id = ?').run(site.id);
+ }
+
+ function remove(viewer, id, { deploys }) {
+ const { site, project } = load(viewer, id, 'maintain');
+ const now = store.now();
+ let freed = [];
+ store.tx(() => {
+ markDeleted(site, now);
+ freed = deploys.collectGarbage(project.id);
+ });
+ deploys.unlinkBlobs(project.id, freed);
+ return { deleted: true };
+ }
+
+ return { get, load, create, listForProject, markDeleted, remove, defaultHostname, checkName, byName: (n) => q.byName.get(n) || null };
+}
+
+module.exports = { createSites, RESERVED, NAME_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+
+/**
+ * Host → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
+ *
+ * host.deploy.created an upload became an immutable, ready deploy
+ * host.deploy.activated a site's active deploy changed (payload.rollback marks a rollback)
+ * host.deploy.failed an upload was refused (payload.code, problem codes; no file contents)
+ * host.domain.verified a custom domain's TXT record was found; the domain is now served
+ *
+ * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only
+ * if its change committed. The relay publishes with Host's service token (events.event.publish,
+ * audience openvibe.events) only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise
+ * rows wait in event_outbox and /api/ready reports the relay as off.
+ */
+const { createClient } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+
+const EVENT_TYPES = Object.freeze(['host.deploy.created', 'host.deploy.activated', 'host.deploy.failed', 'host.domain.verified']);
+
+function createHostOutbox({ db, config, fetchImpl, now, log = console }) {
+ const enabled = Boolean(config.events.url && config.oauth.clientSecret);
+ const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0 };
+ if (fetchImpl) clientOpts.fetch = fetchImpl;
+ if (enabled) {
+ clientOpts.tokenProvider = createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),
+ });
+ } else {
+ clientOpts.getToken = async () => { throw new Error('events relay disabled (Events URL or service credentials not configured)'); };
+ }
+ const events = createEventsClient(createClient(clientOpts), { source: 'host' });
+ let lastError = null;
+ const outbox = createOutbox(db, {
+ events,
+ intervalMs: config.events.intervalMs,
+ now,
+ onError: (err) => {
+ const msg = err && err.message;
+ if (msg !== lastError) log.warn('[Host] event publish failed (will retry):', msg);
+ lastError = msg;
+ },
+ });
+ outbox.ensureSchema();
+
+ /** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
+ function emit(envelope, { traceparent } = {}) {
+ if (!EVENT_TYPES.includes(envelope.event_type)) throw new Error(`undeclared event type ${envelope.event_type}`);
+ return outbox.enqueue(envelope, { traceparent });
+ }
+
+ return {
+ emit,
+ outbox,
+ enabled,
+ start() { if (enabled) outbox.start(); },
+ stop: () => outbox.stop(),
+ kick() { if (enabled) outbox.kick(); },
+ status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
+ };
+}
+
+module.exports = { createHostOutbox, EVENT_TYPES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
+'use strict';
+/**
+ * /api/v1 — Host's API for people (a Network user JWT as a Bearer token) and principals (Network
+ * client-credentials tokens for audience openvibe.host). Cookies are ignored here: tenant pages
+ * on <site>.openvibe.host are same-site with openvibe.host, so cookie-authenticated API calls could
+ * be forged from any tenant page.
+ *
+ * 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
+ * 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
+ * domains GET/POST /sites/:id/domains · POST /domains/:id/verify
+ * DELETE /domains/:id host.domain.manage
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { run, jsonBody, privateNoStore, sendError, ApiError } = require('./errors');
+const { guard } = require('../auth/viewer');
+const { readUpload, UploadError } = require('./upload');
+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 router = express.Router();
+
+ // CORS for first-party browser origins presenting a Bearer token (never credentials/cookies).
+ router.use((req, res, next) => {
+ const origin = req.get('origin');
+ if (origin && config.apiCorsOrigins.includes(origin)) {
+ res.set('Access-Control-Allow-Origin', origin);
+ res.vary('Origin');
+ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
+ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
+ res.set('Access-Control-Max-Age', '600');
+ }
+ if (req.method === 'OPTIONS') return res.status(204).end();
+ privateNoStore(res);
+ next();
+ });
+ router.use(viewers.middleware('api'));
+
+ const siteUrl = (hostname) => {
+ 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 tp = (req) => (req.ov ? req.ov.traceparent : undefined);
+ const loadProject = (req, need) => {
+ const project = projects.get(req.params.id);
+ const role = ctx.access.authorize(project, req.viewer, need);
+ return { project, role };
+ };
+
+ // ── 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)) };
+ }));
+ 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');
+ const p = projects.create(req.viewer, req.body || {});
+ return { project: projectOut(p, 'owner') };
+ }, 201));
+ router.get('/projects/:id', guard('host.site.manage'), run((req) => {
+ const { project, role } = loadProject(req, 'read');
+ return { project: projectOut(project, role), sites: sites.listForProject(project.id).map(siteOut), members: projects.members(project).map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };
+ }));
+ router.delete('/projects/:id', guard('host.site.manage'), run((req) => {
+ const { project } = loadProject(req, 'read');
+ return projects.remove(req.viewer, project, { sites });
+ }));
+ router.get('/projects/:id/quota', guard('host.site.manage'), run((req) => {
+ const { project } = loadProject(req, 'read');
+ return { quota: out.quotaOut(projects.quotaOf(project)), usage: out.usageOut(projects.usageOf(project)) };
+ }));
+ router.put('/projects/:id/quota', guard('host.site.manage'), jsonBody, run((req) => {
+ const project = projects.get(req.params.id);
+ const q = projects.setQuota(req.viewer, project, req.body || {});
+ return { quota: out.quotaOut(q), usage: out.usageOut(projects.usageOf(project)) };
+ }));
+ router.put('/projects/:id/members/:principal', guard('host.site.manage'), jsonBody, run((req) => {
+ const { project } = loadProject(req, 'read');
+ const list = projects.setMember(req.viewer, project, req.params.principal, String((req.body || {}).role || ''));
+ return { members: list.map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };
+ }));
+ router.delete('/projects/:id/members/:principal', guard('host.site.manage'), run((req) => {
+ const { project } = loadProject(req, 'read');
+ const list = projects.removeMember(req.viewer, project, req.params.principal);
+ return { members: list.map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };
+ }));
+
+ // ── Sites ───────────────────────────────────────────────
+ router.get('/projects/:id/sites', guard('host.site.manage'), run((req) => {
+ const { project } = loadProject(req, 'read');
+ return { sites: sites.listForProject(project.id).map(siteOut) };
+ }));
+ router.post('/projects/:id/sites', guard('host.site.manage'), jsonBody, run((req) => {
+ const project = projects.get(req.params.id);
+ ctx.access.authorize(project, req.viewer, 'read');
+ return { site: siteOut(sites.create(req.viewer, project, req.body || {})) };
+ }, 201));
+ router.get('/sites/:id', guard('host.site.manage'), run((req) => {
+ const { site } = sites.load(req.viewer, req.params.id, 'read');
+ return { site: siteOut(site), domains: domains.listForSite(site.id).map((d) => out.domain(d, domains.instructions(d, site))) };
+ }));
+ router.delete('/sites/:id', guard('host.site.manage'), run((req) => sites.remove(req.viewer, req.params.id, { deploys })));
+
+ // ── Deploys ─────────────────────────────────────────────
+ router.get('/sites/:id/deploys', guard('host.deploy.create'), run((req) => {
+ const { site } = sites.load(req.viewer, req.params.id, 'deploy');
+ 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 })),
+ activations: deploys.activationsOf(site.id, 20).map(out.activation),
+ };
+ }));
+
+ router.post('/sites/:id/deploys', guard('host.deploy.create'), async (req, res) => {
+ try {
+ const pre = deploys.precheck(req.viewer, req.params.id);
+ let up;
+ try {
+ up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, limits: pre.limits });
+ } catch (err) {
+ if (!(err instanceof UploadError)) throw err;
+ if (err.closeConnection) res.set('Connection', 'close');
+ const multipart = String(req.headers['content-type'] || '').startsWith('multipart/');
+ const r = 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 = deploys.create(req.viewer, pre, up.entries, { source: up.source, activate, notes: up.notes, traceparent: tp(req) });
+ 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.
+ if (!req.complete) res.set('Connection', 'close');
+ if (!res.headersSent) sendError(res, req, err);
+ }
+ });
+
+ router.get('/deploys/:id', guard('host.deploy.create'), run((req) => {
+ const { deploy, site } = deploys.load(req.viewer, req.params.id, 'deploy');
+ 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');
+ 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) => {
+ const body = req.body || {};
+ const r = deploys.activate(req.viewer, req.params.id, { expectedActive: 'expected_active' in body ? body.expected_active : undefined, traceparent: tp(req) });
+ return { active_deploy_id: r.deploy_id, previous_deploy_id: r.previous_deploy_id, changed: r.changed };
+ }));
+ router.post('/sites/:id/rollback', guard('host.deploy.create'), jsonBody, run((req) => {
+ const body = req.body || {};
+ const r = deploys.rollback(req.viewer, req.params.id, { deployId: body.deploy_id || null, expectedActive: 'expected_active' in body ? body.expected_active : undefined, traceparent: tp(req) });
+ return { active_deploy_id: r.deploy_id, previous_deploy_id: r.previous_deploy_id, changed: r.changed };
+ }));
+ router.delete('/deploys/:id', guard('host.site.manage'), run((req) => deploys.remove(req.viewer, req.params.id)));
+
+ // ── Domains ─────────────────────────────────────────────
+ router.get('/sites/:id/domains', guard('host.domain.manage'), run((req) => {
+ const { site } = sites.load(req.viewer, req.params.id, 'read');
+ return { domains: domains.listForSite(site.id).map((d) => out.domain(d, domains.instructions(d, site))) };
+ }));
+ router.post('/sites/:id/domains', guard('host.domain.manage'), jsonBody, run((req) => {
+ const { domain, site } = domains.add(req.viewer, req.params.id, req.body || {});
+ return { domain: out.domain(domain, domains.instructions(domain, site)) };
+ }, 201));
+ router.post('/domains/:id/verify', guard('host.domain.manage'), run(async (req) => {
+ const d = await domains.verify(req.viewer, req.params.id, { traceparent: tp(req) });
+ const site = sites.get(d.site_id);
+ return { domain: out.domain(d, domains.instructions(d, site)) };
+ }));
+ router.delete('/domains/:id', guard('host.domain.manage'), run((req) => domains.remove(req.viewer, req.params.id)));
+
+ 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;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
+'use strict';
+/**
+ * The dashboard: server-rendered pages for projects, sites, deploys (with rollback), domains
+ * (with DNS instructions), quotas and upload logs. Every action is a POST form that must come from
+ * the dashboard's own origin and carry the form token (see auth/forms.js for why SameSite is not
+ * enough on openvibe.host). Every response is private and never cached by a shared cache.
+ */
+const express = require('express');
+const { renderPage } = require('../render/layout');
+const pages = require('../render/pages');
+const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
+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 router = express.Router();
+ const urlencoded = express.urlencoded({ extended: false, limit: '16kb' });
+
+ const siteUrl = (s) => {
+ const u = new URL(config.baseUrl);
+ return `${u.protocol}//${sites.defaultHostname(s.name)}${u.port ? `:${u.port}` : ''}`;
+ };
+
+ 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 fail(req, res, err) {
+ if (err instanceof ApiError || (err && Number.isInteger(err.status) && err.code)) {
+ if (err.status === 401) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ return page(req, res, err.status, 'Error', pages.errorPage({ status: err.status, message: err.message }));
+ }
+ log.error('[Host dashboard]', err && err.stack ? err.stack : err);
+ return page(req, res, 500, 'Error', pages.errorPage({ status: 500, message: 'Something went wrong on our side. Try again in a moment.' }));
+ }
+
+ const view = (fn) => async (req, res) => {
+ try { await fn(req, res); } catch (err) { fail(req, res, err); }
+ };
+
+ /** POST guard: signed in, same origin, valid form token (urlencoded forms). */
+ const action = (fn) => [urlencoded, async (req, res) => {
+ try {
+ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
+ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) throw new ApiError(403, 'form.invalid', 'this form expired or did not come from this dashboard; reload the page and try again');
+ const to = await fn(req, res);
+ if (to && !res.headersSent) res.redirect(303, to);
+ } catch (err) { fail(req, res, err); }
+ }];
+
+ const notice = (req) => {
+ const n = req.query.notice;
+ return typeof n === 'string' && n.length < 300 ? { kind: req.query.kind === 'error' ? 'error' : 'info', text: n } : 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());
+ page(req, res, 200, 'Projects', pages.home({ projects: projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer) }), notice(req));
+ }));
+
+ router.get('/projects/:id', view((req, res) => {
+ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
+ const project = projects.get(req.params.id);
+ const role = access.authorize(project, req.viewer, 'read');
+ 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));
+ }));
+
+ router.get('/sites/:id', view((req, res) => {
+ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
+ const { site, project, role } = sites.load(req.viewer, req.params.id, 'read');
+ page(req, res, 200, site.name, pages.site({
+ 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));
+ }));
+
+ 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');
+ 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));
+ }));
+
+ // ── Actions ─────────────────────────────────────────────
+ router.post('/projects', action((req) => {
+ const p = projects.create(req.viewer, { name: req.body.name, environment: req.body.environment });
+ return back(`/projects/${p.id}`, 'Project created.');
+ }));
+ router.post('/projects/:id/sites', action((req) => {
+ const project = projects.get(req.params.id);
+ access.authorize(project, req.viewer, 'read');
+ const s = sites.create(req.viewer, project, { name: req.body.name });
+ return back(`/sites/${s.id}`, `Site created: ${siteUrl(s)}`);
+ }));
+ router.post('/projects/:id/members', action((req) => {
+ const project = projects.get(req.params.id);
+ access.authorize(project, req.viewer, 'read');
+ projects.setMember(req.viewer, project, String(req.body.principal || '').trim(), String(req.body.role || ''));
+ return back(`/projects/${project.id}`, 'Member saved.');
+ }));
+ router.post('/projects/:id/members/remove', action((req) => {
+ const project = projects.get(req.params.id);
+ access.authorize(project, req.viewer, 'read');
+ projects.removeMember(req.viewer, project, String(req.body.principal || ''));
+ return back(`/projects/${project.id}`, 'Member removed.');
+ }));
+ router.post('/projects/:id/delete', action((req) => {
+ const project = projects.get(req.params.id);
+ access.authorize(project, req.viewer, 'read');
+ if (req.body.confirm !== 'yes') throw new ApiError(422, 'form.confirm', 'tick the confirmation box to delete');
+ projects.remove(req.viewer, project, { sites });
+ return back('/', 'Project deleted.');
+ }));
+ router.post('/sites/:id/domains', action((req) => {
+ const { domain } = 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.`);
+ }));
+ router.post('/sites/:id/rollback', action((req) => {
+ const r = deploys.rollback(req.viewer, req.params.id, { expectedActive: req.body.expected_active || null, traceparent: req.ov && req.ov.traceparent });
+ return back(`/sites/${req.params.id}`, r.changed ? `Rolled back: ${r.deploy_id} is active.` : 'Nothing changed.');
+ }));
+ router.post('/sites/:id/delete', action((req) => {
+ const { site } = sites.load(req.viewer, req.params.id, 'maintain');
+ if (req.body.confirm !== 'yes') throw new ApiError(422, 'form.confirm', 'tick the confirmation box to delete');
+ sites.remove(req.viewer, site.id, { deploys });
+ return back(`/projects/${site.project_id}`, `${site.name} deleted.`);
+ }));
+ router.post('/deploys/:id/activate', action((req) => {
+ const { site } = deploys.load(req.viewer, req.params.id, 'deploy');
+ const r = deploys.activate(req.viewer, req.params.id, { expectedActive: req.body.expected_active || null, traceparent: req.ov && req.ov.traceparent });
+ return back(`/sites/${site.id}`, r.changed ? `${r.deploy_id} is active.` : 'That deploy was already active.');
+ }));
+ router.post('/deploys/:id/delete', action((req) => {
+ const { site } = deploys.load(req.viewer, req.params.id, 'maintain');
+ deploys.remove(req.viewer, req.params.id);
+ return back(`/sites/${site.id}`, 'Deploy deleted.');
+ }));
+ router.post('/domains/:id/verify', action(async (req) => {
+ const d = await domains.verify(req.viewer, req.params.id, { traceparent: req.ov && req.ov.traceparent });
+ return back(`/sites/${d.site_id}`, d.status === 'verified' ? `${d.hostname} is verified and served.` : `${d.hostname}: ${d.last_error || d.status}`, d.status === 'verified' ? null : 'error');
+ }));
+ router.post('/domains/:id/delete', action((req) => {
+ const { domain } = domains.load(req.viewer, req.params.id, 'maintain');
+ domains.remove(req.viewer, domain.id);
+ return back(`/sites/${domain.site_id}`, `${domain.hostname} removed.`);
+ }));
+
+ // Upload: multipart; the form token is checked after the body is parsed (Origin before).
+ router.post('/sites/:id/deploys', async (req, res) => {
+ 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);
+ let up;
+ try {
+ up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, limits: pre.limits });
+ } catch (err) {
+ if (!(err instanceof UploadError)) throw err;
+ if (err.closeConnection) res.set('Connection', 'close');
+ if (!checkCsrf(config, req.viewer, err.fields && err.fields.csrf) && !err.closeConnection) throw new ApiError(403, 'form.invalid', 'this form expired; reload the page and try again');
+ const r = deploys.recordFailure(req.viewer, pre, { source: err.source || 'files', code: err.code, problems: [{ code: err.code, message: err.message }], traceparent: req.ov && req.ov.traceparent });
+ return res.redirect(303, back(`/deploys/${r.deploy.id}`, `Upload refused: ${err.message}`, 'error'));
+ }
+ 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 = 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.'));
+ } 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;
+ }
+ } catch (err) {
+ if (!req.complete) res.set('Connection', 'close');
+ fail(req, res, err);
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createDashboard };
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+
+/**
+ * Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
+ * and the small request helpers every router shares.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+
+/** A refusal with a stable problem code (e.g. 404 'site.not_found'). */
+class ApiError extends Error {
+ constructor(status, code, detail, extra) {
+ super(detail || code);
+ this.name = 'ApiError';
+ this.status = status;
+ this.code = code;
+ this.extra = extra || null;
+ }
+}
+
+let logger = console;
+/** The app's logger (tests pass a quiet one). */
+function setLogger(l) { logger = l || console; }
+
+function sendError(res, req, err, log = logger) {
+ if (err instanceof ApiError || (err && Number.isInteger(err.status) && typeof err.code === 'string' && err.name !== 'OpenVibeError')) {
+ return contracts.http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov, extra: err.extra || undefined });
+ }
+ log.error('[Host API]', err && err.stack ? err.stack : err);
+ return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+}
+
+/** Wrap a JSON handler: its return value is the body; errors become problems. */
+function run(fn, status = 200) {
+ return async (req, res) => {
+ try {
+ const out = await fn(req, res);
+ if (out === undefined || res.headersSent) return;
+ res.status(typeof status === 'function' ? status(out) : status).json(out);
+ } catch (err) {
+ if (res.headersSent) return;
+ sendError(res, req, err);
+ }
+ };
+}
+
+const jsonParser = express.json({ limit: '64kb' });
+/** JSON body parser whose syntax errors are problems too. */
+function jsonBody(req, res, next) {
+ jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
+}
+
+/** Private, per-viewer responses: never stored by a shared cache. */
+function privateNoStore(res) {
+ res.set('Cache-Control', 'private, no-store');
+ res.vary('Cookie');
+ res.vary('Authorization');
+}
+
+module.exports = { ApiError, sendError, setLogger, run, jsonBody, privateNoStore };
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+/**
+ * Every API and dashboard response is built from these allowlists, never from raw rows. Host
+ * stores no tenant secrets in Stage B (no environment variables, no build secrets, no deploy keys),
+ * and nothing here exposes Host's own configuration. The one value that looks like a token, a
+ * custom domain's TXT verification value, is public by design (it is published in DNS) and is
+ * only shown to the project's members.
+ */
+const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());
+
+function project(p, { role = null, quota = null, usage = null } = {}) {
+ return {
+ id: p.id,
+ name: p.name,
+ environment: p.environment,
+ network_project_id: p.network_project_id || null,
+ owner: p.owner_subject,
+ role: role || p.role || null,
+ created_at: iso(p.created_at),
+ ...(quota ? { quota: quotaOut(quota) } : {}),
+ ...(usage ? { usage: usageOut(usage) } : {}),
+ };
+}
+
+function quotaOut(q) {
+ return {
+ storage_bytes: q.storageBytes, deploys_per_day: q.deploysPerDay, max_files: q.maxFiles,
+ max_file_bytes: q.maxFileBytes, sites: q.sites, custom_domains: q.customDomains,
+ };
+}
+
+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 }) {
+ return {
+ id: s.id,
+ project_id: s.project_id,
+ name: s.name,
+ hostname,
+ url,
+ active_deploy_id: s.active_deploy_id || null,
+ created_at: iso(s.created_at),
+ updated_at: iso(s.updated_at),
+ };
+}
+
+function deploy(d, { active = false, files = null, log = null } = {}) {
+ return {
+ id: d.id,
+ site_id: d.site_id,
+ project_id: d.project_id,
+ state: d.state,
+ active,
+ source: d.source,
+ file_count: d.file_count,
+ total_bytes: d.total_bytes,
+ new_bytes: d.new_bytes,
+ manifest_sha256: d.manifest_sha256 || null,
+ failure_code: d.failure_code || null,
+ created_by: d.created_by,
+ created_at: iso(d.created_at),
+ ...(files ? { files: files.map((f) => ({ path: f.path, sha256: f.sha256, size: f.size, content_type: f.content_type })) } : {}),
+ ...(log ? { log: log.map(logLine) } : {}),
+ };
+}
+
+function logLine(l) {
+ return { level: l.level, message: l.message, at: iso(l.created_at) };
+}
+
+function domain(d, instructions = null) {
+ return {
+ id: d.id,
+ site_id: d.site_id,
+ hostname: d.hostname,
+ kind: d.kind,
+ status: d.status,
+ served: d.status === 'verified',
+ verified_at: iso(d.verified_at),
+ last_checked_at: iso(d.last_checked_at),
+ last_error: d.last_error || null,
+ ...(instructions ? { instructions } : {}),
+ };
+}
+
+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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,220 @@
@@ -0,0 +1,220 @@
+'use strict';
+/**
+ * Serving tenant sites by Host header.
+ *
+ * <name>.<sitesDomain> the site called <name> (one label; anything deeper is unknown)
+ * <verified custom domain> the site it was verified for (pending, failed and lapsed: unknown)
+ * anything else 404 "unknown host" with no tenant content
+ *
+ * A request resolves its site ONCE from the Host header, reads the site's active deploy pointer
+ * ONCE, and from then on reads only immutable rows of that deploy (host_deploy_files). The file's
+ * bytes come from <storage>/projects/<that site's project>/<sha256>: the path is built from the
+ * project id and the manifest's hash, never from the URL, so no URL, encoding or Host trick can
+ * reach another project's objects.
+ *
+ * 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.
+ */
+const fs = require('fs');
+const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');
+const { isHostname } = require('../domain/domains');
+
+const TENANT_CSP = [
+ "default-src 'self'",
+ "script-src 'self'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: https:",
+ "font-src 'self' data:",
+ "media-src 'self' https:",
+ "connect-src 'self'",
+ "object-src 'none'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ "frame-ancestors 'self'",
+ 'upgrade-insecure-requests',
+].join('; ');
+
+const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
+
+/** 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;
+ let h = raw.trim().toLowerCase();
+ if (h.startsWith('[')) {
+ const end = h.indexOf(']');
+ return end > 0 ? h.slice(0, end + 1) : null;
+ }
+ const colon = h.indexOf(':');
+ if (colon >= 0) {
+ if (!/^\d{1,5}$/.test(h.slice(colon + 1))) return null;
+ h = h.slice(0, colon);
+ }
+ if (h.endsWith('.')) h = h.slice(0, -1);
+ if (!h || !/^[a-z0-9.-]+$/.test(h)) return null;
+ return h;
+}
+
+function baseHeaders(res, { sandbox = false } = {}) {
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('Content-Security-Policy', TENANT_CSP);
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()');
+ res.setHeader('X-Served-By', 'OpenVibe.Host');
+ if (sandbox) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
+}
+
+function plain(res, status, text, extra = {}) {
+ res.statusCode = status;
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-store');
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ for (const [k, v] of Object.entries(extra)) res.setHeader(k, v);
+ res.end(text);
+}
+
+function createTenantServer({ store, config, blobs, 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'"),
+ siteByCustom: db.prepare(`SELECT s.*, p.environment FROM host_domains d JOIN host_sites s ON s.id = d.site_id JOIN host_projects p ON p.id = s.project_id
+ WHERE d.hostname = ? AND d.kind = 'custom' AND d.status = 'verified' AND s.status = 'active' AND p.status = 'active'`),
+ file: db.prepare(`SELECT f.sha256, f.size, f.content_type, d.project_id FROM host_deploy_files f JOIN host_deploys d ON d.id = f.deploy_id
+ WHERE f.deploy_id = ? AND f.path = ? AND d.state = 'ready'`),
+ };
+ const suffix = `.${config.sitesDomain}`;
+
+ /**
+ * -> { kind: 'dashboard' } | { kind: 'site', site } | { kind: 'unknown', host }
+ */
+ function resolve(rawHost) {
+ const host = normaliseHost(rawHost);
+ if (!host) return { kind: 'unknown', host: null };
+ if (host === config.dashboardHost || LOOPBACK_HOSTS.has(host)) return { kind: 'dashboard' };
+ if (host.endsWith(suffix)) {
+ const label = host.slice(0, -suffix.length);
+ if (!label || label.includes('.')) return { kind: 'unknown', host };
+ const site = q.siteByName.get(label);
+ return site ? { kind: 'site', site, host } : { kind: 'unknown', host };
+ }
+ if (!isHostname(host)) return { kind: 'unknown', host };
+ const site = q.siteByCustom.get(host);
+ return site ? { kind: 'site', site, host } : { kind: 'unknown', host };
+ }
+
+ /** Raw request path → candidate manifest paths, or null when the path is not a plain path. */
+ function candidates(rawPath) {
+ if (!rawPath.startsWith('/')) return null;
+ const trailing = rawPath.length > 1 && rawPath.endsWith('/');
+ const inner = rawPath.slice(1, trailing ? -1 : undefined);
+ if (!inner) return { list: ['index.html'], dir: true, path: '' };
+ const segments = inner.split('/');
+ const decoded = [];
+ for (const seg of segments) {
+ if (/%2f|%5c|%00/i.test(seg)) return null; // an encoded separator or NUL is never a path
+ let d;
+ try { d = decodeURIComponent(seg); } catch { return null; }
+ decoded.push(d);
+ }
+ const p = decoded.join('/');
+ try { checkPath(p); } catch (err) { if (err instanceof PathError) return null; throw err; }
+ if (trailing) return { list: [`${p}/index.html`], dir: true, path: p };
+ return { list: [p, `${p}.html`], dirIndex: `${p}/index.html`, dir: false, path: p };
+ }
+
+ function send(req, res, site, row, { status = 200, cache }) {
+ 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);
+ const inm = req.headers['if-none-match'];
+ if (status === 200 && inm && inm.split(',').map((s) => s.trim().replace(/^W\//, '')).includes(etag)) {
+ res.statusCode = 304;
+ return res.end();
+ }
+ let start = 0;
+ let end = row.size - 1;
+ const range = status === 200 ? req.headers.range : null;
+ if (range && (!req.headers['if-range'] || req.headers['if-range'] === etag)) {
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(range).trim());
+ if (!m || (!m[1] && !m[2])) { res.statusCode = 416; res.setHeader('Content-Range', `bytes */${row.size}`); return res.end(); }
+ if (m[1]) { start = Number(m[1]); end = m[2] ? Math.min(Number(m[2]), row.size - 1) : row.size - 1; } else { start = Math.max(row.size - Number(m[2]), 0); }
+ if (start > end || start >= row.size) { res.statusCode = 416; res.setHeader('Content-Range', `bytes */${row.size}`); return res.end(); }
+ res.statusCode = 206;
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${row.size}`);
+ } else {
+ res.statusCode = status;
+ }
+ res.setHeader('Content-Length', row.size === 0 ? 0 : end - start + 1);
+ if (req.method === 'HEAD' || row.size === 0) return res.end();
+ const file = blobs.pathFor(row.project_id, row.sha256);
+ const stream = fs.createReadStream(file, { start, end });
+ stream.on('error', (err) => {
+ log.error('[Host] object missing or unreadable:', row.project_id, row.sha256, err.code || err.message);
+ if (!res.headersSent) { res.removeHeader('Content-Length'); plain(res, 500, 'This file could not be read. Try again later.'); } else res.destroy();
+ });
+ stream.pipe(res);
+ }
+
+ function notFound(req, res, site, reason) {
+ const page = site.active_deploy_id ? 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' });
+ res.statusCode = 404;
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-cache');
+ const body = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not found</title>`
+ + '<style>body{font:16px/1.5 system-ui,sans-serif;max-width:36rem;margin:12vh auto;padding:0 1rem;color:#1f2937}small{color:#6b7280}</style></head>'
+ + `<body><h1>Not found</h1><p>${reason === 'no-deploy' ? 'This site has not published anything yet.' : 'There is no page at this address.'}</p><small>Hosted on OpenVibe.Host</small></body></html>`;
+ if (req.method === 'HEAD') return res.end();
+ return res.end(body);
+ }
+
+ /** Serves one request for a resolved site. */
+ function handle(req, res, site) {
+ 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' });
+ }
+ // Absolute-form request targets must agree with the Host header (or they would pick the site).
+ let target = req.url || '/';
+ if (!target.startsWith('/')) {
+ let u;
+ try { u = new URL(target); } catch { return plain(res, 400, 'Bad request target.'); }
+ if (normaliseHost(u.host) !== normaliseHost(req.headers.host)) return plain(res, 400, 'The request target and Host header disagree.');
+ target = `${u.pathname}${u.search}`;
+ }
+ const q0 = target.indexOf('?');
+ const rawPath = q0 >= 0 ? target.slice(0, q0) : target;
+ const search = q0 >= 0 ? target.slice(q0) : '';
+ if (!site.active_deploy_id) return notFound(req, res, site, 'no-deploy');
+ const c = candidates(rawPath);
+ if (!c) return notFound(req, res, site, 'invalid');
+ const deployId = site.active_deploy_id;
+ 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 });
+ }
+ }
+ if (!c.dir && c.dirIndex && q.file.get(deployId, c.dirIndex)) {
+ // /docs → /docs/ so relative links inside docs/index.html resolve; built from the raw path.
+ res.statusCode = 301;
+ res.setHeader('Location', `${rawPath}/${search}`);
+ res.setHeader('Cache-Control', 'no-cache');
+ return res.end();
+ }
+ return notFound(req, res, site, 'missing');
+ }
+
+ function unknownHost(req, res) {
+ return plain(res, 404, 'Unknown host: no site is served at this address.');
+ }
+
+ return { resolve, handle, unknownHost, normaliseHost, candidates, TENANT_CSP };
+}
+
+module.exports = { createTenantServer, normaliseHost, TENANT_CSP };
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -0,0 +1,175 @@
+'use strict';
+/**
+ * Reading a deploy upload from a request, with every size limit applied while streaming:
+ *
+ * Content-Type: application/gzip | application/x-gzip | application/x-tar | application/octet-stream
+ * the body is a tar or tar.gz archive
+ * Content-Type: multipart/form-data
+ * archive=<one .tar/.tar.gz file> or
+ * files=<file>… (the part's filename is the path in the site, e.g. "css/site.css";
+ * a browser folder upload sends "<folder>/css/site.css" and strip=folder
+ * removes that shared first segment)
+ * optional fields: activate=1, root=<subdirectory to deploy>, strip=folder, csrf (dashboard)
+ *
+ * Query parameters activate=1 and root=… work for both forms.
+ * -> { source: 'archive'|'files', entries: [{ path, data }], fields, notes: [] }
+ */
+const Busboy = require('busboy');
+const { readArchive, normaliseName, selectRoot, stripSharedTop, ArchiveError } = require('../artifacts/archive');
+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 fmt = (n) => `${(n / 1048576).toFixed(1)} MiB`;
+
+class UploadError extends Error {
+ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
+}
+
+/** Oversized bodies are not drained: the response closes the connection instead. */
+function tooLarge(max) {
+ const e = new UploadError(413, 'upload.too_large', `the upload is larger than ${fmt(max)}`);
+ e.closeConnection = true;
+ return e;
+}
+
+function readRaw(req, max) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ let size = 0;
+ let failed = false;
+ req.on('data', (c) => {
+ if (failed) return;
+ size += c.length;
+ if (size > max) { failed = true; req.pause(); reject(tooLarge(max)); return; }
+ chunks.push(c);
+ });
+ req.on('end', () => { if (!failed) resolve(Buffer.concat(chunks)); });
+ req.on('error', (err) => { if (!failed) { failed = true; reject(err); } });
+ });
+}
+
+function readMultipart(req, { maxUploadBytes, limits }) {
+ return new Promise((resolve, reject) => {
+ let bb;
+ try {
+ bb = Busboy({
+ headers: req.headers,
+ preservePath: true,
+ limits: { fileSize: Math.max(limits.maxFileBytes, maxUploadBytes) + 1, files: limits.maxFiles + 1, fields: 20, fieldSize: 2048, parts: limits.maxFiles + 25, headerPairs: 50 },
+ });
+ } catch (err) {
+ reject(new UploadError(400, 'upload.invalid_multipart', err.message));
+ return;
+ }
+ const fields = {};
+ const files = [];
+ let archive = null;
+ let total = 0;
+ let failure = null;
+ const fail = (e) => { if (!failure) failure = e; };
+
+ bb.on('field', (name, value) => { if (FIELD_NAMES.has(name)) fields[name] = value; });
+ bb.on('file', (name, stream, info) => {
+ const chunks = [];
+ let size = 0;
+ stream.on('data', (c) => {
+ if (failure) return;
+ size += c.length;
+ total += c.length;
+ if (total > maxUploadBytes) {
+ fail(tooLarge(maxUploadBytes));
+ req.unpipe(bb);
+ req.pause();
+ resolve({ failure, fields });
+ return;
+ }
+ chunks.push(c);
+ });
+ stream.on('limit', () => fail(tooLarge(maxUploadBytes)));
+ stream.on('end', () => {
+ if (failure) return;
+ const data = Buffer.concat(chunks);
+ if (name === 'archive') {
+ if (archive) fail(new UploadError(422, 'upload.invalid', 'send one archive'));
+ archive = data;
+ } else if (name === 'files' || name === 'file') {
+ if (!info.filename) return; // an empty <input type=file>
+ files.push({ path: String(info.filename), data });
+ } else {
+ fail(new UploadError(422, 'upload.invalid', `unexpected file field "${name}" (use "archive" or "files")`));
+ }
+ });
+ });
+ bb.on('filesLimit', () => fail(new UploadError(413, 'quota.max_files', `more than ${limits.maxFiles} files`)));
+ bb.on('partsLimit', () => fail(new UploadError(413, 'quota.max_files', 'too many parts')));
+ bb.on('error', (err) => { fail(new UploadError(400, 'upload.invalid_multipart', err.message)); resolve({ failure, fields }); });
+ bb.on('close', () => {
+ if (failure) return resolve({ failure, fields });
+ if (archive && files.length) return resolve({ failure: new UploadError(422, 'upload.invalid', 'send either an archive or files, not both'), fields });
+ resolve({ fields, archive, files });
+ });
+ req.pipe(bb);
+ });
+}
+
+/**
+ * Throws UploadError for problems with the request itself; everything else (the archive's
+ * contents) is reported by the validator.
+ */
+async function readUpload(req, { maxUploadBytes, limits }) {
+ const len = Number(req.headers['content-length']);
+ if (Number.isFinite(len) && len > maxUploadBytes) throw tooLarge(maxUploadBytes);
+ const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
+ const notes = [];
+ let source;
+ let fields = {};
+ let entries;
+ let rawArchive = null;
+ if (type === 'multipart/form-data') {
+ const r = await readMultipart(req, { maxUploadBytes, limits });
+ fields = r.fields || {};
+ if (r.failure) { r.failure.fields = fields; throw r.failure; }
+ if (r.archive) { rawArchive = r.archive; } else {
+ source = 'files';
+ entries = r.files.map((f) => ({ path: normaliseName(f.path), data: f.data }));
+ notes.push(`received ${entries.length} files as multipart form data`);
+ if ((fields.strip || req.query.strip) === 'folder') {
+ const s = stripSharedTop(entries);
+ entries = s.entries;
+ if (s.stripped) notes.push(`removed the uploaded folder name "${s.stripped}/" from every path`);
+ }
+ }
+ } else if (ARCHIVE_TYPES.has(type)) {
+ rawArchive = await readRaw(req, maxUploadBytes);
+ } else {
+ req.resume(); // bodies of an unsupported type are small or refused by nginx; drain for keep-alive
+ throw new UploadError(415, 'upload.unsupported_type', 'send a tar or tar.gz archive (application/gzip) or multipart/form-data');
+ }
+
+ if (rawArchive) {
+ source = 'archive';
+ let r;
+ try { r = readArchive(rawArchive, limits); } catch (err) {
+ if (err instanceof ArchiveError) { const e = new UploadError(err.status, err.code, err.message); e.fields = fields; e.source = 'archive'; throw e; }
+ throw err;
+ }
+ entries = r.entries.map((e) => ({ path: normaliseName(e.path), data: e.data }));
+ notes.push(`received a ${r.compressed ? 'tar.gz' : 'tar'} archive of ${(rawArchive.length / 1024).toFixed(1)} KiB: ${entries.length} files, ${r.directories} directories`);
+ }
+
+ const root = fields.root || req.query.root;
+ if (root) {
+ let clean;
+ try { clean = checkPath(String(root).replace(/^\.\/+/, '').replace(/\/+$/, '')); } catch (err) {
+ if (err instanceof PathError) { const e = new UploadError(422, 'upload.invalid_root', `root: ${err.message}`); e.fields = fields; e.source = source; throw e; }
+ throw err;
+ }
+ const s = selectRoot(entries, clean);
+ entries = s.entries;
+ notes.push(`deploying from "${clean}/": ${entries.length} files kept, ${s.dropped} outside it ignored`);
+ }
+ return { source, entries, fields, notes };
+}
+
+module.exports = { readUpload, UploadError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+/**
+ * Host's own identifiers: <prefix>_<ULID> (the contracts ULID, so they sort by creation time).
+ *
+ * prj_ project (until OpenVibe.Network has projects; then it references network_project_id)
+ * site_ site dpl_ deploy dom_ domain
+ */
+const { ids } = require('openvibe-contracts');
+
+const PREFIX = { project: 'prj', site: 'site', deploy: 'dpl', domain: 'dom' };
+const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
+const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));
+
+function newId(kind, now) {
+ return `${PREFIX[kind]}_${ids.ulid(now)}`;
+}
+
+function isId(kind, value) {
+ return typeof value === 'string' && RE[kind].test(value);
+}
+
+module.exports = { newId, isId, PREFIX };
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+
+/**
+ * OpenVibe.Host Stage B — process entry. `node server/index.js`
+ * Listens on PORT (4910) behind nginx (deploy/nginx). Serves the dashboard and API on BASE_URL's
+ * host and tenant sites on <site>.<HOST_SITES_DOMAIN> and verified custom domains. Starts the
+ * outbox relay (when EVENTS_URL and the client secret are set) and the worker (domain checks,
+ * object sweep).
+ */
+const { createApp } = require('./app');
+
+const { app, ctx } = createApp();
+const { config } = ctx;
+
+const server = app.listen(config.port, config.host, () => {
+ console.log(`[Host] ${config.nodeEnv} on http://${config.host}:${config.port} → dashboard ${config.baseUrl}, sites *.${config.sitesDomain} (db ${config.dbPath}, objects ${ctx.blobs.root})`);
+ console.log(`[Host] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}`);
+});
+server.keepAliveTimeout = 65_000;
+server.requestTimeout = 10 * 60_000; // large uploads on slow links
+ctx.outbox.start();
+ctx.worker.start();
+
+function shutdown(signal) {
+ console.log(`[Host] ${signal}: closing`);
+ ctx.worker.stop();
+ server.close(async () => {
+ try { await ctx.outbox.stop(); } catch { /* best effort */ }
+ try { ctx.stopMetrics(); } catch { /* best effort */ }
+ try { ctx.store.close(); } catch { /* already closed */ }
+ process.exit(0);
+ });
+ setTimeout(() => process.exit(0), 5000).unref();
+}
+process.on('SIGTERM', () => shutdown('SIGTERM'));
+process.on('SIGINT', () => shutdown('SIGINT'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
+ *
+ * db required a real query on Host's SQLite (the charter tables answer)
+ * storage required the object store directory is writable (uploads and serving need it)
+ * network_jwks optional the Network signing key has loaded; without it tenant sites still
+ * serve, but nobody can sign in and service tokens are refused (503)
+ * events_relay optional the outbox relay is configured and has no rejected rows
+ * domain_checks optional no custom domain is stuck: pending checks are running
+ */
+const { createReadiness } = require('openvibe-shared/ready');
+const { CHARTER_TABLES } = require('./db');
+
+function createHostReadiness({ store, blobs, auth, outbox, release = null }) {
+ const { db } = store;
+ return createReadiness({
+ service: 'host',
+ release,
+ checks: [
+ {
+ name: 'db', required: true,
+ check: () => {
+ const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
+ const missing = CHARTER_TABLES.filter((t) => !names.has(t));
+ return missing.length ? `missing ${missing.join(', ')}` : true;
+ },
+ },
+ { name: 'storage', required: true, check: () => blobs.writable() },
+ {
+ name: 'network_jwks', required: false,
+ check: () => {
+ if (auth.client.publicKey) return true;
+ auth.ensureKey().catch(() => {});
+ return 'Network signing key not loaded yet: sign-in and service calls are unavailable';
+ },
+ },
+ {
+ name: 'events_relay', required: false,
+ check: () => {
+ const s = outbox.status();
+ if (!s.enabled) return `relay off (Events URL or service credentials not configured); ${s.pending} events waiting`;
+ if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
+ return { ok: true, detail: { pending: s.pending } };
+ },
+ },
+ {
+ name: 'domain_checks', required: false,
+ check: () => {
+ const stale = db.prepare("SELECT COUNT(*) AS n FROM host_domains WHERE kind = 'custom' AND status = 'pending' AND COALESCE(last_checked_at, created_at) < ?").get(store.now() - 3600 * 1000).n;
+ return stale ? `${stale} pending custom domains not checked in the last hour (is the worker on?)` : true;
+ },
+ },
+ ],
+ });
+}
+
+module.exports = { createHostReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+
+/**
+ * Dashboard page shell. Every page is server-rendered and complete without JavaScript (all actions
+ * are plain forms); the shared chrome (navbar.js + theme-loader.js from the Network) is progressive.
+ * Dashboard pages are private: noindex, private caching.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const appIcon = require('openvibe-shared/app-icon');
+const chrome = require('openvibe-shared/chrome-ssr');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Host';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+const hashes = new Map();
+function assetVersion(rel) {
+ if (hashes.has(rel)) return hashes.get(rel);
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
+ hashes.set(rel, v);
+ return v;
+}
+const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
+
+/** o: title, body (HTML), viewer, config, path, notice { kind, text } */
+function renderPage(o) {
+ const viewer = o.viewer || { kind: 'anonymous' };
+ const signedIn = viewer.kind === 'user';
+ const loginNext = encodeURIComponent(o.path || '/');
+ const nav = {
+ service: 'host',
+ apiBase: NETWORK_URL,
+ links: [{ label: 'Projects', href: '/' }],
+ history: { type: 'page', title: o.title || SITE_NAME },
+ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: `/auth/login?next=${loginNext}`,
+ };
+ const who = signedIn ? esc((viewer.user && (viewer.user.display_name || viewer.user.username)) || viewer.subject) : '';
+ const account = signedIn
+ ? `Signed in as ${who} · <a href="/auth/logout?next=%2F">Sign out</a>`
+ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ const notice = o.notice ? `<p class="notice notice-${esc(o.notice.kind || 'info')}" role="status">${esc(o.notice.text)}</p>` : '';
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<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'}">
+<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')}">
+<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
+<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
+</head>
+<body>
+<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: [{ label: 'Projects', href: '/' }] })}
+<div class="account-bar" role="navigation" aria-label="Account">${account}</div>
+<main id="main" class="page">
+${notice}
+${o.body || ''}
+</main>
+${chrome.footer({ service: 'host', variant: 'full' })}
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the chrome is optional */ }
+});
+</script>
+</body>
+</html>`;
+}
+
+module.exports = { renderPage, esc, asset, assetVersion, SITE_NAME, NETWORK_URL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+/**
+ * Dashboard page bodies. Every value is escaped; every action is a POST form carrying the form
+ * token, so the dashboard works without JavaScript.
+ */
+const { esc } = require('./layout');
+
+const bytes = (n) => (n >= 1073741824 ? `${(n / 1073741824).toFixed(2)} GiB` : n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);
+const when = (ms) => (ms ? `<time datetime="${new Date(ms).toISOString()}">${esc(new Date(ms).toISOString().replace('T', ' ').slice(0, 16))} UTC</time>` : '');
+const short = (id) => esc(String(id || '').slice(0, 12));
+
+function form(action, csrf, inner, { cls = '', confirm = null, enctype = null } = {}) {
+ return `<form method="post" action="${esc(action)}"${cls ? ` class="${cls}"` : ''}${enctype ? ` enctype="${enctype}"` : ''}>`
+ + `<input type="hidden" name="csrf" value="${esc(csrf)}">${inner}`
+ + `${confirm ? `<label class="confirm"><input type="checkbox" name="confirm" value="yes" required> ${esc(confirm)}</label>` : ''}</form>`;
+}
+
+function signedOut() {
+ return `<section class="intro">
+<h1>OpenVibe.Host</h1>
+<p>Static site hosting for OpenVibe projects. Upload a folder or a <code>.tar.gz</code> of HTML, CSS, JavaScript, images and fonts;
+Host keeps every deploy as an immutable, content-addressed artifact, serves the active one at
+<code><site>.openvibe.host</code> (and on custom domains you verify), and lets you roll back to any earlier deploy in one step.</p>
+<p><strong>Alpha.</strong> Static files only: there is no build step and no server-side code, and nothing you upload is ever executed.
+Hosted bots, mods and apps (Stage C) are not available.</p>
+<p><a class="button" href="/auth/login?next=%2F">Sign in with OpenVibe</a></p>
+</section>`;
+}
+
+function home({ projects, csrf }) {
+ const rows = projects.map((p) => `<tr><td><a href="/projects/${esc(p.id)}">${esc(p.name)}</a></td><td>${esc(p.environment)}</td><td>${esc(p.role || '')}</td><td>${when(p.created_at)}</td></tr>`).join('');
+ return `<h1>Your projects</h1>
+${projects.length ? `<table><thead><tr><th>Project</th><th>Environment</th><th>Your role</th><th>Created</th></tr></thead><tbody>${rows}</tbody></table>` : '<p>You have no projects yet.</p>'}
+<h2>New project</h2>
+${form('/projects', csrf, `<label>Name <input name="name" required maxlength="80"></label>
+<label>Environment <select name="environment"><option value="production">production</option><option value="sandbox">sandbox (smaller quotas, noindex, no custom domains)</option></select></label>
+<button type="submit">Create project</button>`)}`;
+}
+
+function quotaTable(quota, usage) {
+ const row = (label, used, limit, fmt = (x) => esc(x)) => `<tr><th scope="row">${label}</th><td>${fmt(used)}</td><td>${fmt(limit)}</td></tr>`;
+ return `<table class="quota"><thead><tr><th></th><th>Used</th><th>Limit</th></tr></thead><tbody>
+${row('Storage', usage.storageBytes, quota.storageBytes, bytes)}
+${row('Deploys in the last 24 h', usage.deploysLast24h, quota.deploysPerDay)}
+${row('Sites', usage.sites, quota.sites)}
+${row('Custom domains', usage.customDomains, quota.customDomains)}
+<tr><th scope="row">Files per deploy</th><td></td><td>${esc(quota.maxFiles)}</td></tr>
+<tr><th scope="row">Largest file</th><td></td><td>${bytes(quota.maxFileBytes)}</td></tr>
+</tbody></table>`;
+}
+
+function project({ project: p, role, sites, quota, usage, members, csrf, siteUrl }) {
+ const siteRows = sites.map((s) => `<tr><td><a href="/sites/${esc(s.id)}">${esc(s.name)}</a></td><td><a href="${esc(siteUrl(s))}" rel="noopener">${esc(siteUrl(s))}</a></td><td>${s.active_deploy_id ? `<code>${short(s.active_deploy_id)}</code>` : '<em>nothing published</em>'}</td></tr>`).join('');
+ const memberRows = members.map((m) => `<tr><td><code>${esc(m.principal)}</code></td><td>${esc(m.role)}</td><td>${role === 'owner' && m.principal !== p.owner_subject
+ ? form(`/projects/${p.id}/members/remove`, csrf, `<input type="hidden" name="principal" value="${esc(m.principal)}"><button type="submit" class="link">Remove</button>`, { cls: 'inline' }) : ''}</td></tr>`).join('');
+ const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';
+ return `<p class="crumbs"><a href="/">Projects</a> ›</p>
+<h1>${esc(p.name)}</h1>
+<p class="meta"><code>${esc(p.id)}</code> · ${esc(p.environment)} · your role: ${esc(role)}${p.network_project_id ? ` · Network project <code>${esc(p.network_project_id)}</code>` : ''}</p>
+<h2>Sites</h2>
+${sites.length ? `<table><thead><tr><th>Site</th><th>Address</th><th>Active deploy</th></tr></thead><tbody>${siteRows}</tbody></table>` : '<p>No sites yet.</p>'}
+${canMaintain ? form(`/projects/${p.id}/sites`, csrf, `<label>New site name <input name="name" required pattern="[a-z0-9-]{3,40}" maxlength="40" placeholder="my-site"></label><button type="submit">Create site</button>`) : ''}
+<h2>Quotas</h2>
+${quotaTable(quota, usage)}
+<h2>Members</h2>
+<table><thead><tr><th>Principal</th><th>Role</th><th></th></tr></thead><tbody>${memberRows}</tbody></table>
+${role === 'owner' ? form(`/projects/${p.id}/members`, csrf, `<label>Principal <input name="principal" required placeholder="usr_… or app:app_…"></label>
+<label>Role <select name="role"><option value="deployer">deployer</option><option value="maintainer">maintainer</option></select></label><button type="submit">Add or change</button>`) : ''}
+${role === 'owner' || role === 'staff' ? `<h2>Delete project</h2>${form(`/projects/${p.id}/delete`, csrf, '<button type="submit" class="danger">Delete this project, its sites and every deploy</button>', { confirm: 'I understand every site stops at once' })}` : ''}`;
+}
+
+function domainBlock(d, instructions, csrf) {
+ const status = d.status === 'verified' ? '<span class="ok">verified: served</span>' : `<span class="warn">${esc(d.status)}: not served</span>`;
+ let body = `<li><strong>${esc(d.hostname)}</strong> · ${esc(d.kind)} · ${status}${d.last_error ? ` · <span class="muted">${esc(d.last_error)}</span>` : ''}`;
+ if (instructions) {
+ body += `<details${d.status === 'verified' ? '' : ' open'}><summary>DNS records</summary><table class="dns"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>`
+ + `<tr><td>${esc(instructions.verification.type)}</td><td><code>${esc(instructions.verification.name)}</code></td><td><code>${esc(instructions.verification.value)}</code></td><td>${esc(instructions.verification.note)}</td></tr>`
+ + instructions.routing.map((r) => `<tr><td>${esc(r.type)}</td><td><code>${esc(r.name)}</code></td><td><code>${esc(r.value)}</code></td><td>${esc(r.note)}</td></tr>`).join('')
+ + `</tbody></table><p class="muted">${esc(instructions.tls)}</p></details>`;
+ if (d.status !== 'verified') body += form(`/domains/${d.id}/verify`, csrf, '<button type="submit">Check DNS now</button>', { cls: 'inline' });
+ body += form(`/domains/${d.id}/delete`, csrf, '<button type="submit" class="link danger">Remove</button>', { cls: 'inline' });
+ }
+ return `${body}</li>`;
+}
+
+function site({ site: s, project: p, role, deploys, activations, domains, csrf, url }) {
+ const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';
+ const canDeploy = role !== 'staff';
+ const rows = deploys.map((d) => {
+ const active = d.id === s.active_deploy_id;
+ const actions = [];
+ 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>`;
+ }).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>
+<h1>${esc(s.name)}</h1>
+<p class="meta"><a href="${esc(url)}" rel="noopener">${esc(url)}</a> · <code>${esc(s.id)}</code></p>
+${canDeploy ? `<h2>Upload a deploy</h2>
+<p class="muted">Static files only: HTML, CSS, JavaScript, JSON, images, fonts, audio, video, PDF, WebAssembly. No build runs and nothing is executed. Hidden files (except <code>.well-known/</code>), links and server-side code are refused.</p>
+${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>
+<button type="submit">Upload</button>`, { enctype: 'multipart/form-data' })}` : ''}
+<h2>Deploys</h2>
+${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>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` })}` : ''}`;
+}
+
+function deploy({ deploy: d, site: s, project: p, files, log, active }) {
+ const logLines = log.map((l) => `<li class="log-${esc(l.level)}"><span class="level">${esc(l.level)}</span> ${esc(l.message)}</li>`).join('');
+ const fileRows = files.slice(0, 2000).map((f) => `<tr><td><code>${esc(f.path)}</code></td><td>${esc(f.content_type)}</td><td>${bytes(f.size)}</td><td><code title="${esc(f.sha256)}">${esc(f.sha256.slice(0, 12))}</code></td></tr>`).join('');
+ return `<p class="crumbs"><a href="/">Projects</a> › <a href="/projects/${esc(p.id)}">${esc(p.name)}</a> › <a href="/sites/${esc(s.id)}">${esc(s.name)}</a> ›</p>
+<h1>Deploy <code>${short(d.id)}</code></h1>
+<p class="meta">${esc(d.state)}${active ? ' · <strong>active</strong>' : ''} · ${esc(d.file_count)} files · ${bytes(d.total_bytes)} · ${when(d.created_at)} · by <code>${esc(d.created_by)}</code>${d.manifest_sha256 ? ` · manifest <code>${esc(d.manifest_sha256.slice(0, 16))}</code>` : ''}</p>
+<h2>Upload log</h2>
+<ul class="log">${logLines}</ul>
+${files.length ? `<h2>Files</h2><table><thead><tr><th>Path</th><th>Type</th><th>Size</th><th>sha256</th></tr></thead><tbody>${fileRows}</tbody></table>${files.length > 2000 ? `<p>…and ${files.length - 2000} more.</p>` : ''}` : ''}`;
+}
+
+function errorPage({ status, message }) {
+ return `<h1>${status === 404 ? 'Not found' : status === 403 ? 'Not allowed' : status === 401 ? 'Sign in first' : 'Something went wrong'}</h1><p>${esc(message)}</p><p><a href="/">Back to your projects</a></p>`;
+}
+
+module.exports = { signedOut, home, project, site, deploy, errorPage, bytes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+/**
+ * Content-addressed object store for deploy files, one directory per project:
+ *
+ * <root>/projects/<prj_…>/<aa>/<sha256> the bytes (read-only files, 0644)
+ * <root>/tmp/ writes land here, then rename() into place
+ *
+ * Every path is built from a validated project id and a validated lowercase sha256 hex string, never
+ * from a URL or an archive entry name, so no request can name a file outside its project's
+ * directory. The same bytes uploaded by two projects are stored twice: storage is accounted and
+ * deleted per project (ADR-014: tenancy keyed by project id).
+ *
+ * Stage B keeps objects on local disk. The roadmap prefers OpenVibe.Media "where practical"; that
+ * adapter does not exist yet (see README).
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { isId } = require('./ids');
+
+const SHA_RE = /^[0-9a-f]{64}$/;
+
+function createBlobStore(root) {
+ const base = path.resolve(root);
+ const projectsDir = path.join(base, 'projects');
+ const tmpDir = path.join(base, 'tmp');
+ fs.mkdirSync(projectsDir, { recursive: true });
+ fs.mkdirSync(tmpDir, { recursive: true });
+
+ function projectDir(projectId) {
+ if (!isId('project', projectId)) throw new TypeError('invalid project id');
+ return path.join(projectsDir, projectId);
+ }
+
+ function pathFor(projectId, sha256) {
+ if (!SHA_RE.test(String(sha256))) throw new TypeError('invalid sha256');
+ return path.join(projectDir(projectId), sha256.slice(0, 2), sha256);
+ }
+
+ /** Store bytes whose sha256 the caller computed; verified again here. Idempotent. */
+ function put(projectId, sha256, data) {
+ const dest = pathFor(projectId, sha256);
+ const actual = crypto.createHash('sha256').update(data).digest('hex');
+ if (actual !== sha256) throw new Error('blob digest mismatch');
+ try {
+ const st = fs.statSync(dest);
+ if (st.isFile() && st.size === data.length) return false;
+ } catch { /* not there yet */ }
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ const tmp = path.join(tmpDir, `${crypto.randomBytes(12).toString('hex')}.part`);
+ const fd = fs.openSync(tmp, 'wx', 0o644);
+ try {
+ fs.writeSync(fd, data);
+ fs.fsyncSync(fd);
+ } finally { fs.closeSync(fd); }
+ fs.renameSync(tmp, dest);
+ return true;
+ }
+
+ function exists(projectId, sha256) {
+ try { return fs.statSync(pathFor(projectId, sha256)).isFile(); } catch { return false; }
+ }
+
+ function remove(projectId, sha256) {
+ try { fs.unlinkSync(pathFor(projectId, sha256)); return true; } catch { return false; }
+ }
+
+ function removeProject(projectId) {
+ fs.rmSync(projectDir(projectId), { recursive: true, force: true });
+ }
+
+ /** Every (project, sha) on disk: for the orphan sweep. */
+ function* list() {
+ let projects = [];
+ try { projects = fs.readdirSync(projectsDir); } catch { return; }
+ for (const p of projects) {
+ if (!isId('project', p)) continue;
+ let shards = [];
+ try { shards = fs.readdirSync(path.join(projectsDir, p)); } catch { continue; }
+ for (const s of shards) {
+ let files = [];
+ try { files = fs.readdirSync(path.join(projectsDir, p, s)); } catch { continue; }
+ for (const f of files) if (SHA_RE.test(f)) yield { projectId: p, sha256: f, file: path.join(projectsDir, p, s, f) };
+ }
+ }
+ }
+
+ /** Remove leftovers of interrupted writes. */
+ function sweepTmp(olderThanMs = 3600 * 1000) {
+ let n = 0;
+ for (const f of fs.readdirSync(tmpDir)) {
+ const p = path.join(tmpDir, f);
+ try { if (Date.now() - fs.statSync(p).mtimeMs > olderThanMs) { fs.unlinkSync(p); n++; } } catch { /* gone */ }
+ }
+ return n;
+ }
+
+ function writable() {
+ fs.accessSync(tmpDir, fs.constants.W_OK);
+ fs.accessSync(projectsDir, fs.constants.W_OK);
+ return true;
+ }
+
+ return { root: base, pathFor, put, exists, remove, removeProject, list, sweepTmp, writable };
+}
+
+module.exports = { createBlobStore, SHA_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * Background work in the Host process (a few timers; nothing here executes tenant content):
+ *
+ * domain checks every HOST_DOMAIN_RECHECK_MS: pending custom domains are checked again
+ * (they fail after HOST_DOMAIN_PENDING_DAYS); verified ones are re-checked daily
+ * and lapse when their TXT record has been gone for HOST_DOMAIN_LAPSE_DAYS
+ * object sweep hourly: objects on disk that no deploy row references (an upload that failed
+ * after writing objects) and interrupted temp files are removed after an hour
+ * outbox prune daily: sent events older than seven days
+ */
+const fs = require('fs');
+
+function createWorker({ config, store, domains, blobs, outbox, log = console }) {
+ const timers = [];
+ let checking = false;
+
+ async function domainTick() {
+ if (checking) return null;
+ checking = true;
+ try {
+ const s = await domains.recheck();
+ if (s.verified) outbox.kick();
+ return s;
+ } catch (err) {
+ log.error('[Host] domain checks failed:', err.message);
+ return null;
+ } finally { checking = false; }
+ }
+
+ /** Remove objects no row references, once they are older than `graceMs`. */
+ function sweepObjects({ graceMs = 3600 * 1000 } = {}) {
+ const known = store.db.prepare('SELECT 1 FROM host_blobs WHERE project_id = ? AND sha256 = ?');
+ let removed = 0;
+ for (const o of blobs.list()) {
+ if (known.get(o.projectId, o.sha256)) continue;
+ try {
+ if (Date.now() - fs.statSync(o.file).mtimeMs < graceMs) continue;
+ fs.unlinkSync(o.file);
+ removed++;
+ } catch { /* raced with a write or already gone */ }
+ }
+ removed += blobs.sweepTmp(graceMs);
+ return removed;
+ }
+
+ return {
+ domainTick,
+ sweepObjects,
+ start() {
+ if (!config.worker.enabled) return;
+ const every = (ms, fn) => { const t = setInterval(fn, ms); t.unref(); timers.push(t); };
+ every(config.domains.recheckIntervalMs, domainTick);
+ every(3600 * 1000, () => { try { sweepObjects(); } catch (err) { log.warn('[Host] object sweep failed:', err.message); } });
+ every(24 * 3600 * 1000, () => { try { outbox.outbox.prune(); } catch (err) { log.warn('[Host] outbox prune failed:', err.message); } });
+ setTimeout(domainTick, 5000).unref();
+ },
+ stop() { for (const t of timers) clearInterval(t); timers.length = 0; },
+ };
+}
+
+module.exports = { createWorker };
@@ -33,7 +33,7 @@ runTests([
@@ -33,7 +33,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, [], 'Stage A exposes no API, so it enforces no capability');
+ 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');
}),
}),
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 () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+/**
+ * The proposals the lead releases in the next openvibe-contracts version are valid, match what the
+ * code enforces and emits, and do not collide with released ids; and the outbox relay really
+ * delivers Host's events to OpenVibe.Events with a Network service token.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./stageb/boot');
+const { listen } = require('./stageb/mocks');
+const { PROPOSED } = require('../server/auth/capabilities');
+const { EVENT_TYPES } = require('../server/events/outbox');
+
+const DIR = path.join(__dirname, '..', 'docs', 'capabilities-proposal');
+
+(async () => {
+ const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'));
+ const caps = files.map((f) => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')));
+ const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'service-manifest-proposal.json'), 'utf8'));
+
+ await check('every capability proposal is a valid capabilities.capability@1 with 3 segments, owned by host', async () => {
+ for (const c of caps) {
+ const v = contracts.validate('capabilities.capability@1', c);
+ assert.ok(v.valid, `${c.id}: ${JSON.stringify(v.errors)}`);
+ assert.strictEqual(c.owner, 'host');
+ assert.strictEqual(c.id.split('.').length, 3);
+ assert.strictEqual(`${c.id}.json`, files[caps.indexOf(c)]);
+ assert.ok(!contracts.capabilities.get(c.id) || contracts.capabilities.get(c.id).owner === 'host', `${c.id} collides with a released capability`);
+ }
+ });
+
+ await check('the proposals are exactly the capabilities the routes guard, and every route guards one', async () => {
+ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());
+ assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());
+ const api = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
+ const routes = [...api.matchAll(/router\.(get|post|put|delete)\('([^']+)', guard\('([a-z.]+)'\)/g)].map((m) => ({ method: m[1].toUpperCase(), route: `/api/v1${m[2]}`, cap: m[3] }));
+ const all = [...api.matchAll(/router\.(get|post|put|delete)\('/g)].length;
+ assert.strictEqual(routes.length, all, 'every API route has exactly one capability guard');
+ for (const r of routes) {
+ const cap = caps.find((c) => c.id === r.cap);
+ assert.ok(cap, r.cap);
+ assert.ok(cap.implementedBy.includes(`${r.method} ${r.route}`), `${r.method} ${r.route} missing from ${r.cap}.implementedBy`);
+ }
+ });
+
+ await check('the service manifest proposal is a valid registry.service-manifest@1 declaring every emitted event', async () => {
+ const v = contracts.validate('registry.service-manifest@1', manifest);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ assert.strictEqual(manifest.id, 'host');
+ assert.deepStrictEqual([...manifest.eventsProduced].sort(), [...EVENT_TYPES].sort());
+ for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), e);
+ const src = ['server/domain/deploys.js', 'server/domain/domains.js'].map((f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8')).join('\n');
+ for (const m of src.matchAll(/event_type: '([a-z_.]+)'/g)) assert.ok(manifest.eventsProduced.includes(m[1]), m[1]);
+ });
+
+ await check('the outbox relay publishes host.* events to OpenVibe.Events with a Network service token', async () => {
+ const received = [];
+ let network;
+ const events = await listen((req, raw, json) => {
+ if (req.url !== '/api/v1/events' || req.method !== 'POST') return json(404, {});
+ const token = String(req.headers.authorization || '').slice(7);
+ const v = contracts.serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.events' });
+ if (!v.ok || !v.claims.cap.includes('events.event.publish') || v.claims.sub !== 'svc:host') return json(403, { code: 'capability.denied' });
+ const body = JSON.parse(raw);
+ const list = body.events || [body];
+ for (const e of list) { const val = contracts.validate('events.event-envelope@1', e); if (!val.valid) return json(422, { errors: val.errors }); received.push(e); }
+ const results = list.map((e, i) => ({ event_id: e.event_id, seq: received.length - list.length + i + 1, duplicate: false }));
+ return json(200, body.events ? { results } : results[0]);
+ });
+ const t = await boot({ env: { EVENTS_URL: events.url } });
+ network = t.network;
+ const alice = t.user('alice');
+ const p = await t.project(alice, 'E');
+ const s = await t.site(alice, p.id, 'events-site');
+ await t.deploy(alice, s.id, { 'index.html': 'x' });
+ await t.upload(alice, s.id, { 'bad.php': 'x' });
+ await t.ctx.outbox.outbox.flush();
+ assert.deepStrictEqual(received.map((e) => e.event_type).sort(), ['host.deploy.activated', 'host.deploy.created', 'host.deploy.failed']);
+ assert.ok(received.every((e) => e.source === 'host'));
+ assert.strictEqual(t.ctx.outbox.status().pending, 0);
+ await t.close();
+ await events.close();
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
+'use strict';
+/**
+ * Custom domains are served only after DNS TXT verification, belong to one site, can never be an
+ * OpenVibe domain, lapse when the proof disappears, and never carry certificates through the API.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./stageb/boot');
+
+const DAY = 24 * 3600 * 1000;
+
+(async () => {
+ const t = await boot();
+ 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 sa = await t.site(alice, pa.id, 'alice-site');
+ const sb = await t.site(bob, pb.id, 'bob-site');
+ await t.deploy(alice, sa.id, { 'index.html': 'ALICE' });
+ await t.deploy(bob, sb.id, { 'index.html': 'BOB' });
+ const HOST = 'www.alice-example.org';
+ let dom;
+
+ await check('adding a custom domain returns the TXT and routing records; it is NOT served yet', async () => {
+ const r = await t.api('POST', `/api/v1/sites/${sa.id}/domains`, { as: alice, json: { hostname: 'WWW.Alice-Example.org.' } });
+ assert.strictEqual(r.status, 201, r.text);
+ dom = r.json().domain;
+ assert.strictEqual(dom.hostname, HOST);
+ assert.strictEqual(dom.status, 'pending');
+ assert.strictEqual(dom.served, false);
+ assert.strictEqual(dom.instructions.verification.type, 'TXT');
+ assert.strictEqual(dom.instructions.verification.name, `_openvibe-host.${HOST}`);
+ assert.match(dom.instructions.verification.value, /^openvibe-host-verification=[0-9a-f]{40}$/);
+ assert.deepStrictEqual(dom.instructions.routing[0], { type: 'CNAME', name: HOST, value: 'alice-site.openvibe.host', note: 'for a subdomain such as www.example.org' });
+ assert.ok(!/BEGIN|privkey|fullchain/.test(r.text), 'no certificate material');
+ const g = await t.get(HOST, '/');
+ assert.strictEqual(g.status, 404);
+ assert.match(g.text, /Unknown host/);
+ });
+
+ await check('verification: missing record and a wrong value keep it pending (and unserved)', async () => {
+ let r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });
+ assert.strictEqual(r.json().domain.status, 'pending');
+ assert.match(r.json().domain.last_error, /no TXT record/);
+ t.clock.advance(10_000);
+ t.dns.set(`_openvibe-host.${HOST}`, ['openvibe-host-verification=wrong']);
+ r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });
+ assert.strictEqual(r.json().domain.status, 'pending');
+ assert.match(r.json().domain.last_error, /not the expected value/);
+ t.clock.advance(10_000);
+ t.dns.set('__fail__', true);
+ r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });
+ assert.match(r.json().domain.last_error, /DNS lookup failed \(ETIMEOUT\)/);
+ t.dns.delete('__fail__');
+ assert.strictEqual((await t.get(HOST, '/')).status, 404);
+ });
+
+ await check('another tenant can claim the same name while pending, but only the first verified proof wins', async () => {
+ const b = await t.api('POST', `/api/v1/sites/${sb.id}/domains`, { as: bob, json: { hostname: HOST } });
+ assert.strictEqual(b.status, 201);
+ t.clock.advance(10_000);
+ t.dns.set(`_openvibe-host.${HOST}`, [dom.instructions.verification.value, 'v=spf1 -all']);
+ const r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });
+ assert.strictEqual(r.json().domain.status, 'verified');
+ assert.strictEqual(r.json().domain.served, true);
+ const ev = t.events('host.domain.verified');
+ assert.strictEqual(ev.length, 1);
+ assert.deepStrictEqual(ev[0].payload, { project_id: pa.id, site_id: sa.id, site: 'alice-site', hostname: HOST });
+ const g = await t.get(HOST, '/');
+ assert.strictEqual(g.text, 'ALICE');
+ assert.match(g.headers['content-security-policy'], /default-src 'self'/);
+ // Bob publishes his own proof afterwards: too late.
+ t.dns.set(`_openvibe-host.${HOST}`, [b.json().domain.instructions.verification.value]);
+ const bv = await t.api('POST', `/api/v1/domains/${b.json().domain.id}/verify`, { as: bob });
+ assert.strictEqual(bv.json().domain.status, 'failed');
+ assert.match(bv.json().domain.last_error, /verified for another site first/);
+ assert.strictEqual((await t.get(HOST, '/')).text, 'ALICE');
+ const again = await t.api('POST', `/api/v1/sites/${sb.id}/domains`, { as: bob, json: { hostname: 'apex-free.example.org' } });
+ assert.strictEqual(again.status, 201);
+ const taken = await t.api('POST', `/api/v1/projects/${pb.id}/sites`, { as: bob, json: { name: 'bob-two' } });
+ const dup = await t.api('POST', `/api/v1/sites/${taken.json().site.id}/domains`, { as: bob, json: { hostname: HOST } });
+ assert.strictEqual(dup.status, 409);
+ assert.strictEqual(dup.json().code, 'domain.taken');
+ });
+
+ await check('OpenVibe domains, the sites domain and non-hostnames can never be claimed', async () => {
+ for (const h of ['openvibe.live', 'evil.openvibe.network', 'x.openvibe.host', 'openvibe.host', 'openvibe.xyz', 'a.b.openvibe.media', 'events.openvibe.network', 'openre.stream', 'play.openvibe.games',
+ 'localhost', '127.0.0.1', 'example', '-bad.example.org', 'a_b.example.org', 'ex ample.org', 'xn--.example.org']) {
+ const r = await t.api('POST', `/api/v1/sites/${sa.id}/domains`, { as: alice, json: { hostname: h } });
+ assert.strictEqual(r.status, 422, `${h} → ${r.status} ${r.text}`);
+ }
+ });
+
+ await check('the default domain cannot be removed; removing a custom domain stops serving it at once', async () => {
+ const list = (await t.api('GET', `/api/v1/sites/${sa.id}/domains`, { as: alice })).json().domains;
+ const def = list.find((d) => d.kind === 'default');
+ assert.strictEqual((await t.api('DELETE', `/api/v1/domains/${def.id}`, { as: alice })).status, 409);
+ const d2 = await t.api('POST', `/api/v1/sites/${sa.id}/domains`, { as: alice, json: { hostname: 'docs.alice-example.org' } });
+ const d2v = d2.json().domain;
+ t.dns.set(d2v.instructions.verification.name, [d2v.instructions.verification.value]);
+ assert.strictEqual((await t.api('POST', `/api/v1/domains/${d2v.id}/verify`, { as: alice })).json().domain.status, 'verified');
+ assert.strictEqual((await t.get('docs.alice-example.org', '/')).text, 'ALICE');
+ assert.strictEqual((await t.api('DELETE', `/api/v1/domains/${d2v.id}`, { as: alice })).status, 200);
+ assert.strictEqual((await t.get('docs.alice-example.org', '/')).status, 404);
+ });
+
+ await check('worker: pending domains verify in the background and fail after the pending window', async () => {
+ const p = await t.api('POST', `/api/v1/sites/${sb.id}/domains`, { as: bob, json: { hostname: 'late.example.org' } });
+ const pd = p.json().domain;
+ t.clock.advance(11 * 60 * 1000);
+ t.dns.set(pd.instructions.verification.name, [pd.instructions.verification.value]);
+ const s = await t.ctx.worker.domainTick();
+ assert.ok(s.verified >= 1);
+ assert.strictEqual((await t.get('late.example.org', '/')).text, 'BOB');
+ const never = (await t.api('POST', `/api/v1/sites/${sb.id}/domains`, { as: bob, json: { hostname: 'never.example.org' } })).json().domain;
+ t.clock.advance(8 * DAY);
+ await t.ctx.worker.domainTick();
+ const after = t.ctx.domains.get(never.id);
+ assert.strictEqual(after.status, 'failed');
+ });
+
+ await check('worker: a verified domain whose TXT record is gone for the lapse window stops being served', async () => {
+ t.dns.delete(`_openvibe-host.${HOST}`);
+ t.clock.advance(DAY + 1000);
+ await t.ctx.worker.domainTick();
+ assert.strictEqual(t.ctx.domains.get(dom.id).status, 'verified', 'still served during the grace period');
+ assert.ok(t.ctx.domains.get(dom.id).record_missing_since);
+ for (let i = 0; i < 8; i++) { t.clock.advance(DAY + 1000); await t.ctx.worker.domainTick(); }
+ assert.strictEqual(t.ctx.domains.get(dom.id).status, 'lapsed');
+ assert.strictEqual((await t.get(HOST, '/')).status, 404);
+ });
+
+ await check('deleting a site stops its custom domains', async () => {
+ assert.strictEqual((await t.get('late.example.org', '/')).text, 'BOB');
+ await t.api('DELETE', `/api/v1/sites/${sb.id}`, { as: bob });
+ assert.strictEqual((await t.get('late.example.org', '/')).status, 404);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'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.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./stageb/boot');
+
+(async () => {
+ const t = await boot();
+ 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');
+ const SHARED = 'same bytes in both projects';
+ const da = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA HOME', 'shared.txt': SHARED });
+ const db = await t.deploy(bob, beta.id, { 'index.html': 'BETA HOME', 'secret.html': 'BETA SECRET', 'dir/inner.html': 'BETA INNER', 'shared.txt': SHARED });
+ const betaFiles = (await t.api('GET', `/api/v1/deploys/${db.id}`, { as: bob })).json().deploy.files;
+ const secretSha = betaFiles.find((f) => f.path === 'secret.html').sha256;
+ const leaks = (r) => /BETA/.test(r.text);
+
+ await check('each site serves only its own deploy', async () => {
+ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).text, 'ALPHA HOME');
+ assert.strictEqual((await t.get('beta.openvibe.host', '/')).text, 'BETA HOME');
+ assert.strictEqual((await t.get('alpha.openvibe.host', '/secret.html')).status, 404);
+ });
+
+ await check('path tricks on alpha never reach beta\'s files or anyone\'s object store', async () => {
+ const targets = [
+ '/../secret.html', '/../beta/secret.html', '/%2e%2e/secret.html', '/%2E%2E%2Fsecret.html', '/..%2fsecret.html', '/..%2Fbeta%2Fsecret.html',
+ '/.%2e/secret.html', '/%252e%252e/secret.html', '//beta.openvibe.host/secret.html', '/./secret.html', '/dir/../secret.html',
+ '/%00secret.html', '/secret.html%00.html', '/..\\secret.html', '/%5c..%5csecret.html', '/.well-known/../secret.html',
+ `/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`, `/${secretSha}`, `/objects/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`,
+ `/../../objects/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`, `/%2e%2e/%2e%2e/objects/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`,
+ '/dir/inner.html', '/host.db', '/../host.db', '/%2e%2e/host.db',
+ ];
+ for (const p of targets) {
+ const r = await t.get('alpha.openvibe.host', p);
+ assert.ok(!leaks(r), `${p} leaked: ${r.status} ${r.text.slice(0, 80)}`);
+ assert.ok([400, 404].includes(r.status), `${p} → ${r.status}`);
+ assert.ok(!r.body.includes(Buffer.from('SQLite format')), `${p} returned the database`);
+ }
+ });
+
+ await check('Host header tricks resolve to exactly one site or to "unknown host"', async () => {
+ const cases = [
+ ['ALPHA.OPENVIBE.HOST', 'ALPHA HOME'], ['alpha.openvibe.host.', 'ALPHA HOME'], ['alpha.openvibe.host:443', 'ALPHA HOME'],
+ ['beta.alpha.openvibe.host', null], ['alpha.beta.openvibe.host', null], ['alpha.openvibe.host.evil.example', null],
+ ['[email protected]', null], ['alpha.openvibe.host:80:beta', null], ['alpha.openvibe.host/beta', null],
+ ['beta', null], ['openvibe.host.beta', null], ['-beta.openvibe.host', null], ['%62eta.openvibe.host', null], ['', null],
+ ];
+ for (const [host, want] of cases) {
+ const r = await t.get(host, '/secret.html');
+ assert.ok(!leaks(r), `Host ${JSON.stringify(host)} leaked beta`);
+ if (want) { const home = await t.get(host, '/'); assert.strictEqual(home.text, want, host); }
+ }
+ const dash = await t.get('openvibe.host', '/secret.html');
+ assert.ok(!leaks(dash), 'the dashboard host does not serve tenant files');
+ });
+
+ await check('an absolute-form request target must match the Host header', async () => {
+ const r = await t.get('alpha.openvibe.host', 'http://beta.openvibe.host/secret.html');
+ assert.strictEqual(r.status, 400);
+ assert.ok(!leaks(r));
+ const ok = await t.get('beta.openvibe.host', 'http://beta.openvibe.host/secret.html');
+ assert.strictEqual(ok.text, 'BETA SECRET', 'a consistent absolute-form request is fine');
+ });
+
+ await check('tenant hosts never reach the API, auth, metrics or readiness', async () => {
+ for (const p of ['/api/v1/projects', '/api/ready', '/metrics', '/auth/login', '/release.json', '/css/host.css']) {
+ const r = await t.get('alpha.openvibe.host', p, { as: bob });
+ assert.strictEqual(r.status, 404, p);
+ assert.ok(!/projects|"ready"|http_requests_total|oauth/.test(r.text), `${p} leaked: ${r.text.slice(0, 100)}`);
+ }
+ });
+
+ await check('identical bytes are stored per project: deleting one project\'s copy never breaks the other', async () => {
+ const sha = betaFiles.find((f) => f.path === 'shared.txt').sha256;
+ const root = path.join(t.dir, 'objects', 'projects');
+ assert.ok(fs.existsSync(path.join(root, pa.id, sha.slice(0, 2), sha)));
+ assert.ok(fs.existsSync(path.join(root, pb.id, sha.slice(0, 2), sha)));
+ const d2 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA 2' });
+ assert.ok(d2.active);
+ const del = await t.api('DELETE', `/api/v1/deploys/${da.id}`, { as: alice });
+ assert.strictEqual(del.status, 200, del.text);
+ assert.ok(!fs.existsSync(path.join(root, pa.id, sha.slice(0, 2), sha)), 'alice\'s copy is gone');
+ assert.strictEqual((await t.get('beta.openvibe.host', '/shared.txt')).text, SHARED, 'bob\'s copy still serves');
+ });
+
+ await check('the API hides other tenants\' projects, sites, deploys and domains (404, not 403)', async () => {
+ const dom = await t.api('POST', `/api/v1/sites/${beta.id}/domains`, { as: bob, json: { hostname: 'www.beta-example.org' } });
+ const domainId = dom.json().domain.id;
+ const probes = [
+ ['GET', `/api/v1/projects/${pb.id}`], ['GET', `/api/v1/projects/${pb.id}/sites`], ['GET', `/api/v1/projects/${pb.id}/quota`],
+ ['POST', `/api/v1/projects/${pb.id}/sites`, { name: 'hijack' }], ['DELETE', `/api/v1/projects/${pb.id}`],
+ ['GET', `/api/v1/sites/${beta.id}`], ['DELETE', `/api/v1/sites/${beta.id}`], ['GET', `/api/v1/sites/${beta.id}/deploys`],
+ ['POST', `/api/v1/sites/${beta.id}/rollback`, {}], ['GET', `/api/v1/deploys/${db.id}`], ['GET', `/api/v1/deploys/${db.id}/log`],
+ ['POST', `/api/v1/deploys/${db.id}/activate`, {}], ['DELETE', `/api/v1/deploys/${db.id}`], ['GET', `/api/v1/sites/${beta.id}/domains`],
+ ['POST', `/api/v1/sites/${beta.id}/domains`, { hostname: 'x.example.org' }], ['POST', `/api/v1/domains/${domainId}/verify`], ['DELETE', `/api/v1/domains/${domainId}`],
+ ['PUT', `/api/v1/projects/${pb.id}/members/${alice.subject}`, { role: 'owner' }],
+ ];
+ for (const [method, p, json] of probes) {
+ const r = await t.api(method, p, { as: alice, json });
+ assert.strictEqual(r.status, 404, `${method} ${p} → ${r.status} ${r.text}`);
+ assert.ok(!/BETA|beta-example/.test(r.text));
+ }
+ const up = await t.upload(alice, beta.id, { 'index.html': 'PWNED' });
+ assert.strictEqual(up.status, 404);
+ assert.strictEqual((await t.get('beta.openvibe.host', '/')).text, 'BETA HOME');
+ const list = (await t.api('GET', '/api/v1/projects', { as: alice })).json().projects.map((p) => p.id);
+ assert.deepStrictEqual(list, [pa.id]);
+ });
+
+ await check('a tenant cannot point its own site at another tenant\'s deploy', async () => {
+ const r = await t.api('POST', `/api/v1/sites/${alpha.id}/rollback`, { as: alice, json: { deploy_id: db.id } });
+ assert.strictEqual(r.status, 404);
+ assert.ok(!leaks(await t.get('alpha.openvibe.host', '/')));
+ });
+
+ await check('the API ignores cookies: a session cookie alone is anonymous (tenant pages are same-site)', async () => {
+ const r = await t.api('POST', '/api/v1/projects', { session: alice, json: { name: 'csrf' } });
+ assert.strictEqual(r.status, 401);
+ const g = await t.api('GET', `/api/v1/projects/${pa.id}`, { session: alice });
+ assert.strictEqual(g.status, 401);
+ });
+
+ await check('a garbage bearer token is refused, not treated as anonymous', async () => {
+ const r = await t.api('GET', '/api/v1/projects', { as: 'not-a-jwt' });
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.headers['content-type'], 'application/problem+json');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,191 @@
@@ -0,0 +1,191 @@
+'use strict';
+/**
+ * Stage B end to end: project → site → deploy (tar.gz and multipart) → served by Host header with
+ * the right headers → activate / roll back → logs and events.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./stageb/boot');
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice');
+ let project, site, d1, d2;
+
+ await check('a signed-in person creates a project (prj_ id, owner, production quotas) and a site with its default domain', async () => {
+ project = await t.project(alice, 'Alice site');
+ assert.match(project.id, /^prj_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(project.owner, alice.subject);
+ assert.strictEqual(project.environment, 'production');
+ assert.strictEqual(project.role, 'owner');
+ assert.strictEqual(project.quota.deploys_per_day, 50);
+ site = await t.site(alice, project.id, 'alice');
+ assert.match(site.id, /^site_/);
+ assert.strictEqual(site.hostname, 'alice.openvibe.host');
+ assert.strictEqual(site.url, 'https://alice.openvibe.host');
+ const r = await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice });
+ assert.deepStrictEqual(r.json().domains.map((d) => [d.hostname, d.kind, d.status]), [['alice.openvibe.host', 'default', 'verified']]);
+ });
+
+ await check('before any deploy the site answers with its own 404', async () => {
+ const r = await t.get('alice.openvibe.host', '/');
+ assert.strictEqual(r.status, 404);
+ assert.match(r.text, /has not published anything yet/);
+ assert.strictEqual(r.headers['x-content-type-options'], 'nosniff');
+ });
+
+ await check('a tar.gz deploy is validated, stored content-addressed, activated and served with correct headers', async () => {
+ const r = await t.upload(alice, site.id, {
+ 'index.html': '<!doctype html><h1>v1</h1>', 'about/index.html': '<p>about</p>', 'assets/app.3f2a9c1b.js': 'console.log(1)',
+ 'style.css': 'body{}', 'img/logo.svg': '<svg xmlns="http://www.w3.org/2000/svg"/>', '404.html': '<h1>custom 404 v1</h1>', 'CNAME': 'alice.example',
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const body = r.json();
+ d1 = body.deploy;
+ assert.match(d1.id, /^dpl_/);
+ assert.strictEqual(d1.state, 'ready');
+ assert.strictEqual(d1.active, true);
+ assert.strictEqual(body.activated, true);
+ assert.strictEqual(d1.file_count, 7);
+ assert.match(d1.manifest_sha256, /^[0-9a-f]{64}$/);
+ assert.ok(d1.log.some((l) => /nothing was executed/.test(l.message)));
+ const page = await t.get('alice.openvibe.host', '/');
+ assert.strictEqual(page.status, 200);
+ assert.strictEqual(page.text, '<!doctype html><h1>v1</h1>');
+ assert.strictEqual(page.headers['content-type'], 'text/html; charset=utf-8');
+ assert.strictEqual(page.headers['x-content-type-options'], 'nosniff');
+ assert.match(page.headers['content-security-policy'], /default-src 'self'; script-src 'self'/);
+ assert.match(page.headers['content-security-policy'], /object-src 'none'/);
+ assert.strictEqual(page.headers['cache-control'], 'public, max-age=0, must-revalidate');
+ assert.match(page.headers.etag, /^"[0-9a-f]{64}"$/);
+ assert.strictEqual(page.headers['set-cookie'], undefined, 'tenant responses never set cookies');
+ assert.strictEqual(page.headers['x-openvibe-deploy'], d1.id);
+ const js = await t.get('alice.openvibe.host', '/assets/app.3f2a9c1b.js');
+ assert.strictEqual(js.headers['content-type'], 'text/javascript; charset=utf-8');
+ assert.strictEqual(js.headers['cache-control'], 'public, max-age=31536000, immutable');
+ const svg = await t.get('alice.openvibe.host', '/img/logo.svg');
+ assert.strictEqual(svg.headers['content-type'], 'image/svg+xml');
+ const cname = await t.get('alice.openvibe.host', '/CNAME');
+ assert.strictEqual(cname.headers['content-type'], 'text/plain; charset=utf-8');
+ });
+
+ await check('ETag revalidation (304), HEAD, byte ranges, directory index and redirect, custom 404, 405', async () => {
+ const first = await t.get('alice.openvibe.host', '/style.css');
+ const again = await t.get('alice.openvibe.host', '/style.css', { headers: { 'if-none-match': first.headers.etag } });
+ assert.strictEqual(again.status, 304);
+ assert.strictEqual(again.body.length, 0);
+ const head = await t.get('alice.openvibe.host', '/style.css', { method: 'HEAD' });
+ assert.strictEqual(head.status, 200);
+ assert.strictEqual(head.headers['content-length'], '6');
+ const range = await t.get('alice.openvibe.host', '/style.css', { headers: { range: 'bytes=0-3' } });
+ assert.strictEqual(range.status, 206);
+ assert.strictEqual(range.text, 'body');
+ assert.strictEqual(range.headers['content-range'], 'bytes 0-3/6');
+ const bad = await t.get('alice.openvibe.host', '/style.css', { headers: { range: 'bytes=10-20' } });
+ assert.strictEqual(bad.status, 416);
+ const redirect = await t.get('alice.openvibe.host', '/about?x=1');
+ assert.strictEqual(redirect.status, 301);
+ assert.strictEqual(redirect.headers.location, '/about/?x=1');
+ const about = await t.get('alice.openvibe.host', '/about/');
+ assert.strictEqual(about.text, '<p>about</p>');
+ const missing = await t.get('alice.openvibe.host', '/nope.html');
+ assert.strictEqual(missing.status, 404);
+ assert.strictEqual(missing.text, '<h1>custom 404 v1</h1>', 'the deploy\'s own 404.html');
+ assert.match(missing.headers['content-security-policy'], /default-src 'self'/);
+ const post = await t.get('alice.openvibe.host', '/', { method: 'POST', body: 'x' });
+ assert.strictEqual(post.status, 405);
+ assert.strictEqual(post.headers.allow, 'GET, HEAD');
+ });
+
+ await check('a second deploy (not activated) leaves the site unchanged; activate switches; rollback goes back', async () => {
+ d2 = await t.deploy(alice, site.id, { 'index.html': '<h1>v2</h1>', '404.html': 'v2 404' }, { activate: false });
+ assert.strictEqual(d2.active, false);
+ assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<!doctype html><h1>v1</h1>');
+ const a = await t.api('POST', `/api/v1/deploys/${d2.id}/activate`, { as: alice, json: {} });
+ assert.strictEqual(a.status, 200, a.text);
+ assert.deepStrictEqual(a.json(), { active_deploy_id: d2.id, previous_deploy_id: d1.id, changed: true });
+ assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<h1>v2</h1>');
+ assert.strictEqual((await t.get('alice.openvibe.host', '/style.css')).status, 404, 'files of the old deploy are gone from the site');
+ const rb = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: {} });
+ assert.strictEqual(rb.status, 200, rb.text);
+ assert.strictEqual(rb.json().active_deploy_id, d1.id);
+ assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<!doctype html><h1>v1</h1>');
+ const rb2 = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: { deploy_id: d2.id } });
+ assert.strictEqual(rb2.json().active_deploy_id, d2.id);
+ const list = (await t.api('GET', `/api/v1/sites/${site.id}/deploys`, { as: alice })).json();
+ assert.strictEqual(list.active_deploy_id, d2.id);
+ assert.deepStrictEqual(list.deploys.map((d) => d.id), [d2.id, d1.id]);
+ assert.deepStrictEqual(list.activations.map((a2) => a2.kind), ['rollback', 'rollback', 'activate', 'activate']);
+ });
+
+ await check('multipart files upload with a browser folder name stripped, and a subfolder root', async () => {
+ const boundary = '----hostboundary';
+ const part = (name, filename, content) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"${filename ? `; filename="${filename}"` : ''}\r\n${filename ? 'Content-Type: application/octet-stream\r\n' : ''}\r\n${content}\r\n`;
+ const body = part('strip', null, 'folder') + part('files', 'mysite/index.html', '<h1>v3</h1>') + part('files', 'mysite/css/a.css', 'a{}') + `--${boundary}--\r\n`;
+ const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys?activate=1`, { as: alice, body, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().deploy.source, 'files');
+ assert.ok(r.json().deploy.log.some((l) => /removed the uploaded folder name "mysite\/"/.test(l.message)));
+ assert.strictEqual((await t.get('alice.openvibe.host', '/css/a.css')).text, 'a{}');
+ const rooted = await t.upload(alice, site.id, { 'README.md': 'x', 'dist/index.html': '<h1>dist</h1>' }, { query: 'root=dist' });
+ assert.strictEqual(rooted.status, 201, rooted.text);
+ assert.strictEqual(rooted.json().deploy.file_count, 1);
+ assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<h1>dist</h1>');
+ });
+
+ await check('the deploy record: files with sha256 and type, and the upload log', async () => {
+ const r = await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: alice });
+ const files = r.json().deploy.files;
+ assert.ok(files.find((f) => f.path === 'about/index.html' && /^[0-9a-f]{64}$/.test(f.sha256) && f.content_type === 'text/html; charset=utf-8'));
+ const log = await t.api('GET', `/api/v1/deploys/${d1.id}/log`, { as: alice });
+ assert.ok(log.json().log.some((l) => /validated 7 files/.test(l.message)));
+ });
+
+ await check('events: created/activated in the outbox with valid envelopes and no file contents', async () => {
+ const contracts = require('openvibe-contracts');
+ const created = t.events('host.deploy.created');
+ const activated = t.events('host.deploy.activated');
+ assert.ok(created.length >= 4);
+ assert.ok(activated.some((e) => e.payload.rollback === true));
+ for (const e of [...created, ...activated]) {
+ const v = contracts.validate('events.event-envelope@1', e);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ assert.strictEqual(e.source, 'host');
+ assert.deepStrictEqual(e.actor, { type: 'user', id: alice.subject });
+ assert.ok(!JSON.stringify(e).includes('<h1>'));
+ }
+ });
+
+ await check('deleting: the active deploy is refused; an old one is removed with its unshared objects', async () => {
+ const list = (await t.api('GET', `/api/v1/sites/${site.id}/deploys`, { as: alice })).json();
+ const active = list.active_deploy_id;
+ const refused = await t.api('DELETE', `/api/v1/deploys/${active}`, { as: alice });
+ assert.strictEqual(refused.status, 409);
+ assert.strictEqual(refused.json().code, 'deploy.active');
+ const before = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;
+ const del = await t.api('DELETE', `/api/v1/deploys/${d1.id}`, { as: alice });
+ assert.strictEqual(del.status, 200, del.text);
+ assert.ok(del.json().objects_removed > 0);
+ const after = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;
+ assert.ok(after < before);
+ assert.strictEqual((await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: alice })).status, 404);
+ const rb = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: { deploy_id: d1.id } });
+ assert.strictEqual(rb.status, 404, 'a deleted deploy cannot come back');
+ });
+
+ await check('deleting the site stops serving at once and frees the name only for its own project for 30 days', async () => {
+ const del = await t.api('DELETE', `/api/v1/sites/${site.id}`, { as: alice });
+ assert.strictEqual(del.status, 200, del.text);
+ assert.strictEqual((await t.get('alice.openvibe.host', '/')).status, 404);
+ assert.match((await t.get('alice.openvibe.host', '/')).text, /Unknown host/);
+ const bob = t.user('bob');
+ const bp = await t.project(bob, 'Bob');
+ const taken = await t.api('POST', `/api/v1/projects/${bp.id}/sites`, { as: bob, json: { name: 'alice' } });
+ assert.strictEqual(taken.status, 409);
+ assert.strictEqual(taken.json().code, 'site.name_held');
+ const mine = await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: alice, json: { name: 'alice' } });
+ assert.strictEqual(mine.status, 201);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -0,0 +1,193 @@
+'use strict';
+/**
+ * Quotas are enforced by Host (ADR-014) and principals are judged by capability AND project role:
+ * deploys per day, storage (deduplicated), files per deploy, file size, upload size, sites, custom
+ * domains, sandbox limits; service tokens, app principals, sandbox tokens, staff.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./stageb/boot');
+const { site: siteTar } = require('./stageb/tar');
+
+const DAY = 24 * 3600 * 1000;
+
+(async () => {
+ const t = await boot({ env: { HOST_MAX_UPLOAD_BYTES: String(64 * 1024) } });
+ const alice = t.user('alice');
+ const staff = t.user('root', { role: 'admin' });
+ const project = await t.project(alice, 'Q');
+ const site = await t.site(alice, project.id, 'quota-site');
+ const setQuota = (q) => t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: staff, json: q });
+
+ await check('only staff change quotas; the owner reads them', async () => {
+ const mine = await t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: alice, json: { deploys_per_day: 1000 } });
+ assert.strictEqual(mine.status, 403);
+ assert.strictEqual(mine.json().code, 'auth.staff_only');
+ const r = await setQuota({ deploys_per_day: 3 });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().quota.deploys_per_day, 3);
+ const bad = await setQuota({ max_files: -1 });
+ assert.strictEqual(bad.status, 422);
+ });
+
+ await check('deploys per day: refused before the body is read, counted over a rolling 24 hours', async () => {
+ for (let i = 0; i < 3; i++) await t.deploy(alice, site.id, { 'index.html': `v${i}` });
+ const r = await t.upload(alice, site.id, { 'index.html': 'v4' });
+ assert.strictEqual(r.status, 429);
+ assert.strictEqual(r.json().code, 'quota.deploys_per_day');
+ assert.strictEqual((await t.get('quota-site.openvibe.host', '/')).text, 'v2');
+ t.clock.advance(DAY + 1000);
+ assert.strictEqual((await t.upload(alice, site.id, { 'index.html': 'v5' })).status, 201);
+ await setQuota({ deploys_per_day: null });
+ });
+
+ await check('storage quota counts only new bytes (identical content is stored once per project)', async () => {
+ const big = 'x'.repeat(3000);
+ await setQuota({ storage_bytes: 7000 });
+ const used0 = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;
+ const first = await t.upload(alice, site.id, { 'index.html': big });
+ assert.strictEqual(first.status, 201, first.text);
+ assert.strictEqual(first.json().deploy.new_bytes, 3000);
+ const same = await t.upload(alice, site.id, { 'index.html': big, 'copy.html': big });
+ assert.strictEqual(same.status, 201, same.text);
+ assert.strictEqual(same.json().deploy.new_bytes, 0, 'already stored');
+ const over = await t.upload(alice, site.id, { 'index.html': 'y'.repeat(4100) });
+ assert.strictEqual(over.status, 413);
+ assert.strictEqual(over.json().code, 'quota.storage');
+ const tooBig = await t.upload(alice, site.id, { 'a.html': big, 'b.html': big, 'c.html': big });
+ assert.strictEqual(tooBig.status, 413, 'a deploy (before deduplication) is never larger than the storage quota');
+ assert.strictEqual(tooBig.json().code, 'deploy.too_large');
+ const used1 = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;
+ assert.strictEqual(used1 - used0, 3000);
+ await setQuota({ storage_bytes: null });
+ });
+
+ await check('files per deploy and bytes per file', async () => {
+ await setQuota({ max_files: 2, max_file_bytes: 100 });
+ const many = await t.upload(alice, site.id, { 'a.html': '1', 'b.html': '2', 'c.html': '3' });
+ assert.strictEqual(many.status, 413);
+ assert.strictEqual(many.json().code, 'quota.max_files');
+ const large = await t.upload(alice, site.id, { 'a.html': 'z'.repeat(101) });
+ assert.strictEqual(large.status, 413);
+ assert.strictEqual(large.json().code, 'quota.max_file_bytes');
+ await setQuota({ max_files: null, max_file_bytes: null });
+ });
+
+ await check('request size: a body over HOST_MAX_UPLOAD_BYTES is refused (413) and the connection closed', async () => {
+ const crypto = require('crypto');
+ const noise = crypto.randomBytes(80 * 1024); // incompressible
+ const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body: siteTar({ 'blob.bin.png': noise }), headers: { 'content-type': 'application/gzip' } });
+ assert.strictEqual(r.status, 413);
+ assert.strictEqual(r.json().code, 'upload.too_large');
+ assert.strictEqual(r.headers.connection, 'close');
+ });
+
+ await check('sites per project and custom domains per project', async () => {
+ await setQuota({ sites: 2, custom_domains: 1 });
+ await t.site(alice, project.id, 'second-site');
+ const third = await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: alice, json: { name: 'third-site' } });
+ assert.strictEqual(third.status, 429);
+ assert.strictEqual(third.json().code, 'quota.sites');
+ assert.strictEqual((await t.api('POST', `/api/v1/sites/${site.id}/domains`, { as: alice, json: { hostname: 'one.example.org' } })).status, 201);
+ const two = await t.api('POST', `/api/v1/sites/${site.id}/domains`, { as: alice, json: { hostname: 'two.example.org' } });
+ assert.strictEqual(two.status, 429);
+ assert.strictEqual(two.json().code, 'quota.custom_domains');
+ });
+
+ await check('site names: one DNS label, reserved names refused, unique', async () => {
+ for (const name of ['ab', 'www', 'api', 'openvibe-news', 'bad_name', 'UPPER', '-lead', 'trail-', 'a--b', 'xn--80ak6aa92e', 'x'.repeat(41), 'a.b']) {
+ const r = await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: alice, json: { name } });
+ assert.ok([422, 429].includes(r.status), `${name} → ${r.status}`);
+ }
+ });
+
+ await check('sandbox projects: smaller quotas, no custom domains, noindex', async () => {
+ const sb = await t.project(alice, 'Sandbox', { environment: 'sandbox' });
+ assert.strictEqual(sb.environment, 'sandbox');
+ assert.strictEqual(sb.quota.custom_domains, 0);
+ assert.ok(sb.quota.storage_bytes < project.quota.storage_bytes);
+ const s = await t.site(alice, sb.id, 'sandbox-alice');
+ const d = await t.api('POST', `/api/v1/sites/${s.id}/domains`, { as: alice, json: { hostname: 'sb.example.org' } });
+ assert.strictEqual(d.status, 403);
+ await t.deploy(alice, s.id, { 'index.html': 'sb' });
+ assert.strictEqual((await t.get('sandbox-alice.openvibe.host', '/')).headers['x-robots-tag'], 'noindex, nofollow');
+ });
+
+ // ── Principals ──────────────────────────────────────────
+ const ALL = ['host.site.manage', 'host.deploy.create', 'host.domain.manage'];
+
+ await check('service tokens: audience and capability are checked per route', async () => {
+ const wrongAud = t.network.sign({ sub: 'svc:codes', aud: ['openvibe.blog'], cap: ALL });
+ assert.strictEqual((await t.api('GET', '/api/v1/projects', { as: wrongAud })).status, 401);
+ const noCap = t.network.serviceToken('codes', ['host.deploy.create']);
+ const r = await t.api('POST', '/api/v1/projects', { as: noCap, json: { name: 'x' }, headers: { 'x-ov-subject': alice.subject } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'capability.denied');
+ const family = t.network.serviceToken('codes', ['host.*']);
+ const ok = await t.api('GET', `/api/v1/projects/${project.id}`, { as: family, headers: { 'x-ov-subject': alice.subject } });
+ assert.strictEqual(ok.status, 200, 'a host.* grant covers the family');
+ });
+
+ await check('a first-party service acts for a person (X-OV-Subject) and is judged by that person\'s role', async () => {
+ const codes = t.network.serviceToken('codes', ALL);
+ const created = await t.api('POST', '/api/v1/projects', { as: codes, json: { name: 'via codes', network_project_id: 'nprj_123' }, headers: { 'x-ov-subject': alice.subject } });
+ assert.strictEqual(created.status, 201, created.text);
+ assert.strictEqual(created.json().project.owner, alice.subject);
+ assert.strictEqual(created.json().project.network_project_id, 'nprj_123');
+ const dup = await t.api('POST', '/api/v1/projects', { as: codes, json: { name: 'dup', network_project_id: 'nprj_123' }, headers: { 'x-ov-subject': alice.subject } });
+ assert.strictEqual(dup.status, 409);
+ const noPerson = await t.api('POST', '/api/v1/projects', { as: codes, json: { name: 'orphan' } });
+ assert.strictEqual(noPerson.status, 403, 'a project is owned by a person');
+ const stranger = t.user('mallory');
+ const other = await t.api('GET', `/api/v1/projects/${project.id}`, { as: codes, headers: { 'x-ov-subject': stranger.subject } });
+ assert.strictEqual(other.status, 404, 'the capability alone does not open someone else\'s project');
+ });
+
+ await check('apps are principals: a deployer app deploys, cannot manage, cannot act as a person', async () => {
+ const { ids } = require('openvibe-contracts');
+ const appId = ids.newId('app');
+ const ci = t.network.appToken(appId, ALL);
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${site.id}/deploys`, { as: ci })).status, 404, 'not a member yet');
+ const add = await t.api('PUT', `/api/v1/projects/${project.id}/members/app:${appId}`, { as: alice, json: { role: 'deployer' } });
+ assert.strictEqual(add.status, 200, add.text);
+ const up = await t.api('POST', `/api/v1/sites/${site.id}/deploys?activate=1`, { as: ci, body: siteTar({ 'index.html': 'from CI' }), headers: { 'content-type': 'application/gzip' } });
+ assert.strictEqual(up.status, 201, up.text);
+ assert.strictEqual(up.json().deploy.created_by, `app:${appId}`);
+ assert.deepStrictEqual(t.events('host.deploy.activated').pop().actor, { type: 'app', id: appId });
+ assert.strictEqual((await t.get('quota-site.openvibe.host', '/')).text, 'from CI');
+ const mk = await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: ci, json: { name: 'ci-site' } });
+ assert.strictEqual(mk.status, 403);
+ assert.strictEqual(mk.json().code, 'project.role_insufficient');
+ const imp = await t.api('GET', `/api/v1/projects/${project.id}`, { as: ci, headers: { 'x-ov-subject': alice.subject } });
+ assert.strictEqual(imp.status, 400);
+ assert.strictEqual(imp.json().code, 'subject.not_delegable');
+ const owner = await t.api('PUT', `/api/v1/projects/${project.id}/members/app:${appId}`, { as: alice, json: { role: 'owner' } });
+ assert.strictEqual(owner.status, 422, 'only a person can own a project');
+ });
+
+ await check('a sandbox credential is refused on a production project', async () => {
+ const sandboxTok = t.network.serviceToken('codes', ALL, { env: 'sandbox' });
+ const r = await t.api('GET', `/api/v1/projects/${project.id}`, { as: sandboxTok, headers: { 'x-ov-subject': alice.subject } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'environment.sandbox_token');
+ const p = await t.api('POST', '/api/v1/projects', { as: sandboxTok, json: { name: 'prod?' }, headers: { 'x-ov-subject': alice.subject } });
+ assert.strictEqual(p.status, 403);
+ const sb = await t.api('POST', '/api/v1/projects', { as: sandboxTok, json: { name: 'sb', environment: 'sandbox' }, headers: { 'x-ov-subject': alice.subject } });
+ assert.strictEqual(sb.status, 201);
+ });
+
+ await check('mods cannot manage hosted sites', async () => {
+ const { ids } = require('openvibe-contracts');
+ const mod = t.network.sign({ sub: `mod:${ids.newId('mod')}`, actor_type: 'mod', aud: ['openvibe.host'], cap: ALL });
+ assert.strictEqual((await t.api('GET', '/api/v1/projects', { as: mod })).status, 403);
+ });
+
+ await check('staff read and delete for abuse handling but cannot publish into a tenant site', async () => {
+ assert.strictEqual((await t.api('GET', `/api/v1/projects/${project.id}`, { as: staff })).status, 200);
+ const up = await t.upload(staff, site.id, { 'index.html': 'staff content' });
+ assert.strictEqual(up.status, 404);
+ assert.notStrictEqual((await t.get('quota-site.openvibe.host', '/')).text, 'staff content');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+/**
+ * Activation and rollback are one atomic pointer switch: under concurrent traffic every response is
+ * one whole deploy (never a mix, never an error); a failure inside the switch changes nothing;
+ * artifacts are immutable at the database level; the pointer survives a restart.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./stageb/boot');
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice');
+ const project = await t.project(alice, 'R');
+ const site = await t.site(alice, project.id, 'flip');
+ const v1 = await t.deploy(alice, site.id, { 'index.html': 'version-1', 'v.txt': '1' });
+ const v2 = await t.deploy(alice, site.id, { 'index.html': 'version-2', 'v.txt': '2', 'only-v2.txt': 'yes' }, { activate: false });
+ const version = { [v1.id]: '1', [v2.id]: '2' };
+
+ await check('under concurrent traffic every response is exactly one deploy while the pointer flips 40 times', async () => {
+ let flips = 0;
+ let stop = false;
+ const flipper = (async () => {
+ while (!stop && flips < 40) {
+ const target = flips % 2 === 0 ? v2.id : v1.id;
+ const r = await t.api('POST', `/api/v1/deploys/${target}/activate`, { as: alice, json: {} });
+ assert.strictEqual(r.status, 200, r.text);
+ flips++;
+ }
+ })();
+ const seen = { 1: 0, 2: 0 };
+ const readers = Array.from({ length: 8 }, async () => {
+ for (let i = 0; i < 40; i++) {
+ const p = i % 2 ? '/v.txt' : '/';
+ const r = await t.get('flip.openvibe.host', p);
+ assert.strictEqual(r.status, 200, `${p}: ${r.status}`);
+ const v = version[r.headers['x-openvibe-deploy']];
+ assert.ok(v, 'the response names a deploy of this site');
+ assert.strictEqual(p === '/' ? r.text : `version-${r.text}`, `version-${v}`, 'the bytes belong to the deploy the response names');
+ seen[v]++;
+ }
+ });
+ await Promise.all(readers);
+ stop = true;
+ await flipper;
+ assert.ok(flips >= 10, `flipped ${flips} times`);
+ assert.ok(seen[1] + seen[2] === 320);
+ });
+
+ await check('a failure inside the switch leaves the pointer, history and outbox untouched', async () => {
+ await t.api('POST', `/api/v1/deploys/${v1.id}/activate`, { as: alice, json: {} });
+ const db = t.ctx.store.db;
+ const before = { active: db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id, acts: db.prepare('SELECT COUNT(*) AS n FROM host_activations').get().n, events: db.prepare('SELECT COUNT(*) AS n FROM event_outbox').get().n };
+ const emit = t.ctx.outbox.emit;
+ t.ctx.outbox.emit = () => { throw new Error('simulated crash after the pointer moved'); };
+ const r = await t.api('POST', `/api/v1/deploys/${v2.id}/activate`, { as: alice, json: {} });
+ t.ctx.outbox.emit = emit;
+ assert.strictEqual(r.status, 500);
+ assert.strictEqual(db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id, before.active);
+ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM host_activations').get().n, before.acts);
+ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM event_outbox').get().n, before.events);
+ assert.strictEqual((await t.get('flip.openvibe.host', '/')).text, 'version-1');
+ });
+
+ await check('expected_active guards against racing operators (409 with the current pointer)', async () => {
+ const r = await t.api('POST', `/api/v1/deploys/${v2.id}/activate`, { as: alice, json: { expected_active: v2.id } });
+ assert.strictEqual(r.status, 409);
+ assert.strictEqual(r.json().code, 'site.active_changed');
+ assert.strictEqual(r.json().active_deploy_id, v1.id);
+ const ok = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: { expected_active: v1.id } });
+ assert.strictEqual(ok.status, 200);
+ assert.strictEqual(ok.json().active_deploy_id, v2.id);
+ });
+
+ await check('rollback with no earlier deploy says so; a failed deploy cannot be activated', async () => {
+ const s2 = await t.site(alice, project.id, 'single');
+ await t.deploy(alice, s2.id, { 'index.html': 'only' });
+ const r = await t.api('POST', `/api/v1/sites/${s2.id}/rollback`, { as: alice, json: {} });
+ assert.strictEqual(r.status, 409);
+ assert.strictEqual(r.json().code, 'deploy.no_previous');
+ const bad = await t.upload(alice, s2.id, { 'x.php': '<?php' });
+ const id = bad.json().deploy_id;
+ const act = await t.api('POST', `/api/v1/deploys/${id}/activate`, { as: alice, json: {} });
+ assert.strictEqual(act.status, 409);
+ assert.strictEqual(act.json().code, 'deploy.not_ready');
+ });
+
+ await check('artifacts are immutable in the database itself', async () => {
+ const db = t.ctx.store.db;
+ assert.throws(() => db.prepare("UPDATE host_deploys SET manifest = '{}' WHERE id = ?").run(v1.id), /immutable/);
+ assert.throws(() => db.prepare('UPDATE host_deploys SET total_bytes = 1 WHERE id = ?').run(v1.id), /immutable/);
+ assert.throws(() => db.prepare("UPDATE host_deploy_files SET sha256 = 'x' WHERE deploy_id = ?").run(v1.id), /immutable/);
+ assert.throws(() => db.prepare('DELETE FROM host_deploy_files WHERE deploy_id = ?').run(v1.id), /live deploy/);
+ db.prepare("UPDATE host_deploys SET state = 'deleted' WHERE id = (SELECT id FROM host_deploys WHERE state = 'failed' LIMIT 1)").run();
+ assert.throws(() => db.prepare("UPDATE host_deploys SET state = 'ready' WHERE state = 'deleted'").run(), /cannot become ready/);
+ });
+
+ await check('the active pointer survives a restart', async () => {
+ await t.restart();
+ assert.strictEqual((await t.get('flip.openvibe.host', '/')).text, 'version-2');
+ assert.strictEqual((await t.get('flip.openvibe.host', '/only-v2.txt')).text, 'yes');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
+'use strict';
+/**
+ * "Deployment secrets never appear in public project state" (Wave 21 exit criterion), and the
+ * server-rendered dashboard: shared chrome, forms without JavaScript, CSRF that holds even though
+ * tenant sites are same-site, planted cookies ignored, readiness/release/metrics.
+ */
+const assert = require('assert');
+const { boot, check, done, SECRET } = require('./stageb/boot');
+const { tarball } = require('./stageb/tar');
+const { csrfToken } = require('../server/auth/forms');
+
+const ORIGIN = 'https://openvibe.host';
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice');
+ const staff = t.user('ops', { role: 'admin' });
+ const bodies = [];
+ const keep = (r) => { bodies.push(r.text); return r; };
+
+ await check('no project, site, deploy, domain, quota or log response carries a secret or environment value', async () => {
+ const p = keep(await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'Secrets' } })).json().project;
+ const s = keep(await t.api('POST', `/api/v1/projects/${p.id}/sites`, { as: alice, json: { name: 'secret-test' } })).json().site;
+ const d = keep(await t.upload(alice, s.id, { 'index.html': 'hi', 'app.9f8e7d6c.js': 'x' })).json().deploy;
+ const bad = keep(await t.api('POST', `/api/v1/sites/${s.id}/deploys`, { as: alice, body: tarball([{ name: 'index.html', content: 'x' }, { name: '.env', content: 'STRIPE_SECRET_KEY=sk_live_TOPSECRET123\nDATABASE_URL=postgres://u:p@h/db' }]), headers: { 'content-type': 'application/gzip' } }));
+ assert.strictEqual(bad.status, 422);
+ keep(await t.api('POST', `/api/v1/sites/${s.id}/domains`, { as: alice, json: { hostname: 'www.secret-example.org' } }));
+ for (const pth of [`/api/v1/projects`, `/api/v1/projects/${p.id}`, `/api/v1/projects/${p.id}/quota`, `/api/v1/sites/${s.id}`, `/api/v1/sites/${s.id}/deploys`,
+ `/api/v1/deploys/${d.id}`, `/api/v1/deploys/${d.id}/log`, `/api/v1/deploys/${bad.json().deploy_id}/log`, `/api/v1/sites/${s.id}/domains`]) {
+ keep(await t.api('GET', pth, { as: alice }));
+ }
+ keep(await t.api('GET', `/api/v1/projects/${p.id}`, { as: staff }));
+ for (const pth of ['/api/ready', '/release.json', '/api/health']) keep(await t.api('GET', pth));
+ keep(await t.api('GET', '/', { session: alice }));
+ keep(await t.api('GET', `/projects/${p.id}`, { session: alice }));
+ keep(await t.api('GET', `/sites/${s.id}`, { session: alice }));
+ keep(await t.api('GET', `/deploys/${d.id}`, { session: alice }));
+ keep(await t.api('GET', `/deploys/${bad.json().deploy_id}`, { session: alice }));
+ const all = bodies.join('\n');
+ for (const needle of [SECRET, 'test-form-secret', 'sk_live_TOPSECRET123', 'postgres://', 'client_secret', 'OV_OAUTH_CLIENT_SECRET', 'HOST_FORM_SECRET', 'BEGIN PRIVATE', 'privkey', 'password']) {
+ assert.ok(!all.includes(needle), `a response contains ${needle}`);
+ }
+ const deployKeys = Object.keys(d).sort();
+ assert.deepStrictEqual(deployKeys, ['active', 'created_at', 'created_by', 'failure_code', 'file_count', 'id', 'log', 'manifest_sha256', 'new_bytes', 'project_id', 'site_id', 'source', 'state', 'total_bytes'].sort());
+ const events = JSON.stringify(t.events());
+ assert.ok(!events.includes('sk_live') && !events.includes(SECRET));
+ });
+
+ await check('the signed-out home page is server-rendered with the shared chrome and useful without JavaScript', async () => {
+ const r = await t.api('GET', '/');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<script src="https:\/\/openvibe\.network\/shared\/navbar\.js" defer>/);
+ assert.match(r.text, /<noscript><nav aria-label="Site"/);
+ assert.match(r.text, /id="ov-footer"/);
+ assert.match(r.text, /Sign in with OpenVibe/);
+ assert.match(r.text, /Stage C\) are not available/);
+ 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">/);
+ });
+
+ await check('a planted ov_token cookie (e.g. set by a tenant subdomain) is not a session', async () => {
+ const r = await t.api('GET', '/', { headers: { cookie: `ov_token=${t.network.userToken(alice)}` } });
+ assert.match(r.text, /Sign in with OpenVibe/);
+ const me = await t.api('GET', '/auth/me', { headers: { cookie: `ov_token=${t.network.userToken(alice)}` } });
+ assert.strictEqual(me.status, 401);
+ });
+
+ let projectId, siteId;
+ await check('forms: create a project and a site (Origin + form token), then upload a folder and see the log', async () => {
+ const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
+ const p = await t.api('POST', '/projects', { session: alice, form: { csrf, name: 'Dash project', environment: 'production' }, headers: { origin: ORIGIN } });
+ assert.strictEqual(p.status, 303, p.text);
+ projectId = p.headers.location.match(/\/projects\/(prj_[0-9A-Z]+)/)[1];
+ const page = await t.api('GET', `/projects/${projectId}`, { session: alice });
+ assert.match(page.text, /Dash project/);
+ assert.match(page.text, /Deploys in the last 24 h/);
+ const s = await t.api('POST', `/projects/${projectId}/sites`, { session: alice, form: { csrf, name: 'dash-site' }, headers: { origin: ORIGIN } });
+ assert.strictEqual(s.status, 303, s.text);
+ siteId = s.headers.location.match(/\/sites\/(site_[0-9A-Z]+)/)[1];
+ const boundary = 'dashBOUNDARY';
+ const part = (name, filename, content) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"${filename ? `; filename="${filename}"` : ''}\r\n\r\n${content}\r\n`;
+ const body = part('csrf', null, csrf) + part('strip', null, 'folder') + part('activate', null, '1') + part('files', 'site/index.html', 'DASH OK') + `--${boundary}--\r\n`;
+ const up = await t.api('POST', `/sites/${siteId}/deploys`, { session: alice, body, headers: { origin: ORIGIN, 'content-type': `multipart/form-data; boundary=${boundary}` } });
+ assert.strictEqual(up.status, 303, up.text);
+ assert.match(up.headers.location, /^\/deploys\/dpl_/);
+ const log = await t.api('GET', up.headers.location.split('?')[0], { session: alice });
+ assert.match(log.text, /Upload log/);
+ assert.match(log.text, /nothing was executed/);
+ assert.strictEqual((await t.get('dash-site.openvibe.host', '/')).text, 'DASH OK');
+ const sitePage = await t.api('GET', `/sites/${siteId}`, { session: alice });
+ assert.match(sitePage.text, /Upload a deploy/);
+ assert.match(sitePage.text, /webkitdirectory/);
+ 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/);
+ });
+
+ await check('forms: domain instructions, rollback, and refusal of cross-origin posts even with a valid token', async () => {
+ const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
+ const d = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { csrf, hostname: 'www.dash-example.org' }, headers: { origin: ORIGIN } });
+ assert.strictEqual(d.status, 303, d.text);
+ const page = await t.api('GET', `/sites/${siteId}`, { session: alice });
+ assert.match(page.text, /_openvibe-host\.www\.dash-example\.org/);
+ assert.match(page.text, /openvibe-host-verification=[0-9a-f]{40}/);
+ assert.match(page.text, /pending: not served/);
+ const tenant = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { csrf, hostname: 'evil.example.org' }, headers: { origin: 'https://attacker.openvibe.host' } });
+ assert.strictEqual(tenant.status, 403, 'a tenant page on the same site cannot drive the dashboard');
+ const noToken = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { hostname: 'evil.example.org' }, headers: { origin: ORIGIN } });
+ assert.strictEqual(noToken.status, 403);
+ const noOrigin = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { csrf, hostname: 'evil.example.org' } });
+ assert.strictEqual(noOrigin.status, 403);
+ const anon = await t.api('POST', '/projects', { form: { name: 'x' }, headers: { origin: ORIGIN } });
+ assert.strictEqual(anon.status, 303);
+ assert.match(anon.headers.location, /^\/auth\/login/);
+ });
+
+ await check('machine endpoints: /api/ready is truthful, /release.json, /metrics only for direct loopback', async () => {
+ const ready = await t.api('GET', '/api/ready');
+ assert.strictEqual(ready.status, 200, ready.text);
+ const body = ready.json();
+ assert.strictEqual(body.ready, true);
+ const names = body.checks.map ? body.checks.map((c) => c.name) : Object.keys(body.checks);
+ for (const n of ['db', 'storage', 'network_jwks', 'events_relay', 'domain_checks']) assert.ok(names.includes(n), n);
+ assert.match(ready.text, /relay off/);
+ const rel = await t.api('GET', '/release.json');
+ assert.strictEqual(rel.json().service, 'host');
+ const m = await t.api('GET', '/metrics');
+ assert.strictEqual(m.status, 200);
+ assert.match(m.text, /http_requests_total\{method="GET",route="tenant_site"/);
+ assert.match(m.text, /host_sites \d+/);
+ const proxied = await t.api('GET', '/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });
+ assert.strictEqual(proxied.status, 404);
+ const unknown = await t.get('nothing.example.net', '/');
+ assert.strictEqual(unknown.status, 404);
+ assert.match(unknown.text, /Unknown host/);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+'use strict';
+/**
+ * Upload validation: path traversal, links and special files, hidden files, server-side code,
+ * credentials, bad archives and gzip bombs are refused as a whole. A refused upload is a failed
+ * deploy with a log and host.deploy.failed; nothing is stored and the active deploy is unchanged.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const zlib = require('zlib');
+const { boot, check, done } = require('./stageb/boot');
+const { tarball } = require('./stageb/tar');
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice');
+ const project = await t.project(alice, 'A');
+ const site = await t.site(alice, project.id, 'uploads');
+ const good = await t.deploy(alice, site.id, { 'index.html': 'GOOD' });
+ const projectDir = path.join(t.dir, 'objects', 'projects', project.id);
+ const countObjects = () => fs.readdirSync(projectDir).flatMap((d) => fs.readdirSync(path.join(projectDir, d))).length;
+ let baseline = countObjects();
+
+ async function refused(entries, code, { gzip = true, raw = null, contentType } = {}) {
+ const before = t.events('host.deploy.failed').length;
+ const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys?activate=1`, { as: alice, body: raw || tarball(entries, { gzip }), headers: { 'content-type': contentType || 'application/gzip' } });
+ assert.ok([413, 422].includes(r.status), `${code}: status ${r.status} ${r.text}`);
+ const body = r.json();
+ assert.strictEqual(body.code, code, `expected ${code}, got ${body.code}: ${body.detail}`);
+ assert.match(body.deploy_id, /^dpl_/);
+ assert.ok(body.log.some((l) => /deploy refused/.test(l)));
+ const failed = (await t.api('GET', `/api/v1/deploys/${body.deploy_id}`, { as: alice })).json().deploy;
+ assert.strictEqual(failed.state, 'failed');
+ assert.strictEqual(failed.failure_code, code);
+ assert.strictEqual(failed.file_count, 0);
+ assert.strictEqual(t.events('host.deploy.failed').length, before + 1);
+ assert.strictEqual((await t.get('uploads.openvibe.host', '/')).text, 'GOOD', 'the active deploy is unchanged');
+ assert.strictEqual(countObjects(), baseline, 'nothing was stored');
+ return body;
+ }
+
+ await check('path traversal in names: "..", absolute, embedded, backslash, pax and GNU long-name overrides', async () => {
+ await refused([{ name: '../evil.html', content: 'x' }], 'deploy.path_traversal');
+ await refused([{ name: '/etc/passwd', content: 'x' }], 'deploy.path_traversal');
+ await refused([{ name: 'a/../../b.html', content: 'x' }], 'deploy.path_traversal');
+ await refused([{ name: 'ok.html', content: 'x' }, { name: 'sub/./x.html', content: 'x' }], 'deploy.path_traversal');
+ await refused([{ name: '..\\..\\evil.html', content: 'x' }], 'deploy.invalid_path');
+ await refused([{ name: 'innocent.html', content: 'x', pax: { path: '../../etc/cron.d/x.html' } }], 'deploy.path_traversal');
+ await refused([{ name: 'innocent.html', content: 'x', longName: `${'a'.repeat(120)}/../../../evil.html` }], 'deploy.path_traversal');
+ await refused([{ name: 'a//b.html', content: 'x' }], 'deploy.invalid_path');
+ await refused([{ name: 'café.html', content: 'x' }], 'deploy.invalid_path');
+ });
+
+ await check('symbolic links, hard links, devices and FIFOs are refused, never followed or skipped', async () => {
+ await refused([{ name: 'index.html', content: 'x' }, { name: 'passwd.html', type: '2', linkname: '/etc/passwd' }], 'deploy.link_refused');
+ await refused([{ name: 'up.html', type: '2', linkname: '../../host.db' }], 'deploy.link_refused');
+ await refused([{ name: 'index.html', content: 'x' }, { name: 'hard.html', type: '1', linkname: 'index.html' }], 'deploy.link_refused');
+ await refused([{ name: 'tty.html', type: '3' }], 'deploy.special_file_refused');
+ await refused([{ name: 'disk.html', type: '4' }], 'deploy.special_file_refused');
+ await refused([{ name: 'pipe.html', type: '6' }], 'deploy.special_file_refused');
+ await refused([{ name: 'x.html', content: 'x', pax: { linkpath: '/etc/passwd' } }], 'deploy.link_refused');
+ });
+
+ await check('hidden files, server-side code, credentials and unknown types are refused with a reason', async () => {
+ const env = await refused([{ name: 'index.html', content: 'x' }, { name: '.env', content: 'API_KEY=sk_live_should_never_be_served' }], 'deploy.invalid_path');
+ assert.match(env.detail, /hidden file/);
+ await refused([{ name: '.git/config', content: '[core]' }], 'deploy.invalid_path');
+ const php = await refused([{ name: 'index.php', content: '<?php system($_GET["c"]); ?>' }], 'deploy.file_refused');
+ assert.match(php.detail, /never runs anything/);
+ await refused([{ name: 'cgi-bin/run.cgi', content: '#!/bin/sh' }], 'deploy.file_refused');
+ await refused([{ name: 'page.shtml', content: '<!--#exec cmd="id" -->' }], 'deploy.file_refused');
+ await refused([{ name: 'deploy.sh', content: 'rm -rf /' }], 'deploy.file_refused');
+ await refused([{ name: 'server.key', content: '-----BEGIN PRIVATE KEY-----' }], 'deploy.file_refused');
+ await refused([{ name: 'config.env', content: 'SECRET=1' }], 'deploy.file_refused');
+ await refused([{ name: 'tool.exe', content: 'MZ' }], 'deploy.file_refused');
+ await refused([{ name: 'thing.xyz', content: '?' }], 'deploy.type_not_allowed');
+ const ok = await t.upload(alice, site.id, { '.well-known/security.txt': 'Contact: mailto:[email protected]', 'index.html': 'GOOD2' }, { activate: false });
+ assert.strictEqual(ok.status, 201, '.well-known/ is published');
+ baseline = countObjects();
+ });
+
+ await check('duplicates, file/directory conflicts, empty uploads and broken archives', async () => {
+ await refused([{ name: 'a.html', content: '1' }, { name: './a.html', content: '2' }], 'deploy.duplicate_path');
+ await refused([{ name: 'docs', content: '1' }, { name: 'docs/index.html', content: '2' }], 'deploy.path_conflict');
+ await refused([], 'deploy.empty');
+ await refused(null, 'archive.corrupt', { raw: Buffer.from('this is not an archive at all, not even close'.repeat(20)) });
+ await refused(null, 'archive.corrupt', { raw: Buffer.concat([Buffer.from([0x1f, 0x8b]), Buffer.from('garbage')]) });
+ const tar = tarball([{ name: 'a.html', content: 'x'.repeat(2000) }], { gzip: false });
+ await refused(null, 'archive.corrupt', { raw: zlib.gzipSync(tar.subarray(0, 1200)) });
+ const corrupt = Buffer.from(tarball([{ name: 'a.html', content: 'x' }], { gzip: false }));
+ corrupt[10] ^= 0xff; // breaks the header checksum
+ await refused(null, 'archive.corrupt', { raw: corrupt, contentType: 'application/x-tar' });
+ });
+
+ await check('a gzip bomb stops at the project limit instead of filling memory', async () => {
+ await t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: t.user('staff', { role: 'admin' }), json: { storage_bytes: 1024 * 1024 } });
+ const bomb = tarball([{ name: 'zeros.txt', content: Buffer.alloc(40 * 1024 * 1024, 0) }]);
+ assert.ok(bomb.length < 100 * 1024, 'a small upload');
+ const body = await refused(null, 'deploy.too_large', { raw: bomb });
+ assert.match(body.detail, /expands beyond|add up to/);
+ await t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: t.user('staff2', { role: 'admin' }), json: { storage_bytes: null } });
+ });
+
+ await check('multipart filenames go through the same rules', async () => {
+ const boundary = 'xxBOUNDARYxx';
+ const part = (filename, content) => `--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="${filename}"\r\nContent-Type: text/html\r\n\r\n${content}\r\n`;
+ for (const [name, code] of [['../../escape.html', 'deploy.path_traversal'], ['/etc/x.html', 'deploy.path_traversal'], ['.htaccess', 'deploy.invalid_path'], ['shell.php', 'deploy.file_refused']]) {
+ const body = part('index.html', 'x') + part(name, 'y') + `--${boundary}--\r\n`;
+ const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } });
+ assert.strictEqual(r.status, 422, `${name}: ${r.text}`);
+ assert.strictEqual(r.json().code, code, name);
+ }
+ const wrong = await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body: 'x', headers: { 'content-type': 'text/plain' } });
+ assert.strictEqual(wrong.status, 415);
+ });
+
+ await check('the failed deploys and their logs are visible to the owner; the good deploy is still active', async () => {
+ const list = (await t.api('GET', `/api/v1/sites/${site.id}/deploys?limit=100`, { as: alice })).json();
+ assert.strictEqual(list.active_deploy_id, good.id);
+ assert.ok(list.deploys.filter((d) => d.state === 'failed').length >= 25);
+ const one = list.deploys.find((d) => d.failure_code === 'deploy.link_refused');
+ const log = (await t.api('GET', `/api/v1/deploys/${one.id}/log`, { as: alice })).json().log;
+ assert.ok(log.some((l) => l.level === 'error' && /link/.test(l.message)));
+ for (const e of t.events('host.deploy.failed')) assert.ok(!JSON.stringify(e).includes('sk_live'), 'no file contents in events');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
+'use strict';
+/**
+ * Boots Host Stage B on a temp database and object store, with a controllable clock, a DNS table
+ * for custom-domain checks and a mock Network, and returns a raw HTTP client that can send any Host
+ * header and any request target (encoded traversal, absolute-form, …).
+ *
+ * const t = await boot();
+ * await t.api('POST', '/api/v1/projects', { as: user, json: { name: 'x' } })
+ * await t.get('mysite.openvibe.host', '/index.html')
+ * t.dns.set('_openvibe-host.www.example.org', ['openvibe-host-verification=…'])
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { startNetwork } = require('./mocks');
+const { site: siteTar } = require('./tar');
+
+function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
+ let t = start;
+ return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };
+}
+
+const SECRET = 'shh-host-secret-value';
+const DASHBOARD = 'openvibe.host';
+
+async function boot(opts = {}) {
+ const network = await startNetwork();
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-host-test-'));
+ const clock = opts.clock || makeClock();
+ const dns = new Map();
+ const dnsCalls = [];
+ const resolver = {
+ async resolveTxt(name) {
+ dnsCalls.push(name);
+ if (dns.get('__fail__')) { const e = new Error('timeout'); e.code = 'ETIMEOUT'; throw e; }
+ if (!dns.has(name)) { const e = new Error(`queryTxt ENOTFOUND ${name}`); e.code = 'ENOTFOUND'; throw e; }
+ return dns.get(name).map((v) => [v]);
+ },
+ };
+ const env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: `https://${DASHBOARD}`, TRUST_PROXY: '1',
+ HOST_DB_PATH: path.join(dir, 'host.db'), HOST_STORAGE_DIR: path.join(dir, 'objects'),
+ HOST_SITES_DOMAIN: 'openvibe.host',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'host', OV_OAUTH_CLIENT_SECRET: SECRET, COOKIE_SECURE: 'false',
+ HOST_FORM_SECRET: 'test-form-secret', HOST_WORKER: 'off',
+ ...(opts.env || {}),
+ };
+ const configLib = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
+
+ let server = null;
+ let built = null;
+ const t = { network, clock, dns, dnsCalls, dir, env, SECRET };
+
+ async function start() {
+ const config = configLib.load(env);
+ built = createApp({ config, now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl });
+ await built.ctx.auth.ensureKey();
+ server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ t.port = server.address().port;
+ t.app = built.app;
+ t.ctx = built.ctx;
+ t.config = config;
+ }
+ async function stop() {
+ if (server) { server.closeAllConnections(); await new Promise((r) => server.close(r)); }
+ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.stopMetrics(); built.ctx.store.close(); }
+ server = null; built = null;
+ }
+
+ /** Raw request. o: method, host, path, headers, body (Buffer|string), as (user|token), session (user) */
+ function request(o) {
+ return new Promise((resolve, reject) => {
+ const headers = { ...(o.headers || {}) };
+ if (o.host !== undefined) headers.host = o.host;
+ if (o.as && typeof o.as === 'object') headers.authorization = `Bearer ${network.userToken(o.as)}`;
+ if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;
+ if (o.session) headers.cookie = [headers.cookie, `ov_host_session=${network.userToken(o.session)}`].filter(Boolean).join('; ');
+ let body = o.body;
+ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ if (body != null && headers['content-length'] == null) headers['content-length'] = Buffer.byteLength(body);
+ const req = http.request({ host: '127.0.0.1', port: t.port, method: o.method || 'GET', path: o.path, headers, setHost: o.host === undefined }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => {
+ const buf = Buffer.concat(chunks);
+ const text = buf.toString('utf8');
+ resolve({ status: res.statusCode, headers: res.headers, body: buf, text, json() { return JSON.parse(text); } });
+ });
+ });
+ req.on('error', reject);
+ if (body != null) req.write(body);
+ req.end();
+ });
+ }
+
+ t.request = request;
+ t.api = (method, p, o = {}) => request({ ...o, method, host: DASHBOARD, path: p });
+ t.get = (host, p, o = {}) => request({ ...o, method: o.method || 'GET', host, path: p });
+
+ /** Outbox rows as parsed envelopes. */
+ t.events = (type = null) => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type);
+
+ t.user = (name, extra) => network.addUser(name, extra);
+ t.project = async (user, name = 'Project', extra = {}) => {
+ const r = await t.api('POST', '/api/v1/projects', { as: user, json: { name, ...extra } });
+ if (r.status !== 201) throw new Error(`project: ${r.status} ${r.text}`);
+ return r.json().project;
+ };
+ t.site = async (user, projectId, name) => {
+ const r = await t.api('POST', `/api/v1/projects/${projectId}/sites`, { as: user, json: { name } });
+ if (r.status !== 201) throw new Error(`site: ${r.status} ${r.text}`);
+ return r.json().site;
+ };
+ /** Upload { path: content } as a tar.gz; returns the response. */
+ t.upload = (user, siteId, files, { activate = true, query = '', gzip = true, raw = null, contentType = 'application/gzip' } = {}) => t.api('POST', `/api/v1/sites/${siteId}/deploys${activate ? '?activate=1' : '?'}${query ? `&${query}` : ''}`, {
+ as: user, body: raw || siteTar(files, { gzip }), headers: { 'content-type': contentType },
+ });
+ t.deploy = async (user, siteId, files, o) => {
+ const r = await t.upload(user, siteId, files, o);
+ if (r.status !== 201) throw new Error(`deploy: ${r.status} ${r.text}`);
+ return r.json().deploy;
+ };
+
+ t.restart = async () => { await stop(); await start(); };
+ t.close = async () => { await stop(); await network.close(); fs.rmSync(dir, { recursive: true, force: true }); };
+ await start();
+ return t;
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done, makeClock, DASHBOARD, SECRET };
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * In-process stand-in for OpenVibe.Network with a real RS256 key pair: JWKS and /oauth/token
+ * (client_credentials → service tokens with the requested scope as capabilities).
+ * userToken()/serviceToken()/appToken() mint what browsers, services and apps present to Host.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ handler(req, raw, json, res);
+ });
+ });
+ server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) }));
+ });
+}
+
+async function startNetwork() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ let issuer = null;
+ const srv = await listen((req, raw, json) => {
+ if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
+ if (req.url === '/oauth/token' && req.method === 'POST') {
+ let body = {};
+ if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));
+ else { try { body = JSON.parse(raw); } catch { /* */ } }
+ if (body.client_secret !== 'shh-host-secret-value') return json(401, { error: 'invalid_client' });
+ if (body.grant_type === 'client_credentials') {
+ let scope = body.scope;
+ if (scope && typeof scope === 'object') scope = Object.values(scope).join(' ');
+ const cap = String(scope || '').split(/\s+/).filter(Boolean);
+ return json(200, { access_token: sign({ sub: `svc:${body.client_id}`, actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });
+ }
+ return json(400, { error: 'unsupported_grant_type' });
+ }
+ return json(404, { error: 'not found' });
+ });
+ issuer = srv.url;
+ function sign({ sub, actor_type = 'service', aud, cap, ...extra }) {
+ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...extra }, privatePem);
+ }
+ function addUser(username, extra = {}) {
+ return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
+ }
+ function userToken(u) {
+ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
+ }
+ function serviceToken(client, cap, extra = {}) {
+ return sign({ sub: `svc:${client}`, aud: ['openvibe.host'], cap, ...extra });
+ }
+ function appToken(appId, cap, extra = {}) {
+ return sign({ sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.host'], cap, ...extra });
+ }
+ return { ...srv, publicPem, addUser, userToken, serviceToken, appToken, sign };
+}
+
+module.exports = { startNetwork, listen };
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+/**
+ * A test-only tar writer that can produce what a hostile uploader would send: symlinks, hard links,
+ * devices, FIFOs, pax path overrides, GNU long names, absolute and ".." names.
+ *
+ * tarball([{ name, content, type: '0'|'1'|'2'|'3'|'5'|'6'|'x'|'L', linkname, pax: { path } }], { gzip })
+ */
+const zlib = require('zlib');
+
+function header(name, size, type = '0', linkname = '') {
+ const buf = Buffer.alloc(512, 0);
+ const nameBuf = Buffer.from(name, 'utf8');
+ nameBuf.copy(buf, 0, 0, Math.min(nameBuf.length, 100));
+ buf.write('0000644\0', 100);
+ buf.write('0001750\0', 108);
+ buf.write('0001750\0', 116);
+ buf.write(`${size.toString(8).padStart(11, '0')}\0`, 124);
+ buf.write(`${Math.floor(Date.parse('2026-09-22T12:00:00Z') / 1000).toString(8).padStart(11, '0')}\0`, 136);
+ buf.write(' ', 148);
+ buf.write(type, 156);
+ if (linkname) buf.write(linkname, 157);
+ buf.write('ustar\0', 257);
+ buf.write('00', 263);
+ let sum = 0;
+ for (const b of buf) sum += b;
+ buf.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148);
+ return buf;
+}
+
+function block(data) {
+ const pad = (512 - (data.length % 512)) % 512;
+ return pad ? [data, Buffer.alloc(pad, 0)] : [data];
+}
+
+function paxRecord(key, value) {
+ const body = ` ${key}=${value}\n`;
+ let len = body.length + 1;
+ while (String(len).length + body.length !== len) len = String(len).length + body.length;
+ return `${len}${body}`;
+}
+
+function tarball(entries, { gzip = true } = {}) {
+ const parts = [];
+ for (const e of entries) {
+ if (e.pax) {
+ const data = Buffer.from(Object.entries(e.pax).map(([k, v]) => paxRecord(k, v)).join(''), 'utf8');
+ parts.push(header('PaxHeader', data.length, 'x'), ...block(data));
+ }
+ if (e.longName) {
+ const data = Buffer.from(`${e.longName}\0`, 'utf8');
+ parts.push(header('././@LongLink', data.length, 'L'), ...block(data));
+ }
+ const type = e.type || '0';
+ const data = type === '0' ? (Buffer.isBuffer(e.content) ? e.content : Buffer.from(String(e.content == null ? '' : e.content), 'utf8')) : Buffer.alloc(0);
+ parts.push(header(e.name, data.length, type, e.linkname || ''), ...block(data));
+ }
+ parts.push(Buffer.alloc(1024, 0));
+ const tar = Buffer.concat(parts);
+ return gzip ? zlib.gzipSync(tar) : tar;
+}
+
+/** A small static site: { path: content }. */
+function site(files, opts) {
+ return tarball(Object.entries(files).map(([name, content]) => ({ name, content })), opts);
+}
+
+module.exports = { tarball, site };