openvibers/openvibe.space Commits
Space: static hosting moves here from Host (space.* contracts, staff-only), with a dashboard design pass (#36)
Changes
@@@ -33,8 +33,52 @@ OV_COMMUNITY_URL=https://openvibe.community# OpenVibe.Live — the legal pages the shared footer linksOV_LIVE_URL=https://openvibe.live--# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh space. Space holds no--# user content yet; the database carries the shared schema (migrations/) and feeds /api/ready.++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh space. Space owns static hosting++# state as well as the historical forum schema and feeds /api/ready.# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.DATABASE_URL=DATABASE_DIRECT_URL=++++# Static hosting engine (Stage B). Public project creation remains off until launch.++SPACE_PUBLIC_HOSTING=off++SPACE_SITES_DOMAIN=openvibe.website++SPACE_STORAGE_DIR=./data/objects++# SPACE_OBJECT_STORE=media++# OV_MEDIA_URL=http://127.0.0.1:4100++# OV_MEDIA_NAMESPACE=space++# SPACE_FORM_SECRET= # set a stable random value for dashboard forms++# SPACE_WORKER=off # off only for tests; default on++# SPACE_PGLITE_DIR= # development-only embedded database path++# SPACE_MAX_UPLOAD_BYTES=104857600++# SPACE_MAX_UNPACKED_BYTES=268435456++# SPACE_MAX_CONCURRENT_UPLOADS=2++# SPACE_MIN_FREE_BYTES=5368709120++# SPACE_MAX_PROJECTS_PER_OWNER=10++# SPACE_QUOTA_STORAGE_BYTES=1073741824++# SPACE_QUOTA_DEPLOYS_PER_DAY=50++# SPACE_QUOTA_MAX_FILES=10000++# SPACE_QUOTA_MAX_FILE_BYTES=26214400++# SPACE_QUOTA_SITES=10++# SPACE_QUOTA_CUSTOM_DOMAINS=5++# SPACE_SANDBOX_QUOTA_STORAGE_BYTES=104857600++# SPACE_SANDBOX_QUOTA_DEPLOYS_PER_DAY=20++# SPACE_SANDBOX_QUOTA_MAX_FILES=2000++# SPACE_SANDBOX_QUOTA_MAX_FILE_BYTES=10485760++# SPACE_SANDBOX_QUOTA_SITES=3++# SPACE_SANDBOX_QUOTA_CUSTOM_DOMAINS=0++# SPACE_CNAME_TARGET=++# SPACE_ORIGIN_IPV4=++# SPACE_ORIGIN_IPV6=++# SPACE_DNS_SERVERS=++# SPACE_DOMAIN_RECHECK_MS=600000++# SPACE_DOMAIN_PENDING_DAYS=7++# SPACE_DOMAIN_LAPSE_DAYS=7++# SPACE_RESERVED_DOMAINS=++# SPACE_RESERVED_SITE_NAMES=++# SPACE_ACTOR_LIMITS=on++# EVENTS_URL=++# EVENTS_RELAY_INTERVAL_MS=2000++# INDEXNOW_KEY=++# VALKEY_URL=++# VALKEY_PREFIX=ov:space:++# API_CORS_ORIGINS=https://openvibe.network,https://openvibe.codes@@@ -3,3 +3,9 @@# allowlist below is one proven false positive, matched on path AND value so nothing else is hidden.[extend]useDefault = true++++[[allowlists]]++description = "Fake Stripe key planted in a test deploy to prove Space rejects and never echoes secrets (moved from Host)"++condition = "AND"++paths = ['''^test/hosting-secrets-dashboard\.test\.js$''']++regexes = ['''^sk_live_TOPSECRET123$''']@@@ -1,20 +1,20 @@# OpenVibe.Space--**https://openvibe.space — code and dynamic pages for the OpenVibe network.**++**https://openvibe.space — static hosting for the OpenVibe network.**--## Purpose++## Purpose and status--Space is being rebuilt to host **code** and **dynamic pages**, and **spaces**: apps people build--that use their OpenVibe account, the network's services and the SDK.++Space owns static hosting. The Stage B engine moved here from OpenVibe.Host as code; production had no projects, sites or deploys to migrate. It supports projects and members, immutable content-addressed deploys, activation and one-step rollback, private previews, CI Git ingest, per-site configuration, verified custom domains, takedowns, Media-backed objects, Host-header tenant serving, and a resource index. Tenant names serve at `<name>.openvibe.website`.--Nothing is published here yet. The site is honest about that: the home page says what is coming and--nothing else, and no feature is faked.++Public hosting remains off by default (`SPACE_PUBLIC_HOSTING=off`). Only OpenVibe staff can create projects. Abuse handling and a designated DMCA agent precede public launch. Dynamic pages and spaces (small apps on a person's OpenVibe account) are planned and not built.++++OpenVibe.Host is the hosting price comparison; its copy of the engine is being removed, and its old hosting URLs will answer 410 with a link here.## The forum moved to OpenVibe.CommunityThe forum lived here from 3 October 2026 (it moved off Community then). On **2026-10-08 the ownermoved it back**: OpenVibe.Community owns the forum (spaces, threads, posts, votes, categories,--per-space moderators, the Discord relay) again, and Space becomes code hosting and spaces.++per-space moderators, the Discord relay) again, and Space became the network's hosting (static sites now; dynamic pages and spaces next).So Space keeps no forum code and **every forum URL answers a permanent redirect to the same path andquery on https://openvibe.community**:@@@ -28,174 +28,67 @@ query on https://openvibe.community**:A redirected bookmark, crawler or API client therefore lands on Community's copy of the same page.Nothing that is not a forum path redirects.--## What is here today----- **`GET /`** — the home page (server-rendered through the shared OpenVibe Frame), with a clear link to-- the forum at https://openvibe.community/s.--- **Sign-in** — the Network OAuth2 client `space`: `/auth/login|callback|fedcm|logout|me|refresh`.-- No signed-in surface needs it yet; it is kept because the shared navbar signs people in everywhere.--- **Discovery** — `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` (the home page alone).--- **Health and release** — `/api/health`, `/api/ready`, `/release.json`, `/metrics` (loopback only).----## Owns----Space's own database is `ov_space` on the host's data role; `migrations/0001_initial.sql` is the whole--schema and it runs at boot. The tables it creates:----- **forum tables** (what Space served when the forum lived here): `space_groups`, `spaces`,-- `categories`, `threads`, `posts`, `post_versions`, `thread_votes`, `attachments`, `post_reactions`,-- `space_chat_rooms`, `space_moderators`;--- **Discord relay bookkeeping**: `relay_mappings`, `relay_deliveries`, `relay_message_map`,-- `relay_cursors`, `relay_inbound_failures`;--- **projections and records**: `subject_projection` (a display cache of what OpenVibe.Network says-- about a subject — never authority), `legacy_id_map`, `network_blocks`, `account_data_events`;--- **shared runtime tables**: `space_event_inbox`, `token_revocations`, `event_outbox`.----Space is the authority for this schema and for what it serves: the home page, the forum redirects,--sign-in, the discovery files and health/readiness. It is not the authority for the forum rows — the--forum is OpenVibe.Community's, so those rows are Community's, and this release writes no drop--migration.----## Does not own++## Routes--- the forum, pastes, typed comment threads, Pulse and submissions — OpenVibe.Community's--- identity, accounts and tokens — OpenVibe.Network's--- images, memberships and chat rooms — OpenVibe.Media's, OpenVibe.VIP's and OpenVibe.Chat's++| Route | Purpose |++| --- | --- |++| `/` | Public status and static hosting description |++| `/dashboard`, `/projects/:id`, `/sites/:id`, `/deploys/:id` | Signed-in hosting dashboard and forms |++| `/preview/:deployId/*` | Private, sandboxed deploy preview |++| `/api/v1/projects`, `/api/v1/sites`, `/api/v1/deploys`, `/api/v1/domains` | Hosting API (Bearer tokens, no cookie auth) |++| `/api/v1/resources` | First-party resource index |++| `/limits.json` | Enforced hosting limits |++| `/auth/login|callback|fedcm|logout|me|refresh` | Space's Network OAuth client |++| `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` | Discovery for Space's public page |++| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Health, release and observability |--## Depends on++Tenant requests are dispatched by Host header before dashboard routes. `deploy/nginx/openvibe.website.conf` is a reference wildcard vhost; the existing `openvibe.space` vhost includes larger upload locations. Neither is installed by this code change. Unknown hosts answer 404. Tenant sites cannot reach the dashboard, API, auth or metrics. A custom domain serves only after TXT verification.--- OpenVibe.Network (SSO, JWKS, the `space` OAuth client)--- OpenVibe.Community (where every forum URL redirects)--- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.44.0, `openvibe-shared` v4.0.0, pinned by release tarball++## Capabilities and events--## Capabilities++The hosting API guards `space.site.manage`, `space.site.config`, `space.deploy.create`, `space.domain.manage` and `space.resource.read` (openvibe-contracts 0.149.0, owner `space`); every route names exactly one, and `npx openvibe-contracts-check --service space` checks the code against the release. Service tokens need audience `openvibe.space`, and project membership still applies. The per-actor limits (`server/http/actor-limits.js`) count uploads, domain checks, project creation and writes per person or app; those are limit buckets, not capabilities. The retired `space.forum.*` contracts are unrelated to hosting.--`manifests/services/space.json` lists **no capabilities** (`"capabilities": []`), so Space serves no--capability of its own. The forum capabilities it once owned are retired: Contracts still carries--`space.forum.read`, `space.forum.manage`, `space.thread.read`, `space.thread.write`, `space.post.write`--and `space.pulse.read`, each marked `status: retired` and pointing at its `community.*` replacement on--OpenVibe.Community.++The transactional outbox emits `space.deploy.created`, `space.deploy.activated`, `space.deploy.failed` and `space.domain.verified`; each has a payload schema in openvibe-contracts, and the SDK outbox refuses any other type.--Space calls no capability on another service. Its only outbound calls are OpenVibe.Network's OAuth--token grant, `/oauth/revoke` and the JWKS document, which are OAuth endpoints rather than the--capability surface. (The test Network mock still offers `identity.subject.resolve`, but nothing in--`server/` calls it.)++## Data and configuration--## What Space applies from the network: nothing++`migrations/0002_initial.sql` through `0005_git_sources.sql` create the hosting tables, indexes and immutable-artifact triggers from Host Stage B with `space_` names. They run through `openvibe-sdk/db` at boot; tables are never created by a request. `0001_initial.sql` remains the historical forum schema, which this release does not drop. This is a code move, with no data copy.--Space holds **no user content**, so it consumes no network events and has nothing to export or erase:++Copy `.env.example` to `.env`. Production uses `/etc/openvibe/space.env` (0600). Important values:--- **Account export and deletion are gone.** Space's former export/deletion hooks (`network.account.export_requested`,-- `network.account.deleted`) only ever touched the **forum tables** — threads, posts, attachments, votes,-- reactions, the moderator list. Those tables' rows are the forum's, and the forum is-- OpenVibe.Community's, so **the forum's account data is Community's** and Space's hooks were removed.--- **Platform blocks, subject merges and VIP membership changes are gone** for the same reason: they only-- updated forum projections.--- The **token-cutoff projection** (`network.user.token_valid_after`) went with them: without signed-in-- content there is nothing here for a cutoff to protect.----The forum tables stay in Space's database (migrations `0001_initial.sql` onward still create them).--This release writes **no drop migration**; the rows are Community's, and dropping them is a later--decision.----## Configuration----Copy `.env.example` to `.env` (production: `/etc/openvibe/space.env`, mode 0600).----| Variable | Default | Meaning |++| Variable | Default | Purpose || --- | --- | --- |--| `PORT` / `HOST` | `4940` / `127.0.0.1` | Listen address (nginx in front) |--| `BASE_URL` | `https://openvibe.space` in production | Canonical origin |--| `TRUST_PROXY` | `2` | X-Forwarded-For hops (Cloudflare → nginx → Node) |--| `OV_NETWORK_URL` | `https://openvibe.network` | Issuer / authorize URL / JWKS |--| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | Token grants, JWKS (tried first) |--| `OV_OAUTH_CLIENT_ID` | `space` | Registered on the Network |--| `OV_OAUTH_CLIENT_SECRET` | — | **Required** for sign-in |--| `OV_OAUTH_REDIRECT_URI` | `https://openvibe.space/auth/callback` | Must match the registration |--| `OV_COMMUNITY_URL` | `https://openvibe.community` | Where every forum URL redirects |--| `OV_LIVE_URL` | `https://openvibe.live` | Legal pages the shared footer links |--| `DATABASE_URL`, `DATABASE_DIRECT_URL` | unset (development: embedded PGlite in `data/pglite`) | PostgreSQL through PgBouncer, and the owner's direct connection for migrations (written by OpenVibe.Host `roles/data/add-service.sh space`) |----## Run----```--npm install--npm start # http://127.0.0.1:4940--npm test # boots the Network mock in-process; no network needed--```----## Acceptance----`npm test` runs every `test/*.test.js`:----- `home` — the home page says what Space will host and links the forum on Community; discovery, health,-- readiness and sign-in answer; an unknown path is an honest 404;--- `redirects` — `/s`, `/s/*` (301 for GET/HEAD, 308 otherwise) and the forum APIs (308) redirect to the-- same path and query on openvibe.community, and nothing else redirects;--- `boot` — the real process on a fresh PGlite database: migrations run, `/api/ready` is 2xx, the home-- page renders, the forum URLs redirect, and SIGTERM stops it cleanly.----## Security----Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:----- **Auth.** Browsers use Network JWTs (the `ov_token` cookie); the session layer verifies them offline-- against the Network's JWKS. The OAuth `state` cookie is required, and post-auth targets are restricted-- to this site or `https://openvibe.network/…`.--- **Egress.** Space calls only its configured Network host.--- **Secrets.** `OV_OAUTH_CLIENT_SECRET` lives in `/etc/openvibe/space.env` (0600), by name only.++| `SPACE_PUBLIC_HOSTING` | `off` | Staff-only project creation until launch |++| `SPACE_SITES_DOMAIN` | `openvibe.website` | Tenant names |++| `SPACE_STORAGE_DIR` | `./data/objects` | Content-addressed local object store |++| `SPACE_OBJECT_STORE` | `local` | Set `media` for Media write-through and cache |++| `SPACE_FORM_SECRET` | random per process | Dashboard CSRF form signing |++| `SPACE_WORKER` | on | Domain rechecks, object sweep, outbox pruning |++| `EVENTS_URL` | unset | Outbox relay destination; unset keeps rows queued |++| `DATABASE_URL`, `DATABASE_DIRECT_URL` | unset | PostgreSQL serving and owner migration connections |--## Deploy++Other `SPACE_*` quota, upload, domain and DNS settings are listed in `.env.example` and `server/hosting-config.js`. Space keeps its existing OAuth and forum redirect settings.--```--/opt/openvibe.space # git checkout, `npm ci --omit=dev`--/etc/openvibe/space.env # secrets (0600)--deploy/systemd/openvibe-space.service # → /etc/systemd/system/, User=ubuntu, port 4940--deploy/nginx/openvibe.space.conf # → /etc/nginx/sites-available/, TLS from-- # /etc/letsencrypt/live/openvibe.space/--```----The database is `ov_space` on the host's data role. Rollback: ovhost puts the previous sha back when--`/api/ready` does not answer 2xx after the restart.----## Layout++## Run and tests++```sh++npm install++npm start++npm test```--server/-- index.js process entry (listen, graceful stop)-- app.js Express app factory: redirects, home page, sign-in, discovery, health-- config.js env → config-- db.js PostgreSQL (openvibe-sdk/db): initDb/getDb, migrations/ applied at boot-- discovery.js robots disallow set, llms sections, the sitemap (the home page alone)-- observability.js truthful /api/ready (the database is required; the Network key degrades)-- auth/routes.js OAuth2 client (login/callback/fedcm/logout/me/refresh)-- render/layout.js page shell (openvibe-shared/shell) and the redirect/site constants-- render/pages.js the home page and the error pages--public/ css/space.css, assets/ (logo, og-default), manifest.webmanifest--migrations/ the shared schema, including the forum tables Space no longer serves--deploy/ systemd unit, nginx vhost--test/ run.js + home/redirects/boot tests--```----## What is next--Code hosting (repositories, releases) and dynamic pages, then "spaces": apps people build on their--OpenVibe account, the network's services and the SDK.++`test/hosting-core.test.js` exercises migrations, launch gate, project/site creation, tenant dispatch, immutable deploys, pointer rollback and events directly against PGlite without a socket. The ported Stage B integration files under `test/hosting-*.test.js` use `test/stageb/boot.js` and raw HTTP for Host-header and request-target cases. The existing `home`, `redirects`, and `boot` tests cover Space's public app and forum redirects.--## Related services++## Security and dependencies--- Identity/SSO: https://openvibe.network (OpenVibers/OpenVibe.Network)--- The forum and the hub: https://openvibe.community (OpenVibers/OpenVibe.Community)--- Streaming: https://openvibe.live (OpenVibers/OpenVibe.Live)--- Tools: https://openvibe.tools (OpenVibers/OpenVibe.Tools)--- Media: https://openvibe.media (OpenVibers/OpenVibe.Media)++Static uploads are validated before storage; tenant content runs on a separate registrable domain and previews use a sandbox CSP. APIs ignore cookies. OpenVibe.Network supplies identity; `svc:space` needs `events.event.publish` and, when `SPACE_OBJECT_STORE=media`, the `media.object.upload/read/list/delete` grants for the `space` namespace. OpenVibe.Media may hold tenant objects; OpenVibe.Events receives the outbox when configured. Space's own database is `ov_space` through `openvibe-sdk/db`. See [SECURITY.md](SECURITY.md).## LicenseAGPL-3.0-only. See [LICENSE](LICENSE).<!-- versions:start -->--- openvibe-contracts: v0.143.0++- openvibe-contracts: v0.149.0- openvibe-sdk: v0.44.0- openvibe-shared: v4.0.0<!-- versions:end -->@@@ -2,30 +2,35 @@"repository": "OpenVibers/OpenVibe.Space","stage": "alpha","code": true,-- "plan": "OpenVibe Updated Plan Based On Codebase (T0–T19) — the forum moved back to OpenVibe.Community (owner decision, 2026-10-08); Space becomes code hosting and spaces",++ "plan": "T20 step 2: static hosting engine moved into Space; public launch remains gated","runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async)","port": 4940,"domain": "openvibe.space",-- "contracts": "openvibe-contracts v0.143.0",++ "contracts": "openvibe-contracts v0.149.0","sdk": "openvibe-sdk v0.44.0","shared": "openvibe-shared v4.0.0",-- "capabilities": [],-- "capabilitiesRegistered": false,++ "capabilities": [++ "space.site.manage",++ "space.site.config",++ "space.deploy.create",++ "space.domain.manage",++ "space.resource.read"++ ],++ "capabilitiesRegistered": true,"deployed": true,"deployment": "openvibe-ovh: unit openvibe-space.service on 127.0.0.1:4940 (git checkout /opt/openvibe.space, env /etc/openvibe/space.env, database ov_space), deployed with `sudo ovhost deploy space`; public at https://openvibe.space since 2026-10-08.","features": [-- "the home page: what Space will host (code, dynamic pages, \"spaces\" apps) and where the forum went",-- "permanent forum redirects: /s and /s/* (301 GET/HEAD, 308 otherwise) and the forum APIs (308) to openvibe.community",-- "sign-in: the Network OAuth2 client `space` (login, callback, FedCM, logout, me, refresh)",-- "discovery: robots.txt, llms.txt, llms-full.txt, sitemap.xml (the home page alone)",-- "health and release: /api/health, /api/ready, /release.json, /metrics (loopback)",-- "the process boots on a fresh database and stops cleanly (test/boot.test.js)"++ "staff-only static hosting engine: projects, sites, immutable deploys, previews, rollback, Git ingest, custom domains, takedowns, Media-backed objects and resource index",++ "tenant serving at <name>.openvibe.website",++ "permanent forum redirects to OpenVibe.Community",++ "Network OAuth sign-in, discovery, health and readiness"],"knownGaps": [-- "code hosting and dynamic pages are not built yet, and no \"spaces\" apps run here; the home page says so",-- "openvibe-contracts still registers space.forum.read/manage, space.thread.read and space.post.write for Space; the Contracts update that retires or reassigns them is a separate change",-- "the forum tables remain in ov_space (migrations 0001+ still create them) with their rows; no drop migration in this release",-- "Space consumes no network events and has no account export/deletion: it holds no user content, and the forum's account data is Community's"++ "Public project creation is off until abuse handling and a designated DMCA agent are in place",++ "Dynamic pages and spaces apps are not built",++ "The historical forum tables remain in ov_space; this code move copies no production hosting data",++ "No app or agent holds a space.* grant on OpenVibe.Network yet; staff use the dashboard"],-- "updated": "2026-10-11"++ "hostingDeployed": false,++ "updated": "2026-10-12"}@@@ -4,8 +4,8 @@## This file is what is deployed to /etc/nginx/sites-available/openvibe.space.conf## — keep them in sync, and reload nginx after changing it.##--## The Node app (port 4940, systemd unit openvibe-space) renders the site: the home page, sign-in--## (/auth/*), the discovery files (/robots.txt, /llms.txt, /sitemap.xml) and health/readiness.++## The Node app (port 4940, systemd unit openvibe-space) renders the home page, hosting++## dashboard/API, sign-in, discovery files and health/readiness.## The forum is OpenVibe.Community's again (owner decision, 2026-10-08): Node answers every forum URL## — /s and /s/*, /api/v1/spaces|posts|space-groups|relay — with a permanent redirect to the same path## there, so nginx just proxies. nginx terminates TLS, redirects www → apex, rate-limits the auth and@@@ -97,8 +97,30 @@ server {proxy_set_header X-Forwarded-Proto $scheme;}++ # Hosting uploads may carry a compressed archive or multipart files up to SPACE_MAX_UPLOAD_BYTES.++ location ~ ^/api/v1/sites/[^/]+/(source/)?deploys/?$ {++ client_max_body_size 110m;++ limit_req zone=ovspace_api burst=30 nodelay;++ proxy_pass http://127.0.0.1:4940;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ }++ location ~ ^/sites/[^/]+/deploys/?$ {++ client_max_body_size 110m;++ limit_req zone=ovspace_api burst=30 nodelay;++ proxy_pass http://127.0.0.1:4940;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ }++# ── API (moderate rate limit) ───────────────────────────-- # Space serves only /api/health and /api/ready here; the forum APIs are permanent redirects to++ # Hosting APIs and health/readiness live here; the forum APIs are permanent redirects to# OpenVibe.Community, and a redirected request should reach it with its method and body intact.location /api/ {limit_req zone=ovspace_api burst=30 nodelay;nnew file mode 100644@@@ -0,0 +1,36 @@++# Reference wildcard tenant vhost for OpenVibe.Space. Install only with a wildcard certificate++# for openvibe.website and the matching DNS; custom-domain TLS vhosts are generated separately.++limit_req_zone $binary_remote_addr zone=ovspace_sites:20m rate=50r/s;++server {++ listen 80;++ listen [::]:80;++ server_name *.openvibe.website;++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://$host$request_uri; }++}++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name *.openvibe.website;++ ssl_certificate /etc/letsencrypt/live/openvibe.website/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.website/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++ client_max_body_size 1k;++ add_header Strict-Transport-Security "max-age=31536000" always;++ location / {++ limit_except GET HEAD { deny all; }++ limit_req zone=ovspace_sites burst=200 nodelay;++ proxy_pass http://127.0.0.1:4940;++ proxy_http_version 1.1;++ proxy_set_header Connection "";++ proxy_set_header Host $host;++ proxy_set_header Cookie "";++ proxy_set_header Authorization "";++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ proxy_hide_header Set-Cookie;++ proxy_read_timeout 60s;++ }++}nnew file mode 100644@@@ -0,0 +1,170 @@++-- phase: expand++-- OpenVibe.Space on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk++-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),++-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.++++CREATE TABLE space_projects (++ id text COLLATE "C" PRIMARY KEY, -- prj_<ULID>++ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)++ network_project_id text COLLATE "C", -- OpenVibe.Network project id (ADR-014), when it exists++ owner_subject text COLLATE "C" NOT NULL, -- usr_…++ name text COLLATE "C" NOT NULL,++ environment text COLLATE "C" NOT NULL DEFAULT 'production' CHECK (environment IN ('production','sandbox')),++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ deleted_at bigint++);++CREATE UNIQUE INDEX space_projects_network ON space_projects (network_project_id) WHERE network_project_id IS NOT NULL AND status = 'active';++CREATE INDEX space_projects_owner ON space_projects (owner_subject, status);++++CREATE TABLE space_project_members (++ project_id text COLLATE "C" NOT NULL REFERENCES space_projects(id),++ principal text COLLATE "C" NOT NULL, -- usr_… | app:app_… | svc:<id>++ role text COLLATE "C" NOT NULL CHECK (role IN ('owner','maintainer','deployer')),++ added_by text COLLATE "C",++ created_at bigint NOT NULL,++ PRIMARY KEY (project_id, principal)++);++CREATE INDEX space_project_members_principal ON space_project_members (principal);++++CREATE TABLE space_quotas (++ project_id text COLLATE "C" PRIMARY KEY REFERENCES space_projects(id),++ storage_bytes bigint,++ deploys_per_day bigint,++ max_files bigint,++ max_file_bytes bigint,++ sites bigint,++ custom_domains bigint,++ updated_by text COLLATE "C",++ updated_at bigint NOT NULL++);++++CREATE TABLE space_sites (++ id text COLLATE "C" PRIMARY KEY, -- site_<ULID>++ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)++ project_id text COLLATE "C" NOT NULL REFERENCES space_projects(id),++ name text COLLATE "C" NOT NULL, -- the <name>.openvibe.space label++ active_deploy_id text COLLATE "C",++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),++ created_by text COLLATE "C" NOT NULL,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ deleted_at bigint++);++CREATE UNIQUE INDEX space_sites_name ON space_sites (name) WHERE status = 'active';++CREATE INDEX space_sites_project ON space_sites (project_id, status);++++CREATE TABLE space_deploys (++ id text COLLATE "C" PRIMARY KEY, -- dpl_<ULID>++ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)++ project_id text COLLATE "C" NOT NULL REFERENCES space_projects(id),++ site_id text COLLATE "C" NOT NULL REFERENCES space_sites(id),++ state text COLLATE "C" NOT NULL CHECK (state IN ('ready','failed','deleted')),++ source text COLLATE "C" NOT NULL CHECK (source IN ('archive','files')),++ manifest text COLLATE "C", -- JSON space.deploy-manifest@1 (NULL when failed)++ manifest_sha256 text COLLATE "C",++ file_count bigint NOT NULL DEFAULT 0,++ total_bytes bigint NOT NULL DEFAULT 0,++ new_bytes bigint NOT NULL DEFAULT 0, -- bytes this deploy added to the project's storage++ failure_code text COLLATE "C",++ created_by text COLLATE "C" NOT NULL,++ created_at bigint NOT NULL,++ deleted_at bigint++);++CREATE INDEX space_deploys_site ON space_deploys (site_id, created_at);++CREATE INDEX space_deploys_project_day ON space_deploys (project_id, created_at);++++CREATE TABLE space_deploy_files (++ deploy_id text COLLATE "C" NOT NULL REFERENCES space_deploys(id),++ path text COLLATE "C" NOT NULL,++ sha256 text COLLATE "C" NOT NULL,++ size bigint NOT NULL,++ content_type text COLLATE "C" NOT NULL,++ PRIMARY KEY (deploy_id, path)++);++CREATE INDEX space_deploy_files_sha ON space_deploy_files (sha256);++++CREATE TABLE space_blobs (++ project_id text COLLATE "C" NOT NULL,++ sha256 text COLLATE "C" NOT NULL,++ size bigint NOT NULL,++ created_at bigint NOT NULL,++ PRIMARY KEY (project_id, sha256)++);++++CREATE TABLE space_activations (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ site_id text COLLATE "C" NOT NULL REFERENCES space_sites(id),++ deploy_id text COLLATE "C" NOT NULL,++ previous_deploy_id text COLLATE "C",++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('activate','rollback')),++ actor text COLLATE "C" NOT NULL,++ created_at bigint NOT NULL++);++CREATE INDEX space_activations_site ON space_activations (site_id, id);++++CREATE TABLE space_deploy_logs (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ deploy_id text COLLATE "C" NOT NULL,++ level text COLLATE "C" NOT NULL CHECK (level IN ('info','warn','error')),++ message text COLLATE "C" NOT NULL,++ created_at bigint NOT NULL++);++CREATE INDEX space_deploy_logs_deploy ON space_deploy_logs (deploy_id, id);++++CREATE TABLE space_domains (++ id text COLLATE "C" PRIMARY KEY, -- dom_<ULID>++ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)++ project_id text COLLATE "C" NOT NULL REFERENCES space_projects(id),++ site_id text COLLATE "C" NOT NULL REFERENCES space_sites(id),++ hostname text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('default','custom')),++ status text COLLATE "C" NOT NULL CHECK (status IN ('pending','verified','failed','lapsed')),++ token text COLLATE "C", -- the TXT verification value (custom only; public in DNS, not a secret)++ created_by text COLLATE "C" NOT NULL,++ created_at bigint NOT NULL,++ verified_at bigint,++ last_checked_at bigint,++ last_error text COLLATE "C",++ check_count bigint NOT NULL DEFAULT 0,++ record_missing_since bigint -- verified domain whose TXT record disappeared++);++CREATE UNIQUE INDEX space_domains_verified ON space_domains (hostname) WHERE status = 'verified';++CREATE UNIQUE INDEX space_domains_site_host ON space_domains (site_id, hostname);++CREATE INDEX space_domains_status ON space_domains (kind, status, last_checked_at);++++-- Abuse takedowns (staff only): a site, or every site of a project, stops being served (451) while++-- its deploys, files and objects are KEPT for review. Rows are never deleted: a lift is recorded.++CREATE TABLE space_takedowns (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ target_kind text COLLATE "C" NOT NULL CHECK (target_kind IN ('project','site')),++ target_id text COLLATE "C" NOT NULL, -- prj_… or site_…++ reason text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ created_at bigint NOT NULL,++ lifted_by text COLLATE "C",++ lifted_at bigint,++ lift_note text COLLATE "C"++);++CREATE UNIQUE INDEX space_takedowns_active ON space_takedowns (target_kind, target_id) WHERE lifted_at IS NULL;++++-- Deploy artifacts are immutable: only state / deleted_at may change, and never back from 'deleted'.++CREATE FUNCTION space_deploys_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TRUE THEN RAISE EXCEPTION 'space: deploy artifacts are immutable'; END IF; RETURN NEW; END $$;++CREATE TRIGGER space_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 space_deploys FOR EACH ROW EXECUTE FUNCTION space_deploys_immutable();++CREATE FUNCTION space_deploys_no_undelete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.state = 'deleted' OR (OLD.state = 'failed' AND NEW.state = 'ready') THEN RAISE EXCEPTION 'space: a deleted or failed deploy cannot become ready'; END IF; RETURN NEW; END $$;++CREATE TRIGGER space_deploys_no_undelete BEFORE UPDATE OF state ON space_deploys FOR EACH ROW EXECUTE FUNCTION space_deploys_no_undelete();++CREATE FUNCTION space_deploy_files_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TRUE THEN RAISE EXCEPTION 'space: deploy files are immutable'; END IF; RETURN NEW; END $$;++CREATE TRIGGER space_deploy_files_immutable BEFORE UPDATE ON space_deploy_files FOR EACH ROW EXECUTE FUNCTION space_deploy_files_immutable();++CREATE FUNCTION space_deploy_files_delete_only_deleted() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF (SELECT state FROM space_deploys WHERE id = OLD.deploy_id) <> 'deleted' THEN RAISE EXCEPTION 'space: files of a live deploy cannot be removed'; END IF; RETURN OLD; END $$;++CREATE TRIGGER space_deploy_files_delete_only_deleted BEFORE DELETE ON space_deploy_files FOR EACH ROW EXECUTE FUNCTION space_deploy_files_delete_only_deleted();++++-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled++CREATE TABLE IF NOT EXISTS idempotency_receipts (++ consumer text NOT NULL,++ event_id text NOT NULL,++ processed_at bigint NOT NULL,++ PRIMARY KEY (consumer, event_id)++);++++-- openvibe-sdk/events PostgreSQL outboxnnew file mode 100644@@@ -0,0 +1,15 @@++-- phase: expand++-- Per-site serving configuration (plan T12 J3, decision D4): the response headers, the redirects and++-- the SPA fallback of ONE site, kept in PostgreSQL and applied in server/http/tenant.js BEFORE the++-- platform's own headers. The platform's security, routing and caching headers (CSP, HSTS,++-- Set-Cookie, X-Forwarded-*, Cache-Control, …) are reserved and can never be set through it, and a++-- redirect target must be a local path, so a site can never become an open redirect. A site with no++-- row here serves exactly as before (no headers, no redirects, no SPA fallback).++CREATE TABLE space_site_config (++ site_id text COLLATE "C" PRIMARY KEY REFERENCES space_sites(id),++ headers jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "Header-Name": "value" }, validated on write++ redirects jsonb NOT NULL DEFAULT '[]'::jsonb, -- [ { from, to, status } ], local targets only++ spa boolean NOT NULL DEFAULT false, -- serve index.html for extensionless paths++ updated_by text COLLATE "C",++ updated_at bigint NOT NULL++);nnew file mode 100644@@@ -0,0 +1,16 @@++-- phase: expand++-- Preview deploys (plan T12 J4, decision D5): a preview is served by the dashboard only, at++-- /preview/<deploy-id>/…, to a member of the deploy's project — never on a public host, never indexed.++--++-- source='preview' a deploy uploaded as a preview (its archive/files kind is in the upload log).++-- preview pointer space_sites.preview_deploy_id names the site's ONE live preview; the tenant++-- server reads it once, like active_deploy_id. The pointer is cleared when the++-- preview expires, when the site deploys or rolls back (any pointer switch), when++-- the preview is deleted, or when the site is deleted.++-- preview_expires_at a preview stops being served at this instant (epoch ms); the row and its++-- objects stay until the deploy is deleted.++ALTER TABLE space_deploys DROP CONSTRAINT space_deploys_source_check;++ALTER TABLE space_deploys ADD CONSTRAINT space_deploys_source_check CHECK (source IN ('archive', 'files', 'preview'));++++ALTER TABLE space_sites ADD COLUMN preview_deploy_id text COLLATE "C";++ALTER TABLE space_sites ADD COLUMN preview_expires_at bigint;nnew file mode 100644@@@ -0,0 +1,38 @@++-- phase: expand++-- Git deploys, Phase 1 (plan T12 Stage B): provenance and preview, with the build OUTSIDE Host.++-- A site may name the Git repository and branch its project's own CI builds from. That CI checks the++-- commit out, builds it and posts the output to POST /api/v1/sites/:id/source/deploys with the ref and++-- the commit it built; Host validates the files exactly like an upload and stores them as a deploy++-- with source 'git' that becomes the site's live preview, never its active deploy. Host never clones,++-- fetches, holds a credential or runs anything: these rows hold public provenance only.++--++-- space_site_sources the site's connected repository (one per site): an https URL on a known++-- provider and the branch the CI deploys from. No secret, no credential name.++-- space_deploy_git what a git deploy was built from, written in the same transaction as the++-- deploy and immutable afterwards, like the deploy's files.++ALTER TABLE space_deploys DROP CONSTRAINT space_deploys_source_check;++ALTER TABLE space_deploys ADD CONSTRAINT space_deploys_source_check CHECK (source IN ('archive', 'files', 'preview', 'git'));++++CREATE TABLE space_site_sources (++ site_id text COLLATE "C" PRIMARY KEY REFERENCES space_sites(id),++ provider text COLLATE "C" NOT NULL CHECK (provider IN ('github', 'gitlab', 'codeberg')),++ repo_url text COLLATE "C" NOT NULL,++ ref text COLLATE "C" NOT NULL,++ created_by text COLLATE "C",++ created_at bigint NOT NULL,++ updated_by text COLLATE "C",++ updated_at bigint NOT NULL++);++++CREATE TABLE space_deploy_git (++ deploy_id text COLLATE "C" PRIMARY KEY REFERENCES space_deploys(id),++ provider text COLLATE "C" NOT NULL,++ repo_url text COLLATE "C" NOT NULL,++ ref text COLLATE "C" NOT NULL,++ commit_sha text COLLATE "C" NOT NULL,++ created_at bigint NOT NULL++);++++-- A deploy's provenance is immutable, like its files.++CREATE FUNCTION space_deploy_git_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TRUE THEN RAISE EXCEPTION 'space: deploy provenance is immutable'; END IF; RETURN NEW; END $$;++CREATE TRIGGER space_deploy_git_immutable BEFORE UPDATE ON space_deploy_git FOR EACH ROW EXECUTE FUNCTION space_deploy_git_immutable();@@@ -9,18 +9,20 @@"version": "0.1.0","license": "AGPL-3.0-only","dependencies": {++ "busboy": "^1.6.0","cookie-parser": "^1.4.7","dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0","pg": "^8.23.0"},"devDependencies": {-- "@electric-sql/pglite": "^0.5.8"++ "@electric-sql/pglite": "^0.5.8",++ "jsonwebtoken": "^9.0.3"},"engines": {"node": ">=22.0.0"@@@ -115,6 +117,17 @@"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",@@@ -791,9 +804,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.143.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",-- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",++ "version": "0.149.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",++ "integrity": "sha512-7TGh8i3dPHHxQsAblWMSPvkigY//mqaUtm1u7T3QtIiBLlGKRlEwCgc3nTd7agd5f7N8aDxfw6z8Ls8bAg/smA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1231,6 +1244,14 @@"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/toidentifier": {"version": "1.0.1","resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",@@@ -2,7 +2,7 @@"name": "openvibe-space","version": "0.1.0","private": true,-- "description": "OpenVibe.Space — code and dynamic pages for the OpenVibe network, and \"spaces\": apps people build that use their OpenVibe account, the network's services and the SDK. The forum moved back to OpenVibe.Community (owner decision, 2026-10-08); every forum URL here redirects there.",++ "description": "OpenVibe.Space — staff-only static hosting for the OpenVibe network; dynamic pages and spaces are planned. Forum URLs redirect to OpenVibe.Community.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -13,20 +13,21 @@"keywords": ["openvibe","space",-- "code",-- "pages",-- "apps"++ "static hosting",++ "sites",++ "deploys"],"author": "OpenVibers","license": "AGPL-3.0-only","repository": "github:OpenVibers/OpenVibe.Space","dependencies": {++ "busboy": "^1.6.0","cookie-parser": "^1.4.7","dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0","pg": "^8.23.0"@@@ -35,6 +36,7 @@"node": ">=22.0.0"},"devDependencies": {-- "@electric-sql/pglite": "^0.5.8"++ "@electric-sql/pglite": "^0.5.8",++ "jsonwebtoken": "^9.0.3"}}nnew file mode 100644@@@ -0,0 +1,198 @@++/* OpenVibe.Space — the hosting dashboard (server/render/hosting-pages.js, hd-*). Loaded only on dashboard pages++ (layout localStyles); colours come from the network theme variables, with the default theme as fallback. */++++.hosting-dashboard .page {++ --hd-card: var(--bg-card, var(--bg-secondary, #101828));++ --hd-line: var(--border, #24324d);++ --hd-line-strong: var(--border-light, #2c3d5c);++ --hd-text: var(--text-primary, #e6edf7);++ --hd-dim: var(--text-secondary, #96a7c2);++ --hd-faint: var(--text-muted, #6b7c99);++ --hd-accent: var(--accent, #3b82f6);++ --hd-ok: var(--success, #22c55e);++ --hd-warn: var(--warning, #f59e0b);++ --hd-bad: var(--danger, #ef4444);++ max-width: 1120px;++ margin: 0 auto;++ padding: 32px 24px 80px;++ color: var(--hd-text);++}++++.hosting-dashboard code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .86em; }++.hosting-dashboard a { color: var(--accent-light, var(--hd-accent)); }++.hd-muted { color: var(--hd-dim); font-size: 14px; line-height: 1.5; }++.hd-wrap { overflow-wrap: anywhere; }++.hd-num { text-align: right; font-variant-numeric: tabular-nums; }++++/* Page header */++.hd-head { margin: 0 0 24px; }++.hd-head h1 { margin: 6px 0 8px; font-size: clamp(26px, 3.4vw, 36px); line-height: 1.15; letter-spacing: -.02em; overflow-wrap: anywhere; }++.hd-head h1 code { font-size: .8em; }++.hd-eyebrow { display: inline-flex; gap: 8px; align-items: center; margin: 0; padding: 4px 12px; border-radius: 999px; border: 1px solid var(--hd-line-strong); color: var(--accent-light, var(--hd-accent)); font-size: 12px; font-weight: 700; letter-spacing: .4px; }++.hd-sub { margin: 0; color: var(--hd-dim); max-width: 640px; line-height: 1.6; }++.hd-crumbs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 14px; color: var(--hd-faint); }++.hd-crumbs a { color: var(--hd-dim); text-decoration: none; }++.hd-crumbs a:hover { color: var(--hd-text); text-decoration: underline; }++.hd-chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0; color: var(--hd-dim); }++.hd-chips > code { color: var(--hd-faint); }++++/* Badges */++.hd-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; line-height: 18px; white-space: nowrap; border: 1px solid var(--hd-line-strong); color: var(--hd-dim); background: transparent; }++.hd-badge--ok { color: var(--hd-ok); border-color: color-mix(in srgb, var(--hd-ok) 45%, transparent); background: color-mix(in srgb, var(--hd-ok) 12%, transparent); }++.hd-badge--warn { color: var(--hd-warn); border-color: color-mix(in srgb, var(--hd-warn) 45%, transparent); background: color-mix(in srgb, var(--hd-warn) 12%, transparent); }++.hd-badge--bad { color: var(--hd-bad); border-color: color-mix(in srgb, var(--hd-bad) 45%, transparent); background: color-mix(in srgb, var(--hd-bad) 12%, transparent); }++.hd-badge--accent { color: var(--accent-light, var(--hd-accent)); border-color: color-mix(in srgb, var(--hd-accent) 50%, transparent); background: color-mix(in srgb, var(--hd-accent) 14%, transparent); }++++/* Cards */++.hd-card { min-width: 0; margin: 0 0 20px; padding: 20px 22px; border: 1px solid var(--hd-line); border-radius: 16px; background: var(--hd-card); }++.hd-card__head { display: flex; flex-wrap: wrap; gap: 12px 16px; align-items: flex-start; justify-content: space-between; margin: 0 0 14px; }++.hd-card__head h2 { margin: 0; font-size: 18px; line-height: 1.3; }++.hd-card__head p { margin: 4px 0 0; max-width: 720px; }++.hd-card__actions { display: flex; gap: 8px; flex-wrap: wrap; }++.hd-card--danger { border-color: color-mix(in srgb, var(--hd-bad) 40%, var(--hd-line)); }++.hd-card--danger h2 { color: var(--hd-bad); }++.hd-split { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; }++++/* Buttons */++.hd-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; padding: 8px 16px; border-radius: 10px; border: 1px solid var(--hd-line-strong); background: var(--bg-tertiary, #16223a); color: var(--hd-text); font: inherit; font-size: 14px; font-weight: 700; text-decoration: none; cursor: pointer; }++.hd-btn:hover, .hd-btn:focus-visible { border-color: var(--hd-accent); background: var(--bg-hover, #1c2a44); outline: 0; }++.hd-btn--primary { background: var(--accent-strong, #2563eb); border-color: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }++.hd-btn--primary:hover, .hd-btn--primary:focus-visible { background: var(--accent-dark, #1d4ed8); border-color: var(--accent-light, #60a5fa); }++.hd-btn--danger { background: transparent; border-color: color-mix(in srgb, var(--hd-bad) 60%, transparent); color: var(--hd-bad); }++.hd-btn--danger:hover, .hd-btn--danger:focus-visible { background: color-mix(in srgb, var(--hd-bad) 14%, transparent); border-color: var(--hd-bad); }++.hd-btn--small { min-height: 32px; padding: 5px 12px; font-size: 13px; }++.hd-link { padding: 4px 6px; border: 0; background: none; color: var(--hd-dim); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }++.hd-link--danger { color: var(--hd-bad); }++.hd-link:hover, .hd-link:focus-visible { color: var(--hd-text); outline: 0; }++.hd-link--danger:hover, .hd-link--danger:focus-visible { color: var(--hd-bad); text-decoration-thickness: 2px; }++++/* Forms */++.hd-form { display: grid; gap: 14px; margin: 0; }++.hd-form > .hd-btn, .hd-form > .hd-confirm { justify-self: start; }++.hd-form--row { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: flex-end; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--hd-line); }++.hd-form--row .hd-field { flex: 1 1 220px; }++.hd-inline { display: inline-flex; margin: 0; }++.hd-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }++.hd-field { display: grid; gap: 6px; font-size: 14px; font-weight: 600; color: var(--hd-text); }++.hd-field .hd-muted { font-weight: 400; font-size: 13px; }++.hd-field--narrow { max-width: 360px; }++.hosting-dashboard input:not([type=checkbox]):not([type=radio]):not([type=file]),++.hosting-dashboard select,++.hosting-dashboard textarea { width: 100%; box-sizing: border-box; min-height: 40px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--hd-line-strong); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--hd-text); font: inherit; font-size: 14px; font-weight: 400; }++.hosting-dashboard textarea { min-height: 96px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; resize: vertical; }++.hosting-dashboard input:focus-visible, .hosting-dashboard select:focus-visible, .hosting-dashboard textarea:focus-visible { outline: 2px solid var(--hd-accent); outline-offset: 1px; border-color: var(--hd-accent); }++.hosting-dashboard input::placeholder, .hosting-dashboard textarea::placeholder { color: var(--hd-faint); }++.hd-confirm { display: flex; gap: 10px; align-items: flex-start; font-size: 14px; color: var(--hd-dim); }++.hd-confirm input, .hd-choice input { accent-color: var(--hd-accent); width: 16px; height: 16px; margin: 2px 0 0; flex: none; }++++/* Upload: two source tiles, then the three outcomes as choices */++.hd-sources { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }++.hd-source { margin: 0; padding: 14px 16px 16px; border: 1px dashed var(--hd-line-strong); border-radius: 12px; min-width: 0; }++.hd-source legend { padding: 0 6px; font-size: 14px; font-weight: 700; display: inline-flex; gap: 8px; align-items: center; }++.hd-source input[type=file] { width: 100%; color: var(--hd-dim); font: inherit; font-size: 13px; }++.hd-source input[type=file]::file-selector-button { margin-right: 12px; padding: 7px 14px; border-radius: 9px; border: 1px solid var(--hd-line-strong); background: var(--bg-tertiary, #16223a); color: var(--hd-text); font: inherit; font-weight: 700; cursor: pointer; }++.hd-source input[type=file]::file-selector-button:hover { border-color: var(--hd-accent); }++.hd-choices { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin: 0; padding: 0; border: 0; min-width: 0; }++.hd-choices legend { padding: 0 0 8px; font-size: 14px; font-weight: 600; }++.hd-choice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border: 1px solid var(--hd-line-strong); border-radius: 12px; cursor: pointer; }++.hd-choice > span { display: grid; gap: 2px; font-size: 14px; }++.hd-choice:has(input:checked) { border-color: var(--hd-accent); background: color-mix(in srgb, var(--hd-accent) 10%, transparent); }++.hd-choice:focus-within { outline: 2px solid var(--hd-accent); outline-offset: 1px; }++++/* Tables */++.hd-table-wrap { overflow-x: auto; margin: 0 -4px; }++.hd-table { width: 100%; border-collapse: collapse; font-size: 14px; }++.hd-table th { padding: 8px 10px; text-align: left; font-size: 12px; font-weight: 700; letter-spacing: .3px; text-transform: uppercase; color: var(--hd-faint); border-bottom: 1px solid var(--hd-line); white-space: nowrap; }++.hd-table td { padding: 10px; border-bottom: 1px solid var(--hd-line); vertical-align: middle; }++.hd-table tbody tr:last-child td { border-bottom: 0; }++.hd-table tbody tr:hover { background: color-mix(in srgb, var(--hd-text) 3%, transparent); }++.hd-table tr.hd-active td { background: color-mix(in srgb, var(--hd-accent) 8%, transparent); }++.hd-table tr.hd-active td:first-child { box-shadow: inset 3px 0 0 var(--hd-accent); }++.hd-actions { text-align: right; white-space: nowrap; }++.hd-actions > * + * { margin-left: 6px; }++.hd-table--dns td { vertical-align: top; }++++/* Rows (sites), tiles (projects), meters (quotas) */++.hd-rows { list-style: none; margin: 0; padding: 0; }++.hd-row { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--hd-line); }++.hd-row:last-child { border-bottom: 0; }++.hd-row__main { display: grid; gap: 2px; min-width: 0; }++.hd-row__title { font-weight: 700; font-size: 16px; color: var(--hd-text) !important; text-decoration: none; }++.hd-row__title:hover { text-decoration: underline; }++.hd-row__side { display: inline-flex; gap: 8px; align-items: center; color: var(--hd-faint); }++.hd-url { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; color: var(--hd-dim) !important; text-decoration: none; overflow-wrap: anywhere; }++.hd-url i { font-size: 11px; opacity: .7; }++.hd-url:hover { color: var(--hd-text) !important; text-decoration: underline; }++.hd-url--big { font-size: 15px; font-weight: 600; color: var(--accent-light, var(--hd-accent)) !important; }++.hd-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; margin: 0 0 20px; }++.hd-tile { display: flex; gap: 14px; align-items: flex-start; padding: 18px; border: 1px solid var(--hd-line); border-radius: 16px; background: var(--hd-card); color: var(--hd-text) !important; text-decoration: none; transition: border-color .15s; }++.hd-tile:hover, .hd-tile:focus-visible { border-color: var(--hd-accent); outline: 0; }++.hd-tile__icon { display: grid; place-items: center; flex: none; width: 40px; height: 40px; border-radius: 12px; background: color-mix(in srgb, var(--hd-accent) 16%, transparent); color: var(--accent-light, var(--hd-accent)); }++.hd-tile__body { display: grid; gap: 6px; min-width: 0; flex: 1; }++.hd-tile__body strong { font-size: 16px; overflow-wrap: anywhere; }++.hd-tile__meta { display: flex; gap: 6px; flex-wrap: wrap; }++.hd-tile__go { color: var(--hd-faint); align-self: center; }++.hd-meters { display: grid; gap: 14px; }++.hd-meter__label { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--hd-dim); margin-bottom: 6px; }++.hd-meter__label strong { color: var(--hd-text); }++.hd-meter__bar { height: 6px; border-radius: 99px; background: color-mix(in srgb, var(--hd-text) 9%, transparent); overflow: hidden; }++.hd-meter__bar span { display: block; height: 100%; min-width: 2px; border-radius: inherit; background: var(--hd-ok); }++.hd-meter__bar--warn span { background: var(--hd-warn); }++.hd-meter__bar--bad span { background: var(--hd-bad); }++.hd-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--hd-line); }++.hd-facts div { display: grid; gap: 2px; }++.hd-facts dt { font-size: 12px; color: var(--hd-faint); text-transform: uppercase; letter-spacing: .3px; font-weight: 700; }++.hd-facts dd { margin: 0; font-size: 14px; overflow-wrap: anywhere; }++.hd-facts--wide { margin-top: 18px; border: 1px solid var(--hd-line); border-radius: 14px; padding: 14px 16px; background: var(--hd-card); }++++/* Domains, log, history, notices */++.hd-domains { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }++.hd-domain { min-width: 0; padding: 14px 16px; border: 1px solid var(--hd-line); border-radius: 12px; }++.hd-domain__head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }++.hd-domain__head strong { overflow-wrap: anywhere; }++.hd-domain p { margin: 6px 0 0; }++.hd-domain__actions { display: flex; gap: 8px; align-items: center; margin-top: 10px; }++.hd-details { margin-top: 10px; }++.hd-details summary { cursor: pointer; font-size: 14px; font-weight: 600; color: var(--hd-dim); }++.hd-details summary:hover { color: var(--hd-text); }++.hd-details[open] summary { margin-bottom: 8px; }++.hd-history { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 14px; }++.hd-history li { display: flex; gap: 10px; flex-wrap: wrap; }++.hd-history time { color: var(--hd-faint); font-variant-numeric: tabular-nums; }++.hd-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }++.hd-log__line { display: flex; gap: 10px; align-items: baseline; padding: 6px 8px; border-radius: 8px; }++.hd-log__line > span:last-child { overflow-wrap: anywhere; }++.hd-log__line--error { background: color-mix(in srgb, var(--hd-bad) 9%, transparent); }++.hd-log__line--warn { background: color-mix(in srgb, var(--hd-warn) 8%, transparent); }++.hd-notice, .hd-callout { display: flex; gap: 10px; align-items: flex-start; margin: 0 0 14px; padding: 12px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--hd-accent) 40%, var(--hd-line)); background: color-mix(in srgb, var(--hd-accent) 8%, transparent); font-size: 14px; line-height: 1.5; }++.hd-callout p { margin: 0; }++.hd-callout i, .hd-notice i { margin-top: 3px; color: var(--accent-light, var(--hd-accent)); }++.hd-flash { margin: 0 0 18px; padding: 12px 16px; border-radius: 12px; font-size: 14px; border: 1px solid; }++.hd-flash--ok { border-color: color-mix(in srgb, var(--hd-ok) 45%, transparent); background: color-mix(in srgb, var(--hd-ok) 10%, transparent); }++.hd-flash--bad { border-color: color-mix(in srgb, var(--hd-bad) 50%, transparent); background: color-mix(in srgb, var(--hd-bad) 10%, transparent); }++.hd-empty { display: grid; justify-items: center; gap: 8px; margin: 0 0 20px; padding: 36px 16px; border: 1px dashed var(--hd-line-strong); border-radius: 16px; color: var(--hd-dim); text-align: center; }++.hd-empty i { font-size: 26px; color: var(--hd-faint); }++.hd-empty p { margin: 0; }++.hd-empty--small { padding: 20px 12px; margin: 0; }++.hd-error { max-width: 560px; margin: 40px auto; text-align: center; display: grid; justify-items: center; gap: 4px; }++.hd-error > i { font-size: 34px; color: var(--hd-faint); }++.hd-error h1 { margin: 10px 0 0; }++.hd-error p { color: var(--hd-dim); }++++@media (max-width: 900px) {++ .hd-split { grid-template-columns: minmax(0, 1fr); }++}++@media (max-width: 600px) {++ .hosting-dashboard .page { padding: 20px 16px 64px; }++ .hd-card { padding: 16px; border-radius: 14px; }++ .hd-actions { white-space: normal; }++ .hd-table th:nth-child(5), .hd-table td:nth-child(5) { display: none; }++ /* DNS records stack: the type, then name, value and note under it. */++ .hd-table--dns thead { display: none; }++ .hd-table--dns tr { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 10px; padding: 10px 4px; border-bottom: 1px solid var(--hd-line); }++ .hd-table--dns tr:last-child { border-bottom: 0; }++ .hd-table--dns td { padding: 0; border: 0; }++ .hd-table--dns td:first-child { grid-row: span 3; }++ .hd-table--dns td:nth-child(5) { display: block; }++ .hd-domain { padding: 12px; }++}@@@ -64,3 +64,8 @@ body {background: var(--bg-hover, #1c2a44);outline: 0;}++++/* The quotas note names its source file by URL: let it wrap on a phone. */++.sc-note a {++ overflow-wrap: anywhere;++}@@@ -1,13 +1,13 @@'use strict';/**-- * OpenVibe.Space — code and dynamic pages for the OpenVibe network.++ * OpenVibe.Space — static hosting for the OpenVibe network.** Express app factory (server/index.js listens; tests build their own instance).*-- * Space is being rebuilt and publishes one page of its own:++ * Space serves tenant sites, a hosting API and dashboard, plus a public page:*-- * GET / the home page: what Space will host, and where the forum went++ * GET / the home page: static hosting status and where the forum went* GET /auth/login|callback|fedcm|logout|me|refresh sign-in (OpenVibe.Network OAuth2 client)* GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml crawler endpoints (the sitemap is / alone)* GET /api/health, /api/ready, /release.json, /metrics (loopback)@@@ -16,8 +16,8 @@* a permanent redirect to the same path and query there — /s and /s/* (301 for GET and HEAD, 308* otherwise), and /api/v1/spaces*, /api/v1/posts/*, /api/v1/space-groups* and /api/v1/relay* (308).*-- * Space holds no user content, so it consumes no network events and has no account export or-- * deletion: the forum's account data is Community's. See README.md.++ * Space holds hosted content and emits hosting lifecycle events. The historical forum rows belong++ * to Community; the forum redirects below stay intact. See README.md.*/const path = require('path');const express = require('express');@@@ -64,10 +64,30 @@ async function createApp(opts = {}) {const release = require('openvibe-shared/release').createRelease({ service: 'space', root: path.join(__dirname, '..') });require('./render/layout').setRelease(release.release);// HTTP golden signals by route template, process metrics, release_info; GET /metrics answers-- // direct loopback callers only (Track O). Request metrics only: no content counts.-- const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'space', release: release.release });++ // direct loopback callers only (Track O), and only on the dashboard host (mounted after the host dispatch).++ // Request metrics only: no content counts. A tenant request is counted as one route, never by its path.++ const sharedMetrics = require('openvibe-shared/metrics');++ 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: 'space', release: release.release });++ app.use(httpMetrics.middleware);++ require('openvibe-shared/express-async').routeAsyncErrors(app);++ const metrics = { registry, http: httpMetrics, stop: proc.stop };app.locals.metrics = metrics.registry;++ // Host dispatch precedes everything else, including the platform's headers: a tenant site is answered by++ // http/tenant.js with its own headers and never reaches /metrics, auth, the API, readiness or the forum redirects.++ const auth = opts.auth || createAuthClient(config);++ const db = opts.db || getDb();++ app.locals.auth = auth;++ app.locals.config = config;++ app.locals.db = db;++ const hosting = require('./hosting').createHosting(app, { db, auth, config: opts.hostingConfig, now: opts.now, resolver: opts.resolver, fetchImpl: opts.fetchImpl, indexnow: opts.indexnow, log: opts.log, storageDir: opts.storageDir, forumRedirect });++ app.locals.hosting = hosting;++ hosting.mountDispatch(app);++ app.get('/metrics', sharedMetrics.metricsHandler(registry));++app.use(helmet({contentSecurityPolicy: {directives: {@@@ -96,21 +116,13 @@ async function createApp(opts = {}) {}));app.use(cookieParser());-- // ── The forum is Community's: every forum URL redirects there ───-- app.use(forumRedirect);++ hosting.mountRoutes(app);// ── Auth (OAuth2 client of OpenVibe.Network) ─────────────-- const auth = opts.auth || createAuthClient(config);-- app.locals.auth = auth;-- app.locals.config = config;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: 'space', service: 'space', host: 'openvibe.space', name: 'OpenVibe.Space', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }-- // ── Space's database (for readiness and the shared schema) ───-- const db = opts.db || getDb();-- app.locals.db = db;--// /updates: Space ships with the community's log until it has one of its own.app.get('/updates', (_req, res) => res.redirect(302, `${config.communityUrl}/updates`));@@@ -120,7 +132,7 @@ async function createApp(opts = {}) {release.mount(app, { registry: metrics.registry });// Readiness reports what is actually served: 503 only without the database; a Network key that has// not loaded degrades (server/observability.js).-- const readiness = require('./observability').createSpaceReadiness({ db, auth, config, release: release.release });++ const readiness = require('./observability').createSpaceReadiness({ db, auth, config, release: release.release, hosting });app.get('/api/ready', readiness.handler);// ── Static assets (content-hashed ?v= → immutable) ───────@@@ -150,7 +162,7 @@ async function createApp(opts = {}) {});// ── Pages ────────────────────────────────────────────────-- // The home page: what Space will host, and where the forum went (no fake features).++ // The public home page: what works now and what comes next.app.get('/', (_req, res) => res.type('html').set('Cache-Control', PAGE_CACHE).send(require('./render/pages').homePage()));// ── 404 / errors ─────────────────────────────────────────nnew file mode 100644@@@ -0,0 +1,164 @@++'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; it runs on the++ * libuv threadpool (async zlib), so a large upload does not block the event loop.++ */++const zlib = require('zlib');++const { promisify } = require('util');++++const gunzip = promisify(zlib.gunzip);++++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 }++ */++async 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 = await gunzip(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 }); // a view into the tar buffer: no second copy++ }++ 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,106 @@++'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._@+~()!,= -]+$/;++const CA_VALIDATION = new Set(['acme-challenge', 'pki-validation']);++++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`);++ });++ // Certificate authorities prove control of a host name with files under these paths (ACME++ // HTTP-01, and the file-based validation other CAs accept over HTTPS). A tenant must never be++ // able to obtain a certificate for <site>.openvibe.space: the operator issues every certificate.++ if (segments[0] === '.well-known' && CA_VALIDATION.has(String(segments[1]).toLowerCase())) {++ throw new PathError('path.reserved', `"${segments.slice(0, 2).join('/')}/" is reserved: certificate validation files are never published from a site`);++ }++ return input;++}++++// 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: Space 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: 'space.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: 'space.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,32 @@++'use strict';++++/**++ * Capability checks for service tokens (audience openvibe.space), by openvibe-contracts' manifests:++ *++ * space.site.manage projects, members, sites, quotas (read), deleting deploys++ * space.deploy.create upload deploys, list them and their logs, activate, roll back++ * space.domain.manage add, verify and remove custom domains++ * space.site.config read, set and reset a site's response headers, redirects and SPA fallback++ * space.resource.read read the resource index (GET /api/v1/resources; ADR-048 section 3, plan T13 step 8)++ *++ * 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: 'space.site.manage',++ DEPLOY_CREATE: 'space.deploy.create',++ DOMAIN_MANAGE: 'space.domain.manage',++ SITE_CONFIG: 'space.site.config',++ RESOURCE_READ: 'space.resource.read',++});++++/** → { allowed, code, reason } (capabilities.check(); space.* since openvibe-contracts 0.149.0). */++function checkCapability(claims, capabilityId) {++ return capabilities.check(claims, capabilityId);++}++++module.exports = { CAPABILITIES, 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.space++ * are the same site as openvibe.space, 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 SPACE_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(`space-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,134 @@++'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: only Host's own __Host-ov_space_session cookie (./sso.js). ov_token (openvibe-sdk/sso)++ * is display-only for the shared navbar: a tenant page on <site>.openvibe.space can plant one.++ * staff = the contracts staff map's staff.site.configure (ADR-022):++ * quotas, takedowns, and maintaining or deleting any project (never deploying to it).++ * { kind: 'service', service: 'svc:codes' | 'app:app_…', claims, subject, env }++ * A Network client-credentials token for audience openvibe.space. 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 { claimsToUser, decodeJwtPayload } = require('openvibe-sdk/sso');++const { verifyServiceToken } = require('openvibe-sdk/auth');++const { checkCapability } = require('./capabilities');++const sessionToken = (_config, req) => req.cookies && req.cookies.ov_token;++++const { ids, http, staff: staffMap } = contracts;++const PRINCIPAL_SUB = /^(svc|app|mod):/;++const AUDIENCE = 'openvibe.space';++++/** API callers: only the Authorization header (cookies are never read on /api/v1). */++const bearerToken = (req) => {++ const h = String(req.headers.authorization || '');++ return h.startsWith('Bearer ') ? h.slice(7).trim() || null : null;++};++++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, log = console }) {++ async function fromServiceToken(req, token) {++ // The SDK supplies the key (the shared JWKS client, through a Network outage); every token rule is the++ // service's own pinned openvibe-contracts serviceAuth.verifyServiceToken.++ // acceptSandbox: Host takes sandbox tokens itself and confines them to sandbox projects++ // (projects.js/access.js answer 403 environment.sandbox_token elsewhere); openvibe-contracts++ // >= 0.26.0 refuses them before that unless the receiver opts in.++ const r = await verifyServiceToken(token, {++ jwks: `${config.networkInternalUrl}/api/.well-known/jwks`,++ issuer: config.networkUrl,++ audience: AUDIENCE,++ contracts,++ acceptSandbox: true,++ log,++ });++ if (!r.ok) {++ if (r.code === 'token.unavailable') throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');++ 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;++ // Network signs FedCM ID assertions (typ "fedcm", aud = any OpenVibe RP origin, including++ // tenant subdomains of openvibe.space) with the same key and issuer as access tokens. An++ // assertion is only ever swapped at /oauth/token, never presented as a session. Access++ // tokens carry no typ.++ if (claims.typ !== undefined || claims.nonce !== undefined) return null;++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ if (!subject) return null;++ return { kind: 'user', subject, staff: staffMap.can(claims, 'staff.site.configure'), 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 await 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(config, req);++ 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, AUDIENCE };@@@ -42,8 +42,8 @@ module.exports = {// OpenVibe.Live — the legal pages the shared footer links (the Network hosts the profile page).liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),-- // PostgreSQL (ADR-035). Space holds no user content yet; the database is kept for the release-- // ledger and the shared schema migrations. Without DATABASE_URL, development uses an embedded++ // PostgreSQL (ADR-035). Space stores hosted sites and deploy records here, along with the++ // historical schema and release ledger. Without DATABASE_URL, development uses an embedded// PGlite database in data/pglite.db: { url: process.env.DATABASE_URL || '', directUrl: process.env.DATABASE_DIRECT_URL || '' },};@@@ -3,11 +3,11 @@/*** Space's own database: PostgreSQL (ADR-035). The schema is migrations/NNNN_*.sql, applied at boot.*-- * Space holds no user content while it is rebuilt. The migrations still create the forum tables++ * Space owns the static hosting tables in migrations 0002-0005. Migration 0001 still creates the forum tables* (spaces, threads, posts, votes, attachments, the relay's bookkeeping, the event inbox and the* account-data records) that Space served when the forum lived here; they stay in the database —* this release writes no drop migration — and their rows are OpenVibe.Community's now, where the-- * forum is. The database itself stays for the shared schema and the release ledger.++ * forum is. The same database serves the hosting engine and the release ledger.** Timestamps are text ('YYYY-MM-DD HH:MM:SS', UTC): the schema defines ov_now(), datetime() and julianday()* with those semantics, and ov_hot() for the forum's hot rank.@@@ -60,4 +60,8 @@ async function closeDb() {if (db) await db.close().catch(() => {});}--module.exports = { openDb, initDb, getDb, setDb, closeDb, MIGRATIONS };++function createStore(db, { now = () => Date.now() } = {}) {++ return { db, now, tx: async (fn) => db.tx(() => fn()), close: () => db.close() };++}++++module.exports = { openDb, initDb, getDb, setDb, closeDb, createStore, MIGRATIONS };@@@ -5,23 +5,22 @@* /llms-full.txt sections and the sitemap rows. Only data lives here; openvibe-shared/seo writes* every format (server/app.js).*-- * Space publishes one page of its own — the home page — while it is rebuilt to host code and-- * dynamic pages and "spaces", apps people build on their OpenVibe account, the network's services-- * and the SDK. The forum is OpenVibe.Community's (owner decision, 2026-10-08), so nothing forum++ * Space serves static hosting for staff; dynamic pages and spaces are planned. The forum is++ * OpenVibe.Community's (owner decision, 2026-10-08), so nothing forum* shaped appears here: no /s entries, no feeds.*/const config = require('./config');const { abs } = require('./render/layout');--const ROBOTS_DISALLOW = ['/api/', '/auth/', '/*?sso='];--const SUMMARY = 'OpenVibe.Space will host code and dynamic pages, and "spaces": apps people build that use their OpenVibe account, the network\'s services and the SDK. The forum is on OpenVibe.Community.';++const ROBOTS_DISALLOW = ['/api/', '/auth/', '/dashboard', '/projects/', '/sites/', '/deploys/', '/domains/', '/preview/', '/*?sso='];++const SUMMARY = 'OpenVibe.Space serves tested static hosting for OpenVibe staff: immutable deploys, rollback, previews, Git ingest and verified custom domains. Dynamic pages and spaces are planned. The forum moved to OpenVibe.Community.';// ── llms.txt / llms-full.txt (llmstxt.org) ───────────────────function llmsSections() {const u = config.baseUrl;return [{ title: 'This site', links: [-- { title: 'Home', url: `${u}/`, note: 'what Space will host (nothing is published here yet)' },++ { title: 'Home', url: `${u}/`, note: 'static hosting for staff; dynamic pages and spaces planned' },] },{ title: 'The network', links: [{ title: 'The forum', url: `${config.communityUrl}/s`, note: 'OpenVibe.Community hosts the forum' },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 (space_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: 'space' };++}++++function createAccess({ store }) {++ const { db } = store;++ const memberStmt = db.prepare('SELECT role FROM space_project_members WHERE project_id = ? AND principal = ?');++++ async function roleOf(project, viewer) {++ const p = principalOf(viewer);++ if (!p) return null;++ const row = await 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.++ */++ async 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 = await 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,372 @@++'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 +++ * space.deploy.created (+ activation when asked). A refused upload is recorded as a++ * failed deploy with its log and space.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 space.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.++ * preview a deploy uploaded as a preview (source 'preview'): it becomes the site's ONE live++ * preview (space_sites.preview_deploy_id + preview_expires_at) instead of going active,++ * and is served only by the dashboard, to a member, at /preview/<deploy-id>/… (see++ * server/http/tenant.js). Any pointer switch clears it (so a deploy or rollback makes it++ * vanish), it expires on its own, and it is never announced to search engines.++ * git ingestGit: the output a project's OWN CI built from the site's connected repository++ * (domain/site-sources.js), posted with the ref and the commit it built. The ref must be++ * the connected one and the commit a full SHA; the files go through create() like any++ * upload, are stored with source 'git' plus an immutable space_deploy_git row in the same++ * transaction, and always land as the site's preview: approving it is the ordinary++ * activate. A git deploy never moves active_deploy_id by itself.++ *++ * Host never builds: it serves the uploaded files (or the CI's build output) as they are, never++ * clones or fetches a repository, holds no credential for one, and never executes anything.++ */++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;++// How long a preview stays served. Bounded so a draft cannot linger on the dashboard forever; the++// uploader can always upload another one.++const PREVIEW_TTL_MS = 60 * 60 * 1000;++// A full commit id: SHA-1 (40) or SHA-256 (64) object names, lowercase hex.++const COMMIT_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;++const GIT_COLS = 'g.provider AS git_provider, g.repo_url AS git_repo_url, g.ref AS git_ref, g.commit_sha AS git_commit_sha';++const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);++++function createDeploys({ store, config = null, access, projects, sites, blobs, outbox, takedowns, log = console, indexnow = null }) {++ const { db } = store;++ // IndexNow: a site's public page appeared, changed or left the index (never a sandbox site, whose++ // responses carry X-Robots-Tag noindex). `indexnow(hostname, paths)` queues one debounced batch.++ const announce = typeof indexnow === 'function' ? indexnow : () => {};++ const announceSite = (site, project, paths = ['/', '/sitemap.xml']) => {++ if (!project || project.environment !== 'sandbox') announce(sites.defaultHostname(site.name), paths);++ };++ const q = {++ // A git deploy's provenance comes along as git_* columns (NULL for any other deploy).++ byId: db.prepare(`SELECT d.*, ${GIT_COLS} FROM space_deploys d LEFT JOIN space_deploy_git g ON g.deploy_id = d.id WHERE d.id = ?`),++ forSite: db.prepare(`SELECT d.*, ${GIT_COLS} FROM space_deploys d LEFT JOIN space_deploy_git g ON g.deploy_id = d.id++ WHERE d.site_id = ? AND d.state <> 'deleted' ORDER BY d.created_at DESC, d.seq DESC LIMIT ?`),++ insert: db.prepare(`INSERT INTO space_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 space_deploy_files (deploy_id, path, sha256, size, content_type) VALUES (?, ?, ?, ?, ?)'),++ insertBlob: db.prepare('INSERT INTO space_blobs (project_id, sha256, size, created_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING'),++ hasBlob: db.prepare('SELECT 1 FROM space_blobs WHERE project_id = ? AND sha256 = ?'),++ storageUsed: db.prepare('SELECT COALESCE(SUM(size), 0)::bigint AS n FROM space_blobs WHERE project_id = ?'),++ deploysSince: db.prepare('SELECT COUNT(*) AS n FROM space_deploys WHERE project_id = ? AND created_at > ?'),++ logLine: db.prepare('INSERT INTO space_deploy_logs (deploy_id, level, message, created_at) VALUES (?, ?, ?, ?)'),++ logFor: db.prepare('SELECT level, message, created_at FROM space_deploy_logs WHERE deploy_id = ? ORDER BY id'),++ files: db.prepare('SELECT path, sha256, size, content_type FROM space_deploy_files WHERE deploy_id = ? ORDER BY path'),++ setPointer: db.prepare('UPDATE space_sites SET active_deploy_id = ?, updated_at = ? WHERE id = ? AND status = \'active\' AND active_deploy_id IS NOT DISTINCT FROM ?'),++ setPreview: db.prepare('UPDATE space_sites SET preview_deploy_id = ?, preview_expires_at = ? WHERE id = ?'),++ clearPreview: db.prepare('UPDATE space_sites SET preview_deploy_id = NULL, preview_expires_at = NULL WHERE id = ?'),++ clearPreviewIf: db.prepare('UPDATE space_sites SET preview_deploy_id = NULL, preview_expires_at = NULL WHERE id = ? AND preview_deploy_id = ?'),++ activation: db.prepare('INSERT INTO space_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 space_activations WHERE site_id = ? ORDER BY id DESC LIMIT ?'),++ markDeleted: db.prepare("UPDATE space_deploys SET state = 'deleted', deleted_at = ? WHERE id = ?"),++ dropFiles: db.prepare('DELETE FROM space_deploy_files WHERE deploy_id = ?'),++ orphans: db.prepare(`SELECT sha256 FROM space_blobs b WHERE b.project_id = ? AND NOT EXISTS (++ SELECT 1 FROM space_deploy_files f JOIN space_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 space_blobs WHERE project_id = ? AND sha256 = ?'),++ sourceOf: db.prepare('SELECT provider, repo_url, ref FROM space_site_sources WHERE site_id = ?'),++ insertGit: db.prepare('INSERT INTO space_deploy_git (deploy_id, provider, repo_url, ref, commit_sha, created_at) VALUES (?, ?, ?, ?, ?, ?)'),++ };++++ async function get(id) {++ return isId('deploy', id) ? await q.byId.get(id) || null : null;++ }++++ /** The deploy, its site and project, authorized at `need`; 404 for anyone without access. */++ async function load(viewer, id, need) {++ const deploy = await get(id);++ const notFound = new ApiError(404, 'deploy.not_found', 'no such deploy');++ if (!deploy || deploy.state === 'deleted') throw notFound;++ const site = await sites.get(deploy.site_id);++ if (!site || site.status !== 'active') throw notFound;++ const project = await projects.get(deploy.project_id);++ await 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). */++ async function limitsFor(project) {++ const quota = await 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? */++ async function precheck(viewer, siteId) {++ const { site, project } = await sites.load(viewer, siteId, 'deploy');++ await takedowns.assertOpen(site);++ const minFree = config ? config.uploads.minFreeBytes : 0;++ if (minFree > 0) {++ let free = Infinity;++ try { free = blobs.freeBytes(); } catch (err) { log.warn('[Space] could not read free disk space:', err.message); }++ if (free < minFree) {++ log.warn(`[Space] uploads refused: ${free} bytes free, SPACE_MIN_FREE_BYTES is ${minFree}`);++ throw new ApiError(507, 'storage.space_full', 'Space is short of disk space and is not accepting deploys right now; try again later');++ }++ }++ const limits = await limitsFor(project);++ const recent = (await 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 };++ }++++ async function writeLog(deployId, lines, now) {++ for (const l of lines) await q.logLine.run(deployId, l.level, String(l.message).slice(0, 2000), now);++ }++++ /** A refused upload becomes a failed deploy (with its log) and space.deploy.failed. */++ async 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` },++ ];++ await store.tx(async () => {++ await 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 });++ await writeLog(id, lines, now);++ await outbox.emit({++ event_type: 'space.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: await get(id), log: await 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.++ */++ async function create(viewer, ctx, entries, { source, activate = false, preview = false, notes = [], traceparent, git = null } = {}) {++ const { site, project, limits } = ctx;++ // A preview is never activated: it is the site's live preview (its pointer), not its active++ // deploy, and it is stored with source 'preview' so the two can never be confused. A git++ // deploy is always a preview and keeps source 'git'; its provenance row says what it is.++ const isGit = source === 'git';++ if (isGit && (!git || preview !== true)) throw new Error('a git deploy is created by ingestGit, as a preview');++ const isPreview = preview === true;++ const storedSource = isPreview && !isGit ? 'preview' : source;++ if (isPreview) activate = false;++ const fail = async (status, code, problems, extraNotes = []) => {++ const r = await recordFailure(viewer, ctx, { source: storedSource, code, problems, notes: [...notes, ...extraNotes], 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) await 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 (!await 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 = (await q.storageUsed.get(project.id)).n;++ if (used + newBytes > limits.quota.storageBytes) {++ await 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. With the++ // Media store this is where a deploy waits for Media to acknowledge every object: a refusal++ // fails the deploy, recorded with its log, instead of publishing a local-only success.++ const stored = [];++ let current = null;++ try {++ for (const f of fresh.values()) {++ current = f.sha256;++ await blobs.put(project.id, f.sha256, f.data);++ stored.push(f.sha256);++ }++ } catch (err) {++ // Name every object this failed deploy left behind so an orphan is traceable: the ones++ // Media already acknowledged, plus the one being written when it refused.++ const left = [...stored, ...(current && !stored.includes(current) ? [current] : [])];++ log.error('[Space] object store refused a deploy:', project.id, err.code || err.message,++ `objects left by the failed deploy: ${left.length ? left.join(' ') : 'none'}`);++ await fail(502, 'storage.object_store', [{ code: 'storage.object_store', message: 'the object store could not store this deploy; nothing was published' }],++ [`object store: objects left by the failed deploy, which no deploy row references (a sweep or the next deploy reclaims them): ${left.join(' ') || 'none'}`]);++ }++++ 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: isGit ? 'built outside Space by the project\'s CI; Space stored the files as they are and executed nothing' : 'no build step: Space serves the uploaded files as they are; nothing was executed' },++ ];++ let activated = null;++ const previewExpiresAt = isPreview ? now + PREVIEW_TTL_MS : null;++ await store.tx(async () => {++ await q.insert.run({ id, project_id: project.id, site_id: site.id, state: 'ready', source: storedSource, manifest: built.manifestJson, manifest_sha256: built.manifestSha256, file_count: built.manifest.file_count, total_bytes: built.manifest.total_bytes, new_bytes: newBytes, failure_code: null, created_by: who, created_at: now });++ for (const f of built.files) await q.insertFile.run(id, f.path, f.sha256, f.size, f.content_type);++ for (const f of fresh.values()) await q.insertBlob.run(project.id, f.sha256, f.size, now);++ if (isGit) await q.insertGit.run(id, git.provider, git.repo_url, git.ref, git.commit_sha, now);++ await outbox.emit({++ event_type: 'space.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: storedSource, file_count: built.manifest.file_count, total_bytes: built.manifest.total_bytes, manifest_sha256: built.manifestSha256 },++ }, { traceparent });++ if (isPreview) {++ await q.setPreview.run(id, previewExpiresAt, site.id);++ lines.push({ level: 'info', message: `preview: served only at /preview/${id}/ to a project member until it expires; the public site still serves its active deploy` });++ } else if (activate) {++ activated = await switchPointer(viewer, site, await get(id), 'activate', { traceparent });++ lines.push({ level: 'info', message: 'activated: the site now serves this deploy' });++ } else {++ lines.push({ level: 'info', message: 'ready; not active until you activate it' });++ }++ await writeLog(id, lines, now);++ });++ outbox.kick();++ if (activated && activated.changed) announceSite(site, project);++ return { deploy: await get(id), log: await q.logFor.all(id), activated, preview: isPreview ? { deploy_id: id, expires_at: previewExpiresAt } : null };++ }++++ /**++ * May the site take a git deploy of `ref` at `commit_sha`? -> the provenance to record.++ * 409 source.not_connected, 409 source.ref_mismatch, 422 source.commit_sha / source.ref. Either++ * value may be left out (undefined) to check only the connection, before the body is read.++ */++ async function gitTarget(site, { ref, commit_sha: sha } = {}) {++ const src = await q.sourceOf.get(site.id);++ if (!src) throw new ApiError(409, 'source.not_connected', 'this site has no Git source: connect a repository and branch first (PUT /api/v1/sites/:id/source)');++ if (ref !== undefined) {++ if (typeof ref !== 'string' || !ref) throw new ApiError(422, 'source.ref', 'ref is required: the branch this commit was built from');++ if (ref !== src.ref) throw new ApiError(409, 'source.ref_mismatch', `this site deploys from ${src.ref}, not ${ref.slice(0, 200)}`);++ }++ if (sha !== undefined && (typeof sha !== 'string' || !COMMIT_SHA_RE.test(sha))) {++ throw new ApiError(422, 'source.commit_sha', 'commit_sha must be the full commit id: 40 or 64 lowercase hex characters');++ }++ return { provider: src.provider, repo_url: src.repo_url, ref: src.ref, commit_sha: sha };++ }++++ /**++ * A git deploy: the build output the project's CI posts for the connected ref at commit_sha.++ * ctx is precheck()'s result for siteId. It lands as the site's preview and never activates;++ * a refused file is a failed deploy (recordFailure, through create) and space.deploy.failed.++ */++ async function ingestGit(viewer, ctx, siteId, entries, { ref, commit_sha, notes = [], traceparent } = {}) {++ if (!ctx || !ctx.site || ctx.site.id !== siteId) throw new ApiError(404, 'site.not_found', 'no such site');++ const git = await gitTarget(ctx.site, { ref: ref == null ? '' : ref, commit_sha: commit_sha == null ? '' : commit_sha });++ const why = [...notes, `git: ${git.repo_url} ${git.ref} at ${git.commit_sha}`];++ return await create(viewer, ctx, entries, { source: 'git', preview: true, activate: false, notes: why, traceparent, git });++ }++++ /** Inside a transaction. Compare-and-set on the pointer read in the same transaction. */++ async function switchPointer(viewer, site, deploy, kind, { expectedActive, traceparent } = {}) {++ const current = await db.prepare('SELECT active_deploy_id, status FROM space_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 = await q.setPointer.run(deploy.id, now, site.id, current.active_deploy_id);++ if (r.changes !== 1) throw new ApiError(409, 'site.active_changed', 'the active deploy changed during the switch');++ await q.activation.run(site.id, deploy.id, current.active_deploy_id, kind, principalOf(viewer) || 'space', now);++ // A real deploy or rollback supersedes any pending preview: it stops being served at once.++ await q.clearPreview.run(site.id);++ await outbox.emit({++ event_type: 'space.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 };++ }++++ async function activate(viewer, id, { expectedActive, traceparent } = {}) {++ const { deploy, site, project } = await load(viewer, id, 'deploy');++ await takedowns.assertOpen(site);++ const out = await store.tx(async () => await switchPointer(viewer, site, deploy, 'activate', { expectedActive, traceparent }));++ outbox.kick();++ if (out.changed) announceSite(site, project);++ return out;++ }++++ /** To `deployId`, or else to the most recent previously active deploy that still exists. */++ async function rollback(viewer, siteId, { deployId, expectedActive, traceparent } = {}) {++ const { site, project } = await sites.load(viewer, siteId, 'deploy');++ await takedowns.assertOpen(site);++ const out = await store.tx(async () => {++ const current = (await db.prepare('SELECT active_deploy_id FROM space_sites WHERE id = ?').get(site.id)).active_deploy_id;++ let target = null;++ if (deployId) {++ target = await 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 await q.activations.all(site.id, 200)) {++ for (const cand of [a.previous_deploy_id, a.deploy_id]) {++ if (!cand || cand === current) continue;++ const d = await 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 await switchPointer(viewer, site, target, 'rollback', { expectedActive, traceparent });++ });++ outbox.kick();++ if (out.changed) announceSite(site, project);++ return out;++ }++++ /** Inside a transaction: drop blob rows no deploy of the project references. -> freed shas */++ async function collectGarbage(projectId) {++ const freed = (await q.orphans.all(projectId)).map((r) => r.sha256);++ for (const sha of freed) await q.dropBlob.run(projectId, sha);++ return freed;++ }++++ async function unlinkBlobs(projectId, shas) {++ for (const sha of shas) {++ try { await blobs.remove(projectId, sha); } catch (err) { log.warn('[Space] could not remove object:', err.message); }++ }++ }++++ async function remove(viewer, id) {++ const { deploy, site, project } = await load(viewer, id, 'maintain');++ await takedowns.assertDeletable(viewer, { site });++ let freed = [];++ await store.tx(async () => {++ const active = (await db.prepare('SELECT active_deploy_id FROM space_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');++ await q.markDeleted.run(store.now(), deploy.id);++ await q.dropFiles.run(deploy.id);++ // A deleted preview must stop being served even if it was the site's live one.++ await q.clearPreviewIf.run(site.id, deploy.id);++ freed = await collectGarbage(project.id);++ });++ await unlinkBlobs(project.id, freed);++ return { deleted: true, objects_removed: freed.length };++ }++++ return {++ get, load, precheck, create, ingestGit, gitTarget, recordFailure, activate, rollback, remove, collectGarbage, unlinkBlobs, limitsFor,++ list: async (siteId, limit = 100) => await q.forSite.all(siteId, Math.min(Math.max(limit, 1), 200)),++ logOf: async (id) => await q.logFor.all(id),++ filesOf: async (id) => await q.files.all(id),++ activationsOf: async (siteId, limit = 50) => await q.activations.all(siteId, limit),++ };++}++++module.exports = { createDeploys, PREVIEW_TTL_MS };nnew file mode 100644@@@ -0,0 +1,225 @@++'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-space.<hostname> TXT "openvibe-space-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++ * SPACE_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 SPACE_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-space';++const TXT_PREFIX = 'openvibe-space-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, indexnow = null }) {++ const { db } = store;++ // IndexNow: a verified custom domain starts serving the site; tell the engines its page is up.++ const announce = typeof indexnow === 'function' ? indexnow : () => {};++ const dnsResolver = resolver || defaultResolver(config.domains.dnsServers);++ const reserved = firstPartyDomains(config);++ const q = {++ byId: db.prepare('SELECT * FROM space_domains WHERE id = ?'),++ forSite: db.prepare("SELECT * FROM space_domains WHERE site_id = ? ORDER BY kind DESC, created_at, seq"),++ verifiedByHost: db.prepare("SELECT * FROM space_domains WHERE hostname = ? AND status = 'verified'"),++ onSite: db.prepare('SELECT * FROM space_domains WHERE site_id = ? AND hostname = ?'),++ insert: db.prepare(`INSERT INTO space_domains (id, project_id, site_id, hostname, kind, status, token, created_by, created_at)++ VALUES (?, ?, ?, ?, 'custom', 'pending', ?, ?, ?)`),++ checked: db.prepare('UPDATE space_domains SET last_checked_at = ?, last_error = ?, check_count = check_count + 1 WHERE id = ?'),++ verified: db.prepare("UPDATE space_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 space_domains SET status = ?, last_error = ? WHERE id = ?'),++ missing: db.prepare('UPDATE space_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 space_domains SET record_missing_since = NULL, last_checked_at = ?, last_error = NULL, check_count = check_count + 1 WHERE id = ?'),++ remove: db.prepare('DELETE FROM space_domains WHERE id = ?'),++ duePending: db.prepare("SELECT * FROM space_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 space_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}`;++++ async function load(viewer, id, need) {++ const domain = isId('domain', id) ? await q.byId.get(id) : null;++ const notFound = new ApiError(404, 'domain.not_found', 'no such domain');++ if (!domain) throw notFound;++ const site = await sites.get(domain.site_id);++ if (!site || site.status !== 'active') throw notFound;++ await access.authorize(await 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: Space re-checks it daily' },++ routing,++ tls: 'after verification an operator issues the certificate (Let\'s Encrypt); HTTPS starts working once it is installed',++ };++ }++++ async function add(viewer, siteId, input = {}) {++ const { site, project } = await 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 = await projects.quotaOf(project);++ if ((await projects.usageOf(project)).customDomains >= quota.customDomains) throw new ApiError(429, 'quota.custom_domains', `this project may have ${quota.customDomains} custom domains`);++ if (await q.onSite.get(site.id, hostname)) throw new ApiError(409, 'domain.exists', `${hostname} is already on this site`);++ if (await 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');++ await q.insert.run(id, project.id, site.id, hostname, token, principalOf(viewer), store.now());++ const domain = await 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) { await q.checked.run(store.now(), r.error, domain.id); return await q.byId.get(domain.id); }++ if (!r.values.includes(expected)) {++ await 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 await q.byId.get(domain.id);++ }++ try {++ await store.tx(async () => {++ if (await q.verifiedByHost.get(domain.hostname)) throw new ApiError(409, 'domain.taken', `${domain.hostname} was verified for another site first`);++ const site = await sites.get(domain.site_id);++ if (!site || site.status !== 'active') throw new ApiError(404, 'domain.not_found', 'no such domain');++ if ((await q.verified.run(store.now(), store.now(), domain.id)).changes !== 1) return;++ await outbox.emit({++ event_type: 'space.domain.verified', actor: viewer ? actorRef(viewer) : { type: 'service', id: 'space' }, 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') { await q.setStatus.run('failed', err.message, domain.id); return await q.byId.get(domain.id); }++ throw err;++ }++ outbox.kick();++ announce(domain.hostname, ['/', '/sitemap.xml']);++ return await q.byId.get(domain.id);++ }++++ async function verify(viewer, id, opts = {}) {++ const { domain } = await load(viewer, id, 'maintain');++ return await verifyDomain(domain, viewer, opts);++ }++++ async function remove(viewer, id) {++ const { domain } = await load(viewer, id, 'maintain');++ if (domain.kind === 'default') throw new ApiError(409, 'domain.default', 'the default domain goes away only with the site');++ await 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 await q.duePending.all(now - config.domains.recheckIntervalMs + 1000, limit)) {++ if (now - d.created_at > config.domains.pendingDays * DAY) {++ await 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 await q.dueVerified.all(now - DAY, limit)) {++ const r = await lookup(d.hostname);++ summary.checked++;++ if (!r.ok) { await q.checked.run(store.now(), r.error, d.id); continue; }++ if (r.values.includes(`${TXT_PREFIX}${d.token}`)) { await q.present.run(store.now(), d.id); continue; }++ await q.missing.run(store.now(), store.now(), `the TXT record at ${challengeName(d.hostname)} is gone`, d.id);++ const row = await q.byId.get(d.id);++ if (row.record_missing_since && store.now() - row.record_missing_since > config.domains.lapseDays * DAY) {++ await 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(`[Space] custom domain ${d.hostname} lapsed (TXT record gone)`);++ }++ }++ return summary;++ }++++ return {++ load, add, verify, verifyDomain, remove, recheck, instructions, checkHostname,++ listForSite: async (siteId) => await q.forSite.all(siteId),++ get: async (id) => (isId('domain', id) ? await q.byId.get(id) || null : null),++ verifiedCustom: async () => await db.prepare("SELECT hostname, site_id FROM space_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,174 @@++'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, takedowns, log = console, indexnow = null }) {++ const { db } = store;++ const announce = typeof indexnow === 'function' ? indexnow : () => {};++ const q = {++ byId: db.prepare('SELECT * FROM space_projects WHERE id = ?'),++ forPrincipal: db.prepare(`SELECT p.*, m.role FROM space_projects p JOIN space_project_members m ON m.project_id = p.id++ WHERE m.principal = ? AND p.status = 'active' ORDER BY p.created_at DESC, p.seq DESC LIMIT 200`),++ all: db.prepare("SELECT p.*, NULL AS role FROM space_projects p WHERE p.status = 'active' ORDER BY p.created_at DESC, p.seq DESC LIMIT 200"),++ ownedCount: db.prepare("SELECT COUNT(*) AS n FROM space_projects WHERE owner_subject = ? AND status = 'active'"),++ insert: db.prepare(`INSERT INTO space_projects (id, network_project_id, owner_subject, name, environment, status, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`),++ addMember: db.prepare(`INSERT INTO space_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 space_project_members WHERE project_id = ? AND principal = ?'),++ members: db.prepare('SELECT principal, role, added_by, created_at FROM space_project_members WHERE project_id = ? ORDER BY created_at'),++ quotaRow: db.prepare('SELECT * FROM space_quotas WHERE project_id = ?'),++ storageUsed: db.prepare('SELECT COALESCE(SUM(size), 0)::bigint AS n, COUNT(*) AS c FROM space_blobs WHERE project_id = ?'),++ deploysSince: db.prepare('SELECT COUNT(*) AS n FROM space_deploys WHERE project_id = ? AND created_at > ?'),++ siteCount: db.prepare("SELECT COUNT(*) AS n FROM space_sites WHERE project_id = ? AND status = 'active'"),++ customDomainCount: db.prepare("SELECT COUNT(*) AS n FROM space_domains WHERE project_id = ? AND kind = 'custom' AND status IN ('pending','verified')"),++ markDeleted: db.prepare("UPDATE space_projects SET status = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?"),++ };++++ async function get(id) {++ return isId('project', id) ? await q.byId.get(id) || null : null;++ }++++ /** The effective quota: the project's override where set, else the environment's default. */++ async function quotaOf(project) {++ const defaults = config.quotas[project.environment] || config.quotas.production;++ const row = await 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;++ }++++ async function usageOf(project) {++ const s = await q.storageUsed.get(project.id);++ return {++ storageBytes: s.n,++ objects: s.c,++ deploysLast24h: (await q.deploysSince.get(project.id, store.now() - DAY)).n,++ sites: (await q.siteCount.get(project.id)).n,++ customDomains: (await q.customDomainCount.get(project.id)).n,++ };++ }++++ async function create(viewer, input = {}) {++ if (!config.publicHosting && !viewer.staff) {++ throw new ApiError(403, 'space.not_launched', 'OpenVibe.Space is not open for hosting yet: it opens after a launch review, and only OpenVibe staff can create projects until then');++ }++ 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');++ }++ const maxOwned = (config.projects && config.projects.maxPerOwner) || 10;++ if ((await q.ownedCount.get(owner)).n >= maxOwned) throw new ApiError(429, 'quota.projects', `you already own ${maxOwned} projects`);++ const id = newId('project', store.now());++ const now = store.now();++ try {++ await store.tx(async () => {++ await q.insert.run(id, networkProjectId, owner, name, environment, now, now);++ await q.addMember.run(id, owner, 'owner', owner, now);++ });++ } catch (err) {++ if (err.code === '23505') throw new ApiError(409, 'project.network_project_taken', 'that Network project already has a Space project');++ throw err;++ }++ return await get(id);++ }++++ async function listFor(viewer) {++ if (viewer.kind === 'user' && viewer.staff && viewer.allProjects) return await q.all.all();++ const p = principalOf(viewer);++ return p ? await q.forPrincipal.all(p) : [];++ }++++ async function members(project) {++ return await q.members.all(project.id);++ }++++ async function setMember(viewer, project, principal, role) {++ await 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');++ await q.addMember.run(project.id, principal, role, principalOf(viewer), store.now());++ return await members(project);++ }++++ async function removeMember(viewer, project, principal) {++ await access.authorize(project, viewer, 'own');++ if (principal === project.owner_subject) throw new ApiError(409, 'member.owner_fixed', 'the project owner cannot be removed');++ await q.removeMember.run(project.id, principal);++ return await members(project);++ }++++ async function setQuota(viewer, project, input = {}) {++ await access.authorize(project, viewer, 'staff');++ const row = await 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;++ }++ await db.prepare(`INSERT INTO space_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 await quotaOf(project);++ }++++ /** Owner (or staff) deletes the project: every site stops serving at once; objects are removed. */++ async function remove(viewer, project, { sites }) {++ await access.authorize(project, viewer, 'own');++ await takedowns.assertDeletable(viewer, { projectId: project.id });++ const now = store.now();++ const doomed = await sites.listForProject(project.id);++ await store.tx(async () => {++ for (const s of doomed) await sites.markDeleted(s, now);++ await db.prepare("UPDATE space_deploys SET state = 'deleted', deleted_at = ? WHERE project_id = ? AND state <> 'deleted'").run(now, project.id);++ await db.prepare("DELETE FROM space_deploy_files WHERE deploy_id IN (SELECT id FROM space_deploys WHERE project_id = ?)").run(project.id);++ await db.prepare('DELETE FROM space_blobs WHERE project_id = ?').run(project.id);++ await db.prepare('DELETE FROM space_domains WHERE project_id = ?').run(project.id);++ await q.markDeleted.run(now, now, project.id);++ });++ try { await blobs.removeProject(project.id); } catch (err) { log.warn('[Space] could not remove project objects:', err.message); }++ // IndexNow: every site of the project left the index (never a sandbox project, which is noindex).++ if (project.environment !== 'sandbox') for (const s of doomed) announce(`${s.name}.${config.sitesDomain}`, ['/', '/sitemap.xml']);++ 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,182 @@++'use strict';++/**++ * Per-site serving configuration (plan T12 J3, decision D4): the response headers, the redirects and++ * the SPA fallback of ONE site, kept in PostgreSQL and applied by server/http/tenant.js BEFORE the++ * platform's own headers. The platform's security, routing and caching headers (CSP, HSTS,++ * Set-Cookie, X-Forwarded-*, Cache-Control, …) are reserved and can never be set through it; a++ * redirect target must be a local path, so a site can never become an open redirect. A site with no++ * row serves exactly as before.++ */++const { ApiError } = require('../http/errors');++const { principalOf } = require('./access');++++const MAX_HEADERS = 50;++const MAX_HEADER_VALUE = 1024;++const MAX_REDIRECTS = 200;++const MAX_LOCATION = 1024;++const HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;++const REDIRECT_STATUS = new Set([301, 302, 307, 308]);++++// Headers the platform owns: a site's config can never set one. `X-Forwarded-*` is refused as a++// prefix, so a site can never lie about the client to an upstream reading those headers.++const RESERVED_HEADERS = new Set([++ 'content-security-policy', 'content-security-policy-report-only', 'strict-transport-security',++ 'set-cookie', 'set-cookie2', 'clear-site-data',++ 'content-length', 'transfer-encoding', 'connection', 'keep-alive', 'trailer', 'upgrade', 'te', 'host', 'location',++ 'content-type', 'content-disposition', 'content-encoding', 'content-range', 'accept-ranges',++ 'cache-control', 'cdn-cache-control', 'etag', 'expires', 'last-modified', 'vary', 'age',++ 'x-content-type-options', 'x-served-by', 'x-robots-tag', 'referrer-policy',++ 'cross-origin-opener-policy', 'cross-origin-embedder-policy', 'cross-origin-resource-policy', 'permissions-policy',++ // Scope and transport are the platform's too: a wider service-worker scope, or another host:port for this origin.++ 'service-worker-allowed', 'alt-svc',++]);++const RESERVED_PREFIXES = ['x-forwarded-'];++++/** Why `name` cannot be configured, or null when it can. */++function reservedReason(name) {++ const lower = String(name).toLowerCase();++ if (RESERVED_HEADERS.has(lower)) return `"${name}" is set by the platform and cannot be configured`;++ if (RESERVED_PREFIXES.some((p) => lower.startsWith(p))) return `"${name}" is reserved (X-Forwarded-*) and cannot be configured`;++ return null;++}++function isReservedHeader(name) { return reservedReason(name) !== null; }++++const fail = (code, message) => { throw new ApiError(422, code, message); };++++function cleanHeaderValue(name, value) {++ const v = value == null ? '' : String(value);++ if (v.length > MAX_HEADER_VALUE) fail('site_config.header_value', `the value of "${name}" is longer than ${MAX_HEADER_VALUE} characters`);++ // eslint-disable-next-line no-control-regex++ if (/[\u0000-\u001f\u007f]/.test(v)) fail('site_config.header_value', `the value of "${name}" contains a control character: a header value must be one line`);++ return v;++}++++/** name → value object of configurable headers, or throws ApiError(422). */++function cleanHeaders(input) {++ if (input == null) return {};++ if (typeof input !== 'object' || Array.isArray(input)) fail('site_config.headers', 'headers must be an object of name → value');++ const names = Object.keys(input);++ if (names.length > MAX_HEADERS) fail('site_config.too_many', `a site may set at most ${MAX_HEADERS} headers`);++ const out = {};++ for (const name of names) {++ if (!HEADER_NAME_RE.test(name)) fail('site_config.header_name', `"${name}" is not a valid header name`);++ const reason = reservedReason(name);++ if (reason) fail('site_config.reserved_header', reason);++ out[name] = cleanHeaderValue(name, input[name]);++ }++ return out;++}++++/** A redirect source or target must be a local path: no scheme, no host, no backslash (which browsers read as "/"). */++function cleanLocalPath(value, field) {++ const s = value == null ? '' : String(value).trim();++ if (!s || s.length > MAX_LOCATION) fail('site_config.redirect', `${field} must be a path of 1–${MAX_LOCATION} characters`);++ // eslint-disable-next-line no-control-regex++ if (/[\u0000-\u001f\u007f]/.test(s)) fail('site_config.redirect', `${field} contains a control character`);++ if (s.includes('\\')) fail('site_config.redirect', `${field} contains a backslash`);++ if (s[0] !== '/') fail('site_config.redirect', `${field} must be a local path starting with "/"`);++ if (s.startsWith('//')) fail('site_config.redirect', `${field} must be a local path, not a protocol-relative URL`);++ let decoded;++ try { decoded = decodeURIComponent(s); } catch { fail('site_config.redirect', `${field} is not valid percent-encoding`); }++ // eslint-disable-next-line no-control-regex++ if (decoded.includes('\\') || /[\u0000-\u001f\u007f]/.test(decoded) || /(^|\/)\/\//.test(decoded)) fail('site_config.redirect', `${field} must be a local path (it decoded to a scheme, a host or a control character)`);++ return s;++}++++/** [{ from, to, status }] of local-only redirects, or throws ApiError(422). */++function cleanRedirects(input) {++ if (input == null) return [];++ if (!Array.isArray(input)) fail('site_config.redirects', 'redirects must be an array');++ if (input.length > MAX_REDIRECTS) fail('site_config.too_many', `a site may have at most ${MAX_REDIRECTS} redirects`);++ const seen = new Set();++ const out = [];++ for (const [i, r] of input.entries()) {++ if (!r || typeof r !== 'object' || Array.isArray(r)) fail('site_config.redirect', `redirect ${i + 1} must be an object with "from" and "to"`);++ const from = cleanLocalPath(r.from, `redirect ${i + 1} "from"`);++ const to = cleanLocalPath(r.to, `redirect ${i + 1} "to"`);++ if (seen.has(from)) fail('site_config.redirect', `two redirects have the same "from" path (${from})`);++ seen.add(from);++ const status = r.status == null ? 301 : Number(r.status);++ if (!REDIRECT_STATUS.has(status)) fail('site_config.redirect', `redirect ${i + 1} status must be one of 301, 302, 307, 308`);++ out.push({ from, to, status });++ }++ return out;++}++++const cleanSpa = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v == null ? '' : v).toLowerCase());++++/** The whole config, cleaned. */++function normalize(input = {}) {++ return { headers: cleanHeaders(input.headers), redirects: cleanRedirects(input.redirects), spa: cleanSpa(input.spa) };++}++++function defaults() { return { headers: {}, redirects: [], spa: false }; }++++// ── Dashboard text form (one "Name: value" per line; one "from -> to [status]" per line) ──++function headersToText(headers) {++ return Object.entries(headers || {}).map(([k, v]) => `${k}: ${v}`).join('\n');++}++function parseHeadersText(text) {++ const obj = {};++ for (const raw of String(text || '').split('\n')) {++ const line = raw.trim();++ if (!line) continue;++ const i = line.indexOf(':');++ if (i <= 0) fail('site_config.header_syntax', `each header line must be "Name: value" (got "${line.slice(0, 60)}")`);++ const name = line.slice(0, i).trim();++ if (name in obj) fail('site_config.header_syntax', `"${name}" is listed twice`);++ obj[name] = line.slice(i + 1).trim();++ }++ return cleanHeaders(obj);++}++function redirectsToText(redirects) {++ return (redirects || []).map((r) => `${r.from} -> ${r.to}${r.status && r.status !== 301 ? ` ${r.status}` : ''}`).join('\n');++}++function parseRedirectsText(text) {++ const list = [];++ for (const raw of String(text || '').split('\n')) {++ const line = raw.trim();++ if (!line) continue;++ const m = /^(\S+)\s*->\s*(\S+)(?:\s+(\d{3}))?$/.exec(line);++ if (!m) fail('site_config.redirect_syntax', `each redirect line must be "from -> to [status]" (got "${line.slice(0, 60)}")`);++ list.push({ from: m[1], to: m[2], status: m[3] ? Number(m[3]) : 301 });++ }++ return cleanRedirects(list);++}++++function createSiteConfig({ store }) {++ const { db } = store;++ const q = {++ bySite: db.prepare('SELECT * FROM space_site_config WHERE site_id = ?'),++ upsert: db.prepare(`INSERT INTO space_site_config (site_id, headers, redirects, spa, updated_by, updated_at)++ VALUES (?, ?::jsonb, ?::jsonb, ?, ?, ?)++ ON CONFLICT (site_id) DO UPDATE SET headers = EXCLUDED.headers, redirects = EXCLUDED.redirects,++ spa = EXCLUDED.spa, updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at`),++ remove: db.prepare('DELETE FROM space_site_config WHERE site_id = ?'),++ };++++ const rowToConfig = (row) => (row++ ? { headers: row.headers && typeof row.headers === 'object' && !Array.isArray(row.headers) ? row.headers : {}, redirects: Array.isArray(row.redirects) ? row.redirects : [], spa: Boolean(row.spa) }++ : defaults());++++ async function getForSite(siteId) { return rowToConfig(await q.bySite.get(siteId)); }++++ async function set(viewer, site, input) {++ const cfg = normalize(input);++ await q.upsert.run(site.id, JSON.stringify(cfg.headers), JSON.stringify(cfg.redirects), cfg.spa, principalOf(viewer), store.now());++ return await getForSite(site.id);++ }++++ async function remove(site) { await q.remove.run(site.id); return defaults(); }++++ return {++ getForSite, set, remove, defaults, normalize, cleanHeaders, cleanRedirects,++ headersToText, parseHeadersText, redirectsToText, parseRedirectsText,++ };++}++++module.exports = {++ createSiteConfig, normalize, defaults, isReservedHeader, cleanHeaders, cleanRedirects,++ parseHeadersText, parseRedirectsText, headersToText, redirectsToText,++ RESERVED_HEADERS, RESERVED_PREFIXES, REDIRECT_STATUS, MAX_HEADERS, MAX_REDIRECTS,++};nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * A site's Git source (plan T12 Stage B, git deploys Phase 1): the repository and branch the project's++ * own CI builds the site from. Host never clones, fetches or builds it and holds no credential for++ * it: the row is public provenance that a git deploy (deploys.ingestGit) must match. The CI token++ * that posts the build output is the trust boundary.++ *++ * repo_url https on the provider's own host (github.com, gitlab.com, codeberg.org), path++ * owner/repo with an optional .git; never userinfo, a port, a query or a fragment.++ * Stored canonically as https://<host>/<owner>/<repo>.++ * ref one branch name, by git check-ref-format's rules, at most 200 characters.++ *++ * Read at `read`, written and removed at `maintain`, through sites.load (404 to anyone else).++ */++const { ApiError } = require('../http/errors');++const { principalOf } = require('./access');++++const PROVIDERS = Object.freeze({ 'github.com': 'github', 'gitlab.com': 'gitlab', 'codeberg.org': 'codeberg' });++const PROVIDER_NAMES = new Set(Object.values(PROVIDERS));++const FIELDS = new Set(['provider', 'repo_url', 'ref']);++const MAX_URL = 300;++const MAX_REF = 200;++// Only these characters, so no userinfo (@), port (:), query (?), fragment (#), percent-encoding or space can hide in a URL.++const URL_RE = /^https:\/\/([A-Za-z0-9.-]+)\/([A-Za-z0-9][A-Za-z0-9_.-]{0,99})\/([A-Za-z0-9][A-Za-z0-9_.-]{0,99})$/;++const REF_CHARS_RE = /^[A-Za-z0-9._/+=,#-]+$/;++++const fail = (code, message) => { throw new ApiError(422, code, message); };++++/** -> { provider, repo_url } (canonical), or throws ApiError(422). */++function cleanRepoUrl(value, provider) {++ const s = typeof value === 'string' ? value.trim() : '';++ if (!s || s.length > MAX_URL) fail('source.repo_url', `repo_url must be an https URL of at most ${MAX_URL} characters`);++ const m = URL_RE.exec(s);++ if (!m) fail('source.repo_url', 'repo_url must be https://<provider host>/<owner>/<repo>[.git], with no user, port, query or fragment');++ const host = m[1].toLowerCase();++ const repo = m[3].endsWith('.git') ? m[3].slice(0, -4) : m[3];++ if (!PROVIDERS[host]) fail('source.repo_url', `repo_url must be on ${Object.keys(PROVIDERS).join(', ')}`);++ if (provider != null && provider !== '') {++ if (!PROVIDER_NAMES.has(provider)) fail('source.provider', `provider must be one of ${[...PROVIDER_NAMES].join(', ')}`);++ if (provider !== PROVIDERS[host]) fail('source.provider', `a ${provider} repository is not on ${host}`);++ }++ return { provider: PROVIDERS[host], repo_url: `https://${host}/${m[2]}/${repo}` };++}++++/** A branch name by git check-ref-format's rules (and a narrower character set), or throws ApiError(422). */++function cleanRef(value) {++ const s = typeof value === 'string' ? value.trim() : '';++ const bad = (why) => fail('source.ref', `ref must be a branch name: ${why}`);++ if (!s || s.length > MAX_REF) bad(`1–${MAX_REF} characters`);++ if (!REF_CHARS_RE.test(s)) bad('letters, digits and . _ / + = , # - only');++ if (s.startsWith('-')) bad('it may not start with "-"');++ if (s.includes('..')) bad('it may not contain ".."');++ if (s.startsWith('/') || s.endsWith('/') || s.includes('//')) bad('no leading, trailing or doubled "/"');++ if (s.endsWith('.')) bad('it may not end with "."');++ if (s === 'HEAD' || s.startsWith('refs/')) bad('name the branch itself, not HEAD or refs/…');++ for (const part of s.split('/')) {++ if (part.startsWith('.') || part.endsWith('.lock')) bad('no part may start with "." or end with ".lock"');++ }++ return s;++}++++/** Validates a PUT body -> { provider, repo_url, ref }, or throws ApiError(422). */++function normalize(input) {++ if (!input || typeof input !== 'object' || Array.isArray(input)) fail('source.invalid', 'the body must be { provider, repo_url, ref }');++ const extra = Object.keys(input).filter((k) => !FIELDS.has(k));++ // Refused, not ignored: a token or key sent here must never look as if Host kept it.++ if (extra.length) fail('source.unknown_field', `unknown field ${extra[0]}: a source is { provider, repo_url, ref } and holds no credential`);++ return { ...cleanRepoUrl(input.repo_url, input.provider), ref: cleanRef(input.ref) };++}++++function createSiteSources({ store, sites }) {++ const { db } = store;++ const q = {++ bySite: db.prepare('SELECT * FROM space_site_sources WHERE site_id = ?'),++ upsert: db.prepare(`INSERT INTO space_site_sources (site_id, provider, repo_url, ref, created_by, created_at, updated_by, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (site_id) DO UPDATE SET provider = EXCLUDED.provider, repo_url = EXCLUDED.repo_url, ref = EXCLUDED.ref,++ updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at`),++ remove: db.prepare('DELETE FROM space_site_sources WHERE site_id = ?'),++ };++++ /** The site's source row, or null (no authorization: callers have loaded the site). */++ async function forSite(siteId) { return await q.bySite.get(siteId) || null; }++++ async function get(viewer, siteId) {++ const { site } = await sites.load(viewer, siteId, 'read');++ return { site, source: await forSite(site.id) };++ }++++ async function put(viewer, siteId, input) {++ const { site } = await sites.load(viewer, siteId, 'maintain');++ const src = normalize(input);++ const who = principalOf(viewer);++ const now = store.now();++ await q.upsert.run(site.id, src.provider, src.repo_url, src.ref, who, now, who, now);++ return { site, source: await forSite(site.id) };++ }++++ async function remove(viewer, siteId) {++ const { site } = await sites.load(viewer, siteId, 'maintain');++ await q.remove.run(site.id);++ return { site, source: null };++ }++++ return { forSite, get, put, remove, normalize };++}++++module.exports = { createSiteSources, normalize, cleanRepoUrl, cleanRef, PROVIDERS, MAX_REF };nnew file mode 100644@@@ -0,0 +1,124 @@++'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, takedowns, indexnow = null }) {++ const { db } = store;++ const reserved = new Set([...RESERVED, ...config.sites.extraReservedNames]);++ const q = {++ byId: db.prepare('SELECT * FROM space_sites WHERE id = ?'),++ byName: db.prepare("SELECT * FROM space_sites WHERE name = ? AND status = 'active'"),++ held: db.prepare("SELECT project_id FROM space_sites WHERE name = ? AND status = 'deleted' AND deleted_at > ? ORDER BY deleted_at DESC LIMIT 1"),++ forProject: db.prepare("SELECT * FROM space_sites WHERE project_id = ? AND status = 'active' ORDER BY created_at, seq"),++ insert: db.prepare(`INSERT INTO space_sites (id, project_id, name, active_deploy_id, status, created_by, created_at, updated_at)++ VALUES (?, ?, ?, NULL, 'active', ?, ?, ?)`),++ insertDefaultDomain: db.prepare(`INSERT INTO space_domains (id, project_id, site_id, hostname, kind, status, token, created_by, created_at, verified_at)++ VALUES (?, ?, ?, ?, 'default', 'verified', NULL, ?, ?, ?)`),++ markDeleted: db.prepare("UPDATE space_sites SET status = 'deleted', active_deploy_id = NULL, preview_deploy_id = NULL, preview_expires_at = NULL, deleted_at = ?, updated_at = ? WHERE id = ?"),++ };++++ const defaultHostname = (name) => `${name}.${config.sitesDomain}`;++ // IndexNow: the site's public pages left the index (never a sandbox site, which is noindex).++ const announce = typeof indexnow === 'function' ? indexnow : () => {};++ const announceSite = (site, project) => {++ if (!project || project.environment !== 'sandbox') announce(defaultHostname(site.name), ['/', '/sitemap.xml']);++ };++++ async function get(id) {++ return isId('site', id) ? await q.byId.get(id) || null : null;++ }++++ /** The site and its project, authorized at `need`; 404 for anyone without access. */++ async function load(viewer, id, need) {++ const site = await get(id);++ const notFound = new ApiError(404, 'site.not_found', 'no such site');++ if (!site || site.status !== 'active') throw notFound;++ const project = await projects.get(site.project_id);++ const role = await 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`);++ }++++ async function create(viewer, project, input = {}) {++ await access.authorize(project, viewer, 'maintain');++ const name = String(input.name || '').trim().toLowerCase();++ checkName(name);++ const quota = await projects.quotaOf(project);++ if ((await projects.usageOf(project)).sites >= quota.sites) throw new ApiError(429, 'quota.sites', `this project may have ${quota.sites} sites`);++ if (await q.byName.get(name)) throw new ApiError(409, 'site.name_taken', `${defaultHostname(name)} is taken`);++ const held = await 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 {++ await store.tx(async () => {++ await q.insert.run(id, project.id, name, who, now, now);++ await q.insertDefaultDomain.run(newId('domain', now), project.id, id, defaultHostname(name), who, now, now);++ });++ } catch (err) {++ if (err.code === '23505') throw new ApiError(409, 'site.name_taken', `${defaultHostname(name)} is taken`);++ throw err;++ }++ return await get(id);++ }++++ async function listForProject(projectId) {++ return await q.forProject.all(projectId);++ }++++ /** Inside a transaction: the site stops serving immediately; deploys and domains go with it. */++ async function markDeleted(site, now) {++ await q.markDeleted.run(now, now, site.id);++ await db.prepare("UPDATE space_deploys SET state = 'deleted', deleted_at = ? WHERE site_id = ? AND state <> 'deleted'").run(now, site.id);++ await db.prepare('DELETE FROM space_deploy_files WHERE deploy_id IN (SELECT id FROM space_deploys WHERE site_id = ?)').run(site.id);++ await db.prepare('DELETE FROM space_domains WHERE site_id = ?').run(site.id);++ await db.prepare('DELETE FROM space_site_config WHERE site_id = ?').run(site.id);++ await db.prepare('DELETE FROM space_site_sources WHERE site_id = ?').run(site.id);++ }++++ async function remove(viewer, id, { deploys }) {++ const { site, project } = await load(viewer, id, 'maintain');++ await takedowns.assertDeletable(viewer, { site });++ const now = store.now();++ let freed = [];++ await store.tx(async () => {++ await markDeleted(site, now);++ freed = await deploys.collectGarbage(project.id);++ });++ // Await it: with the Media store the deletes are slow and detached otherwise, and the caller++ // (project/site removal) should finish only once the durable objects are gone.++ await deploys.unlinkBlobs(project.id, freed);++ announceSite(site, project);++ return { deleted: true };++ }++++ return { get, load, create, listForProject, markDeleted, remove, defaultHostname, checkName, byName: async (n) => await q.byName.get(n) || null };++}++++module.exports = { createSites, RESERVED, NAME_RE };nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++/**++ * Abuse takedowns, by OpenVibe staff only (Network role admin).++ *++ * A takedown stops a site, or every site of a project, from being served at once: its default and++ * custom domains answer 451 with no tenant content. Unlike a delete, it KEEPS the deploys, their++ * file rows, logs and objects, so a report can be reviewed and, where the law requires it, the++ * material preserved and handed over. While a takedown is active the project's members cannot++ * upload, activate, roll back or delete anything it covers; staff can still delete. Every takedown++ * and every lift stays in space_takedowns.++ *++ * Members see that their site is taken down, and why (site/project `takedown` in the API).++ */++const { ApiError } = require('../http/errors');++const { principalOf } = require('./access');++++const REASON_MAX = 500;++++function createTakedowns({ store }) {++ const { db } = store;++ const q = {++ forSite: db.prepare(`SELECT target_kind, reason, created_at FROM space_takedowns++ WHERE lifted_at IS NULL AND ((target_kind = 'site' AND target_id = ?) OR (target_kind = 'project' AND target_id = ?))++ ORDER BY target_kind = 'project' DESC, created_at LIMIT 1`),++ forProject: db.prepare("SELECT target_kind, reason, created_at FROM space_takedowns WHERE lifted_at IS NULL AND target_kind = 'project' AND target_id = ?"),++ anyInProject: db.prepare(`SELECT target_kind, reason, created_at FROM space_takedowns t++ WHERE lifted_at IS NULL AND ((target_kind = 'project' AND target_id = @p)++ OR (target_kind = 'site' AND target_id IN (SELECT id FROM space_sites WHERE project_id = @p AND status = 'active')))++ LIMIT 1`),++ insert: db.prepare('INSERT INTO space_takedowns (target_kind, target_id, reason, created_by, created_at) VALUES (?, ?, ?, ?, ?)'),++ lift: db.prepare('UPDATE space_takedowns SET lifted_by = ?, lifted_at = ?, lift_note = ? WHERE target_kind = ? AND target_id = ? AND lifted_at IS NULL'),++ };++++ const shape = (row) => (row ? { scope: row.target_kind, reason: row.reason, since: row.created_at } : null);++++ /** The takedown that covers a site (its own, or its project's), or null. */++ const ofSite = async (site) => shape(await q.forSite.get(site.id, site.project_id));++ /** The project-wide takedown, or null. */++ const ofProject = async (projectId) => shape(await q.forProject.get(projectId));++++ /** Members may not publish into, or change, content that is taken down. */++ async function assertOpen(site) {++ const t = await ofSite(site);++ if (t) throw new ApiError(403, 'site.taken_down', `this ${t.scope} was taken down by OpenVibe staff (${t.reason}); nothing can be published or changed until staff lift it`);++ }++++ /** Deleting taken-down content would destroy what staff are reviewing: staff only. */++ async function assertDeletable(viewer, { site = null, projectId = null }) {++ if (viewer && viewer.staff) return;++ const row = site ? await q.forSite.get(site.id, site.project_id) : await q.anyInProject.get({ p: projectId });++ if (row) throw new ApiError(409, 'site.taken_down', `this ${row.target_kind} was taken down by OpenVibe staff and is kept for review; it cannot be deleted until staff lift the takedown`);++ }++++ function requireStaff(viewer) {++ if (!viewer || !viewer.staff) throw new ApiError(403, 'auth.staff_only', 'only OpenVibe staff can take content down or lift a takedown');++ }++++ /** kind: 'site' | 'project'. -> the takedown */++ async function takeDown(viewer, kind, targetId, reasonRaw) {++ requireStaff(viewer);++ const reason = String(reasonRaw || '').trim();++ if (!reason || reason.length > REASON_MAX) throw new ApiError(422, 'takedown.reason_required', `a takedown needs a reason of 1–${REASON_MAX} characters (members see it)`);++ try {++ await q.insert.run(kind, targetId, reason, principalOf(viewer), store.now());++ } catch (err) {++ if (err.code === '23505') throw new ApiError(409, 'takedown.exists', `this ${kind} is already taken down`);++ throw err;++ }++ return { scope: kind, reason, since: store.now() };++ }++++ async function lift(viewer, kind, targetId, note) {++ requireStaff(viewer);++ const r = await q.lift.run(principalOf(viewer), store.now(), note == null ? null : String(note).slice(0, REASON_MAX), kind, targetId);++ if (r.changes !== 1) throw new ApiError(404, 'takedown.not_found', `this ${kind} is not taken down`);++ return { lifted: true };++ }++++ return { ofSite, ofProject, assertOpen, assertDeletable, takeDown, lift };++}++++module.exports = { createTakedowns };nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++++/**++ * Space → OpenVibe.Events through the openvibe-sdk service outbox (ADR-004).++ *++ * space.deploy.created an upload became an immutable, ready deploy++ * space.deploy.activated a site's active deploy changed (payload.rollback marks a rollback)++ * space.deploy.failed an upload was refused (payload.code, problem codes; no file contents)++ * space.domain.verified a custom domain's TXT record was found; the domain is now served++ *++ * The SDK writes each row inside the change's own transaction (emit joins the ambient openvibe-sdk/db++ * transaction), so an event exists if and only if its change committed. The relay publishes with++ * Space'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. The undeclared event types below are refused by emit().++ */++const { createServiceOutbox } = require('openvibe-sdk/events');++++const EVENT_TYPES = Object.freeze(['space.deploy.created', 'space.deploy.activated', 'space.deploy.failed', 'space.domain.verified']);++++function createHostOutbox({ db, config, fetchImpl, now, log = console }) {++ return createServiceOutbox({++ db,++ source: 'space',++ eventsUrl: config.events.url,++ networkInternalUrl: config.networkInternalUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ intervalMs: config.events.intervalMs,++ now,++ log,++ eventTypes: EVENT_TYPES,++ ...(fetchImpl ? { fetch: fetchImpl } : {}),++ });++}++++module.exports = { createHostOutbox, EVENT_TYPES };nnew file mode 100644@@@ -0,0 +1,151 @@++'use strict';++++/**++ * OpenVibe.Space Stage B (tenant static hosting) configuration. Every value comes from the++ * environment (production: /etc/openvibe/space.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, 4940);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.space' : `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.SPACE_SITES_DOMAIN || 'openvibe.website').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: 'space',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ dashboardHost,++ sitesDomain,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1, // nginx (realip) → Node++++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).++ // SPACE_PGLITE_DIR: where the development-only embedded database lives (default data/pglite); tests++ // point it at a temp directory so a spawned server gets a database of its own.++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.SPACE_PGLITE_DIR || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:space:' },++ // Content-addressed deploy objects, one directory per project (tenancy keyed by project id).++ storageDir: env.SPACE_STORAGE_DIR || './data/objects',++++ // Tenant objects: local disk is the store and the read cache. SPACE_OBJECT_STORE=media writes++ // every object through to OpenVibe.Media as well (Space's own namespace `space`, keyed per++ // project, sha256 as content_hash) and serves a local miss from there; unset keeps the local++ // store exactly as it is. OV_MEDIA_URL is the internal Media base URL; the credential is++ // Space's Network service principal (OV_OAUTH_CLIENT_ID/SECRET), audience openvibe.media.++ objectStore: {++ mode: String(env.SPACE_OBJECT_STORE || '').toLowerCase() === 'media' ? 'media' : 'local',++ },++ media: {++ url: trim(env.OV_MEDIA_URL || 'http://127.0.0.1:4100'),++ namespace: String(env.OV_MEDIA_NAMESPACE || 'space').trim() || 'space',++ scope: 'media.object.upload media.object.read media.object.list media.object.delete',++ },++++ // 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 || 'space',++ 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.SPACE_FORM_SECRET || '',++++ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a tenant site the++ // moment its active deploy appears, changes or goes away (the key file is served at++ // /<key>.txt on every host). Unset → off: no key file, nothing sent. Tests and drills never++ // set it (the drill sets INDEXNOW_KEY empty in space.example.json).++ indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },++++ // 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),++ },++ community: {++ internalUrl: trim(env.SPACE_COMMUNITY_URL || ''),++ publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),++ },++++ uploads: {++ // Largest request body accepted for one deploy (the compressed archive or all multipart parts).++ maxUploadBytes: int(env.SPACE_MAX_UPLOAD_BYTES, 100 * MiB),++ // Largest size an archive may unpack to (also capped by the project's storage quota). The++ // unpacked files are held in memory while they are checked, so this bounds what a small++ // gzip bomb can cost, whatever quota staff grant a project.++ maxUnpackedBytes: int(env.SPACE_MAX_UNPACKED_BYTES, 256 * MiB),++ // Uploads read and validated at the same time, service-wide (each is held in memory).++ maxConcurrent: int(env.SPACE_MAX_CONCURRENT_UPLOADS, 2),++ // Uploads are refused while the object store's filesystem has less than this free. The++ // disk is shared with every other service on the host (Live, Media, databases), so a++ // tenant must never be able to fill it.++ minFreeBytes: int(env.SPACE_MIN_FREE_BYTES, 5 * 1024 * MiB),++ },++ // Projects one person may own (each gets the per-project quotas below).++ projects: {++ maxPerOwner: int(env.SPACE_MAX_PROJECTS_PER_OWNER, 10),++ },++ // Public hosting is owner-gated (plan T20/O34: the owner's go-ahead, a DMCA designated agent and a Public++ // Suffix List entry come first). Until SPACE_PUBLIC_HOSTING=on only OpenVibe staff can create projects, and the++ // front page says hosting is not open yet; everything else about the engine is unchanged.++ publicHosting: String(env.SPACE_PUBLIC_HOSTING || 'off').trim().toLowerCase() === 'on',++++ // Per-project quotas (a row in space_quotas overrides them per project; only staff set it).++ quotas: {++ production: quota('SPACE_QUOTA_', { storageBytes: 1024 * MiB, deploysPerDay: 50, maxFiles: 10000, maxFileBytes: 25 * MiB, sites: 10, customDomains: 5 }),++ sandbox: quota('SPACE_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.SPACE_CNAME_TARGET || '').toLowerCase() || null,++ originIpv4: env.SPACE_ORIGIN_IPV4 || null,++ originIpv6: env.SPACE_ORIGIN_IPV6 || null,++ dnsServers: list(env.SPACE_DNS_SERVERS),++ recheckIntervalMs: int(env.SPACE_DOMAIN_RECHECK_MS, 10 * 60 * 1000),++ pendingDays: int(env.SPACE_DOMAIN_PENDING_DAYS, 7),++ lapseDays: int(env.SPACE_DOMAIN_LAPSE_DAYS, 7),++ extraReserved: list(env.SPACE_RESERVED_DOMAINS).map((d) => d.toLowerCase()),++ },++++ sites: { extraReservedNames: list(env.SPACE_RESERVED_SITE_NAMES).map((d) => d.toLowerCase()) },++++ worker: {++ enabled: env.SPACE_WORKER !== 'off',++ id: env.SPACE_WORKER_ID || `space-${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,103 @@++'use strict';++++/** Static hosting engine mounted by Space's existing app. */++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const { createIndexNow } = require('openvibe-shared/indexnow');++const { load } = require('./hosting-config');++const { createBlobStore } = require('./storage');++const { createHostOutbox } = require('./events/outbox');++const { createAccess } = require('./domain/access');++const { createTakedowns } = require('./domain/takedowns');++const { createProjects } = require('./domain/projects');++const { createSites } = require('./domain/sites');++const { createDeploys } = require('./domain/deploys');++const { createDomains } = require('./domain/domains');++const { createSiteConfig } = require('./domain/site-config');++const { createSiteSources } = require('./domain/site-sources');++const { createViewerResolver, guard } = require('./auth/viewer');++const { createTenantServer } = require('./http/tenant');++const { createUploadGate } = require('./http/upload');++const { createApi } = require('./http/api');++const resourceIndex = require('./registry/resource-index');++const { createDashboard } = require('./http/dashboard');++const { createWorker } = require('./worker');++const { createStore } = require('./db');++++function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, fetchImpl, indexnow: injectedIndexNow, log = console, storageDir, valkey, forumRedirect } = {}) {++ const config = suppliedConfig || load();++ const store = createStore(db, { now });++ const media = config.objectStore.mode === 'media' ? {++ url: config.media.url, namespace: config.media.namespace, scope: config.media.scope,++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, ...(fetchImpl ? { fetchImpl } : {}),++ } : null;++ const referenced = db.prepare('SELECT 1 FROM space_blobs WHERE project_id = ? AND sha256 = ?');++ const blobs = createBlobStore(storageDir || config.storageDir, {++ media, isReferenced: async (projectId, sha256) => Boolean(await referenced.get(projectId, sha256)),++ });++ const indexnow = injectedIndexNow !== undefined ? injectedIndexNow : createIndexNow({ host: config.baseUrl, key: config.indexnow.key, ...(fetchImpl ? { fetch: fetchImpl } : {}), log });++ const indexnowHosts = new Map();++ function announce(hostname, paths) {++ if (!indexnow.enabled || !hostname) return;++ if (!indexnowHosts.has(hostname)) indexnowHosts.set(hostname, createIndexNow({ host: hostname, key: config.indexnow.key, ...(fetchImpl ? { fetch: fetchImpl } : {}), log }));++ const u = new URL(config.baseUrl);++ const origin = `${u.protocol}//${hostname}${u.port ? `:${u.port}` : ''}`;++ (injectedIndexNow !== undefined ? indexnow : indexnowHosts.get(hostname)).pingSoon(paths.map((p) => new URL(p, origin).href));++ }++ const outbox = createHostOutbox({ db, config, fetchImpl, now: store.now, log });++ const access = createAccess({ store });++ const takedowns = createTakedowns({ store });++ const projects = createProjects({ store, config, access, blobs, takedowns, log, indexnow: announce });++ const sites = createSites({ store, config, access, projects, takedowns, indexnow: announce });++ const deploys = createDeploys({ store, config, access, projects, sites, blobs, outbox, takedowns, log, indexnow: announce });++ const domains = createDomains({ store, config, access, projects, sites, outbox, resolver, log, indexnow: announce });++ const siteConfig = createSiteConfig({ store });++ const siteSources = createSiteSources({ store, sites });++ const viewers = createViewerResolver({ auth, config, log });++ const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });++ const uploadGate = createUploadGate(config.uploads.maxConcurrent);++ const worker = createWorker({ config, store, domains, blobs, outbox, log });++ const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, auth, viewers, tenant, worker, uploadGate, indexnow, log, valkey: valkey || null };++ ctx.actorLimits = require('./http/actor-limits').createHostActorLimits({ registry: app.locals.metrics, valkey: ctx.valkey });++ app.locals.ctx = ctx;++ if (app.locals.metrics && typeof app.locals.metrics.gauge === 'function') {++ app.locals.metrics.gauge({++ name: 'space_sites', help: 'Active tenant sites',++ collect: async () => [{ labels: {}, value: (await db.prepare("SELECT COUNT(*) AS n FROM space_sites WHERE status = 'active'").get()).n }],++ });++ }++ require('./http/errors').setLogger(log);++++ /**++ * Mounted first (server/app.js), before the platform's own headers, metrics route, auth, API and readiness:++ * a tenant host is answered here and never reaches anything below (Host's order, test/hosting-isolation and++ * test/hosting-site-config). The IndexNow key file answers on every host, a tenant's included.++ */++ ctx.mountDispatch = (target) => {++ if (indexnow.enabled) target.use(indexnow.keyFile);++ target.use(async (req, res, next) => {++ try {++ const target = await tenant.resolve(req.headers.host);++ req.hostTarget = target.kind;++ if (target.kind === 'dashboard') return next();++ if (target.kind === 'site') return await tenant.handle(req, res, target.site);++ return tenant.unknownHost(req, res);++ } catch (err) { return next(err); }++ });++ };++++ /** The dashboard host's hosting routes: forum redirects, the hosting API, previews, the dashboard and the limits page. */++ ctx.mountRoutes = (app) => {++ if (forumRedirect) app.use(forumRedirect);++ app.use(contracts.http.middleware());++ app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), resourceIndex.router({ guard: guard('space.resource.read') }));++ app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));++ app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => Promise.resolve(tenant.preview(req, res)).catch(next));++ app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));++ require('./limits').mountLimits(app, config);++ };++ return ctx;++}++++module.exports = { createHosting };nnew file mode 100644@@@ -0,0 +1,58 @@++/**++ * Per-actor limits on the dashboard's and the API's writes (roadmap WS-R task 4; openvibe-sdk/limits).++ *++ * A signed-in person (req.viewer.kind === 'user') is counted by subject; a service token by its principal (a service++ * here manages its own projects, not other people's). Anonymous callers and reads are not counted: the per-address++ * limits in app.js stay. Every write takes SPACE_LIMITS_MINUTE / SPACE_LIMITS_HOUR (120 and 3000); deploy uploads++ * (stored and unpacked), domain verification (DNS lookups) and project creation have tighter numbers on top. Past a++ * limit: 429 problem+json `rate_limited` with Retry-After before the route runs, logged and counted in++ * space_rate_limited_total{limit,window}. Counters live in this process.++ */++'use strict';++++const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/limits');++++const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) && n > 0 ? n : d; };++++/** [name, method regex, path regex (relative to the router), { minute, hour }]; the first match wins. */++const ROUTES = [++ ['space.deploy.upload', /^POST$/, /^\/sites\/[^/]+\/deploys\/?$/, { minute: 10, hour: 100 }],++ ['space.domain.verify', /^POST$/, /^\/domains\/[^/]+\/verify$/, { minute: 10, hour: 100 }],++ ['space.project.create', /^POST$/, /^\/projects\/?$/, { minute: 5, hour: 30 }],++];++++function createHostActorLimits({ env = process.env, registry = null, now, valkey = null } = {}) {++ // SPACE_ACTOR_LIMITS=off: a rollback lever (and what the Stage B suites use: they deploy dozens of times a minute).++ if (/^(off|0|false)$/i.test(String(env.SPACE_ACTOR_LIMITS || ''))) return function noLimits(req, res, next) { next(); };++ const refused = registry && typeof registry.counter === 'function'++ ? registry.counter({ name: 'space_rate_limited_total', help: 'Writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })++ : null;++ const actor = (req) => {++ const v = req.viewer;++ if (!v) return null;++ if (v.kind === 'user' && v.subject) return `user:${v.subject}`;++ if (v.kind === 'service' && v.claims && v.claims.sub) return String(v.claims.sub);++ return null;++ };++ const limits = createActorLimiter({++ limits: { minute: num(env.SPACE_LIMITS_MINUTE, 120), hour: num(env.SPACE_LIMITS_HOUR, 3000) },++ actor,++ ...(now ? { now } : {}),++ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),++ onLimited(e) {++ console.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);++ if (refused) refused.inc({ limit: e.name, window: e.window });++ },++ });++ const write = limits('space.write');++ const named = ROUTES.map(([name, method, pathRe, own]) => ({ method, pathRe, mw: limits(name, own) }));++ const WRITE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);++ return function hostActorLimits(req, res, next) {++ if (!WRITE.has(req.method)) return next();++ const own = named.find((r) => r.method.test(req.method) && r.pathRe.test(req.path));++ return write(req, res, (err) => (err ? next(err) : own ? own.mw(req, res, next) : next()));++ };++}++++module.exports = { createHostActorLimits, ROUTES };nnew file mode 100644@@@ -0,0 +1,271 @@++'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.space). Cookies are ignored here: tenant pages++ * on <site>.openvibe.space are same-site with openvibe.space, 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 space.site.manage++ * sites GET/POST /projects/:id/sites · GET/DELETE /sites/:id space.site.manage++ * GET/PUT/DELETE /sites/:id/config (headers, redirects, SPA fallback) space.site.config++ * GET/PUT/DELETE /sites/:id/source (Git repository + branch) space.site.manage++ * deploys GET/POST /sites/:id/deploys · GET /deploys/:id · GET /deploys/:id/log++ * POST /deploys/:id/activate · POST /sites/:id/rollback space.deploy.create++ * DELETE /deploys/:id space.site.manage++ * POST /sites/:id/source/deploys (the project's CI posts a build; always a preview)++ * space.deploy.create++ * domains GET/POST /sites/:id/domains · POST /domains/:id/verify++ * DELETE /domains/:id space.domain.manage++ * takedowns POST/DELETE /projects/:id/takedown · POST/DELETE /sites/:id/takedown space.site.manage (staff)++ */++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, siteConfig, siteSources, viewers, takedowns } = 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'));++ if (ctx.actorLimits) router.use(ctx.actorLimits);++++ const siteUrl = (hostname) => {++ const u = new URL(config.baseUrl);++ return `${u.protocol}//${hostname}${u.port ? `:${u.port}` : ''}`;++ };++ const siteOut = async (s) => { const hostname = sites.defaultHostname(s.name); return out.site(s, { hostname, url: siteUrl(hostname), takedown: await takedowns.ofSite(s) }); };++ const projectOut = async (p, role) => out.project(p, { role, quota: await projects.quotaOf(p), usage: await projects.usageOf(p), takedown: await takedowns.ofProject(p.id) });++ const tp = (req) => (req.ov ? req.ov.traceparent : undefined);++ const loadProject = async (req, need) => {++ const project = await projects.get(req.params.id);++ const role = await ctx.access.authorize(project, req.viewer, need);++ return { project, role };++ };++++ // ── Projects ────────────────────────────────────────────++ router.get('/projects', guard('space.site.manage'), run(async (req) => {++ if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');++ return { projects: (await Promise.all((await projects.listFor(req.viewer)).map(async (p) => out.project(p, { takedown: await takedowns.ofProject(p.id) })))) };++ }));++ router.post('/projects', guard('space.site.manage'), jsonBody, run(async (req) => {++ if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');++ const p = await projects.create(req.viewer, req.body || {});++ return { project: await projectOut(p, 'owner') };++ }, 201));++ router.get('/projects/:id', guard('space.site.manage'), run(async (req) => {++ const { project, role } = await loadProject(req, 'read');++ return { project: await projectOut(project, role), sites: (await Promise.all((await sites.listForProject(project.id)).map(siteOut))), members: (await projects.members(project)).map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };++ }));++ router.delete('/projects/:id', guard('space.site.manage'), run(async (req) => {++ const { project } = await loadProject(req, 'read');++ return await projects.remove(req.viewer, project, { sites });++ }));++ router.get('/projects/:id/quota', guard('space.site.manage'), run(async (req) => {++ const { project } = await loadProject(req, 'read');++ return { quota: out.quotaOut(await projects.quotaOf(project)), usage: out.usageOut(await projects.usageOf(project)) };++ }));++ router.put('/projects/:id/quota', guard('space.site.manage'), jsonBody, run(async (req) => {++ const project = await projects.get(req.params.id);++ const q = await projects.setQuota(req.viewer, project, req.body || {});++ return { quota: out.quotaOut(q), usage: out.usageOut(await projects.usageOf(project)) };++ }));++ router.put('/projects/:id/members/:principal', guard('space.site.manage'), jsonBody, run(async (req) => {++ const { project } = await loadProject(req, 'read');++ const list = await 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('space.site.manage'), run(async (req) => {++ const { project } = await loadProject(req, 'read');++ const list = await 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) })) };++ }));++++ // ── Takedowns (staff only; members see them on the project and site) ──++ // Serving stops at once and the content is kept for review (domain/takedowns.js).++ router.post('/projects/:id/takedown', guard('space.site.manage'), jsonBody, run(async (req) => {++ const { project } = await loadProject(req, 'read');++ return { takedown: out.takedownOut(await takedowns.takeDown(req.viewer, 'project', project.id, (req.body || {}).reason)) };++ }, 201));++ router.delete('/projects/:id/takedown', guard('space.site.manage'), jsonBody, run(async (req) => {++ const { project } = await loadProject(req, 'read');++ return await takedowns.lift(req.viewer, 'project', project.id, (req.body || {}).note);++ }));++ router.post('/sites/:id/takedown', guard('space.site.manage'), jsonBody, run(async (req) => {++ const { site } = await sites.load(req.viewer, req.params.id, 'read');++ return { takedown: out.takedownOut(await takedowns.takeDown(req.viewer, 'site', site.id, (req.body || {}).reason)) };++ }, 201));++ router.delete('/sites/:id/takedown', guard('space.site.manage'), jsonBody, run(async (req) => {++ const { site } = await sites.load(req.viewer, req.params.id, 'read');++ return await takedowns.lift(req.viewer, 'site', site.id, (req.body || {}).note);++ }));++++ // ── Sites ───────────────────────────────────────────────++ router.get('/projects/:id/sites', guard('space.site.manage'), run(async (req) => {++ const { project } = await loadProject(req, 'read');++ return { sites: (await Promise.all((await sites.listForProject(project.id)).map(siteOut))) };++ }));++ router.post('/projects/:id/sites', guard('space.site.manage'), jsonBody, run(async (req) => {++ const project = await projects.get(req.params.id);++ await ctx.access.authorize(project, req.viewer, 'read');++ return { site: await siteOut(await sites.create(req.viewer, project, req.body || {})) };++ }, 201));++ router.get('/sites/:id', guard('space.site.manage'), run(async (req) => {++ const { site } = await sites.load(req.viewer, req.params.id, 'read');++ return { site: await siteOut(site), domains: (await domains.listForSite(site.id)).map((d) => out.domain(d, domains.instructions(d, site))) };++ }));++ router.delete('/sites/:id', guard('space.site.manage'), run(async (req) => await sites.remove(req.viewer, req.params.id, { deploys })));++++ // ── Deploys ─────────────────────────────────────────────++ router.get('/sites/:id/deploys', guard('space.deploy.create'), run(async (req) => {++ // Reads need the least member role (deployer); staff may read them too, e.g. to review a takedown.++ const { site } = await sites.load(req.viewer, req.params.id, 'read');++ return {++ active_deploy_id: site.active_deploy_id || null,++ deploys: (await deploys.list(site.id, Number(req.query.limit) || 50)).map((d) => out.deploy(d, { active: d.id === site.active_deploy_id })),++ activations: (await deploys.activationsOf(site.id, 20)).map(out.activation),++ };++ }));++++ /**++ * An upload (POST /sites/:id/deploys) or, with `git`, a git deploy (POST /sites/:id/source/deploys):++ * the same body, limits, gate and failure records; a git deploy also names ref and commit_sha++ * (query or form fields) and always lands as the site's preview.++ */++ async function receive(req, res, { git = false } = {}) {++ let release = null;++ try {++ const pre = await deploys.precheck(req.viewer, req.params.id);++ if (git) {++ // Refused before a byte is read: activate, an unconnected site, a wrong ref or SHA in the query.++ if (truthy(req.query.activate)) throw noActivate();++ await deploys.gitTarget(pre.site, { ref: req.query.ref, commit_sha: req.query.commit_sha });++ }++ release = ctx.uploadGate.enter();++ if (!release) { res.set('Retry-After', '30'); throw new ApiError(503, 'upload.busy', 'Space is validating other uploads right now; try again in 30 seconds'); }++ let up;++ try {++ up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });++ } 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 = await deploys.recordFailure(req.viewer, pre, { source: git ? 'git' : 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}`) } });++ }++ if (git) {++ if (truthy(up.fields.activate)) throw noActivate();++ const field = (name) => (up.fields[name] != null ? up.fields[name] : req.query[name]);++ const r = await deploys.ingestGit(req.viewer, pre, pre.site.id, up.entries, { ref: field('ref'), commit_sha: field('commit_sha'), notes: up.notes, traceparent: tp(req) });++ const site = await sites.get(pre.site.id);++ return res.status(201).json({ deploy: out.deploy(r.deploy, { active: site.active_deploy_id === r.deploy.id, log: r.log }), activated: false, preview: r.preview, url: siteUrl(sites.defaultHostname(site.name)) });++ }++ const preview = truthy(up.fields.preview != null ? up.fields.preview : req.query.preview);++ // A preview is never the site's active deploy; it is served only at /preview/<id>/ later.++ const activate = !preview && truthy(up.fields.activate != null ? up.fields.activate : req.query.activate);++ const r = await deploys.create(req.viewer, pre, up.entries, { source: up.source, activate, preview, notes: up.notes, traceparent: tp(req) });++ const site = await sites.get(pre.site.id);++ res.status(201).json({ deploy: out.deploy(r.deploy, { active: site.active_deploy_id === r.deploy.id, log: r.log }), activated: Boolean(r.activated && r.activated.changed), preview: r.preview, url: siteUrl(sites.defaultHostname(site.name)) });++ } catch (err) {++ // Refused before the body was read (404, 429, 503): close instead of draining a large upload.++ if (!req.complete) res.set('Connection', 'close');++ if (!res.headersSent) sendError(res, req, err);++ } finally {++ if (release) release();++ }++ }++ const noActivate = () => new ApiError(422, 'source.activate', 'a git deploy is never activated on arrival: it lands as the site\'s preview; approve it with POST /api/v1/deploys/:id/activate');++ router.post('/sites/:id/deploys', guard('space.deploy.create'), (req, res) => receive(req, res));++++ router.get('/deploys/:id', guard('space.deploy.create'), run(async (req) => {++ const { deploy, site } = await deploys.load(req.viewer, req.params.id, 'read');++ return { deploy: out.deploy(deploy, { active: site.active_deploy_id === deploy.id, files: await deploys.filesOf(deploy.id) }) };++ }));++ router.get('/deploys/:id/log', guard('space.deploy.create'), run(async (req) => {++ const { deploy } = await deploys.load(req.viewer, req.params.id, 'read');++ return { deploy_id: deploy.id, state: deploy.state, log: (await deploys.logOf(deploy.id)).map(out.logLine) };++ }));++ router.post('/deploys/:id/activate', guard('space.deploy.create'), jsonBody, run(async (req) => {++ const body = req.body || {};++ const r = await 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('space.deploy.create'), jsonBody, run(async (req) => {++ const body = req.body || {};++ const r = await 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('space.site.manage'), run(async (req) => await deploys.remove(req.viewer, req.params.id)));++++ // ── Domains ─────────────────────────────────────────────++ router.get('/sites/:id/domains', guard('space.domain.manage'), run(async (req) => {++ const { site } = await sites.load(req.viewer, req.params.id, 'read');++ return { domains: (await domains.listForSite(site.id)).map((d) => out.domain(d, domains.instructions(d, site))) };++ }));++ router.post('/sites/:id/domains', guard('space.domain.manage'), jsonBody, run(async (req) => {++ const { domain, site } = await 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('space.domain.manage'), run(async (req) => {++ const d = await domains.verify(req.viewer, req.params.id, { traceparent: tp(req) });++ const site = await sites.get(d.site_id);++ return { domain: out.domain(d, domains.instructions(d, site)) };++ }));++ router.delete('/domains/:id', guard('space.domain.manage'), run(async (req) => await domains.remove(req.viewer, req.params.id)));++++ // ── Per-site configuration: headers, redirects, SPA fallback (space.site.config) ──++ // Applied by http/tenant.js before the platform's headers; reserved headers are refused on write.++ router.get('/sites/:id/config', guard('space.site.config'), run(async (req) => {++ const { site } = await sites.load(req.viewer, req.params.id, 'read');++ return { config: await siteConfig.getForSite(site.id) };++ }));++ router.put('/sites/:id/config', guard('space.site.config'), jsonBody, run(async (req) => {++ const { site } = await sites.load(req.viewer, req.params.id, 'maintain');++ return { config: await siteConfig.set(req.viewer, site, req.body || {}) };++ }));++ router.delete('/sites/:id/config', guard('space.site.config'), run(async (req) => {++ const { site } = await sites.load(req.viewer, req.params.id, 'maintain');++ return { config: await siteConfig.remove(site) };++ }));++++ // ── Git source: the repository + branch the project's own CI deploys from (space.site.manage) ──++ // Host never clones, fetches or builds it and holds no credential: the CI posts its build output++ // to /source/deploys with the ref and the commit it built, and that deploy is only ever a preview.++ router.get('/sites/:id/source', guard('space.site.manage'), run(async (req) => {++ const { source } = await siteSources.get(req.viewer, req.params.id);++ return { source: out.source(source) };++ }));++ router.put('/sites/:id/source', guard('space.site.manage'), jsonBody, run(async (req) => {++ const { source } = await siteSources.put(req.viewer, req.params.id, req.body);++ return { source: out.source(source) };++ }));++ router.delete('/sites/:id/source', guard('space.site.manage'), run(async (req) => {++ await siteSources.remove(req.viewer, req.params.id);++ return { source: null };++ }));++ router.post('/sites/:id/source/deploys', guard('space.deploy.create'), (req, res) => receive(req, res, { git: true }));++++ 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,224 @@++'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.space). Every response is private and never cached by a shared cache.++ */++const express = require('express');++const { renderPage } = require('../render/hosting-layout');++const pages = require('../render/hosting-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, siteConfig, siteSources, 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'));++ if (ctx.actorLimits) router.use(ctx.actorLimits);++ router.use((req, res, next) => { privateNoStore(res); next(); });++++ function page(req, res, status, title, body, notice, { indexable = false, styles, css, jsonLd } = {}) {++ res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice, indexable, styles, css, jsonLd }));++ }++++ 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('[Space 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;++ };++ /** A staff takedown outranks any other notice: members must see why nothing is served. */++ const takedownNotice = (t) => (t ? { kind: 'error', text: `This ${t.scope} was taken down by OpenVibe staff: ${t.reason}. Nothing of it is served, and nothing can be published or deleted, until staff lift the takedown.` } : null);++ const back = (path, text, kind) => `${path}?notice=${encodeURIComponent(text)}${kind ? `&kind=${kind}` : ''}`;++++ // ── Pages ───────────────────────────────────────────────++ // What shipped on OpenVibe.Space: the shared update log every OpenVibe site has.++ router.get('/dashboard', view(async (req, res) => {++ if (req.viewer.kind !== 'user') return res.redirect(303, '/auth/login?next=%2Fdashboard');++ page(req, res, 200, 'Projects', pages.home({ projects: await projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer), canCreate: config.publicHosting || Boolean(req.viewer.staff) }), notice(req));++ }));++++ router.get('/projects/:id', view(async (req, res) => {++ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');++ const project = await projects.get(req.params.id);++ const role = await access.authorize(project, req.viewer, 'read');++ page(req, res, 200, project.name, pages.project({++ project, role, sites: await sites.listForProject(project.id), quota: await projects.quotaOf(project), usage: await projects.usageOf(project),++ members: await projects.members(project), csrf: csrfToken(config, req.viewer), siteUrl,++ }), takedownNotice(await ctx.takedowns.ofProject(project.id)) || notice(req));++ }));++++ router.get('/sites/:id', view(async (req, res) => {++ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');++ const { site, project, role } = await sites.load(req.viewer, req.params.id, 'read');++ const cfg = await siteConfig.getForSite(site.id);++ page(req, res, 200, site.name, pages.site({++ site, project, role, url: siteUrl(site), csrf: csrfToken(config, req.viewer), now: ctx.store.now(),++ deploys: await deploys.list(site.id, 50), activations: await deploys.activationsOf(site.id, 10),++ domains: (await domains.listForSite(site.id)).map((d) => ({ domain: d, instructions: domains.instructions(d, site) })),++ siteConfig: { ...cfg, headersText: siteConfig.headersToText(cfg.headers), redirectsText: siteConfig.redirectsToText(cfg.redirects) },++ source: await siteSources.forSite(site.id),++ }), takedownNotice(await ctx.takedowns.ofSite(site)) || notice(req));++ }));++++ router.get('/deploys/:id', view(async (req, res) => {++ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');++ const { deploy, site, project } = await deploys.load(req.viewer, req.params.id, 'read');++ page(req, res, 200, `Deploy ${deploy.id.slice(0, 12)}`, pages.deploy({++ deploy, site, project, active: site.active_deploy_id === deploy.id, files: await deploys.filesOf(deploy.id), log: await deploys.logOf(deploy.id),++ }), notice(req));++ }));++++ // ── Actions ─────────────────────────────────────────────++ router.post('/projects', action(async (req) => {++ const p = await 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(async (req) => {++ const project = await projects.get(req.params.id);++ await access.authorize(project, req.viewer, 'read');++ const s = await sites.create(req.viewer, project, { name: req.body.name });++ return back(`/sites/${s.id}`, `Site created: ${siteUrl(s)}`);++ }));++ router.post('/projects/:id/members', action(async (req) => {++ const project = await projects.get(req.params.id);++ await access.authorize(project, req.viewer, 'read');++ await 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(async (req) => {++ const project = await projects.get(req.params.id);++ await access.authorize(project, req.viewer, 'read');++ await projects.removeMember(req.viewer, project, String(req.body.principal || ''));++ return back(`/projects/${project.id}`, 'Member removed.');++ }));++ router.post('/projects/:id/delete', action(async (req) => {++ const project = await projects.get(req.params.id);++ await access.authorize(project, req.viewer, 'read');++ if (req.body.confirm !== 'yes') throw new ApiError(422, 'form.confirm', 'tick the confirmation box to delete');++ await projects.remove(req.viewer, project, { sites });++ return back('/dashboard', 'Project deleted.');++ }));++ router.post('/sites/:id/config', action(async (req) => {++ const { site } = await sites.load(req.viewer, req.params.id, 'maintain');++ const headers = siteConfig.parseHeadersText(req.body.headers);++ const redirects = siteConfig.parseRedirectsText(req.body.redirects);++ await siteConfig.set(req.viewer, site, { headers, redirects, spa: req.body.spa === '1' });++ return back(`/sites/${site.id}`, 'Site configuration saved.');++ }));++ // The Git source the project's CI deploys from (Host never fetches it); git deploys land as previews.++ router.post('/sites/:id/source', action(async (req) => {++ const { site } = await siteSources.put(req.viewer, req.params.id, { provider: req.body.provider || undefined, repo_url: req.body.repo_url, ref: req.body.ref });++ return back(`/sites/${site.id}`, 'Git source saved. Your CI posts each build to it as a preview.');++ }));++ router.post('/sites/:id/source/remove', action(async (req) => {++ const { site } = await siteSources.remove(req.viewer, req.params.id);++ return back(`/sites/${site.id}`, 'Git source removed.');++ }));++ router.post('/sites/:id/domains', action(async (req) => {++ const { domain } = await domains.add(req.viewer, req.params.id, { hostname: req.body.hostname });++ return back(`/sites/${domain.site_id}`, `Added ${domain.hostname}: publish the DNS records below, then check.`);++ }));++ router.post('/sites/:id/rollback', action(async (req) => {++ const r = await 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(async (req) => {++ const { site } = await 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');++ await sites.remove(req.viewer, site.id, { deploys });++ return back(`/projects/${site.project_id}`, `${site.name} deleted.`);++ }));++ router.post('/deploys/:id/activate', action(async (req) => {++ const { site } = await deploys.load(req.viewer, req.params.id, 'deploy');++ const r = await 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(async (req) => {++ const { site } = await deploys.load(req.viewer, req.params.id, 'maintain');++ await 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(async (req) => {++ const { domain } = await domains.load(req.viewer, req.params.id, 'maintain');++ await 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) => {++ let release = null;++ try {++ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');++ if (!sameOrigin(config, req)) throw new ApiError(403, 'form.invalid', 'this form did not come from this dashboard');++ const pre = await deploys.precheck(req.viewer, req.params.id);++ release = ctx.uploadGate.enter();++ if (!release) { res.set('Retry-After', '30'); throw new ApiError(503, 'upload.busy', 'Space is validating other uploads right now; try again in 30 seconds'); }++ let up;++ try {++ up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });++ } 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 = await 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 {++ // The form's choice: activate it, keep it ready, or make it the site's private preview.++ const mode = ['activate', 'ready', 'preview'].includes(up.fields.mode) ? up.fields.mode : (up.fields.activate === '1' ? 'activate' : 'ready');++ const r = await deploys.create(req.viewer, pre, up.entries, { source: up.source, activate: mode === 'activate', preview: mode === 'preview', notes: up.notes, traceparent: req.ov && req.ov.traceparent });++ const done = r.preview ? 'Deployed as a preview: open it from the site page. Only members of this project can see it.'++ : r.activated && r.activated.changed ? 'Deployed and activated.' : 'Deployed. Activate it from the site page.';++ return res.redirect(303, back(`/deploys/${r.deploy.id}`, done));++ } catch (err) {++ if (err instanceof ApiError && err.extra && err.extra.deploy_id) return res.redirect(303, back(`/deploys/${err.extra.deploy_id}`, `Upload refused: ${err.message}`, 'error'));++ throw err;++ }++ } catch (err) {++ if (!req.complete) res.set('Connection', 'close');++ fail(req, res, err);++ } finally {++ if (release) release();++ }++ });++++ return router;++}++++module.exports = { createDashboard };nnew file mode 100644@@@ -0,0 +1,61 @@++'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');++const cache = require('openvibe-shared/cache-policy');++++/** 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('[Space 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', cache.htmlHeaders({ private: true }));++ res.vary('Cookie');++ res.vary('Authorization');++}++++module.exports = { ApiError, sendError, setLogger, run, jsonBody, privateNoStore };nnew file mode 100644@@@ -0,0 +1,115 @@++'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());++++/** A staff takedown as members see it (domain/takedowns.js), or null. */++function takedownOut(t) {++ return t ? { scope: t.scope, reason: t.reason, since: iso(t.since) } : null;++}++++function project(p, { role = null, quota = null, usage = null, takedown = null } = {}) {++ return {++ id: p.id,++ name: p.name,++ 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),++ takedown: takedownOut(takedown),++ ...(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, takedown = null }) {++ return {++ id: s.id,++ project_id: s.project_id,++ name: s.name,++ hostname,++ url,++ active_deploy_id: s.active_deploy_id || null,++ takedown: takedownOut(takedown),++ created_at: iso(s.created_at),++ updated_at: iso(s.updated_at),++ };++}++++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),++ ...(d.source === 'git' && d.git_commit_sha ? { git: { provider: d.git_provider, repo_url: d.git_repo_url, ref: d.git_ref, commit_sha: d.git_commit_sha } } : {}),++ ...(files ? { files: files.map((f) => ({ path: f.path, sha256: f.sha256, size: f.size, content_type: f.content_type })) } : {}),++ ...(log ? { log: log.map(logLine) } : {}),++ };++}++++/** A site's Git source (domain/site-sources.js): public provenance only, never a credential. */++function source(src) {++ if (!src) return null;++ return {++ site_id: src.site_id,++ provider: src.provider,++ repo_url: src.repo_url,++ ref: src.ref,++ created_by: src.created_by || null,++ created_at: iso(src.created_at),++ updated_by: src.updated_by || null,++ updated_at: iso(src.updated_at),++ };++}++++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, takedownOut, quotaOut, usageOut, site, deploy, source, logLine, domain, activation, iso };nnew file mode 100644@@@ -0,0 +1,436 @@++'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 (space_deploy_files). The file's++ * bytes come from the blob store keyed by the project id and the manifest's hash — the local disk++ * cache, or, with SPACE_OBJECT_STORE=media, the object fetched back from OpenVibe.Media and verified++ * (server/storage.js). The key is never built 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.++ * A site that staff took down (domain/takedowns.js) answers 451 with none of its content.++ * A deploy that ships no sitemap.xml or robots.txt gets a generated one (the manifest's HTML pages on++ * the host the request came in on, so a verified custom domain gets its own; a sandbox site gets++ * `Disallow: /`). A file the tenant uploaded at either path is served instead.++ *++ * A site's own configuration (domain/site-config.js) adds its response headers, its local-only++ * redirects and an optional SPA fallback (extensionless paths serve index.html). The headers are++ * applied BEFORE the platform's own, and a reserved header (CSP, HSTS, Set-Cookie, X-Forwarded-*, …)++ * is never applied even if a row holds one, so a tenant can never weaken or spoof them.++ *++ * preview() serves a site's ONE live preview deploy (space_sites.preview_deploy_id) on the dashboard++ * host only, to a member of its project, at /preview/<deploy-id>/… — always noindex, never cached by++ * a shared cache, and never announced to a search engine (plan T12 J4, decision D5).++ */++const fs = require('fs');++const { sitemapXml, robotsTxt } = require('openvibe-shared/seo');++const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');++const { isHostname } = require('../domain/domains');++const { isReservedHeader } = require('../domain/site-config');++const { isId } = require('../ids');++++const TENANT_CSP = [++ "default-src 'self'",++ "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]']);++++/**++ * A preview is tenant content served from the dashboard's OWN origin, so — unlike tenant sites, which++ * live on a separate registrable domain — it must be stopped from acting as openvibe.space. Its CSP++ * sandboxes the document into an opaque origin (there is deliberately no `allow-same-origin`), so++ * every tenant script, style and asset it loads runs without access to the dashboard's cookies,++ * storage or DOM (the "active content runs only on the tenant's own origin" control, docs/threat-review.md §5).++ */++function previewCsp(origin) {++ return [++ "default-src 'none'",++ `script-src ${origin}`,++ `style-src ${origin} 'unsafe-inline'`,++ `img-src ${origin} data: https:`,++ `font-src ${origin} data:`,++ `media-src ${origin} https:`,++ `connect-src ${origin}`,++ "object-src 'none'",++ "base-uri 'none'",++ "form-action 'none'",++ "frame-ancestors 'none'",++ 'sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',++ ].join('; ');++}++++/** Host header → lowercase host name without port or trailing dot; null when it is not one. */++function normaliseHost(raw) {++ if (typeof raw !== 'string' || !raw || raw.length > 260) return null;++ 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.Space');++ 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);++}++++/** Apply a site's configured headers, never a reserved one (defense in depth: they are refused on write too). */++function applyConfiguredHeaders(res, headers) {++ for (const [name, value] of Object.entries(headers || {})) {++ if (isReservedHeader(name)) continue;++ try { res.setHeader(name, value); } catch { /* a name or value Node refuses is simply not set */ }++ }++}++++function createTenantServer({ store, config, blobs, takedowns = null, siteConfig = null, access = null, log = console }) {++ const { db } = store;++ const q = {++ siteByName: db.prepare("SELECT s.*, p.environment FROM space_sites s JOIN space_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 space_domains d JOIN space_sites s ON s.id = d.site_id JOIN space_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 space_deploy_files f JOIN space_deploys d ON d.id = f.deploy_id++ WHERE f.deploy_id = ? AND f.path = ? AND d.state = 'ready'`),++ htmlFiles: db.prepare("SELECT path FROM space_deploy_files WHERE deploy_id = ? AND content_type LIKE 'text/html%' ORDER BY path"),++ deployById: db.prepare('SELECT id, site_id, project_id, state FROM space_deploys WHERE id = ?'),++ siteById: db.prepare('SELECT * FROM space_sites WHERE id = ?'),++ projectById: db.prepare('SELECT id, status, environment FROM space_projects WHERE id = ?'),++ };++ const suffix = `.${config.sitesDomain}`;++ let previewOrigin = "'self'";++ try { previewOrigin = new URL(config.baseUrl).origin; } catch { /* keep 'self' */ }++ const PREVIEW_CSP = previewCsp(previewOrigin);++++ /**++ * -> { kind: 'dashboard' } | { kind: 'site', site } | { kind: 'unknown', host }++ */++ async 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 = await q.siteByName.get(label);++ return site ? { kind: 'site', site, host } : { kind: 'unknown', host };++ }++ if (!isHostname(host)) return { kind: 'unknown', host };++ const site = await 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 };++ }++++ async function send(req, res, site, row, { status = 200, cache, deployId = site.active_deploy_id }) {++ const etag = `"${row.sha256}"`;++ res.setHeader('ETag', etag);++ res.setHeader('Content-Type', row.content_type);++ res.setHeader('Cache-Control', cache);++ res.setHeader('Accept-Ranges', 'bytes');++ res.setHeader('X-OpenVibe-Deploy', deployId);++ const inm = req.headers['if-none-match'];++ if (status === 200 && inm && inm.split(',').map((s) => s.trim().replace(/^W\//, '')).includes(etag)) {++ res.statusCode = 304;++ 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;++ }++ const length = row.size === 0 ? 0 : end - start + 1;++ if (req.method === 'HEAD' || row.size === 0) { res.setHeader('Content-Length', length); return res.end(); }++ // The local cache, or (Media store) the object fetched from Media, its sha256 verified, and++ // cached. A miss that cannot be satisfied is a 500: bytes of the wrong content are never served.++ let file;++ try {++ file = await blobs.ensure(row.project_id, row.sha256);++ } catch (err) {++ log.error('[Space] object missing or unreadable:', row.project_id, row.sha256, err.code || err.message);++ if (!res.headersSent) { res.removeHeader('Content-Range'); return plain(res, 500, 'This file could not be read. Try again later.'); }++ return res.destroy();++ }++ res.setHeader('Content-Length', length);++ const stream = fs.createReadStream(file, { start, end });++ stream.on('error', (err) => {++ log.error('[Space] 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);++ }++++ /** The absolute origin for the host this request arrived on (tenant hosts are https in production). */++ function originOf(req) {++ const host = normaliseHost(req.headers.host);++ if (!host) return null;++ let proto = 'https';++ try { proto = new URL(config.baseUrl).protocol.replace(':', '') || 'https'; } catch { /* default */ }++ return `${proto}://${host}`;++ }++++ /**++ * A manifest HTML path as the absolute-path URL a crawler should use: the error page is left out,++ * `index.html` becomes the directory URL, and everything else keeps its manifest path.++ */++ function pagePath(p) {++ if (!/\.html?$/i.test(p)) return null;++ if (/(^|\/)404\.html?$/i.test(p)) return null;++ const stem = p.replace(/\.html?$/i, '');++ if (stem === 'index') return '/';++ if (stem.endsWith('/index')) return `/${stem.slice(0, -'index'.length)}`;++ return `/${p}`;++ }++++ /**++ * A sitemap the deploy did not ship: the manifest's HTML pages on the host this request came in on.++ * Served per host (a sitemap may only list URLs of one host), so a verified custom domain gets its++ * own copy under its own name.++ */++ async function generatedSitemap(req, res, site) {++ const origin = originOf(req);++ if (!origin) return await notFound(req, res, site, 'invalid');++ const locs = [];++ const seen = new Set();++ for (const row of await q.htmlFiles.all(site.active_deploy_id)) {++ const p = pagePath(row.path);++ if (!p) continue;++ const loc = `${origin}${p}`;++ if (seen.has(loc)) continue;++ seen.add(loc);++ locs.push({ loc });++ }++ const body = sitemapXml(locs);++ res.statusCode = 200;++ res.setHeader('Content-Type', 'application/xml; charset=utf-8');++ res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');++ res.setHeader('Content-Length', Buffer.byteLength(body));++ return req.method === 'HEAD' ? res.end() : res.end(body);++ }++++ /** A robots.txt the deploy did not ship: welcome crawlers to a production site, keep a sandbox out. */++ async function generatedRobots(req, res, site) {++ const origin = originOf(req);++ if (!origin) return await notFound(req, res, site, 'invalid');++ const body = site.environment === 'sandbox'++ ? robotsTxt({ disallow: ['/'], allow: [], allowAI: false })++ : robotsTxt({ sitemaps: [`${origin}/sitemap.xml`] });++ res.statusCode = 200;++ res.setHeader('Content-Type', 'text/plain; charset=utf-8');++ res.setHeader('Cache-Control', 'public, max-age=3600');++ res.setHeader('Content-Length', Buffer.byteLength(body));++ return req.method === 'HEAD' ? res.end() : res.end(body);++ }++++ async function notFound(req, res, site, reason) {++ const page = site.active_deploy_id ? await q.file.get(site.active_deploy_id, '404.html') : null;++ if (page && page.project_id === site.project_id) return send(req, res, site, page, { status: 404, cache: 'no-cache' });++ 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.Space</small></body></html>`;++ if (req.method === 'HEAD') return res.end();++ return res.end(body);++ }++++ /** An extensionless path (`/app/route`, `/a-b-c`, `/`) — the only kind an SPA fallback rewrites. */++ function isExtensionlessPath(rawPath) {++ if (rawPath === '/' || rawPath === '') return true;++ const p = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath;++ const last = p.slice(p.lastIndexOf('/') + 1);++ return !last.includes('.');++ }++++ /** Serves one request for a resolved site. */++ async function handle(req, res, site) {++ const cfg = siteConfig ? await siteConfig.getForSite(site.id) : null;++ // The site's own headers go on first; baseHeaders then overrides every reserved one, so a++ // tenant can never weaken CSP/HSTS/nosniff or set a cookie.++ if (cfg) applyConfiguredHeaders(res, cfg.headers);++ baseHeaders(res, { sandbox: site.environment === 'sandbox' });++ if (req.method !== 'GET' && req.method !== 'HEAD') {++ return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });++ }++ // Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.++ if (takedowns && await takedowns.ofSite(site)) {++ // Clear-Site-Data asks the browser to drop what the site left behind for this visitor++ // (caches, storage and service workers a phishing page may have installed).++ return plain(res, 451, 'This site is unavailable: OpenVibe.Space staff took it down after a report.', { 'Clear-Site-Data': '"cache", "storage"' });++ }++ // 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) : '';++ // A configured redirect: an exact path to a local path (validated on write, never an open redirect).++ // It does not need an active deploy, and it is checked before anything is read from one.++ if (cfg && cfg.redirects.length) {++ const r = cfg.redirects.find((x) => x.from === rawPath);++ if (r) {++ res.statusCode = r.status;++ res.setHeader('Location', r.to);++ res.setHeader('Cache-Control', 'no-cache');++ return res.end();++ }++ }++ if (!site.active_deploy_id) return await notFound(req, res, site, 'no-deploy');++ const c = candidates(rawPath);++ if (!c) return await notFound(req, res, site, 'invalid');++ const deployId = site.active_deploy_id;++ for (const p of c.list) {++ const row = await q.file.get(deployId, p);++ if (row && row.project_id === site.project_id) {++ const hashed = isHashedAsset(p);++ // Browsers keep fingerprinted assets for a year; a shared CDN in front of the tenant++ // vhost (Cloudflare) keeps them for an hour at most, so a takedown, deletion or++ // rollback leaves the edge within the hour even if nobody purges it.++ if (hashed) res.setHeader('CDN-Cache-Control', 'public, max-age=3600');++ return send(req, res, site, row, { cache: hashed ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate' });++ }++ }++ if (!c.dir && c.dirIndex && await 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();++ }++ // The deploy ships no sitemap.xml / robots.txt of its own: generate them (the tenant's own file,++ // looked up above, always wins). IndexNow's ping of /sitemap.xml now has a real target.++ if (rawPath === '/sitemap.xml') return await generatedSitemap(req, res, site);++ if (rawPath === '/robots.txt') return await generatedRobots(req, res, site);++ // SPA fallback: an extensionless path with no file of its own serves this site's index.html++ // (a client-side route). A missing file with an extension is still a 404.++ if (cfg && cfg.spa && isExtensionlessPath(rawPath)) {++ const row = await q.file.get(deployId, 'index.html');++ if (row && row.project_id === site.project_id) return send(req, res, site, row, { cache: 'public, max-age=0, must-revalidate' });++ }++ return await notFound(req, res, site, 'missing');++ }++++ function unknownHost(req, res) {++ return plain(res, 404, 'Unknown host: no site is served at this address.');++ }++++ /**++ * GET/HEAD /preview/<deploy-id>/<path> — one deploy of a site, served ONLY to a member of its++ * project and ONLY on the dashboard host (the dispatch in server/app.js never sends a tenant host++ * here, so a preview can never appear at <site>.<sitesDomain>). It serves exactly the deploy the++ * site currently points at as its live preview (space_sites.preview_deploy_id) while that preview++ * has not expired; once it expires, the site deploys or rolls back, or the preview is deleted, the++ * address is a plain 404. Every response is noindex, no-store and never announced to a search++ * engine, and the bytes come from the deploy's own project and manifest — never from the URL —++ * exactly as in handle().++ */++ async function preview(req, res) {++ // Never the dashboard's own CSP: the preview is sandboxed into an opaque origin (see previewCsp).++ res.setHeader('Content-Security-Policy', PREVIEW_CSP);++ res.setHeader('X-Robots-Tag', 'noindex, nofollow');++ res.setHeader('Cache-Control', 'private, no-store');++ const miss = () => plain(res, 404, 'No preview is served at this address.');++ const viewer = req.viewer;++ if (!access || !viewer || viewer.kind === 'anonymous') return miss();++ if (req.method !== 'GET' && req.method !== 'HEAD') return plain(res, 405, 'Static preview: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });++ const raw = req.originalUrl || req.url || '';++ const cut = raw.indexOf('?');++ const m = /^\/preview\/([^/?#]+)(\/[^?#]*)?$/.exec(cut >= 0 ? raw.slice(0, cut) : raw);++ if (!m || !isId('deploy', m[1])) return miss();++ const deploy = await q.deployById.get(m[1]);++ if (!deploy || deploy.state !== 'ready') return miss();++ const site = await q.siteById.get(deploy.site_id);++ if (!site || site.status !== 'active' || site.project_id !== deploy.project_id) return miss();++ // Only the site's ONE live preview is served, and only until it expires.++ if (site.preview_deploy_id !== deploy.id || !site.preview_expires_at || site.preview_expires_at <= store.now()) return miss();++ const project = await q.projectById.get(deploy.project_id);++ try { await access.authorize(project, viewer, 'read'); } catch { return miss(); }++ if (takedowns && await takedowns.ofSite(site)) {++ return plain(res, 451, 'This site is unavailable: OpenVibe.Space staff took it down after a report.', { 'Clear-Site-Data': '"cache", "storage"' });++ }++ const c = candidates(m[2] || '/');++ if (!c) return miss();++ for (const p of c.list) {++ const row = await q.file.get(deploy.id, p);++ if (row && row.project_id === deploy.project_id) {++ return send(req, res, site, row, { cache: 'private, no-store, max-age=0', deployId: deploy.id });++ }++ }++ return miss();++ }++++ return { resolve, handle, preview, unknownHost, normaliseHost, candidates, TENANT_CSP };++}++++module.exports = { createTenantServer, normaliseHost, TENANT_CSP };nnew file mode 100644@@@ -0,0 +1,197 @@++'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)++ * git deploys (POST /sites/:id/source/deploys) also: ref=<branch>, commit_sha=<full commit id>++ *++ * 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', 'preview', 'mode', 'root', 'strip', 'csrf', 'expected_active', 'ref', 'commit_sha']);++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, maxUnpackedBytes = Infinity, 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 = await readArchive(rawArchive, { ...limits, maxTotalBytes: Math.min(limits.maxTotalBytes, maxUnpackedBytes) }); } 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 };++}++++/**++ * At most `max` uploads are read and validated at once, service-wide. An upload is held in memory++ * (up to SPACE_MAX_UPLOAD_BYTES, plus up to SPACE_MAX_UNPACKED_BYTES once unpacked), so without a cap++ * a handful of accounts uploading together could exhaust the host's memory. enter() -> release()++ * or null when every slot is taken (the caller answers 503 with Retry-After before reading a byte).++ */++function createUploadGate(max) {++ const limit = Math.max(1, Number(max) || 1);++ let inFlight = 0;++ return {++ enter() {++ if (inFlight >= limit) return null;++ inFlight++;++ let released = false;++ return () => { if (!released) { released = true; inFlight--; } };++ },++ get inFlight() { return inFlight; },++ limit,++ };++}++++module.exports = { readUpload, UploadError, createUploadGate };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)++ * sit_ site dpl_ deploy dom_ domain++ */++const { ids } = require('openvibe-contracts');++++const PREFIX = { project: 'prj', site: 'sit', 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 };@@@ -8,15 +8,16 @@ const config = require('./config');const { createApp } = require('./app');(async () => {-- // PostgreSQL first (migrations run as the owner). Space holds no user content yet, but the-- // database carries the shared schema and the readiness check reports it.++ // PostgreSQL first (migrations run as the owner); readiness reports the serving database.await require('./db').initDb(config);const app = await createApp();const server = app.listen(config.port, config.host, () => {console.log(`[Space] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);-- console.log(`[Space] code and dynamic pages (nothing published here yet); the forum is on ${config.communityUrl}; identity via ${config.networkUrl}`);++ console.log(`[Space] static hosting for staff; dynamic pages and spaces planned; the forum is on ${config.communityUrl}; identity via ${config.networkUrl}`);});server.keepAliveTimeout = 65_000;++ app.locals.hosting.worker.start();++ app.locals.hosting.outbox.start();// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────// SIGTERM: the server stops taking connections, closes idle keep-alive ones (it keeps them 65 s@@@ -26,7 +27,7 @@ const { createApp } = require('./app');gracefulStop({name: 'Space', server,drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,-- stop: [],++ stop: [async () => { app.locals.hosting.worker.stop(); await app.locals.hosting.outbox.stop(); }],close: [async () => {console.log('[Space] stopped');nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++/**++ * GET /limits.json (roadmap WS-N task 7): the limits a developer project meets here, read from the++ * running configuration, so OpenVibe.Codes' limits page (openvibe.codes/docs/limits) shows what is++ * enforced and never restates it. Public: these are the defaults; a project's own override (a++ * space_quotas row staff set) is on its quota page, not here.++ *++ * limits[] id, label, capability (the grant it bounds), unit (count | bytes | per_day),++ * production and sandbox (the project's environment; 0 means none allowed),++ * exceeded (what the caller gets past it)++ */++function limitsOf(config) {++ const p = config.quotas.production;++ const s = config.quotas.sandbox;++ const row = (id, label, key, unit, exceeded) => ({ id, label, capability: 'space.site.manage', unit, production: p[key], sandbox: s[key], exceeded });++ return {++ service: 'space',++ scope: 'per project and environment; projects per owner',++ limits: [++ { id: 'projects_per_owner', label: 'Projects one person owns', capability: 'space.site.manage', unit: 'count',++ production: config.projects.maxPerOwner, sandbox: config.projects.maxPerOwner, exceeded: '429 quota.projects' },++ row('sites', 'Sites', 'sites', 'count', '429 quota.sites'),++ row('custom_domains', 'Custom domains', 'customDomains', 'count', '429 quota.custom_domains'),++ row('deploys_per_day', 'Deploys in 24 hours', 'deploysPerDay', 'per_day', '429 quota.deploys_per_day'),++ row('storage_bytes', 'Stored bytes (every deploy kept)', 'storageBytes', 'bytes', '413 quota.storage'),++ row('max_files', 'Files in one deploy', 'maxFiles', 'count', '413 quota.max_files'),++ row('max_file_bytes', 'Size of one file', 'maxFileBytes', 'bytes', '413 quota.max_file_bytes'),++ ],++ };++}++++function mountLimits(app, config) {++ const body = limitsOf(config);++ app.get('/limits.json', (_req, res) => {++ res.set('Cache-Control', 'public, max-age=300').set('Access-Control-Allow-Origin', '*').json(body);++ });++}++++module.exports = { limitsOf, mountLimits };@@@ -6,12 +6,17 @@* network_jwks optional the Network signing key has loaded. Without it the home page and the* discovery files still work, but nobody can sign in, so it degrades* rather than fails++ * storage required the hosting object store directory is writable (uploads and serving need it)++ * disk_headroom optional free space above SPACE_MIN_FREE_BYTES; below it only new deploys are refused++ * valkey optional per-actor limits are shared across processes++ * 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** Request metrics come from openvibe-shared/metrics in app.js.*/const { createReadiness } = require('openvibe-shared/ready');--function createSpaceReadiness({ db, auth, config, release = null } = {}) {++function createSpaceReadiness({ db, auth, config, release = null, hosting = null } = {}) {const checks = [{name: 'db', required: true,@@@ -32,7 +37,41 @@ function createSpaceReadiness({ db, auth, config, release = null } = {}) {},},];++ if (hosting) checks.push(...hostingChecks(hosting));return createReadiness({ service: 'space', release, checks });}++/** The hosting engine's checks (moved from OpenVibe.Host with the engine, T20). */++function hostingChecks({ blobs, outbox, store, config, valkey }) {++ return [++ { name: 'storage', required: true, check: () => blobs.writable() },++ {++ // Serving goes on; only new deploys are refused below the floor.++ name: 'disk_headroom', required: false,++ check: () => {++ const free = blobs.freeBytes();++ const floor = config.uploads.minFreeBytes;++ return free >= floor ? { ok: true, detail: { free_bytes: free } } : `${free} bytes free, below SPACE_MIN_FREE_BYTES (${floor}): new deploys are refused`;++ },++ },++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },++ {++ name: 'events_relay', required: false,++ check: async () => {++ const s = await 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: async () => {++ const stale = (await store.db.prepare("SELECT COUNT(*) AS n FROM space_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 = { createSpaceReadiness };nnew file mode 100644@@@ -0,0 +1,205 @@++'use strict';++/**++ * Host's authority resource index (ADR-048 section 3; capability space.resource.read):++ *++ * GET /api/v1/resources[?project=&kind=&owner=&cursor=&limit=] → common.resource-list-result@1++ * GET /api/v1/resources/:ovrn → common.resource-summary@1++ *++ * It pages the resources OpenVibe.Space owns — its tenant sites (sit_), the immutable deploys of those++ * sites (dpl_) and their default and custom domains (dom_), each from its own table — as++ * common.resource-summary@1, the shape OpenVibe.Services fans out over and merges++ * (openvibe-sdk/resources' createResourceIndex). It is the same shape and the same path++ * OpenVibe.Network's index answers (server/registry/resource-index.js there).++ *++ * Host lists no projects: Host mints its own prj_ ids until it adopts Network projects (ADR-014), and++ * only Network lists projects (ADR-048, step 8). Every summary still carries the project_id of the++ * project that owns it, which is the tenancy boundary.++ *++ * Tenancy: `?project=prj_…` is the caller's tenancy boundary. With it, only that project's resources++ * answer — a resource of another project is never returned. Without it the first-party caller (the++ * capability is first-party, resourceConstraints none) sees every Host-owned resource, which is what an++ * authority-wide fan-out needs.++ *++ * OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one++ * formatter, so it is present exactly when the resource is a nameable resource. All three Host kinds++ * are: sit_, dpl_ and dom_ carry a three-letter prefix and the resource carries a project, so every++ * summary is named ovrn:host:<prj_…>:<type>/<id> — and that is also what GET /api/v1/resources/:ovrn++ * can read: only a resource whose computed ovrn equals the one asked for answers.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++// Host's JSON handler wrapper: a rejected handler reaches the app's error handler, and these handlers++// write their own response. Express 4 does not catch a rejected async handler.++const { run } = require('../http/errors');++++const SERVICE = 'space';++const SITE_KIND = 'space.site';++const DEPLOY_KIND = 'space.deploy';++const DOMAIN_KIND = 'space.domain';++const KINDS = [SITE_KIND, DEPLOY_KIND, DOMAIN_KIND];++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const OWNER_ID_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const DEFAULT_LIMIT = 100;++const MAX_LIMIT = 1000;++++/** A subject-ref {type:'user', id} for a stored subject, or null when it is not a usr_ id. */++const userRef = (subject) => (USER_SUBJECT_RE.test(String(subject || '')) ? { type: 'user', id: subject } : null);++++/** common.resource-summary@1 for a space_sites row. A site is named by the label it is served at. */++function siteSummary(s) {++ return {++ id: s.id, kind: SITE_KIND, service: SERVICE, project_id: s.project_id,++ ...(userRef(s.created_by) ? { owner: userRef(s.created_by) } : {}),++ name: s.name, state: s.status, created_at: new Date(Number(s.created_at)).toISOString(),++ };++}++++/** common.resource-summary@1 for a space_deploys row. A deploy has no name of its own. */++function deploySummary(d) {++ return {++ id: d.id, kind: DEPLOY_KIND, service: SERVICE, project_id: d.project_id,++ ...(userRef(d.created_by) ? { owner: userRef(d.created_by) } : {}),++ state: d.state, created_at: new Date(Number(d.created_at)).toISOString(),++ };++}++++/** common.resource-summary@1 for a space_domains row: a domain's name is the hostname it answers on. */++function domainSummary(d) {++ return {++ id: d.id, kind: DOMAIN_KIND, service: SERVICE, project_id: d.project_id,++ ...(userRef(d.created_by) ? { owner: userRef(d.created_by) } : {}),++ name: d.hostname, state: d.status, created_at: new Date(Number(d.created_at)).toISOString(),++ };++}++++/** The summary's ovrn, or null when it has none: contracts.resources.nameOf is the one formatter. */++function ovrnOf(summary) {++ return contracts.resources.nameOf(summary);++}++++/** The summary with its ovrn attached when it has one. */++function named(summary) {++ const ovrn = ovrnOf(summary);++ return ovrn ? { ...summary, ovrn } : summary;++}++++/** A stable (kind, id) ordering, so a cursor can be a position in it. */++const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);++++/** The summaries matching the project, kind and owner filters, sorted by (kind, id). */++// Scope: space.resource.read is first-party (resourceConstraints none), so its holder sees every project and++// ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that++// principal's grants here instead of trusting the query.++async function collect(db, { project = null, kind = null, owner = null } = {}) {++ // Only usr_ subjects become summary owners; an agt_ subject cannot match any Host summary.++ if (owner && !USER_SUBJECT_RE.test(owner)) return [];++ const clauses = [];++ const values = [];++ if (project) { clauses.push('project_id = ?'); values.push(project); }++ if (owner) { clauses.push('created_by = ?'); values.push(owner); }++ const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';++ const out = [];++ if (!kind || kind === SITE_KIND) {++ const rows = await db.prepare(`SELECT * FROM space_sites${where} ORDER BY id`).all(...values);++ for (const r of rows) out.push(named(siteSummary(r)));++ }++ if (!kind || kind === DEPLOY_KIND) {++ const rows = await db.prepare(`SELECT * FROM space_deploys${where} ORDER BY id`).all(...values);++ for (const r of rows) out.push(named(deploySummary(r)));++ }++ if (!kind || kind === DOMAIN_KIND) {++ const rows = await db.prepare(`SELECT * FROM space_domains${where} ORDER BY id`).all(...values);++ for (const r of rows) out.push(named(domainSummary(r)));++ }++ return out.sort(order);++}++++/** A cursor is an opaque base64url [kind, id] position; only one this index issued decodes to that. */++const encodeCursor = (s) => Buffer.from(JSON.stringify([s.kind, s.id])).toString('base64url');++function decodeCursor(raw) {++ let v;++ try { v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }++ return Array.isArray(v) && v.length === 2 && typeof v[0] === 'string' && typeof v[1] === 'string' ? v : null;++}++const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id > id);++++/** The query as filters, or { error } for a value that cannot be honoured. An unknown kind is kept (it matches nothing). */++function filtersOf(query) {++ const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;++ if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };++ const owner = typeof query.owner === 'string' && query.owner !== '' ? query.owner : null;++ if (query.owner != null && query.owner !== '' && (!owner || !OWNER_ID_RE.test(owner))) return { error: 'owner must be a usr_ or agt_ id' };++ const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;++ let limit = DEFAULT_LIMIT;++ if (typeof query.limit === 'string' && query.limit !== '') {++ if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };++ limit = Number(query.limit);++ }++ let cursor = null;++ if (typeof query.cursor === 'string' && query.cursor !== '') {++ cursor = decodeCursor(query.cursor);++ if (!cursor) return { error: 'cursor is not one this index issued' };++ }++ return { project, kind, owner, limit, cursor };++}++++/** The OVRN type segment → the table and summary it belongs to. */++const OF_TYPE = {++ site: { table: 'space_sites', summary: siteSummary },++ deploy: { table: 'space_deploys', summary: deploySummary },++ domain: { table: 'space_domains', summary: domainSummary },++};++++function router({ guard }) {++ const open = (res) => res.set('Cache-Control', 'private, max-age=60');++ const bad = (res, detail) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail });++ const unknown = (res, name) => contracts.http.sendProblem(res, 404, 'resources.unknown_resource', { detail: `no resource named ${name}` });++++ /**++ * The resource index is read by first-party services for OpenVibe.Services' fan-out; a person holds++ * no service token and the capability is first-party, so only a service token reaches the guard.++ */++ function firstParty(req, res, next) {++ const v = req.viewer;++ if (v && v.kind === 'service') return next();++ if (!v || v.kind === 'anonymous') return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'the resource index is read by first-party services: present a service token for audience openvibe.space', ctx: req.ov });++ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `${SERVICE}.resource.read is first-party and not granted to a person`, ctx: req.ov });++ }++++ /** One common.resource-list-result@1 page: the filtered, sorted summaries from the cursor, then `limit` of them. */++ async function page(req, res) {++ const f = filtersOf(req.query);++ if (f.error) return bad(res, f.error);++ const all = await collect(req.app.locals.ctx.store.db, f);++ const rest = f.cursor ? all.filter((s) => afterCursor(s, f.cursor)) : all;++ const resources = rest.slice(0, f.limit);++ const next_cursor = rest.length > f.limit ? encodeCursor(resources[resources.length - 1]) : null;++ open(res).json({ resources, next_cursor });++ }++++ /** GET /api/v1/resources/:ovrn: the summary whose computed ovrn is exactly the one asked for. */++ async function one(req, res) {++ const name = String(req.params.ovrn);++ const parsed = contracts.resources.parse(name);++ let summary = null;++ if (parsed && parsed.service === SERVICE && OF_TYPE[parsed.type]) {++ const db = req.app.locals.ctx.store.db;++ const row = await db.prepare(`SELECT * FROM ${OF_TYPE[parsed.type].table} WHERE id = ?`).get(parsed.id);++ if (row) summary = named(OF_TYPE[parsed.type].summary(row));++ }++ if (!summary || summary.ovrn !== name) return unknown(res, name);++ open(res).json(summary);++ }++++ const r = express.Router();++ r.use(firstParty);++ r.get('/', guard, run(page));++ r.get('/:ovrn', guard, run(one));++ return r;++}++++module.exports = {++ router, SERVICE, KINDS, SITE_KIND, DEPLOY_KIND, DOMAIN_KIND, DEFAULT_LIMIT, MAX_LIMIT,++ siteSummary, deploySummary, domainSummary, ovrnOf, collect, encodeCursor, filtersOf,++};nnew file mode 100644@@@ -0,0 +1,7 @@++'use strict';++const { renderPage: spacePage, escapeHtml: esc } = require('./layout');++function renderPage(o) {++ const notice = o.notice ? `<p role="status" class="hd-flash hd-flash--${o.notice.kind === 'error' ? 'bad' : 'ok'}">${esc(o.notice.text)}</p>` : '';++ return spacePage({ title: o.title || 'Hosting', canonicalPath: o.path || '/dashboard', robots: o.indexable ? 'index,follow' : 'noindex,nofollow', body: `${notice}${o.body || ''}`, styles: o.styles || [], localStyles: ['css/hosting.css'], bodyClass: 'hosting-dashboard' });++}++module.exports = { renderPage, esc };nnew file mode 100644@@@ -0,0 +1,171 @@++'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. Styles: public/css/hosting.css (hd-*), loaded++ * only on these pages.++ */++const { esc } = require('./hosting-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));++const icon = (name) => `<i class="fa-solid ${name}" aria-hidden="true"></i>`;++const badge = (text, tone = 'neutral') => `<span class="hd-badge hd-badge--${tone}">${esc(text)}</span>`;++const STATE_TONE = { ready: 'ok', failed: 'bad', receiving: 'warn', processing: 'warn' };++++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="hd-confirm"><input type="checkbox" name="confirm" value="yes" required> ${esc(confirm)}</label>` : ''}</form>`;++}++++function crumbs(list) {++ return `<nav class="hd-crumbs" aria-label="Breadcrumb">${list.map(([href, label]) => `<a href="${esc(href)}">${esc(label)}</a>`).join('<span aria-hidden="true">›</span>')}</nav>`;++}++++function card(title, inner, { sub = '', actions = '', cls = '', id = '' } = {}) {++ return `<section class="hd-card${cls ? ` ${cls}` : ''}"${id ? ` id="${esc(id)}"` : ''}>`++ + `<header class="hd-card__head"><div><h2>${title}</h2>${sub ? `<p class="hd-muted">${sub}</p>` : ''}</div>${actions ? `<div class="hd-card__actions">${actions}</div>` : ''}</header>`++ + `${inner}</section>`;++}++++function home({ projects, csrf, canCreate = true }) {++ const cards = projects.map((p) => `<a class="hd-tile" href="/projects/${esc(p.id)}">++<span class="hd-tile__icon">${icon('fa-folder-open')}</span>++<span class="hd-tile__body"><strong>${esc(p.name)}</strong><span class="hd-tile__meta">${badge(p.environment, p.environment === 'sandbox' ? 'warn' : 'accent')}${p.role ? ` ${badge(p.role)}` : ''}</span><span class="hd-muted">Created ${when(p.created_at)}</span></span>++<span class="hd-tile__go" aria-hidden="true">${icon('fa-arrow-right')}</span></a>`).join('');++ const create = !canCreate++ ? `<div class="hd-callout">${icon('fa-lock')}<p>OpenVibe.Space is not open for hosting yet. It opens after a launch review (abuse handling and a designated DMCA agent first); until then only OpenVibe staff can create projects.</p></div>`++ : form('/projects', csrf, `<div class="hd-fields">++<label class="hd-field">Name <input name="name" required maxlength="80" placeholder="My website"></label>++<label class="hd-field">Environment <select name="environment"><option value="production">production</option><option value="sandbox">sandbox (smaller quotas, noindex, no custom domains)</option></select></label>++</div><button type="submit" class="hd-btn hd-btn--primary">${icon('fa-plus')} Create project</button>`, { cls: 'hd-form' });++ return `<header class="hd-head"><p class="hd-eyebrow">${icon('fa-globe')} Static hosting</p><h1>Your projects</h1>++<p class="hd-sub">A project holds sites, their deploys and domains, and the people who may change them.</p></header>++${projects.length ? `<div class="hd-tiles">${cards}</div>` : `<div class="hd-empty">${icon('fa-folder-plus')}<p>You have no projects yet.</p></div>`}++${card('New project', create, { sub: canCreate ? 'Production sites can take custom domains; a sandbox is for trying things and is never indexed.' : '' })}`;++}++++function meter(label, used, limit, fmt = (x) => esc(x)) {++ const pct = limit > 0 ? Math.min(100, Math.round((Number(used) / Number(limit)) * 100)) : 0;++ const tone = pct >= 90 ? 'bad' : pct >= 70 ? 'warn' : 'ok';++ return `<div class="hd-meter"><div class="hd-meter__label"><span>${label}</span><span><strong>${fmt(used)}</strong> of ${fmt(limit)}</span></div>`++ + `<div class="hd-meter__bar hd-meter__bar--${tone}" role="img" aria-label="${pct}% used"><span style="width:${pct}%"></span></div></div>`;++}++++function quotaTable(quota, usage) {++ return `<div class="hd-meters">++${meter('Storage', usage.storageBytes, quota.storageBytes, bytes)}++${meter('Deploys in the last 24 h', usage.deploysLast24h, quota.deploysPerDay)}++${meter('Sites', usage.sites, quota.sites)}++${meter('Custom domains', usage.customDomains, quota.customDomains)}++</div>++<dl class="hd-facts"><div><dt>Files per deploy</dt><dd>${esc(Number(quota.maxFiles).toLocaleString('en-US'))}</dd></div><div><dt>Largest file</dt><dd>${bytes(quota.maxFileBytes)}</dd></div></dl>`;++}++++function project({ project: p, role, sites, quota, usage, members, csrf, siteUrl }) {++ const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';++ const siteRows = sites.map((s) => `<li class="hd-row">++<span class="hd-row__main"><a class="hd-row__title" href="/sites/${esc(s.id)}">${esc(s.name)}</a><a class="hd-url" href="${esc(siteUrl(s))}" rel="noopener">${esc(siteUrl(s).replace(/^https?:\/\//, ''))} ${icon('fa-arrow-up-right-from-square')}</a></span>++<span class="hd-row__side">${s.active_deploy_id ? `${badge('live', 'ok')} <code>${short(s.active_deploy_id)}</code>` : badge('nothing published')}</span></li>`).join('');++ const memberRows = members.map((m) => `<tr><td><code>${esc(m.principal)}</code></td><td>${badge(m.role, m.role === 'owner' ? 'accent' : 'neutral')}</td><td class="hd-actions">${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="hd-link hd-link--danger">Remove</button>`, { cls: 'hd-inline' }) : ''}</td></tr>`).join('');++ return `<header class="hd-head">${crumbs([['/dashboard', 'Projects']])}++<h1>${esc(p.name)}</h1>++<p class="hd-chips">${badge(p.environment, p.environment === 'sandbox' ? 'warn' : 'accent')} ${badge(`your role: ${role}`)} <code>${esc(p.id)}</code>${p.network_project_id ? ` <span class="hd-muted">Network project <code>${esc(p.network_project_id)}</code></span>` : ''}</p></header>++<div class="hd-split">++<div class="hd-split__main">++${card('Sites', `${sites.length ? `<ul class="hd-rows">${siteRows}</ul>` : `<div class="hd-empty hd-empty--small">${icon('fa-globe')}<p>No sites yet.</p></div>`}++${canMaintain ? form(`/projects/${p.id}/sites`, csrf, `<label class="hd-field">New site name <input name="name" required pattern="[a-z0-9-]{3,40}" maxlength="40" placeholder="my-site"></label><button type="submit" class="hd-btn hd-btn--primary">${icon('fa-plus')} Create site</button>`, { cls: 'hd-form hd-form--row' }) : ''}`, { sub: 'Each site is served at its own address under openvibe.website.' })}++${card('Members', `<div class="hd-table-wrap"><table class="hd-table"><thead><tr><th>Principal</th><th>Role</th><th></th></tr></thead><tbody>${memberRows}</tbody></table></div>++${role === 'owner' ? form(`/projects/${p.id}/members`, csrf, `<label class="hd-field">Principal <input name="principal" required placeholder="usr_… or app:app_…"></label>++<label class="hd-field">Role <select name="role"><option value="deployer">deployer</option><option value="maintainer">maintainer</option></select></label><button type="submit" class="hd-btn">Add or change</button>`, { cls: 'hd-form hd-form--row' }) : ''}`, { sub: 'Deployers upload and activate; maintainers also manage sites, domains and configuration.' })}++</div>++<aside class="hd-split__side">++${card('Quotas', quotaTable(quota, usage))}++</aside>++</div>++${role === 'owner' || role === 'staff' ? card('Delete project', form(`/projects/${p.id}/delete`, csrf, '<button type="submit" class="hd-btn hd-btn--danger">Delete this project, its sites and every deploy</button>', { cls: 'hd-form', confirm: 'I understand every site stops at once' }), { cls: 'hd-card--danger', sub: 'Every site in it stops being served at once, and its deploys are deleted.' }) : ''}`;++}++++function domainBlock(d, instructions, csrf) {++ const status = d.status === 'verified' ? badge('verified · served', 'ok') : badge(`${d.status} · not served`, 'warn');++ let body = `<li class="hd-domain"><div class="hd-domain__head"><strong>${esc(d.hostname)}</strong> ${badge(d.kind)} ${status}</div>${d.last_error ? `<p class="hd-muted">${esc(d.last_error)}</p>` : ''}`;++ if (instructions) {++ const rec = (r) => `<tr><td>${badge(r.type)}</td><td><code>${esc(r.name)}</code></td><td><code class="hd-wrap">${esc(r.value)}</code></td><td class="hd-muted">${esc(r.note)}</td></tr>`;++ body += `<details class="hd-details"${d.status === 'verified' ? '' : ' open'}><summary>DNS records</summary><div class="hd-table-wrap"><table class="hd-table hd-table--dns"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>`++ + rec(instructions.verification) + instructions.routing.map(rec).join('')++ + `</tbody></table></div><p class="hd-muted">${esc(instructions.tls)}</p></details>`;++ body += '<div class="hd-domain__actions">';++ if (d.status !== 'verified') body += form(`/domains/${d.id}/verify`, csrf, `<button type="submit" class="hd-btn hd-btn--small">${icon('fa-rotate')} Check DNS now</button>`, { cls: 'hd-inline' });++ body += form(`/domains/${d.id}/delete`, csrf, '<button type="submit" class="hd-link hd-link--danger">Remove</button>', { cls: 'hd-inline' });++ body += '</div>';++ }++ return `${body}</li>`;++}++++function site({ site: s, project: p, role, deploys, activations, domains, siteConfig, csrf, url, now = Date.now() }) {++ const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';++ const canDeploy = role !== 'staff';++ // The site's one live preview (plan T12 J4): served to project members on this dashboard until it expires.++ const previewId = s.preview_deploy_id && Number(s.preview_expires_at) > now ? s.preview_deploy_id : null;++ const previewHref = (id) => `/preview/${encodeURIComponent(id)}/`;++ const rows = deploys.map((d) => {++ const active = d.id === s.active_deploy_id;++ const previewing = d.id === previewId;++ const actions = [];++ if (previewing && d.state === 'ready') actions.push(`<a class="hd-btn hd-btn--small" href="${esc(previewHref(d.id))}" target="_blank" rel="noopener">${icon('fa-eye')} Open preview</a>`);++ if (canDeploy && d.state === 'ready' && !active) actions.push(form(`/deploys/${d.id}/activate`, csrf, `<input type="hidden" name="expected_active" value="${esc(s.active_deploy_id || '')}"><button type="submit" class="hd-btn hd-btn--small hd-btn--primary">Activate</button>`, { cls: 'hd-inline' }));++ if (canMaintain && !active) actions.push(form(`/deploys/${d.id}/delete`, csrf, '<button type="submit" class="hd-link hd-link--danger">Delete</button>', { cls: 'hd-inline' }));++ return `<tr${active ? ' class="hd-active"' : ''}><td><a href="/deploys/${esc(d.id)}"><code>${short(d.id)}</code></a>${active ? ` ${badge('active', 'accent')}` : ''}${previewing ? ` ${badge('preview', 'warn')}` : ''}</td>`++ + `<td>${badge(d.state, STATE_TONE[d.state] || 'neutral')}${d.failure_code ? ` <code>${esc(d.failure_code)}</code>` : ''}</td><td class="hd-num">${esc(d.file_count)}</td><td class="hd-num">${bytes(d.total_bytes)}</td><td>${when(d.created_at)}</td><td class="hd-actions">${actions.join(' ')}</td></tr>`;++ }).join('');++ const history = activations.slice(0, 10).map((a) => `<li>${when(a.created_at)} <span>${esc(a.kind)} → <code>${short(a.deploy_id)}</code>${a.previous_deploy_id ? ` <span class="hd-muted">(was <code>${short(a.previous_deploy_id)}</code>)</span>` : ''} <span class="hd-muted">by <code>${esc(a.actor)}</code></span></span></li>`).join('');++ const status = s.active_deploy_id ? `${badge('live', 'ok')} <code>${short(s.active_deploy_id)}</code>` : badge('nothing published');++ const rollback = 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" class="hd-btn hd-btn--small">${icon('fa-clock-rotate-left')} Roll back to the previous deploy</button>`, { cls: 'hd-inline' }) : '';++ return `<header class="hd-head">${crumbs([['/dashboard', 'Projects'], [`/projects/${p.id}`, p.name]])}++<h1>${esc(s.name)}</h1>++<p class="hd-chips"><a class="hd-url hd-url--big" href="${esc(url)}" rel="noopener">${esc(url.replace(/^https?:\/\//, ''))} ${icon('fa-arrow-up-right-from-square')}</a> ${status} <code>${esc(s.id)}</code></p></header>++${canDeploy ? card('Upload a deploy', form(`/sites/${s.id}/deploys`, csrf, `<div class="hd-sources">++<fieldset class="hd-source"><legend>${icon('fa-folder-open')} A folder</legend><input type="file" name="files" multiple webkitdirectory><input type="hidden" name="strip" value="folder"></fieldset>++<fieldset class="hd-source"><legend>${icon('fa-file-zipper')} …or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar"></fieldset>++</div>++<label class="hd-field hd-field--narrow">Deploy from subfolder (optional) <input name="root" placeholder="dist"></label>++<fieldset class="hd-choices"><legend>When it is ready</legend>++<label class="hd-choice"><input type="radio" name="mode" value="activate" checked><span><strong>Activate it</strong><span class="hd-muted">Live as soon as it is checked.</span></span></label>++<label class="hd-choice"><input type="radio" name="mode" value="ready"><span><strong>Keep it ready</strong><span class="hd-muted">Activate later from the list below.</span></span></label>++<label class="hd-choice"><input type="radio" name="mode" value="preview"><span><strong>Preview only</strong><span class="hd-muted">A private address for members of this project, for one hour.</span></span></label>++</fieldset>++<button type="submit" class="hd-btn hd-btn--primary">${icon('fa-cloud-arrow-up')} Upload</button>`, { cls: 'hd-form', enctype: 'multipart/form-data' }),++ { sub: '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.' }) : ''}++${card('Deploys', `${previewId ? `<p class="hd-notice">${icon('fa-eye')} A preview of <code>${short(previewId)}</code> is open to members of this project at <a href="${esc(previewHref(previewId))}" target="_blank" rel="noopener">${esc(previewHref(previewId))}</a> until ${when(Number(s.preview_expires_at))}. It is never indexed, and activating or rolling back ends it.</p>` : ''}++${deploys.length ? `<div class="hd-table-wrap"><table class="hd-table"><thead><tr><th>Deploy</th><th>State</th><th class="hd-num">Files</th><th class="hd-num">Size</th><th>Uploaded</th><th></th></tr></thead><tbody>${rows}</tbody></table></div>` : `<div class="hd-empty hd-empty--small">${icon('fa-box-open')}<p>No deploys yet.</p></div>`}++${history ? `<details class="hd-details"><summary>Activation history</summary><ul class="hd-history">${history}</ul></details>` : ''}`, { actions: rollback, sub: 'Every deploy is kept. Activating one switches the site at once; nothing is rebuilt.' })}++${card('Domains', `<ul class="hd-domains">${domains.map((d) => domainBlock(d.domain, d.instructions, csrf)).join('')}</ul>++${canMaintain && p.environment !== 'sandbox' ? form(`/sites/${s.id}/domains`, csrf, `<label class="hd-field">Custom domain <input name="hostname" required placeholder="www.example.org"></label><button type="submit" class="hd-btn">${icon('fa-plus')} Add domain</button>`, { cls: 'hd-form hd-form--row' }) : ''}`, { sub: 'A custom domain is served once its TXT record is found.' })}++${canMaintain ? card('Per-site configuration', form(`/sites/${s.id}/config`, csrf, `<label class="hd-field">Headers <span class="hd-muted">one per line: <code>Name: value</code></span><textarea name="headers" rows="4" spellcheck="false" placeholder="X-Frame-Options: SAMEORIGIN">${esc(siteConfig.headersText)}</textarea></label>++<label class="hd-field">Redirects <span class="hd-muted">one per line: <code>from -> to [status]</code></span><textarea name="redirects" rows="4" spellcheck="false" placeholder="/old -> /new 301">${esc(siteConfig.redirectsText)}</textarea></label>++<label class="hd-confirm"><input type="checkbox" name="spa" value="1"${siteConfig.spa ? ' checked' : ''}> Serve index.html for extensionless paths (single-page app)</label>++<button type="submit" class="hd-btn hd-btn--primary">Save configuration</button>`, { cls: 'hd-form' }),++ { sub: 'Headers, redirects and an SPA fallback, applied to every response of this site. Platform headers (Content-Security-Policy, Strict-Transport-Security, Set-Cookie, X-Forwarded-*, caching) cannot be set here, and a redirect target must be a local path.' }) : ''}++${canMaintain ? card('Delete site', form(`/sites/${s.id}/delete`, csrf, '<button type="submit" class="hd-btn hd-btn--danger">Delete this site</button>', { cls: 'hd-form', confirm: `${s.name} stops being served at once` }), { cls: 'hd-card--danger', sub: 'The site and its deploys are deleted; its address stops answering.' }) : ''}`;++}++++function deploy({ deploy: d, site: s, project: p, files, log, active }) {++ const LEVEL_TONE = { error: 'bad', warn: 'warn', info: 'neutral' };++ const logLines = log.map((l) => `<li class="hd-log__line hd-log__line--${esc(l.level)}">${badge(l.level, LEVEL_TONE[l.level] || 'neutral')}<span>${esc(l.message)}</span></li>`).join('');++ const fileRows = files.slice(0, 2000).map((f) => `<tr><td><code class="hd-wrap">${esc(f.path)}</code></td><td class="hd-muted">${esc(f.content_type)}</td><td class="hd-num">${bytes(f.size)}</td><td><code title="${esc(f.sha256)}">${esc(f.sha256.slice(0, 12))}</code></td></tr>`).join('');++ return `<header class="hd-head">${crumbs([['/dashboard', 'Projects'], [`/projects/${p.id}`, p.name], [`/sites/${s.id}`, s.name]])}++<h1>Deploy <code>${short(d.id)}</code></h1>++<p class="hd-chips">${badge(d.state, STATE_TONE[d.state] || 'neutral')}${active ? ` ${badge('active', 'accent')}` : ''}</p>++<dl class="hd-facts hd-facts--wide"><div><dt>Files</dt><dd>${esc(d.file_count)}</dd></div><div><dt>Size</dt><dd>${bytes(d.total_bytes)}</dd></div><div><dt>Uploaded</dt><dd>${when(d.created_at)}</dd></div><div><dt>By</dt><dd><code>${esc(d.created_by)}</code></dd></div>${d.manifest_sha256 ? `<div><dt>Manifest</dt><dd><code title="${esc(d.manifest_sha256)}">${esc(d.manifest_sha256.slice(0, 16))}</code></dd></div>` : ''}</dl></header>++${card('Upload log', `<ul class="hd-log">${logLines}</ul>`)}++${files.length ? card('Files', `<div class="hd-table-wrap"><table class="hd-table"><thead><tr><th>Path</th><th>Type</th><th class="hd-num">Size</th><th>sha256</th></tr></thead><tbody>${fileRows}</tbody></table></div>${files.length > 2000 ? `<p class="hd-muted">…and ${files.length - 2000} more.</p>` : ''}`, { sub: 'Stored once by content: a file that did not change since an earlier deploy of this project is not stored again.' }) : ''}`;++}++++function errorPage({ status, message }) {++ return `<div class="hd-error">${icon(status === 404 ? 'fa-circle-question' : status === 403 ? 'fa-lock' : status === 401 ? 'fa-right-to-bracket' : 'fa-triangle-exclamation')}++<h1>${status === 404 ? 'Not found' : status === 403 ? 'Not allowed' : status === 401 ? 'Sign in first' : 'Something went wrong'}</h1><p>${esc(message)}</p><p><a class="hd-btn" href="/dashboard">Back to your projects</a></p></div>`;++}++++module.exports = { home, project, site, deploy, errorPage, bytes };@@@ -21,7 +21,7 @@ function escapeHtml(s) {const SITE_NAME = 'OpenVibe.Space';const NETWORK_URL = 'https://openvibe.network';--const DEFAULT_DESCRIPTION = 'OpenVibe.Space will host code and dynamic pages, and "spaces": apps people build that use their OpenVibe account, the network\'s services and the SDK. The forum is on OpenVibe.Community.';++const DEFAULT_DESCRIPTION = 'OpenVibe.Space hosts static sites for OpenVibe staff, with immutable deploys, rollback, previews, Git ingest and custom domains. Dynamic pages and spaces are planned.';const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up@@@ -66,6 +66,7 @@ function navbarInit(opts) {// decision, 2026-10-08), so its link points there rather than at a /s that redirects.links: [{ label: 'Home', href: '/', active: opts.active === 'home' },++ { label: 'Hosting', href: '/dashboard' },{ label: 'The forum', href: `${config.communityUrl}/s` },],menu: { after: [{ label: 'OpenVibe.Network', href: NETWORK_URL, icon: 'fa-globe' }] },@@@ -86,7 +87,7 @@ function footerInit(opts) {mount: '#ov-footer',brandName: SITE_NAME,updates: `${config.communityUrl}/updates`, // this site ships with the community's log for now-- tagline: 'Code and dynamic pages for the people of OpenVibe.',++ tagline: 'Static hosting for the people of OpenVibe.',legalBase: config.liveUrl,links: [{heading: 'Space',@@@ -107,6 +108,7 @@ function footerInit(opts) {* feeds ([{ title, href }] RSS alternates; default: none),* historyType, historyTitle, footerVariant ('full'|'compact'), bodyClass,* styles (Shared stylesheets by name, linked before space.css so this site's rules win),++ * localStyles (this site's page-only stylesheets, e.g. 'css/hosting.css', linked after space.css),* published/modified (ISO, for article:*), noFrame (error pages during outages)*/function renderPage(o) {@@@ -120,7 +122,7 @@ function renderPage(o) {// with the navbar init, the noscript nav and the SSR footer; the rest of the head is this site's own.const html = shell.page({name: SITE_NAME, service: 'space', lang: 'en',-- title: o.title || `${SITE_NAME} — code and dynamic pages`,++ title: o.title || `${SITE_NAME} — static hosting`,titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',type: o.ogType || 'website', image: o.ogImage || DEFAULT_OG_IMAGE, imageAlt: o.imageAlt,@@@ -135,6 +137,8 @@ function renderPage(o) {...feeds.map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`),...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),`<link rel="stylesheet" href="${asset('css/space.css')}">`,++ // Page-only stylesheets of this site (the dashboard's hosting.css), after space.css; the home page loads none.++ ...(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`),'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',`<meta name="ov-boost" content="space@${escapeHtml(RELEASE)}">`,`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,@@@ -4,8 +4,8 @@* Page templates. Plain template strings, every value escaped, no framework. The markup is the whole* page for crawlers and no-JS readers; the shared Frame adds the navbar, footer and theme.*-- * Space publishes one page of its own while it is rebuilt: the home page, which says what Space will-- * host and points at the forum on OpenVibe.Community and the developer platform. It claims nothing Space does not have.++ * The home page says what Space hosts today (static sites, staff-only until the public launch), how a deploy++ * works, the API and the quotas the engine enforces, and what comes next. It claims nothing Space does not have.*/const config = require('../config');const sc = require('openvibe-shared/showcase');@@@ -15,37 +15,75 @@ const FORUM_URL = `${config.communityUrl}/s`;const FORUM_LABEL = FORUM_URL.replace(/^https?:\/\//, '');const SERVICES_URL = 'https://openvibe.services/';--/** The home page: what Space will host, and where things are today. Built from the network's showcase sections-- * (openvibe-shared/showcase), so it reads like every other product home. It promises nothing as open: every card says-- * what Space WILL host, and the live links go to the forum and the developer platform. */++const MiB = 1024 * 1024;++const size = (n) => (n >= 1024 * MiB ? `${n / (1024 * MiB)} GB` : `${Math.round(n / MiB)} MB`);++let quotas = null;++/** The per-project quotas the engine enforces (server/hosting-config.js), read once. */++function defaultQuotas() {++ if (!quotas) quotas = require('../hosting-config').load().quotas;++ return quotas;++}++++/** Hosting is working for staff; dynamic pages and spaces are the next stage. */function homePage() {++ const q = defaultQuotas();++ const row = (label, key, fmt = String) => ({ label, values: [fmt(q.production[key]), fmt(q.sandbox[key])] });const body = sc.hero({-- eyebrow: 'OpenVibe.Space · being rebuilt',-- title: 'Code and dynamic pages,',-- accent: 'on your OpenVibe account.',-- lede: 'OpenVibe.Space is being rebuilt to host static sites, dynamic pages and spaces: small apps people make that sign in with OpenVibe and use the network\'s services through the SDK. Nothing is hosted here yet.',++ eyebrow: 'OpenVibe.Space · static hosting',++ title: 'Publish a static site.',++ accent: 'Keep every deploy.',++ lede: 'Static site hosting is built and tested: immutable, content-addressed deploys, one-step rollback, previews, Git ingest and verified custom domains. It is open to OpenVibe staff only until public launch.',actions: [-- { label: 'The forum is on OpenVibe.Community', href: FORUM_URL, primary: true, icon: 'fa-comments' },-- { label: 'Build on OpenVibe today', href: SERVICES_URL, icon: 'fa-code' },++ { label: 'Hosting dashboard', href: '/dashboard', primary: true, icon: 'fa-globe' },++ { label: 'How it works', href: '#how', icon: 'fa-route' },++ ],++ note: 'Public hosting follows abuse handling and a designated DMCA agent. Until then only OpenVibe staff can create projects.',++ }) + sc.steps({++ id: 'how',++ title: 'How a deploy works',++ items: [++ { title: 'Make a project and a site', text: 'A project holds sites and the people who may change them. Each site gets its own address under openvibe.website.' },++ { title: 'Upload, or connect Git', text: 'Upload a folder or a .tar.gz from the dashboard or the API, or deploy from a public Git repository. Nothing is built or executed: the files are served as they are.' },++ { title: 'Preview, then activate', text: 'Check a deploy at its private preview address, then make it live. Activation switches one pointer, so rolling back is one step too.' },],-- note: `The forum moved to OpenVibe.Community (${FORUM_LABEL}); every old forum address redirects there.`,}) + sc.features({-- title: 'What Space will host',-- lede: 'Each part is composed from services the network already runs. None of it is open yet.',++ title: 'What works now',items: [-- { icon: 'fa-file-code', title: 'Static sites', text: 'Upload a folder or connect a Git repository. Every deploy is kept, and the active one rolls back in one step.' },-- { icon: 'fa-bolt', title: 'Dynamic pages', text: 'Server functions that run on OpenVibe.Run workers, with their files kept on OpenVibe.Media.' },-- { icon: 'fa-cubes', title: 'Spaces', text: 'Small apps that sign people in with their OpenVibe account and ask only for the grants they need.' },-- { icon: 'fa-code-branch', title: 'Templates and forks', text: 'Start from a template, or fork someone\'s space and make it yours.' },-- { icon: 'fa-wand-magic-sparkles', title: 'Build with an agent', text: 'Describe a space and an agent on OpenVibe.Actor and OpenVibe.Codes builds it, with every change shown.' },-- { icon: 'fa-globe', title: 'Your own address', text: 'Each site gets a name under openvibe.website; a custom domain is verified before it is served.' },++ { icon: 'fa-cubes', title: 'Immutable deploys', text: 'Every deploy is content-addressed and kept: files are stored once by their hash, and a deploy never changes after upload.' },++ { icon: 'fa-clock-rotate-left', title: 'Previews and rollback', text: 'A private preview for the project\'s members, one-step activation, and a rollback to the deploy that was live before.' },++ { icon: 'fa-code-branch', title: 'Git deploys', text: 'Point a site at a public repository and branch; each ingest lands as a preview for someone to approve.' },++ { icon: 'fa-globe', title: 'Your own address', text: 'Sites serve under openvibe.website. A custom domain is served once its TXT record proves it is yours.' },++ { icon: 'fa-sliders', title: 'Headers and redirects', text: 'Per-site response headers, redirects and a single-page-app fallback. Platform security headers always win.' },++ { icon: 'fa-users', title: 'Members and roles', text: 'Owners, maintainers and deployers per project; an app can deploy too, with a grant and a role, never as the owner.' },++ { icon: 'fa-shield-halved', title: 'Takedowns kept for review', text: 'OpenVibe staff can stop serving a site or a whole project while its content is kept for review, and lift it again.' },++ { icon: 'fa-terminal', title: 'An API for all of it', text: 'Projects, sites, deploys, domains and configuration over /api/v1, with OpenVibe.Network tokens and space.* capabilities.' },++ ],++ }) + sc.code({++ title: 'The same over the API',++ lede: 'With a Network token for openvibe.space that holds space.deploy.create and a role in the site\'s project.',++ samples: [{ label: 'Upload and activate a build', lang: 'bash', code: `tar -czf site.tar.gz -C dist .++curl -X POST "https://openvibe.space/api/v1/sites/$SITE_ID/deploys?activate=1" \\++ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/gzip' \\++ --data-binary @site.tar.gz` }],++ }) + sc.limits({++ title: 'Quotas per project',++ lede: 'What a project may use today; staff can raise a project\'s quota.',++ columns: ['Production', 'Sandbox'],++ rows: [++ row('Storage', 'storageBytes', size),++ row('Deploys per day', 'deploysPerDay'),++ row('Sites', 'sites'),++ row('Custom domains', 'customDomains'),++ row('Files per deploy', 'maxFiles', (n) => n.toLocaleString('en-US')),++ row('Largest file', 'maxFileBytes', size),],++ source: 'https://github.com/OpenVibers/OpenVibe.Space/blob/main/server/hosting-config.js',}) + sc.steps({-- title: 'Where things are today',++ title: 'Next',items: [-- { title: 'The forum', href: FORUM_URL, text: 'Boards, threads, votes and moderation moved to OpenVibe.Community.' },-- { title: 'The developer platform', href: SERVICES_URL, text: 'Projects, credentials, grants and the SDK that spaces will be built on.' },-- { title: 'The source code', href: 'https://github.com/OpenVibers/OpenVibe.Space', text: 'Space is open source (AGPL-3.0): follow the rebuild there.' },++ { title: 'Dynamic pages', text: 'Planned; server side pages are not built yet.' },++ { title: 'Spaces', text: 'Planned; small apps on a person\'s OpenVibe account are not built yet.' },++ { title: 'The forum', href: FORUM_URL, text: 'The forum moved to OpenVibe.Community.' },],});return renderPage({ canonicalPath: '/', active: 'home', styles: [sc.STYLESHEET], body });nnew file mode 100644@@@ -0,0 +1,371 @@++'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).++ *++ * Two modes, selected by config (SPACE_OBJECT_STORE):++ *++ * local (default, and what an unset SPACE_OBJECT_STORE gets) — the layout above, exactly as before.++ * media — the same local layout is the READ cache, and every put is written through to++ * OpenVibe.Media's Object API v2 (openvibe-sdk/media createObjectsClient) as a private++ * object in Host's own namespace, keyed per project with the file's sha256 as content_hash.++ * A deploy is not reported as stored until Media acknowledged the write; a Media failure++ * rejects the put and the deploy fails, never a silent local-only success. ensure() serves++ * the local cache and, on a miss, streams the object back from Media while hashing it,++ * verifies the sha256 and only then renames it into the cache and serves it. Media objects++ * are deleted only where the local store would delete the blob today (deploy GC, project++ * removal, the worker's orphan sweep), and the delete re-checks space_blobs just before it++ * runs, so a Media object a deploy has since re-referenced is not deleted. The worker's++ * sweep walks only locally cached blobs, so a Media-only orphan is left to project++ * removal. A zero-byte file is cache-only (Media's upload refuses empty objects) and is++ * never fetched. A put is serialised per (project, sha256) and remove deletes every++ * matching object, so two deploys sharing a new sha cannot leave a duplicate behind.++ *++ * Nothing in this module reaches the network on load: the Media client is built inside++ * createMediaStore(), only when the Media store is selected, and it fetches a token lazily.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const { Readable, Transform } = require('stream');++const { pipeline } = require('stream/promises');++const { isId } = require('./ids');++++const SHA_RE = /^[0-9a-f]{64}$/;++// What Space's Network service principal must be granted for audience openvibe.media, in Space's own++// namespace (`host` / `space.*`). Media's v2 object API checks a verb per namespace: upload, read,++// list, delete. Media's older ids keep granting the newer verbs, but asking for each verb is exact.++const MEDIA_SCOPE = 'media.object.upload media.object.read media.object.list media.object.delete';++const MEDIA_AUDIENCE = 'openvibe.media';++// A Media download that has not answered within this long is abandoned and the read fails with a++// clean 500 instead of hanging. The SDK client has its own timeouts; this raw fetch does not.++const MEDIA_FETCH_TIMEOUT_MS = 30 * 1000;++++const sha256Of = (data) => crypto.createHash('sha256').update(data).digest('hex');++++/** A put/ensure/delete failure against Media, with a stable code. Never carries a secret. */++function mediaFailure(action, err) {++ const e = new Error(err && err.message ? `${action}: ${err.message}` : action);++ e.code = 'storage.media';++ if (err) e.cause = err;++ return e;++}++++/** The local, content-addressed store. `ensure` is the synchronous read path (a cache hit is the file). */++function createLocalStore(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 = sha256Of(data);++ 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;++ }++++ /** Free bytes on the filesystem that holds the objects (shared with every other service on the host). */++ function freeBytes() {++ const s = fs.statfsSync(base);++ return Number(s.bavail) * Number(s.bsize);++ }++++ function writable() {++ fs.accessSync(tmpDir, fs.constants.W_OK);++ fs.accessSync(projectsDir, fs.constants.W_OK);++ return true;++ }++++ /** The path to serve: a local cache hit is already the file. */++ function ensure(projectId, sha256) {++ return pathFor(projectId, sha256);++ }++++ return { root: base, tmpDir, pathFor, put, exists, remove, removeProject, list, sweepTmp, writable, freeBytes, ensure };++}++++/**++ * The Media-backed store: local disk is the read cache, Media is the source of truth. It exposes the++ * same interface as the local store; put/ensure/remove/removeProject become async (they talk to++ * Media), and the rest delegate to the cache.++ *++ * opts.isReferenced(projectId, sha256) -> whether space_blobs still has a row, i.e. whether a deploy++ * still references the blob. remove() re-checks it just before deleting, so a blob a concurrent++ * deploy re-used after the caller found it unreferenced keeps its Media copy.++ */++function createMediaStore(local, media, { isReferenced } = {}) {++ const { createObjectsClient } = require('openvibe-sdk/media'); // v2 object API++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ if (!media || !media.url) throw new Error('SPACE_OBJECT_STORE=media needs OV_MEDIA_URL (the internal OpenVibe.Media base URL)');++ if (!media.clientSecret) throw new Error('SPACE_OBJECT_STORE=media needs OV_OAUTH_CLIENT_SECRET: Space stores objects as its own Media tenant through a Network service token');++ const tokenClient = createServiceTokenClient({++ tokenUrl: media.tokenUrl,++ clientId: media.clientId,++ clientSecret: media.clientSecret,++ audience: MEDIA_AUDIENCE,++ scope: media.scope || MEDIA_SCOPE,++ ...(media.fetchImpl ? { fetch: media.fetchImpl } : {}),++ });++ const objects = createObjectsClient({++ app: media.namespace || 'space',++ baseUrl: String(media.url).replace(/\/+$/, ''),++ tokenClient,++ ...(media.fetchImpl ? { fetch: media.fetchImpl } : {}),++ });++ const fetchBytes = media.fetchImpl || globalThis.fetch;++ const fetchTimeoutMs = Number(media.fetchTimeoutMs) > 0 ? Number(media.fetchTimeoutMs) : MEDIA_FETCH_TIMEOUT_MS;++ // (project, sha256) -> Media object id, so a deploy does not list Host's namespace once per file.++ // A restart simply repopulates it; deleting an object drops its entry. Only an optimisation: an++ // empty map still finds every object by its content_hash.++ const known = new Map();++ const keyOf = (projectId, sha256) => `${projectId}\n${sha256}`;++ // In-flight tasks by key: a concurrent put of the same new blob waits for the first upload++ // instead of racing it into a duplicate object, and a burst of cache misses for one blob shares++ // a single download. Keys are prefixed so a put and an ensure never block each other.++ const inflight = new Map();++++ const isOurs = (o, projectId, sha256) => Boolean(o)++ && o.content_hash === sha256++ && o.metadata && o.metadata.project_id === projectId;++++ /** Run fn after every earlier task with the same key, whichever way it settled. */++ function serialise(map, key, fn) {++ const prev = map.get(key) || Promise.resolve();++ const run = prev.then(fn, fn);++ const tail = run.then(() => {}, () => {}); // the queue's tail never rejects++ map.set(key, tail);++ tail.then(() => { if (map.get(key) === tail) map.delete(key); });++ return run;++ }++++ /** Every Media object id for this (project, sha256): there can be duplicates from before puts were serialised. */++ async function findAllIds(projectId, sha256) {++ const ids = [];++ for await (const o of objects.iterate({})) if (isOurs(o, projectId, sha256)) ids.push(o.id);++ return ids;++ }++++ async function findId(projectId, sha256) {++ const key = keyOf(projectId, sha256);++ const hit = known.get(key);++ if (hit) return hit;++ const ids = await findAllIds(projectId, sha256);++ if (!ids.length) return null;++ known.set(key, ids[0]);++ return ids[0];++ }++++ /** Stream a Media object into a temp file while hashing it; rename into the cache only on a match. */++ async function downloadToCache(projectId, sha256, url) {++ const dest = local.pathFor(projectId, sha256);++ const tmp = path.join(local.tmpDir, `${crypto.randomBytes(12).toString('hex')}.part`);++ const hash = crypto.createHash('sha256');++ try {++ const res = await fetchBytes(url, { signal: AbortSignal.timeout(fetchTimeoutMs) });++ if (!res.ok) throw new Error(`download answered ${res.status}`);++ fs.mkdirSync(path.dirname(dest), { recursive: true });++ if (res.body) {++ const meter = new Transform({ transform(chunk, _enc, cb) { hash.update(chunk); cb(null, chunk); } });++ await pipeline(Readable.fromWeb(res.body), meter, fs.createWriteStream(tmp));++ } else {++ const buf = Buffer.from(await res.arrayBuffer());++ hash.update(buf);++ fs.writeFileSync(tmp, buf);++ }++ const actual = hash.digest('hex');++ if (actual !== sha256) {++ const e = new Error(`object ${sha256}: Media returned bytes whose sha256 is ${actual}; refused`);++ e.code = 'storage.corrupt';++ throw e;++ }++ fs.renameSync(tmp, dest);++ } catch (err) {++ fs.rmSync(tmp, { force: true });++ if (err && err.code === 'storage.corrupt') throw err;++ throw mediaFailure(`could not fetch object ${sha256} from Media`, err);++ }++ }++++ async function doPut(projectId, sha256, data) {++ if (!SHA_RE.test(String(sha256))) throw new TypeError('invalid sha256');++ // Media's upload refuses zero bytes ("nothing to upload"); an empty file is a valid manifest++ // entry, so it lives in the local cache only. ensure() serves it from there and, since the++ // serving path short-circuits size 0, never asks Media (which has no object for it).++ if (data.length === 0) return local.put(projectId, sha256, data);++ const changed = local.put(projectId, sha256, data); // the cache first; verifies the digest++ // A blob this process just wrote is new here: upload it directly. Listing Host's whole++ // namespace once per file would be quadratic on a 300-file deploy, and Media's v2 list API has++ // no content_hash or metadata filter. Only a blob that was already cached (a previous deploy++ // in this process, or an earlier run) is looked up, so a restart does not upload a second copy.++ if (!changed) {++ if (await findId(projectId, sha256)) return changed;++ }++ let obj;++ try {++ obj = await objects.upload(data, {++ kind: 'file', visibility: 'private', mimeType: 'application/octet-stream',++ filename: sha256, contentHash: sha256,++ metadata: { project_id: projectId, sha256 },++ });++ } catch (err) {++ throw mediaFailure(`Media refused to store object ${sha256}`, err);++ }++ known.set(keyOf(projectId, sha256), obj.id);++ return changed;++ }++++ /** Cache the bytes locally (as today), then write through to Media. Rejects if Media refuses. */++ function put(projectId, sha256, data) {++ return serialise(inflight, `put:${keyOf(projectId, sha256)}`, () => doPut(projectId, sha256, data));++ }++++ async function doEnsure(projectId, sha256) {++ if (local.exists(projectId, sha256)) return local.pathFor(projectId, sha256); // a peer cached it while we waited++ const id = await findId(projectId, sha256);++ if (!id) throw mediaFailure(`object ${sha256} is not in Media`, null);++ let url;++ try {++ ({ url } = await objects.signedUrl(id, { ttl: 300 }));++ } catch (err) {++ throw mediaFailure(`could not fetch object ${sha256} from Media`, err);++ }++ await downloadToCache(projectId, sha256, url);++ return local.pathFor(projectId, sha256);++ }++++ /**++ * Serve the cache; on a miss fetch from Media, verify the sha256, cache it, then serve. Concurrent++ * misses for one blob share a single download.++ */++ function ensure(projectId, sha256) {++ if (local.exists(projectId, sha256)) return Promise.resolve(local.pathFor(projectId, sha256));++ return serialise(inflight, `ensure:${keyOf(projectId, sha256)}`, () => doEnsure(projectId, sha256));++ }++++ /** Delete the Media object(s) and the cache file. Callers pass only blobs no deploy references. */++ async function remove(projectId, sha256) {++ if (!SHA_RE.test(String(sha256))) throw new TypeError('invalid sha256');++ let ids;++ try {++ ids = await findAllIds(projectId, sha256);++ } catch (err) {++ throw mediaFailure(`Media could not delete object ${sha256}`, err);++ }++ // Between the caller's "unreferenced" decision and this delete a concurrent deploy may have++ // re-used the sha. space_blobs is the authority: if a row now exists, keep Media's durable copy++ // and the cache file. The check is after the (slow) namespace scan, right before the delete.++ if (isReferenced && await isReferenced(projectId, sha256)) return false;++ if (ids.length) {++ try {++ for (const id of ids) await objects.delete(id);++ } catch (err) {++ throw mediaFailure(`Media could not delete object ${sha256}`, err);++ }++ known.delete(keyOf(projectId, sha256));++ }++ return local.remove(projectId, sha256);++ }++++ /** Delete every Media object of the project and the project's cache directory. */++ async function removeProject(projectId) {++ if (!isId('project', projectId)) throw new TypeError('invalid project id');++ let failure = null;++ try {++ for await (const o of objects.iterate({})) {++ if (o && o.metadata && o.metadata.project_id === projectId) {++ try {++ await objects.delete(o.id);++ if (o.content_hash) known.delete(keyOf(projectId, o.content_hash));++ } catch (err) { failure = failure || err; }++ }++ }++ } catch (err) { failure = failure || err; }++ local.removeProject(projectId);++ if (failure) throw mediaFailure(`Media could not delete the objects of ${projectId}`, failure);++ }++++ return { ...local, put, ensure, remove, removeProject };++}++++/**++ * root: the local object directory. opts.media, when set, selects the Media-backed store (its++ * presence, built from config, is the `SPACE_OBJECT_STORE=media` switch); without it the local store++ * behaves exactly as before. opts.isReferenced is the Media store's space_blobs re-check on delete.++ */++function createBlobStore(root, opts = {}) {++ const local = createLocalStore(root);++ if (!opts.media) return local;++ return createMediaStore(local, opts.media, { isReferenced: opts.isReferenced });++}++++module.exports = { createBlobStore, SHA_RE, MEDIA_SCOPE, MEDIA_AUDIENCE };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * Background work in the Host process (a few timers; nothing here executes tenant content):++ *++ * domain checks every SPACE_DOMAIN_RECHECK_MS: pending custom domains are checked again++ * (they fail after SPACE_DOMAIN_PENDING_DAYS); verified ones are re-checked daily++ * and lapse when their TXT record has been gone for SPACE_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('[Space] domain checks failed:', err.message);++ return null;++ } finally { checking = false; }++ }++++ /** Remove objects no row references, once they are older than `graceMs`. */++ async function sweepObjects({ graceMs = 3600 * 1000 } = {}) {++ const known = store.db.prepare('SELECT 1 FROM space_blobs WHERE project_id = ? AND sha256 = ?');++ let removed = 0;++ for (const o of blobs.list()) {++ if (await known.get(o.projectId, o.sha256)) continue;++ try {++ if (Date.now() - fs.statSync(o.file).mtimeMs < graceMs) continue;++ // Through the store, so with the Media store the unreferenced Media object goes too.++ if (await blobs.remove(o.projectId, o.sha256)) removed++;++ } catch (err) {++ // A storage.media failure is a delete Media refused (grants, an outage): the sweep would++ // otherwise look like it worked while the object leaks. Anything else raced a write or++ // the file is already gone.++ if (err && err.code === 'storage.media') {++ log.warn('[Space] object sweep could not delete a Media object:', o.projectId, o.sha256, err.message);++ }++ }++ }++ 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, async () => { try { await sweepObjects(); } catch (err) { log.warn('[Space] object sweep failed:', err.message); } });++ every(24 * 3600 * 1000, async () => { try { await outbox.outbox.prune(); } catch (err) { log.warn('[Space] outbox prune failed:', err.message); } });++ setTimeout(domainTick, 5000).unref();++ },++ stop() { for (const t of timers) clearInterval(t); timers.length = 0; },++ };++}++++module.exports = { createWorker };@@@ -67,8 +67,8 @@ async function check(name, fn) {const r = await fetch(`${base}/`);assert.strictEqual(r.status, 200);const html = await r.text();-- assert.match(html, /<title>OpenVibe\.Space — code and dynamic pages<\/title>/);-- assert.match(html, /<h1>Code and dynamic pages,<span class="sc-accent"> on your OpenVibe account\.<\/span><\/h1>/);++ assert.match(html, /<title>OpenVibe\.Space — static hosting<\/title>/);++ assert.match(html, /<h1>Publish a static site\.<span class="sc-accent"> Keep every deploy\.<\/span><\/h1>/);assert.match(html, /The forum moved to OpenVibe\.Community/);assert.match(html, /href="https:\/\/openvibe\.community\/s"/);});@@@ -10,13 +10,13 @@ const { boot, check, done } = require('./helpers/app');(async () => {const t = await boot();-- await check('the home page says what Space will host and links the forum on OpenVibe.Community', async () => {++ await check('the home page describes staff static hosting and links the forum on OpenVibe.Community', async () => {const r = await t.get('/');assert.strictEqual(r.status, 200);-- assert.match(r.text, /<h1>Code and dynamic pages,<span class="sc-accent"> on your OpenVibe account\.<\/span><\/h1>/);-- assert.match(r.text, /static sites, dynamic pages and spaces/);-- assert.match(r.text, /Nothing is hosted here yet\./, 'it claims nothing is open');-- assert.match(r.text, /What Space will host/);++ assert.match(r.text, /<h1>Publish a static site\.<span class="sc-accent"> Keep every deploy\.<\/span><\/h1>/);++ assert.match(r.text, /Static site hosting is built and tested/);++ assert.match(r.text, /OpenVibe staff only until public launch/);++ assert.match(r.text, /What works now/);assert.match(r.text, /showcase\.css/, 'built from the network\'s showcase sections');assert.match(r.text, /The forum moved to OpenVibe\.Community/);assert.match(r.text, /href="https:\/\/openvibe\.community\/s"/, 'the forum link points at Community');nnew file mode 100644@@@ -0,0 +1,195 @@++'use strict';++/**++ * Abuse controls (docs/threat-review.md): staff takedowns stop serving at once and keep the content++ * for review; members cannot publish, switch or delete around a takedown; certificate-validation++ * paths cannot be published; uploads are validated a few at a time, so concurrent uploads cannot++ * exhaust memory; fingerprinted assets leave a shared CDN within the hour.++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++const { boot, check, done } = require('./stageb/boot');++const { site: siteTar } = require('./stageb/tar');++++(async () => {++ const t = await boot({ env: { SPACE_MAX_CONCURRENT_UPLOADS: '1', SPACE_MAX_PROJECTS_PER_OWNER: '4' } });++ const alice = t.user('alice');++ const staff = t.user('root', { role: 'admin' });++ const pa = await t.project(alice, 'A');++ const alpha = await t.site(alice, pa.id, 'alpha');++ const other = await t.site(alice, pa.id, 'other');++ const d1 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA ONE', 'app.3f2a9c1b.js': 'js();' });++ const d2 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA TWO' });++ await t.deploy(alice, other.id, { 'index.html': 'OTHER' });++ const dom = await t.api('POST', `/api/v1/sites/${alpha.id}/domains`, { as: alice, json: { hostname: 'www.alpha-example.org' } });++ const domain = dom.json().domain;++ t.dns.set("_openvibe-space.www.alpha-example.org", [domain.instructions.verification.value]);++ assert.strictEqual((await t.api('POST', `/api/v1/domains/${domain.id}/verify`, { as: alice })).json().domain.status, 'verified');++ const objectsOf = (p) => { const out = []; const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) walk(path.join(d, e.name)); else out.push(e.name); } }; walk(path.join(t.dir, 'objects', 'projects', p)); return out.sort(); };++++ await check('fingerprinted assets: immutable for browsers, at most an hour at a shared CDN', async () => {++ const r = await t.get('alpha.openvibe.website', '/app.3f2a9c1b.js');++ await t.api('POST', `/api/v1/deploys/${d1.id}/activate`, { as: alice });++ const r1 = await t.get('alpha.openvibe.website', '/app.3f2a9c1b.js');++ assert.strictEqual(r.status, 404, 'not in the active deploy yet');++ assert.strictEqual(r1.headers['cache-control'], 'public, max-age=31536000, immutable');++ assert.strictEqual(r1.headers['cdn-cache-control'], 'public, max-age=3600');++ assert.strictEqual((await t.get('alpha.openvibe.website', '/')).headers['cdn-cache-control'], undefined);++ await t.api('POST', `/api/v1/deploys/${d2.id}/activate`, { as: alice });++ });++++ await check('only staff take a site down; members and strangers cannot', async () => {++ const mine = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: alice, json: { reason: 'self' } });++ assert.strictEqual(mine.status, 403);++ assert.strictEqual(mine.json().code, 'auth.staff_only');++ const stranger = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: t.user('mallory'), json: { reason: 'x' } });++ assert.strictEqual(stranger.status, 404, 'a stranger learns nothing');++ const noReason = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: {} });++ assert.strictEqual(noReason.status, 422);++ });++++ const objectsBefore = objectsOf(pa.id);++ await check('a takedown stops the default and custom domains at once (451, no tenant bytes) and keeps everything', async () => {++ const r = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'phishing report #42' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().takedown.reason, 'phishing report #42');++ for (const host of ['alpha.openvibe.website', 'www.alpha-example.org']) {++ for (const p of ['/', '/index.html', '/app.3f2a9c1b.js', '/404.html', '/missing']) {++ const g = await t.get(host, p);++ assert.strictEqual(g.status, 451, `${host}${p}`);++ assert.ok(!/ALPHA|js\(\)/.test(g.text), 'no tenant content');++ assert.strictEqual(g.headers['cache-control'], 'no-store');++ assert.strictEqual(g.headers.etag, undefined);++ assert.strictEqual(g.headers['clear-site-data'], '"cache", "storage"', 'the visitor\'s browser drops what the site left behind');++ }++ }++ assert.strictEqual((await t.get('other.openvibe.website', '/')).text, 'OTHER', 'a site-level takedown leaves the project\'s other sites up');++ assert.deepStrictEqual(objectsOf(pa.id), objectsBefore, 'every object kept');++ const files = await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: staff });++ assert.strictEqual(files.json().deploy.files.length, 2, 'file rows kept for review');++ assert.strictEqual((await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'again' } })).status, 409);++ });++++ await check('members see the takedown and why, and cannot publish, switch or delete around it', async () => {++ const s = await t.api('GET', `/api/v1/sites/${alpha.id}`, { as: alice });++ assert.deepStrictEqual([s.json().site.takedown.scope, s.json().site.takedown.reason], ['site', 'phishing report #42']);++ const up = await t.upload(alice, alpha.id, { 'index.html': 'EVADE' });++ assert.strictEqual(up.status, 403);++ assert.strictEqual(up.json().code, 'site.taken_down');++ for (const [method, p, json] of [++ ['POST', `/api/v1/deploys/${d1.id}/activate`, {}], ['POST', `/api/v1/sites/${alpha.id}/rollback`, {}],++ ['DELETE', `/api/v1/deploys/${d1.id}`], ['DELETE', `/api/v1/sites/${alpha.id}`], ['DELETE', `/api/v1/projects/${pa.id}`],++ ]) {++ const r = await t.api(method, p, { as: alice, json });++ assert.ok([403, 409].includes(r.status), `${method} ${p} → ${r.status} ${r.text}`);++ assert.strictEqual(r.json().code, 'site.taken_down');++ }++ const page = await t.api('GET', `/sites/${alpha.id}`, { session: alice });++ assert.match(page.text, /taken down by OpenVibe staff: phishing report #42/);++ assert.deepStrictEqual(objectsOf(pa.id), objectsBefore);++ });++++ await check('staff lift a takedown: the same content serves again; members only lift nothing', async () => {++ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: alice })).status, 403);++ const r = await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { note: 'reviewed: false positive' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual((await t.get('alpha.openvibe.website', '/')).text, 'ALPHA TWO');++ assert.strictEqual((await t.get('www.alpha-example.org', '/')).text, 'ALPHA TWO');++ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff })).status, 404);++ const rows = await t.ctx.store.db.prepare('SELECT target_kind, reason, (lifted_at IS NOT NULL)::int AS lifted, lift_note FROM space_takedowns').all();++ assert.deepStrictEqual(rows, [{ target_kind: 'site', reason: 'phishing report #42', lifted: 1, lift_note: 'reviewed: false positive' }], 'the history is kept');++ });++++ await check('a project takedown covers every site of the project; staff can still delete', async () => {++ const r = await t.api('POST', `/api/v1/projects/${pa.id}/takedown`, { as: staff, json: { reason: 'spam network' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual((await t.get('alpha.openvibe.website', '/')).status, 451);++ assert.strictEqual((await t.get('other.openvibe.website', '/')).status, 451);++ const p = await t.api('GET', `/api/v1/projects/${pa.id}`, { as: alice });++ assert.strictEqual(p.json().project.takedown.scope, 'project');++ assert.strictEqual(p.json().sites[0].takedown.scope, 'project');++ const list = await t.api('GET', '/api/v1/projects', { as: alice });++ assert.strictEqual(list.json().projects[0].takedown.reason, 'spam network');++ assert.strictEqual((await t.upload(alice, other.id, { 'index.html': 'x' })).status, 403);++ const del = await t.api('DELETE', `/api/v1/sites/${other.id}`, { as: staff });++ assert.strictEqual(del.status, 200, del.text);++ assert.strictEqual((await t.get('other.openvibe.website', '/')).status, 404);++ });++++ await check('certificate-validation paths are never published (no tenant can get a certificate for its host name)', async () => {++ const pb = await t.project(alice, 'B');++ const beta = await t.site(alice, pb.id, 'beta');++ for (const p of ['.well-known/acme-challenge/token123', '.well-known/pki-validation/fileauth.txt', '.well-known/ACME-CHALLENGE/x']) {++ const r = await t.upload(alice, beta.id, { 'index.html': 'B', [p]: 'proof' });++ assert.strictEqual(r.status, 422, `${p} → ${r.status}`);++ assert.ok(r.json().log.some((l) => /reserved/.test(l)), r.text);++ }++ await t.deploy(alice, beta.id, { 'index.html': 'B', '.well-known/security.txt': 'Contact: mailto:[email protected]' });++ assert.strictEqual((await t.get('beta.openvibe.website', '/.well-known/security.txt')).status, 200, 'other .well-known files still publish');++ assert.strictEqual((await t.get('beta.openvibe.website', '/.well-known/acme-challenge/token123')).status, 404);++ });++++ await check('uploads are validated a few at a time: a second concurrent upload gets 503 + Retry-After and is not a failed deploy', async () => {++ const pc = await t.project(alice, 'C');++ const gamma = await t.site(alice, pc.id, 'gamma');++ const body = siteTar({ 'index.html': 'GAMMA' });++ const failedBefore = (await t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM space_deploys WHERE state = 'failed'").get()).n;++ // The first upload sends its headers and half its body, then waits.++ let firstResponse;++ let finish;++ const first = new Promise((resolve) => {++ const req = http.request({ host: '127.0.0.1', port: t.port, method: 'POST', path: `/api/v1/sites/${gamma.id}/deploys?activate=1`, headers: { host: 'openvibe.space', authorization: `Bearer ${t.network.userToken(alice)}`, 'content-type': 'application/gzip', 'content-length': body.length } }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => { firstResponse = { status: res.statusCode, text: Buffer.concat(chunks).toString() }; resolve(); });++ });++ req.write(body.subarray(0, Math.floor(body.length / 2)));++ finish = () => req.end(body.subarray(Math.floor(body.length / 2)));++ });++ await new Promise((r) => setTimeout(r, 150));++ assert.strictEqual(t.ctx.uploadGate.inFlight, 1);++ const second = await t.upload(alice, gamma.id, { 'index.html': 'SECOND' });++ assert.strictEqual(second.status, 503, second.text);++ assert.strictEqual(second.json().code, 'upload.busy');++ assert.strictEqual(second.headers['retry-after'], '30');++ finish();++ await first;++ assert.strictEqual(firstResponse.status, 201, firstResponse.text);++ assert.strictEqual(t.ctx.uploadGate.inFlight, 0, 'the slot is released');++ assert.strictEqual((await t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM space_deploys WHERE state = 'failed'").get()).n, failedBefore, 'a busy refusal is not the tenant\'s failed deploy');++ const third = await t.upload(alice, gamma.id, { 'index.html': 'THIRD' });++ assert.strictEqual(third.status, 201, third.text);++ // A refused upload releases its slot too.++ const bad = await t.upload(alice, gamma.id, { '../x.html': 'no' });++ assert.strictEqual(bad.status, 422);++ assert.strictEqual(t.ctx.uploadGate.inFlight, 0);++ });++++ await check('the shared disk: below SPACE_MIN_FREE_BYTES deploys are refused (507) before the body is read; serving goes on; readiness says so', async () => {++ const pd = await t.project(alice, 'D');++ const delta = await t.site(alice, pd.id, 'delta');++ await t.deploy(alice, delta.id, { 'index.html': 'DELTA' });++ const saved = t.ctx.config.uploads.minFreeBytes;++ t.ctx.config.uploads.minFreeBytes = Number.MAX_SAFE_INTEGER;++ try {++ const r = await t.upload(alice, delta.id, { 'index.html': 'MORE' });++ assert.strictEqual(r.status, 507, r.text);++ assert.strictEqual(r.json().code, 'storage.space_full');++ assert.strictEqual((await t.get('delta.openvibe.website', '/')).text, 'DELTA');++ const ready = (await t.api('GET', '/api/ready')).json();++ const check = Array.isArray(ready.checks) ? ready.checks.find((c) => c.name === 'disk_headroom') : ready.checks.disk_headroom;++ assert.ok(check && !check.ok && /new deploys are refused/.test(JSON.stringify(check)), JSON.stringify(ready));++ } finally { t.ctx.config.uploads.minFreeBytes = saved; }++ assert.strictEqual((await t.upload(alice, delta.id, { 'index.html': 'MORE' })).status, 201);++ });++++ await check('one person owns at most SPACE_MAX_PROJECTS_PER_OWNER projects (each gets its own quotas)', async () => {++ const r = await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'fifth' } });++ assert.strictEqual(r.status, 429, r.text);++ assert.match(r.json().detail, /you already own 4 projects/);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++/**++ * Space's hosting capabilities and service manifest as openvibe-contracts released them (0.149.0) match what the code++ * enforces and emits; and the outbox relay really delivers Space'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 { CAPABILITIES } = require('../server/auth/capabilities');++const { EVENT_TYPES } = require('../server/events/outbox');++++(async () => {++ const ids = Object.values(CAPABILITIES);++ const caps = ids.map((id) => contracts.capabilities.get(id));++ const manifest = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/manifests/services/space.json'), 'utf8'));++++ await check('every capability Space guards is released, active and owned by space', async () => {++ for (const [i, c] of caps.entries()) {++ assert.ok(c, `${ids[i]} is not in openvibe-contracts`);++ assert.strictEqual(c.owner, 'space');++ // Every capability Host guards is active (space.resource.read since openvibe-contracts 0.108.0).++ assert.strictEqual(c.status, 'active', c.id);++ }++ });++++ await check('the released capabilities are exactly the ones the routes guard, and every route guards one', async () => {++ assert.deepStrictEqual([...manifest.capabilities].sort(), [...ids].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);++ const key = `${r.method} ${r.route}`;++ assert.ok(cap.implementedBy.includes(key), `${key} missing from ${r.cap}.implementedBy`);++ }++ });++++ await check('the proposed service manifest declares every event the service emits', async () => {++ assert.strictEqual(manifest.id, 'space');++ for (const e of EVENT_TYPES) assert.ok(manifest.eventsProduced.includes(e), e);++ 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 space.* 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:space') 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(), ['space.deploy.activated', 'space.deploy.created', 'space.deploy.failed']);++ assert.ok(received.every((e) => e.source === 'space'));++ assert.strictEqual((await t.ctx.outbox.status()).pending, 0);++ await t.close();++ await events.close();++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,50 @@++'use strict';++/** Socket-free Stage B smoke test against Space's full migrated PGlite schema. */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { testDb } = require('./helpers/db');++const { createApp } = require('../server/app');++const { load } = require('../server/hosting-config');++++(async () => {++ const db = await testDb();++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-space-core-'));++ try {++ const auth = { verify: async () => null, keys: { loaded: () => false } };++ const config = load({ NODE_ENV: 'test', BASE_URL: 'https://openvibe.space', SPACE_WORKER: 'off', SPACE_MIN_FREE_BYTES: '0' });++ assert.strictEqual(config.publicHosting, false);++ assert.strictEqual(config.sitesDomain, 'openvibe.website');++ const app = await createApp({ db, auth, hostingConfig: config, storageDir: dir });++ const h = app.locals.hosting;++ const user = { kind: 'user', subject: 'usr_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', staff: false };++ await assert.rejects(h.projects.create(user, { name: 'Blocked' }), (e) => e.status === 403 && e.code === 'space.not_launched');++ user.staff = true;++ const project = await h.projects.create(user, { name: 'Trial' });++ const site = await h.sites.create(user, project, { name: 'trial-site' });++ assert.strictEqual((await h.tenant.resolve('trial-site.openvibe.website')).kind, 'site');++ assert.strictEqual((await h.tenant.resolve('trial-site.openvibe.space')).kind, 'unknown');++ const pre = await h.deploys.precheck(user, site.id);++ const first = await h.deploys.create(user, pre, [{ path: 'index.html', data: Buffer.from('<h1>First</h1>') }], { source: 'files', activate: true });++ assert.strictEqual(first.deploy.state, 'ready');++ assert.strictEqual(first.activated.changed, true);++ const second = await h.deploys.create(user, await h.deploys.precheck(user, site.id), [{ path: 'index.html', data: Buffer.from('<h1>Second</h1>') }], { source: 'files', activate: true });++ assert.notStrictEqual(first.deploy.manifest_sha256, second.deploy.manifest_sha256);++ const switched = await h.deploys.activate(user, first.deploy.id, { expectedActive: second.deploy.id });++ assert.strictEqual(switched.deploy_id, first.deploy.id);++ assert.strictEqual((await h.sites.get(site.id)).active_deploy_id, first.deploy.id);++ const rows = await db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all();++ const types = rows.map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope).event_type);++ for (const row of rows) {++ const envelope = typeof row.envelope === 'string' ? JSON.parse(row.envelope) : row.envelope;++ assert.strictEqual(require('openvibe-contracts').validate('events.event-envelope@1', envelope).valid, true);++ assert.strictEqual(require('openvibe-contracts').validate(`${envelope.event_type}@1`, envelope.payload).valid, true, envelope.event_type);++ }++ assert.deepStrictEqual(types, ['space.deploy.created', 'space.deploy.activated', 'space.deploy.created', 'space.deploy.activated', 'space.deploy.activated']);++ console.log('hosting-core: passed');++ } finally {++ await db.close();++ fs.rmSync(dir, { recursive: true, force: true });++ }++})().catch((err) => { console.error(err); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++/**++ * Regression (ADR-035): the Host dispatch middleware in server/app.js awaits tenant.resolve and++ * tenant.handle, which read the database asynchronously. Express 4 does not catch a rejected async++ * handler: a transient database error used to escape as an unhandled rejection and leave the request++ * hanging with no response. It must reach the app's error handler instead (a logged 500).++ */++const assert = require('assert');++const { boot, check, done } = require('./stageb/boot');++++/** The response, or a failure the moment it is clear no response is coming. */++function withDeadline(promise, what) {++ return Promise.race([++ promise,++ new Promise((_, reject) => {++ const timer = setTimeout(() => reject(new Error(`${what}: no response after 3000ms — the rejection never reached the error handler`)), 3000);++ promise.then(() => clearTimeout(timer), () => clearTimeout(timer));++ }),++ ]);++}++++(async () => {++ const t = await boot();++ const alice = t.user('alice');++++ await check('a failing Host resolve answers 500 instead of hanging the request', async () => {++ const original = t.ctx.tenant.resolve;++ t.ctx.tenant.resolve = async () => { throw new Error('transient database error'); };++ try {++ const r = await withDeadline(t.get('alice.openvibe.website', '/'), 'resolve');++ assert.strictEqual(r.status, 500, `expected 500, got ${r.status}: ${r.text.slice(0, 120)}`);++ assert.match(r.text, /Something went wrong/);++ } finally {++ t.ctx.tenant.resolve = original;++ }++ });++++ await check('a failing tenant handler answers 500 instead of hanging the request', async () => {++ const project = await t.project(alice, 'Dispatch errors');++ const site = await t.site(alice, project.id, 'alice');++ await t.deploy(alice, site.id, { 'index.html': 'hello' });++ const original = t.ctx.tenant.handle;++ t.ctx.tenant.handle = async () => { throw new Error('transient database error'); };++ try {++ const r = await withDeadline(t.get('alice.openvibe.website', '/'), 'handle');++ assert.strictEqual(r.status, 500, `expected 500, got ${r.status}: ${r.text.slice(0, 120)}`);++ } finally {++ t.ctx.tenant.handle = original;++ }++ });++++ await check('the site is served normally again once the failure passes', async () => {++ const r = await t.get('alice.openvibe.website', '/');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.text, 'hello');++ });++++ await t.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-space.${HOST}`);++ assert.match(dom.instructions.verification.value, /^openvibe-space-verification=[0-9a-f]{40}$/);++ assert.deepStrictEqual(dom.instructions.routing[0], { type: 'CNAME', name: HOST, value: 'alice-site.openvibe.website', 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-space.${HOST}`, ['openvibe-space-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-space.${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 = await t.events('space.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-space.${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.website', 'openvibe.space', 'openvibe.xyz', 'a.b.openvibe.media', 'openvibe.events', '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 = await 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-space.${HOST}`);++ t.clock.advance(DAY + 1000);++ await t.ctx.worker.domainTick();++ assert.strictEqual((await t.ctx.domains.get(dom.id)).status, 'verified', 'still served during the grace period');++ assert.ok((await 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((await 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,210 @@++'use strict';++/**++ * Git deploys, Phase 1 (plan T12 Stage B): a site's Git source is public provenance (repository +++ * branch) set by a maintainer; the project's own CI builds the commit and posts the output to++ * POST /api/v1/sites/:id/source/deploys with an app deployer token. Host validates the files like any++ * upload, stores a source=git deploy with an immutable space_deploy_git row and makes it the site's++ * preview; it never activates on arrival, and every refusal leaves the active deploy untouched.++ */++const assert = require('assert');++const { boot, check, done } = require('./stageb/boot');++const { site: siteTar } = require('./stageb/tar');++++const SHA = 'a'.repeat(40);++const SHA256 = '0123456789abcdef'.repeat(4);++const SOURCE_KEYS = ['created_at', 'created_by', 'provider', 'ref', 'repo_url', 'site_id', 'updated_at', 'updated_by'];++++(async () => {++ const t = await boot();++ const alice = t.user('alice');++ const dora = t.user('dora');++ const mallory = t.user('mallory');++ const project = await t.project(alice, 'Git');++ const site = await t.site(alice, project.id, 'gitsite');++ const other = await t.site(alice, project.id, 'unlinked');++ await t.api('PUT', `/api/v1/projects/${project.id}/members/${dora.subject}`, { as: alice, json: { role: 'deployer' } });++ const live = await t.deploy(alice, site.id, { 'index.html': 'LIVE' });++ await t.deploy(alice, other.id, { 'index.html': 'OTHER' });++++ const { ids } = require('openvibe-contracts');++ const appId = ids.newId('app');++ const ci = t.network.appToken(appId, ['space.site.manage', 'space.deploy.create']);++ assert.strictEqual((await t.api('PUT', `/api/v1/projects/${project.id}/members/app:${appId}`, { as: alice, json: { role: 'deployer' } })).status, 200);++++ const db = t.ctx.store.db;++ const pointers = async (id = site.id) => await db.prepare('SELECT active_deploy_id, preview_deploy_id FROM space_sites WHERE id = ?').get(id);++ const putSource = (as, body, id = site.id) => t.api('PUT', `/api/v1/sites/${id}/source`, { as, json: body });++ const ingest = (files, query, { as = ci, id = site.id } = {}) => t.api('POST', `/api/v1/sites/${id}/source/deploys?${query}`, { as, body: siteTar(files), headers: { 'content-type': 'application/gzip' } });++ const assertSource = (s) => assert.deepStrictEqual(Object.keys(s).sort(), SOURCE_KEYS, `only the allowlisted source fields: ${JSON.stringify(s)}`);++++ await check('a maintainer connects a source (200); a deployer gets 403, a non-member 404, the read role can GET', async () => {++ const r = await putSource(alice, { provider: 'github', repo_url: 'https://github.com/OpenVibers/site.git', ref: 'main' });++ assert.strictEqual(r.status, 200, r.text);++ const src = r.json().source;++ assertSource(src);++ assert.deepStrictEqual([src.provider, src.repo_url, src.ref, src.site_id], ['github', 'https://github.com/OpenVibers/site', 'main', site.id]);++ const dep = await putSource(dora, { repo_url: 'https://github.com/x/y', ref: 'main' });++ assert.strictEqual(dep.status, 403, dep.text);++ assert.strictEqual(dep.json().code, 'project.role_insufficient');++ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${site.id}/source`, { as: dora })).status, 403);++ assert.strictEqual((await putSource(mallory, { repo_url: 'https://github.com/x/y', ref: 'main' })).status, 404);++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${site.id}/source`, { as: mallory })).status, 404);++ const read = await t.api('GET', `/api/v1/sites/${site.id}/source`, { as: dora });++ assert.strictEqual(read.status, 200, read.text);++ assertSource(read.json().source);++ assert.strictEqual(read.json().source.ref, 'main');++ });++++ await check('a bad URL, provider or ref is 422 and changes nothing', async () => {++ const bad = [++ [{ repo_url: 'http://github.com/a/b', ref: 'main' }, 'source.repo_url'],++ [{ repo_url: '[email protected]:a/b.git', ref: 'main' }, 'source.repo_url'],++ [{ repo_url: 'ssh://github.com/a/b', ref: 'main' }, 'source.repo_url'],++ [{ repo_url: 'https://user:[email protected]/a/b', ref: 'main' }, 'source.repo_url'],++ [{ repo_url: 'https://github.com:8443/a/b', ref: 'main' }, 'source.repo_url'],++ [{ repo_url: 'https://github.com/a/b?x=1', ref: 'main' }, 'source.repo_url'],++ [{ repo_url: 'https://github.com/a/b#frag', ref: 'main' }, 'source.repo_url'],++ [{ repo_url: 'https://github.com/a/b/c', ref: 'main' }, 'source.repo_url'],++ [{ repo_url: 'https://evil.example/a/b', ref: 'main' }, 'source.repo_url'],++ [{ repo_url: 'https://127.0.0.1/a/b', ref: 'main' }, 'source.repo_url'],++ [{ provider: 'bitbucket', repo_url: 'https://github.com/a/b', ref: 'main' }, 'source.provider'],++ [{ provider: 'gitlab', repo_url: 'https://github.com/a/b', ref: 'main' }, 'source.provider'],++ [{ repo_url: 'https://github.com/a/b', ref: '' }, 'source.ref'],++ [{ repo_url: 'https://github.com/a/b', ref: '-main' }, 'source.ref'],++ [{ repo_url: 'https://github.com/a/b', ref: 'a..b' }, 'source.ref'],++ [{ repo_url: 'https://github.com/a/b', ref: 'main~1' }, 'source.ref'],++ [{ repo_url: 'https://github.com/a/b', ref: 'x.lock' }, 'source.ref'],++ [{ repo_url: 'https://github.com/a/b', ref: 'a b' }, 'source.ref'],++ [{ repo_url: 'https://github.com/a/b', ref: 'b'.repeat(201) }, 'source.ref'],++ [{ repo_url: 'https://github.com/a/b', ref: 'main', token: 'ghp_secret' }, 'source.unknown_field'],++ ];++ for (const [body, code] of bad) {++ const r = await putSource(alice, body);++ assert.strictEqual(r.status, 422, `${JSON.stringify(body)}: ${r.status} ${r.text}`);++ assert.strictEqual(r.json().code, code, `${JSON.stringify(body)}: ${r.text}`);++ assert.ok(!r.text.includes('ghp_secret'));++ }++ const now = (await t.api('GET', `/api/v1/sites/${site.id}/source`, { as: alice })).json().source;++ assert.strictEqual(now.repo_url, 'https://github.com/OpenVibers/site');++ for (const ok of [{ repo_url: 'https://gitlab.com/g/p', ref: 'release/1.2' }, { repo_url: 'https://codeberg.org/o/r.git', ref: 'feature/x-y_z' }]) {++ const r = await putSource(alice, ok, other.id);++ assert.strictEqual(r.status, 200, r.text);++ }++ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${other.id}/source`, { as: alice })).status, 200);++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${other.id}/source`, { as: alice })).json().source, null);++ });++++ let git = null;++ await check('an app deployer ingests a build: source=git, provenance row, preview set, the old deploy still served', async () => {++ const created = (await t.events('space.deploy.created')).length;++ const r = await ingest({ 'index.html': 'FROM GIT' }, `ref=main&commit_sha=${SHA}`);++ assert.strictEqual(r.status, 201, r.text);++ const body = r.json();++ git = body.deploy;++ assert.strictEqual(git.source, 'git');++ assert.strictEqual(git.state, 'ready');++ assert.strictEqual(git.active, false);++ assert.strictEqual(git.created_by, `app:${appId}`);++ assert.deepStrictEqual(git.git, { provider: 'github', repo_url: 'https://github.com/OpenVibers/site', ref: 'main', commit_sha: SHA });++ assert.strictEqual(body.activated, false);++ assert.strictEqual(body.preview.deploy_id, git.id);++ const row = await db.prepare('SELECT * FROM space_deploy_git WHERE deploy_id = ?').get(git.id);++ assert.deepStrictEqual([row.provider, row.repo_url, row.ref, row.commit_sha], ['github', 'https://github.com/OpenVibers/site', 'main', SHA]);++ assert.deepStrictEqual(await pointers(), { active_deploy_id: live.id, preview_deploy_id: git.id });++ assert.strictEqual((await t.get('gitsite.openvibe.website', '/')).text, 'LIVE');++ assert.strictEqual((await t.events('space.deploy.created')).length, created + 1);++ assert.strictEqual((await t.events('space.deploy.created')).pop().payload.source, 'git');++ const listed = (await t.api('GET', `/api/v1/sites/${site.id}/deploys`, { as: dora })).json().deploys.find((d) => d.id === git.id);++ assert.strictEqual(listed.git.commit_sha, SHA);++ const one = (await t.api('GET', `/api/v1/deploys/${git.id}`, { as: dora })).json().deploy;++ assert.deepStrictEqual(Object.keys(one.git).sort(), ['commit_sha', 'provider', 'ref', 'repo_url']);++ // A user Bearer token (a deployer) may ingest too; a 64-hex SHA-256 commit id is accepted.++ const byUser = await ingest({ 'index.html': 'FROM GIT 2' }, `ref=main&commit_sha=${SHA256}`, { as: dora });++ assert.strictEqual(byUser.status, 201, byUser.text);++ assert.strictEqual(byUser.json().deploy.git.commit_sha, SHA256);++ assert.strictEqual((await pointers()).active_deploy_id, live.id);++ git = byUser.json().deploy;++ });++++ await check('activate approves it: active_deploy_id flips, the preview clears, the site serves it', async () => {++ const r = await t.api('POST', `/api/v1/deploys/${git.id}/activate`, { as: ci, json: { expected_active: live.id } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(await pointers(), { active_deploy_id: git.id, preview_deploy_id: null });++ assert.strictEqual((await t.get('gitsite.openvibe.website', '/')).text, 'FROM GIT 2');++ assert.strictEqual((await t.events('space.deploy.activated')).pop().payload.deploy_id, git.id);++ });++++ await check('refusals leave active_deploy_id unchanged; an invalid file is a failed deploy + space.deploy.failed', async () => {++ const before = await pointers();++ const failedBefore = (await t.events('space.deploy.failed')).length;++ const deploysBefore = (await db.prepare('SELECT COUNT(*) AS n FROM space_deploys').get()).n;++++ const bad = await ingest({ 'index.html': 'x', 'shell.php': '<?php system($_GET[1]);' }, `ref=main&commit_sha=${SHA}`);++ assert.strictEqual(bad.status, 422, bad.text);++ const failed = (await t.api('GET', `/api/v1/deploys/${bad.json().deploy_id}`, { as: alice })).json().deploy;++ assert.deepStrictEqual([failed.state, failed.source], ['failed', 'git']);++ assert.strictEqual((await t.events('space.deploy.failed')).length, failedBefore + 1);++ assert.strictEqual(await db.prepare('SELECT 1 FROM space_deploy_git WHERE deploy_id = ?').get(failed.id), undefined, 'no provenance row for a refused deploy');++++ const mismatch = await ingest({ 'index.html': 'x' }, `ref=dev&commit_sha=${SHA}`);++ assert.strictEqual(mismatch.status, 409, mismatch.text);++ assert.strictEqual(mismatch.json().code, 'source.ref_mismatch');++ const noRef = await ingest({ 'index.html': 'x' }, `commit_sha=${SHA}`);++ assert.strictEqual(noRef.status, 422, noRef.text);++ for (const sha of ['abc', 'A'.repeat(40), 'g'.repeat(40), 'a'.repeat(41), '']) {++ const r = await ingest({ 'index.html': 'x' }, `ref=main&commit_sha=${sha}`);++ assert.strictEqual(r.status, 422, `${sha}: ${r.text}`);++ assert.strictEqual(r.json().code, 'source.commit_sha');++ }++ const unlinked = await ingest({ 'index.html': 'x' }, `ref=main&commit_sha=${SHA}`, { id: other.id, as: alice });++ assert.strictEqual(unlinked.status, 409, unlinked.text);++ assert.strictEqual(unlinked.json().code, 'source.not_connected');++ const act = await ingest({ 'index.html': 'x' }, `ref=main&commit_sha=${SHA}&activate=1`);++ assert.strictEqual(act.status, 422, act.text);++ assert.strictEqual(act.json().code, 'source.activate');++++ assert.deepStrictEqual(await pointers(), before);++ assert.strictEqual((await t.get('gitsite.openvibe.website', '/')).text, 'FROM GIT 2');++ assert.strictEqual((await pointers(other.id)).active_deploy_id, (await db.prepare('SELECT id FROM space_deploys WHERE site_id = ? AND state = \'ready\'').get(other.id)).id);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM space_deploys').get()).n, deploysBefore + 1, 'only the invalid file left a (failed) deploy');++ assert.strictEqual((await t.events('space.deploy.failed')).length, failedBefore + 1);++ });++++ await check('activate as a multipart field is refused too, and a non-member cannot ingest', async () => {++ const boundary = 'xGitBoundary';++ const tar = siteTar({ 'index.html': 'MP' });++ const body = Buffer.concat([++ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="activate"\r\n\r\n1\r\n--${boundary}\r\nContent-Disposition: form-data; name="archive"; filename="site.tar.gz"\r\nContent-Type: application/gzip\r\n\r\n`),++ tar, Buffer.from(`\r\n--${boundary}--\r\n`),++ ]);++ const before = await pointers();++ const r = await t.api('POST', `/api/v1/sites/${site.id}/source/deploys?ref=main&commit_sha=${SHA}`, { as: ci, body, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } });++ assert.strictEqual(r.status, 422, r.text);++ assert.strictEqual(r.json().code, 'source.activate');++ assert.strictEqual((await ingest({ 'index.html': 'x' }, `ref=main&commit_sha=${SHA}`, { as: mallory })).status, 404);++ assert.deepStrictEqual(await pointers(), before);++ });++++ await check('the dashboard form (form token + origin) saves and removes a source at maintain; a deployer cannot', async () => {++ const { csrfToken } = require('../server/auth/forms');++ const origin = { origin: 'https://openvibe.space' };++ const form = (user, p, body) => t.api('POST', p, { session: user, form: { csrf: csrfToken({ formSecret: 'test-form-secret' }, { subject: user.subject }), ...body }, headers: origin });++ const saved = await form(alice, `/sites/${other.id}/source`, { repo_url: 'https://codeberg.org/o/r', ref: 'pages' });++ assert.strictEqual(saved.status, 303, saved.text);++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${other.id}/source`, { as: alice })).json().source.ref, 'pages');++ const forged = await t.api('POST', `/sites/${other.id}/source`, { session: alice, form: { csrf: 'nope', repo_url: 'https://github.com/x/y', ref: 'main' }, headers: origin });++ assert.notStrictEqual(forged.status, 303);++ assert.strictEqual((await form(dora, `/sites/${other.id}/source/remove`, {})).status, 403);++ assert.strictEqual((await form(alice, `/sites/${other.id}/source/remove`, {})).status, 303);++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${other.id}/source`, { as: alice })).json().source, null);++ });++++ await check('space_deploy_git rows are immutable', async () => {++ await assert.rejects(db.prepare("UPDATE space_deploy_git SET commit_sha = 'b' WHERE deploy_id = ?").run(git.id), /immutable/);++ await assert.rejects(db.prepare("UPDATE space_deploy_git SET ref = 'dev' WHERE deploy_id = ?").run(git.id), /immutable/);++ assert.strictEqual((await db.prepare('SELECT commit_sha FROM space_deploy_git WHERE deploy_id = ?').get(git.id)).commit_sha, SHA256);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,210 @@++'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, the API,++ * cross-origin reads from a tenant page (no CORS grant anywhere), existence oracles (ETag, Range),++ * delegated service tokens, app principals and the dashboard: a tenant only ever reaches its own++ * site, deploys and domains.++ */++const assert = require('assert');++const fs = require('fs');++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.website', '/')).text, 'ALPHA HOME');++ assert.strictEqual((await t.get('beta.openvibe.website', '/')).text, 'BETA HOME');++ assert.strictEqual((await t.get('alpha.openvibe.website', '/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.website/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', '/space.db', '/../space.db', '/%2e%2e/space.db',++ ];++ for (const p of targets) {++ const r = await t.get('alpha.openvibe.website', 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.WEBSITE', 'ALPHA HOME'], ['alpha.openvibe.website.', 'ALPHA HOME'], ['alpha.openvibe.website:443', 'ALPHA HOME'],++ ['beta.alpha.openvibe.website', null], ['alpha.beta.openvibe.website', null], ['alpha.openvibe.website.evil.example', null],++ ['[email protected]', null], ['alpha.openvibe.website:80:beta', null], ['alpha.openvibe.website/beta', null],++ ['beta', null], ['openvibe.space.beta', null], ['-beta.openvibe.website', null], ['%62eta.openvibe.website', 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.space', '/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.website', 'http://beta.openvibe.website/secret.html');++ assert.strictEqual(r.status, 400);++ assert.ok(!leaks(r));++ const ok = await t.get('beta.openvibe.website', 'http://beta.openvibe.website/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', '/limits.json', '/css/space.css']) {++ const r = await t.get('alpha.openvibe.website', 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('the dashboard answers /limits.json from config (WS-N task 7)', async () => {++ const r = await t.api('GET', '/limits.json');++ assert.strictEqual(r.status, 200);++ const by = Object.fromEntries(r.json().limits.map((l) => [l.id, l]));++ assert.deepStrictEqual([by.sites.production, by.sites.sandbox], [t.config.quotas.production.sites, t.config.quotas.sandbox.sites]);++ assert.strictEqual(by.projects_per_owner.production, t.config.projects.maxPerOwner);++ assert.strictEqual(by.storage_bytes.exceeded, '413 quota.storage');++ });++++ 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.website', '/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.website', '/')).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.website', '/')));++ });++++ 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 check('a tenant page\'s scripts cannot read another tenant\'s files: no CORS grant on tenant responses, preflights refused', async () => {++ for (const origin of ['https://alpha.openvibe.website', 'null', 'https://evil.example']) {++ const r = await t.get('beta.openvibe.website', '/secret.html', { headers: { origin } });++ assert.strictEqual(r.status, 200);++ for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith('access-control-'), `beta answered ${origin} with ${h}`);++ const pre = await t.request({ method: 'OPTIONS', host: 'beta.openvibe.website', path: '/secret.html', headers: { origin, 'access-control-request-method': 'GET' } });++ assert.strictEqual(pre.status, 405);++ assert.ok(!Object.keys(pre.headers).some((h) => h.startsWith('access-control-')));++ }++ assert.strictEqual((await t.get('beta.openvibe.website', '/secret.html')).headers['cross-origin-opener-policy'], 'same-origin');++ });++++ await check('the Host API grants CORS to no tenant origin (a tenant page cannot call it from a visitor\'s browser)', async () => {++ for (const origin of ['https://alpha.openvibe.website', 'https://beta.openvibe.website', 'https://openvibe.space.evil.example']) {++ const r = await t.api('GET', '/api/v1/projects', { as: bob, headers: { origin } });++ assert.strictEqual(r.headers['access-control-allow-origin'], undefined, origin);++ const pre = await t.request({ method: 'OPTIONS', host: 'openvibe.space', path: '/api/v1/projects', headers: { origin, 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });++ assert.strictEqual(pre.headers['access-control-allow-origin'], undefined, `preflight from ${origin}`);++ }++ });++++ await check('no existence oracle: another tenant\'s sha256 as If-None-Match or a Range on alpha is still a 404', async () => {++ const r = await t.get('alpha.openvibe.website', '/secret.html', { headers: { 'if-none-match': `"${secretSha}"` } });++ assert.strictEqual(r.status, 404);++ const r2 = await t.get('alpha.openvibe.website', `/${secretSha}`, { headers: { 'if-none-match': `"${secretSha}"`, range: 'bytes=0-3' } });++ assert.strictEqual(r2.status, 404);++ assert.ok(!leaks(r) && !leaks(r2));++ });++++ await check('delegation and app principals stay inside their own project', async () => {++ const codes = t.network.serviceToken('codes', ['space.site.manage', 'space.deploy.create', 'space.domain.manage']);++ for (const p of [`/api/v1/projects/${pb.id}`, `/api/v1/deploys/${db.id}`, `/api/v1/deploys/${db.id}/log`, `/api/v1/sites/${beta.id}/deploys`]) {++ const r = await t.api('GET', p, { as: codes, headers: { 'x-ov-subject': alice.subject } });++ assert.strictEqual(r.status, 404, `svc:codes for alice → ${p} ${r.status}`);++ assert.ok(!leaks(r) && !r.text.includes(secretSha));++ }++ const ci = t.network.appToken('app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', ['space.deploy.create', 'space.site.manage']);++ const add = await t.api('PUT', `/api/v1/projects/${pa.id}/members/app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3`, { as: alice, json: { role: 'deployer' } });++ assert.strictEqual(add.status, 200, add.text);++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/deploys`, { as: ci })).status, 200, 'the app reads its own project');++ for (const p of [`/api/v1/deploys/${db.id}`, `/api/v1/sites/${beta.id}/deploys`]) assert.strictEqual((await t.api('GET', p, { as: ci })).status, 404, p);++ const up = await t.api('POST', `/api/v1/sites/${beta.id}/deploys?activate=1`, { as: ci, body: require('./stageb/tar').site({ 'index.html': 'PWNED' }), headers: { 'content-type': 'application/gzip' } });++ assert.strictEqual(up.status, 404);++ // X-OV-Subject is honoured for first-party services only: an app cannot borrow bob's identity.++ const borrow = await t.api('GET', `/api/v1/deploys/${db.id}`, { as: ci, headers: { 'x-ov-subject': bob.subject } });++ assert.strictEqual(borrow.status, 400);++ assert.strictEqual(borrow.json().code, 'subject.not_delegable');++ assert.ok(!leaks(borrow));++ assert.strictEqual((await t.get('beta.openvibe.website', '/')).text, 'BETA HOME');++ });++++ await check('the dashboard (cookie session) shows a non-member nothing of another tenant: no names, files or hashes', async () => {++ for (const p of [`/projects/${pb.id}`, `/sites/${beta.id}`, `/deploys/${db.id}`]) {++ const r = await t.api('GET', p, { session: alice });++ assert.strictEqual(r.status, 404, `${p} → ${r.status}`);++ assert.ok(!/secret\.html|BETA|beta-example/.test(r.text) && !r.text.includes(secretSha), `${p} leaked`);++ }++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++/** Public hosting remains gated while the engine works for OpenVibe staff. */++const assert = require('assert');++const { boot, check, done } = require('./stageb/boot');++++(async () => {++ const t = await boot({ env: { SPACE_PUBLIC_HOSTING: 'off' } });++ const alice = t.user('alice');++ const staff = t.user('root', { role: 'admin' });++ await check('the public front page explains the tested engine and launch gate', async () => {++ const r = await t.api('GET', '/');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Static site hosting is built and tested/);++ assert.match(r.text, /OpenVibe staff only until public launch/);++ assert.match(r.text, /designated DMCA agent/);++ assert.match(r.text, /Dynamic pages/);++ });++ await check('a person cannot create a project until launch', async () => {++ const r = await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'Too early' } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'space.not_launched');++ });++ await check('the dashboard explains why the form is unavailable', async () => {++ const r = await t.api('GET', '/dashboard', { session: alice });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /not open for hosting yet/);++ assert.doesNotMatch(r.text, /<form[^>]+action="\/projects"/);++ });++ await check('staff can create a project', async () => {++ assert.match((await t.project(staff, 'Staff test')).id, /^prj_/);++ });++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });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, /^sit_/);++ assert.strictEqual(site.hostname, 'alice.openvibe.website');++ assert.strictEqual(site.url, 'https://alice.openvibe.website');++ 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.website', 'default', 'verified']]);++ });++++ await check('before any deploy the site answers with its own 404', async () => {++ const r = await t.get('alice.openvibe.website', '/');++ 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.website', '/');++ 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.website', '/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.website', '/img/logo.svg');++ assert.strictEqual(svg.headers['content-type'], 'image/svg+xml');++ const cname = await t.get('alice.openvibe.website', '/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.website', '/style.css');++ const again = await t.get('alice.openvibe.website', '/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.website', '/style.css', { method: 'HEAD' });++ assert.strictEqual(head.status, 200);++ assert.strictEqual(head.headers['content-length'], '6');++ const range = await t.get('alice.openvibe.website', '/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.website', '/style.css', { headers: { range: 'bytes=10-20' } });++ assert.strictEqual(bad.status, 416);++ const redirect = await t.get('alice.openvibe.website', '/about?x=1');++ assert.strictEqual(redirect.status, 301);++ assert.strictEqual(redirect.headers.location, '/about/?x=1');++ const about = await t.get('alice.openvibe.website', '/about/');++ assert.strictEqual(about.text, '<p>about</p>');++ const missing = await t.get('alice.openvibe.website', '/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.website', '/', { 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.website', '/')).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.website', '/')).text, '<h1>v2</h1>');++ assert.strictEqual((await t.get('alice.openvibe.website', '/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.website', '/')).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.website', '/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.website', '/')).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 = await t.events('space.deploy.created');++ const activated = await t.events('space.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, 'space');++ 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.website', '/')).status, 404);++ assert.match((await t.get('alice.openvibe.website', '/')).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,29 @@++'use strict';++/** Space discovery describes staff static hosting without listing tenant or private pages. */++const assert = require('assert');++const { boot, check, done, DASHBOARD } = require('./stageb/boot');++++(async () => {++ const t = await boot();++ await check('llms documents describe Space and omit tenant pages', async () => {++ for (const file of ['/llms.txt', '/llms-full.txt']) {++ const r = await t.get(DASHBOARD, file);++ assert.strictEqual(r.status, 200);++ assert.match(r.headers['content-type'], /^text\/plain/);++ assert.match(r.text, /OpenVibe.Space/);++ assert.match(r.text, /OpenVibe.Community/);++ assert.doesNotMatch(r.text, /\/preview\/|\/dashboard\/|\/api\/v1\/projects/);++ }++ });++ await check('the public page and sitemap stay on Space', async () => {++ const r = await t.get(DASHBOARD, '/');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Static site hosting is built and tested/);++ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.space\/">/);++ const map = await t.get(DASHBOARD, '/sitemap.xml');++ assert.strictEqual(map.status, 200);++ assert.deepStrictEqual([...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]), ['https://openvibe.space/']);++ });++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,153 @@++'use strict';++/**++ * Preview deploys (plan T12 J4, decision D5). Uploading a deploy with ?preview=1 stores it with++ * source 'preview' and makes it the site's live preview (space_sites.preview_deploy_id) WITHOUT++ * activating it: the public site keeps serving its active deploy. A preview is served only on the++ * dashboard host, at /preview/<deploy-id>/…, only to a member of the deploy's project, always++ * noindex and never cached by a shared cache; it is never pinged to IndexNow and never appears in++ * the site's sitemap. It stops being served when it expires, when the site deploys or rolls back,++ * or when the preview is deleted, and it can only reach its own project's objects.++ */++const assert = require('assert');++const { boot, check, done, DASHBOARD } = require('./stageb/boot');++const { PREVIEW_TTL_MS } = require('../server/domain/deploys');++++/** A spy in place of IndexNow's HTTP send: records every pingSoon batch (the repo's test pattern). */++function makeSpy() {++ const pings = [];++ return {++ pings,++ module: {++ enabled: true,++ keyFile: (_req, _res, next) => next(),++ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },++ ping: async () => ({ sent: 0, status: 0 }),++ flush: async () => ({ sent: 0, status: 0 }),++ },++ };++}++++(async () => {++ const s = makeSpy();++ const t = await boot({ indexnow: s.module });++ const alice = t.user('alice');++ const bob = t.user('bob');++ const pa = await t.project(alice, 'A');++ const pb = await t.project(bob, 'B');++ const alpha = await t.site(alice, pa.id, 'alpha');++ const beta = await t.site(bob, pb.id, 'beta');++ await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA LIVE', 'about.html': 'ALPHA ABOUT' });++ await t.deploy(bob, beta.id, { 'index.html': 'BETA LIVE', 'secret.txt': 'BETA SECRET' });++ s.pings.length = 0;++++ const preview = (id, p = '/') => `/preview/${id}${p}`;++ const asMember = (id, p, user) => t.get(DASHBOARD, preview(id, p), { session: user });++ let pv = null;++++ await check('a preview is stored as source=preview, points the site at it, and never goes active', async () => {++ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW', 'about.html': 'PREVIEW ABOUT' }, { activate: false, query: 'preview=1' });++ assert.strictEqual(r.status, 201, r.text);++ const body = r.json();++ pv = body.deploy;++ assert.strictEqual(pv.source, 'preview');++ assert.strictEqual(body.activated, false, 'a preview is never activated');++ assert.ok(body.preview && body.preview.deploy_id === pv.id, r.text);++ assert.ok(body.preview.expires_at > t.clock.now());++ const pub = await t.get('alpha.openvibe.website', '/');++ assert.strictEqual(pub.status, 200);++ assert.strictEqual(pub.text, 'ALPHA LIVE', 'the public site keeps serving its active deploy');++ assert.ok(!s.pings.some((u) => u.includes('/preview/')), 'a preview is never pinged');++ });++++ await check('a member views the preview on the dashboard: 200, noindex, no-store, no cookie', async () => {++ const r = await asMember(pv.id, '/', alice);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.text, 'ALPHA PREVIEW');++ assert.strictEqual(r.headers['x-robots-tag'], 'noindex, nofollow');++ assert.match(r.headers['cache-control'], /no-store/);++ assert.strictEqual(r.headers['set-cookie'], undefined);++ assert.strictEqual(r.headers['x-openvibe-deploy'], pv.id);++ // Tenant content on the dashboard's own origin is sandboxed into an opaque origin: it can++ // never act as openvibe.space (no allow-same-origin, never the dashboard's own CSP).++ assert.match(r.headers['content-security-policy'], /sandbox /);++ assert.ok(!r.headers['content-security-policy'].includes('allow-same-origin'), 'the preview must not keep the dashboard origin');++ assert.ok(r.headers['content-security-policy'].startsWith("default-src 'none'"), 'not the dashboard CSP');++ const file = await asMember(pv.id, '/about.html', alice);++ assert.strictEqual(file.status, 200);++ assert.strictEqual(file.text, 'PREVIEW ABOUT');++ const head = await t.get(DASHBOARD, preview(pv.id), { method: 'HEAD', session: alice });++ assert.strictEqual(head.status, 200);++ assert.strictEqual(head.text, '');++ });++++ await check('non-members and signed-out callers cannot view a preview', async () => {++ assert.strictEqual((await asMember(pv.id, '/', bob)).status, 404, 'another project member must not see it');++ assert.strictEqual((await t.get(DASHBOARD, preview(pv.id))).status, 404, 'signed out must not see it');++ assert.strictEqual((await t.get(DASHBOARD, preview(pv.id), { as: bob })).status, 404, 'a bearer token is not a dashboard session');++ assert.strictEqual((await asMember('dpl_00000000000000000000000000', '/', alice)).status, 404);++ assert.strictEqual((await asMember('not-an-id', '/', alice)).status, 404);++ });++++ await check('a preview is never served on a public tenant host', async () => {++ for (const host of ['alpha.openvibe.website', 'beta.openvibe.website']) {++ const r = await t.get(host, preview(pv.id));++ assert.strictEqual(r.status, 404, `${host} → ${r.status}`);++ assert.ok(!r.text.includes('ALPHA PREVIEW'), host);++ }++ });++++ await check('isolation: another project\'s preview and another project\'s files are unreachable', async () => {++ const b = await t.upload(bob, beta.id, { 'index.html': 'BETA PREVIEW' }, { activate: false, query: 'preview=1' });++ assert.strictEqual(b.status, 201, b.text);++ const bpv = b.json().deploy;++ assert.strictEqual((await asMember(bpv.id, '/', alice)).status, 404, 'alice is not on beta');++ assert.strictEqual((await asMember(bpv.id, '/', bob)).status, 200);++ assert.strictEqual((await asMember(pv.id, '/secret.txt', alice)).status, 404, 'alpha cannot read beta\'s same-named file');++ for (const p of ['/../secret.txt', '/%2e%2e/secret.txt', '/a%2fb', '/%00']) {++ assert.strictEqual((await asMember(pv.id, p, alice)).status, 404, p);++ }++ });++++ await check('a rollback makes the live preview vanish', async () => {++ await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA LIVE 2' });++ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 2' }, { activate: false, query: 'preview=1' });++ const p2 = r.json().deploy;++ assert.strictEqual((await asMember(p2.id, '/', alice)).status, 200);++ const rb = await t.api('POST', `/api/v1/sites/${alpha.id}/rollback`, { as: alice, json: {} });++ assert.strictEqual(rb.status, 200, rb.text);++ assert.strictEqual((await asMember(p2.id, '/', alice)).status, 404, 'a rollback supersedes the preview');++ });++++ await check('an expired preview vanishes', async () => {++ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 3' }, { activate: false, query: 'preview=1' });++ const p3 = r.json().deploy;++ assert.strictEqual((await asMember(p3.id, '/', alice)).status, 200);++ t.clock.advance(PREVIEW_TTL_MS + 1000);++ assert.strictEqual((await asMember(p3.id, '/', alice)).status, 404, 'an expired preview is not served');++ });++++ await check('deleting the preview deploy makes it vanish', async () => {++ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 4' }, { activate: false, query: 'preview=1' });++ const p4 = r.json().deploy;++ assert.strictEqual((await asMember(p4.id, '/', alice)).status, 200);++ const del = await t.api('DELETE', `/api/v1/deploys/${p4.id}`, { as: alice, json: {} });++ assert.strictEqual(del.status, 200, del.text);++ assert.strictEqual((await asMember(p4.id, '/', alice)).status, 404, 'a deleted preview is not served');++ });++++ await check('a preview is never listed in the sitemap and never pinged', async () => {++ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 5', 'page.html': 'PAGE' }, { activate: false, query: 'preview=1' });++ const p5 = r.json().deploy;++ assert.strictEqual((await asMember(p5.id, '/page.html', alice)).status, 200);++ s.pings.length = 0;++ const sm = await t.get('alpha.openvibe.website', '/sitemap.xml');++ assert.strictEqual(sm.status, 200, sm.text);++ assert.ok(!sm.text.includes('/preview/'), 'the sitemap never lists a preview URL');++ assert.ok(!sm.text.includes(p5.id), 'the sitemap never names a preview deploy');++ assert.ok(!sm.text.includes('ALPHA PREVIEW'), 'the sitemap cannot leak draft content');++ assert.deepStrictEqual(s.pings, [], 'viewing a preview pings nothing');++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,216 @@++'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: { SPACE_MAX_UPLOAD_BYTES: String(64 * 1024), SPACE_MAX_UNPACKED_BYTES: String(1024 * 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.website', '/')).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 SPACE_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('decompression: an archive unpacks to at most SPACE_MAX_UNPACKED_BYTES, however large the storage quota', async () => {++ // A few KiB of gzip that expands to 2 MiB (the default production quota would allow 1 GiB:++ // a 1 MB request would otherwise inflate to 1 GiB in memory and block the event loop).++ const zeros = Buffer.alloc(512 * 1024, 0);++ const bomb = siteTar({ 'a.png': zeros, 'b.png': zeros, 'c.png': zeros, 'd.png': zeros });++ assert.ok(bomb.length < 64 * 1024, 'well under the request limit');++ const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body: bomb, headers: { 'content-type': 'application/gzip' } });++ assert.strictEqual(r.status, 413, r.text);++ assert.strictEqual(r.json().code, 'deploy.too_large');++ });++++ 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.website', '/')).headers['x-robots-tag'], 'noindex, nofollow');++ });++++ // ── Principals ──────────────────────────────────────────++ const ALL = ['space.site.manage', 'space.deploy.create', 'space.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', ['space.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', ['space.*']);++ 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 space.* 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((await t.events('space.deploy.activated')).pop().actor, { type: 'app', id: appId });++ assert.strictEqual((await t.get('quota-site.openvibe.website', '/')).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('a FedCM ID assertion is not a session: a tenant page on <site>.openvibe.space cannot act as its visitor', async () => {++ // Network signs FedCM assertions with the key and issuer of its access tokens, for any RP++ // origin under an OpenVibe zone, tenant subdomains of openvibe.space included.++ const assertion = t.network.fedcmAssertion(alice, 'https://evil-tenant.openvibe.website');++ const list = await t.api('GET', '/api/v1/projects', { as: assertion });++ assert.strictEqual(list.status, 401, list.text);++ const del = await t.api('DELETE', `/api/v1/projects/${project.id}`, { as: assertion });++ assert.strictEqual(del.status, 401, del.text);++ const dash = await t.request({ method: 'GET', host: 'openvibe.space', path: '/', headers: { cookie: `ov_token=${assertion}` } });++ assert.ok(!/Your projects/.test(dash.text), 'the dashboard does not accept it as a session either');++ });++++ 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.space'], 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.website', '/')).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.website', 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: (await db.prepare('SELECT active_deploy_id FROM space_sites WHERE id = ?').get(site.id)).active_deploy_id, acts: (await db.prepare('SELECT COUNT(*) AS n FROM space_activations').get()).n, events: (await 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((await db.prepare('SELECT active_deploy_id FROM space_sites WHERE id = ?').get(site.id)).active_deploy_id, before.active);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM space_activations').get()).n, before.acts);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM event_outbox').get()).n, before.events);++ assert.strictEqual((await t.get('flip.openvibe.website', '/')).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;++ await assert.rejects(db.prepare("UPDATE space_deploys SET manifest = '{}' WHERE id = ?").run(v1.id), /immutable/);++ await assert.rejects(db.prepare('UPDATE space_deploys SET total_bytes = 1 WHERE id = ?').run(v1.id), /immutable/);++ await assert.rejects(db.prepare("UPDATE space_deploy_files SET sha256 = 'x' WHERE deploy_id = ?").run(v1.id), /immutable/);++ await assert.rejects(db.prepare('DELETE FROM space_deploy_files WHERE deploy_id = ?').run(v1.id), /live deploy/);++ await db.prepare("UPDATE space_deploys SET state = 'deleted' WHERE id = (SELECT id FROM space_deploys WHERE state = 'failed' LIMIT 1)").run();++ await assert.rejects(db.prepare("UPDATE space_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.website', '/')).text, 'version-2');++ assert.strictEqual((await t.get('flip.openvibe.website', '/only-v2.txt')).text, 'yes');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++/** Hosting responses do not expose secrets; Space's own auth protects dashboard forms. */++const assert = require('assert');++const { boot, check, done, SECRET } = require('./stageb/boot');++const { tarball } = require('./stageb/tar');++const { csrfToken } = require('../server/auth/forms');++++(async () => {++ const t = await boot();++ const alice = t.user('alice');++ let project, site;++ await check('API, pages and events contain no upload or service secrets', async () => {++ const bodies = [];++ const keep = (r) => { bodies.push(r.text); return r; };++ project = keep(await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'Secrets' } })).json().project;++ site = keep(await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: alice, json: { name: 'secret-test' } })).json().site;++ const deploy = keep(await t.upload(alice, site.id, { 'index.html': 'hello' })).json().deploy;++ const bad = keep(await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body: tarball([++ { name: 'index.html', content: 'x' }, { name: '.env', content: 'STRIPE_SECRET_KEY=sk_live_TOPSECRET123' },++ ]), headers: { 'content-type': 'application/gzip' } }));++ assert.strictEqual(bad.status, 422);++ for (const route of ['/', '/dashboard', `/projects/${project.id}`, `/sites/${site.id}`, `/deploys/${deploy.id}`, '/api/ready', '/release.json']) {++ keep(await t.api('GET', route, { session: alice }));++ }++ for (const route of [`/api/v1/projects/${project.id}`, `/api/v1/sites/${site.id}`, `/api/v1/deploys/${deploy.id}/log`]) keep(await t.api('GET', route, { as: alice }));++ const all = bodies.join('\n') + JSON.stringify(await t.events());++ for (const needle of [SECRET, 'test-form-secret', 'sk_live_TOPSECRET123', 'STRIPE_SECRET_KEY', 'OV_OAUTH_CLIENT_SECRET', 'SPACE_FORM_SECRET']) assert.ok(!all.includes(needle), needle);++ });++ await check('dashboard forms require Space session, matching origin and CSRF token', async () => {++ const home = await t.api('GET', '/dashboard', { session: alice });++ assert.strictEqual(home.status, 200);++ assert.match(home.text, /Your projects/);++ const csrf = csrfToken(t.config, { subject: alice.subject });++ const action = `/projects/${project.id}/sites`;++ const cross = await t.api('POST', action, { session: alice, form: { csrf, name: 'cross-site' }, headers: { origin: 'https://attacker.example' } });++ assert.strictEqual(cross.status, 403);++ const missing = await t.api('POST', action, { session: alice, form: { name: 'no-token' }, headers: { origin: 'https://openvibe.space' } });++ assert.strictEqual(missing.status, 403);++ const valid = await t.api('POST', action, { session: alice, form: { csrf, name: 'from-form' }, headers: { origin: 'https://openvibe.space' } });++ assert.strictEqual(valid.status, 303);++ });++ await check('hosting API ignores browser cookies', async () => {++ const r = await t.api('GET', '/api/v1/projects', { session: alice });++ assert.strictEqual(r.status, 401);++ });++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,149 @@++'use strict';++/**++ * Per-site configuration (plan T12 J3, decision D4): response headers, local-only redirects and the++ * SPA fallback, stored per site (space_site_config) and applied by http/tenant.js BEFORE the++ * platform's headers. The platform's security, routing and caching headers can never be set through++ * it (on write or from a smuggled row); a redirect target is always a local path; and a config is++ * private to its site and project.++ */++const assert = require('assert');++const { boot, check, done } = require('./stageb/boot');++++const EMPTY = { headers: {}, redirects: [], spa: false };++++(async () => {++ const t = await boot();++ const alice = t.user('alice');++ const bob = t.user('bob');++ const staff = t.user('root', { role: 'admin' });++ const pa = await t.project(alice, 'A');++ const pb = await t.project(bob, 'B');++ const alpha = await t.site(alice, pa.id, 'alpha');++ const beta = await t.site(bob, pb.id, 'beta');++ await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA HOME', 'about.html': 'ALPHA ABOUT', 'app.3f2a9c1b.js': 'js();', '404.html': 'ALPHA NOT FOUND' });++ await t.deploy(bob, beta.id, { 'index.html': 'BETA HOME' });++++ const put = (as, id, json) => t.api('PUT', `/api/v1/sites/${id}/config`, { as, json });++++ await check('defaults: no row means no headers, no redirects, no SPA', async () => {++ const r = await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: alice });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json().config, EMPTY);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM space_site_config').get()).n, 0);++ });++++ await check('the API sets, reads and resets a config', async () => {++ const set = await put(alice, alpha.id, {++ headers: { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' },++ redirects: [{ from: '/old', to: '/new', status: 308 }, { from: '/moved', to: '/about.html' }],++ spa: true,++ });++ assert.strictEqual(set.status, 200, set.text);++ assert.deepStrictEqual(set.json().config.headers, { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' });++ assert.deepStrictEqual(set.json().config.redirects, [{ from: '/old', to: '/new', status: 308 }, { from: '/moved', to: '/about.html', status: 301 }]);++ assert.strictEqual(set.json().config.spa, true);++ assert.deepStrictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: alice })).json().config, set.json().config);++ const del = await t.api('DELETE', `/api/v1/sites/${alpha.id}/config`, { as: alice });++ assert.strictEqual(del.status, 200, del.text);++ assert.deepStrictEqual(del.json().config, EMPTY);++ assert.strictEqual((await t.get('alpha.openvibe.website', '/old')).status, 404, 'reset drops the redirect');++ });++++ await check('configured headers ride on 200, the 404 and the 451 after a takedown', async () => {++ assert.strictEqual((await put(alice, alpha.id, { headers: { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' } })).status, 200);++ const ok = await t.get('alpha.openvibe.website', '/');++ assert.strictEqual(ok.status, 200);++ assert.strictEqual(ok.headers['x-frame-options'], 'SAMEORIGIN');++ assert.strictEqual(ok.headers['x-content-language'], 'en');++ assert.strictEqual(ok.headers['x-served-by'], 'OpenVibe.Space');++ const nf = await t.get('alpha.openvibe.website', '/nope.html');++ assert.strictEqual(nf.status, 404);++ assert.strictEqual(nf.text, 'ALPHA NOT FOUND');++ assert.strictEqual(nf.headers['x-frame-options'], 'SAMEORIGIN');++ const tk = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'config test' } });++ assert.strictEqual(tk.status, 201, tk.text);++ const down = await t.get('alpha.openvibe.website', '/');++ assert.strictEqual(down.status, 451);++ assert.strictEqual(down.headers['x-frame-options'], 'SAMEORIGIN');++ assert.strictEqual(down.headers['clear-site-data'], '"cache", "storage"');++ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff })).status, 200);++ assert.strictEqual((await t.get('alpha.openvibe.website', '/')).status, 200);++ });++++ await check('reserved headers and header injection are refused on write', async () => {++ for (const name of ['Content-Security-Policy', 'Strict-Transport-Security', 'Set-Cookie', 'X-Forwarded-For', 'X-Forwarded-Proto', 'Cache-Control', 'Content-Type', 'X-Content-Type-Options', 'Location', 'Service-Worker-Allowed', 'Alt-Svc']) {++ const r = await put(alice, alpha.id, { headers: { [name]: 'x' } });++ assert.strictEqual(r.status, 422, `${name} → ${r.status} ${r.text}`);++ assert.strictEqual(r.json().code, 'site_config.reserved_header', name);++ }++ const inj = await put(alice, alpha.id, { headers: { 'X-Ok': 'a\r\nSet-Cookie: sid=1' } });++ assert.strictEqual(inj.status, 422);++ assert.strictEqual(inj.json().code, 'site_config.header_value');++ assert.strictEqual((await t.get('alpha.openvibe.website', '/')).headers['set-cookie'], undefined);++ });++++ await check('a reserved header smuggled into the row is never served (platform headers win)', async () => {++ await t.ctx.store.db.prepare(`INSERT INTO space_site_config (site_id, headers, redirects, spa, updated_by, updated_at)++ VALUES (?, ?::jsonb, ?::jsonb, false, ?, ?)++ ON CONFLICT (site_id) DO UPDATE SET headers = EXCLUDED.headers`)++ .run(alpha.id, JSON.stringify({++ 'Content-Security-Policy': 'default-src *', 'Strict-Transport-Security': 'max-age=0',++ 'Set-Cookie': 'sid=1', 'X-Forwarded-For': '1.2.3.4', 'X-Custom': 'kept',++ }), JSON.stringify([]), 'test', t.clock.now());++ const r = await t.get('alpha.openvibe.website', '/');++ assert.match(r.headers['content-security-policy'], /default-src 'self'/);++ assert.ok(!/default-src \*/.test(r.headers['content-security-policy']), 'the tenant CSP cannot be replaced');++ assert.strictEqual(r.headers['strict-transport-security'], undefined);++ assert.strictEqual(r.headers['set-cookie'], undefined);++ assert.strictEqual(r.headers['x-forwarded-for'], undefined);++ assert.strictEqual(r.headers['x-custom'], 'kept', 'a non-reserved header in the same row still applies');++ });++++ await check('no open redirect: a foreign target is refused on write; a local one is kept', async () => {++ for (const to of ['//evil.com', '/\\evil.com', 'https://evil.com', 'http:evil.com', '/\t/evil.com', '/%09/evil.com', '///evil.com', '/%2f%2fevil.com', '/\n/evil.com', 'javascript:alert(1)', ' //evil.com', '\\evil.com']) {++ const r = await put(alice, alpha.id, { redirects: [{ from: '/go', to }] });++ assert.strictEqual(r.status, 422, `${JSON.stringify(to)} → ${r.status} ${r.text}`);++ assert.strictEqual(r.json().code, 'site_config.redirect');++ }++ assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: 'https://evil.com/x', to: '/x' }] })).status, 422, 'a foreign "from" is refused too');++ assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: '/go', to: '/new', status: 200 }] })).status, 422, 'only 301/302/307/308');++ assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: '/old', to: '/about.html', status: 308 }] })).status, 200);++ const r = await t.get('alpha.openvibe.website', '/old');++ assert.strictEqual(r.status, 308);++ assert.strictEqual(r.headers.location, '/about.html');++ });++++ await check('SPA fallback: extensionless paths serve index.html; an extension is still a 404', async () => {++ assert.strictEqual((await put(alice, alpha.id, { spa: true })).status, 200, 'spa only');++ for (const p of ['/app', '/app/dashboard', '/settings/']) {++ const r = await t.get('alpha.openvibe.website', p);++ assert.strictEqual(r.status, 200, `${p} → ${r.status}`);++ assert.strictEqual(r.text, 'ALPHA HOME');++ assert.strictEqual(r.headers['content-type'], 'text/html; charset=utf-8');++ }++ assert.strictEqual((await t.get('alpha.openvibe.website', '/about.html')).text, 'ALPHA ABOUT', 'a real file wins');++ for (const p of ['/missing.js', '/app/missing.css', '/nope.json']) assert.strictEqual((await t.get('alpha.openvibe.website', p)).status, 404, p);++ assert.strictEqual((await put(alice, alpha.id, { spa: false })).status, 200);++ assert.strictEqual((await t.get('alpha.openvibe.website', '/app/dashboard')).status, 404, 'SPA off restores the 404');++ });++++ await check('a config is private to its site and project (404 to non-members; no leak to another site)', async () => {++ assert.strictEqual((await put(alice, alpha.id, { headers: { 'X-Alpha-Secret': 'alpha-only' } })).status, 200);++ for (const [method, json] of [['GET', undefined], ['PUT', { headers: { 'X-Evil': '1' } }], ['DELETE', undefined]]) {++ const r = await t.api(method, `/api/v1/sites/${alpha.id}/config`, { as: bob, json });++ assert.strictEqual(r.status, 404, `${method} → ${r.status} ${r.text}`);++ }++ const b = await t.get('beta.openvibe.website', '/');++ assert.strictEqual(b.text, 'BETA HOME');++ assert.strictEqual(b.headers['x-alpha-secret'], undefined);++ // A service token needs space.site.config AND the acting principal's project role.++ const noCap = t.network.serviceToken('codes', ['space.site.manage']);++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: noCap, headers: { 'x-ov-subject': alice.subject } })).status, 403);++ const stranger = t.network.serviceToken('codes', ['space.site.config']);++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: stranger, headers: { 'x-ov-subject': bob.subject } })).status, 404, 'a non-member service learns nothing');++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: stranger, headers: { 'x-ov-subject': alice.subject } })).status, 200);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * Tenant sitemap.xml and robots.txt when the deploy ships none: the generated sitemap lists the++ * manifest's HTML pages (index → the directory URL, the error page left out) on the host the request++ * came in on, so a verified custom domain gets its own copy under its own name; robots welcomes++ * crawlers to a production site and keeps a sandbox out (Disallow: /). A file the tenant uploaded at++ * either path always wins. Neither generated body carries a secret, and both say how to cache them.++ */++const assert = require('assert');++const { boot, check, done } = require('./stageb/boot');++++const HOST = 'widgets.openvibe.website';++const CUSTOM = 'www.widgets-example.org';++++(async () => {++ const t = await boot();++ const alice = t.user('alice', { display_name: 'Alice' });++ const project = await t.project(alice, 'Widgets');++ const site = await t.site(alice, project.id, 'widgets');++ const origin = `https://${HOST}`;++++ await t.deploy(alice, site.id, {++ 'index.html': '<h1>Home</h1>',++ 'about.html': '<h1>About</h1>',++ 'docs/index.html': '<h1>Docs</h1>',++ '404.html': '<h1>Gone</h1>',++ 'style.css': 'body{}',++ 'notes.txt': 'hi',++ }, { activate: true });++++ await check('a generated sitemap lists the manifest HTML paths on this host (index → /, 404 left out)', async () => {++ const r = await t.get(HOST, '/sitemap.xml');++ assert.strictEqual(r.status, 200, r.text);++ assert.match(r.headers['content-type'], /application\/xml/);++ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);++ assert.deepStrictEqual([...new Set(locs)].sort(), [`${origin}/`, `${origin}/about.html`, `${origin}/docs/`].sort());++ });++++ await check('a generated robots.txt welcomes crawlers and names the sitemap', async () => {++ const r = await t.get(HOST, '/robots.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers['content-type'], /text\/plain/);++ assert.match(r.text, /^User-agent: \*\nAllow: \/\n/);++ assert.ok(r.text.includes(`Sitemap: ${origin}/sitemap.xml`), r.text);++ });++++ await check('a verified custom domain serves its own generated sitemap under its own name', async () => {++ const add = await t.api('POST', `/api/v1/sites/${site.id}/domains`, { as: alice, json: { hostname: CUSTOM } });++ assert.strictEqual(add.status, 201, add.text);++ const dom = add.json().domain;++ t.dns.set(`_openvibe-space.${CUSTOM}`, [dom.instructions.verification.value]);++ const v = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });++ assert.strictEqual(v.json().domain.status, 'verified', v.text);++ const r = await t.get(CUSTOM, '/sitemap.xml');++ assert.strictEqual(r.status, 200, r.text);++ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);++ assert.deepStrictEqual([...new Set(locs)].sort(), [`https://${CUSTOM}/`, `https://${CUSTOM}/about.html`, `https://${CUSTOM}/docs/`].sort());++ const rb = await t.get(CUSTOM, '/robots.txt');++ assert.ok(rb.text.includes(`Sitemap: https://${CUSTOM}/sitemap.xml`), rb.text);++ });++++ await check("the tenant's own sitemap.xml and robots.txt win", async () => {++ await t.deploy(alice, site.id, {++ 'index.html': '<h1>Home</h1>',++ 'sitemap.xml': '<?xml version="1.0" encoding="UTF-8"?><urlset><url><loc>https://example.com/own</loc></url></urlset>',++ 'robots.txt': 'User-agent: *\nDisallow: /private\n',++ }, { activate: true });++ const sm = await t.get(HOST, '/sitemap.xml');++ assert.strictEqual(sm.status, 200);++ assert.match(sm.headers['content-type'], /application\/xml/);++ assert.ok(sm.text.includes('https://example.com/own'), sm.text);++ assert.ok(!sm.text.includes('/about.html'), sm.text);++ const rb = await t.get(HOST, '/robots.txt');++ assert.strictEqual(rb.text, 'User-agent: *\nDisallow: /private\n');++ assert.match(rb.headers['content-type'], /text\/plain/);++ });++++ await check('a sandbox site generates Disallow: / (and stays noindex)', async () => {++ const sandbox = await t.project(alice, 'Sandbox', { environment: 'sandbox' });++ const sb = await t.site(alice, sandbox.id, 'playground');++ await t.deploy(alice, sb.id, { 'index.html': '<h1>Play</h1>' }, { activate: true });++ const r = await t.get(`${sb.name}.openvibe.website`, '/robots.txt');++ assert.strictEqual(r.status, 200, r.text);++ assert.match(r.text, /^User-agent: \*\nDisallow: \/\n*$/);++ assert.ok(!r.text.includes('Sitemap'), r.text);++ assert.strictEqual(r.headers['x-robots-tag'], 'noindex, nofollow');++ });++++ await check('generated files are cacheable, answer HEAD, and never carry a secret', async () => {++ await t.deploy(alice, site.id, { 'index.html': '<h1>Home</h1>', 'about.html': '<h1>About</h1>' }, { activate: true });++ const sm = await t.get(HOST, '/sitemap.xml');++ const rb = await t.get(HOST, '/robots.txt');++ assert.match(sm.headers['cache-control'], /max-age=/);++ assert.match(rb.headers['cache-control'], /max-age=/);++ const head = await t.get(HOST, '/sitemap.xml', { method: 'HEAD' });++ assert.strictEqual(head.status, 200);++ assert.strictEqual(head.text, '');++ for (const r of [sm, rb]) {++ for (const secretish of [t.SECRET, '_openvibe-space', 'openvibe-space-verification', 'prj_', 'sit_', 'dpl_', 'dom_']) {++ assert.ok(!r.text.includes(secretish), `${secretish} in ${r.text}`);++ }++ assert.strictEqual(r.headers['set-cookie'], undefined);++ assert.match(r.headers['content-security-policy'], /default-src 'self'/);++ }++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,309 @@++'use strict';++/**++ * Host's Media-backed object store (SPACE_OBJECT_STORE=media, plan T12): local disk is the read cache,++ * OpenVibe.Media is the source of truth. Against a fake Media Object API v2 (test/stageb/media.js)++ * and the same boot() as the other Stage B suites.++ *++ * - a deploy writes every object through to Media and serves from the local cache;++ * - a deploy fails (502 storage.object_store) when Media refuses the write;++ * - a local cache miss re-fetches from Media and verifies the sha256; a tampered object is refused;++ * - identical bytes in one project are stored in Media once;++ * - GC deletes a Media object only when no deploy references it;++ * - project removal deletes the project's Media objects;++ * - with SPACE_OBJECT_STORE unset nothing calls Media at all.++ */++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./stageb/boot');++const { startMedia } = require('./stageb/media');++const { createBlobStore, MEDIA_SCOPE } = require('../server/storage');++const { createWorker } = require('../server/worker');++++const cachePath = (t, projectId, sha) => path.join(t.dir, 'objects', 'projects', projectId, sha.slice(0, 2), sha);++const shaOf = (s) => crypto.createHash('sha256').update(s).digest('hex');++/** A store built directly (not through the app) with the test's fake Media and mock Network. */++const directStore = (t, media, { dir, isReferenced, fetchTimeoutMs } = {}) => createBlobStore(++ dir || fs.mkdtempSync(path.join(t.dir, 'direct-')),++ {++ media: {++ url: media.url, namespace: 'space', scope: MEDIA_SCOPE, clientId: 'space', clientSecret: t.SECRET,++ tokenUrl: `${t.network.url}/oauth/token`,++ ...(fetchTimeoutMs ? { fetchTimeoutMs } : {}),++ },++ isReferenced,++ },++);++const filesOf = async (t, as, deployId) => (await t.api('GET', `/api/v1/deploys/${deployId}`, { as })).json().deploy.files;++++(async () => {++ const media = await startMedia();++ const t = await boot({ env: { SPACE_OBJECT_STORE: 'media', OV_MEDIA_URL: media.url } });++ const alice = t.user('alice');++++ await check('booting with SPACE_OBJECT_STORE=media selects the Media store', async () => {++ assert.strictEqual(t.config.objectStore.mode, 'media');++ assert.strictEqual(t.config.media.url, media.url);++ });++++ const pa = await t.project(alice, 'A');++ const sa = await t.site(alice, pa.id, 'mediasite');++ let firstFiles = [];++ await check('a deploy writes every object through to Media, private and keyed per project', async () => {++ const d = await t.deploy(alice, sa.id, { 'index.html': 'HELLO MEDIA', 'app.js': 'console.log(1)' });++ firstFiles = await filesOf(t, alice, d.id);++ assert.strictEqual(firstFiles.length, 2);++ for (const f of firstFiles) {++ const o = media.byHash(pa.id, f.sha256);++ assert.ok(o, `Media holds ${f.path}`);++ assert.strictEqual(o.visibility, 'private');++ assert.strictEqual(o.lifecycle_status, 'ready');++ assert.strictEqual(o.metadata.project_id, pa.id);++ assert.ok(fs.existsSync(cachePath(t, pa.id, f.sha256)), `${f.path} is cached locally`);++ }++ assert.strictEqual((await t.get('mediasite.openvibe.website', '/')).text, 'HELLO MEDIA');++ });++++ await check('a deploy fails when Media refuses the write (never a local-only success)', async () => {++ const pb = await t.project(alice, 'B');++ const sb = await t.site(alice, pb.id, 'refuses');++ media.failUpload = true;++ try {++ const r = await t.upload(alice, sb.id, { 'index.html': 'NOPE' });++ assert.strictEqual(r.status, 502, r.text);++ assert.strictEqual(r.json().code, 'storage.object_store');++ assert.ok(r.json().deploy_id, 'the failed deploy is recorded');++ assert.strictEqual(media.readyFor(pb.id).length, 0, 'nothing reached Media as ready');++ assert.strictEqual((await t.get('refuses.openvibe.website', '/')).status, 404, 'nothing is served');++ } finally { media.failUpload = false; }++ // The next deploy of the same project goes through once Media recovers.++ await t.deploy(alice, sb.id, { 'index.html': 'RECOVERED' });++ assert.strictEqual((await t.get('refuses.openvibe.website', '/')).text, 'RECOVERED');++ });++++ await check('a local miss re-fetches from Media and verifies the sha256; tampered bytes are refused', async () => {++ const sha = firstFiles.find((f) => f.path === 'index.html').sha256;++ const local = cachePath(t, pa.id, sha);++ fs.rmSync(local);++ assert.ok(!fs.existsSync(local), 'evicted the local cache');++ const ok = await t.get('mediasite.openvibe.website', '/');++ assert.strictEqual(ok.status, 200, ok.text);++ assert.strictEqual(ok.text, 'HELLO MEDIA');++ assert.ok(fs.existsSync(local), 'a verified fetch is cached again');++++ const obj = media.byHash(pa.id, sha);++ fs.rmSync(local);++ media.tamper.add(obj.id);++ const bad = await t.get('mediasite.openvibe.website', '/');++ assert.strictEqual(bad.status, 500);++ assert.ok(!bad.text.includes('HELLO MEDIA'), 'tampered bytes are never served');++ assert.ok(!fs.existsSync(local), 'tampered bytes are never cached');++ media.tamper.delete(obj.id);++ });++++ await check('identical bytes in one project are stored in Media once', async () => {++ const before = media.countInits();++ const d = await t.deploy(alice, sa.id, { 'index.html': 'HELLO MEDIA', 'extra.txt': 'EXTRA' });++ const files = await filesOf(t, alice, d.id);++ assert.strictEqual(media.countInits(), before + 1, 'only the new file is uploaded; index.html is reused');++ // One Media object per unique sha of the project (index.html, app.js, extra.txt).++ const uniq = new Set([...firstFiles, ...files].map((f) => f.sha256));++ assert.strictEqual(uniq.size, 3);++ assert.strictEqual(media.readyFor(pa.id).length, uniq.size);++ });++++ await check('GC deletes a Media object only when no remaining deploy references it', async () => {++ const pc = await t.project(alice, 'C');++ const sc = await t.site(alice, pc.id, 'gamma');++ const g1 = await t.deploy(alice, sc.id, { 'a.html': 'AAA', 'shared.txt': 'SHARED' });++ const g2 = await t.deploy(alice, sc.id, { 'b.html': 'BBB', 'shared.txt': 'SHARED' }); // now active++ assert.ok(g2.active);++ const f1 = await filesOf(t, alice, g1.id);++ const shaA = f1.find((f) => f.path === 'a.html').sha256;++ const shaShared = f1.find((f) => f.path === 'shared.txt').sha256;++ const del = await t.api('DELETE', `/api/v1/deploys/${g1.id}`, { as: alice });++ assert.strictEqual(del.status, 200, del.text);++ assert.strictEqual(media.byHash(pc.id, shaA), null, 'the unreferenced object is deleted from Media');++ assert.ok(media.byHash(pc.id, shaShared), 'an object a remaining deploy references is kept');++ assert.strictEqual((await t.get('gamma.openvibe.website', '/shared.txt')).text, 'SHARED');++ });++++ await check('project removal deletes the project\'s Media objects, and nobody else\'s', async () => {++ const pd = await t.project(alice, 'D');++ const sd = await t.site(alice, pd.id, 'removed');++ const d1 = await t.deploy(alice, sd.id, { 'x.html': 'XXXX' });++ const shaX = (await filesOf(t, alice, d1.id)).find((f) => f.path === 'x.html').sha256;++ const pe = await t.project(alice, 'E');++ const se = await t.site(alice, pe.id, 'kept');++ const d2 = await t.deploy(alice, se.id, { 'y.html': 'YYYY' });++ const shaY = (await filesOf(t, alice, d2.id)).find((f) => f.path === 'y.html').sha256;++ assert.ok(media.byHash(pd.id, shaX) && media.byHash(pe.id, shaY));++ const rm = await t.api('DELETE', `/api/v1/projects/${pd.id}`, { as: alice });++ assert.strictEqual(rm.status, 200, rm.text);++ assert.strictEqual(media.byHash(pd.id, shaX), null, 'the removed project\'s object is gone from Media');++ assert.ok(media.byHash(pe.id, shaY), 'another project\'s object is untouched');++ assert.strictEqual((await t.get('kept.openvibe.website', '/y.html')).text, 'YYYY');++ });++++ await check('with SPACE_OBJECT_STORE unset nothing calls Media', async () => {++ const media2 = await startMedia();++ const t2 = await boot({ env: { OV_MEDIA_URL: media2.url } });++ try {++ const bob = t2.user('bob');++ const p = await t2.project(bob, 'L');++ const s = await t2.site(bob, p.id, 'localonly');++ await t2.deploy(bob, s.id, { 'index.html': 'LOCAL ONLY' });++ assert.strictEqual(media2.calls.length, 0, 'the local store never talks to Media');++ assert.strictEqual((await t2.get('localonly.openvibe.website', '/')).text, 'LOCAL ONLY');++ } finally { await t2.close(); await media2.close(); }++ });++++ await check('a zero-byte file is stored locally only and never sent to Media', async () => {++ const pz = await t.project(alice, 'Z');++ const sz = await t.site(alice, pz.id, 'zero');++ const before = media.countInits();++ const d = await t.deploy(alice, sz.id, { 'index.html': 'ZERO SITE', 'empty.txt': '' });++ const empty = (await filesOf(t, alice, d.id)).find((f) => f.path === 'empty.txt');++ assert.strictEqual(empty.size, 0);++ assert.strictEqual(media.countInits(), before + 1, 'only index.html is uploaded; the empty file is not');++ assert.strictEqual(media.byHash(pz.id, empty.sha256), null, 'Media has no object for the empty file');++ assert.ok(fs.existsSync(cachePath(t, pz.id, empty.sha256)), 'the empty file is in the local cache');++ const r = await t.get('zero.openvibe.website', '/empty.txt');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.text, '');++ });++++ await check('put uploads a fresh blob directly and looks up only an already-cached one', async () => {++ const dir = fs.mkdtempSync(path.join(t.dir, 'direct-put-'));++ const s1 = directStore(t, media, { dir });++ const blob = Buffer.from('DIRECT UPLOAD');++ const h = shaOf('DIRECT UPLOAD');++ const lists0 = media.countLists();++ await s1.put(pa.id, h, blob);++ assert.strictEqual(media.countLists(), lists0, 'a fresh blob is uploaded without listing the namespace');++ assert.ok(media.byHash(pa.id, h));++ // A restart: a new store over the same warm cache, with no in-memory id map.++ const s2 = directStore(t, media, { dir });++ const inits = media.countInits();++ const lists1 = media.countLists();++ await s2.put(pa.id, h, blob);++ assert.strictEqual(media.countInits(), inits, 'the cached blob is not uploaded a second time');++ assert.ok(media.countLists() > lists1, 'the already-cached blob is looked up once');++ assert.strictEqual(media.ready().filter((o) => o.metadata.project_id === pa.id && o.content_hash === h).length, 1);++ });++++ await check('concurrent puts of one new sha upload it once', async () => {++ const store = directStore(t, media, { dir: fs.mkdtempSync(path.join(t.dir, 'direct-race-')) });++ const blob = Buffer.from('RACE');++ const h = shaOf('RACE');++ const inits = media.countInits();++ const changed = await Promise.all([store.put(pa.id, h, blob), store.put(pa.id, h, blob), store.put(pa.id, h, blob)]);++ assert.deepStrictEqual(changed, [true, false, false]);++ assert.strictEqual(media.countInits(), inits + 1, 'three concurrent puts, one upload');++ assert.strictEqual(media.ready().filter((o) => o.metadata.project_id === pa.id && o.content_hash === h).length, 1);++ });++++ await check('remove deletes every Media object for a blob, not just the first', async () => {++ const store = directStore(t, media, { dir: fs.mkdtempSync(path.join(t.dir, 'direct-dupes-')) });++ const blob = Buffer.from('DUPLICATE');++ const h = shaOf('DUPLICATE');++ media.seed(pa.id, blob);++ media.seed(pa.id, blob); // two pre-existing duplicates (from before puts were serialised)++ await store.put(pa.id, h, blob); // plus the cache and one more object++ const countOf = () => media.ready().filter((o) => o.metadata.project_id === pa.id && o.content_hash === h).length;++ assert.strictEqual(countOf(), 3);++ assert.strictEqual(await store.remove(pa.id, h), true);++ assert.strictEqual(countOf(), 0, 'every duplicate is gone');++ assert.ok(!fs.existsSync(store.pathFor(pa.id, h)), 'the cache file is gone');++ });++++ await check('remove keeps a Media object and cache that space_blobs re-references mid-sweep', async () => {++ let referenced = false;++ const store = directStore(t, media, {++ dir: fs.mkdtempSync(path.join(t.dir, 'direct-ref-')),++ isReferenced: async () => referenced,++ });++ const blob = Buffer.from('RE-REFERENCED');++ const h = shaOf('RE-REFERENCED');++ await store.put(pa.id, h, blob);++ referenced = true; // a concurrent deploy reused the sha after the caller decided it was unreferenced++ assert.strictEqual(await store.remove(pa.id, h), false, 'remove reports nothing removed');++ assert.ok(media.byHash(pa.id, h), 'the durable copy stays in Media');++ assert.ok(fs.existsSync(store.pathFor(pa.id, h)), 'the cache stays');++ referenced = false;++ assert.strictEqual(await store.remove(pa.id, h), true);++ assert.strictEqual(media.byHash(pa.id, h), null, 'once unreferenced, remove deletes it');++ assert.ok(!fs.existsSync(store.pathFor(pa.id, h)));++ });++++ await check('concurrent cache misses for one blob stream it in once and rename it in', async () => {++ const store = directStore(t, media, { dir: fs.mkdtempSync(path.join(t.dir, 'direct-coalesce-')) });++ const blob = Buffer.from('COALESCE ME');++ const h = shaOf('COALESCE ME');++ await store.put(pa.id, h, blob);++ fs.rmSync(store.pathFor(pa.id, h));++ const downloads = media.countDownloads();++ const files = await Promise.all([store.ensure(pa.id, h), store.ensure(pa.id, h), store.ensure(pa.id, h)]);++ assert.deepStrictEqual(files, [store.pathFor(pa.id, h), store.pathFor(pa.id, h), store.pathFor(pa.id, h)]);++ assert.strictEqual(media.countDownloads(), downloads + 1, 'one download for three misses');++ assert.strictEqual(fs.readFileSync(files[0], 'utf8'), 'COALESCE ME');++ assert.deepStrictEqual(fs.readdirSync(store.tmpDir).filter((f) => f.endsWith('.part')), [], 'no temp file is left behind');++ });++++ await check('a Media download past the fetch timeout fails cleanly and caches nothing', async () => {++ const store = directStore(t, media, { dir: fs.mkdtempSync(path.join(t.dir, 'direct-slow-')), fetchTimeoutMs: 60 });++ const blob = Buffer.from('SLOW OBJECT');++ const h = shaOf('SLOW OBJECT');++ media.seed(pa.id, blob);++ media.delayContentMs = 400;++ try {++ await assert.rejects(() => store.ensure(pa.id, h), (e) => e.code === 'storage.media');++ } finally { media.delayContentMs = 0; }++ assert.ok(!fs.existsSync(store.pathFor(pa.id, h)), 'nothing is cached from a failed download');++ });++++ await check('a failed deploy names the objects it left in the deploy log', async () => {++ const ph = await t.project(alice, 'H');++ const sh = await t.site(alice, ph.id, 'partial');++ const shaA = shaOf('AAAA PARTIAL');++ media.failUploadAfter = media.acceptedInits() + 1; // the first object lands, the second is refused++ let r;++ try { r = await t.upload(alice, sh.id, { 'a.txt': 'AAAA PARTIAL', 'b.txt': 'BBBB PARTIAL' }); }++ finally { media.failUploadAfter = null; }++ assert.strictEqual(r.status, 502, r.text);++ assert.strictEqual(r.json().code, 'storage.object_store');++ assert.ok(media.byHash(ph.id, shaA), 'the first object was left in Media');++ const log = r.json().log.join('\n');++ assert.ok(log.includes(shaA), `the deploy log names the leftover object: ${log}`);++ });++++ await check('the object sweep logs a Media delete failure at warn instead of swallowing it', async () => {++ const dir = fs.mkdtempSync(path.join(t.dir, 'sweep-'));++ const file = path.join(dir, 'blob');++ fs.writeFileSync(file, 'x');++ fs.utimesSync(file, new Date(0), new Date(0));++ const sha = 'a'.repeat(64);++ const warnings = [];++ const worker = createWorker({++ config: { worker: { enabled: false } },++ store: { db: { prepare: () => ({ get: async () => null }) } }, // no space_blobs row++ domains: {},++ blobs: {++ list: function* () { yield { projectId: pa.id, sha256: sha, file }; },++ sweepTmp: () => 0,++ remove: async () => { const e = new Error('media down'); e.code = 'storage.media'; throw e; },++ },++ outbox: {},++ log: { log() {}, error() {}, warn: (...a) => warnings.push(a.join(' ')) },++ });++ const removed = await worker.sweepObjects({ graceMs: 0 });++ assert.strictEqual(removed, 0, 'a failed delete is not counted as removed');++ assert.strictEqual(warnings.length, 1, `expected exactly one warn, got ${JSON.stringify(warnings)}`);++ assert.ok(warnings[0].includes('Media') && warnings[0].includes(sha) && warnings[0].includes('media down'), warnings[0]);++ });++++ await t.close();++ await media.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,146 @@++'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 space.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');++const { readArchive } = require('../server/artifacts/archive');++++(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 = (await t.events('space.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((await t.events('space.deploy.failed')).length, before + 1);++ assert.strictEqual((await t.get('uploads.openvibe.website', '/')).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: '../../space.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('a large decompression leaves the event loop responsive', async () => {++ const size = 32 * 1024 * 1024;++ const raw = tarball([{ name: 'big.txt', content: Buffer.alloc(size, 97) }]);++ assert.ok(raw.length < 1024 * 1024, 'the archive is small on the wire');++ let resolved = false;++ const pending = readArchive(raw, { maxTotalBytes: 64 * 1024 * 1024, maxFiles: 10, maxFileBytes: 64 * 1024 * 1024 })++ .then((r) => { resolved = true; return r; });++ assert.strictEqual(typeof pending.then, 'function', 'readArchive is asynchronous');++ await new Promise((r) => setTimeout(r, 0));++ assert.strictEqual(resolved, false, 'a timer fired while the archive was still decompressing');++ const parsed = await pending;++ assert.strictEqual(parsed.compressed, true);++ assert.strictEqual(parsed.entries.length, 1);++ assert.strictEqual(parsed.entries[0].data.length, size);++ });++++ 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 await t.events('space.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,104 @@++'use strict';++/**++ * IndexNow (openvibe-shared/indexnow): INDEXNOW_KEY unset → the feature is off (no key route, nothing++ * sent). With a key the key file is served at /<key>.txt as text/plain on every host, and a tenant++ * site whose active deploy appears, changes or goes away pings the engines with the site's page and++ * its sitemap. A ready deploy that is not active (a draft) never pings; a sandbox site (noindex) never++ * pings.++ */++const assert = require('assert');++const { boot, check, done, DASHBOARD } = require('./stageb/boot');++++const KEY = 'k'.repeat(32);++++/** A spy in place of the module's HTTP send: records every pingSoon batch (the repo's test pattern). */++function makeSpy() {++ const pings = [];++ return {++ pings,++ module: {++ enabled: true,++ keyFile: (_req, _res, next) => next(),++ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },++ ping: async () => ({ sent: 0, status: 0 }),++ flush: async () => ({ sent: 0, status: 0 }),++ },++ };++}++++(async () => {++ const off = await boot();++ await check('without a key IndexNow is off: no key route and nothing sent', async () => {++ assert.strictEqual(off.ctx.indexnow.enabled, false);++ const res = await off.get(DASHBOARD, `/${KEY}.txt`);++ assert.strictEqual(res.status, 404, res.text);++ });++ await off.close();++++ const on = await boot({ env: { INDEXNOW_KEY: KEY } });++ await check('with a key the key file answers text/plain with the key (dashboard and tenant hosts)', async () => {++ assert.strictEqual(on.ctx.indexnow.enabled, true);++ const dash = await on.get(DASHBOARD, `/${KEY}.txt`);++ assert.strictEqual(dash.status, 200, dash.text);++ assert.match(dash.headers['content-type'], /text\/plain/);++ assert.strictEqual(dash.text, KEY);++ const tenant = await on.get('anything.openvibe.website', `/${KEY}.txt`);++ assert.strictEqual(tenant.status, 200, tenant.text);++ assert.strictEqual(tenant.text, KEY);++ });++ await on.close();++++ const s = makeSpy();++ const t = await boot({ indexnow: s.module });++ const alice = t.user('alice', { display_name: 'Alice' });++ const project = await t.project(alice, 'Widgets');++ const site = await t.site(alice, project.id, 'widgets');++ const origin = 'https://widgets.openvibe.website';++ let deploy;++++ await check('a ready deploy that is not active (a draft) never pings', async () => {++ const r = await t.upload(alice, site.id, { 'index.html': '<h1>Widgets</h1>' }, { activate: false });++ assert.strictEqual(r.status, 201, r.text);++ deploy = r.json().deploy;++ assert.deepStrictEqual(s.pings, []);++ });++++ await check('activating the deploy pings the site page and the sitemap', async () => {++ const r = await t.api('POST', `/api/v1/deploys/${deploy.id}/activate`, { as: alice, json: {} });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.json().changed, r.text);++ assert.ok(s.pings.includes(`${origin}/`), JSON.stringify(s.pings));++ assert.ok(s.pings.includes(`${origin}/sitemap.xml`), JSON.stringify(s.pings));++ });++++ await check('a rollback pings the page and the sitemap again', async () => {++ const r2 = await t.upload(alice, site.id, { 'index.html': '<h1>Widgets v2</h1>' }, { activate: true });++ assert.strictEqual(r2.status, 201, r2.text);++ s.pings.length = 0;++ 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().changed, true);++ assert.ok(s.pings.includes(`${origin}/`), JSON.stringify(s.pings));++ assert.ok(s.pings.includes(`${origin}/sitemap.xml`), JSON.stringify(s.pings));++ });++++ await check('deleting the site pings the page and the sitemap again', async () => {++ s.pings.length = 0;++ const r = await t.api('DELETE', `/api/v1/sites/${site.id}`, { as: alice, json: {} });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(s.pings.includes(`${origin}/`), JSON.stringify(s.pings));++ assert.ok(s.pings.includes(`${origin}/sitemap.xml`), JSON.stringify(s.pings));++ });++++ await check('a sandbox site (noindex) never pings, even when it activates', async () => {++ const sandboxProject = await t.project(alice, 'Sandbox', { environment: 'sandbox' });++ const sandboxSite = await t.site(alice, sandboxProject.id, 'playground');++ s.pings.length = 0;++ const r = await t.upload(alice, sandboxSite.id, { 'index.html': '<h1>Play</h1>' }, { activate: true });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual(s.pings, []);++ });++ await t.close();++++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,225 @@++'use strict';++// Host's authority resource index (ADR-048 section 3, capability space.resource.read, plan T13 step 8):++// GET /api/v1/resources pages common.resource-summary@1 for the resources Host owns - its tenant sites++// (space.site, sit_), the immutable deploys of those sites (space.deploy, dpl_) and their default and++// custom domains (space.domain, dom_) - as common.resource-list-result@1, and GET /api/v1/resources/:ovrn++// reads one by its computed ovrn. ?project=&kind=&owner=&cursor=&limit= are honoured; ?project= is the tenancy++// boundary, so a resource of another project is never returned, and Host lists no projects at all.++// Every summary and every page is validated against the released schemas.++const assert = require('assert');++const { validate } = require('openvibe-contracts');++const { boot, check, done } = require('./stageb/boot');++++(async () => {++ const t = await boot();++ const alice = t.user('alice');++ const bob = t.user('bob');++ const prjA = (await t.project(alice, 'Resource A')).id;++ const prjB = (await t.project(alice, 'Resource B')).id;++ const prjC = (await t.project(bob, 'Resource C')).id;++++ // Sites: two in A, one in B and one owned by Bob in C (each mints a default domain).++ const siteA1 = await t.site(alice, prjA, 'ri-a-one');++ const siteA2 = await t.site(alice, prjA, 'ri-a-two');++ const siteB = await t.site(alice, prjB, 'ri-b-one');++ const siteC = await t.site(bob, prjC, 'ri-c-one');++++ // Deploys of A's first site: one ready, one refused (a failed deploy).++ const readyDeploy = await t.deploy(alice, siteA1.id, { 'index.html': 'v1' });++ const refusedUpload = await t.upload(alice, siteA1.id, { 'bad.php': 'x' });++ assert.ok([413, 422].includes(refusedUpload.status), refusedUpload.text);++ const failedDeploy = { id: refusedUpload.json().deploy_id };++ assert.match(failedDeploy.id, /^dpl_/);++++ // A custom domain of A's first site, still waiting for its TXT record.++ const custom = await t.api('POST', `/api/v1/sites/${siteA1.id}/domains`, { as: alice, json: { hostname: 'ri.example.org' } });++ assert.strictEqual(custom.status, 201, custom.text);++ const customDomain = custom.json().domain;++++ // The default domain each site create mints (its id is what the index lists).++ const defaultDomains = [];++ for (const [site, user] of [[siteA1, alice], [siteA2, alice], [siteB, alice], [siteC, bob]]) {++ const seen = await t.api('GET', `/api/v1/sites/${site.id}`, { as: user });++ assert.strictEqual(seen.status, 200, seen.text);++ for (const d of seen.json().domains) if (d.kind === 'default') defaultDomains.push(d);++ }++ assert.strictEqual(defaultDomains.length, 4);++++ const resourcesOf = async (query, headers) => {++ const r = await t.api('GET', `/api/v1/resources${query}`, headers);++ assert.strictEqual(r.status, 200, `${query}: ${r.status} ${r.text}`);++ const page = r.json();++ assert.deepStrictEqual(Object.keys(page).sort(), ['next_cursor', 'resources'], 'the page carries only the contract fields');++ const pageCheck = validate('common.resource-list-result@1', page);++ assert.ok(pageCheck.valid, `${query}: ${JSON.stringify(pageCheck.errors)}`);++ for (const s of page.resources) {++ const v = validate('common.resource-summary@1', s);++ assert.ok(v.valid, `${query} ${s.id}: ${JSON.stringify(v.errors)}`);++ }++ return page;++ };++++ await check('auth: no token 401, a token without space.resource.read 403, with it 200', async () => {++ const anonymous = await t.api('GET', '/api/v1/resources');++ assert.strictEqual(anonymous.status, 401);++ assert.strictEqual(anonymous.json().code, 'auth.required');++ const person = await t.api('GET', '/api/v1/resources', { as: alice });++ assert.strictEqual(person.status, 403, 'the index is first-party: a person holds no service capability');++ assert.strictEqual(person.json().code, 'capability.denied');++ const wrongAudience = t.network.sign({ sub: 'svc:services', aud: ['openvibe.blog'], cap: ['space.resource.read'] });++ assert.strictEqual((await t.api('GET', '/api/v1/resources', { as: wrongAudience })).status, 401);++ const noCap = t.network.serviceToken('codes', ['space.site.manage']);++ const denied = await t.api('GET', '/api/v1/resources', { as: noCap });++ assert.strictEqual(denied.status, 403);++ assert.strictEqual(denied.json().code, 'capability.denied');++ const allowed = await t.api('GET', '/api/v1/resources', { as: t.network.serviceToken('services', ['space.resource.read']) });++ assert.strictEqual(allowed.status, 200, allowed.text);++ });++++ const bearer = t.network.serviceToken('services', ['space.resource.read']);++ const auth = { as: bearer };++++ await check('the page lists every kind Host owns, sorted by (kind, id), each summary valid', async () => {++ const page = await resourcesOf('', auth);++ const expected = [++ { kind: 'space.deploy', id: readyDeploy.id }, { kind: 'space.deploy', id: failedDeploy.id },++ { kind: 'space.domain', id: customDomain.id },++ { kind: 'space.site', id: siteA1.id }, { kind: 'space.site', id: siteA2.id }, { kind: 'space.site', id: siteB.id }, { kind: 'space.site', id: siteC.id },++ ];++ const byId = new Map(page.resources.map((r) => [r.id, r]));++ for (const d of defaultDomains) expected.push({ kind: 'space.domain', id: d.id });++ expected.sort((x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0));++ assert.deepStrictEqual(page.resources.map((r) => [r.kind, r.id]), expected.map((r) => [r.kind, r.id]), 'all three kinds, sorted by (kind, id)');++ assert.deepStrictEqual([...new Set(page.resources.map((r) => r.service))], ['space']);++ assert.strictEqual(page.next_cursor, null, 'one page holds the whole index');++++ const site = byId.get(siteA1.id);++ assert.strictEqual(site.project_id, prjA, 'a summary carries its project');++ assert.strictEqual(site.name, 'ri-a-one');++ assert.strictEqual(site.state, 'active');++ assert.deepStrictEqual(site.owner, { type: 'user', id: alice.subject }, 'a usr_ subject is the owner');++ assert.strictEqual(byId.get(readyDeploy.id).state, 'ready');++ assert.strictEqual(byId.get(failedDeploy.id).state, 'failed');++ assert.strictEqual(byId.get(customDomain.id).name, 'ri.example.org');++ assert.strictEqual(byId.get(customDomain.id).state, 'pending');++ });++++ await check('every summary carries an OVRN composed by contracts.resources.nameOf', async () => {++ const page = await resourcesOf('', auth);++ for (const s of page.resources) {++ assert.strictEqual(s.ovrn, `ovrn:space:${s.project_id}:${s.kind.split('.')[1]}/${s.id}`, s.id);++ }++ assert.strictEqual(page.resources.find((r) => r.id === readyDeploy.id).ovrn, `ovrn:space:${prjA}:deploy/${readyDeploy.id}`);++ assert.strictEqual(page.resources.find((r) => r.id === siteB.id).ovrn, `ovrn:space:${prjB}:site/${siteB.id}`);++ });++++ await check('?project= is the tenancy boundary: another project\'s rows are never returned', async () => {++ const a = await resourcesOf(`?project=${prjA}`, auth);++ const ids = a.resources.map((r) => r.id);++ assert.ok(ids.includes(siteA1.id) && ids.includes(siteA2.id) && ids.includes(readyDeploy.id) && ids.includes(customDomain.id));++ assert.ok(a.resources.every((r) => r.project_id === prjA), 'only project A');++ assert.ok(!ids.includes(siteB.id), 'project B is never mixed in');++ const b = await resourcesOf(`?project=${prjB}`, auth);++ assert.deepStrictEqual([...new Set(b.resources.map((r) => r.project_id))], [prjB]);++ assert.ok(b.resources.every((r) => r.id !== siteA1.id && r.id !== readyDeploy.id));++ const empty = await resourcesOf('?project=prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', auth);++ assert.deepStrictEqual(empty.resources, [], 'an unknown project has no resources');++ // Host mints its own prj_ ids and lists no projects: no space.project kind exists.++ assert.ok((await resourcesOf('?kind=space.project', auth)).resources.length === 0);++ });++++ await check('?kind= narrows to one kind; an unknown kind is an empty page, not an error', async () => {++ const sites = await resourcesOf('?kind=space.site', auth);++ assert.deepStrictEqual(sites.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id, siteB.id, siteC.id].sort());++ const deploys = await resourcesOf('?kind=space.deploy', auth);++ assert.deepStrictEqual(deploys.resources.map((r) => r.id).sort(), [readyDeploy.id, failedDeploy.id].sort());++ const domains = await resourcesOf('?kind=space.domain', auth);++ assert.strictEqual(domains.resources.length, 5, "four default domains and A's custom one");++ const scoped = await resourcesOf(`?project=${prjA}&kind=space.site`, auth);++ assert.deepStrictEqual(scoped.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id].sort(), 'kind narrows within the project');++ assert.deepStrictEqual((await resourcesOf('?kind=space.unknown', auth)).resources, []);++ });++++ await check('?owner= filters every kind and combines with project, kind and cursor', async () => {++ const all = (await resourcesOf('', auth)).resources;++ const aliceRows = all.filter((r) => r.owner && r.owner.id === alice.subject);++ const bobRows = all.filter((r) => r.owner && r.owner.id === bob.subject);++ assert.deepStrictEqual(bobRows.map((r) => r.id).sort(), [siteC.id, defaultDomains.find((d) => d.site_id === siteC.id).id].sort());++ const own = await resourcesOf(`?owner=${alice.subject}`, auth);++ assert.deepStrictEqual(own.resources, aliceRows, 'only Alice-owned summaries in index order');++ assert.deepStrictEqual((await resourcesOf(`?owner=${bob.subject}`, auth)).resources, bobRows, 'Bob owns a site and its default domain');++ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject}&kind=space.site&project=${prjA}`, auth)).resources,++ aliceRows.filter((r) => r.kind === 'space.site' && r.project_id === prjA));++ assert.deepStrictEqual((await resourcesOf(`?owner=${bob.subject}&project=${prjA}`, auth)).resources, []);++ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject}&project=${prjC}`, auth)).resources, []);++ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject.replace(/^usr_/, 'agt_')}`, auth)).resources, []);++ assert.deepStrictEqual((await resourcesOf('?owner=', auth)).resources, all, 'empty owner means no filter');++++ const seen = [];++ let cursor = null;++ let pages = 0;++ for (;;) {++ const page = await resourcesOf(`?owner=${alice.subject}&limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, auth);++ assert.ok(page.resources.length <= 2);++ seen.push(...page.resources);++ if (page.next_cursor === null) break;++ cursor = page.next_cursor;++ if (++pages > 50) assert.fail('the owner cursor chain never ended');++ }++ assert.deepStrictEqual(seen, aliceRows, 'owner paging has no duplicates or gaps');++ });++++ await check('the cursor pages the index without duplicates, gaps or reordering', async () => {++ const whole = (await resourcesOf('', auth)).resources.map((r) => r.id);++ const seen = [];++ let cursor = null;++ let pages = 0;++ for (;;) {++ const page = await resourcesOf(`?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, auth);++ assert.ok(page.resources.length <= 2);++ seen.push(...page.resources.map((r) => r.id));++ if (page.next_cursor === null) break;++ assert.ok(typeof page.next_cursor === 'string' && page.next_cursor !== '');++ cursor = page.next_cursor;++ if (++pages > 50) assert.fail('the cursor chain never ended');++ }++ assert.deepStrictEqual(seen, whole, 'no duplicates, none skipped, order preserved');++ assert.deepStrictEqual((await resourcesOf('?limit=1000', auth)).resources.map((r) => r.id), whole, 'a large limit still pages one result');++ });++++ await check('GET /:ovrn reads the one resource whose computed ovrn it is', async () => {++ const page = await resourcesOf('', auth);++ const one = await t.api('GET', `/api/v1/resources/${encodeURIComponent(`ovrn:space:${prjA}:site/${siteA1.id}`)}`, auth);++ assert.strictEqual(one.status, 200, one.text);++ assert.strictEqual(one.headers['cache-control'], 'private, max-age=60');++ assert.ok(validate('common.resource-summary@1', one.json()).valid);++ assert.deepStrictEqual(one.json(), page.resources.find((r) => r.id === siteA1.id), 'the same summary the list answers');++++ const missing = [++ ['a project OVRN', `ovrn:space:${prjA}:project/${prjA}`],++ ['an unknown type', `ovrn:space:${prjA}:widget/sit_01J8ZQ4Y7N3M2K1H0G9F8E7D6C`],++ ["another service's OVRN", `ovrn:network:${prjA}:site/${siteA1.id}`],++ ['the right id under another project', `ovrn:space:${prjB}:site/${siteA1.id}`],++ ['an unknown id', `ovrn:space:${prjA}:site/sit_01J8ZQ4Y7N3M2K1H0G9F8E7D6C`],++ ['a non-OVRN', 'nope'],++ ];++ for (const [why, name] of missing) {++ const r = await t.api('GET', `/api/v1/resources/${encodeURIComponent(name)}`, auth);++ assert.strictEqual(r.status, 404, `${why}: ${r.status} ${r.text}`);++ assert.strictEqual(r.headers['content-type'], 'application/problem+json', why);++ assert.strictEqual(r.json().code, 'resources.unknown_resource', why);++ }++ });++++ await check('a query that cannot be honoured is 400 resources.bad_query', async () => {++ const bad = [['?project=nope', 'project not a prj_ id'], ['?owner=svc:live', 'owner not a subject id'], ['?owner=usr_short', 'owner too short'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];++ for (const [q, why] of bad) {++ const r = await t.api('GET', `/api/v1/resources${q}`, auth);++ assert.strictEqual(r.status, 400, why);++ assert.strictEqual(r.json().code, 'resources.bad_query', why);++ }++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,153 @@++'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.website', '/index.html')++ * t.dns.set('_openvibe-space.www.example.org', ['openvibe-space-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');++++// The Stage B suites deploy dozens of times a minute as one person: per-actor limits are tested on their own++// (test/actor-limits.test.js), so they are off here unless a test turns them on.++if (process.env.SPACE_ACTOR_LIMITS === undefined) process.env.SPACE_ACTOR_LIMITS = 'off';++++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-space-secret-value';++const DASHBOARD = 'openvibe.space';++++async function boot(opts = {}) {++ const network = await startNetwork();++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-space-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',++ SPACE_SITES_DOMAIN: 'openvibe.website',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'space', OV_OAUTH_CLIENT_SECRET: SECRET, COOKIE_SECURE: 'false',++ SPACE_FORM_SECRET: 'test-form-secret', SPACE_WORKER: 'off', SPACE_MIN_FREE_BYTES: '0',++ SPACE_STORAGE_DIR: path.join(dir, 'objects'),++ SPACE_PUBLIC_HOSTING: 'on', // the suites exercise the engine as launched; test/host-launch-switch.test.js covers off++ ...(opts.env || {}),++ };++ const configLib = require('../../server/hosting-config');++ // One database per boot (PGlite, or SPACE_TEST_STORE=pg: the containers); a restart keeps it, like a file did.++ const db = await require('../helpers/db').testDb();++ const testdb = { db, close: () => db.close() };++ 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);++ Object.assign(process.env, env);++ for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];++ const { createApp } = require('../../server/app');++ const app = await createApp({ db, hostingConfig: config, now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl, indexnow: opts.indexnow });++ await app.locals.auth.ensureKey();++ built = { app, ctx: app.locals.hosting };++ 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(); }++ 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}`;++ // A dashboard session is Host's own cookie (server/auth/sso.js); ov_token is display-only.++ if (o.session) headers.cookie = [headers.cookie, `ov_token=${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 = async (type = null) => (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope) : r.envelope) : 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 testdb.close(); 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,150 @@++'use strict';++/**++ * An in-process stand-in for OpenVibe.Media's Object API v2, wide enough for Host's Media object++ * store: init (POST /objects), the content PUT, complete, metadata GET, list, the signed download++ * link, and delete — the routes OpenVibe.Media serves from server/objects/routes.js, served here++ * from memory. It follows test/stageb/mocks.js (an HTTP server on a random port) and test/s3-mock.js++ * (a recorded command log plus injectable failures).++ *++ * const media = await startMedia();++ * media.url the base URL to give Host (OV_MEDIA_URL)++ * media.objects Map id -> object (bytes, content_hash, metadata, lifecycle_status)++ * media.calls every request { method, url, bytes, auth }++ * media.failUpload = true every init answers 403 (a deploy must then fail)++ * media.failUploadAfter = n refuse inits once n have succeeded (fail mid-deploy)++ * media.delayContentMs = ms delay the signed content GET (a stalled download)++ * media.failDelete = true every delete answers 500++ * media.tamper.add(id) the signed content GET answers other bytes++ * media.countInits() init requests so far++ * media.acceptedInits() inits that were allowed (the rest were refused)++ * media.countLists() namespace list requests so far++ * media.countDownloads() signed content GETs so far++ * media.seed(projectId, bytes) put a ready object straight into the fake, returns its id++ * media.readyFor(projectId) ready objects whose metadata.project_id is that project++ * media.byHash(projectId, sha) the ready object for that project and content hash++ * await media.close()++ */++const http = require('http');++const crypto = require('crypto');++const { ids } = require('openvibe-contracts');++++const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');++++function startMedia({ app = 'space' } = {}) {++ const objects = new Map();++ const calls = [];++ const media = { objects, calls, app, failUpload: false, failUploadAfter: null, delayContentMs: 0, failDelete: false, tamper: new Set() };++ let acceptedInits = 0;++ const basePath = `/api/v2/${encodeURIComponent(app)}/objects`;++ const base = () => `http://127.0.0.1:${server.address().port}`;++ const json = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ const publicShape = (o) => ({++ id: o.id, app_id: app, namespace: o.namespace, kind: o.kind, visibility: o.visibility,++ lifecycle_status: o.lifecycle_status, mime_type: o.mime_type, size_bytes: o.size_bytes,++ content_hash: o.content_hash || null, metadata: o.metadata, public_url: null,++ });++++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ calls.push({ method: req.method, url: req.url, bytes: raw.length, auth: req.headers.authorization || null });++ const p = new URL(req.url, 'http://localhost').pathname;++ const rel = p === basePath ? '' : p.startsWith(`${basePath}/`) ? p.slice(basePath.length + 1) : null;++ if (rel === null) return json(res, 404, { code: 'media.not_found' });++++ if (rel === '' && req.method === 'POST') {++ if (media.failUpload || (media.failUploadAfter != null && acceptedInits >= media.failUploadAfter)) {++ return json(res, 403, { code: 'media.object.denied', detail: 'the fake refuses every upload' });++ }++ acceptedInits++;++ let body = {};++ try { body = JSON.parse(raw.toString('utf8')); } catch { /* malformed: treat as empty */ }++ const id = `med_${ids.ulid()}`;++ const o = {++ id, namespace: body.namespace || app, kind: body.kind || 'file', visibility: body.visibility || 'private',++ lifecycle_status: 'uploading', mime_type: body.mime_type || null, size_bytes: Number(body.size_bytes) || 0,++ content_hash: null, metadata: { ...(body.metadata || {}) }, bytes: null,++ };++ objects.set(id, o);++ return json(res, 201, {++ id, object: publicShape(o),++ upload: { method: 'PUT', url: `${base()}${basePath}/${id}/content?token=tok`, token: 'tok', expires_at: new Date(Date.now() + 3600e3).toISOString(), max_bytes: o.size_bytes, content_type: o.mime_type },++ });++ }++ if (rel === '' && req.method === 'GET') {++ const all = [...objects.values()].filter((o) => o.lifecycle_status !== 'deleted');++ return json(res, 200, { objects: all.map(publicShape), next_cursor: null, limit: 50 });++ }++++ const m = /^([^/]+)(?:\/(.*))?$/.exec(rel);++ const obj = m && objects.get(m[1]);++ if (!obj) return json(res, 404, { code: 'media.object.not_found' });++ const sub = m[2] || '';++ if (sub === 'content' && req.method === 'PUT') {++ obj.bytes = Buffer.from(raw);++ obj.content_hash = sha256(obj.bytes);++ obj.size_bytes = obj.bytes.length;++ return json(res, 200, {});++ }++ if (sub === 'content' && req.method === 'GET') {++ const sendContent = () => {++ if (res.destroyed || res.writableEnded) return; // the client gave up (timeout)++ if (media.tamper.has(obj.id)) { res.writeHead(200, { 'Content-Type': 'application/octet-stream' }); return res.end(Buffer.from('TAMPERED BYTES')); }++ res.writeHead(200, { 'Content-Type': 'application/octet-stream' });++ return res.end(obj.bytes || Buffer.alloc(0));++ };++ if (media.delayContentMs) { setTimeout(sendContent, media.delayContentMs); return; }++ return sendContent();++ }++ if (sub === 'complete' && req.method === 'POST') {++ let body = {};++ try { body = JSON.parse(raw.toString('utf8')); } catch { /* */ }++ const expected = String(body.content_hash || '').toLowerCase();++ if (expected && expected !== obj.content_hash) return json(res, 422, { code: 'media.object.hash_mismatch', detail: 'content hash mismatch' });++ obj.lifecycle_status = 'ready';++ return json(res, 200, publicShape(obj));++ }++ if (sub === 'download' && req.method === 'GET') {++ if (obj.lifecycle_status !== 'ready') return json(res, 409, { code: 'media.object.not_ready' });++ return json(res, 200, { url: `${base()}${basePath}/${obj.id}/content?token=signed`, expires_at: new Date(Date.now() + 300e3).toISOString(), public: false });++ }++ if (sub === '' && req.method === 'GET') return json(res, 200, publicShape(obj));++ if (sub === '' && req.method === 'DELETE') {++ if (media.failDelete) return json(res, 500, { code: 'media.object.delete_failed' });++ obj.lifecycle_status = 'deleted';++ return json(res, 200, publicShape(obj));++ }++ return json(res, 404, { code: 'media.not_found' });++ });++ });++++ return new Promise((resolve) => {++ server.listen(0, '127.0.0.1', () => {++ media.url = base();++ media.close = () => new Promise((r) => server.close(r));++ media.countInits = () => calls.filter((c) => c.method === 'POST' && new URL(c.url, 'http://x').pathname === basePath).length;++ media.acceptedInits = () => acceptedInits;++ media.countLists = () => calls.filter((c) => c.method === 'GET' && new URL(c.url, 'http://x').pathname === basePath).length;++ media.countDownloads = () => calls.filter((c) => c.method === 'GET' && new URL(c.url, 'http://x').pathname.endsWith('/content')).length;++ media.seed = (projectId, bytes) => {++ const b = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);++ const h = sha256(b);++ const id = `med_${ids.ulid()}`;++ objects.set(id, {++ id, namespace: app, kind: 'file', visibility: 'private', lifecycle_status: 'ready',++ mime_type: 'application/octet-stream', size_bytes: b.length, content_hash: h,++ metadata: { project_id: projectId, sha256: h }, bytes: b,++ });++ return id;++ };++ media.ready = () => [...objects.values()].filter((o) => o.lifecycle_status === 'ready');++ media.readyFor = (projectId) => media.ready().filter((o) => o.metadata && o.metadata.project_id === projectId);++ media.byHash = (projectId, sha) => media.ready().find((o) => o.metadata && o.metadata.project_id === projectId && o.content_hash === sha) || null;++ resolve(media);++ });++ });++}++++module.exports = { startMedia };nnew file mode 100644@@@ -0,0 +1,84 @@++'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; authorization_code and++ * refresh_token → user tokens for issueCode()/refreshToken()).++ * 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-space-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 });++ }++ // Browser sign-in: a code from issueCode(), a refresh token from refreshToken() (single use, rotated).++ const grant = body.grant_type === 'authorization_code' ? grants.get(`code:${body.code}`) : body.grant_type === 'refresh_token' ? grants.get(`rt:${body.refresh_token}`) : undefined;++ if (grant === undefined && ['authorization_code', 'refresh_token'].includes(body.grant_type)) return json(400, { error: 'invalid_grant' });++ if (grant) {++ grants.delete(body.grant_type === 'authorization_code' ? `code:${body.code}` : `rt:${body.refresh_token}`);++ return json(200, { access_token: userToken(grant), refresh_token: refreshToken(grant), token_type: 'Bearer', expires_in: 3600 });++ }++ 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);++ }++ const grants = new Map();++ const issueCode = (u) => { const c = crypto.randomBytes(12).toString('hex'); grants.set(`code:${c}`, u); return c; };++ const refreshToken = (u) => { const r = crypto.randomBytes(12).toString('hex'); grants.set(`rt:${r}`, u); return r; };++ 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, audience: ['openvibe.network', 'openvibe.space'], expiresIn: '1h' });++ }++ /** A FedCM ID assertion as Network's /fedcm/assertion signs it (same key and issuer, aud = the RP origin). */++ function fedcmAssertion(u, origin, nonce = 'n0nce') {++ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), id: 1, subject_id: u.subject, username: u.username, display_name: u.display_name, nonce, typ: 'fedcm', jti: crypto.randomBytes(16).toString('hex') }, privatePem, { algorithm: 'RS256', issuer, audience: origin, expiresIn: 300 });++ }++ function serviceToken(client, cap, extra = {}) {++ return sign({ sub: `svc:${client}`, aud: ['openvibe.space'], cap, ...extra });++ }++ // An app token carries its developer project and env, as Network's do (identity.service-token-claims 1.2.0).++ function appToken(appId, cap, extra = {}) {++ return sign({ sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.space'], cap, project_id: 'prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', env: 'production', ...extra });++ }++ return { ...srv, publicPem, addUser, userToken, issueCode, refreshToken, fedcmAssertion, 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 };
@@ -33,8 +33,52 @@ OV_COMMUNITY_URL=https://openvibe.community
@@ -33,8 +33,52 @@ OV_COMMUNITY_URL=https://openvibe.community
# OpenVibe.Live — the legal pages the shared footer links
# OpenVibe.Live — the legal pages the shared footer links
OV_LIVE_URL=https://openvibe.live
OV_LIVE_URL=https://openvibe.live
-# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh space. Space holds no
-# user content yet; the database carries the shared schema (migrations/) and feeds /api/ready.
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh space. Space owns static hosting
+# state as well as the historical forum schema and feeds /api/ready.
# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
DATABASE_URL=
DATABASE_URL=
DATABASE_DIRECT_URL=
DATABASE_DIRECT_URL=
+
+# Static hosting engine (Stage B). Public project creation remains off until launch.
+SPACE_PUBLIC_HOSTING=off
+SPACE_SITES_DOMAIN=openvibe.website
+SPACE_STORAGE_DIR=./data/objects
+# SPACE_OBJECT_STORE=media
+# OV_MEDIA_URL=http://127.0.0.1:4100
+# OV_MEDIA_NAMESPACE=space
+# SPACE_FORM_SECRET= # set a stable random value for dashboard forms
+# SPACE_WORKER=off # off only for tests; default on
+# SPACE_PGLITE_DIR= # development-only embedded database path
+# SPACE_MAX_UPLOAD_BYTES=104857600
+# SPACE_MAX_UNPACKED_BYTES=268435456
+# SPACE_MAX_CONCURRENT_UPLOADS=2
+# SPACE_MIN_FREE_BYTES=5368709120
+# SPACE_MAX_PROJECTS_PER_OWNER=10
+# SPACE_QUOTA_STORAGE_BYTES=1073741824
+# SPACE_QUOTA_DEPLOYS_PER_DAY=50
+# SPACE_QUOTA_MAX_FILES=10000
+# SPACE_QUOTA_MAX_FILE_BYTES=26214400
+# SPACE_QUOTA_SITES=10
+# SPACE_QUOTA_CUSTOM_DOMAINS=5
+# SPACE_SANDBOX_QUOTA_STORAGE_BYTES=104857600
+# SPACE_SANDBOX_QUOTA_DEPLOYS_PER_DAY=20
+# SPACE_SANDBOX_QUOTA_MAX_FILES=2000
+# SPACE_SANDBOX_QUOTA_MAX_FILE_BYTES=10485760
+# SPACE_SANDBOX_QUOTA_SITES=3
+# SPACE_SANDBOX_QUOTA_CUSTOM_DOMAINS=0
+# SPACE_CNAME_TARGET=
+# SPACE_ORIGIN_IPV4=
+# SPACE_ORIGIN_IPV6=
+# SPACE_DNS_SERVERS=
+# SPACE_DOMAIN_RECHECK_MS=600000
+# SPACE_DOMAIN_PENDING_DAYS=7
+# SPACE_DOMAIN_LAPSE_DAYS=7
+# SPACE_RESERVED_DOMAINS=
+# SPACE_RESERVED_SITE_NAMES=
+# SPACE_ACTOR_LIMITS=on
+# EVENTS_URL=
+# EVENTS_RELAY_INTERVAL_MS=2000
+# INDEXNOW_KEY=
+# VALKEY_URL=
+# VALKEY_PREFIX=ov:space:
+# API_CORS_ORIGINS=https://openvibe.network,https://openvibe.codes
@@ -3,3 +3,9 @@
@@ -3,3 +3,9 @@
# allowlist below is one proven false positive, matched on path AND value so nothing else is hidden.
# allowlist below is one proven false positive, matched on path AND value so nothing else is hidden.
[extend]
[extend]
useDefault = true
useDefault = true
+
+[[allowlists]]
+description = "Fake Stripe key planted in a test deploy to prove Space rejects and never echoes secrets (moved from Host)"
+condition = "AND"
+paths = ['''^test/hosting-secrets-dashboard\.test\.js$''']
+regexes = ['''^sk_live_TOPSECRET123$''']
@@ -1,20 +1,20 @@
@@ -1,20 +1,20 @@
# OpenVibe.Space
# OpenVibe.Space
-**https://openvibe.space — code and dynamic pages for the OpenVibe network.**
+**https://openvibe.space — static hosting for the OpenVibe network.**
-## Purpose
+## Purpose and status
-Space is being rebuilt to host **code** and **dynamic pages**, and **spaces**: apps people build
-that use their OpenVibe account, the network's services and the SDK.
+Space owns static hosting. The Stage B engine moved here from OpenVibe.Host as code; production had no projects, sites or deploys to migrate. It supports projects and members, immutable content-addressed deploys, activation and one-step rollback, private previews, CI Git ingest, per-site configuration, verified custom domains, takedowns, Media-backed objects, Host-header tenant serving, and a resource index. Tenant names serve at `<name>.openvibe.website`.
-Nothing is published here yet. The site is honest about that: the home page says what is coming and
-nothing else, and no feature is faked.
+Public hosting remains off by default (`SPACE_PUBLIC_HOSTING=off`). Only OpenVibe staff can create projects. Abuse handling and a designated DMCA agent precede public launch. Dynamic pages and spaces (small apps on a person's OpenVibe account) are planned and not built.
+
+OpenVibe.Host is the hosting price comparison; its copy of the engine is being removed, and its old hosting URLs will answer 410 with a link here.
## The forum moved to OpenVibe.Community
## The forum moved to OpenVibe.Community
The forum lived here from 3 October 2026 (it moved off Community then). On **2026-10-08 the owner
The forum lived here from 3 October 2026 (it moved off Community then). On **2026-10-08 the owner
moved it back**: OpenVibe.Community owns the forum (spaces, threads, posts, votes, categories,
moved it back**: OpenVibe.Community owns the forum (spaces, threads, posts, votes, categories,
-per-space moderators, the Discord relay) again, and Space becomes code hosting and spaces.
+per-space moderators, the Discord relay) again, and Space became the network's hosting (static sites now; dynamic pages and spaces next).
So Space keeps no forum code and **every forum URL answers a permanent redirect to the same path and
So Space keeps no forum code and **every forum URL answers a permanent redirect to the same path and
query on https://openvibe.community**:
query on https://openvibe.community**:
@@ -28,174 +28,67 @@ query on https://openvibe.community**:
@@ -28,174 +28,67 @@ query on https://openvibe.community**:
A redirected bookmark, crawler or API client therefore lands on Community's copy of the same page.
A redirected bookmark, crawler or API client therefore lands on Community's copy of the same page.
Nothing that is not a forum path redirects.
Nothing that is not a forum path redirects.
-## What is here today
-
-- **`GET /`** — the home page (server-rendered through the shared OpenVibe Frame), with a clear link to
- the forum at https://openvibe.community/s.
-- **Sign-in** — the Network OAuth2 client `space`: `/auth/login|callback|fedcm|logout|me|refresh`.
- No signed-in surface needs it yet; it is kept because the shared navbar signs people in everywhere.
-- **Discovery** — `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` (the home page alone).
-- **Health and release** — `/api/health`, `/api/ready`, `/release.json`, `/metrics` (loopback only).
-
-## Owns
-
-Space's own database is `ov_space` on the host's data role; `migrations/0001_initial.sql` is the whole
-schema and it runs at boot. The tables it creates:
-
-- **forum tables** (what Space served when the forum lived here): `space_groups`, `spaces`,
- `categories`, `threads`, `posts`, `post_versions`, `thread_votes`, `attachments`, `post_reactions`,
- `space_chat_rooms`, `space_moderators`;
-- **Discord relay bookkeeping**: `relay_mappings`, `relay_deliveries`, `relay_message_map`,
- `relay_cursors`, `relay_inbound_failures`;
-- **projections and records**: `subject_projection` (a display cache of what OpenVibe.Network says
- about a subject — never authority), `legacy_id_map`, `network_blocks`, `account_data_events`;
-- **shared runtime tables**: `space_event_inbox`, `token_revocations`, `event_outbox`.
-
-Space is the authority for this schema and for what it serves: the home page, the forum redirects,
-sign-in, the discovery files and health/readiness. It is not the authority for the forum rows — the
-forum is OpenVibe.Community's, so those rows are Community's, and this release writes no drop
-migration.
-
-## Does not own
+## Routes
-- the forum, pastes, typed comment threads, Pulse and submissions — OpenVibe.Community's
-- identity, accounts and tokens — OpenVibe.Network's
-- images, memberships and chat rooms — OpenVibe.Media's, OpenVibe.VIP's and OpenVibe.Chat's
+| Route | Purpose |
+| --- | --- |
+| `/` | Public status and static hosting description |
+| `/dashboard`, `/projects/:id`, `/sites/:id`, `/deploys/:id` | Signed-in hosting dashboard and forms |
+| `/preview/:deployId/*` | Private, sandboxed deploy preview |
+| `/api/v1/projects`, `/api/v1/sites`, `/api/v1/deploys`, `/api/v1/domains` | Hosting API (Bearer tokens, no cookie auth) |
+| `/api/v1/resources` | First-party resource index |
+| `/limits.json` | Enforced hosting limits |
+| `/auth/login|callback|fedcm|logout|me|refresh` | Space's Network OAuth client |
+| `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` | Discovery for Space's public page |
+| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Health, release and observability |
-## Depends on
+Tenant requests are dispatched by Host header before dashboard routes. `deploy/nginx/openvibe.website.conf` is a reference wildcard vhost; the existing `openvibe.space` vhost includes larger upload locations. Neither is installed by this code change. Unknown hosts answer 404. Tenant sites cannot reach the dashboard, API, auth or metrics. A custom domain serves only after TXT verification.
-- OpenVibe.Network (SSO, JWKS, the `space` OAuth client)
-- OpenVibe.Community (where every forum URL redirects)
-- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.44.0, `openvibe-shared` v4.0.0, pinned by release tarball
+## Capabilities and events
-## Capabilities
+The hosting API guards `space.site.manage`, `space.site.config`, `space.deploy.create`, `space.domain.manage` and `space.resource.read` (openvibe-contracts 0.149.0, owner `space`); every route names exactly one, and `npx openvibe-contracts-check --service space` checks the code against the release. Service tokens need audience `openvibe.space`, and project membership still applies. The per-actor limits (`server/http/actor-limits.js`) count uploads, domain checks, project creation and writes per person or app; those are limit buckets, not capabilities. The retired `space.forum.*` contracts are unrelated to hosting.
-`manifests/services/space.json` lists **no capabilities** (`"capabilities": []`), so Space serves no
-capability of its own. The forum capabilities it once owned are retired: Contracts still carries
-`space.forum.read`, `space.forum.manage`, `space.thread.read`, `space.thread.write`, `space.post.write`
-and `space.pulse.read`, each marked `status: retired` and pointing at its `community.*` replacement on
-OpenVibe.Community.
+The transactional outbox emits `space.deploy.created`, `space.deploy.activated`, `space.deploy.failed` and `space.domain.verified`; each has a payload schema in openvibe-contracts, and the SDK outbox refuses any other type.
-Space calls no capability on another service. Its only outbound calls are OpenVibe.Network's OAuth
-token grant, `/oauth/revoke` and the JWKS document, which are OAuth endpoints rather than the
-capability surface. (The test Network mock still offers `identity.subject.resolve`, but nothing in
-`server/` calls it.)
+## Data and configuration
-## What Space applies from the network: nothing
+`migrations/0002_initial.sql` through `0005_git_sources.sql` create the hosting tables, indexes and immutable-artifact triggers from Host Stage B with `space_` names. They run through `openvibe-sdk/db` at boot; tables are never created by a request. `0001_initial.sql` remains the historical forum schema, which this release does not drop. This is a code move, with no data copy.
-Space holds **no user content**, so it consumes no network events and has nothing to export or erase:
+Copy `.env.example` to `.env`. Production uses `/etc/openvibe/space.env` (0600). Important values:
-- **Account export and deletion are gone.** Space's former export/deletion hooks (`network.account.export_requested`,
- `network.account.deleted`) only ever touched the **forum tables** — threads, posts, attachments, votes,
- reactions, the moderator list. Those tables' rows are the forum's, and the forum is
- OpenVibe.Community's, so **the forum's account data is Community's** and Space's hooks were removed.
-- **Platform blocks, subject merges and VIP membership changes are gone** for the same reason: they only
- updated forum projections.
-- The **token-cutoff projection** (`network.user.token_valid_after`) went with them: without signed-in
- content there is nothing here for a cutoff to protect.
-
-The forum tables stay in Space's database (migrations `0001_initial.sql` onward still create them).
-This release writes **no drop migration**; the rows are Community's, and dropping them is a later
-decision.
-
-## Configuration
-
-Copy `.env.example` to `.env` (production: `/etc/openvibe/space.env`, mode 0600).
-
-| Variable | Default | Meaning |
+| Variable | Default | Purpose |
| --- | --- | --- |
| --- | --- | --- |
-| `PORT` / `HOST` | `4940` / `127.0.0.1` | Listen address (nginx in front) |
-| `BASE_URL` | `https://openvibe.space` in production | Canonical origin |
-| `TRUST_PROXY` | `2` | X-Forwarded-For hops (Cloudflare → nginx → Node) |
-| `OV_NETWORK_URL` | `https://openvibe.network` | Issuer / authorize URL / JWKS |
-| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | Token grants, JWKS (tried first) |
-| `OV_OAUTH_CLIENT_ID` | `space` | Registered on the Network |
-| `OV_OAUTH_CLIENT_SECRET` | — | **Required** for sign-in |
-| `OV_OAUTH_REDIRECT_URI` | `https://openvibe.space/auth/callback` | Must match the registration |
-| `OV_COMMUNITY_URL` | `https://openvibe.community` | Where every forum URL redirects |
-| `OV_LIVE_URL` | `https://openvibe.live` | Legal pages the shared footer links |
-| `DATABASE_URL`, `DATABASE_DIRECT_URL` | unset (development: embedded PGlite in `data/pglite`) | PostgreSQL through PgBouncer, and the owner's direct connection for migrations (written by OpenVibe.Host `roles/data/add-service.sh space`) |
-
-## Run
-
-```
-npm install
-npm start # http://127.0.0.1:4940
-npm test # boots the Network mock in-process; no network needed
-```
-
-## Acceptance
-
-`npm test` runs every `test/*.test.js`:
-
-- `home` — the home page says what Space will host and links the forum on Community; discovery, health,
- readiness and sign-in answer; an unknown path is an honest 404;
-- `redirects` — `/s`, `/s/*` (301 for GET/HEAD, 308 otherwise) and the forum APIs (308) redirect to the
- same path and query on openvibe.community, and nothing else redirects;
-- `boot` — the real process on a fresh PGlite database: migrations run, `/api/ready` is 2xx, the home
- page renders, the forum URLs redirect, and SIGTERM stops it cleanly.
-
-## Security
-
-Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
-
-- **Auth.** Browsers use Network JWTs (the `ov_token` cookie); the session layer verifies them offline
- against the Network's JWKS. The OAuth `state` cookie is required, and post-auth targets are restricted
- to this site or `https://openvibe.network/…`.
-- **Egress.** Space calls only its configured Network host.
-- **Secrets.** `OV_OAUTH_CLIENT_SECRET` lives in `/etc/openvibe/space.env` (0600), by name only.
+| `SPACE_PUBLIC_HOSTING` | `off` | Staff-only project creation until launch |
+| `SPACE_SITES_DOMAIN` | `openvibe.website` | Tenant names |
+| `SPACE_STORAGE_DIR` | `./data/objects` | Content-addressed local object store |
+| `SPACE_OBJECT_STORE` | `local` | Set `media` for Media write-through and cache |
+| `SPACE_FORM_SECRET` | random per process | Dashboard CSRF form signing |
+| `SPACE_WORKER` | on | Domain rechecks, object sweep, outbox pruning |
+| `EVENTS_URL` | unset | Outbox relay destination; unset keeps rows queued |
+| `DATABASE_URL`, `DATABASE_DIRECT_URL` | unset | PostgreSQL serving and owner migration connections |
-## Deploy
+Other `SPACE_*` quota, upload, domain and DNS settings are listed in `.env.example` and `server/hosting-config.js`. Space keeps its existing OAuth and forum redirect settings.
-```
-/opt/openvibe.space # git checkout, `npm ci --omit=dev`
-/etc/openvibe/space.env # secrets (0600)
-deploy/systemd/openvibe-space.service # → /etc/systemd/system/, User=ubuntu, port 4940
-deploy/nginx/openvibe.space.conf # → /etc/nginx/sites-available/, TLS from
- # /etc/letsencrypt/live/openvibe.space/
-```
-
-The database is `ov_space` on the host's data role. Rollback: ovhost puts the previous sha back when
-`/api/ready` does not answer 2xx after the restart.
-
-## Layout
+## Run and tests
+```sh
+npm install
+npm start
+npm test
```
```
-server/
- index.js process entry (listen, graceful stop)
- app.js Express app factory: redirects, home page, sign-in, discovery, health
- config.js env → config
- db.js PostgreSQL (openvibe-sdk/db): initDb/getDb, migrations/ applied at boot
- discovery.js robots disallow set, llms sections, the sitemap (the home page alone)
- observability.js truthful /api/ready (the database is required; the Network key degrades)
- auth/routes.js OAuth2 client (login/callback/fedcm/logout/me/refresh)
- render/layout.js page shell (openvibe-shared/shell) and the redirect/site constants
- render/pages.js the home page and the error pages
-public/ css/space.css, assets/ (logo, og-default), manifest.webmanifest
-migrations/ the shared schema, including the forum tables Space no longer serves
-deploy/ systemd unit, nginx vhost
-test/ run.js + home/redirects/boot tests
-```
-
-## What is next
-Code hosting (repositories, releases) and dynamic pages, then "spaces": apps people build on their
-OpenVibe account, the network's services and the SDK.
+`test/hosting-core.test.js` exercises migrations, launch gate, project/site creation, tenant dispatch, immutable deploys, pointer rollback and events directly against PGlite without a socket. The ported Stage B integration files under `test/hosting-*.test.js` use `test/stageb/boot.js` and raw HTTP for Host-header and request-target cases. The existing `home`, `redirects`, and `boot` tests cover Space's public app and forum redirects.
-## Related services
+## Security and dependencies
-- Identity/SSO: https://openvibe.network (OpenVibers/OpenVibe.Network)
-- The forum and the hub: https://openvibe.community (OpenVibers/OpenVibe.Community)
-- Streaming: https://openvibe.live (OpenVibers/OpenVibe.Live)
-- Tools: https://openvibe.tools (OpenVibers/OpenVibe.Tools)
-- Media: https://openvibe.media (OpenVibers/OpenVibe.Media)
+Static uploads are validated before storage; tenant content runs on a separate registrable domain and previews use a sandbox CSP. APIs ignore cookies. OpenVibe.Network supplies identity; `svc:space` needs `events.event.publish` and, when `SPACE_OBJECT_STORE=media`, the `media.object.upload/read/list/delete` grants for the `space` namespace. OpenVibe.Media may hold tenant objects; OpenVibe.Events receives the outbox when configured. Space's own database is `ov_space` through `openvibe-sdk/db`. See [SECURITY.md](SECURITY.md).
## License
## License
AGPL-3.0-only. See [LICENSE](LICENSE).
AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.143.0
+- openvibe-contracts: v0.149.0
- openvibe-sdk: v0.44.0
- openvibe-sdk: v0.44.0
- openvibe-shared: v4.0.0
- openvibe-shared: v4.0.0
<!-- versions:end -->
<!-- versions:end -->
@@ -2,30 +2,35 @@
@@ -2,30 +2,35 @@
"repository": "OpenVibers/OpenVibe.Space",
"repository": "OpenVibers/OpenVibe.Space",
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
- "plan": "OpenVibe Updated Plan Based On Codebase (T0–T19) — the forum moved back to OpenVibe.Community (owner decision, 2026-10-08); Space becomes code hosting and spaces",
+ "plan": "T20 step 2: static hosting engine moved into Space; public launch remains gated",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async)",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async)",
"port": 4940,
"port": 4940,
"domain": "openvibe.space",
"domain": "openvibe.space",
- "contracts": "openvibe-contracts v0.143.0",
+ "contracts": "openvibe-contracts v0.149.0",
"sdk": "openvibe-sdk v0.44.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.0.0",
"shared": "openvibe-shared v4.0.0",
- "capabilities": [],
- "capabilitiesRegistered": false,
+ "capabilities": [
+ "space.site.manage",
+ "space.site.config",
+ "space.deploy.create",
+ "space.domain.manage",
+ "space.resource.read"
+ ],
+ "capabilitiesRegistered": true,
"deployed": true,
"deployed": true,
"deployment": "openvibe-ovh: unit openvibe-space.service on 127.0.0.1:4940 (git checkout /opt/openvibe.space, env /etc/openvibe/space.env, database ov_space), deployed with `sudo ovhost deploy space`; public at https://openvibe.space since 2026-10-08.",
"deployment": "openvibe-ovh: unit openvibe-space.service on 127.0.0.1:4940 (git checkout /opt/openvibe.space, env /etc/openvibe/space.env, database ov_space), deployed with `sudo ovhost deploy space`; public at https://openvibe.space since 2026-10-08.",
"features": [
"features": [
- "the home page: what Space will host (code, dynamic pages, \"spaces\" apps) and where the forum went",
- "permanent forum redirects: /s and /s/* (301 GET/HEAD, 308 otherwise) and the forum APIs (308) to openvibe.community",
- "sign-in: the Network OAuth2 client `space` (login, callback, FedCM, logout, me, refresh)",
- "discovery: robots.txt, llms.txt, llms-full.txt, sitemap.xml (the home page alone)",
- "health and release: /api/health, /api/ready, /release.json, /metrics (loopback)",
- "the process boots on a fresh database and stops cleanly (test/boot.test.js)"
+ "staff-only static hosting engine: projects, sites, immutable deploys, previews, rollback, Git ingest, custom domains, takedowns, Media-backed objects and resource index",
+ "tenant serving at <name>.openvibe.website",
+ "permanent forum redirects to OpenVibe.Community",
+ "Network OAuth sign-in, discovery, health and readiness"
],
],
"knownGaps": [
"knownGaps": [
- "code hosting and dynamic pages are not built yet, and no \"spaces\" apps run here; the home page says so",
- "openvibe-contracts still registers space.forum.read/manage, space.thread.read and space.post.write for Space; the Contracts update that retires or reassigns them is a separate change",
- "the forum tables remain in ov_space (migrations 0001+ still create them) with their rows; no drop migration in this release",
- "Space consumes no network events and has no account export/deletion: it holds no user content, and the forum's account data is Community's"
+ "Public project creation is off until abuse handling and a designated DMCA agent are in place",
+ "Dynamic pages and spaces apps are not built",
+ "The historical forum tables remain in ov_space; this code move copies no production hosting data",
+ "No app or agent holds a space.* grant on OpenVibe.Network yet; staff use the dashboard"
],
],
- "updated": "2026-10-11"
+ "hostingDeployed": false,
+ "updated": "2026-10-12"
}
}
@@ -4,8 +4,8 @@
@@ -4,8 +4,8 @@
## This file is what is deployed to /etc/nginx/sites-available/openvibe.space.conf
## This file is what is deployed to /etc/nginx/sites-available/openvibe.space.conf
## — keep them in sync, and reload nginx after changing it.
## — keep them in sync, and reload nginx after changing it.
##
##
-## The Node app (port 4940, systemd unit openvibe-space) renders the site: the home page, sign-in
-## (/auth/*), the discovery files (/robots.txt, /llms.txt, /sitemap.xml) and health/readiness.
+## The Node app (port 4940, systemd unit openvibe-space) renders the home page, hosting
+## dashboard/API, sign-in, discovery files and health/readiness.
## The forum is OpenVibe.Community's again (owner decision, 2026-10-08): Node answers every forum URL
## The forum is OpenVibe.Community's again (owner decision, 2026-10-08): Node answers every forum URL
## — /s and /s/*, /api/v1/spaces|posts|space-groups|relay — with a permanent redirect to the same path
## — /s and /s/*, /api/v1/spaces|posts|space-groups|relay — with a permanent redirect to the same path
## there, so nginx just proxies. nginx terminates TLS, redirects www → apex, rate-limits the auth and
## there, so nginx just proxies. nginx terminates TLS, redirects www → apex, rate-limits the auth and
@@ -97,8 +97,30 @@ server {
@@ -97,8 +97,30 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+ # Hosting uploads may carry a compressed archive or multipart files up to SPACE_MAX_UPLOAD_BYTES.
+ location ~ ^/api/v1/sites/[^/]+/(source/)?deploys/?$ {
+ client_max_body_size 110m;
+ limit_req zone=ovspace_api burst=30 nodelay;
+ proxy_pass http://127.0.0.1:4940;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+ location ~ ^/sites/[^/]+/deploys/?$ {
+ client_max_body_size 110m;
+ limit_req zone=ovspace_api burst=30 nodelay;
+ proxy_pass http://127.0.0.1:4940;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
# ── API (moderate rate limit) ───────────────────────────
# ── API (moderate rate limit) ───────────────────────────
- # Space serves only /api/health and /api/ready here; the forum APIs are permanent redirects to
+ # Hosting APIs and health/readiness live here; the forum APIs are permanent redirects to
# OpenVibe.Community, and a redirected request should reach it with its method and body intact.
# OpenVibe.Community, and a redirected request should reach it with its method and body intact.
location /api/ {
location /api/ {
limit_req zone=ovspace_api burst=30 nodelay;
limit_req zone=ovspace_api burst=30 nodelay;
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+# Reference wildcard tenant vhost for OpenVibe.Space. Install only with a wildcard certificate
+# for openvibe.website and the matching DNS; custom-domain TLS vhosts are generated separately.
+limit_req_zone $binary_remote_addr zone=ovspace_sites:20m rate=50r/s;
+server {
+ listen 80;
+ listen [::]:80;
+ server_name *.openvibe.website;
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://$host$request_uri; }
+}
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name *.openvibe.website;
+ ssl_certificate /etc/letsencrypt/live/openvibe.website/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.website/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ client_max_body_size 1k;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ location / {
+ limit_except GET HEAD { deny all; }
+ limit_req zone=ovspace_sites burst=200 nodelay;
+ proxy_pass http://127.0.0.1:4940;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header Cookie "";
+ proxy_set_header Authorization "";
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_hide_header Set-Cookie;
+ proxy_read_timeout 60s;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
+-- phase: expand
+-- OpenVibe.Space on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk
+-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),
+-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.
+
+CREATE TABLE space_projects (
+ id text COLLATE "C" PRIMARY KEY, -- prj_<ULID>
+ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)
+ network_project_id text COLLATE "C", -- OpenVibe.Network project id (ADR-014), when it exists
+ owner_subject text COLLATE "C" NOT NULL, -- usr_…
+ name text COLLATE "C" NOT NULL,
+ environment text COLLATE "C" NOT NULL DEFAULT 'production' CHECK (environment IN ('production','sandbox')),
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ deleted_at bigint
+);
+CREATE UNIQUE INDEX space_projects_network ON space_projects (network_project_id) WHERE network_project_id IS NOT NULL AND status = 'active';
+CREATE INDEX space_projects_owner ON space_projects (owner_subject, status);
+
+CREATE TABLE space_project_members (
+ project_id text COLLATE "C" NOT NULL REFERENCES space_projects(id),
+ principal text COLLATE "C" NOT NULL, -- usr_… | app:app_… | svc:<id>
+ role text COLLATE "C" NOT NULL CHECK (role IN ('owner','maintainer','deployer')),
+ added_by text COLLATE "C",
+ created_at bigint NOT NULL,
+ PRIMARY KEY (project_id, principal)
+);
+CREATE INDEX space_project_members_principal ON space_project_members (principal);
+
+CREATE TABLE space_quotas (
+ project_id text COLLATE "C" PRIMARY KEY REFERENCES space_projects(id),
+ storage_bytes bigint,
+ deploys_per_day bigint,
+ max_files bigint,
+ max_file_bytes bigint,
+ sites bigint,
+ custom_domains bigint,
+ updated_by text COLLATE "C",
+ updated_at bigint NOT NULL
+);
+
+CREATE TABLE space_sites (
+ id text COLLATE "C" PRIMARY KEY, -- site_<ULID>
+ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)
+ project_id text COLLATE "C" NOT NULL REFERENCES space_projects(id),
+ name text COLLATE "C" NOT NULL, -- the <name>.openvibe.space label
+ active_deploy_id text COLLATE "C",
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),
+ created_by text COLLATE "C" NOT NULL,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ deleted_at bigint
+);
+CREATE UNIQUE INDEX space_sites_name ON space_sites (name) WHERE status = 'active';
+CREATE INDEX space_sites_project ON space_sites (project_id, status);
+
+CREATE TABLE space_deploys (
+ id text COLLATE "C" PRIMARY KEY, -- dpl_<ULID>
+ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)
+ project_id text COLLATE "C" NOT NULL REFERENCES space_projects(id),
+ site_id text COLLATE "C" NOT NULL REFERENCES space_sites(id),
+ state text COLLATE "C" NOT NULL CHECK (state IN ('ready','failed','deleted')),
+ source text COLLATE "C" NOT NULL CHECK (source IN ('archive','files')),
+ manifest text COLLATE "C", -- JSON space.deploy-manifest@1 (NULL when failed)
+ manifest_sha256 text COLLATE "C",
+ file_count bigint NOT NULL DEFAULT 0,
+ total_bytes bigint NOT NULL DEFAULT 0,
+ new_bytes bigint NOT NULL DEFAULT 0, -- bytes this deploy added to the project's storage
+ failure_code text COLLATE "C",
+ created_by text COLLATE "C" NOT NULL,
+ created_at bigint NOT NULL,
+ deleted_at bigint
+);
+CREATE INDEX space_deploys_site ON space_deploys (site_id, created_at);
+CREATE INDEX space_deploys_project_day ON space_deploys (project_id, created_at);
+
+CREATE TABLE space_deploy_files (
+ deploy_id text COLLATE "C" NOT NULL REFERENCES space_deploys(id),
+ path text COLLATE "C" NOT NULL,
+ sha256 text COLLATE "C" NOT NULL,
+ size bigint NOT NULL,
+ content_type text COLLATE "C" NOT NULL,
+ PRIMARY KEY (deploy_id, path)
+);
+CREATE INDEX space_deploy_files_sha ON space_deploy_files (sha256);
+
+CREATE TABLE space_blobs (
+ project_id text COLLATE "C" NOT NULL,
+ sha256 text COLLATE "C" NOT NULL,
+ size bigint NOT NULL,
+ created_at bigint NOT NULL,
+ PRIMARY KEY (project_id, sha256)
+);
+
+CREATE TABLE space_activations (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ site_id text COLLATE "C" NOT NULL REFERENCES space_sites(id),
+ deploy_id text COLLATE "C" NOT NULL,
+ previous_deploy_id text COLLATE "C",
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('activate','rollback')),
+ actor text COLLATE "C" NOT NULL,
+ created_at bigint NOT NULL
+);
+CREATE INDEX space_activations_site ON space_activations (site_id, id);
+
+CREATE TABLE space_deploy_logs (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ deploy_id text COLLATE "C" NOT NULL,
+ level text COLLATE "C" NOT NULL CHECK (level IN ('info','warn','error')),
+ message text COLLATE "C" NOT NULL,
+ created_at bigint NOT NULL
+);
+CREATE INDEX space_deploy_logs_deploy ON space_deploy_logs (deploy_id, id);
+
+CREATE TABLE space_domains (
+ id text COLLATE "C" PRIMARY KEY, -- dom_<ULID>
+ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)
+ project_id text COLLATE "C" NOT NULL REFERENCES space_projects(id),
+ site_id text COLLATE "C" NOT NULL REFERENCES space_sites(id),
+ hostname text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('default','custom')),
+ status text COLLATE "C" NOT NULL CHECK (status IN ('pending','verified','failed','lapsed')),
+ token text COLLATE "C", -- the TXT verification value (custom only; public in DNS, not a secret)
+ created_by text COLLATE "C" NOT NULL,
+ created_at bigint NOT NULL,
+ verified_at bigint,
+ last_checked_at bigint,
+ last_error text COLLATE "C",
+ check_count bigint NOT NULL DEFAULT 0,
+ record_missing_since bigint -- verified domain whose TXT record disappeared
+);
+CREATE UNIQUE INDEX space_domains_verified ON space_domains (hostname) WHERE status = 'verified';
+CREATE UNIQUE INDEX space_domains_site_host ON space_domains (site_id, hostname);
+CREATE INDEX space_domains_status ON space_domains (kind, status, last_checked_at);
+
+-- Abuse takedowns (staff only): a site, or every site of a project, stops being served (451) while
+-- its deploys, files and objects are KEPT for review. Rows are never deleted: a lift is recorded.
+CREATE TABLE space_takedowns (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ target_kind text COLLATE "C" NOT NULL CHECK (target_kind IN ('project','site')),
+ target_id text COLLATE "C" NOT NULL, -- prj_… or site_…
+ reason text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ created_at bigint NOT NULL,
+ lifted_by text COLLATE "C",
+ lifted_at bigint,
+ lift_note text COLLATE "C"
+);
+CREATE UNIQUE INDEX space_takedowns_active ON space_takedowns (target_kind, target_id) WHERE lifted_at IS NULL;
+
+-- Deploy artifacts are immutable: only state / deleted_at may change, and never back from 'deleted'.
+CREATE FUNCTION space_deploys_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TRUE THEN RAISE EXCEPTION 'space: deploy artifacts are immutable'; END IF; RETURN NEW; END $$;
+CREATE TRIGGER space_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 space_deploys FOR EACH ROW EXECUTE FUNCTION space_deploys_immutable();
+CREATE FUNCTION space_deploys_no_undelete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.state = 'deleted' OR (OLD.state = 'failed' AND NEW.state = 'ready') THEN RAISE EXCEPTION 'space: a deleted or failed deploy cannot become ready'; END IF; RETURN NEW; END $$;
+CREATE TRIGGER space_deploys_no_undelete BEFORE UPDATE OF state ON space_deploys FOR EACH ROW EXECUTE FUNCTION space_deploys_no_undelete();
+CREATE FUNCTION space_deploy_files_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TRUE THEN RAISE EXCEPTION 'space: deploy files are immutable'; END IF; RETURN NEW; END $$;
+CREATE TRIGGER space_deploy_files_immutable BEFORE UPDATE ON space_deploy_files FOR EACH ROW EXECUTE FUNCTION space_deploy_files_immutable();
+CREATE FUNCTION space_deploy_files_delete_only_deleted() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF (SELECT state FROM space_deploys WHERE id = OLD.deploy_id) <> 'deleted' THEN RAISE EXCEPTION 'space: files of a live deploy cannot be removed'; END IF; RETURN OLD; END $$;
+CREATE TRIGGER space_deploy_files_delete_only_deleted BEFORE DELETE ON space_deploy_files FOR EACH ROW EXECUTE FUNCTION space_deploy_files_delete_only_deleted();
+
+-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled
+CREATE TABLE IF NOT EXISTS idempotency_receipts (
+ consumer text NOT NULL,
+ event_id text NOT NULL,
+ processed_at bigint NOT NULL,
+ PRIMARY KEY (consumer, event_id)
+);
+
+-- openvibe-sdk/events PostgreSQL outbox
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+-- phase: expand
+-- Per-site serving configuration (plan T12 J3, decision D4): the response headers, the redirects and
+-- the SPA fallback of ONE site, kept in PostgreSQL and applied in server/http/tenant.js BEFORE the
+-- platform's own headers. The platform's security, routing and caching headers (CSP, HSTS,
+-- Set-Cookie, X-Forwarded-*, Cache-Control, …) are reserved and can never be set through it, and a
+-- redirect target must be a local path, so a site can never become an open redirect. A site with no
+-- row here serves exactly as before (no headers, no redirects, no SPA fallback).
+CREATE TABLE space_site_config (
+ site_id text COLLATE "C" PRIMARY KEY REFERENCES space_sites(id),
+ headers jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "Header-Name": "value" }, validated on write
+ redirects jsonb NOT NULL DEFAULT '[]'::jsonb, -- [ { from, to, status } ], local targets only
+ spa boolean NOT NULL DEFAULT false, -- serve index.html for extensionless paths
+ updated_by text COLLATE "C",
+ updated_at bigint NOT NULL
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+-- phase: expand
+-- Preview deploys (plan T12 J4, decision D5): a preview is served by the dashboard only, at
+-- /preview/<deploy-id>/…, to a member of the deploy's project — never on a public host, never indexed.
+--
+-- source='preview' a deploy uploaded as a preview (its archive/files kind is in the upload log).
+-- preview pointer space_sites.preview_deploy_id names the site's ONE live preview; the tenant
+-- server reads it once, like active_deploy_id. The pointer is cleared when the
+-- preview expires, when the site deploys or rolls back (any pointer switch), when
+-- the preview is deleted, or when the site is deleted.
+-- preview_expires_at a preview stops being served at this instant (epoch ms); the row and its
+-- objects stay until the deploy is deleted.
+ALTER TABLE space_deploys DROP CONSTRAINT space_deploys_source_check;
+ALTER TABLE space_deploys ADD CONSTRAINT space_deploys_source_check CHECK (source IN ('archive', 'files', 'preview'));
+
+ALTER TABLE space_sites ADD COLUMN preview_deploy_id text COLLATE "C";
+ALTER TABLE space_sites ADD COLUMN preview_expires_at bigint;
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+-- phase: expand
+-- Git deploys, Phase 1 (plan T12 Stage B): provenance and preview, with the build OUTSIDE Host.
+-- A site may name the Git repository and branch its project's own CI builds from. That CI checks the
+-- commit out, builds it and posts the output to POST /api/v1/sites/:id/source/deploys with the ref and
+-- the commit it built; Host validates the files exactly like an upload and stores them as a deploy
+-- with source 'git' that becomes the site's live preview, never its active deploy. Host never clones,
+-- fetches, holds a credential or runs anything: these rows hold public provenance only.
+--
+-- space_site_sources the site's connected repository (one per site): an https URL on a known
+-- provider and the branch the CI deploys from. No secret, no credential name.
+-- space_deploy_git what a git deploy was built from, written in the same transaction as the
+-- deploy and immutable afterwards, like the deploy's files.
+ALTER TABLE space_deploys DROP CONSTRAINT space_deploys_source_check;
+ALTER TABLE space_deploys ADD CONSTRAINT space_deploys_source_check CHECK (source IN ('archive', 'files', 'preview', 'git'));
+
+CREATE TABLE space_site_sources (
+ site_id text COLLATE "C" PRIMARY KEY REFERENCES space_sites(id),
+ provider text COLLATE "C" NOT NULL CHECK (provider IN ('github', 'gitlab', 'codeberg')),
+ repo_url text COLLATE "C" NOT NULL,
+ ref text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C",
+ created_at bigint NOT NULL,
+ updated_by text COLLATE "C",
+ updated_at bigint NOT NULL
+);
+
+CREATE TABLE space_deploy_git (
+ deploy_id text COLLATE "C" PRIMARY KEY REFERENCES space_deploys(id),
+ provider text COLLATE "C" NOT NULL,
+ repo_url text COLLATE "C" NOT NULL,
+ ref text COLLATE "C" NOT NULL,
+ commit_sha text COLLATE "C" NOT NULL,
+ created_at bigint NOT NULL
+);
+
+-- A deploy's provenance is immutable, like its files.
+CREATE FUNCTION space_deploy_git_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TRUE THEN RAISE EXCEPTION 'space: deploy provenance is immutable'; END IF; RETURN NEW; END $$;
+CREATE TRIGGER space_deploy_git_immutable BEFORE UPDATE ON space_deploy_git FOR EACH ROW EXECUTE FUNCTION space_deploy_git_immutable();
@@ -9,18 +9,20 @@
@@ -9,18 +9,20 @@
"version": "0.1.0",
"version": "0.1.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
+ "busboy": "^1.6.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.3"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -115,6 +117,17 @@
@@ -115,6 +117,17 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
"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": {
"node_modules/bytes": {
"version": "3.1.2",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -791,9 +804,9 @@
@@ -791,9 +804,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.143.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",
+ "version": "0.149.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",
+ "integrity": "sha512-7TGh8i3dPHHxQsAblWMSPvkigY//mqaUtm1u7T3QtIiBLlGKRlEwCgc3nTd7agd5f7N8aDxfw6z8Ls8bAg/smA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1231,6 +1244,14 @@
@@ -1231,6 +1244,14 @@
"node": ">= 0.8"
"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/toidentifier": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-space",
"name": "openvibe-space",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.Space — code and dynamic pages for the OpenVibe network, and \"spaces\": apps people build that use their OpenVibe account, the network's services and the SDK. The forum moved back to OpenVibe.Community (owner decision, 2026-10-08); every forum URL here redirects there.",
+ "description": "OpenVibe.Space — staff-only static hosting for the OpenVibe network; dynamic pages and spaces are planned. Forum URLs redirect to OpenVibe.Community.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -13,20 +13,21 @@
@@ -13,20 +13,21 @@
"keywords": [
"keywords": [
"openvibe",
"openvibe",
"space",
"space",
- "code",
- "pages",
- "apps"
+ "static hosting",
+ "sites",
+ "deploys"
],
],
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Space",
"repository": "github:OpenVibers/OpenVibe.Space",
"dependencies": {
"dependencies": {
+ "busboy": "^1.6.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -35,6 +36,7 @@
@@ -35,6 +36,7 @@
"node": ">=22.0.0"
"node": ">=22.0.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.3"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,198 @@
@@ -0,0 +1,198 @@
+/* OpenVibe.Space — the hosting dashboard (server/render/hosting-pages.js, hd-*). Loaded only on dashboard pages
+ (layout localStyles); colours come from the network theme variables, with the default theme as fallback. */
+
+.hosting-dashboard .page {
+ --hd-card: var(--bg-card, var(--bg-secondary, #101828));
+ --hd-line: var(--border, #24324d);
+ --hd-line-strong: var(--border-light, #2c3d5c);
+ --hd-text: var(--text-primary, #e6edf7);
+ --hd-dim: var(--text-secondary, #96a7c2);
+ --hd-faint: var(--text-muted, #6b7c99);
+ --hd-accent: var(--accent, #3b82f6);
+ --hd-ok: var(--success, #22c55e);
+ --hd-warn: var(--warning, #f59e0b);
+ --hd-bad: var(--danger, #ef4444);
+ max-width: 1120px;
+ margin: 0 auto;
+ padding: 32px 24px 80px;
+ color: var(--hd-text);
+}
+
+.hosting-dashboard code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .86em; }
+.hosting-dashboard a { color: var(--accent-light, var(--hd-accent)); }
+.hd-muted { color: var(--hd-dim); font-size: 14px; line-height: 1.5; }
+.hd-wrap { overflow-wrap: anywhere; }
+.hd-num { text-align: right; font-variant-numeric: tabular-nums; }
+
+/* Page header */
+.hd-head { margin: 0 0 24px; }
+.hd-head h1 { margin: 6px 0 8px; font-size: clamp(26px, 3.4vw, 36px); line-height: 1.15; letter-spacing: -.02em; overflow-wrap: anywhere; }
+.hd-head h1 code { font-size: .8em; }
+.hd-eyebrow { display: inline-flex; gap: 8px; align-items: center; margin: 0; padding: 4px 12px; border-radius: 999px; border: 1px solid var(--hd-line-strong); color: var(--accent-light, var(--hd-accent)); font-size: 12px; font-weight: 700; letter-spacing: .4px; }
+.hd-sub { margin: 0; color: var(--hd-dim); max-width: 640px; line-height: 1.6; }
+.hd-crumbs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 14px; color: var(--hd-faint); }
+.hd-crumbs a { color: var(--hd-dim); text-decoration: none; }
+.hd-crumbs a:hover { color: var(--hd-text); text-decoration: underline; }
+.hd-chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0; color: var(--hd-dim); }
+.hd-chips > code { color: var(--hd-faint); }
+
+/* Badges */
+.hd-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; line-height: 18px; white-space: nowrap; border: 1px solid var(--hd-line-strong); color: var(--hd-dim); background: transparent; }
+.hd-badge--ok { color: var(--hd-ok); border-color: color-mix(in srgb, var(--hd-ok) 45%, transparent); background: color-mix(in srgb, var(--hd-ok) 12%, transparent); }
+.hd-badge--warn { color: var(--hd-warn); border-color: color-mix(in srgb, var(--hd-warn) 45%, transparent); background: color-mix(in srgb, var(--hd-warn) 12%, transparent); }
+.hd-badge--bad { color: var(--hd-bad); border-color: color-mix(in srgb, var(--hd-bad) 45%, transparent); background: color-mix(in srgb, var(--hd-bad) 12%, transparent); }
+.hd-badge--accent { color: var(--accent-light, var(--hd-accent)); border-color: color-mix(in srgb, var(--hd-accent) 50%, transparent); background: color-mix(in srgb, var(--hd-accent) 14%, transparent); }
+
+/* Cards */
+.hd-card { min-width: 0; margin: 0 0 20px; padding: 20px 22px; border: 1px solid var(--hd-line); border-radius: 16px; background: var(--hd-card); }
+.hd-card__head { display: flex; flex-wrap: wrap; gap: 12px 16px; align-items: flex-start; justify-content: space-between; margin: 0 0 14px; }
+.hd-card__head h2 { margin: 0; font-size: 18px; line-height: 1.3; }
+.hd-card__head p { margin: 4px 0 0; max-width: 720px; }
+.hd-card__actions { display: flex; gap: 8px; flex-wrap: wrap; }
+.hd-card--danger { border-color: color-mix(in srgb, var(--hd-bad) 40%, var(--hd-line)); }
+.hd-card--danger h2 { color: var(--hd-bad); }
+.hd-split { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; }
+
+/* Buttons */
+.hd-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; padding: 8px 16px; border-radius: 10px; border: 1px solid var(--hd-line-strong); background: var(--bg-tertiary, #16223a); color: var(--hd-text); font: inherit; font-size: 14px; font-weight: 700; text-decoration: none; cursor: pointer; }
+.hd-btn:hover, .hd-btn:focus-visible { border-color: var(--hd-accent); background: var(--bg-hover, #1c2a44); outline: 0; }
+.hd-btn--primary { background: var(--accent-strong, #2563eb); border-color: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
+.hd-btn--primary:hover, .hd-btn--primary:focus-visible { background: var(--accent-dark, #1d4ed8); border-color: var(--accent-light, #60a5fa); }
+.hd-btn--danger { background: transparent; border-color: color-mix(in srgb, var(--hd-bad) 60%, transparent); color: var(--hd-bad); }
+.hd-btn--danger:hover, .hd-btn--danger:focus-visible { background: color-mix(in srgb, var(--hd-bad) 14%, transparent); border-color: var(--hd-bad); }
+.hd-btn--small { min-height: 32px; padding: 5px 12px; font-size: 13px; }
+.hd-link { padding: 4px 6px; border: 0; background: none; color: var(--hd-dim); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
+.hd-link--danger { color: var(--hd-bad); }
+.hd-link:hover, .hd-link:focus-visible { color: var(--hd-text); outline: 0; }
+.hd-link--danger:hover, .hd-link--danger:focus-visible { color: var(--hd-bad); text-decoration-thickness: 2px; }
+
+/* Forms */
+.hd-form { display: grid; gap: 14px; margin: 0; }
+.hd-form > .hd-btn, .hd-form > .hd-confirm { justify-self: start; }
+.hd-form--row { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: flex-end; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--hd-line); }
+.hd-form--row .hd-field { flex: 1 1 220px; }
+.hd-inline { display: inline-flex; margin: 0; }
+.hd-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
+.hd-field { display: grid; gap: 6px; font-size: 14px; font-weight: 600; color: var(--hd-text); }
+.hd-field .hd-muted { font-weight: 400; font-size: 13px; }
+.hd-field--narrow { max-width: 360px; }
+.hosting-dashboard input:not([type=checkbox]):not([type=radio]):not([type=file]),
+.hosting-dashboard select,
+.hosting-dashboard textarea { width: 100%; box-sizing: border-box; min-height: 40px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--hd-line-strong); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--hd-text); font: inherit; font-size: 14px; font-weight: 400; }
+.hosting-dashboard textarea { min-height: 96px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; resize: vertical; }
+.hosting-dashboard input:focus-visible, .hosting-dashboard select:focus-visible, .hosting-dashboard textarea:focus-visible { outline: 2px solid var(--hd-accent); outline-offset: 1px; border-color: var(--hd-accent); }
+.hosting-dashboard input::placeholder, .hosting-dashboard textarea::placeholder { color: var(--hd-faint); }
+.hd-confirm { display: flex; gap: 10px; align-items: flex-start; font-size: 14px; color: var(--hd-dim); }
+.hd-confirm input, .hd-choice input { accent-color: var(--hd-accent); width: 16px; height: 16px; margin: 2px 0 0; flex: none; }
+
+/* Upload: two source tiles, then the three outcomes as choices */
+.hd-sources { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
+.hd-source { margin: 0; padding: 14px 16px 16px; border: 1px dashed var(--hd-line-strong); border-radius: 12px; min-width: 0; }
+.hd-source legend { padding: 0 6px; font-size: 14px; font-weight: 700; display: inline-flex; gap: 8px; align-items: center; }
+.hd-source input[type=file] { width: 100%; color: var(--hd-dim); font: inherit; font-size: 13px; }
+.hd-source input[type=file]::file-selector-button { margin-right: 12px; padding: 7px 14px; border-radius: 9px; border: 1px solid var(--hd-line-strong); background: var(--bg-tertiary, #16223a); color: var(--hd-text); font: inherit; font-weight: 700; cursor: pointer; }
+.hd-source input[type=file]::file-selector-button:hover { border-color: var(--hd-accent); }
+.hd-choices { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin: 0; padding: 0; border: 0; min-width: 0; }
+.hd-choices legend { padding: 0 0 8px; font-size: 14px; font-weight: 600; }
+.hd-choice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border: 1px solid var(--hd-line-strong); border-radius: 12px; cursor: pointer; }
+.hd-choice > span { display: grid; gap: 2px; font-size: 14px; }
+.hd-choice:has(input:checked) { border-color: var(--hd-accent); background: color-mix(in srgb, var(--hd-accent) 10%, transparent); }
+.hd-choice:focus-within { outline: 2px solid var(--hd-accent); outline-offset: 1px; }
+
+/* Tables */
+.hd-table-wrap { overflow-x: auto; margin: 0 -4px; }
+.hd-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.hd-table th { padding: 8px 10px; text-align: left; font-size: 12px; font-weight: 700; letter-spacing: .3px; text-transform: uppercase; color: var(--hd-faint); border-bottom: 1px solid var(--hd-line); white-space: nowrap; }
+.hd-table td { padding: 10px; border-bottom: 1px solid var(--hd-line); vertical-align: middle; }
+.hd-table tbody tr:last-child td { border-bottom: 0; }
+.hd-table tbody tr:hover { background: color-mix(in srgb, var(--hd-text) 3%, transparent); }
+.hd-table tr.hd-active td { background: color-mix(in srgb, var(--hd-accent) 8%, transparent); }
+.hd-table tr.hd-active td:first-child { box-shadow: inset 3px 0 0 var(--hd-accent); }
+.hd-actions { text-align: right; white-space: nowrap; }
+.hd-actions > * + * { margin-left: 6px; }
+.hd-table--dns td { vertical-align: top; }
+
+/* Rows (sites), tiles (projects), meters (quotas) */
+.hd-rows { list-style: none; margin: 0; padding: 0; }
+.hd-row { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--hd-line); }
+.hd-row:last-child { border-bottom: 0; }
+.hd-row__main { display: grid; gap: 2px; min-width: 0; }
+.hd-row__title { font-weight: 700; font-size: 16px; color: var(--hd-text) !important; text-decoration: none; }
+.hd-row__title:hover { text-decoration: underline; }
+.hd-row__side { display: inline-flex; gap: 8px; align-items: center; color: var(--hd-faint); }
+.hd-url { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; color: var(--hd-dim) !important; text-decoration: none; overflow-wrap: anywhere; }
+.hd-url i { font-size: 11px; opacity: .7; }
+.hd-url:hover { color: var(--hd-text) !important; text-decoration: underline; }
+.hd-url--big { font-size: 15px; font-weight: 600; color: var(--accent-light, var(--hd-accent)) !important; }
+.hd-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; margin: 0 0 20px; }
+.hd-tile { display: flex; gap: 14px; align-items: flex-start; padding: 18px; border: 1px solid var(--hd-line); border-radius: 16px; background: var(--hd-card); color: var(--hd-text) !important; text-decoration: none; transition: border-color .15s; }
+.hd-tile:hover, .hd-tile:focus-visible { border-color: var(--hd-accent); outline: 0; }
+.hd-tile__icon { display: grid; place-items: center; flex: none; width: 40px; height: 40px; border-radius: 12px; background: color-mix(in srgb, var(--hd-accent) 16%, transparent); color: var(--accent-light, var(--hd-accent)); }
+.hd-tile__body { display: grid; gap: 6px; min-width: 0; flex: 1; }
+.hd-tile__body strong { font-size: 16px; overflow-wrap: anywhere; }
+.hd-tile__meta { display: flex; gap: 6px; flex-wrap: wrap; }
+.hd-tile__go { color: var(--hd-faint); align-self: center; }
+.hd-meters { display: grid; gap: 14px; }
+.hd-meter__label { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--hd-dim); margin-bottom: 6px; }
+.hd-meter__label strong { color: var(--hd-text); }
+.hd-meter__bar { height: 6px; border-radius: 99px; background: color-mix(in srgb, var(--hd-text) 9%, transparent); overflow: hidden; }
+.hd-meter__bar span { display: block; height: 100%; min-width: 2px; border-radius: inherit; background: var(--hd-ok); }
+.hd-meter__bar--warn span { background: var(--hd-warn); }
+.hd-meter__bar--bad span { background: var(--hd-bad); }
+.hd-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--hd-line); }
+.hd-facts div { display: grid; gap: 2px; }
+.hd-facts dt { font-size: 12px; color: var(--hd-faint); text-transform: uppercase; letter-spacing: .3px; font-weight: 700; }
+.hd-facts dd { margin: 0; font-size: 14px; overflow-wrap: anywhere; }
+.hd-facts--wide { margin-top: 18px; border: 1px solid var(--hd-line); border-radius: 14px; padding: 14px 16px; background: var(--hd-card); }
+
+/* Domains, log, history, notices */
+.hd-domains { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
+.hd-domain { min-width: 0; padding: 14px 16px; border: 1px solid var(--hd-line); border-radius: 12px; }
+.hd-domain__head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
+.hd-domain__head strong { overflow-wrap: anywhere; }
+.hd-domain p { margin: 6px 0 0; }
+.hd-domain__actions { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
+.hd-details { margin-top: 10px; }
+.hd-details summary { cursor: pointer; font-size: 14px; font-weight: 600; color: var(--hd-dim); }
+.hd-details summary:hover { color: var(--hd-text); }
+.hd-details[open] summary { margin-bottom: 8px; }
+.hd-history { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 14px; }
+.hd-history li { display: flex; gap: 10px; flex-wrap: wrap; }
+.hd-history time { color: var(--hd-faint); font-variant-numeric: tabular-nums; }
+.hd-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
+.hd-log__line { display: flex; gap: 10px; align-items: baseline; padding: 6px 8px; border-radius: 8px; }
+.hd-log__line > span:last-child { overflow-wrap: anywhere; }
+.hd-log__line--error { background: color-mix(in srgb, var(--hd-bad) 9%, transparent); }
+.hd-log__line--warn { background: color-mix(in srgb, var(--hd-warn) 8%, transparent); }
+.hd-notice, .hd-callout { display: flex; gap: 10px; align-items: flex-start; margin: 0 0 14px; padding: 12px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--hd-accent) 40%, var(--hd-line)); background: color-mix(in srgb, var(--hd-accent) 8%, transparent); font-size: 14px; line-height: 1.5; }
+.hd-callout p { margin: 0; }
+.hd-callout i, .hd-notice i { margin-top: 3px; color: var(--accent-light, var(--hd-accent)); }
+.hd-flash { margin: 0 0 18px; padding: 12px 16px; border-radius: 12px; font-size: 14px; border: 1px solid; }
+.hd-flash--ok { border-color: color-mix(in srgb, var(--hd-ok) 45%, transparent); background: color-mix(in srgb, var(--hd-ok) 10%, transparent); }
+.hd-flash--bad { border-color: color-mix(in srgb, var(--hd-bad) 50%, transparent); background: color-mix(in srgb, var(--hd-bad) 10%, transparent); }
+.hd-empty { display: grid; justify-items: center; gap: 8px; margin: 0 0 20px; padding: 36px 16px; border: 1px dashed var(--hd-line-strong); border-radius: 16px; color: var(--hd-dim); text-align: center; }
+.hd-empty i { font-size: 26px; color: var(--hd-faint); }
+.hd-empty p { margin: 0; }
+.hd-empty--small { padding: 20px 12px; margin: 0; }
+.hd-error { max-width: 560px; margin: 40px auto; text-align: center; display: grid; justify-items: center; gap: 4px; }
+.hd-error > i { font-size: 34px; color: var(--hd-faint); }
+.hd-error h1 { margin: 10px 0 0; }
+.hd-error p { color: var(--hd-dim); }
+
+@media (max-width: 900px) {
+ .hd-split { grid-template-columns: minmax(0, 1fr); }
+}
+@media (max-width: 600px) {
+ .hosting-dashboard .page { padding: 20px 16px 64px; }
+ .hd-card { padding: 16px; border-radius: 14px; }
+ .hd-actions { white-space: normal; }
+ .hd-table th:nth-child(5), .hd-table td:nth-child(5) { display: none; }
+ /* DNS records stack: the type, then name, value and note under it. */
+ .hd-table--dns thead { display: none; }
+ .hd-table--dns tr { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 10px; padding: 10px 4px; border-bottom: 1px solid var(--hd-line); }
+ .hd-table--dns tr:last-child { border-bottom: 0; }
+ .hd-table--dns td { padding: 0; border: 0; }
+ .hd-table--dns td:first-child { grid-row: span 3; }
+ .hd-table--dns td:nth-child(5) { display: block; }
+ .hd-domain { padding: 12px; }
+}
@@ -64,3 +64,8 @@ body {
@@ -64,3 +64,8 @@ body {
background: var(--bg-hover, #1c2a44);
background: var(--bg-hover, #1c2a44);
outline: 0;
outline: 0;
}
}
+
+/* The quotas note names its source file by URL: let it wrap on a phone. */
+.sc-note a {
+ overflow-wrap: anywhere;
+}
@@ -1,13 +1,13 @@
@@ -1,13 +1,13 @@
'use strict';
'use strict';
/**
/**
- * OpenVibe.Space — code and dynamic pages for the OpenVibe network.
+ * OpenVibe.Space — static hosting for the OpenVibe network.
*
*
* Express app factory (server/index.js listens; tests build their own instance).
* Express app factory (server/index.js listens; tests build their own instance).
*
*
- * Space is being rebuilt and publishes one page of its own:
+ * Space serves tenant sites, a hosting API and dashboard, plus a public page:
*
*
- * GET / the home page: what Space will host, and where the forum went
+ * GET / the home page: static hosting status and where the forum went
* GET /auth/login|callback|fedcm|logout|me|refresh sign-in (OpenVibe.Network OAuth2 client)
* GET /auth/login|callback|fedcm|logout|me|refresh sign-in (OpenVibe.Network OAuth2 client)
* GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml crawler endpoints (the sitemap is / alone)
* GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml crawler endpoints (the sitemap is / alone)
* GET /api/health, /api/ready, /release.json, /metrics (loopback)
* GET /api/health, /api/ready, /release.json, /metrics (loopback)
@@ -16,8 +16,8 @@
@@ -16,8 +16,8 @@
* a permanent redirect to the same path and query there — /s and /s/* (301 for GET and HEAD, 308
* a permanent redirect to the same path and query there — /s and /s/* (301 for GET and HEAD, 308
* otherwise), and /api/v1/spaces*, /api/v1/posts/*, /api/v1/space-groups* and /api/v1/relay* (308).
* otherwise), and /api/v1/spaces*, /api/v1/posts/*, /api/v1/space-groups* and /api/v1/relay* (308).
*
*
- * Space holds no user content, so it consumes no network events and has no account export or
- * deletion: the forum's account data is Community's. See README.md.
+ * Space holds hosted content and emits hosting lifecycle events. The historical forum rows belong
+ * to Community; the forum redirects below stay intact. See README.md.
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -64,10 +64,30 @@ async function createApp(opts = {}) {
@@ -64,10 +64,30 @@ async function createApp(opts = {}) {
const release = require('openvibe-shared/release').createRelease({ service: 'space', root: path.join(__dirname, '..') });
const release = require('openvibe-shared/release').createRelease({ service: 'space', root: path.join(__dirname, '..') });
require('./render/layout').setRelease(release.release);
require('./render/layout').setRelease(release.release);
// HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
// HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
- // direct loopback callers only (Track O). Request metrics only: no content counts.
- const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'space', release: release.release });
+ // direct loopback callers only (Track O), and only on the dashboard host (mounted after the host dispatch).
+ // Request metrics only: no content counts. A tenant request is counted as one route, never by its path.
+ const sharedMetrics = require('openvibe-shared/metrics');
+ 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: 'space', release: release.release });
+ app.use(httpMetrics.middleware);
+ require('openvibe-shared/express-async').routeAsyncErrors(app);
+ const metrics = { registry, http: httpMetrics, stop: proc.stop };
app.locals.metrics = metrics.registry;
app.locals.metrics = metrics.registry;
+ // Host dispatch precedes everything else, including the platform's headers: a tenant site is answered by
+ // http/tenant.js with its own headers and never reaches /metrics, auth, the API, readiness or the forum redirects.
+ const auth = opts.auth || createAuthClient(config);
+ const db = opts.db || getDb();
+ app.locals.auth = auth;
+ app.locals.config = config;
+ app.locals.db = db;
+ const hosting = require('./hosting').createHosting(app, { db, auth, config: opts.hostingConfig, now: opts.now, resolver: opts.resolver, fetchImpl: opts.fetchImpl, indexnow: opts.indexnow, log: opts.log, storageDir: opts.storageDir, forumRedirect });
+ app.locals.hosting = hosting;
+ hosting.mountDispatch(app);
+ app.get('/metrics', sharedMetrics.metricsHandler(registry));
+
app.use(helmet({
app.use(helmet({
contentSecurityPolicy: {
contentSecurityPolicy: {
directives: {
directives: {
@@ -96,21 +116,13 @@ async function createApp(opts = {}) {
@@ -96,21 +116,13 @@ async function createApp(opts = {}) {
}));
}));
app.use(cookieParser());
app.use(cookieParser());
- // ── The forum is Community's: every forum URL redirects there ───
- app.use(forumRedirect);
+ hosting.mountRoutes(app);
// ── Auth (OAuth2 client of OpenVibe.Network) ─────────────
// ── Auth (OAuth2 client of OpenVibe.Network) ─────────────
- const auth = opts.auth || createAuthClient(config);
- app.locals.auth = auth;
- app.locals.config = config;
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth', createAuthRoutes(config, auth));
app.use('/auth', createAuthRoutes(config, auth));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'space', service: 'space', host: 'openvibe.space', name: 'OpenVibe.Space', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'space', service: 'space', host: 'openvibe.space', name: 'OpenVibe.Space', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
- // ── Space's database (for readiness and the shared schema) ───
- const db = opts.db || getDb();
- app.locals.db = db;
-
// /updates: Space ships with the community's log until it has one of its own.
// /updates: Space ships with the community's log until it has one of its own.
app.get('/updates', (_req, res) => res.redirect(302, `${config.communityUrl}/updates`));
app.get('/updates', (_req, res) => res.redirect(302, `${config.communityUrl}/updates`));
@@ -120,7 +132,7 @@ async function createApp(opts = {}) {
@@ -120,7 +132,7 @@ async function createApp(opts = {}) {
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
// Readiness reports what is actually served: 503 only without the database; a Network key that has
// Readiness reports what is actually served: 503 only without the database; a Network key that has
// not loaded degrades (server/observability.js).
// not loaded degrades (server/observability.js).
- const readiness = require('./observability').createSpaceReadiness({ db, auth, config, release: release.release });
+ const readiness = require('./observability').createSpaceReadiness({ db, auth, config, release: release.release, hosting });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// ── Static assets (content-hashed ?v= → immutable) ───────
// ── Static assets (content-hashed ?v= → immutable) ───────
@@ -150,7 +162,7 @@ async function createApp(opts = {}) {
@@ -150,7 +162,7 @@ async function createApp(opts = {}) {
});
});
// ── Pages ────────────────────────────────────────────────
// ── Pages ────────────────────────────────────────────────
- // The home page: what Space will host, and where the forum went (no fake features).
+ // The public home page: what works now and what comes next.
app.get('/', (_req, res) => res.type('html').set('Cache-Control', PAGE_CACHE).send(require('./render/pages').homePage()));
app.get('/', (_req, res) => res.type('html').set('Cache-Control', PAGE_CACHE).send(require('./render/pages').homePage()));
// ── 404 / errors ─────────────────────────────────────────
// ── 404 / errors ─────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+'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; it runs on the
+ * libuv threadpool (async zlib), so a large upload does not block the event loop.
+ */
+const zlib = require('zlib');
+const { promisify } = require('util');
+
+const gunzip = promisify(zlib.gunzip);
+
+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 }
+ */
+async 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 = await gunzip(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 }); // a view into the tar buffer: no second copy
+ }
+ 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,106 @@
@@ -0,0 +1,106 @@
+'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._@+~()!,= -]+$/;
+const CA_VALIDATION = new Set(['acme-challenge', 'pki-validation']);
+
+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`);
+ });
+ // Certificate authorities prove control of a host name with files under these paths (ACME
+ // HTTP-01, and the file-based validation other CAs accept over HTTPS). A tenant must never be
+ // able to obtain a certificate for <site>.openvibe.space: the operator issues every certificate.
+ if (segments[0] === '.well-known' && CA_VALIDATION.has(String(segments[1]).toLowerCase())) {
+ throw new PathError('path.reserved', `"${segments.slice(0, 2).join('/')}/" is reserved: certificate validation files are never published from a site`);
+ }
+ return input;
+}
+
+// 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: Space 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: 'space.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: 'space.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,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/**
+ * Capability checks for service tokens (audience openvibe.space), by openvibe-contracts' manifests:
+ *
+ * space.site.manage projects, members, sites, quotas (read), deleting deploys
+ * space.deploy.create upload deploys, list them and their logs, activate, roll back
+ * space.domain.manage add, verify and remove custom domains
+ * space.site.config read, set and reset a site's response headers, redirects and SPA fallback
+ * space.resource.read read the resource index (GET /api/v1/resources; ADR-048 section 3, plan T13 step 8)
+ *
+ * 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: 'space.site.manage',
+ DEPLOY_CREATE: 'space.deploy.create',
+ DOMAIN_MANAGE: 'space.domain.manage',
+ SITE_CONFIG: 'space.site.config',
+ RESOURCE_READ: 'space.resource.read',
+});
+
+/** → { allowed, code, reason } (capabilities.check(); space.* since openvibe-contracts 0.149.0). */
+function checkCapability(claims, capabilityId) {
+ return capabilities.check(claims, capabilityId);
+}
+
+module.exports = { CAPABILITIES, 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.space
+ * are the same site as openvibe.space, 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 SPACE_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(`space-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,134 @@
@@ -0,0 +1,134 @@
+'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: only Host's own __Host-ov_space_session cookie (./sso.js). ov_token (openvibe-sdk/sso)
+ * is display-only for the shared navbar: a tenant page on <site>.openvibe.space can plant one.
+ * staff = the contracts staff map's staff.site.configure (ADR-022):
+ * quotas, takedowns, and maintaining or deleting any project (never deploying to it).
+ * { kind: 'service', service: 'svc:codes' | 'app:app_…', claims, subject, env }
+ * A Network client-credentials token for audience openvibe.space. 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 { claimsToUser, decodeJwtPayload } = require('openvibe-sdk/sso');
+const { verifyServiceToken } = require('openvibe-sdk/auth');
+const { checkCapability } = require('./capabilities');
+const sessionToken = (_config, req) => req.cookies && req.cookies.ov_token;
+
+const { ids, http, staff: staffMap } = contracts;
+const PRINCIPAL_SUB = /^(svc|app|mod):/;
+const AUDIENCE = 'openvibe.space';
+
+/** API callers: only the Authorization header (cookies are never read on /api/v1). */
+const bearerToken = (req) => {
+ const h = String(req.headers.authorization || '');
+ return h.startsWith('Bearer ') ? h.slice(7).trim() || null : null;
+};
+
+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, log = console }) {
+ async function fromServiceToken(req, token) {
+ // The SDK supplies the key (the shared JWKS client, through a Network outage); every token rule is the
+ // service's own pinned openvibe-contracts serviceAuth.verifyServiceToken.
+ // acceptSandbox: Host takes sandbox tokens itself and confines them to sandbox projects
+ // (projects.js/access.js answer 403 environment.sandbox_token elsewhere); openvibe-contracts
+ // >= 0.26.0 refuses them before that unless the receiver opts in.
+ const r = await verifyServiceToken(token, {
+ jwks: `${config.networkInternalUrl}/api/.well-known/jwks`,
+ issuer: config.networkUrl,
+ audience: AUDIENCE,
+ contracts,
+ acceptSandbox: true,
+ log,
+ });
+ if (!r.ok) {
+ if (r.code === 'token.unavailable') throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
+ 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;
+ // Network signs FedCM ID assertions (typ "fedcm", aud = any OpenVibe RP origin, including
+ // tenant subdomains of openvibe.space) with the same key and issuer as access tokens. An
+ // assertion is only ever swapped at /oauth/token, never presented as a session. Access
+ // tokens carry no typ.
+ if (claims.typ !== undefined || claims.nonce !== undefined) return null;
+ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ if (!subject) return null;
+ return { kind: 'user', subject, staff: staffMap.can(claims, 'staff.site.configure'), 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 await 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(config, req);
+ 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, AUDIENCE };
@@ -42,8 +42,8 @@ module.exports = {
@@ -42,8 +42,8 @@ module.exports = {
// OpenVibe.Live — the legal pages the shared footer links (the Network hosts the profile page).
// OpenVibe.Live — the legal pages the shared footer links (the Network hosts the profile page).
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
- // PostgreSQL (ADR-035). Space holds no user content yet; the database is kept for the release
- // ledger and the shared schema migrations. Without DATABASE_URL, development uses an embedded
+ // PostgreSQL (ADR-035). Space stores hosted sites and deploy records here, along with the
+ // historical schema and release ledger. Without DATABASE_URL, development uses an embedded
// PGlite database in data/pglite.
// PGlite database in data/pglite.
db: { url: process.env.DATABASE_URL || '', directUrl: process.env.DATABASE_DIRECT_URL || '' },
db: { url: process.env.DATABASE_URL || '', directUrl: process.env.DATABASE_DIRECT_URL || '' },
};
};
@@ -3,11 +3,11 @@
@@ -3,11 +3,11 @@
/**
/**
* Space's own database: PostgreSQL (ADR-035). The schema is migrations/NNNN_*.sql, applied at boot.
* Space's own database: PostgreSQL (ADR-035). The schema is migrations/NNNN_*.sql, applied at boot.
*
*
- * Space holds no user content while it is rebuilt. The migrations still create the forum tables
+ * Space owns the static hosting tables in migrations 0002-0005. Migration 0001 still creates the forum tables
* (spaces, threads, posts, votes, attachments, the relay's bookkeeping, the event inbox and the
* (spaces, threads, posts, votes, attachments, the relay's bookkeeping, the event inbox and the
* account-data records) that Space served when the forum lived here; they stay in the database —
* account-data records) that Space served when the forum lived here; they stay in the database —
* this release writes no drop migration — and their rows are OpenVibe.Community's now, where the
* this release writes no drop migration — and their rows are OpenVibe.Community's now, where the
- * forum is. The database itself stays for the shared schema and the release ledger.
+ * forum is. The same database serves the hosting engine and the release ledger.
*
*
* Timestamps are text ('YYYY-MM-DD HH:MM:SS', UTC): the schema defines ov_now(), datetime() and julianday()
* Timestamps are text ('YYYY-MM-DD HH:MM:SS', UTC): the schema defines ov_now(), datetime() and julianday()
* with those semantics, and ov_hot() for the forum's hot rank.
* with those semantics, and ov_hot() for the forum's hot rank.
@@ -60,4 +60,8 @@ async function closeDb() {
@@ -60,4 +60,8 @@ async function closeDb() {
if (db) await db.close().catch(() => {});
if (db) await db.close().catch(() => {});
}
}
-module.exports = { openDb, initDb, getDb, setDb, closeDb, MIGRATIONS };
+function createStore(db, { now = () => Date.now() } = {}) {
+ return { db, now, tx: async (fn) => db.tx(() => fn()), close: () => db.close() };
+}
+
+module.exports = { openDb, initDb, getDb, setDb, closeDb, createStore, MIGRATIONS };
@@ -5,23 +5,22 @@
@@ -5,23 +5,22 @@
* /llms-full.txt sections and the sitemap rows. Only data lives here; openvibe-shared/seo writes
* /llms-full.txt sections and the sitemap rows. Only data lives here; openvibe-shared/seo writes
* every format (server/app.js).
* every format (server/app.js).
*
*
- * Space publishes one page of its own — the home page — while it is rebuilt to host code and
- * dynamic pages and "spaces", apps people build on their OpenVibe account, the network's services
- * and the SDK. The forum is OpenVibe.Community's (owner decision, 2026-10-08), so nothing forum
+ * Space serves static hosting for staff; dynamic pages and spaces are planned. The forum is
+ * OpenVibe.Community's (owner decision, 2026-10-08), so nothing forum
* shaped appears here: no /s entries, no feeds.
* shaped appears here: no /s entries, no feeds.
*/
*/
const config = require('./config');
const config = require('./config');
const { abs } = require('./render/layout');
const { abs } = require('./render/layout');
-const ROBOTS_DISALLOW = ['/api/', '/auth/', '/*?sso='];
-const SUMMARY = 'OpenVibe.Space will host code and dynamic pages, and "spaces": apps people build that use their OpenVibe account, the network\'s services and the SDK. The forum is on OpenVibe.Community.';
+const ROBOTS_DISALLOW = ['/api/', '/auth/', '/dashboard', '/projects/', '/sites/', '/deploys/', '/domains/', '/preview/', '/*?sso='];
+const SUMMARY = 'OpenVibe.Space serves tested static hosting for OpenVibe staff: immutable deploys, rollback, previews, Git ingest and verified custom domains. Dynamic pages and spaces are planned. The forum moved to OpenVibe.Community.';
// ── llms.txt / llms-full.txt (llmstxt.org) ───────────────────
// ── llms.txt / llms-full.txt (llmstxt.org) ───────────────────
function llmsSections() {
function llmsSections() {
const u = config.baseUrl;
const u = config.baseUrl;
return [
return [
{ title: 'This site', links: [
{ title: 'This site', links: [
- { title: 'Home', url: `${u}/`, note: 'what Space will host (nothing is published here yet)' },
+ { title: 'Home', url: `${u}/`, note: 'static hosting for staff; dynamic pages and spaces planned' },
] },
] },
{ title: 'The network', links: [
{ title: 'The network', links: [
{ title: 'The forum', url: `${config.communityUrl}/s`, note: 'OpenVibe.Community hosts the forum' },
{ title: 'The forum', url: `${config.communityUrl}/s`, note: 'OpenVibe.Community hosts the forum' },
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 (space_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: 'space' };
+}
+
+function createAccess({ store }) {
+ const { db } = store;
+ const memberStmt = db.prepare('SELECT role FROM space_project_members WHERE project_id = ? AND principal = ?');
+
+ async function roleOf(project, viewer) {
+ const p = principalOf(viewer);
+ if (!p) return null;
+ const row = await 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.
+ */
+ async 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 = await 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,372 @@
@@ -0,0 +1,372 @@
+'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 +
+ * space.deploy.created (+ activation when asked). A refused upload is recorded as a
+ * failed deploy with its log and space.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 space.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.
+ * preview a deploy uploaded as a preview (source 'preview'): it becomes the site's ONE live
+ * preview (space_sites.preview_deploy_id + preview_expires_at) instead of going active,
+ * and is served only by the dashboard, to a member, at /preview/<deploy-id>/… (see
+ * server/http/tenant.js). Any pointer switch clears it (so a deploy or rollback makes it
+ * vanish), it expires on its own, and it is never announced to search engines.
+ * git ingestGit: the output a project's OWN CI built from the site's connected repository
+ * (domain/site-sources.js), posted with the ref and the commit it built. The ref must be
+ * the connected one and the commit a full SHA; the files go through create() like any
+ * upload, are stored with source 'git' plus an immutable space_deploy_git row in the same
+ * transaction, and always land as the site's preview: approving it is the ordinary
+ * activate. A git deploy never moves active_deploy_id by itself.
+ *
+ * Host never builds: it serves the uploaded files (or the CI's build output) as they are, never
+ * clones or fetches a repository, holds no credential for one, and never executes anything.
+ */
+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;
+// How long a preview stays served. Bounded so a draft cannot linger on the dashboard forever; the
+// uploader can always upload another one.
+const PREVIEW_TTL_MS = 60 * 60 * 1000;
+// A full commit id: SHA-1 (40) or SHA-256 (64) object names, lowercase hex.
+const COMMIT_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
+const GIT_COLS = 'g.provider AS git_provider, g.repo_url AS git_repo_url, g.ref AS git_ref, g.commit_sha AS git_commit_sha';
+const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);
+
+function createDeploys({ store, config = null, access, projects, sites, blobs, outbox, takedowns, log = console, indexnow = null }) {
+ const { db } = store;
+ // IndexNow: a site's public page appeared, changed or left the index (never a sandbox site, whose
+ // responses carry X-Robots-Tag noindex). `indexnow(hostname, paths)` queues one debounced batch.
+ const announce = typeof indexnow === 'function' ? indexnow : () => {};
+ const announceSite = (site, project, paths = ['/', '/sitemap.xml']) => {
+ if (!project || project.environment !== 'sandbox') announce(sites.defaultHostname(site.name), paths);
+ };
+ const q = {
+ // A git deploy's provenance comes along as git_* columns (NULL for any other deploy).
+ byId: db.prepare(`SELECT d.*, ${GIT_COLS} FROM space_deploys d LEFT JOIN space_deploy_git g ON g.deploy_id = d.id WHERE d.id = ?`),
+ forSite: db.prepare(`SELECT d.*, ${GIT_COLS} FROM space_deploys d LEFT JOIN space_deploy_git g ON g.deploy_id = d.id
+ WHERE d.site_id = ? AND d.state <> 'deleted' ORDER BY d.created_at DESC, d.seq DESC LIMIT ?`),
+ insert: db.prepare(`INSERT INTO space_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 space_deploy_files (deploy_id, path, sha256, size, content_type) VALUES (?, ?, ?, ?, ?)'),
+ insertBlob: db.prepare('INSERT INTO space_blobs (project_id, sha256, size, created_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING'),
+ hasBlob: db.prepare('SELECT 1 FROM space_blobs WHERE project_id = ? AND sha256 = ?'),
+ storageUsed: db.prepare('SELECT COALESCE(SUM(size), 0)::bigint AS n FROM space_blobs WHERE project_id = ?'),
+ deploysSince: db.prepare('SELECT COUNT(*) AS n FROM space_deploys WHERE project_id = ? AND created_at > ?'),
+ logLine: db.prepare('INSERT INTO space_deploy_logs (deploy_id, level, message, created_at) VALUES (?, ?, ?, ?)'),
+ logFor: db.prepare('SELECT level, message, created_at FROM space_deploy_logs WHERE deploy_id = ? ORDER BY id'),
+ files: db.prepare('SELECT path, sha256, size, content_type FROM space_deploy_files WHERE deploy_id = ? ORDER BY path'),
+ setPointer: db.prepare('UPDATE space_sites SET active_deploy_id = ?, updated_at = ? WHERE id = ? AND status = \'active\' AND active_deploy_id IS NOT DISTINCT FROM ?'),
+ setPreview: db.prepare('UPDATE space_sites SET preview_deploy_id = ?, preview_expires_at = ? WHERE id = ?'),
+ clearPreview: db.prepare('UPDATE space_sites SET preview_deploy_id = NULL, preview_expires_at = NULL WHERE id = ?'),
+ clearPreviewIf: db.prepare('UPDATE space_sites SET preview_deploy_id = NULL, preview_expires_at = NULL WHERE id = ? AND preview_deploy_id = ?'),
+ activation: db.prepare('INSERT INTO space_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 space_activations WHERE site_id = ? ORDER BY id DESC LIMIT ?'),
+ markDeleted: db.prepare("UPDATE space_deploys SET state = 'deleted', deleted_at = ? WHERE id = ?"),
+ dropFiles: db.prepare('DELETE FROM space_deploy_files WHERE deploy_id = ?'),
+ orphans: db.prepare(`SELECT sha256 FROM space_blobs b WHERE b.project_id = ? AND NOT EXISTS (
+ SELECT 1 FROM space_deploy_files f JOIN space_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 space_blobs WHERE project_id = ? AND sha256 = ?'),
+ sourceOf: db.prepare('SELECT provider, repo_url, ref FROM space_site_sources WHERE site_id = ?'),
+ insertGit: db.prepare('INSERT INTO space_deploy_git (deploy_id, provider, repo_url, ref, commit_sha, created_at) VALUES (?, ?, ?, ?, ?, ?)'),
+ };
+
+ async function get(id) {
+ return isId('deploy', id) ? await q.byId.get(id) || null : null;
+ }
+
+ /** The deploy, its site and project, authorized at `need`; 404 for anyone without access. */
+ async function load(viewer, id, need) {
+ const deploy = await get(id);
+ const notFound = new ApiError(404, 'deploy.not_found', 'no such deploy');
+ if (!deploy || deploy.state === 'deleted') throw notFound;
+ const site = await sites.get(deploy.site_id);
+ if (!site || site.status !== 'active') throw notFound;
+ const project = await projects.get(deploy.project_id);
+ await 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). */
+ async function limitsFor(project) {
+ const quota = await 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? */
+ async function precheck(viewer, siteId) {
+ const { site, project } = await sites.load(viewer, siteId, 'deploy');
+ await takedowns.assertOpen(site);
+ const minFree = config ? config.uploads.minFreeBytes : 0;
+ if (minFree > 0) {
+ let free = Infinity;
+ try { free = blobs.freeBytes(); } catch (err) { log.warn('[Space] could not read free disk space:', err.message); }
+ if (free < minFree) {
+ log.warn(`[Space] uploads refused: ${free} bytes free, SPACE_MIN_FREE_BYTES is ${minFree}`);
+ throw new ApiError(507, 'storage.space_full', 'Space is short of disk space and is not accepting deploys right now; try again later');
+ }
+ }
+ const limits = await limitsFor(project);
+ const recent = (await 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 };
+ }
+
+ async function writeLog(deployId, lines, now) {
+ for (const l of lines) await q.logLine.run(deployId, l.level, String(l.message).slice(0, 2000), now);
+ }
+
+ /** A refused upload becomes a failed deploy (with its log) and space.deploy.failed. */
+ async 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` },
+ ];
+ await store.tx(async () => {
+ await 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 });
+ await writeLog(id, lines, now);
+ await outbox.emit({
+ event_type: 'space.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: await get(id), log: await 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.
+ */
+ async function create(viewer, ctx, entries, { source, activate = false, preview = false, notes = [], traceparent, git = null } = {}) {
+ const { site, project, limits } = ctx;
+ // A preview is never activated: it is the site's live preview (its pointer), not its active
+ // deploy, and it is stored with source 'preview' so the two can never be confused. A git
+ // deploy is always a preview and keeps source 'git'; its provenance row says what it is.
+ const isGit = source === 'git';
+ if (isGit && (!git || preview !== true)) throw new Error('a git deploy is created by ingestGit, as a preview');
+ const isPreview = preview === true;
+ const storedSource = isPreview && !isGit ? 'preview' : source;
+ if (isPreview) activate = false;
+ const fail = async (status, code, problems, extraNotes = []) => {
+ const r = await recordFailure(viewer, ctx, { source: storedSource, code, problems, notes: [...notes, ...extraNotes], 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) await 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 (!await 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 = (await q.storageUsed.get(project.id)).n;
+ if (used + newBytes > limits.quota.storageBytes) {
+ await 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. With the
+ // Media store this is where a deploy waits for Media to acknowledge every object: a refusal
+ // fails the deploy, recorded with its log, instead of publishing a local-only success.
+ const stored = [];
+ let current = null;
+ try {
+ for (const f of fresh.values()) {
+ current = f.sha256;
+ await blobs.put(project.id, f.sha256, f.data);
+ stored.push(f.sha256);
+ }
+ } catch (err) {
+ // Name every object this failed deploy left behind so an orphan is traceable: the ones
+ // Media already acknowledged, plus the one being written when it refused.
+ const left = [...stored, ...(current && !stored.includes(current) ? [current] : [])];
+ log.error('[Space] object store refused a deploy:', project.id, err.code || err.message,
+ `objects left by the failed deploy: ${left.length ? left.join(' ') : 'none'}`);
+ await fail(502, 'storage.object_store', [{ code: 'storage.object_store', message: 'the object store could not store this deploy; nothing was published' }],
+ [`object store: objects left by the failed deploy, which no deploy row references (a sweep or the next deploy reclaims them): ${left.join(' ') || 'none'}`]);
+ }
+
+ 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: isGit ? 'built outside Space by the project\'s CI; Space stored the files as they are and executed nothing' : 'no build step: Space serves the uploaded files as they are; nothing was executed' },
+ ];
+ let activated = null;
+ const previewExpiresAt = isPreview ? now + PREVIEW_TTL_MS : null;
+ await store.tx(async () => {
+ await q.insert.run({ id, project_id: project.id, site_id: site.id, state: 'ready', source: storedSource, manifest: built.manifestJson, manifest_sha256: built.manifestSha256, file_count: built.manifest.file_count, total_bytes: built.manifest.total_bytes, new_bytes: newBytes, failure_code: null, created_by: who, created_at: now });
+ for (const f of built.files) await q.insertFile.run(id, f.path, f.sha256, f.size, f.content_type);
+ for (const f of fresh.values()) await q.insertBlob.run(project.id, f.sha256, f.size, now);
+ if (isGit) await q.insertGit.run(id, git.provider, git.repo_url, git.ref, git.commit_sha, now);
+ await outbox.emit({
+ event_type: 'space.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: storedSource, file_count: built.manifest.file_count, total_bytes: built.manifest.total_bytes, manifest_sha256: built.manifestSha256 },
+ }, { traceparent });
+ if (isPreview) {
+ await q.setPreview.run(id, previewExpiresAt, site.id);
+ lines.push({ level: 'info', message: `preview: served only at /preview/${id}/ to a project member until it expires; the public site still serves its active deploy` });
+ } else if (activate) {
+ activated = await switchPointer(viewer, site, await get(id), 'activate', { traceparent });
+ lines.push({ level: 'info', message: 'activated: the site now serves this deploy' });
+ } else {
+ lines.push({ level: 'info', message: 'ready; not active until you activate it' });
+ }
+ await writeLog(id, lines, now);
+ });
+ outbox.kick();
+ if (activated && activated.changed) announceSite(site, project);
+ return { deploy: await get(id), log: await q.logFor.all(id), activated, preview: isPreview ? { deploy_id: id, expires_at: previewExpiresAt } : null };
+ }
+
+ /**
+ * May the site take a git deploy of `ref` at `commit_sha`? -> the provenance to record.
+ * 409 source.not_connected, 409 source.ref_mismatch, 422 source.commit_sha / source.ref. Either
+ * value may be left out (undefined) to check only the connection, before the body is read.
+ */
+ async function gitTarget(site, { ref, commit_sha: sha } = {}) {
+ const src = await q.sourceOf.get(site.id);
+ if (!src) throw new ApiError(409, 'source.not_connected', 'this site has no Git source: connect a repository and branch first (PUT /api/v1/sites/:id/source)');
+ if (ref !== undefined) {
+ if (typeof ref !== 'string' || !ref) throw new ApiError(422, 'source.ref', 'ref is required: the branch this commit was built from');
+ if (ref !== src.ref) throw new ApiError(409, 'source.ref_mismatch', `this site deploys from ${src.ref}, not ${ref.slice(0, 200)}`);
+ }
+ if (sha !== undefined && (typeof sha !== 'string' || !COMMIT_SHA_RE.test(sha))) {
+ throw new ApiError(422, 'source.commit_sha', 'commit_sha must be the full commit id: 40 or 64 lowercase hex characters');
+ }
+ return { provider: src.provider, repo_url: src.repo_url, ref: src.ref, commit_sha: sha };
+ }
+
+ /**
+ * A git deploy: the build output the project's CI posts for the connected ref at commit_sha.
+ * ctx is precheck()'s result for siteId. It lands as the site's preview and never activates;
+ * a refused file is a failed deploy (recordFailure, through create) and space.deploy.failed.
+ */
+ async function ingestGit(viewer, ctx, siteId, entries, { ref, commit_sha, notes = [], traceparent } = {}) {
+ if (!ctx || !ctx.site || ctx.site.id !== siteId) throw new ApiError(404, 'site.not_found', 'no such site');
+ const git = await gitTarget(ctx.site, { ref: ref == null ? '' : ref, commit_sha: commit_sha == null ? '' : commit_sha });
+ const why = [...notes, `git: ${git.repo_url} ${git.ref} at ${git.commit_sha}`];
+ return await create(viewer, ctx, entries, { source: 'git', preview: true, activate: false, notes: why, traceparent, git });
+ }
+
+ /** Inside a transaction. Compare-and-set on the pointer read in the same transaction. */
+ async function switchPointer(viewer, site, deploy, kind, { expectedActive, traceparent } = {}) {
+ const current = await db.prepare('SELECT active_deploy_id, status FROM space_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 = await q.setPointer.run(deploy.id, now, site.id, current.active_deploy_id);
+ if (r.changes !== 1) throw new ApiError(409, 'site.active_changed', 'the active deploy changed during the switch');
+ await q.activation.run(site.id, deploy.id, current.active_deploy_id, kind, principalOf(viewer) || 'space', now);
+ // A real deploy or rollback supersedes any pending preview: it stops being served at once.
+ await q.clearPreview.run(site.id);
+ await outbox.emit({
+ event_type: 'space.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 };
+ }
+
+ async function activate(viewer, id, { expectedActive, traceparent } = {}) {
+ const { deploy, site, project } = await load(viewer, id, 'deploy');
+ await takedowns.assertOpen(site);
+ const out = await store.tx(async () => await switchPointer(viewer, site, deploy, 'activate', { expectedActive, traceparent }));
+ outbox.kick();
+ if (out.changed) announceSite(site, project);
+ return out;
+ }
+
+ /** To `deployId`, or else to the most recent previously active deploy that still exists. */
+ async function rollback(viewer, siteId, { deployId, expectedActive, traceparent } = {}) {
+ const { site, project } = await sites.load(viewer, siteId, 'deploy');
+ await takedowns.assertOpen(site);
+ const out = await store.tx(async () => {
+ const current = (await db.prepare('SELECT active_deploy_id FROM space_sites WHERE id = ?').get(site.id)).active_deploy_id;
+ let target = null;
+ if (deployId) {
+ target = await 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 await q.activations.all(site.id, 200)) {
+ for (const cand of [a.previous_deploy_id, a.deploy_id]) {
+ if (!cand || cand === current) continue;
+ const d = await 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 await switchPointer(viewer, site, target, 'rollback', { expectedActive, traceparent });
+ });
+ outbox.kick();
+ if (out.changed) announceSite(site, project);
+ return out;
+ }
+
+ /** Inside a transaction: drop blob rows no deploy of the project references. -> freed shas */
+ async function collectGarbage(projectId) {
+ const freed = (await q.orphans.all(projectId)).map((r) => r.sha256);
+ for (const sha of freed) await q.dropBlob.run(projectId, sha);
+ return freed;
+ }
+
+ async function unlinkBlobs(projectId, shas) {
+ for (const sha of shas) {
+ try { await blobs.remove(projectId, sha); } catch (err) { log.warn('[Space] could not remove object:', err.message); }
+ }
+ }
+
+ async function remove(viewer, id) {
+ const { deploy, site, project } = await load(viewer, id, 'maintain');
+ await takedowns.assertDeletable(viewer, { site });
+ let freed = [];
+ await store.tx(async () => {
+ const active = (await db.prepare('SELECT active_deploy_id FROM space_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');
+ await q.markDeleted.run(store.now(), deploy.id);
+ await q.dropFiles.run(deploy.id);
+ // A deleted preview must stop being served even if it was the site's live one.
+ await q.clearPreviewIf.run(site.id, deploy.id);
+ freed = await collectGarbage(project.id);
+ });
+ await unlinkBlobs(project.id, freed);
+ return { deleted: true, objects_removed: freed.length };
+ }
+
+ return {
+ get, load, precheck, create, ingestGit, gitTarget, recordFailure, activate, rollback, remove, collectGarbage, unlinkBlobs, limitsFor,
+ list: async (siteId, limit = 100) => await q.forSite.all(siteId, Math.min(Math.max(limit, 1), 200)),
+ logOf: async (id) => await q.logFor.all(id),
+ filesOf: async (id) => await q.files.all(id),
+ activationsOf: async (siteId, limit = 50) => await q.activations.all(siteId, limit),
+ };
+}
+
+module.exports = { createDeploys, PREVIEW_TTL_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,225 @@
@@ -0,0 +1,225 @@
+'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-space.<hostname> TXT "openvibe-space-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
+ * SPACE_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 SPACE_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-space';
+const TXT_PREFIX = 'openvibe-space-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, indexnow = null }) {
+ const { db } = store;
+ // IndexNow: a verified custom domain starts serving the site; tell the engines its page is up.
+ const announce = typeof indexnow === 'function' ? indexnow : () => {};
+ const dnsResolver = resolver || defaultResolver(config.domains.dnsServers);
+ const reserved = firstPartyDomains(config);
+ const q = {
+ byId: db.prepare('SELECT * FROM space_domains WHERE id = ?'),
+ forSite: db.prepare("SELECT * FROM space_domains WHERE site_id = ? ORDER BY kind DESC, created_at, seq"),
+ verifiedByHost: db.prepare("SELECT * FROM space_domains WHERE hostname = ? AND status = 'verified'"),
+ onSite: db.prepare('SELECT * FROM space_domains WHERE site_id = ? AND hostname = ?'),
+ insert: db.prepare(`INSERT INTO space_domains (id, project_id, site_id, hostname, kind, status, token, created_by, created_at)
+ VALUES (?, ?, ?, ?, 'custom', 'pending', ?, ?, ?)`),
+ checked: db.prepare('UPDATE space_domains SET last_checked_at = ?, last_error = ?, check_count = check_count + 1 WHERE id = ?'),
+ verified: db.prepare("UPDATE space_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 space_domains SET status = ?, last_error = ? WHERE id = ?'),
+ missing: db.prepare('UPDATE space_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 space_domains SET record_missing_since = NULL, last_checked_at = ?, last_error = NULL, check_count = check_count + 1 WHERE id = ?'),
+ remove: db.prepare('DELETE FROM space_domains WHERE id = ?'),
+ duePending: db.prepare("SELECT * FROM space_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 space_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}`;
+
+ async function load(viewer, id, need) {
+ const domain = isId('domain', id) ? await q.byId.get(id) : null;
+ const notFound = new ApiError(404, 'domain.not_found', 'no such domain');
+ if (!domain) throw notFound;
+ const site = await sites.get(domain.site_id);
+ if (!site || site.status !== 'active') throw notFound;
+ await access.authorize(await 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: Space re-checks it daily' },
+ routing,
+ tls: 'after verification an operator issues the certificate (Let\'s Encrypt); HTTPS starts working once it is installed',
+ };
+ }
+
+ async function add(viewer, siteId, input = {}) {
+ const { site, project } = await 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 = await projects.quotaOf(project);
+ if ((await projects.usageOf(project)).customDomains >= quota.customDomains) throw new ApiError(429, 'quota.custom_domains', `this project may have ${quota.customDomains} custom domains`);
+ if (await q.onSite.get(site.id, hostname)) throw new ApiError(409, 'domain.exists', `${hostname} is already on this site`);
+ if (await 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');
+ await q.insert.run(id, project.id, site.id, hostname, token, principalOf(viewer), store.now());
+ const domain = await 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) { await q.checked.run(store.now(), r.error, domain.id); return await q.byId.get(domain.id); }
+ if (!r.values.includes(expected)) {
+ await 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 await q.byId.get(domain.id);
+ }
+ try {
+ await store.tx(async () => {
+ if (await q.verifiedByHost.get(domain.hostname)) throw new ApiError(409, 'domain.taken', `${domain.hostname} was verified for another site first`);
+ const site = await sites.get(domain.site_id);
+ if (!site || site.status !== 'active') throw new ApiError(404, 'domain.not_found', 'no such domain');
+ if ((await q.verified.run(store.now(), store.now(), domain.id)).changes !== 1) return;
+ await outbox.emit({
+ event_type: 'space.domain.verified', actor: viewer ? actorRef(viewer) : { type: 'service', id: 'space' }, 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') { await q.setStatus.run('failed', err.message, domain.id); return await q.byId.get(domain.id); }
+ throw err;
+ }
+ outbox.kick();
+ announce(domain.hostname, ['/', '/sitemap.xml']);
+ return await q.byId.get(domain.id);
+ }
+
+ async function verify(viewer, id, opts = {}) {
+ const { domain } = await load(viewer, id, 'maintain');
+ return await verifyDomain(domain, viewer, opts);
+ }
+
+ async function remove(viewer, id) {
+ const { domain } = await load(viewer, id, 'maintain');
+ if (domain.kind === 'default') throw new ApiError(409, 'domain.default', 'the default domain goes away only with the site');
+ await 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 await q.duePending.all(now - config.domains.recheckIntervalMs + 1000, limit)) {
+ if (now - d.created_at > config.domains.pendingDays * DAY) {
+ await 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 await q.dueVerified.all(now - DAY, limit)) {
+ const r = await lookup(d.hostname);
+ summary.checked++;
+ if (!r.ok) { await q.checked.run(store.now(), r.error, d.id); continue; }
+ if (r.values.includes(`${TXT_PREFIX}${d.token}`)) { await q.present.run(store.now(), d.id); continue; }
+ await q.missing.run(store.now(), store.now(), `the TXT record at ${challengeName(d.hostname)} is gone`, d.id);
+ const row = await q.byId.get(d.id);
+ if (row.record_missing_since && store.now() - row.record_missing_since > config.domains.lapseDays * DAY) {
+ await 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(`[Space] custom domain ${d.hostname} lapsed (TXT record gone)`);
+ }
+ }
+ return summary;
+ }
+
+ return {
+ load, add, verify, verifyDomain, remove, recheck, instructions, checkHostname,
+ listForSite: async (siteId) => await q.forSite.all(siteId),
+ get: async (id) => (isId('domain', id) ? await q.byId.get(id) || null : null),
+ verifiedCustom: async () => await db.prepare("SELECT hostname, site_id FROM space_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,174 @@
@@ -0,0 +1,174 @@
+'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, takedowns, log = console, indexnow = null }) {
+ const { db } = store;
+ const announce = typeof indexnow === 'function' ? indexnow : () => {};
+ const q = {
+ byId: db.prepare('SELECT * FROM space_projects WHERE id = ?'),
+ forPrincipal: db.prepare(`SELECT p.*, m.role FROM space_projects p JOIN space_project_members m ON m.project_id = p.id
+ WHERE m.principal = ? AND p.status = 'active' ORDER BY p.created_at DESC, p.seq DESC LIMIT 200`),
+ all: db.prepare("SELECT p.*, NULL AS role FROM space_projects p WHERE p.status = 'active' ORDER BY p.created_at DESC, p.seq DESC LIMIT 200"),
+ ownedCount: db.prepare("SELECT COUNT(*) AS n FROM space_projects WHERE owner_subject = ? AND status = 'active'"),
+ insert: db.prepare(`INSERT INTO space_projects (id, network_project_id, owner_subject, name, environment, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`),
+ addMember: db.prepare(`INSERT INTO space_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 space_project_members WHERE project_id = ? AND principal = ?'),
+ members: db.prepare('SELECT principal, role, added_by, created_at FROM space_project_members WHERE project_id = ? ORDER BY created_at'),
+ quotaRow: db.prepare('SELECT * FROM space_quotas WHERE project_id = ?'),
+ storageUsed: db.prepare('SELECT COALESCE(SUM(size), 0)::bigint AS n, COUNT(*) AS c FROM space_blobs WHERE project_id = ?'),
+ deploysSince: db.prepare('SELECT COUNT(*) AS n FROM space_deploys WHERE project_id = ? AND created_at > ?'),
+ siteCount: db.prepare("SELECT COUNT(*) AS n FROM space_sites WHERE project_id = ? AND status = 'active'"),
+ customDomainCount: db.prepare("SELECT COUNT(*) AS n FROM space_domains WHERE project_id = ? AND kind = 'custom' AND status IN ('pending','verified')"),
+ markDeleted: db.prepare("UPDATE space_projects SET status = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?"),
+ };
+
+ async function get(id) {
+ return isId('project', id) ? await q.byId.get(id) || null : null;
+ }
+
+ /** The effective quota: the project's override where set, else the environment's default. */
+ async function quotaOf(project) {
+ const defaults = config.quotas[project.environment] || config.quotas.production;
+ const row = await 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;
+ }
+
+ async function usageOf(project) {
+ const s = await q.storageUsed.get(project.id);
+ return {
+ storageBytes: s.n,
+ objects: s.c,
+ deploysLast24h: (await q.deploysSince.get(project.id, store.now() - DAY)).n,
+ sites: (await q.siteCount.get(project.id)).n,
+ customDomains: (await q.customDomainCount.get(project.id)).n,
+ };
+ }
+
+ async function create(viewer, input = {}) {
+ if (!config.publicHosting && !viewer.staff) {
+ throw new ApiError(403, 'space.not_launched', 'OpenVibe.Space is not open for hosting yet: it opens after a launch review, and only OpenVibe staff can create projects until then');
+ }
+ 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');
+ }
+ const maxOwned = (config.projects && config.projects.maxPerOwner) || 10;
+ if ((await q.ownedCount.get(owner)).n >= maxOwned) throw new ApiError(429, 'quota.projects', `you already own ${maxOwned} projects`);
+ const id = newId('project', store.now());
+ const now = store.now();
+ try {
+ await store.tx(async () => {
+ await q.insert.run(id, networkProjectId, owner, name, environment, now, now);
+ await q.addMember.run(id, owner, 'owner', owner, now);
+ });
+ } catch (err) {
+ if (err.code === '23505') throw new ApiError(409, 'project.network_project_taken', 'that Network project already has a Space project');
+ throw err;
+ }
+ return await get(id);
+ }
+
+ async function listFor(viewer) {
+ if (viewer.kind === 'user' && viewer.staff && viewer.allProjects) return await q.all.all();
+ const p = principalOf(viewer);
+ return p ? await q.forPrincipal.all(p) : [];
+ }
+
+ async function members(project) {
+ return await q.members.all(project.id);
+ }
+
+ async function setMember(viewer, project, principal, role) {
+ await 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');
+ await q.addMember.run(project.id, principal, role, principalOf(viewer), store.now());
+ return await members(project);
+ }
+
+ async function removeMember(viewer, project, principal) {
+ await access.authorize(project, viewer, 'own');
+ if (principal === project.owner_subject) throw new ApiError(409, 'member.owner_fixed', 'the project owner cannot be removed');
+ await q.removeMember.run(project.id, principal);
+ return await members(project);
+ }
+
+ async function setQuota(viewer, project, input = {}) {
+ await access.authorize(project, viewer, 'staff');
+ const row = await 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;
+ }
+ await db.prepare(`INSERT INTO space_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 await quotaOf(project);
+ }
+
+ /** Owner (or staff) deletes the project: every site stops serving at once; objects are removed. */
+ async function remove(viewer, project, { sites }) {
+ await access.authorize(project, viewer, 'own');
+ await takedowns.assertDeletable(viewer, { projectId: project.id });
+ const now = store.now();
+ const doomed = await sites.listForProject(project.id);
+ await store.tx(async () => {
+ for (const s of doomed) await sites.markDeleted(s, now);
+ await db.prepare("UPDATE space_deploys SET state = 'deleted', deleted_at = ? WHERE project_id = ? AND state <> 'deleted'").run(now, project.id);
+ await db.prepare("DELETE FROM space_deploy_files WHERE deploy_id IN (SELECT id FROM space_deploys WHERE project_id = ?)").run(project.id);
+ await db.prepare('DELETE FROM space_blobs WHERE project_id = ?').run(project.id);
+ await db.prepare('DELETE FROM space_domains WHERE project_id = ?').run(project.id);
+ await q.markDeleted.run(now, now, project.id);
+ });
+ try { await blobs.removeProject(project.id); } catch (err) { log.warn('[Space] could not remove project objects:', err.message); }
+ // IndexNow: every site of the project left the index (never a sandbox project, which is noindex).
+ if (project.environment !== 'sandbox') for (const s of doomed) announce(`${s.name}.${config.sitesDomain}`, ['/', '/sitemap.xml']);
+ 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,182 @@
@@ -0,0 +1,182 @@
+'use strict';
+/**
+ * Per-site serving configuration (plan T12 J3, decision D4): the response headers, the redirects and
+ * the SPA fallback of ONE site, kept in PostgreSQL and applied by server/http/tenant.js BEFORE the
+ * platform's own headers. The platform's security, routing and caching headers (CSP, HSTS,
+ * Set-Cookie, X-Forwarded-*, Cache-Control, …) are reserved and can never be set through it; a
+ * redirect target must be a local path, so a site can never become an open redirect. A site with no
+ * row serves exactly as before.
+ */
+const { ApiError } = require('../http/errors');
+const { principalOf } = require('./access');
+
+const MAX_HEADERS = 50;
+const MAX_HEADER_VALUE = 1024;
+const MAX_REDIRECTS = 200;
+const MAX_LOCATION = 1024;
+const HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;
+const REDIRECT_STATUS = new Set([301, 302, 307, 308]);
+
+// Headers the platform owns: a site's config can never set one. `X-Forwarded-*` is refused as a
+// prefix, so a site can never lie about the client to an upstream reading those headers.
+const RESERVED_HEADERS = new Set([
+ 'content-security-policy', 'content-security-policy-report-only', 'strict-transport-security',
+ 'set-cookie', 'set-cookie2', 'clear-site-data',
+ 'content-length', 'transfer-encoding', 'connection', 'keep-alive', 'trailer', 'upgrade', 'te', 'host', 'location',
+ 'content-type', 'content-disposition', 'content-encoding', 'content-range', 'accept-ranges',
+ 'cache-control', 'cdn-cache-control', 'etag', 'expires', 'last-modified', 'vary', 'age',
+ 'x-content-type-options', 'x-served-by', 'x-robots-tag', 'referrer-policy',
+ 'cross-origin-opener-policy', 'cross-origin-embedder-policy', 'cross-origin-resource-policy', 'permissions-policy',
+ // Scope and transport are the platform's too: a wider service-worker scope, or another host:port for this origin.
+ 'service-worker-allowed', 'alt-svc',
+]);
+const RESERVED_PREFIXES = ['x-forwarded-'];
+
+/** Why `name` cannot be configured, or null when it can. */
+function reservedReason(name) {
+ const lower = String(name).toLowerCase();
+ if (RESERVED_HEADERS.has(lower)) return `"${name}" is set by the platform and cannot be configured`;
+ if (RESERVED_PREFIXES.some((p) => lower.startsWith(p))) return `"${name}" is reserved (X-Forwarded-*) and cannot be configured`;
+ return null;
+}
+function isReservedHeader(name) { return reservedReason(name) !== null; }
+
+const fail = (code, message) => { throw new ApiError(422, code, message); };
+
+function cleanHeaderValue(name, value) {
+ const v = value == null ? '' : String(value);
+ if (v.length > MAX_HEADER_VALUE) fail('site_config.header_value', `the value of "${name}" is longer than ${MAX_HEADER_VALUE} characters`);
+ // eslint-disable-next-line no-control-regex
+ if (/[\u0000-\u001f\u007f]/.test(v)) fail('site_config.header_value', `the value of "${name}" contains a control character: a header value must be one line`);
+ return v;
+}
+
+/** name → value object of configurable headers, or throws ApiError(422). */
+function cleanHeaders(input) {
+ if (input == null) return {};
+ if (typeof input !== 'object' || Array.isArray(input)) fail('site_config.headers', 'headers must be an object of name → value');
+ const names = Object.keys(input);
+ if (names.length > MAX_HEADERS) fail('site_config.too_many', `a site may set at most ${MAX_HEADERS} headers`);
+ const out = {};
+ for (const name of names) {
+ if (!HEADER_NAME_RE.test(name)) fail('site_config.header_name', `"${name}" is not a valid header name`);
+ const reason = reservedReason(name);
+ if (reason) fail('site_config.reserved_header', reason);
+ out[name] = cleanHeaderValue(name, input[name]);
+ }
+ return out;
+}
+
+/** A redirect source or target must be a local path: no scheme, no host, no backslash (which browsers read as "/"). */
+function cleanLocalPath(value, field) {
+ const s = value == null ? '' : String(value).trim();
+ if (!s || s.length > MAX_LOCATION) fail('site_config.redirect', `${field} must be a path of 1–${MAX_LOCATION} characters`);
+ // eslint-disable-next-line no-control-regex
+ if (/[\u0000-\u001f\u007f]/.test(s)) fail('site_config.redirect', `${field} contains a control character`);
+ if (s.includes('\\')) fail('site_config.redirect', `${field} contains a backslash`);
+ if (s[0] !== '/') fail('site_config.redirect', `${field} must be a local path starting with "/"`);
+ if (s.startsWith('//')) fail('site_config.redirect', `${field} must be a local path, not a protocol-relative URL`);
+ let decoded;
+ try { decoded = decodeURIComponent(s); } catch { fail('site_config.redirect', `${field} is not valid percent-encoding`); }
+ // eslint-disable-next-line no-control-regex
+ if (decoded.includes('\\') || /[\u0000-\u001f\u007f]/.test(decoded) || /(^|\/)\/\//.test(decoded)) fail('site_config.redirect', `${field} must be a local path (it decoded to a scheme, a host or a control character)`);
+ return s;
+}
+
+/** [{ from, to, status }] of local-only redirects, or throws ApiError(422). */
+function cleanRedirects(input) {
+ if (input == null) return [];
+ if (!Array.isArray(input)) fail('site_config.redirects', 'redirects must be an array');
+ if (input.length > MAX_REDIRECTS) fail('site_config.too_many', `a site may have at most ${MAX_REDIRECTS} redirects`);
+ const seen = new Set();
+ const out = [];
+ for (const [i, r] of input.entries()) {
+ if (!r || typeof r !== 'object' || Array.isArray(r)) fail('site_config.redirect', `redirect ${i + 1} must be an object with "from" and "to"`);
+ const from = cleanLocalPath(r.from, `redirect ${i + 1} "from"`);
+ const to = cleanLocalPath(r.to, `redirect ${i + 1} "to"`);
+ if (seen.has(from)) fail('site_config.redirect', `two redirects have the same "from" path (${from})`);
+ seen.add(from);
+ const status = r.status == null ? 301 : Number(r.status);
+ if (!REDIRECT_STATUS.has(status)) fail('site_config.redirect', `redirect ${i + 1} status must be one of 301, 302, 307, 308`);
+ out.push({ from, to, status });
+ }
+ return out;
+}
+
+const cleanSpa = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v == null ? '' : v).toLowerCase());
+
+/** The whole config, cleaned. */
+function normalize(input = {}) {
+ return { headers: cleanHeaders(input.headers), redirects: cleanRedirects(input.redirects), spa: cleanSpa(input.spa) };
+}
+
+function defaults() { return { headers: {}, redirects: [], spa: false }; }
+
+// ── Dashboard text form (one "Name: value" per line; one "from -> to [status]" per line) ──
+function headersToText(headers) {
+ return Object.entries(headers || {}).map(([k, v]) => `${k}: ${v}`).join('\n');
+}
+function parseHeadersText(text) {
+ const obj = {};
+ for (const raw of String(text || '').split('\n')) {
+ const line = raw.trim();
+ if (!line) continue;
+ const i = line.indexOf(':');
+ if (i <= 0) fail('site_config.header_syntax', `each header line must be "Name: value" (got "${line.slice(0, 60)}")`);
+ const name = line.slice(0, i).trim();
+ if (name in obj) fail('site_config.header_syntax', `"${name}" is listed twice`);
+ obj[name] = line.slice(i + 1).trim();
+ }
+ return cleanHeaders(obj);
+}
+function redirectsToText(redirects) {
+ return (redirects || []).map((r) => `${r.from} -> ${r.to}${r.status && r.status !== 301 ? ` ${r.status}` : ''}`).join('\n');
+}
+function parseRedirectsText(text) {
+ const list = [];
+ for (const raw of String(text || '').split('\n')) {
+ const line = raw.trim();
+ if (!line) continue;
+ const m = /^(\S+)\s*->\s*(\S+)(?:\s+(\d{3}))?$/.exec(line);
+ if (!m) fail('site_config.redirect_syntax', `each redirect line must be "from -> to [status]" (got "${line.slice(0, 60)}")`);
+ list.push({ from: m[1], to: m[2], status: m[3] ? Number(m[3]) : 301 });
+ }
+ return cleanRedirects(list);
+}
+
+function createSiteConfig({ store }) {
+ const { db } = store;
+ const q = {
+ bySite: db.prepare('SELECT * FROM space_site_config WHERE site_id = ?'),
+ upsert: db.prepare(`INSERT INTO space_site_config (site_id, headers, redirects, spa, updated_by, updated_at)
+ VALUES (?, ?::jsonb, ?::jsonb, ?, ?, ?)
+ ON CONFLICT (site_id) DO UPDATE SET headers = EXCLUDED.headers, redirects = EXCLUDED.redirects,
+ spa = EXCLUDED.spa, updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at`),
+ remove: db.prepare('DELETE FROM space_site_config WHERE site_id = ?'),
+ };
+
+ const rowToConfig = (row) => (row
+ ? { headers: row.headers && typeof row.headers === 'object' && !Array.isArray(row.headers) ? row.headers : {}, redirects: Array.isArray(row.redirects) ? row.redirects : [], spa: Boolean(row.spa) }
+ : defaults());
+
+ async function getForSite(siteId) { return rowToConfig(await q.bySite.get(siteId)); }
+
+ async function set(viewer, site, input) {
+ const cfg = normalize(input);
+ await q.upsert.run(site.id, JSON.stringify(cfg.headers), JSON.stringify(cfg.redirects), cfg.spa, principalOf(viewer), store.now());
+ return await getForSite(site.id);
+ }
+
+ async function remove(site) { await q.remove.run(site.id); return defaults(); }
+
+ return {
+ getForSite, set, remove, defaults, normalize, cleanHeaders, cleanRedirects,
+ headersToText, parseHeadersText, redirectsToText, parseRedirectsText,
+ };
+}
+
+module.exports = {
+ createSiteConfig, normalize, defaults, isReservedHeader, cleanHeaders, cleanRedirects,
+ parseHeadersText, parseRedirectsText, headersToText, redirectsToText,
+ RESERVED_HEADERS, RESERVED_PREFIXES, REDIRECT_STATUS, MAX_HEADERS, MAX_REDIRECTS,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * A site's Git source (plan T12 Stage B, git deploys Phase 1): the repository and branch the project's
+ * own CI builds the site from. Host never clones, fetches or builds it and holds no credential for
+ * it: the row is public provenance that a git deploy (deploys.ingestGit) must match. The CI token
+ * that posts the build output is the trust boundary.
+ *
+ * repo_url https on the provider's own host (github.com, gitlab.com, codeberg.org), path
+ * owner/repo with an optional .git; never userinfo, a port, a query or a fragment.
+ * Stored canonically as https://<host>/<owner>/<repo>.
+ * ref one branch name, by git check-ref-format's rules, at most 200 characters.
+ *
+ * Read at `read`, written and removed at `maintain`, through sites.load (404 to anyone else).
+ */
+const { ApiError } = require('../http/errors');
+const { principalOf } = require('./access');
+
+const PROVIDERS = Object.freeze({ 'github.com': 'github', 'gitlab.com': 'gitlab', 'codeberg.org': 'codeberg' });
+const PROVIDER_NAMES = new Set(Object.values(PROVIDERS));
+const FIELDS = new Set(['provider', 'repo_url', 'ref']);
+const MAX_URL = 300;
+const MAX_REF = 200;
+// Only these characters, so no userinfo (@), port (:), query (?), fragment (#), percent-encoding or space can hide in a URL.
+const URL_RE = /^https:\/\/([A-Za-z0-9.-]+)\/([A-Za-z0-9][A-Za-z0-9_.-]{0,99})\/([A-Za-z0-9][A-Za-z0-9_.-]{0,99})$/;
+const REF_CHARS_RE = /^[A-Za-z0-9._/+=,#-]+$/;
+
+const fail = (code, message) => { throw new ApiError(422, code, message); };
+
+/** -> { provider, repo_url } (canonical), or throws ApiError(422). */
+function cleanRepoUrl(value, provider) {
+ const s = typeof value === 'string' ? value.trim() : '';
+ if (!s || s.length > MAX_URL) fail('source.repo_url', `repo_url must be an https URL of at most ${MAX_URL} characters`);
+ const m = URL_RE.exec(s);
+ if (!m) fail('source.repo_url', 'repo_url must be https://<provider host>/<owner>/<repo>[.git], with no user, port, query or fragment');
+ const host = m[1].toLowerCase();
+ const repo = m[3].endsWith('.git') ? m[3].slice(0, -4) : m[3];
+ if (!PROVIDERS[host]) fail('source.repo_url', `repo_url must be on ${Object.keys(PROVIDERS).join(', ')}`);
+ if (provider != null && provider !== '') {
+ if (!PROVIDER_NAMES.has(provider)) fail('source.provider', `provider must be one of ${[...PROVIDER_NAMES].join(', ')}`);
+ if (provider !== PROVIDERS[host]) fail('source.provider', `a ${provider} repository is not on ${host}`);
+ }
+ return { provider: PROVIDERS[host], repo_url: `https://${host}/${m[2]}/${repo}` };
+}
+
+/** A branch name by git check-ref-format's rules (and a narrower character set), or throws ApiError(422). */
+function cleanRef(value) {
+ const s = typeof value === 'string' ? value.trim() : '';
+ const bad = (why) => fail('source.ref', `ref must be a branch name: ${why}`);
+ if (!s || s.length > MAX_REF) bad(`1–${MAX_REF} characters`);
+ if (!REF_CHARS_RE.test(s)) bad('letters, digits and . _ / + = , # - only');
+ if (s.startsWith('-')) bad('it may not start with "-"');
+ if (s.includes('..')) bad('it may not contain ".."');
+ if (s.startsWith('/') || s.endsWith('/') || s.includes('//')) bad('no leading, trailing or doubled "/"');
+ if (s.endsWith('.')) bad('it may not end with "."');
+ if (s === 'HEAD' || s.startsWith('refs/')) bad('name the branch itself, not HEAD or refs/…');
+ for (const part of s.split('/')) {
+ if (part.startsWith('.') || part.endsWith('.lock')) bad('no part may start with "." or end with ".lock"');
+ }
+ return s;
+}
+
+/** Validates a PUT body -> { provider, repo_url, ref }, or throws ApiError(422). */
+function normalize(input) {
+ if (!input || typeof input !== 'object' || Array.isArray(input)) fail('source.invalid', 'the body must be { provider, repo_url, ref }');
+ const extra = Object.keys(input).filter((k) => !FIELDS.has(k));
+ // Refused, not ignored: a token or key sent here must never look as if Host kept it.
+ if (extra.length) fail('source.unknown_field', `unknown field ${extra[0]}: a source is { provider, repo_url, ref } and holds no credential`);
+ return { ...cleanRepoUrl(input.repo_url, input.provider), ref: cleanRef(input.ref) };
+}
+
+function createSiteSources({ store, sites }) {
+ const { db } = store;
+ const q = {
+ bySite: db.prepare('SELECT * FROM space_site_sources WHERE site_id = ?'),
+ upsert: db.prepare(`INSERT INTO space_site_sources (site_id, provider, repo_url, ref, created_by, created_at, updated_by, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (site_id) DO UPDATE SET provider = EXCLUDED.provider, repo_url = EXCLUDED.repo_url, ref = EXCLUDED.ref,
+ updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at`),
+ remove: db.prepare('DELETE FROM space_site_sources WHERE site_id = ?'),
+ };
+
+ /** The site's source row, or null (no authorization: callers have loaded the site). */
+ async function forSite(siteId) { return await q.bySite.get(siteId) || null; }
+
+ async function get(viewer, siteId) {
+ const { site } = await sites.load(viewer, siteId, 'read');
+ return { site, source: await forSite(site.id) };
+ }
+
+ async function put(viewer, siteId, input) {
+ const { site } = await sites.load(viewer, siteId, 'maintain');
+ const src = normalize(input);
+ const who = principalOf(viewer);
+ const now = store.now();
+ await q.upsert.run(site.id, src.provider, src.repo_url, src.ref, who, now, who, now);
+ return { site, source: await forSite(site.id) };
+ }
+
+ async function remove(viewer, siteId) {
+ const { site } = await sites.load(viewer, siteId, 'maintain');
+ await q.remove.run(site.id);
+ return { site, source: null };
+ }
+
+ return { forSite, get, put, remove, normalize };
+}
+
+module.exports = { createSiteSources, normalize, cleanRepoUrl, cleanRef, PROVIDERS, MAX_REF };
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'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, takedowns, indexnow = null }) {
+ const { db } = store;
+ const reserved = new Set([...RESERVED, ...config.sites.extraReservedNames]);
+ const q = {
+ byId: db.prepare('SELECT * FROM space_sites WHERE id = ?'),
+ byName: db.prepare("SELECT * FROM space_sites WHERE name = ? AND status = 'active'"),
+ held: db.prepare("SELECT project_id FROM space_sites WHERE name = ? AND status = 'deleted' AND deleted_at > ? ORDER BY deleted_at DESC LIMIT 1"),
+ forProject: db.prepare("SELECT * FROM space_sites WHERE project_id = ? AND status = 'active' ORDER BY created_at, seq"),
+ insert: db.prepare(`INSERT INTO space_sites (id, project_id, name, active_deploy_id, status, created_by, created_at, updated_at)
+ VALUES (?, ?, ?, NULL, 'active', ?, ?, ?)`),
+ insertDefaultDomain: db.prepare(`INSERT INTO space_domains (id, project_id, site_id, hostname, kind, status, token, created_by, created_at, verified_at)
+ VALUES (?, ?, ?, ?, 'default', 'verified', NULL, ?, ?, ?)`),
+ markDeleted: db.prepare("UPDATE space_sites SET status = 'deleted', active_deploy_id = NULL, preview_deploy_id = NULL, preview_expires_at = NULL, deleted_at = ?, updated_at = ? WHERE id = ?"),
+ };
+
+ const defaultHostname = (name) => `${name}.${config.sitesDomain}`;
+ // IndexNow: the site's public pages left the index (never a sandbox site, which is noindex).
+ const announce = typeof indexnow === 'function' ? indexnow : () => {};
+ const announceSite = (site, project) => {
+ if (!project || project.environment !== 'sandbox') announce(defaultHostname(site.name), ['/', '/sitemap.xml']);
+ };
+
+ async function get(id) {
+ return isId('site', id) ? await q.byId.get(id) || null : null;
+ }
+
+ /** The site and its project, authorized at `need`; 404 for anyone without access. */
+ async function load(viewer, id, need) {
+ const site = await get(id);
+ const notFound = new ApiError(404, 'site.not_found', 'no such site');
+ if (!site || site.status !== 'active') throw notFound;
+ const project = await projects.get(site.project_id);
+ const role = await 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`);
+ }
+
+ async function create(viewer, project, input = {}) {
+ await access.authorize(project, viewer, 'maintain');
+ const name = String(input.name || '').trim().toLowerCase();
+ checkName(name);
+ const quota = await projects.quotaOf(project);
+ if ((await projects.usageOf(project)).sites >= quota.sites) throw new ApiError(429, 'quota.sites', `this project may have ${quota.sites} sites`);
+ if (await q.byName.get(name)) throw new ApiError(409, 'site.name_taken', `${defaultHostname(name)} is taken`);
+ const held = await 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 {
+ await store.tx(async () => {
+ await q.insert.run(id, project.id, name, who, now, now);
+ await q.insertDefaultDomain.run(newId('domain', now), project.id, id, defaultHostname(name), who, now, now);
+ });
+ } catch (err) {
+ if (err.code === '23505') throw new ApiError(409, 'site.name_taken', `${defaultHostname(name)} is taken`);
+ throw err;
+ }
+ return await get(id);
+ }
+
+ async function listForProject(projectId) {
+ return await q.forProject.all(projectId);
+ }
+
+ /** Inside a transaction: the site stops serving immediately; deploys and domains go with it. */
+ async function markDeleted(site, now) {
+ await q.markDeleted.run(now, now, site.id);
+ await db.prepare("UPDATE space_deploys SET state = 'deleted', deleted_at = ? WHERE site_id = ? AND state <> 'deleted'").run(now, site.id);
+ await db.prepare('DELETE FROM space_deploy_files WHERE deploy_id IN (SELECT id FROM space_deploys WHERE site_id = ?)').run(site.id);
+ await db.prepare('DELETE FROM space_domains WHERE site_id = ?').run(site.id);
+ await db.prepare('DELETE FROM space_site_config WHERE site_id = ?').run(site.id);
+ await db.prepare('DELETE FROM space_site_sources WHERE site_id = ?').run(site.id);
+ }
+
+ async function remove(viewer, id, { deploys }) {
+ const { site, project } = await load(viewer, id, 'maintain');
+ await takedowns.assertDeletable(viewer, { site });
+ const now = store.now();
+ let freed = [];
+ await store.tx(async () => {
+ await markDeleted(site, now);
+ freed = await deploys.collectGarbage(project.id);
+ });
+ // Await it: with the Media store the deletes are slow and detached otherwise, and the caller
+ // (project/site removal) should finish only once the durable objects are gone.
+ await deploys.unlinkBlobs(project.id, freed);
+ announceSite(site, project);
+ return { deleted: true };
+ }
+
+ return { get, load, create, listForProject, markDeleted, remove, defaultHostname, checkName, byName: async (n) => await q.byName.get(n) || null };
+}
+
+module.exports = { createSites, RESERVED, NAME_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+/**
+ * Abuse takedowns, by OpenVibe staff only (Network role admin).
+ *
+ * A takedown stops a site, or every site of a project, from being served at once: its default and
+ * custom domains answer 451 with no tenant content. Unlike a delete, it KEEPS the deploys, their
+ * file rows, logs and objects, so a report can be reviewed and, where the law requires it, the
+ * material preserved and handed over. While a takedown is active the project's members cannot
+ * upload, activate, roll back or delete anything it covers; staff can still delete. Every takedown
+ * and every lift stays in space_takedowns.
+ *
+ * Members see that their site is taken down, and why (site/project `takedown` in the API).
+ */
+const { ApiError } = require('../http/errors');
+const { principalOf } = require('./access');
+
+const REASON_MAX = 500;
+
+function createTakedowns({ store }) {
+ const { db } = store;
+ const q = {
+ forSite: db.prepare(`SELECT target_kind, reason, created_at FROM space_takedowns
+ WHERE lifted_at IS NULL AND ((target_kind = 'site' AND target_id = ?) OR (target_kind = 'project' AND target_id = ?))
+ ORDER BY target_kind = 'project' DESC, created_at LIMIT 1`),
+ forProject: db.prepare("SELECT target_kind, reason, created_at FROM space_takedowns WHERE lifted_at IS NULL AND target_kind = 'project' AND target_id = ?"),
+ anyInProject: db.prepare(`SELECT target_kind, reason, created_at FROM space_takedowns t
+ WHERE lifted_at IS NULL AND ((target_kind = 'project' AND target_id = @p)
+ OR (target_kind = 'site' AND target_id IN (SELECT id FROM space_sites WHERE project_id = @p AND status = 'active')))
+ LIMIT 1`),
+ insert: db.prepare('INSERT INTO space_takedowns (target_kind, target_id, reason, created_by, created_at) VALUES (?, ?, ?, ?, ?)'),
+ lift: db.prepare('UPDATE space_takedowns SET lifted_by = ?, lifted_at = ?, lift_note = ? WHERE target_kind = ? AND target_id = ? AND lifted_at IS NULL'),
+ };
+
+ const shape = (row) => (row ? { scope: row.target_kind, reason: row.reason, since: row.created_at } : null);
+
+ /** The takedown that covers a site (its own, or its project's), or null. */
+ const ofSite = async (site) => shape(await q.forSite.get(site.id, site.project_id));
+ /** The project-wide takedown, or null. */
+ const ofProject = async (projectId) => shape(await q.forProject.get(projectId));
+
+ /** Members may not publish into, or change, content that is taken down. */
+ async function assertOpen(site) {
+ const t = await ofSite(site);
+ if (t) throw new ApiError(403, 'site.taken_down', `this ${t.scope} was taken down by OpenVibe staff (${t.reason}); nothing can be published or changed until staff lift it`);
+ }
+
+ /** Deleting taken-down content would destroy what staff are reviewing: staff only. */
+ async function assertDeletable(viewer, { site = null, projectId = null }) {
+ if (viewer && viewer.staff) return;
+ const row = site ? await q.forSite.get(site.id, site.project_id) : await q.anyInProject.get({ p: projectId });
+ if (row) throw new ApiError(409, 'site.taken_down', `this ${row.target_kind} was taken down by OpenVibe staff and is kept for review; it cannot be deleted until staff lift the takedown`);
+ }
+
+ function requireStaff(viewer) {
+ if (!viewer || !viewer.staff) throw new ApiError(403, 'auth.staff_only', 'only OpenVibe staff can take content down or lift a takedown');
+ }
+
+ /** kind: 'site' | 'project'. -> the takedown */
+ async function takeDown(viewer, kind, targetId, reasonRaw) {
+ requireStaff(viewer);
+ const reason = String(reasonRaw || '').trim();
+ if (!reason || reason.length > REASON_MAX) throw new ApiError(422, 'takedown.reason_required', `a takedown needs a reason of 1–${REASON_MAX} characters (members see it)`);
+ try {
+ await q.insert.run(kind, targetId, reason, principalOf(viewer), store.now());
+ } catch (err) {
+ if (err.code === '23505') throw new ApiError(409, 'takedown.exists', `this ${kind} is already taken down`);
+ throw err;
+ }
+ return { scope: kind, reason, since: store.now() };
+ }
+
+ async function lift(viewer, kind, targetId, note) {
+ requireStaff(viewer);
+ const r = await q.lift.run(principalOf(viewer), store.now(), note == null ? null : String(note).slice(0, REASON_MAX), kind, targetId);
+ if (r.changes !== 1) throw new ApiError(404, 'takedown.not_found', `this ${kind} is not taken down`);
+ return { lifted: true };
+ }
+
+ return { ofSite, ofProject, assertOpen, assertDeletable, takeDown, lift };
+}
+
+module.exports = { createTakedowns };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+
+/**
+ * Space → OpenVibe.Events through the openvibe-sdk service outbox (ADR-004).
+ *
+ * space.deploy.created an upload became an immutable, ready deploy
+ * space.deploy.activated a site's active deploy changed (payload.rollback marks a rollback)
+ * space.deploy.failed an upload was refused (payload.code, problem codes; no file contents)
+ * space.domain.verified a custom domain's TXT record was found; the domain is now served
+ *
+ * The SDK writes each row inside the change's own transaction (emit joins the ambient openvibe-sdk/db
+ * transaction), so an event exists if and only if its change committed. The relay publishes with
+ * Space'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. The undeclared event types below are refused by emit().
+ */
+const { createServiceOutbox } = require('openvibe-sdk/events');
+
+const EVENT_TYPES = Object.freeze(['space.deploy.created', 'space.deploy.activated', 'space.deploy.failed', 'space.domain.verified']);
+
+function createHostOutbox({ db, config, fetchImpl, now, log = console }) {
+ return createServiceOutbox({
+ db,
+ source: 'space',
+ eventsUrl: config.events.url,
+ networkInternalUrl: config.networkInternalUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ intervalMs: config.events.intervalMs,
+ now,
+ log,
+ eventTypes: EVENT_TYPES,
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
+ });
+}
+
+module.exports = { createHostOutbox, EVENT_TYPES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+'use strict';
+
+/**
+ * OpenVibe.Space Stage B (tenant static hosting) configuration. Every value comes from the
+ * environment (production: /etc/openvibe/space.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, 4940);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.space' : `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.SPACE_SITES_DOMAIN || 'openvibe.website').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: 'space',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ dashboardHost,
+ sitesDomain,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1, // nginx (realip) → Node
+
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
+ // SPACE_PGLITE_DIR: where the development-only embedded database lives (default data/pglite); tests
+ // point it at a temp directory so a spawned server gets a database of its own.
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.SPACE_PGLITE_DIR || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:space:' },
+ // Content-addressed deploy objects, one directory per project (tenancy keyed by project id).
+ storageDir: env.SPACE_STORAGE_DIR || './data/objects',
+
+ // Tenant objects: local disk is the store and the read cache. SPACE_OBJECT_STORE=media writes
+ // every object through to OpenVibe.Media as well (Space's own namespace `space`, keyed per
+ // project, sha256 as content_hash) and serves a local miss from there; unset keeps the local
+ // store exactly as it is. OV_MEDIA_URL is the internal Media base URL; the credential is
+ // Space's Network service principal (OV_OAUTH_CLIENT_ID/SECRET), audience openvibe.media.
+ objectStore: {
+ mode: String(env.SPACE_OBJECT_STORE || '').toLowerCase() === 'media' ? 'media' : 'local',
+ },
+ media: {
+ url: trim(env.OV_MEDIA_URL || 'http://127.0.0.1:4100'),
+ namespace: String(env.OV_MEDIA_NAMESPACE || 'space').trim() || 'space',
+ scope: 'media.object.upload media.object.read media.object.list media.object.delete',
+ },
+
+ // 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 || 'space',
+ 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.SPACE_FORM_SECRET || '',
+
+ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a tenant site the
+ // moment its active deploy appears, changes or goes away (the key file is served at
+ // /<key>.txt on every host). Unset → off: no key file, nothing sent. Tests and drills never
+ // set it (the drill sets INDEXNOW_KEY empty in space.example.json).
+ indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },
+
+ // 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),
+ },
+ community: {
+ internalUrl: trim(env.SPACE_COMMUNITY_URL || ''),
+ publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
+ },
+
+ uploads: {
+ // Largest request body accepted for one deploy (the compressed archive or all multipart parts).
+ maxUploadBytes: int(env.SPACE_MAX_UPLOAD_BYTES, 100 * MiB),
+ // Largest size an archive may unpack to (also capped by the project's storage quota). The
+ // unpacked files are held in memory while they are checked, so this bounds what a small
+ // gzip bomb can cost, whatever quota staff grant a project.
+ maxUnpackedBytes: int(env.SPACE_MAX_UNPACKED_BYTES, 256 * MiB),
+ // Uploads read and validated at the same time, service-wide (each is held in memory).
+ maxConcurrent: int(env.SPACE_MAX_CONCURRENT_UPLOADS, 2),
+ // Uploads are refused while the object store's filesystem has less than this free. The
+ // disk is shared with every other service on the host (Live, Media, databases), so a
+ // tenant must never be able to fill it.
+ minFreeBytes: int(env.SPACE_MIN_FREE_BYTES, 5 * 1024 * MiB),
+ },
+ // Projects one person may own (each gets the per-project quotas below).
+ projects: {
+ maxPerOwner: int(env.SPACE_MAX_PROJECTS_PER_OWNER, 10),
+ },
+ // Public hosting is owner-gated (plan T20/O34: the owner's go-ahead, a DMCA designated agent and a Public
+ // Suffix List entry come first). Until SPACE_PUBLIC_HOSTING=on only OpenVibe staff can create projects, and the
+ // front page says hosting is not open yet; everything else about the engine is unchanged.
+ publicHosting: String(env.SPACE_PUBLIC_HOSTING || 'off').trim().toLowerCase() === 'on',
+
+ // Per-project quotas (a row in space_quotas overrides them per project; only staff set it).
+ quotas: {
+ production: quota('SPACE_QUOTA_', { storageBytes: 1024 * MiB, deploysPerDay: 50, maxFiles: 10000, maxFileBytes: 25 * MiB, sites: 10, customDomains: 5 }),
+ sandbox: quota('SPACE_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.SPACE_CNAME_TARGET || '').toLowerCase() || null,
+ originIpv4: env.SPACE_ORIGIN_IPV4 || null,
+ originIpv6: env.SPACE_ORIGIN_IPV6 || null,
+ dnsServers: list(env.SPACE_DNS_SERVERS),
+ recheckIntervalMs: int(env.SPACE_DOMAIN_RECHECK_MS, 10 * 60 * 1000),
+ pendingDays: int(env.SPACE_DOMAIN_PENDING_DAYS, 7),
+ lapseDays: int(env.SPACE_DOMAIN_LAPSE_DAYS, 7),
+ extraReserved: list(env.SPACE_RESERVED_DOMAINS).map((d) => d.toLowerCase()),
+ },
+
+ sites: { extraReservedNames: list(env.SPACE_RESERVED_SITE_NAMES).map((d) => d.toLowerCase()) },
+
+ worker: {
+ enabled: env.SPACE_WORKER !== 'off',
+ id: env.SPACE_WORKER_ID || `space-${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,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+
+/** Static hosting engine mounted by Space's existing app. */
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const { createIndexNow } = require('openvibe-shared/indexnow');
+const { load } = require('./hosting-config');
+const { createBlobStore } = require('./storage');
+const { createHostOutbox } = require('./events/outbox');
+const { createAccess } = require('./domain/access');
+const { createTakedowns } = require('./domain/takedowns');
+const { createProjects } = require('./domain/projects');
+const { createSites } = require('./domain/sites');
+const { createDeploys } = require('./domain/deploys');
+const { createDomains } = require('./domain/domains');
+const { createSiteConfig } = require('./domain/site-config');
+const { createSiteSources } = require('./domain/site-sources');
+const { createViewerResolver, guard } = require('./auth/viewer');
+const { createTenantServer } = require('./http/tenant');
+const { createUploadGate } = require('./http/upload');
+const { createApi } = require('./http/api');
+const resourceIndex = require('./registry/resource-index');
+const { createDashboard } = require('./http/dashboard');
+const { createWorker } = require('./worker');
+const { createStore } = require('./db');
+
+function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, fetchImpl, indexnow: injectedIndexNow, log = console, storageDir, valkey, forumRedirect } = {}) {
+ const config = suppliedConfig || load();
+ const store = createStore(db, { now });
+ const media = config.objectStore.mode === 'media' ? {
+ url: config.media.url, namespace: config.media.namespace, scope: config.media.scope,
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, ...(fetchImpl ? { fetchImpl } : {}),
+ } : null;
+ const referenced = db.prepare('SELECT 1 FROM space_blobs WHERE project_id = ? AND sha256 = ?');
+ const blobs = createBlobStore(storageDir || config.storageDir, {
+ media, isReferenced: async (projectId, sha256) => Boolean(await referenced.get(projectId, sha256)),
+ });
+ const indexnow = injectedIndexNow !== undefined ? injectedIndexNow : createIndexNow({ host: config.baseUrl, key: config.indexnow.key, ...(fetchImpl ? { fetch: fetchImpl } : {}), log });
+ const indexnowHosts = new Map();
+ function announce(hostname, paths) {
+ if (!indexnow.enabled || !hostname) return;
+ if (!indexnowHosts.has(hostname)) indexnowHosts.set(hostname, createIndexNow({ host: hostname, key: config.indexnow.key, ...(fetchImpl ? { fetch: fetchImpl } : {}), log }));
+ const u = new URL(config.baseUrl);
+ const origin = `${u.protocol}//${hostname}${u.port ? `:${u.port}` : ''}`;
+ (injectedIndexNow !== undefined ? indexnow : indexnowHosts.get(hostname)).pingSoon(paths.map((p) => new URL(p, origin).href));
+ }
+ const outbox = createHostOutbox({ db, config, fetchImpl, now: store.now, log });
+ const access = createAccess({ store });
+ const takedowns = createTakedowns({ store });
+ const projects = createProjects({ store, config, access, blobs, takedowns, log, indexnow: announce });
+ const sites = createSites({ store, config, access, projects, takedowns, indexnow: announce });
+ const deploys = createDeploys({ store, config, access, projects, sites, blobs, outbox, takedowns, log, indexnow: announce });
+ const domains = createDomains({ store, config, access, projects, sites, outbox, resolver, log, indexnow: announce });
+ const siteConfig = createSiteConfig({ store });
+ const siteSources = createSiteSources({ store, sites });
+ const viewers = createViewerResolver({ auth, config, log });
+ const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
+ const uploadGate = createUploadGate(config.uploads.maxConcurrent);
+ const worker = createWorker({ config, store, domains, blobs, outbox, log });
+ const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, auth, viewers, tenant, worker, uploadGate, indexnow, log, valkey: valkey || null };
+ ctx.actorLimits = require('./http/actor-limits').createHostActorLimits({ registry: app.locals.metrics, valkey: ctx.valkey });
+ app.locals.ctx = ctx;
+ if (app.locals.metrics && typeof app.locals.metrics.gauge === 'function') {
+ app.locals.metrics.gauge({
+ name: 'space_sites', help: 'Active tenant sites',
+ collect: async () => [{ labels: {}, value: (await db.prepare("SELECT COUNT(*) AS n FROM space_sites WHERE status = 'active'").get()).n }],
+ });
+ }
+ require('./http/errors').setLogger(log);
+
+ /**
+ * Mounted first (server/app.js), before the platform's own headers, metrics route, auth, API and readiness:
+ * a tenant host is answered here and never reaches anything below (Host's order, test/hosting-isolation and
+ * test/hosting-site-config). The IndexNow key file answers on every host, a tenant's included.
+ */
+ ctx.mountDispatch = (target) => {
+ if (indexnow.enabled) target.use(indexnow.keyFile);
+ target.use(async (req, res, next) => {
+ try {
+ const target = await tenant.resolve(req.headers.host);
+ req.hostTarget = target.kind;
+ if (target.kind === 'dashboard') return next();
+ if (target.kind === 'site') return await tenant.handle(req, res, target.site);
+ return tenant.unknownHost(req, res);
+ } catch (err) { return next(err); }
+ });
+ };
+
+ /** The dashboard host's hosting routes: forum redirects, the hosting API, previews, the dashboard and the limits page. */
+ ctx.mountRoutes = (app) => {
+ if (forumRedirect) app.use(forumRedirect);
+ app.use(contracts.http.middleware());
+ app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), resourceIndex.router({ guard: guard('space.resource.read') }));
+ app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
+ app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => Promise.resolve(tenant.preview(req, res)).catch(next));
+ app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
+ require('./limits').mountLimits(app, config);
+ };
+ return ctx;
+}
+
+module.exports = { createHosting };
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+/**
+ * Per-actor limits on the dashboard's and the API's writes (roadmap WS-R task 4; openvibe-sdk/limits).
+ *
+ * A signed-in person (req.viewer.kind === 'user') is counted by subject; a service token by its principal (a service
+ * here manages its own projects, not other people's). Anonymous callers and reads are not counted: the per-address
+ * limits in app.js stay. Every write takes SPACE_LIMITS_MINUTE / SPACE_LIMITS_HOUR (120 and 3000); deploy uploads
+ * (stored and unpacked), domain verification (DNS lookups) and project creation have tighter numbers on top. Past a
+ * limit: 429 problem+json `rate_limited` with Retry-After before the route runs, logged and counted in
+ * space_rate_limited_total{limit,window}. Counters live in this process.
+ */
+'use strict';
+
+const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/limits');
+
+const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) && n > 0 ? n : d; };
+
+/** [name, method regex, path regex (relative to the router), { minute, hour }]; the first match wins. */
+const ROUTES = [
+ ['space.deploy.upload', /^POST$/, /^\/sites\/[^/]+\/deploys\/?$/, { minute: 10, hour: 100 }],
+ ['space.domain.verify', /^POST$/, /^\/domains\/[^/]+\/verify$/, { minute: 10, hour: 100 }],
+ ['space.project.create', /^POST$/, /^\/projects\/?$/, { minute: 5, hour: 30 }],
+];
+
+function createHostActorLimits({ env = process.env, registry = null, now, valkey = null } = {}) {
+ // SPACE_ACTOR_LIMITS=off: a rollback lever (and what the Stage B suites use: they deploy dozens of times a minute).
+ if (/^(off|0|false)$/i.test(String(env.SPACE_ACTOR_LIMITS || ''))) return function noLimits(req, res, next) { next(); };
+ const refused = registry && typeof registry.counter === 'function'
+ ? registry.counter({ name: 'space_rate_limited_total', help: 'Writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
+ : null;
+ const actor = (req) => {
+ const v = req.viewer;
+ if (!v) return null;
+ if (v.kind === 'user' && v.subject) return `user:${v.subject}`;
+ if (v.kind === 'service' && v.claims && v.claims.sub) return String(v.claims.sub);
+ return null;
+ };
+ const limits = createActorLimiter({
+ limits: { minute: num(env.SPACE_LIMITS_MINUTE, 120), hour: num(env.SPACE_LIMITS_HOUR, 3000) },
+ actor,
+ ...(now ? { now } : {}),
+ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
+ onLimited(e) {
+ console.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
+ if (refused) refused.inc({ limit: e.name, window: e.window });
+ },
+ });
+ const write = limits('space.write');
+ const named = ROUTES.map(([name, method, pathRe, own]) => ({ method, pathRe, mw: limits(name, own) }));
+ const WRITE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
+ return function hostActorLimits(req, res, next) {
+ if (!WRITE.has(req.method)) return next();
+ const own = named.find((r) => r.method.test(req.method) && r.pathRe.test(req.path));
+ return write(req, res, (err) => (err ? next(err) : own ? own.mw(req, res, next) : next()));
+ };
+}
+
+module.exports = { createHostActorLimits, ROUTES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,271 @@
@@ -0,0 +1,271 @@
+'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.space). Cookies are ignored here: tenant pages
+ * on <site>.openvibe.space are same-site with openvibe.space, 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 space.site.manage
+ * sites GET/POST /projects/:id/sites · GET/DELETE /sites/:id space.site.manage
+ * GET/PUT/DELETE /sites/:id/config (headers, redirects, SPA fallback) space.site.config
+ * GET/PUT/DELETE /sites/:id/source (Git repository + branch) space.site.manage
+ * deploys GET/POST /sites/:id/deploys · GET /deploys/:id · GET /deploys/:id/log
+ * POST /deploys/:id/activate · POST /sites/:id/rollback space.deploy.create
+ * DELETE /deploys/:id space.site.manage
+ * POST /sites/:id/source/deploys (the project's CI posts a build; always a preview)
+ * space.deploy.create
+ * domains GET/POST /sites/:id/domains · POST /domains/:id/verify
+ * DELETE /domains/:id space.domain.manage
+ * takedowns POST/DELETE /projects/:id/takedown · POST/DELETE /sites/:id/takedown space.site.manage (staff)
+ */
+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, siteConfig, siteSources, viewers, takedowns } = 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'));
+ if (ctx.actorLimits) router.use(ctx.actorLimits);
+
+ const siteUrl = (hostname) => {
+ const u = new URL(config.baseUrl);
+ return `${u.protocol}//${hostname}${u.port ? `:${u.port}` : ''}`;
+ };
+ const siteOut = async (s) => { const hostname = sites.defaultHostname(s.name); return out.site(s, { hostname, url: siteUrl(hostname), takedown: await takedowns.ofSite(s) }); };
+ const projectOut = async (p, role) => out.project(p, { role, quota: await projects.quotaOf(p), usage: await projects.usageOf(p), takedown: await takedowns.ofProject(p.id) });
+ const tp = (req) => (req.ov ? req.ov.traceparent : undefined);
+ const loadProject = async (req, need) => {
+ const project = await projects.get(req.params.id);
+ const role = await ctx.access.authorize(project, req.viewer, need);
+ return { project, role };
+ };
+
+ // ── Projects ────────────────────────────────────────────
+ router.get('/projects', guard('space.site.manage'), run(async (req) => {
+ if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');
+ return { projects: (await Promise.all((await projects.listFor(req.viewer)).map(async (p) => out.project(p, { takedown: await takedowns.ofProject(p.id) })))) };
+ }));
+ router.post('/projects', guard('space.site.manage'), jsonBody, run(async (req) => {
+ if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');
+ const p = await projects.create(req.viewer, req.body || {});
+ return { project: await projectOut(p, 'owner') };
+ }, 201));
+ router.get('/projects/:id', guard('space.site.manage'), run(async (req) => {
+ const { project, role } = await loadProject(req, 'read');
+ return { project: await projectOut(project, role), sites: (await Promise.all((await sites.listForProject(project.id)).map(siteOut))), members: (await projects.members(project)).map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };
+ }));
+ router.delete('/projects/:id', guard('space.site.manage'), run(async (req) => {
+ const { project } = await loadProject(req, 'read');
+ return await projects.remove(req.viewer, project, { sites });
+ }));
+ router.get('/projects/:id/quota', guard('space.site.manage'), run(async (req) => {
+ const { project } = await loadProject(req, 'read');
+ return { quota: out.quotaOut(await projects.quotaOf(project)), usage: out.usageOut(await projects.usageOf(project)) };
+ }));
+ router.put('/projects/:id/quota', guard('space.site.manage'), jsonBody, run(async (req) => {
+ const project = await projects.get(req.params.id);
+ const q = await projects.setQuota(req.viewer, project, req.body || {});
+ return { quota: out.quotaOut(q), usage: out.usageOut(await projects.usageOf(project)) };
+ }));
+ router.put('/projects/:id/members/:principal', guard('space.site.manage'), jsonBody, run(async (req) => {
+ const { project } = await loadProject(req, 'read');
+ const list = await 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('space.site.manage'), run(async (req) => {
+ const { project } = await loadProject(req, 'read');
+ const list = await 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) })) };
+ }));
+
+ // ── Takedowns (staff only; members see them on the project and site) ──
+ // Serving stops at once and the content is kept for review (domain/takedowns.js).
+ router.post('/projects/:id/takedown', guard('space.site.manage'), jsonBody, run(async (req) => {
+ const { project } = await loadProject(req, 'read');
+ return { takedown: out.takedownOut(await takedowns.takeDown(req.viewer, 'project', project.id, (req.body || {}).reason)) };
+ }, 201));
+ router.delete('/projects/:id/takedown', guard('space.site.manage'), jsonBody, run(async (req) => {
+ const { project } = await loadProject(req, 'read');
+ return await takedowns.lift(req.viewer, 'project', project.id, (req.body || {}).note);
+ }));
+ router.post('/sites/:id/takedown', guard('space.site.manage'), jsonBody, run(async (req) => {
+ const { site } = await sites.load(req.viewer, req.params.id, 'read');
+ return { takedown: out.takedownOut(await takedowns.takeDown(req.viewer, 'site', site.id, (req.body || {}).reason)) };
+ }, 201));
+ router.delete('/sites/:id/takedown', guard('space.site.manage'), jsonBody, run(async (req) => {
+ const { site } = await sites.load(req.viewer, req.params.id, 'read');
+ return await takedowns.lift(req.viewer, 'site', site.id, (req.body || {}).note);
+ }));
+
+ // ── Sites ───────────────────────────────────────────────
+ router.get('/projects/:id/sites', guard('space.site.manage'), run(async (req) => {
+ const { project } = await loadProject(req, 'read');
+ return { sites: (await Promise.all((await sites.listForProject(project.id)).map(siteOut))) };
+ }));
+ router.post('/projects/:id/sites', guard('space.site.manage'), jsonBody, run(async (req) => {
+ const project = await projects.get(req.params.id);
+ await ctx.access.authorize(project, req.viewer, 'read');
+ return { site: await siteOut(await sites.create(req.viewer, project, req.body || {})) };
+ }, 201));
+ router.get('/sites/:id', guard('space.site.manage'), run(async (req) => {
+ const { site } = await sites.load(req.viewer, req.params.id, 'read');
+ return { site: await siteOut(site), domains: (await domains.listForSite(site.id)).map((d) => out.domain(d, domains.instructions(d, site))) };
+ }));
+ router.delete('/sites/:id', guard('space.site.manage'), run(async (req) => await sites.remove(req.viewer, req.params.id, { deploys })));
+
+ // ── Deploys ─────────────────────────────────────────────
+ router.get('/sites/:id/deploys', guard('space.deploy.create'), run(async (req) => {
+ // Reads need the least member role (deployer); staff may read them too, e.g. to review a takedown.
+ const { site } = await sites.load(req.viewer, req.params.id, 'read');
+ return {
+ active_deploy_id: site.active_deploy_id || null,
+ deploys: (await deploys.list(site.id, Number(req.query.limit) || 50)).map((d) => out.deploy(d, { active: d.id === site.active_deploy_id })),
+ activations: (await deploys.activationsOf(site.id, 20)).map(out.activation),
+ };
+ }));
+
+ /**
+ * An upload (POST /sites/:id/deploys) or, with `git`, a git deploy (POST /sites/:id/source/deploys):
+ * the same body, limits, gate and failure records; a git deploy also names ref and commit_sha
+ * (query or form fields) and always lands as the site's preview.
+ */
+ async function receive(req, res, { git = false } = {}) {
+ let release = null;
+ try {
+ const pre = await deploys.precheck(req.viewer, req.params.id);
+ if (git) {
+ // Refused before a byte is read: activate, an unconnected site, a wrong ref or SHA in the query.
+ if (truthy(req.query.activate)) throw noActivate();
+ await deploys.gitTarget(pre.site, { ref: req.query.ref, commit_sha: req.query.commit_sha });
+ }
+ release = ctx.uploadGate.enter();
+ if (!release) { res.set('Retry-After', '30'); throw new ApiError(503, 'upload.busy', 'Space is validating other uploads right now; try again in 30 seconds'); }
+ let up;
+ try {
+ up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
+ } 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 = await deploys.recordFailure(req.viewer, pre, { source: git ? 'git' : 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}`) } });
+ }
+ if (git) {
+ if (truthy(up.fields.activate)) throw noActivate();
+ const field = (name) => (up.fields[name] != null ? up.fields[name] : req.query[name]);
+ const r = await deploys.ingestGit(req.viewer, pre, pre.site.id, up.entries, { ref: field('ref'), commit_sha: field('commit_sha'), notes: up.notes, traceparent: tp(req) });
+ const site = await sites.get(pre.site.id);
+ return res.status(201).json({ deploy: out.deploy(r.deploy, { active: site.active_deploy_id === r.deploy.id, log: r.log }), activated: false, preview: r.preview, url: siteUrl(sites.defaultHostname(site.name)) });
+ }
+ const preview = truthy(up.fields.preview != null ? up.fields.preview : req.query.preview);
+ // A preview is never the site's active deploy; it is served only at /preview/<id>/ later.
+ const activate = !preview && truthy(up.fields.activate != null ? up.fields.activate : req.query.activate);
+ const r = await deploys.create(req.viewer, pre, up.entries, { source: up.source, activate, preview, notes: up.notes, traceparent: tp(req) });
+ const site = await sites.get(pre.site.id);
+ res.status(201).json({ deploy: out.deploy(r.deploy, { active: site.active_deploy_id === r.deploy.id, log: r.log }), activated: Boolean(r.activated && r.activated.changed), preview: r.preview, url: siteUrl(sites.defaultHostname(site.name)) });
+ } catch (err) {
+ // Refused before the body was read (404, 429, 503): close instead of draining a large upload.
+ if (!req.complete) res.set('Connection', 'close');
+ if (!res.headersSent) sendError(res, req, err);
+ } finally {
+ if (release) release();
+ }
+ }
+ const noActivate = () => new ApiError(422, 'source.activate', 'a git deploy is never activated on arrival: it lands as the site\'s preview; approve it with POST /api/v1/deploys/:id/activate');
+ router.post('/sites/:id/deploys', guard('space.deploy.create'), (req, res) => receive(req, res));
+
+ router.get('/deploys/:id', guard('space.deploy.create'), run(async (req) => {
+ const { deploy, site } = await deploys.load(req.viewer, req.params.id, 'read');
+ return { deploy: out.deploy(deploy, { active: site.active_deploy_id === deploy.id, files: await deploys.filesOf(deploy.id) }) };
+ }));
+ router.get('/deploys/:id/log', guard('space.deploy.create'), run(async (req) => {
+ const { deploy } = await deploys.load(req.viewer, req.params.id, 'read');
+ return { deploy_id: deploy.id, state: deploy.state, log: (await deploys.logOf(deploy.id)).map(out.logLine) };
+ }));
+ router.post('/deploys/:id/activate', guard('space.deploy.create'), jsonBody, run(async (req) => {
+ const body = req.body || {};
+ const r = await 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('space.deploy.create'), jsonBody, run(async (req) => {
+ const body = req.body || {};
+ const r = await 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('space.site.manage'), run(async (req) => await deploys.remove(req.viewer, req.params.id)));
+
+ // ── Domains ─────────────────────────────────────────────
+ router.get('/sites/:id/domains', guard('space.domain.manage'), run(async (req) => {
+ const { site } = await sites.load(req.viewer, req.params.id, 'read');
+ return { domains: (await domains.listForSite(site.id)).map((d) => out.domain(d, domains.instructions(d, site))) };
+ }));
+ router.post('/sites/:id/domains', guard('space.domain.manage'), jsonBody, run(async (req) => {
+ const { domain, site } = await 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('space.domain.manage'), run(async (req) => {
+ const d = await domains.verify(req.viewer, req.params.id, { traceparent: tp(req) });
+ const site = await sites.get(d.site_id);
+ return { domain: out.domain(d, domains.instructions(d, site)) };
+ }));
+ router.delete('/domains/:id', guard('space.domain.manage'), run(async (req) => await domains.remove(req.viewer, req.params.id)));
+
+ // ── Per-site configuration: headers, redirects, SPA fallback (space.site.config) ──
+ // Applied by http/tenant.js before the platform's headers; reserved headers are refused on write.
+ router.get('/sites/:id/config', guard('space.site.config'), run(async (req) => {
+ const { site } = await sites.load(req.viewer, req.params.id, 'read');
+ return { config: await siteConfig.getForSite(site.id) };
+ }));
+ router.put('/sites/:id/config', guard('space.site.config'), jsonBody, run(async (req) => {
+ const { site } = await sites.load(req.viewer, req.params.id, 'maintain');
+ return { config: await siteConfig.set(req.viewer, site, req.body || {}) };
+ }));
+ router.delete('/sites/:id/config', guard('space.site.config'), run(async (req) => {
+ const { site } = await sites.load(req.viewer, req.params.id, 'maintain');
+ return { config: await siteConfig.remove(site) };
+ }));
+
+ // ── Git source: the repository + branch the project's own CI deploys from (space.site.manage) ──
+ // Host never clones, fetches or builds it and holds no credential: the CI posts its build output
+ // to /source/deploys with the ref and the commit it built, and that deploy is only ever a preview.
+ router.get('/sites/:id/source', guard('space.site.manage'), run(async (req) => {
+ const { source } = await siteSources.get(req.viewer, req.params.id);
+ return { source: out.source(source) };
+ }));
+ router.put('/sites/:id/source', guard('space.site.manage'), jsonBody, run(async (req) => {
+ const { source } = await siteSources.put(req.viewer, req.params.id, req.body);
+ return { source: out.source(source) };
+ }));
+ router.delete('/sites/:id/source', guard('space.site.manage'), run(async (req) => {
+ await siteSources.remove(req.viewer, req.params.id);
+ return { source: null };
+ }));
+ router.post('/sites/:id/source/deploys', guard('space.deploy.create'), (req, res) => receive(req, res, { git: true }));
+
+ 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,224 @@
@@ -0,0 +1,224 @@
+'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.space). Every response is private and never cached by a shared cache.
+ */
+const express = require('express');
+const { renderPage } = require('../render/hosting-layout');
+const pages = require('../render/hosting-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, siteConfig, siteSources, 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'));
+ if (ctx.actorLimits) router.use(ctx.actorLimits);
+ router.use((req, res, next) => { privateNoStore(res); next(); });
+
+ function page(req, res, status, title, body, notice, { indexable = false, styles, css, jsonLd } = {}) {
+ res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice, indexable, styles, css, jsonLd }));
+ }
+
+ 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('[Space 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;
+ };
+ /** A staff takedown outranks any other notice: members must see why nothing is served. */
+ const takedownNotice = (t) => (t ? { kind: 'error', text: `This ${t.scope} was taken down by OpenVibe staff: ${t.reason}. Nothing of it is served, and nothing can be published or deleted, until staff lift the takedown.` } : null);
+ const back = (path, text, kind) => `${path}?notice=${encodeURIComponent(text)}${kind ? `&kind=${kind}` : ''}`;
+
+ // ── Pages ───────────────────────────────────────────────
+ // What shipped on OpenVibe.Space: the shared update log every OpenVibe site has.
+ router.get('/dashboard', view(async (req, res) => {
+ if (req.viewer.kind !== 'user') return res.redirect(303, '/auth/login?next=%2Fdashboard');
+ page(req, res, 200, 'Projects', pages.home({ projects: await projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer), canCreate: config.publicHosting || Boolean(req.viewer.staff) }), notice(req));
+ }));
+
+ router.get('/projects/:id', view(async (req, res) => {
+ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
+ const project = await projects.get(req.params.id);
+ const role = await access.authorize(project, req.viewer, 'read');
+ page(req, res, 200, project.name, pages.project({
+ project, role, sites: await sites.listForProject(project.id), quota: await projects.quotaOf(project), usage: await projects.usageOf(project),
+ members: await projects.members(project), csrf: csrfToken(config, req.viewer), siteUrl,
+ }), takedownNotice(await ctx.takedowns.ofProject(project.id)) || notice(req));
+ }));
+
+ router.get('/sites/:id', view(async (req, res) => {
+ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
+ const { site, project, role } = await sites.load(req.viewer, req.params.id, 'read');
+ const cfg = await siteConfig.getForSite(site.id);
+ page(req, res, 200, site.name, pages.site({
+ site, project, role, url: siteUrl(site), csrf: csrfToken(config, req.viewer), now: ctx.store.now(),
+ deploys: await deploys.list(site.id, 50), activations: await deploys.activationsOf(site.id, 10),
+ domains: (await domains.listForSite(site.id)).map((d) => ({ domain: d, instructions: domains.instructions(d, site) })),
+ siteConfig: { ...cfg, headersText: siteConfig.headersToText(cfg.headers), redirectsText: siteConfig.redirectsToText(cfg.redirects) },
+ source: await siteSources.forSite(site.id),
+ }), takedownNotice(await ctx.takedowns.ofSite(site)) || notice(req));
+ }));
+
+ router.get('/deploys/:id', view(async (req, res) => {
+ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
+ const { deploy, site, project } = await deploys.load(req.viewer, req.params.id, 'read');
+ page(req, res, 200, `Deploy ${deploy.id.slice(0, 12)}`, pages.deploy({
+ deploy, site, project, active: site.active_deploy_id === deploy.id, files: await deploys.filesOf(deploy.id), log: await deploys.logOf(deploy.id),
+ }), notice(req));
+ }));
+
+ // ── Actions ─────────────────────────────────────────────
+ router.post('/projects', action(async (req) => {
+ const p = await 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(async (req) => {
+ const project = await projects.get(req.params.id);
+ await access.authorize(project, req.viewer, 'read');
+ const s = await sites.create(req.viewer, project, { name: req.body.name });
+ return back(`/sites/${s.id}`, `Site created: ${siteUrl(s)}`);
+ }));
+ router.post('/projects/:id/members', action(async (req) => {
+ const project = await projects.get(req.params.id);
+ await access.authorize(project, req.viewer, 'read');
+ await 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(async (req) => {
+ const project = await projects.get(req.params.id);
+ await access.authorize(project, req.viewer, 'read');
+ await projects.removeMember(req.viewer, project, String(req.body.principal || ''));
+ return back(`/projects/${project.id}`, 'Member removed.');
+ }));
+ router.post('/projects/:id/delete', action(async (req) => {
+ const project = await projects.get(req.params.id);
+ await access.authorize(project, req.viewer, 'read');
+ if (req.body.confirm !== 'yes') throw new ApiError(422, 'form.confirm', 'tick the confirmation box to delete');
+ await projects.remove(req.viewer, project, { sites });
+ return back('/dashboard', 'Project deleted.');
+ }));
+ router.post('/sites/:id/config', action(async (req) => {
+ const { site } = await sites.load(req.viewer, req.params.id, 'maintain');
+ const headers = siteConfig.parseHeadersText(req.body.headers);
+ const redirects = siteConfig.parseRedirectsText(req.body.redirects);
+ await siteConfig.set(req.viewer, site, { headers, redirects, spa: req.body.spa === '1' });
+ return back(`/sites/${site.id}`, 'Site configuration saved.');
+ }));
+ // The Git source the project's CI deploys from (Host never fetches it); git deploys land as previews.
+ router.post('/sites/:id/source', action(async (req) => {
+ const { site } = await siteSources.put(req.viewer, req.params.id, { provider: req.body.provider || undefined, repo_url: req.body.repo_url, ref: req.body.ref });
+ return back(`/sites/${site.id}`, 'Git source saved. Your CI posts each build to it as a preview.');
+ }));
+ router.post('/sites/:id/source/remove', action(async (req) => {
+ const { site } = await siteSources.remove(req.viewer, req.params.id);
+ return back(`/sites/${site.id}`, 'Git source removed.');
+ }));
+ router.post('/sites/:id/domains', action(async (req) => {
+ const { domain } = await domains.add(req.viewer, req.params.id, { hostname: req.body.hostname });
+ return back(`/sites/${domain.site_id}`, `Added ${domain.hostname}: publish the DNS records below, then check.`);
+ }));
+ router.post('/sites/:id/rollback', action(async (req) => {
+ const r = await 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(async (req) => {
+ const { site } = await 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');
+ await sites.remove(req.viewer, site.id, { deploys });
+ return back(`/projects/${site.project_id}`, `${site.name} deleted.`);
+ }));
+ router.post('/deploys/:id/activate', action(async (req) => {
+ const { site } = await deploys.load(req.viewer, req.params.id, 'deploy');
+ const r = await 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(async (req) => {
+ const { site } = await deploys.load(req.viewer, req.params.id, 'maintain');
+ await 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(async (req) => {
+ const { domain } = await domains.load(req.viewer, req.params.id, 'maintain');
+ await 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) => {
+ let release = null;
+ try {
+ if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
+ if (!sameOrigin(config, req)) throw new ApiError(403, 'form.invalid', 'this form did not come from this dashboard');
+ const pre = await deploys.precheck(req.viewer, req.params.id);
+ release = ctx.uploadGate.enter();
+ if (!release) { res.set('Retry-After', '30'); throw new ApiError(503, 'upload.busy', 'Space is validating other uploads right now; try again in 30 seconds'); }
+ let up;
+ try {
+ up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
+ } 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 = await 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 {
+ // The form's choice: activate it, keep it ready, or make it the site's private preview.
+ const mode = ['activate', 'ready', 'preview'].includes(up.fields.mode) ? up.fields.mode : (up.fields.activate === '1' ? 'activate' : 'ready');
+ const r = await deploys.create(req.viewer, pre, up.entries, { source: up.source, activate: mode === 'activate', preview: mode === 'preview', notes: up.notes, traceparent: req.ov && req.ov.traceparent });
+ const done = r.preview ? 'Deployed as a preview: open it from the site page. Only members of this project can see it.'
+ : r.activated && r.activated.changed ? 'Deployed and activated.' : 'Deployed. Activate it from the site page.';
+ return res.redirect(303, back(`/deploys/${r.deploy.id}`, done));
+ } catch (err) {
+ if (err instanceof ApiError && err.extra && err.extra.deploy_id) return res.redirect(303, back(`/deploys/${err.extra.deploy_id}`, `Upload refused: ${err.message}`, 'error'));
+ throw err;
+ }
+ } catch (err) {
+ if (!req.complete) res.set('Connection', 'close');
+ fail(req, res, err);
+ } finally {
+ if (release) release();
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createDashboard };
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'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');
+const cache = require('openvibe-shared/cache-policy');
+
+/** 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('[Space 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', cache.htmlHeaders({ private: true }));
+ 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,115 @@
@@ -0,0 +1,115 @@
+'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());
+
+/** A staff takedown as members see it (domain/takedowns.js), or null. */
+function takedownOut(t) {
+ return t ? { scope: t.scope, reason: t.reason, since: iso(t.since) } : null;
+}
+
+function project(p, { role = null, quota = null, usage = null, takedown = null } = {}) {
+ return {
+ id: p.id,
+ name: p.name,
+ 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),
+ takedown: takedownOut(takedown),
+ ...(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, takedown = null }) {
+ return {
+ id: s.id,
+ project_id: s.project_id,
+ name: s.name,
+ hostname,
+ url,
+ active_deploy_id: s.active_deploy_id || null,
+ takedown: takedownOut(takedown),
+ created_at: iso(s.created_at),
+ updated_at: iso(s.updated_at),
+ };
+}
+
+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),
+ ...(d.source === 'git' && d.git_commit_sha ? { git: { provider: d.git_provider, repo_url: d.git_repo_url, ref: d.git_ref, commit_sha: d.git_commit_sha } } : {}),
+ ...(files ? { files: files.map((f) => ({ path: f.path, sha256: f.sha256, size: f.size, content_type: f.content_type })) } : {}),
+ ...(log ? { log: log.map(logLine) } : {}),
+ };
+}
+
+/** A site's Git source (domain/site-sources.js): public provenance only, never a credential. */
+function source(src) {
+ if (!src) return null;
+ return {
+ site_id: src.site_id,
+ provider: src.provider,
+ repo_url: src.repo_url,
+ ref: src.ref,
+ created_by: src.created_by || null,
+ created_at: iso(src.created_at),
+ updated_by: src.updated_by || null,
+ updated_at: iso(src.updated_at),
+ };
+}
+
+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, takedownOut, quotaOut, usageOut, site, deploy, source, logLine, domain, activation, iso };
new file mode 100644
new file mode 100644
@@ -0,0 +1,436 @@
@@ -0,0 +1,436 @@
+'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 (space_deploy_files). The file's
+ * bytes come from the blob store keyed by the project id and the manifest's hash — the local disk
+ * cache, or, with SPACE_OBJECT_STORE=media, the object fetched back from OpenVibe.Media and verified
+ * (server/storage.js). The key is never built 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.
+ * A site that staff took down (domain/takedowns.js) answers 451 with none of its content.
+ * A deploy that ships no sitemap.xml or robots.txt gets a generated one (the manifest's HTML pages on
+ * the host the request came in on, so a verified custom domain gets its own; a sandbox site gets
+ * `Disallow: /`). A file the tenant uploaded at either path is served instead.
+ *
+ * A site's own configuration (domain/site-config.js) adds its response headers, its local-only
+ * redirects and an optional SPA fallback (extensionless paths serve index.html). The headers are
+ * applied BEFORE the platform's own, and a reserved header (CSP, HSTS, Set-Cookie, X-Forwarded-*, …)
+ * is never applied even if a row holds one, so a tenant can never weaken or spoof them.
+ *
+ * preview() serves a site's ONE live preview deploy (space_sites.preview_deploy_id) on the dashboard
+ * host only, to a member of its project, at /preview/<deploy-id>/… — always noindex, never cached by
+ * a shared cache, and never announced to a search engine (plan T12 J4, decision D5).
+ */
+const fs = require('fs');
+const { sitemapXml, robotsTxt } = require('openvibe-shared/seo');
+const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');
+const { isHostname } = require('../domain/domains');
+const { isReservedHeader } = require('../domain/site-config');
+const { isId } = require('../ids');
+
+const TENANT_CSP = [
+ "default-src 'self'",
+ "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]']);
+
+/**
+ * A preview is tenant content served from the dashboard's OWN origin, so — unlike tenant sites, which
+ * live on a separate registrable domain — it must be stopped from acting as openvibe.space. Its CSP
+ * sandboxes the document into an opaque origin (there is deliberately no `allow-same-origin`), so
+ * every tenant script, style and asset it loads runs without access to the dashboard's cookies,
+ * storage or DOM (the "active content runs only on the tenant's own origin" control, docs/threat-review.md §5).
+ */
+function previewCsp(origin) {
+ return [
+ "default-src 'none'",
+ `script-src ${origin}`,
+ `style-src ${origin} 'unsafe-inline'`,
+ `img-src ${origin} data: https:`,
+ `font-src ${origin} data:`,
+ `media-src ${origin} https:`,
+ `connect-src ${origin}`,
+ "object-src 'none'",
+ "base-uri 'none'",
+ "form-action 'none'",
+ "frame-ancestors 'none'",
+ 'sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
+ ].join('; ');
+}
+
+/** Host header → lowercase host name without port or trailing dot; null when it is not one. */
+function normaliseHost(raw) {
+ if (typeof raw !== 'string' || !raw || raw.length > 260) return null;
+ 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.Space');
+ 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);
+}
+
+/** Apply a site's configured headers, never a reserved one (defense in depth: they are refused on write too). */
+function applyConfiguredHeaders(res, headers) {
+ for (const [name, value] of Object.entries(headers || {})) {
+ if (isReservedHeader(name)) continue;
+ try { res.setHeader(name, value); } catch { /* a name or value Node refuses is simply not set */ }
+ }
+}
+
+function createTenantServer({ store, config, blobs, takedowns = null, siteConfig = null, access = null, log = console }) {
+ const { db } = store;
+ const q = {
+ siteByName: db.prepare("SELECT s.*, p.environment FROM space_sites s JOIN space_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 space_domains d JOIN space_sites s ON s.id = d.site_id JOIN space_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 space_deploy_files f JOIN space_deploys d ON d.id = f.deploy_id
+ WHERE f.deploy_id = ? AND f.path = ? AND d.state = 'ready'`),
+ htmlFiles: db.prepare("SELECT path FROM space_deploy_files WHERE deploy_id = ? AND content_type LIKE 'text/html%' ORDER BY path"),
+ deployById: db.prepare('SELECT id, site_id, project_id, state FROM space_deploys WHERE id = ?'),
+ siteById: db.prepare('SELECT * FROM space_sites WHERE id = ?'),
+ projectById: db.prepare('SELECT id, status, environment FROM space_projects WHERE id = ?'),
+ };
+ const suffix = `.${config.sitesDomain}`;
+ let previewOrigin = "'self'";
+ try { previewOrigin = new URL(config.baseUrl).origin; } catch { /* keep 'self' */ }
+ const PREVIEW_CSP = previewCsp(previewOrigin);
+
+ /**
+ * -> { kind: 'dashboard' } | { kind: 'site', site } | { kind: 'unknown', host }
+ */
+ async 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 = await q.siteByName.get(label);
+ return site ? { kind: 'site', site, host } : { kind: 'unknown', host };
+ }
+ if (!isHostname(host)) return { kind: 'unknown', host };
+ const site = await 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 };
+ }
+
+ async function send(req, res, site, row, { status = 200, cache, deployId = site.active_deploy_id }) {
+ const etag = `"${row.sha256}"`;
+ res.setHeader('ETag', etag);
+ res.setHeader('Content-Type', row.content_type);
+ res.setHeader('Cache-Control', cache);
+ res.setHeader('Accept-Ranges', 'bytes');
+ res.setHeader('X-OpenVibe-Deploy', deployId);
+ const inm = req.headers['if-none-match'];
+ if (status === 200 && inm && inm.split(',').map((s) => s.trim().replace(/^W\//, '')).includes(etag)) {
+ res.statusCode = 304;
+ 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;
+ }
+ const length = row.size === 0 ? 0 : end - start + 1;
+ if (req.method === 'HEAD' || row.size === 0) { res.setHeader('Content-Length', length); return res.end(); }
+ // The local cache, or (Media store) the object fetched from Media, its sha256 verified, and
+ // cached. A miss that cannot be satisfied is a 500: bytes of the wrong content are never served.
+ let file;
+ try {
+ file = await blobs.ensure(row.project_id, row.sha256);
+ } catch (err) {
+ log.error('[Space] object missing or unreadable:', row.project_id, row.sha256, err.code || err.message);
+ if (!res.headersSent) { res.removeHeader('Content-Range'); return plain(res, 500, 'This file could not be read. Try again later.'); }
+ return res.destroy();
+ }
+ res.setHeader('Content-Length', length);
+ const stream = fs.createReadStream(file, { start, end });
+ stream.on('error', (err) => {
+ log.error('[Space] 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);
+ }
+
+ /** The absolute origin for the host this request arrived on (tenant hosts are https in production). */
+ function originOf(req) {
+ const host = normaliseHost(req.headers.host);
+ if (!host) return null;
+ let proto = 'https';
+ try { proto = new URL(config.baseUrl).protocol.replace(':', '') || 'https'; } catch { /* default */ }
+ return `${proto}://${host}`;
+ }
+
+ /**
+ * A manifest HTML path as the absolute-path URL a crawler should use: the error page is left out,
+ * `index.html` becomes the directory URL, and everything else keeps its manifest path.
+ */
+ function pagePath(p) {
+ if (!/\.html?$/i.test(p)) return null;
+ if (/(^|\/)404\.html?$/i.test(p)) return null;
+ const stem = p.replace(/\.html?$/i, '');
+ if (stem === 'index') return '/';
+ if (stem.endsWith('/index')) return `/${stem.slice(0, -'index'.length)}`;
+ return `/${p}`;
+ }
+
+ /**
+ * A sitemap the deploy did not ship: the manifest's HTML pages on the host this request came in on.
+ * Served per host (a sitemap may only list URLs of one host), so a verified custom domain gets its
+ * own copy under its own name.
+ */
+ async function generatedSitemap(req, res, site) {
+ const origin = originOf(req);
+ if (!origin) return await notFound(req, res, site, 'invalid');
+ const locs = [];
+ const seen = new Set();
+ for (const row of await q.htmlFiles.all(site.active_deploy_id)) {
+ const p = pagePath(row.path);
+ if (!p) continue;
+ const loc = `${origin}${p}`;
+ if (seen.has(loc)) continue;
+ seen.add(loc);
+ locs.push({ loc });
+ }
+ const body = sitemapXml(locs);
+ res.statusCode = 200;
+ res.setHeader('Content-Type', 'application/xml; charset=utf-8');
+ res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
+ res.setHeader('Content-Length', Buffer.byteLength(body));
+ return req.method === 'HEAD' ? res.end() : res.end(body);
+ }
+
+ /** A robots.txt the deploy did not ship: welcome crawlers to a production site, keep a sandbox out. */
+ async function generatedRobots(req, res, site) {
+ const origin = originOf(req);
+ if (!origin) return await notFound(req, res, site, 'invalid');
+ const body = site.environment === 'sandbox'
+ ? robotsTxt({ disallow: ['/'], allow: [], allowAI: false })
+ : robotsTxt({ sitemaps: [`${origin}/sitemap.xml`] });
+ res.statusCode = 200;
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
+ res.setHeader('Cache-Control', 'public, max-age=3600');
+ res.setHeader('Content-Length', Buffer.byteLength(body));
+ return req.method === 'HEAD' ? res.end() : res.end(body);
+ }
+
+ async function notFound(req, res, site, reason) {
+ const page = site.active_deploy_id ? await q.file.get(site.active_deploy_id, '404.html') : null;
+ if (page && page.project_id === site.project_id) return send(req, res, site, page, { status: 404, cache: 'no-cache' });
+ 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.Space</small></body></html>`;
+ if (req.method === 'HEAD') return res.end();
+ return res.end(body);
+ }
+
+ /** An extensionless path (`/app/route`, `/a-b-c`, `/`) — the only kind an SPA fallback rewrites. */
+ function isExtensionlessPath(rawPath) {
+ if (rawPath === '/' || rawPath === '') return true;
+ const p = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath;
+ const last = p.slice(p.lastIndexOf('/') + 1);
+ return !last.includes('.');
+ }
+
+ /** Serves one request for a resolved site. */
+ async function handle(req, res, site) {
+ const cfg = siteConfig ? await siteConfig.getForSite(site.id) : null;
+ // The site's own headers go on first; baseHeaders then overrides every reserved one, so a
+ // tenant can never weaken CSP/HSTS/nosniff or set a cookie.
+ if (cfg) applyConfiguredHeaders(res, cfg.headers);
+ baseHeaders(res, { sandbox: site.environment === 'sandbox' });
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
+ return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });
+ }
+ // Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.
+ if (takedowns && await takedowns.ofSite(site)) {
+ // Clear-Site-Data asks the browser to drop what the site left behind for this visitor
+ // (caches, storage and service workers a phishing page may have installed).
+ return plain(res, 451, 'This site is unavailable: OpenVibe.Space staff took it down after a report.', { 'Clear-Site-Data': '"cache", "storage"' });
+ }
+ // 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) : '';
+ // A configured redirect: an exact path to a local path (validated on write, never an open redirect).
+ // It does not need an active deploy, and it is checked before anything is read from one.
+ if (cfg && cfg.redirects.length) {
+ const r = cfg.redirects.find((x) => x.from === rawPath);
+ if (r) {
+ res.statusCode = r.status;
+ res.setHeader('Location', r.to);
+ res.setHeader('Cache-Control', 'no-cache');
+ return res.end();
+ }
+ }
+ if (!site.active_deploy_id) return await notFound(req, res, site, 'no-deploy');
+ const c = candidates(rawPath);
+ if (!c) return await notFound(req, res, site, 'invalid');
+ const deployId = site.active_deploy_id;
+ for (const p of c.list) {
+ const row = await q.file.get(deployId, p);
+ if (row && row.project_id === site.project_id) {
+ const hashed = isHashedAsset(p);
+ // Browsers keep fingerprinted assets for a year; a shared CDN in front of the tenant
+ // vhost (Cloudflare) keeps them for an hour at most, so a takedown, deletion or
+ // rollback leaves the edge within the hour even if nobody purges it.
+ if (hashed) res.setHeader('CDN-Cache-Control', 'public, max-age=3600');
+ return send(req, res, site, row, { cache: hashed ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate' });
+ }
+ }
+ if (!c.dir && c.dirIndex && await 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();
+ }
+ // The deploy ships no sitemap.xml / robots.txt of its own: generate them (the tenant's own file,
+ // looked up above, always wins). IndexNow's ping of /sitemap.xml now has a real target.
+ if (rawPath === '/sitemap.xml') return await generatedSitemap(req, res, site);
+ if (rawPath === '/robots.txt') return await generatedRobots(req, res, site);
+ // SPA fallback: an extensionless path with no file of its own serves this site's index.html
+ // (a client-side route). A missing file with an extension is still a 404.
+ if (cfg && cfg.spa && isExtensionlessPath(rawPath)) {
+ const row = await q.file.get(deployId, 'index.html');
+ if (row && row.project_id === site.project_id) return send(req, res, site, row, { cache: 'public, max-age=0, must-revalidate' });
+ }
+ return await notFound(req, res, site, 'missing');
+ }
+
+ function unknownHost(req, res) {
+ return plain(res, 404, 'Unknown host: no site is served at this address.');
+ }
+
+ /**
+ * GET/HEAD /preview/<deploy-id>/<path> — one deploy of a site, served ONLY to a member of its
+ * project and ONLY on the dashboard host (the dispatch in server/app.js never sends a tenant host
+ * here, so a preview can never appear at <site>.<sitesDomain>). It serves exactly the deploy the
+ * site currently points at as its live preview (space_sites.preview_deploy_id) while that preview
+ * has not expired; once it expires, the site deploys or rolls back, or the preview is deleted, the
+ * address is a plain 404. Every response is noindex, no-store and never announced to a search
+ * engine, and the bytes come from the deploy's own project and manifest — never from the URL —
+ * exactly as in handle().
+ */
+ async function preview(req, res) {
+ // Never the dashboard's own CSP: the preview is sandboxed into an opaque origin (see previewCsp).
+ res.setHeader('Content-Security-Policy', PREVIEW_CSP);
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
+ res.setHeader('Cache-Control', 'private, no-store');
+ const miss = () => plain(res, 404, 'No preview is served at this address.');
+ const viewer = req.viewer;
+ if (!access || !viewer || viewer.kind === 'anonymous') return miss();
+ if (req.method !== 'GET' && req.method !== 'HEAD') return plain(res, 405, 'Static preview: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });
+ const raw = req.originalUrl || req.url || '';
+ const cut = raw.indexOf('?');
+ const m = /^\/preview\/([^/?#]+)(\/[^?#]*)?$/.exec(cut >= 0 ? raw.slice(0, cut) : raw);
+ if (!m || !isId('deploy', m[1])) return miss();
+ const deploy = await q.deployById.get(m[1]);
+ if (!deploy || deploy.state !== 'ready') return miss();
+ const site = await q.siteById.get(deploy.site_id);
+ if (!site || site.status !== 'active' || site.project_id !== deploy.project_id) return miss();
+ // Only the site's ONE live preview is served, and only until it expires.
+ if (site.preview_deploy_id !== deploy.id || !site.preview_expires_at || site.preview_expires_at <= store.now()) return miss();
+ const project = await q.projectById.get(deploy.project_id);
+ try { await access.authorize(project, viewer, 'read'); } catch { return miss(); }
+ if (takedowns && await takedowns.ofSite(site)) {
+ return plain(res, 451, 'This site is unavailable: OpenVibe.Space staff took it down after a report.', { 'Clear-Site-Data': '"cache", "storage"' });
+ }
+ const c = candidates(m[2] || '/');
+ if (!c) return miss();
+ for (const p of c.list) {
+ const row = await q.file.get(deploy.id, p);
+ if (row && row.project_id === deploy.project_id) {
+ return send(req, res, site, row, { cache: 'private, no-store, max-age=0', deployId: deploy.id });
+ }
+ }
+ return miss();
+ }
+
+ return { resolve, handle, preview, unknownHost, normaliseHost, candidates, TENANT_CSP };
+}
+
+module.exports = { createTenantServer, normaliseHost, TENANT_CSP };
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
+'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)
+ * git deploys (POST /sites/:id/source/deploys) also: ref=<branch>, commit_sha=<full commit id>
+ *
+ * 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', 'preview', 'mode', 'root', 'strip', 'csrf', 'expected_active', 'ref', 'commit_sha']);
+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, maxUnpackedBytes = Infinity, 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 = await readArchive(rawArchive, { ...limits, maxTotalBytes: Math.min(limits.maxTotalBytes, maxUnpackedBytes) }); } 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 };
+}
+
+/**
+ * At most `max` uploads are read and validated at once, service-wide. An upload is held in memory
+ * (up to SPACE_MAX_UPLOAD_BYTES, plus up to SPACE_MAX_UNPACKED_BYTES once unpacked), so without a cap
+ * a handful of accounts uploading together could exhaust the host's memory. enter() -> release()
+ * or null when every slot is taken (the caller answers 503 with Retry-After before reading a byte).
+ */
+function createUploadGate(max) {
+ const limit = Math.max(1, Number(max) || 1);
+ let inFlight = 0;
+ return {
+ enter() {
+ if (inFlight >= limit) return null;
+ inFlight++;
+ let released = false;
+ return () => { if (!released) { released = true; inFlight--; } };
+ },
+ get inFlight() { return inFlight; },
+ limit,
+ };
+}
+
+module.exports = { readUpload, UploadError, createUploadGate };
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)
+ * sit_ site dpl_ deploy dom_ domain
+ */
+const { ids } = require('openvibe-contracts');
+
+const PREFIX = { project: 'prj', site: 'sit', 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 };
@@ -8,15 +8,16 @@ const config = require('./config');
@@ -8,15 +8,16 @@ const config = require('./config');
const { createApp } = require('./app');
const { createApp } = require('./app');
(async () => {
(async () => {
- // PostgreSQL first (migrations run as the owner). Space holds no user content yet, but the
- // database carries the shared schema and the readiness check reports it.
+ // PostgreSQL first (migrations run as the owner); readiness reports the serving database.
await require('./db').initDb(config);
await require('./db').initDb(config);
const app = await createApp();
const app = await createApp();
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
console.log(`[Space] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
console.log(`[Space] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
- console.log(`[Space] code and dynamic pages (nothing published here yet); the forum is on ${config.communityUrl}; identity via ${config.networkUrl}`);
+ console.log(`[Space] static hosting for staff; dynamic pages and spaces planned; the forum is on ${config.communityUrl}; identity via ${config.networkUrl}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
+ app.locals.hosting.worker.start();
+ app.locals.hosting.outbox.start();
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// SIGTERM: the server stops taking connections, closes idle keep-alive ones (it keeps them 65 s
// SIGTERM: the server stops taking connections, closes idle keep-alive ones (it keeps them 65 s
@@ -26,7 +27,7 @@ const { createApp } = require('./app');
@@ -26,7 +27,7 @@ const { createApp } = require('./app');
gracefulStop({
gracefulStop({
name: 'Space', server,
name: 'Space', server,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
- stop: [],
+ stop: [async () => { app.locals.hosting.worker.stop(); await app.locals.hosting.outbox.stop(); }],
close: [
close: [
async () => {
async () => {
console.log('[Space] stopped');
console.log('[Space] stopped');
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+/**
+ * GET /limits.json (roadmap WS-N task 7): the limits a developer project meets here, read from the
+ * running configuration, so OpenVibe.Codes' limits page (openvibe.codes/docs/limits) shows what is
+ * enforced and never restates it. Public: these are the defaults; a project's own override (a
+ * space_quotas row staff set) is on its quota page, not here.
+ *
+ * limits[] id, label, capability (the grant it bounds), unit (count | bytes | per_day),
+ * production and sandbox (the project's environment; 0 means none allowed),
+ * exceeded (what the caller gets past it)
+ */
+function limitsOf(config) {
+ const p = config.quotas.production;
+ const s = config.quotas.sandbox;
+ const row = (id, label, key, unit, exceeded) => ({ id, label, capability: 'space.site.manage', unit, production: p[key], sandbox: s[key], exceeded });
+ return {
+ service: 'space',
+ scope: 'per project and environment; projects per owner',
+ limits: [
+ { id: 'projects_per_owner', label: 'Projects one person owns', capability: 'space.site.manage', unit: 'count',
+ production: config.projects.maxPerOwner, sandbox: config.projects.maxPerOwner, exceeded: '429 quota.projects' },
+ row('sites', 'Sites', 'sites', 'count', '429 quota.sites'),
+ row('custom_domains', 'Custom domains', 'customDomains', 'count', '429 quota.custom_domains'),
+ row('deploys_per_day', 'Deploys in 24 hours', 'deploysPerDay', 'per_day', '429 quota.deploys_per_day'),
+ row('storage_bytes', 'Stored bytes (every deploy kept)', 'storageBytes', 'bytes', '413 quota.storage'),
+ row('max_files', 'Files in one deploy', 'maxFiles', 'count', '413 quota.max_files'),
+ row('max_file_bytes', 'Size of one file', 'maxFileBytes', 'bytes', '413 quota.max_file_bytes'),
+ ],
+ };
+}
+
+function mountLimits(app, config) {
+ const body = limitsOf(config);
+ app.get('/limits.json', (_req, res) => {
+ res.set('Cache-Control', 'public, max-age=300').set('Access-Control-Allow-Origin', '*').json(body);
+ });
+}
+
+module.exports = { limitsOf, mountLimits };
@@ -6,12 +6,17 @@
@@ -6,12 +6,17 @@
* network_jwks optional the Network signing key has loaded. Without it the home page and the
* network_jwks optional the Network signing key has loaded. Without it the home page and the
* discovery files still work, but nobody can sign in, so it degrades
* discovery files still work, but nobody can sign in, so it degrades
* rather than fails
* rather than fails
+ * storage required the hosting object store directory is writable (uploads and serving need it)
+ * disk_headroom optional free space above SPACE_MIN_FREE_BYTES; below it only new deploys are refused
+ * valkey optional per-actor limits are shared across processes
+ * 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
*
*
* Request metrics come from openvibe-shared/metrics in app.js.
* Request metrics come from openvibe-shared/metrics in app.js.
*/
*/
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
-function createSpaceReadiness({ db, auth, config, release = null } = {}) {
+function createSpaceReadiness({ db, auth, config, release = null, hosting = null } = {}) {
const checks = [
const checks = [
{
{
name: 'db', required: true,
name: 'db', required: true,
@@ -32,7 +37,41 @@ function createSpaceReadiness({ db, auth, config, release = null } = {}) {
@@ -32,7 +37,41 @@ function createSpaceReadiness({ db, auth, config, release = null } = {}) {
},
},
},
},
];
];
+ if (hosting) checks.push(...hostingChecks(hosting));
return createReadiness({ service: 'space', release, checks });
return createReadiness({ service: 'space', release, checks });
}
}
+/** The hosting engine's checks (moved from OpenVibe.Host with the engine, T20). */
+function hostingChecks({ blobs, outbox, store, config, valkey }) {
+ return [
+ { name: 'storage', required: true, check: () => blobs.writable() },
+ {
+ // Serving goes on; only new deploys are refused below the floor.
+ name: 'disk_headroom', required: false,
+ check: () => {
+ const free = blobs.freeBytes();
+ const floor = config.uploads.minFreeBytes;
+ return free >= floor ? { ok: true, detail: { free_bytes: free } } : `${free} bytes free, below SPACE_MIN_FREE_BYTES (${floor}): new deploys are refused`;
+ },
+ },
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
+ {
+ name: 'events_relay', required: false,
+ check: async () => {
+ const s = await 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: async () => {
+ const stale = (await store.db.prepare("SELECT COUNT(*) AS n FROM space_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 = { createSpaceReadiness };
module.exports = { createSpaceReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,205 @@
@@ -0,0 +1,205 @@
+'use strict';
+/**
+ * Host's authority resource index (ADR-048 section 3; capability space.resource.read):
+ *
+ * GET /api/v1/resources[?project=&kind=&owner=&cursor=&limit=] → common.resource-list-result@1
+ * GET /api/v1/resources/:ovrn → common.resource-summary@1
+ *
+ * It pages the resources OpenVibe.Space owns — its tenant sites (sit_), the immutable deploys of those
+ * sites (dpl_) and their default and custom domains (dom_), each from its own table — as
+ * common.resource-summary@1, the shape OpenVibe.Services fans out over and merges
+ * (openvibe-sdk/resources' createResourceIndex). It is the same shape and the same path
+ * OpenVibe.Network's index answers (server/registry/resource-index.js there).
+ *
+ * Host lists no projects: Host mints its own prj_ ids until it adopts Network projects (ADR-014), and
+ * only Network lists projects (ADR-048, step 8). Every summary still carries the project_id of the
+ * project that owns it, which is the tenancy boundary.
+ *
+ * Tenancy: `?project=prj_…` is the caller's tenancy boundary. With it, only that project's resources
+ * answer — a resource of another project is never returned. Without it the first-party caller (the
+ * capability is first-party, resourceConstraints none) sees every Host-owned resource, which is what an
+ * authority-wide fan-out needs.
+ *
+ * OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one
+ * formatter, so it is present exactly when the resource is a nameable resource. All three Host kinds
+ * are: sit_, dpl_ and dom_ carry a three-letter prefix and the resource carries a project, so every
+ * summary is named ovrn:host:<prj_…>:<type>/<id> — and that is also what GET /api/v1/resources/:ovrn
+ * can read: only a resource whose computed ovrn equals the one asked for answers.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+// Host's JSON handler wrapper: a rejected handler reaches the app's error handler, and these handlers
+// write their own response. Express 4 does not catch a rejected async handler.
+const { run } = require('../http/errors');
+
+const SERVICE = 'space';
+const SITE_KIND = 'space.site';
+const DEPLOY_KIND = 'space.deploy';
+const DOMAIN_KIND = 'space.domain';
+const KINDS = [SITE_KIND, DEPLOY_KIND, DOMAIN_KIND];
+const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const OWNER_ID_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
+const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DEFAULT_LIMIT = 100;
+const MAX_LIMIT = 1000;
+
+/** A subject-ref {type:'user', id} for a stored subject, or null when it is not a usr_ id. */
+const userRef = (subject) => (USER_SUBJECT_RE.test(String(subject || '')) ? { type: 'user', id: subject } : null);
+
+/** common.resource-summary@1 for a space_sites row. A site is named by the label it is served at. */
+function siteSummary(s) {
+ return {
+ id: s.id, kind: SITE_KIND, service: SERVICE, project_id: s.project_id,
+ ...(userRef(s.created_by) ? { owner: userRef(s.created_by) } : {}),
+ name: s.name, state: s.status, created_at: new Date(Number(s.created_at)).toISOString(),
+ };
+}
+
+/** common.resource-summary@1 for a space_deploys row. A deploy has no name of its own. */
+function deploySummary(d) {
+ return {
+ id: d.id, kind: DEPLOY_KIND, service: SERVICE, project_id: d.project_id,
+ ...(userRef(d.created_by) ? { owner: userRef(d.created_by) } : {}),
+ state: d.state, created_at: new Date(Number(d.created_at)).toISOString(),
+ };
+}
+
+/** common.resource-summary@1 for a space_domains row: a domain's name is the hostname it answers on. */
+function domainSummary(d) {
+ return {
+ id: d.id, kind: DOMAIN_KIND, service: SERVICE, project_id: d.project_id,
+ ...(userRef(d.created_by) ? { owner: userRef(d.created_by) } : {}),
+ name: d.hostname, state: d.status, created_at: new Date(Number(d.created_at)).toISOString(),
+ };
+}
+
+/** The summary's ovrn, or null when it has none: contracts.resources.nameOf is the one formatter. */
+function ovrnOf(summary) {
+ return contracts.resources.nameOf(summary);
+}
+
+/** The summary with its ovrn attached when it has one. */
+function named(summary) {
+ const ovrn = ovrnOf(summary);
+ return ovrn ? { ...summary, ovrn } : summary;
+}
+
+/** A stable (kind, id) ordering, so a cursor can be a position in it. */
+const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);
+
+/** The summaries matching the project, kind and owner filters, sorted by (kind, id). */
+// Scope: space.resource.read is first-party (resourceConstraints none), so its holder sees every project and
+// ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that
+// principal's grants here instead of trusting the query.
+async function collect(db, { project = null, kind = null, owner = null } = {}) {
+ // Only usr_ subjects become summary owners; an agt_ subject cannot match any Host summary.
+ if (owner && !USER_SUBJECT_RE.test(owner)) return [];
+ const clauses = [];
+ const values = [];
+ if (project) { clauses.push('project_id = ?'); values.push(project); }
+ if (owner) { clauses.push('created_by = ?'); values.push(owner); }
+ const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
+ const out = [];
+ if (!kind || kind === SITE_KIND) {
+ const rows = await db.prepare(`SELECT * FROM space_sites${where} ORDER BY id`).all(...values);
+ for (const r of rows) out.push(named(siteSummary(r)));
+ }
+ if (!kind || kind === DEPLOY_KIND) {
+ const rows = await db.prepare(`SELECT * FROM space_deploys${where} ORDER BY id`).all(...values);
+ for (const r of rows) out.push(named(deploySummary(r)));
+ }
+ if (!kind || kind === DOMAIN_KIND) {
+ const rows = await db.prepare(`SELECT * FROM space_domains${where} ORDER BY id`).all(...values);
+ for (const r of rows) out.push(named(domainSummary(r)));
+ }
+ return out.sort(order);
+}
+
+/** A cursor is an opaque base64url [kind, id] position; only one this index issued decodes to that. */
+const encodeCursor = (s) => Buffer.from(JSON.stringify([s.kind, s.id])).toString('base64url');
+function decodeCursor(raw) {
+ let v;
+ try { v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
+ return Array.isArray(v) && v.length === 2 && typeof v[0] === 'string' && typeof v[1] === 'string' ? v : null;
+}
+const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id > id);
+
+/** The query as filters, or { error } for a value that cannot be honoured. An unknown kind is kept (it matches nothing). */
+function filtersOf(query) {
+ const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
+ if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };
+ const owner = typeof query.owner === 'string' && query.owner !== '' ? query.owner : null;
+ if (query.owner != null && query.owner !== '' && (!owner || !OWNER_ID_RE.test(owner))) return { error: 'owner must be a usr_ or agt_ id' };
+ const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
+ let limit = DEFAULT_LIMIT;
+ if (typeof query.limit === 'string' && query.limit !== '') {
+ if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };
+ limit = Number(query.limit);
+ }
+ let cursor = null;
+ if (typeof query.cursor === 'string' && query.cursor !== '') {
+ cursor = decodeCursor(query.cursor);
+ if (!cursor) return { error: 'cursor is not one this index issued' };
+ }
+ return { project, kind, owner, limit, cursor };
+}
+
+/** The OVRN type segment → the table and summary it belongs to. */
+const OF_TYPE = {
+ site: { table: 'space_sites', summary: siteSummary },
+ deploy: { table: 'space_deploys', summary: deploySummary },
+ domain: { table: 'space_domains', summary: domainSummary },
+};
+
+function router({ guard }) {
+ const open = (res) => res.set('Cache-Control', 'private, max-age=60');
+ const bad = (res, detail) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail });
+ const unknown = (res, name) => contracts.http.sendProblem(res, 404, 'resources.unknown_resource', { detail: `no resource named ${name}` });
+
+ /**
+ * The resource index is read by first-party services for OpenVibe.Services' fan-out; a person holds
+ * no service token and the capability is first-party, so only a service token reaches the guard.
+ */
+ function firstParty(req, res, next) {
+ const v = req.viewer;
+ if (v && v.kind === 'service') return next();
+ if (!v || v.kind === 'anonymous') return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'the resource index is read by first-party services: present a service token for audience openvibe.space', ctx: req.ov });
+ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `${SERVICE}.resource.read is first-party and not granted to a person`, ctx: req.ov });
+ }
+
+ /** One common.resource-list-result@1 page: the filtered, sorted summaries from the cursor, then `limit` of them. */
+ async function page(req, res) {
+ const f = filtersOf(req.query);
+ if (f.error) return bad(res, f.error);
+ const all = await collect(req.app.locals.ctx.store.db, f);
+ const rest = f.cursor ? all.filter((s) => afterCursor(s, f.cursor)) : all;
+ const resources = rest.slice(0, f.limit);
+ const next_cursor = rest.length > f.limit ? encodeCursor(resources[resources.length - 1]) : null;
+ open(res).json({ resources, next_cursor });
+ }
+
+ /** GET /api/v1/resources/:ovrn: the summary whose computed ovrn is exactly the one asked for. */
+ async function one(req, res) {
+ const name = String(req.params.ovrn);
+ const parsed = contracts.resources.parse(name);
+ let summary = null;
+ if (parsed && parsed.service === SERVICE && OF_TYPE[parsed.type]) {
+ const db = req.app.locals.ctx.store.db;
+ const row = await db.prepare(`SELECT * FROM ${OF_TYPE[parsed.type].table} WHERE id = ?`).get(parsed.id);
+ if (row) summary = named(OF_TYPE[parsed.type].summary(row));
+ }
+ if (!summary || summary.ovrn !== name) return unknown(res, name);
+ open(res).json(summary);
+ }
+
+ const r = express.Router();
+ r.use(firstParty);
+ r.get('/', guard, run(page));
+ r.get('/:ovrn', guard, run(one));
+ return r;
+}
+
+module.exports = {
+ router, SERVICE, KINDS, SITE_KIND, DEPLOY_KIND, DOMAIN_KIND, DEFAULT_LIMIT, MAX_LIMIT,
+ siteSummary, deploySummary, domainSummary, ovrnOf, collect, encodeCursor, filtersOf,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+'use strict';
+const { renderPage: spacePage, escapeHtml: esc } = require('./layout');
+function renderPage(o) {
+ const notice = o.notice ? `<p role="status" class="hd-flash hd-flash--${o.notice.kind === 'error' ? 'bad' : 'ok'}">${esc(o.notice.text)}</p>` : '';
+ return spacePage({ title: o.title || 'Hosting', canonicalPath: o.path || '/dashboard', robots: o.indexable ? 'index,follow' : 'noindex,nofollow', body: `${notice}${o.body || ''}`, styles: o.styles || [], localStyles: ['css/hosting.css'], bodyClass: 'hosting-dashboard' });
+}
+module.exports = { renderPage, esc };
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+'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. Styles: public/css/hosting.css (hd-*), loaded
+ * only on these pages.
+ */
+const { esc } = require('./hosting-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));
+const icon = (name) => `<i class="fa-solid ${name}" aria-hidden="true"></i>`;
+const badge = (text, tone = 'neutral') => `<span class="hd-badge hd-badge--${tone}">${esc(text)}</span>`;
+const STATE_TONE = { ready: 'ok', failed: 'bad', receiving: 'warn', processing: 'warn' };
+
+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="hd-confirm"><input type="checkbox" name="confirm" value="yes" required> ${esc(confirm)}</label>` : ''}</form>`;
+}
+
+function crumbs(list) {
+ return `<nav class="hd-crumbs" aria-label="Breadcrumb">${list.map(([href, label]) => `<a href="${esc(href)}">${esc(label)}</a>`).join('<span aria-hidden="true">›</span>')}</nav>`;
+}
+
+function card(title, inner, { sub = '', actions = '', cls = '', id = '' } = {}) {
+ return `<section class="hd-card${cls ? ` ${cls}` : ''}"${id ? ` id="${esc(id)}"` : ''}>`
+ + `<header class="hd-card__head"><div><h2>${title}</h2>${sub ? `<p class="hd-muted">${sub}</p>` : ''}</div>${actions ? `<div class="hd-card__actions">${actions}</div>` : ''}</header>`
+ + `${inner}</section>`;
+}
+
+function home({ projects, csrf, canCreate = true }) {
+ const cards = projects.map((p) => `<a class="hd-tile" href="/projects/${esc(p.id)}">
+<span class="hd-tile__icon">${icon('fa-folder-open')}</span>
+<span class="hd-tile__body"><strong>${esc(p.name)}</strong><span class="hd-tile__meta">${badge(p.environment, p.environment === 'sandbox' ? 'warn' : 'accent')}${p.role ? ` ${badge(p.role)}` : ''}</span><span class="hd-muted">Created ${when(p.created_at)}</span></span>
+<span class="hd-tile__go" aria-hidden="true">${icon('fa-arrow-right')}</span></a>`).join('');
+ const create = !canCreate
+ ? `<div class="hd-callout">${icon('fa-lock')}<p>OpenVibe.Space is not open for hosting yet. It opens after a launch review (abuse handling and a designated DMCA agent first); until then only OpenVibe staff can create projects.</p></div>`
+ : form('/projects', csrf, `<div class="hd-fields">
+<label class="hd-field">Name <input name="name" required maxlength="80" placeholder="My website"></label>
+<label class="hd-field">Environment <select name="environment"><option value="production">production</option><option value="sandbox">sandbox (smaller quotas, noindex, no custom domains)</option></select></label>
+</div><button type="submit" class="hd-btn hd-btn--primary">${icon('fa-plus')} Create project</button>`, { cls: 'hd-form' });
+ return `<header class="hd-head"><p class="hd-eyebrow">${icon('fa-globe')} Static hosting</p><h1>Your projects</h1>
+<p class="hd-sub">A project holds sites, their deploys and domains, and the people who may change them.</p></header>
+${projects.length ? `<div class="hd-tiles">${cards}</div>` : `<div class="hd-empty">${icon('fa-folder-plus')}<p>You have no projects yet.</p></div>`}
+${card('New project', create, { sub: canCreate ? 'Production sites can take custom domains; a sandbox is for trying things and is never indexed.' : '' })}`;
+}
+
+function meter(label, used, limit, fmt = (x) => esc(x)) {
+ const pct = limit > 0 ? Math.min(100, Math.round((Number(used) / Number(limit)) * 100)) : 0;
+ const tone = pct >= 90 ? 'bad' : pct >= 70 ? 'warn' : 'ok';
+ return `<div class="hd-meter"><div class="hd-meter__label"><span>${label}</span><span><strong>${fmt(used)}</strong> of ${fmt(limit)}</span></div>`
+ + `<div class="hd-meter__bar hd-meter__bar--${tone}" role="img" aria-label="${pct}% used"><span style="width:${pct}%"></span></div></div>`;
+}
+
+function quotaTable(quota, usage) {
+ return `<div class="hd-meters">
+${meter('Storage', usage.storageBytes, quota.storageBytes, bytes)}
+${meter('Deploys in the last 24 h', usage.deploysLast24h, quota.deploysPerDay)}
+${meter('Sites', usage.sites, quota.sites)}
+${meter('Custom domains', usage.customDomains, quota.customDomains)}
+</div>
+<dl class="hd-facts"><div><dt>Files per deploy</dt><dd>${esc(Number(quota.maxFiles).toLocaleString('en-US'))}</dd></div><div><dt>Largest file</dt><dd>${bytes(quota.maxFileBytes)}</dd></div></dl>`;
+}
+
+function project({ project: p, role, sites, quota, usage, members, csrf, siteUrl }) {
+ const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';
+ const siteRows = sites.map((s) => `<li class="hd-row">
+<span class="hd-row__main"><a class="hd-row__title" href="/sites/${esc(s.id)}">${esc(s.name)}</a><a class="hd-url" href="${esc(siteUrl(s))}" rel="noopener">${esc(siteUrl(s).replace(/^https?:\/\//, ''))} ${icon('fa-arrow-up-right-from-square')}</a></span>
+<span class="hd-row__side">${s.active_deploy_id ? `${badge('live', 'ok')} <code>${short(s.active_deploy_id)}</code>` : badge('nothing published')}</span></li>`).join('');
+ const memberRows = members.map((m) => `<tr><td><code>${esc(m.principal)}</code></td><td>${badge(m.role, m.role === 'owner' ? 'accent' : 'neutral')}</td><td class="hd-actions">${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="hd-link hd-link--danger">Remove</button>`, { cls: 'hd-inline' }) : ''}</td></tr>`).join('');
+ return `<header class="hd-head">${crumbs([['/dashboard', 'Projects']])}
+<h1>${esc(p.name)}</h1>
+<p class="hd-chips">${badge(p.environment, p.environment === 'sandbox' ? 'warn' : 'accent')} ${badge(`your role: ${role}`)} <code>${esc(p.id)}</code>${p.network_project_id ? ` <span class="hd-muted">Network project <code>${esc(p.network_project_id)}</code></span>` : ''}</p></header>
+<div class="hd-split">
+<div class="hd-split__main">
+${card('Sites', `${sites.length ? `<ul class="hd-rows">${siteRows}</ul>` : `<div class="hd-empty hd-empty--small">${icon('fa-globe')}<p>No sites yet.</p></div>`}
+${canMaintain ? form(`/projects/${p.id}/sites`, csrf, `<label class="hd-field">New site name <input name="name" required pattern="[a-z0-9-]{3,40}" maxlength="40" placeholder="my-site"></label><button type="submit" class="hd-btn hd-btn--primary">${icon('fa-plus')} Create site</button>`, { cls: 'hd-form hd-form--row' }) : ''}`, { sub: 'Each site is served at its own address under openvibe.website.' })}
+${card('Members', `<div class="hd-table-wrap"><table class="hd-table"><thead><tr><th>Principal</th><th>Role</th><th></th></tr></thead><tbody>${memberRows}</tbody></table></div>
+${role === 'owner' ? form(`/projects/${p.id}/members`, csrf, `<label class="hd-field">Principal <input name="principal" required placeholder="usr_… or app:app_…"></label>
+<label class="hd-field">Role <select name="role"><option value="deployer">deployer</option><option value="maintainer">maintainer</option></select></label><button type="submit" class="hd-btn">Add or change</button>`, { cls: 'hd-form hd-form--row' }) : ''}`, { sub: 'Deployers upload and activate; maintainers also manage sites, domains and configuration.' })}
+</div>
+<aside class="hd-split__side">
+${card('Quotas', quotaTable(quota, usage))}
+</aside>
+</div>
+${role === 'owner' || role === 'staff' ? card('Delete project', form(`/projects/${p.id}/delete`, csrf, '<button type="submit" class="hd-btn hd-btn--danger">Delete this project, its sites and every deploy</button>', { cls: 'hd-form', confirm: 'I understand every site stops at once' }), { cls: 'hd-card--danger', sub: 'Every site in it stops being served at once, and its deploys are deleted.' }) : ''}`;
+}
+
+function domainBlock(d, instructions, csrf) {
+ const status = d.status === 'verified' ? badge('verified · served', 'ok') : badge(`${d.status} · not served`, 'warn');
+ let body = `<li class="hd-domain"><div class="hd-domain__head"><strong>${esc(d.hostname)}</strong> ${badge(d.kind)} ${status}</div>${d.last_error ? `<p class="hd-muted">${esc(d.last_error)}</p>` : ''}`;
+ if (instructions) {
+ const rec = (r) => `<tr><td>${badge(r.type)}</td><td><code>${esc(r.name)}</code></td><td><code class="hd-wrap">${esc(r.value)}</code></td><td class="hd-muted">${esc(r.note)}</td></tr>`;
+ body += `<details class="hd-details"${d.status === 'verified' ? '' : ' open'}><summary>DNS records</summary><div class="hd-table-wrap"><table class="hd-table hd-table--dns"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>`
+ + rec(instructions.verification) + instructions.routing.map(rec).join('')
+ + `</tbody></table></div><p class="hd-muted">${esc(instructions.tls)}</p></details>`;
+ body += '<div class="hd-domain__actions">';
+ if (d.status !== 'verified') body += form(`/domains/${d.id}/verify`, csrf, `<button type="submit" class="hd-btn hd-btn--small">${icon('fa-rotate')} Check DNS now</button>`, { cls: 'hd-inline' });
+ body += form(`/domains/${d.id}/delete`, csrf, '<button type="submit" class="hd-link hd-link--danger">Remove</button>', { cls: 'hd-inline' });
+ body += '</div>';
+ }
+ return `${body}</li>`;
+}
+
+function site({ site: s, project: p, role, deploys, activations, domains, siteConfig, csrf, url, now = Date.now() }) {
+ const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';
+ const canDeploy = role !== 'staff';
+ // The site's one live preview (plan T12 J4): served to project members on this dashboard until it expires.
+ const previewId = s.preview_deploy_id && Number(s.preview_expires_at) > now ? s.preview_deploy_id : null;
+ const previewHref = (id) => `/preview/${encodeURIComponent(id)}/`;
+ const rows = deploys.map((d) => {
+ const active = d.id === s.active_deploy_id;
+ const previewing = d.id === previewId;
+ const actions = [];
+ if (previewing && d.state === 'ready') actions.push(`<a class="hd-btn hd-btn--small" href="${esc(previewHref(d.id))}" target="_blank" rel="noopener">${icon('fa-eye')} Open preview</a>`);
+ if (canDeploy && d.state === 'ready' && !active) actions.push(form(`/deploys/${d.id}/activate`, csrf, `<input type="hidden" name="expected_active" value="${esc(s.active_deploy_id || '')}"><button type="submit" class="hd-btn hd-btn--small hd-btn--primary">Activate</button>`, { cls: 'hd-inline' }));
+ if (canMaintain && !active) actions.push(form(`/deploys/${d.id}/delete`, csrf, '<button type="submit" class="hd-link hd-link--danger">Delete</button>', { cls: 'hd-inline' }));
+ return `<tr${active ? ' class="hd-active"' : ''}><td><a href="/deploys/${esc(d.id)}"><code>${short(d.id)}</code></a>${active ? ` ${badge('active', 'accent')}` : ''}${previewing ? ` ${badge('preview', 'warn')}` : ''}</td>`
+ + `<td>${badge(d.state, STATE_TONE[d.state] || 'neutral')}${d.failure_code ? ` <code>${esc(d.failure_code)}</code>` : ''}</td><td class="hd-num">${esc(d.file_count)}</td><td class="hd-num">${bytes(d.total_bytes)}</td><td>${when(d.created_at)}</td><td class="hd-actions">${actions.join(' ')}</td></tr>`;
+ }).join('');
+ const history = activations.slice(0, 10).map((a) => `<li>${when(a.created_at)} <span>${esc(a.kind)} → <code>${short(a.deploy_id)}</code>${a.previous_deploy_id ? ` <span class="hd-muted">(was <code>${short(a.previous_deploy_id)}</code>)</span>` : ''} <span class="hd-muted">by <code>${esc(a.actor)}</code></span></span></li>`).join('');
+ const status = s.active_deploy_id ? `${badge('live', 'ok')} <code>${short(s.active_deploy_id)}</code>` : badge('nothing published');
+ const rollback = 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" class="hd-btn hd-btn--small">${icon('fa-clock-rotate-left')} Roll back to the previous deploy</button>`, { cls: 'hd-inline' }) : '';
+ return `<header class="hd-head">${crumbs([['/dashboard', 'Projects'], [`/projects/${p.id}`, p.name]])}
+<h1>${esc(s.name)}</h1>
+<p class="hd-chips"><a class="hd-url hd-url--big" href="${esc(url)}" rel="noopener">${esc(url.replace(/^https?:\/\//, ''))} ${icon('fa-arrow-up-right-from-square')}</a> ${status} <code>${esc(s.id)}</code></p></header>
+${canDeploy ? card('Upload a deploy', form(`/sites/${s.id}/deploys`, csrf, `<div class="hd-sources">
+<fieldset class="hd-source"><legend>${icon('fa-folder-open')} A folder</legend><input type="file" name="files" multiple webkitdirectory><input type="hidden" name="strip" value="folder"></fieldset>
+<fieldset class="hd-source"><legend>${icon('fa-file-zipper')} …or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar"></fieldset>
+</div>
+<label class="hd-field hd-field--narrow">Deploy from subfolder (optional) <input name="root" placeholder="dist"></label>
+<fieldset class="hd-choices"><legend>When it is ready</legend>
+<label class="hd-choice"><input type="radio" name="mode" value="activate" checked><span><strong>Activate it</strong><span class="hd-muted">Live as soon as it is checked.</span></span></label>
+<label class="hd-choice"><input type="radio" name="mode" value="ready"><span><strong>Keep it ready</strong><span class="hd-muted">Activate later from the list below.</span></span></label>
+<label class="hd-choice"><input type="radio" name="mode" value="preview"><span><strong>Preview only</strong><span class="hd-muted">A private address for members of this project, for one hour.</span></span></label>
+</fieldset>
+<button type="submit" class="hd-btn hd-btn--primary">${icon('fa-cloud-arrow-up')} Upload</button>`, { cls: 'hd-form', enctype: 'multipart/form-data' }),
+ { sub: '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.' }) : ''}
+${card('Deploys', `${previewId ? `<p class="hd-notice">${icon('fa-eye')} A preview of <code>${short(previewId)}</code> is open to members of this project at <a href="${esc(previewHref(previewId))}" target="_blank" rel="noopener">${esc(previewHref(previewId))}</a> until ${when(Number(s.preview_expires_at))}. It is never indexed, and activating or rolling back ends it.</p>` : ''}
+${deploys.length ? `<div class="hd-table-wrap"><table class="hd-table"><thead><tr><th>Deploy</th><th>State</th><th class="hd-num">Files</th><th class="hd-num">Size</th><th>Uploaded</th><th></th></tr></thead><tbody>${rows}</tbody></table></div>` : `<div class="hd-empty hd-empty--small">${icon('fa-box-open')}<p>No deploys yet.</p></div>`}
+${history ? `<details class="hd-details"><summary>Activation history</summary><ul class="hd-history">${history}</ul></details>` : ''}`, { actions: rollback, sub: 'Every deploy is kept. Activating one switches the site at once; nothing is rebuilt.' })}
+${card('Domains', `<ul class="hd-domains">${domains.map((d) => domainBlock(d.domain, d.instructions, csrf)).join('')}</ul>
+${canMaintain && p.environment !== 'sandbox' ? form(`/sites/${s.id}/domains`, csrf, `<label class="hd-field">Custom domain <input name="hostname" required placeholder="www.example.org"></label><button type="submit" class="hd-btn">${icon('fa-plus')} Add domain</button>`, { cls: 'hd-form hd-form--row' }) : ''}`, { sub: 'A custom domain is served once its TXT record is found.' })}
+${canMaintain ? card('Per-site configuration', form(`/sites/${s.id}/config`, csrf, `<label class="hd-field">Headers <span class="hd-muted">one per line: <code>Name: value</code></span><textarea name="headers" rows="4" spellcheck="false" placeholder="X-Frame-Options: SAMEORIGIN">${esc(siteConfig.headersText)}</textarea></label>
+<label class="hd-field">Redirects <span class="hd-muted">one per line: <code>from -> to [status]</code></span><textarea name="redirects" rows="4" spellcheck="false" placeholder="/old -> /new 301">${esc(siteConfig.redirectsText)}</textarea></label>
+<label class="hd-confirm"><input type="checkbox" name="spa" value="1"${siteConfig.spa ? ' checked' : ''}> Serve index.html for extensionless paths (single-page app)</label>
+<button type="submit" class="hd-btn hd-btn--primary">Save configuration</button>`, { cls: 'hd-form' }),
+ { sub: 'Headers, redirects and an SPA fallback, applied to every response of this site. Platform headers (Content-Security-Policy, Strict-Transport-Security, Set-Cookie, X-Forwarded-*, caching) cannot be set here, and a redirect target must be a local path.' }) : ''}
+${canMaintain ? card('Delete site', form(`/sites/${s.id}/delete`, csrf, '<button type="submit" class="hd-btn hd-btn--danger">Delete this site</button>', { cls: 'hd-form', confirm: `${s.name} stops being served at once` }), { cls: 'hd-card--danger', sub: 'The site and its deploys are deleted; its address stops answering.' }) : ''}`;
+}
+
+function deploy({ deploy: d, site: s, project: p, files, log, active }) {
+ const LEVEL_TONE = { error: 'bad', warn: 'warn', info: 'neutral' };
+ const logLines = log.map((l) => `<li class="hd-log__line hd-log__line--${esc(l.level)}">${badge(l.level, LEVEL_TONE[l.level] || 'neutral')}<span>${esc(l.message)}</span></li>`).join('');
+ const fileRows = files.slice(0, 2000).map((f) => `<tr><td><code class="hd-wrap">${esc(f.path)}</code></td><td class="hd-muted">${esc(f.content_type)}</td><td class="hd-num">${bytes(f.size)}</td><td><code title="${esc(f.sha256)}">${esc(f.sha256.slice(0, 12))}</code></td></tr>`).join('');
+ return `<header class="hd-head">${crumbs([['/dashboard', 'Projects'], [`/projects/${p.id}`, p.name], [`/sites/${s.id}`, s.name]])}
+<h1>Deploy <code>${short(d.id)}</code></h1>
+<p class="hd-chips">${badge(d.state, STATE_TONE[d.state] || 'neutral')}${active ? ` ${badge('active', 'accent')}` : ''}</p>
+<dl class="hd-facts hd-facts--wide"><div><dt>Files</dt><dd>${esc(d.file_count)}</dd></div><div><dt>Size</dt><dd>${bytes(d.total_bytes)}</dd></div><div><dt>Uploaded</dt><dd>${when(d.created_at)}</dd></div><div><dt>By</dt><dd><code>${esc(d.created_by)}</code></dd></div>${d.manifest_sha256 ? `<div><dt>Manifest</dt><dd><code title="${esc(d.manifest_sha256)}">${esc(d.manifest_sha256.slice(0, 16))}</code></dd></div>` : ''}</dl></header>
+${card('Upload log', `<ul class="hd-log">${logLines}</ul>`)}
+${files.length ? card('Files', `<div class="hd-table-wrap"><table class="hd-table"><thead><tr><th>Path</th><th>Type</th><th class="hd-num">Size</th><th>sha256</th></tr></thead><tbody>${fileRows}</tbody></table></div>${files.length > 2000 ? `<p class="hd-muted">…and ${files.length - 2000} more.</p>` : ''}`, { sub: 'Stored once by content: a file that did not change since an earlier deploy of this project is not stored again.' }) : ''}`;
+}
+
+function errorPage({ status, message }) {
+ return `<div class="hd-error">${icon(status === 404 ? 'fa-circle-question' : status === 403 ? 'fa-lock' : status === 401 ? 'fa-right-to-bracket' : 'fa-triangle-exclamation')}
+<h1>${status === 404 ? 'Not found' : status === 403 ? 'Not allowed' : status === 401 ? 'Sign in first' : 'Something went wrong'}</h1><p>${esc(message)}</p><p><a class="hd-btn" href="/dashboard">Back to your projects</a></p></div>`;
+}
+
+module.exports = { home, project, site, deploy, errorPage, bytes };
@@ -21,7 +21,7 @@ function escapeHtml(s) {
@@ -21,7 +21,7 @@ function escapeHtml(s) {
const SITE_NAME = 'OpenVibe.Space';
const SITE_NAME = 'OpenVibe.Space';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
-const DEFAULT_DESCRIPTION = 'OpenVibe.Space will host code and dynamic pages, and "spaces": apps people build that use their OpenVibe account, the network\'s services and the SDK. The forum is on OpenVibe.Community.';
+const DEFAULT_DESCRIPTION = 'OpenVibe.Space hosts static sites for OpenVibe staff, with immutable deploys, rollback, previews, Git ingest and custom domains. Dynamic pages and spaces are planned.';
const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;
const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;
// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
@@ -66,6 +66,7 @@ function navbarInit(opts) {
@@ -66,6 +66,7 @@ function navbarInit(opts) {
// decision, 2026-10-08), so its link points there rather than at a /s that redirects.
// decision, 2026-10-08), so its link points there rather than at a /s that redirects.
links: [
links: [
{ label: 'Home', href: '/', active: opts.active === 'home' },
{ label: 'Home', href: '/', active: opts.active === 'home' },
+ { label: 'Hosting', href: '/dashboard' },
{ label: 'The forum', href: `${config.communityUrl}/s` },
{ label: 'The forum', href: `${config.communityUrl}/s` },
],
],
menu: { after: [{ label: 'OpenVibe.Network', href: NETWORK_URL, icon: 'fa-globe' }] },
menu: { after: [{ label: 'OpenVibe.Network', href: NETWORK_URL, icon: 'fa-globe' }] },
@@ -86,7 +87,7 @@ function footerInit(opts) {
@@ -86,7 +87,7 @@ function footerInit(opts) {
mount: '#ov-footer',
mount: '#ov-footer',
brandName: SITE_NAME,
brandName: SITE_NAME,
updates: `${config.communityUrl}/updates`, // this site ships with the community's log for now
updates: `${config.communityUrl}/updates`, // this site ships with the community's log for now
- tagline: 'Code and dynamic pages for the people of OpenVibe.',
+ tagline: 'Static hosting for the people of OpenVibe.',
legalBase: config.liveUrl,
legalBase: config.liveUrl,
links: [{
links: [{
heading: 'Space',
heading: 'Space',
@@ -107,6 +108,7 @@ function footerInit(opts) {
@@ -107,6 +108,7 @@ function footerInit(opts) {
* feeds ([{ title, href }] RSS alternates; default: none),
* feeds ([{ title, href }] RSS alternates; default: none),
* historyType, historyTitle, footerVariant ('full'|'compact'), bodyClass,
* historyType, historyTitle, footerVariant ('full'|'compact'), bodyClass,
* styles (Shared stylesheets by name, linked before space.css so this site's rules win),
* styles (Shared stylesheets by name, linked before space.css so this site's rules win),
+ * localStyles (this site's page-only stylesheets, e.g. 'css/hosting.css', linked after space.css),
* published/modified (ISO, for article:*), noFrame (error pages during outages)
* published/modified (ISO, for article:*), noFrame (error pages during outages)
*/
*/
function renderPage(o) {
function renderPage(o) {
@@ -120,7 +122,7 @@ function renderPage(o) {
@@ -120,7 +122,7 @@ function renderPage(o) {
// with the navbar init, the noscript nav and the SSR footer; the rest of the head is this site's own.
// with the navbar init, the noscript nav and the SSR footer; the rest of the head is this site's own.
const html = shell.page({
const html = shell.page({
name: SITE_NAME, service: 'space', lang: 'en',
name: SITE_NAME, service: 'space', lang: 'en',
- title: o.title || `${SITE_NAME} — code and dynamic pages`,
+ title: o.title || `${SITE_NAME} — static hosting`,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',
siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',
type: o.ogType || 'website', image: o.ogImage || DEFAULT_OG_IMAGE, imageAlt: o.imageAlt,
type: o.ogType || 'website', image: o.ogImage || DEFAULT_OG_IMAGE, imageAlt: o.imageAlt,
@@ -135,6 +137,8 @@ function renderPage(o) {
@@ -135,6 +137,8 @@ function renderPage(o) {
...feeds.map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`),
...feeds.map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`),
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),
`<link rel="stylesheet" href="${asset('css/space.css')}">`,
`<link rel="stylesheet" href="${asset('css/space.css')}">`,
+ // Page-only stylesheets of this site (the dashboard's hosting.css), after space.css; the home page loads none.
+ ...(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`),
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',
`<meta name="ov-boost" content="space@${escapeHtml(RELEASE)}">`,
`<meta name="ov-boost" content="space@${escapeHtml(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
@@ -4,8 +4,8 @@
@@ -4,8 +4,8 @@
* Page templates. Plain template strings, every value escaped, no framework. The markup is the whole
* Page templates. Plain template strings, every value escaped, no framework. The markup is the whole
* page for crawlers and no-JS readers; the shared Frame adds the navbar, footer and theme.
* page for crawlers and no-JS readers; the shared Frame adds the navbar, footer and theme.
*
*
- * Space publishes one page of its own while it is rebuilt: the home page, which says what Space will
- * host and points at the forum on OpenVibe.Community and the developer platform. It claims nothing Space does not have.
+ * The home page says what Space hosts today (static sites, staff-only until the public launch), how a deploy
+ * works, the API and the quotas the engine enforces, and what comes next. It claims nothing Space does not have.
*/
*/
const config = require('../config');
const config = require('../config');
const sc = require('openvibe-shared/showcase');
const sc = require('openvibe-shared/showcase');
@@ -15,37 +15,75 @@ const FORUM_URL = `${config.communityUrl}/s`;
@@ -15,37 +15,75 @@ const FORUM_URL = `${config.communityUrl}/s`;
const FORUM_LABEL = FORUM_URL.replace(/^https?:\/\//, '');
const FORUM_LABEL = FORUM_URL.replace(/^https?:\/\//, '');
const SERVICES_URL = 'https://openvibe.services/';
const SERVICES_URL = 'https://openvibe.services/';
-/** The home page: what Space will host, and where things are today. Built from the network's showcase sections
- * (openvibe-shared/showcase), so it reads like every other product home. It promises nothing as open: every card says
- * what Space WILL host, and the live links go to the forum and the developer platform. */
+const MiB = 1024 * 1024;
+const size = (n) => (n >= 1024 * MiB ? `${n / (1024 * MiB)} GB` : `${Math.round(n / MiB)} MB`);
+let quotas = null;
+/** The per-project quotas the engine enforces (server/hosting-config.js), read once. */
+function defaultQuotas() {
+ if (!quotas) quotas = require('../hosting-config').load().quotas;
+ return quotas;
+}
+
+/** Hosting is working for staff; dynamic pages and spaces are the next stage. */
function homePage() {
function homePage() {
+ const q = defaultQuotas();
+ const row = (label, key, fmt = String) => ({ label, values: [fmt(q.production[key]), fmt(q.sandbox[key])] });
const body = sc.hero({
const body = sc.hero({
- eyebrow: 'OpenVibe.Space · being rebuilt',
- title: 'Code and dynamic pages,',
- accent: 'on your OpenVibe account.',
- lede: 'OpenVibe.Space is being rebuilt to host static sites, dynamic pages and spaces: small apps people make that sign in with OpenVibe and use the network\'s services through the SDK. Nothing is hosted here yet.',
+ eyebrow: 'OpenVibe.Space · static hosting',
+ title: 'Publish a static site.',
+ accent: 'Keep every deploy.',
+ lede: 'Static site hosting is built and tested: immutable, content-addressed deploys, one-step rollback, previews, Git ingest and verified custom domains. It is open to OpenVibe staff only until public launch.',
actions: [
actions: [
- { label: 'The forum is on OpenVibe.Community', href: FORUM_URL, primary: true, icon: 'fa-comments' },
- { label: 'Build on OpenVibe today', href: SERVICES_URL, icon: 'fa-code' },
+ { label: 'Hosting dashboard', href: '/dashboard', primary: true, icon: 'fa-globe' },
+ { label: 'How it works', href: '#how', icon: 'fa-route' },
+ ],
+ note: 'Public hosting follows abuse handling and a designated DMCA agent. Until then only OpenVibe staff can create projects.',
+ }) + sc.steps({
+ id: 'how',
+ title: 'How a deploy works',
+ items: [
+ { title: 'Make a project and a site', text: 'A project holds sites and the people who may change them. Each site gets its own address under openvibe.website.' },
+ { title: 'Upload, or connect Git', text: 'Upload a folder or a .tar.gz from the dashboard or the API, or deploy from a public Git repository. Nothing is built or executed: the files are served as they are.' },
+ { title: 'Preview, then activate', text: 'Check a deploy at its private preview address, then make it live. Activation switches one pointer, so rolling back is one step too.' },
],
],
- note: `The forum moved to OpenVibe.Community (${FORUM_LABEL}); every old forum address redirects there.`,
}) + sc.features({
}) + sc.features({
- title: 'What Space will host',
- lede: 'Each part is composed from services the network already runs. None of it is open yet.',
+ title: 'What works now',
items: [
items: [
- { icon: 'fa-file-code', title: 'Static sites', text: 'Upload a folder or connect a Git repository. Every deploy is kept, and the active one rolls back in one step.' },
- { icon: 'fa-bolt', title: 'Dynamic pages', text: 'Server functions that run on OpenVibe.Run workers, with their files kept on OpenVibe.Media.' },
- { icon: 'fa-cubes', title: 'Spaces', text: 'Small apps that sign people in with their OpenVibe account and ask only for the grants they need.' },
- { icon: 'fa-code-branch', title: 'Templates and forks', text: 'Start from a template, or fork someone\'s space and make it yours.' },
- { icon: 'fa-wand-magic-sparkles', title: 'Build with an agent', text: 'Describe a space and an agent on OpenVibe.Actor and OpenVibe.Codes builds it, with every change shown.' },
- { icon: 'fa-globe', title: 'Your own address', text: 'Each site gets a name under openvibe.website; a custom domain is verified before it is served.' },
+ { icon: 'fa-cubes', title: 'Immutable deploys', text: 'Every deploy is content-addressed and kept: files are stored once by their hash, and a deploy never changes after upload.' },
+ { icon: 'fa-clock-rotate-left', title: 'Previews and rollback', text: 'A private preview for the project\'s members, one-step activation, and a rollback to the deploy that was live before.' },
+ { icon: 'fa-code-branch', title: 'Git deploys', text: 'Point a site at a public repository and branch; each ingest lands as a preview for someone to approve.' },
+ { icon: 'fa-globe', title: 'Your own address', text: 'Sites serve under openvibe.website. A custom domain is served once its TXT record proves it is yours.' },
+ { icon: 'fa-sliders', title: 'Headers and redirects', text: 'Per-site response headers, redirects and a single-page-app fallback. Platform security headers always win.' },
+ { icon: 'fa-users', title: 'Members and roles', text: 'Owners, maintainers and deployers per project; an app can deploy too, with a grant and a role, never as the owner.' },
+ { icon: 'fa-shield-halved', title: 'Takedowns kept for review', text: 'OpenVibe staff can stop serving a site or a whole project while its content is kept for review, and lift it again.' },
+ { icon: 'fa-terminal', title: 'An API for all of it', text: 'Projects, sites, deploys, domains and configuration over /api/v1, with OpenVibe.Network tokens and space.* capabilities.' },
+ ],
+ }) + sc.code({
+ title: 'The same over the API',
+ lede: 'With a Network token for openvibe.space that holds space.deploy.create and a role in the site\'s project.',
+ samples: [{ label: 'Upload and activate a build', lang: 'bash', code: `tar -czf site.tar.gz -C dist .
+curl -X POST "https://openvibe.space/api/v1/sites/$SITE_ID/deploys?activate=1" \\
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/gzip' \\
+ --data-binary @site.tar.gz` }],
+ }) + sc.limits({
+ title: 'Quotas per project',
+ lede: 'What a project may use today; staff can raise a project\'s quota.',
+ columns: ['Production', 'Sandbox'],
+ rows: [
+ row('Storage', 'storageBytes', size),
+ row('Deploys per day', 'deploysPerDay'),
+ row('Sites', 'sites'),
+ row('Custom domains', 'customDomains'),
+ row('Files per deploy', 'maxFiles', (n) => n.toLocaleString('en-US')),
+ row('Largest file', 'maxFileBytes', size),
],
],
+ source: 'https://github.com/OpenVibers/OpenVibe.Space/blob/main/server/hosting-config.js',
}) + sc.steps({
}) + sc.steps({
- title: 'Where things are today',
+ title: 'Next',
items: [
items: [
- { title: 'The forum', href: FORUM_URL, text: 'Boards, threads, votes and moderation moved to OpenVibe.Community.' },
- { title: 'The developer platform', href: SERVICES_URL, text: 'Projects, credentials, grants and the SDK that spaces will be built on.' },
- { title: 'The source code', href: 'https://github.com/OpenVibers/OpenVibe.Space', text: 'Space is open source (AGPL-3.0): follow the rebuild there.' },
+ { title: 'Dynamic pages', text: 'Planned; server side pages are not built yet.' },
+ { title: 'Spaces', text: 'Planned; small apps on a person\'s OpenVibe account are not built yet.' },
+ { title: 'The forum', href: FORUM_URL, text: 'The forum moved to OpenVibe.Community.' },
],
],
});
});
return renderPage({ canonicalPath: '/', active: 'home', styles: [sc.STYLESHEET], body });
return renderPage({ canonicalPath: '/', active: 'home', styles: [sc.STYLESHEET], body });
new file mode 100644
new file mode 100644
@@ -0,0 +1,371 @@
@@ -0,0 +1,371 @@
+'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).
+ *
+ * Two modes, selected by config (SPACE_OBJECT_STORE):
+ *
+ * local (default, and what an unset SPACE_OBJECT_STORE gets) — the layout above, exactly as before.
+ * media — the same local layout is the READ cache, and every put is written through to
+ * OpenVibe.Media's Object API v2 (openvibe-sdk/media createObjectsClient) as a private
+ * object in Host's own namespace, keyed per project with the file's sha256 as content_hash.
+ * A deploy is not reported as stored until Media acknowledged the write; a Media failure
+ * rejects the put and the deploy fails, never a silent local-only success. ensure() serves
+ * the local cache and, on a miss, streams the object back from Media while hashing it,
+ * verifies the sha256 and only then renames it into the cache and serves it. Media objects
+ * are deleted only where the local store would delete the blob today (deploy GC, project
+ * removal, the worker's orphan sweep), and the delete re-checks space_blobs just before it
+ * runs, so a Media object a deploy has since re-referenced is not deleted. The worker's
+ * sweep walks only locally cached blobs, so a Media-only orphan is left to project
+ * removal. A zero-byte file is cache-only (Media's upload refuses empty objects) and is
+ * never fetched. A put is serialised per (project, sha256) and remove deletes every
+ * matching object, so two deploys sharing a new sha cannot leave a duplicate behind.
+ *
+ * Nothing in this module reaches the network on load: the Media client is built inside
+ * createMediaStore(), only when the Media store is selected, and it fetches a token lazily.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { Readable, Transform } = require('stream');
+const { pipeline } = require('stream/promises');
+const { isId } = require('./ids');
+
+const SHA_RE = /^[0-9a-f]{64}$/;
+// What Space's Network service principal must be granted for audience openvibe.media, in Space's own
+// namespace (`host` / `space.*`). Media's v2 object API checks a verb per namespace: upload, read,
+// list, delete. Media's older ids keep granting the newer verbs, but asking for each verb is exact.
+const MEDIA_SCOPE = 'media.object.upload media.object.read media.object.list media.object.delete';
+const MEDIA_AUDIENCE = 'openvibe.media';
+// A Media download that has not answered within this long is abandoned and the read fails with a
+// clean 500 instead of hanging. The SDK client has its own timeouts; this raw fetch does not.
+const MEDIA_FETCH_TIMEOUT_MS = 30 * 1000;
+
+const sha256Of = (data) => crypto.createHash('sha256').update(data).digest('hex');
+
+/** A put/ensure/delete failure against Media, with a stable code. Never carries a secret. */
+function mediaFailure(action, err) {
+ const e = new Error(err && err.message ? `${action}: ${err.message}` : action);
+ e.code = 'storage.media';
+ if (err) e.cause = err;
+ return e;
+}
+
+/** The local, content-addressed store. `ensure` is the synchronous read path (a cache hit is the file). */
+function createLocalStore(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 = sha256Of(data);
+ 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;
+ }
+
+ /** Free bytes on the filesystem that holds the objects (shared with every other service on the host). */
+ function freeBytes() {
+ const s = fs.statfsSync(base);
+ return Number(s.bavail) * Number(s.bsize);
+ }
+
+ function writable() {
+ fs.accessSync(tmpDir, fs.constants.W_OK);
+ fs.accessSync(projectsDir, fs.constants.W_OK);
+ return true;
+ }
+
+ /** The path to serve: a local cache hit is already the file. */
+ function ensure(projectId, sha256) {
+ return pathFor(projectId, sha256);
+ }
+
+ return { root: base, tmpDir, pathFor, put, exists, remove, removeProject, list, sweepTmp, writable, freeBytes, ensure };
+}
+
+/**
+ * The Media-backed store: local disk is the read cache, Media is the source of truth. It exposes the
+ * same interface as the local store; put/ensure/remove/removeProject become async (they talk to
+ * Media), and the rest delegate to the cache.
+ *
+ * opts.isReferenced(projectId, sha256) -> whether space_blobs still has a row, i.e. whether a deploy
+ * still references the blob. remove() re-checks it just before deleting, so a blob a concurrent
+ * deploy re-used after the caller found it unreferenced keeps its Media copy.
+ */
+function createMediaStore(local, media, { isReferenced } = {}) {
+ const { createObjectsClient } = require('openvibe-sdk/media'); // v2 object API
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ if (!media || !media.url) throw new Error('SPACE_OBJECT_STORE=media needs OV_MEDIA_URL (the internal OpenVibe.Media base URL)');
+ if (!media.clientSecret) throw new Error('SPACE_OBJECT_STORE=media needs OV_OAUTH_CLIENT_SECRET: Space stores objects as its own Media tenant through a Network service token');
+ const tokenClient = createServiceTokenClient({
+ tokenUrl: media.tokenUrl,
+ clientId: media.clientId,
+ clientSecret: media.clientSecret,
+ audience: MEDIA_AUDIENCE,
+ scope: media.scope || MEDIA_SCOPE,
+ ...(media.fetchImpl ? { fetch: media.fetchImpl } : {}),
+ });
+ const objects = createObjectsClient({
+ app: media.namespace || 'space',
+ baseUrl: String(media.url).replace(/\/+$/, ''),
+ tokenClient,
+ ...(media.fetchImpl ? { fetch: media.fetchImpl } : {}),
+ });
+ const fetchBytes = media.fetchImpl || globalThis.fetch;
+ const fetchTimeoutMs = Number(media.fetchTimeoutMs) > 0 ? Number(media.fetchTimeoutMs) : MEDIA_FETCH_TIMEOUT_MS;
+ // (project, sha256) -> Media object id, so a deploy does not list Host's namespace once per file.
+ // A restart simply repopulates it; deleting an object drops its entry. Only an optimisation: an
+ // empty map still finds every object by its content_hash.
+ const known = new Map();
+ const keyOf = (projectId, sha256) => `${projectId}\n${sha256}`;
+ // In-flight tasks by key: a concurrent put of the same new blob waits for the first upload
+ // instead of racing it into a duplicate object, and a burst of cache misses for one blob shares
+ // a single download. Keys are prefixed so a put and an ensure never block each other.
+ const inflight = new Map();
+
+ const isOurs = (o, projectId, sha256) => Boolean(o)
+ && o.content_hash === sha256
+ && o.metadata && o.metadata.project_id === projectId;
+
+ /** Run fn after every earlier task with the same key, whichever way it settled. */
+ function serialise(map, key, fn) {
+ const prev = map.get(key) || Promise.resolve();
+ const run = prev.then(fn, fn);
+ const tail = run.then(() => {}, () => {}); // the queue's tail never rejects
+ map.set(key, tail);
+ tail.then(() => { if (map.get(key) === tail) map.delete(key); });
+ return run;
+ }
+
+ /** Every Media object id for this (project, sha256): there can be duplicates from before puts were serialised. */
+ async function findAllIds(projectId, sha256) {
+ const ids = [];
+ for await (const o of objects.iterate({})) if (isOurs(o, projectId, sha256)) ids.push(o.id);
+ return ids;
+ }
+
+ async function findId(projectId, sha256) {
+ const key = keyOf(projectId, sha256);
+ const hit = known.get(key);
+ if (hit) return hit;
+ const ids = await findAllIds(projectId, sha256);
+ if (!ids.length) return null;
+ known.set(key, ids[0]);
+ return ids[0];
+ }
+
+ /** Stream a Media object into a temp file while hashing it; rename into the cache only on a match. */
+ async function downloadToCache(projectId, sha256, url) {
+ const dest = local.pathFor(projectId, sha256);
+ const tmp = path.join(local.tmpDir, `${crypto.randomBytes(12).toString('hex')}.part`);
+ const hash = crypto.createHash('sha256');
+ try {
+ const res = await fetchBytes(url, { signal: AbortSignal.timeout(fetchTimeoutMs) });
+ if (!res.ok) throw new Error(`download answered ${res.status}`);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ if (res.body) {
+ const meter = new Transform({ transform(chunk, _enc, cb) { hash.update(chunk); cb(null, chunk); } });
+ await pipeline(Readable.fromWeb(res.body), meter, fs.createWriteStream(tmp));
+ } else {
+ const buf = Buffer.from(await res.arrayBuffer());
+ hash.update(buf);
+ fs.writeFileSync(tmp, buf);
+ }
+ const actual = hash.digest('hex');
+ if (actual !== sha256) {
+ const e = new Error(`object ${sha256}: Media returned bytes whose sha256 is ${actual}; refused`);
+ e.code = 'storage.corrupt';
+ throw e;
+ }
+ fs.renameSync(tmp, dest);
+ } catch (err) {
+ fs.rmSync(tmp, { force: true });
+ if (err && err.code === 'storage.corrupt') throw err;
+ throw mediaFailure(`could not fetch object ${sha256} from Media`, err);
+ }
+ }
+
+ async function doPut(projectId, sha256, data) {
+ if (!SHA_RE.test(String(sha256))) throw new TypeError('invalid sha256');
+ // Media's upload refuses zero bytes ("nothing to upload"); an empty file is a valid manifest
+ // entry, so it lives in the local cache only. ensure() serves it from there and, since the
+ // serving path short-circuits size 0, never asks Media (which has no object for it).
+ if (data.length === 0) return local.put(projectId, sha256, data);
+ const changed = local.put(projectId, sha256, data); // the cache first; verifies the digest
+ // A blob this process just wrote is new here: upload it directly. Listing Host's whole
+ // namespace once per file would be quadratic on a 300-file deploy, and Media's v2 list API has
+ // no content_hash or metadata filter. Only a blob that was already cached (a previous deploy
+ // in this process, or an earlier run) is looked up, so a restart does not upload a second copy.
+ if (!changed) {
+ if (await findId(projectId, sha256)) return changed;
+ }
+ let obj;
+ try {
+ obj = await objects.upload(data, {
+ kind: 'file', visibility: 'private', mimeType: 'application/octet-stream',
+ filename: sha256, contentHash: sha256,
+ metadata: { project_id: projectId, sha256 },
+ });
+ } catch (err) {
+ throw mediaFailure(`Media refused to store object ${sha256}`, err);
+ }
+ known.set(keyOf(projectId, sha256), obj.id);
+ return changed;
+ }
+
+ /** Cache the bytes locally (as today), then write through to Media. Rejects if Media refuses. */
+ function put(projectId, sha256, data) {
+ return serialise(inflight, `put:${keyOf(projectId, sha256)}`, () => doPut(projectId, sha256, data));
+ }
+
+ async function doEnsure(projectId, sha256) {
+ if (local.exists(projectId, sha256)) return local.pathFor(projectId, sha256); // a peer cached it while we waited
+ const id = await findId(projectId, sha256);
+ if (!id) throw mediaFailure(`object ${sha256} is not in Media`, null);
+ let url;
+ try {
+ ({ url } = await objects.signedUrl(id, { ttl: 300 }));
+ } catch (err) {
+ throw mediaFailure(`could not fetch object ${sha256} from Media`, err);
+ }
+ await downloadToCache(projectId, sha256, url);
+ return local.pathFor(projectId, sha256);
+ }
+
+ /**
+ * Serve the cache; on a miss fetch from Media, verify the sha256, cache it, then serve. Concurrent
+ * misses for one blob share a single download.
+ */
+ function ensure(projectId, sha256) {
+ if (local.exists(projectId, sha256)) return Promise.resolve(local.pathFor(projectId, sha256));
+ return serialise(inflight, `ensure:${keyOf(projectId, sha256)}`, () => doEnsure(projectId, sha256));
+ }
+
+ /** Delete the Media object(s) and the cache file. Callers pass only blobs no deploy references. */
+ async function remove(projectId, sha256) {
+ if (!SHA_RE.test(String(sha256))) throw new TypeError('invalid sha256');
+ let ids;
+ try {
+ ids = await findAllIds(projectId, sha256);
+ } catch (err) {
+ throw mediaFailure(`Media could not delete object ${sha256}`, err);
+ }
+ // Between the caller's "unreferenced" decision and this delete a concurrent deploy may have
+ // re-used the sha. space_blobs is the authority: if a row now exists, keep Media's durable copy
+ // and the cache file. The check is after the (slow) namespace scan, right before the delete.
+ if (isReferenced && await isReferenced(projectId, sha256)) return false;
+ if (ids.length) {
+ try {
+ for (const id of ids) await objects.delete(id);
+ } catch (err) {
+ throw mediaFailure(`Media could not delete object ${sha256}`, err);
+ }
+ known.delete(keyOf(projectId, sha256));
+ }
+ return local.remove(projectId, sha256);
+ }
+
+ /** Delete every Media object of the project and the project's cache directory. */
+ async function removeProject(projectId) {
+ if (!isId('project', projectId)) throw new TypeError('invalid project id');
+ let failure = null;
+ try {
+ for await (const o of objects.iterate({})) {
+ if (o && o.metadata && o.metadata.project_id === projectId) {
+ try {
+ await objects.delete(o.id);
+ if (o.content_hash) known.delete(keyOf(projectId, o.content_hash));
+ } catch (err) { failure = failure || err; }
+ }
+ }
+ } catch (err) { failure = failure || err; }
+ local.removeProject(projectId);
+ if (failure) throw mediaFailure(`Media could not delete the objects of ${projectId}`, failure);
+ }
+
+ return { ...local, put, ensure, remove, removeProject };
+}
+
+/**
+ * root: the local object directory. opts.media, when set, selects the Media-backed store (its
+ * presence, built from config, is the `SPACE_OBJECT_STORE=media` switch); without it the local store
+ * behaves exactly as before. opts.isReferenced is the Media store's space_blobs re-check on delete.
+ */
+function createBlobStore(root, opts = {}) {
+ const local = createLocalStore(root);
+ if (!opts.media) return local;
+ return createMediaStore(local, opts.media, { isReferenced: opts.isReferenced });
+}
+
+module.exports = { createBlobStore, SHA_RE, MEDIA_SCOPE, MEDIA_AUDIENCE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * Background work in the Host process (a few timers; nothing here executes tenant content):
+ *
+ * domain checks every SPACE_DOMAIN_RECHECK_MS: pending custom domains are checked again
+ * (they fail after SPACE_DOMAIN_PENDING_DAYS); verified ones are re-checked daily
+ * and lapse when their TXT record has been gone for SPACE_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('[Space] domain checks failed:', err.message);
+ return null;
+ } finally { checking = false; }
+ }
+
+ /** Remove objects no row references, once they are older than `graceMs`. */
+ async function sweepObjects({ graceMs = 3600 * 1000 } = {}) {
+ const known = store.db.prepare('SELECT 1 FROM space_blobs WHERE project_id = ? AND sha256 = ?');
+ let removed = 0;
+ for (const o of blobs.list()) {
+ if (await known.get(o.projectId, o.sha256)) continue;
+ try {
+ if (Date.now() - fs.statSync(o.file).mtimeMs < graceMs) continue;
+ // Through the store, so with the Media store the unreferenced Media object goes too.
+ if (await blobs.remove(o.projectId, o.sha256)) removed++;
+ } catch (err) {
+ // A storage.media failure is a delete Media refused (grants, an outage): the sweep would
+ // otherwise look like it worked while the object leaks. Anything else raced a write or
+ // the file is already gone.
+ if (err && err.code === 'storage.media') {
+ log.warn('[Space] object sweep could not delete a Media object:', o.projectId, o.sha256, err.message);
+ }
+ }
+ }
+ 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, async () => { try { await sweepObjects(); } catch (err) { log.warn('[Space] object sweep failed:', err.message); } });
+ every(24 * 3600 * 1000, async () => { try { await outbox.outbox.prune(); } catch (err) { log.warn('[Space] outbox prune failed:', err.message); } });
+ setTimeout(domainTick, 5000).unref();
+ },
+ stop() { for (const t of timers) clearInterval(t); timers.length = 0; },
+ };
+}
+
+module.exports = { createWorker };
@@ -67,8 +67,8 @@ async function check(name, fn) {
@@ -67,8 +67,8 @@ async function check(name, fn) {
const r = await fetch(`${base}/`);
const r = await fetch(`${base}/`);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const html = await r.text();
const html = await r.text();
- assert.match(html, /<title>OpenVibe\.Space — code and dynamic pages<\/title>/);
- assert.match(html, /<h1>Code and dynamic pages,<span class="sc-accent"> on your OpenVibe account\.<\/span><\/h1>/);
+ assert.match(html, /<title>OpenVibe\.Space — static hosting<\/title>/);
+ assert.match(html, /<h1>Publish a static site\.<span class="sc-accent"> Keep every deploy\.<\/span><\/h1>/);
assert.match(html, /The forum moved to OpenVibe\.Community/);
assert.match(html, /The forum moved to OpenVibe\.Community/);
assert.match(html, /href="https:\/\/openvibe\.community\/s"/);
assert.match(html, /href="https:\/\/openvibe\.community\/s"/);
});
});
@@ -10,13 +10,13 @@ const { boot, check, done } = require('./helpers/app');
@@ -10,13 +10,13 @@ const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
- await check('the home page says what Space will host and links the forum on OpenVibe.Community', async () => {
+ await check('the home page describes staff static hosting and links the forum on OpenVibe.Community', async () => {
const r = await t.get('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.match(r.text, /<h1>Code and dynamic pages,<span class="sc-accent"> on your OpenVibe account\.<\/span><\/h1>/);
- assert.match(r.text, /static sites, dynamic pages and spaces/);
- assert.match(r.text, /Nothing is hosted here yet\./, 'it claims nothing is open');
- assert.match(r.text, /What Space will host/);
+ assert.match(r.text, /<h1>Publish a static site\.<span class="sc-accent"> Keep every deploy\.<\/span><\/h1>/);
+ assert.match(r.text, /Static site hosting is built and tested/);
+ assert.match(r.text, /OpenVibe staff only until public launch/);
+ assert.match(r.text, /What works now/);
assert.match(r.text, /showcase\.css/, 'built from the network\'s showcase sections');
assert.match(r.text, /showcase\.css/, 'built from the network\'s showcase sections');
assert.match(r.text, /The forum moved to OpenVibe\.Community/);
assert.match(r.text, /The forum moved to OpenVibe\.Community/);
assert.match(r.text, /href="https:\/\/openvibe\.community\/s"/, 'the forum link points at Community');
assert.match(r.text, /href="https:\/\/openvibe\.community\/s"/, 'the forum link points at Community');
new file mode 100644
new file mode 100644
@@ -0,0 +1,195 @@
@@ -0,0 +1,195 @@
+'use strict';
+/**
+ * Abuse controls (docs/threat-review.md): staff takedowns stop serving at once and keep the content
+ * for review; members cannot publish, switch or delete around a takedown; certificate-validation
+ * paths cannot be published; uploads are validated a few at a time, so concurrent uploads cannot
+ * exhaust memory; fingerprinted assets leave a shared CDN within the hour.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const { boot, check, done } = require('./stageb/boot');
+const { site: siteTar } = require('./stageb/tar');
+
+(async () => {
+ const t = await boot({ env: { SPACE_MAX_CONCURRENT_UPLOADS: '1', SPACE_MAX_PROJECTS_PER_OWNER: '4' } });
+ const alice = t.user('alice');
+ const staff = t.user('root', { role: 'admin' });
+ const pa = await t.project(alice, 'A');
+ const alpha = await t.site(alice, pa.id, 'alpha');
+ const other = await t.site(alice, pa.id, 'other');
+ const d1 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA ONE', 'app.3f2a9c1b.js': 'js();' });
+ const d2 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA TWO' });
+ await t.deploy(alice, other.id, { 'index.html': 'OTHER' });
+ const dom = await t.api('POST', `/api/v1/sites/${alpha.id}/domains`, { as: alice, json: { hostname: 'www.alpha-example.org' } });
+ const domain = dom.json().domain;
+ t.dns.set("_openvibe-space.www.alpha-example.org", [domain.instructions.verification.value]);
+ assert.strictEqual((await t.api('POST', `/api/v1/domains/${domain.id}/verify`, { as: alice })).json().domain.status, 'verified');
+ const objectsOf = (p) => { const out = []; const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) walk(path.join(d, e.name)); else out.push(e.name); } }; walk(path.join(t.dir, 'objects', 'projects', p)); return out.sort(); };
+
+ await check('fingerprinted assets: immutable for browsers, at most an hour at a shared CDN', async () => {
+ const r = await t.get('alpha.openvibe.website', '/app.3f2a9c1b.js');
+ await t.api('POST', `/api/v1/deploys/${d1.id}/activate`, { as: alice });
+ const r1 = await t.get('alpha.openvibe.website', '/app.3f2a9c1b.js');
+ assert.strictEqual(r.status, 404, 'not in the active deploy yet');
+ assert.strictEqual(r1.headers['cache-control'], 'public, max-age=31536000, immutable');
+ assert.strictEqual(r1.headers['cdn-cache-control'], 'public, max-age=3600');
+ assert.strictEqual((await t.get('alpha.openvibe.website', '/')).headers['cdn-cache-control'], undefined);
+ await t.api('POST', `/api/v1/deploys/${d2.id}/activate`, { as: alice });
+ });
+
+ await check('only staff take a site down; members and strangers cannot', async () => {
+ const mine = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: alice, json: { reason: 'self' } });
+ assert.strictEqual(mine.status, 403);
+ assert.strictEqual(mine.json().code, 'auth.staff_only');
+ const stranger = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: t.user('mallory'), json: { reason: 'x' } });
+ assert.strictEqual(stranger.status, 404, 'a stranger learns nothing');
+ const noReason = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: {} });
+ assert.strictEqual(noReason.status, 422);
+ });
+
+ const objectsBefore = objectsOf(pa.id);
+ await check('a takedown stops the default and custom domains at once (451, no tenant bytes) and keeps everything', async () => {
+ const r = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'phishing report #42' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().takedown.reason, 'phishing report #42');
+ for (const host of ['alpha.openvibe.website', 'www.alpha-example.org']) {
+ for (const p of ['/', '/index.html', '/app.3f2a9c1b.js', '/404.html', '/missing']) {
+ const g = await t.get(host, p);
+ assert.strictEqual(g.status, 451, `${host}${p}`);
+ assert.ok(!/ALPHA|js\(\)/.test(g.text), 'no tenant content');
+ assert.strictEqual(g.headers['cache-control'], 'no-store');
+ assert.strictEqual(g.headers.etag, undefined);
+ assert.strictEqual(g.headers['clear-site-data'], '"cache", "storage"', 'the visitor\'s browser drops what the site left behind');
+ }
+ }
+ assert.strictEqual((await t.get('other.openvibe.website', '/')).text, 'OTHER', 'a site-level takedown leaves the project\'s other sites up');
+ assert.deepStrictEqual(objectsOf(pa.id), objectsBefore, 'every object kept');
+ const files = await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: staff });
+ assert.strictEqual(files.json().deploy.files.length, 2, 'file rows kept for review');
+ assert.strictEqual((await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'again' } })).status, 409);
+ });
+
+ await check('members see the takedown and why, and cannot publish, switch or delete around it', async () => {
+ const s = await t.api('GET', `/api/v1/sites/${alpha.id}`, { as: alice });
+ assert.deepStrictEqual([s.json().site.takedown.scope, s.json().site.takedown.reason], ['site', 'phishing report #42']);
+ const up = await t.upload(alice, alpha.id, { 'index.html': 'EVADE' });
+ assert.strictEqual(up.status, 403);
+ assert.strictEqual(up.json().code, 'site.taken_down');
+ for (const [method, p, json] of [
+ ['POST', `/api/v1/deploys/${d1.id}/activate`, {}], ['POST', `/api/v1/sites/${alpha.id}/rollback`, {}],
+ ['DELETE', `/api/v1/deploys/${d1.id}`], ['DELETE', `/api/v1/sites/${alpha.id}`], ['DELETE', `/api/v1/projects/${pa.id}`],
+ ]) {
+ const r = await t.api(method, p, { as: alice, json });
+ assert.ok([403, 409].includes(r.status), `${method} ${p} → ${r.status} ${r.text}`);
+ assert.strictEqual(r.json().code, 'site.taken_down');
+ }
+ const page = await t.api('GET', `/sites/${alpha.id}`, { session: alice });
+ assert.match(page.text, /taken down by OpenVibe staff: phishing report #42/);
+ assert.deepStrictEqual(objectsOf(pa.id), objectsBefore);
+ });
+
+ await check('staff lift a takedown: the same content serves again; members only lift nothing', async () => {
+ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: alice })).status, 403);
+ const r = await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { note: 'reviewed: false positive' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual((await t.get('alpha.openvibe.website', '/')).text, 'ALPHA TWO');
+ assert.strictEqual((await t.get('www.alpha-example.org', '/')).text, 'ALPHA TWO');
+ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff })).status, 404);
+ const rows = await t.ctx.store.db.prepare('SELECT target_kind, reason, (lifted_at IS NOT NULL)::int AS lifted, lift_note FROM space_takedowns').all();
+ assert.deepStrictEqual(rows, [{ target_kind: 'site', reason: 'phishing report #42', lifted: 1, lift_note: 'reviewed: false positive' }], 'the history is kept');
+ });
+
+ await check('a project takedown covers every site of the project; staff can still delete', async () => {
+ const r = await t.api('POST', `/api/v1/projects/${pa.id}/takedown`, { as: staff, json: { reason: 'spam network' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual((await t.get('alpha.openvibe.website', '/')).status, 451);
+ assert.strictEqual((await t.get('other.openvibe.website', '/')).status, 451);
+ const p = await t.api('GET', `/api/v1/projects/${pa.id}`, { as: alice });
+ assert.strictEqual(p.json().project.takedown.scope, 'project');
+ assert.strictEqual(p.json().sites[0].takedown.scope, 'project');
+ const list = await t.api('GET', '/api/v1/projects', { as: alice });
+ assert.strictEqual(list.json().projects[0].takedown.reason, 'spam network');
+ assert.strictEqual((await t.upload(alice, other.id, { 'index.html': 'x' })).status, 403);
+ const del = await t.api('DELETE', `/api/v1/sites/${other.id}`, { as: staff });
+ assert.strictEqual(del.status, 200, del.text);
+ assert.strictEqual((await t.get('other.openvibe.website', '/')).status, 404);
+ });
+
+ await check('certificate-validation paths are never published (no tenant can get a certificate for its host name)', async () => {
+ const pb = await t.project(alice, 'B');
+ const beta = await t.site(alice, pb.id, 'beta');
+ for (const p of ['.well-known/acme-challenge/token123', '.well-known/pki-validation/fileauth.txt', '.well-known/ACME-CHALLENGE/x']) {
+ const r = await t.upload(alice, beta.id, { 'index.html': 'B', [p]: 'proof' });
+ assert.strictEqual(r.status, 422, `${p} → ${r.status}`);
+ assert.ok(r.json().log.some((l) => /reserved/.test(l)), r.text);
+ }
+ await t.deploy(alice, beta.id, { 'index.html': 'B', '.well-known/security.txt': 'Contact: mailto:[email protected]' });
+ assert.strictEqual((await t.get('beta.openvibe.website', '/.well-known/security.txt')).status, 200, 'other .well-known files still publish');
+ assert.strictEqual((await t.get('beta.openvibe.website', '/.well-known/acme-challenge/token123')).status, 404);
+ });
+
+ await check('uploads are validated a few at a time: a second concurrent upload gets 503 + Retry-After and is not a failed deploy', async () => {
+ const pc = await t.project(alice, 'C');
+ const gamma = await t.site(alice, pc.id, 'gamma');
+ const body = siteTar({ 'index.html': 'GAMMA' });
+ const failedBefore = (await t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM space_deploys WHERE state = 'failed'").get()).n;
+ // The first upload sends its headers and half its body, then waits.
+ let firstResponse;
+ let finish;
+ const first = new Promise((resolve) => {
+ const req = http.request({ host: '127.0.0.1', port: t.port, method: 'POST', path: `/api/v1/sites/${gamma.id}/deploys?activate=1`, headers: { host: 'openvibe.space', authorization: `Bearer ${t.network.userToken(alice)}`, 'content-type': 'application/gzip', 'content-length': body.length } }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => { firstResponse = { status: res.statusCode, text: Buffer.concat(chunks).toString() }; resolve(); });
+ });
+ req.write(body.subarray(0, Math.floor(body.length / 2)));
+ finish = () => req.end(body.subarray(Math.floor(body.length / 2)));
+ });
+ await new Promise((r) => setTimeout(r, 150));
+ assert.strictEqual(t.ctx.uploadGate.inFlight, 1);
+ const second = await t.upload(alice, gamma.id, { 'index.html': 'SECOND' });
+ assert.strictEqual(second.status, 503, second.text);
+ assert.strictEqual(second.json().code, 'upload.busy');
+ assert.strictEqual(second.headers['retry-after'], '30');
+ finish();
+ await first;
+ assert.strictEqual(firstResponse.status, 201, firstResponse.text);
+ assert.strictEqual(t.ctx.uploadGate.inFlight, 0, 'the slot is released');
+ assert.strictEqual((await t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM space_deploys WHERE state = 'failed'").get()).n, failedBefore, 'a busy refusal is not the tenant\'s failed deploy');
+ const third = await t.upload(alice, gamma.id, { 'index.html': 'THIRD' });
+ assert.strictEqual(third.status, 201, third.text);
+ // A refused upload releases its slot too.
+ const bad = await t.upload(alice, gamma.id, { '../x.html': 'no' });
+ assert.strictEqual(bad.status, 422);
+ assert.strictEqual(t.ctx.uploadGate.inFlight, 0);
+ });
+
+ await check('the shared disk: below SPACE_MIN_FREE_BYTES deploys are refused (507) before the body is read; serving goes on; readiness says so', async () => {
+ const pd = await t.project(alice, 'D');
+ const delta = await t.site(alice, pd.id, 'delta');
+ await t.deploy(alice, delta.id, { 'index.html': 'DELTA' });
+ const saved = t.ctx.config.uploads.minFreeBytes;
+ t.ctx.config.uploads.minFreeBytes = Number.MAX_SAFE_INTEGER;
+ try {
+ const r = await t.upload(alice, delta.id, { 'index.html': 'MORE' });
+ assert.strictEqual(r.status, 507, r.text);
+ assert.strictEqual(r.json().code, 'storage.space_full');
+ assert.strictEqual((await t.get('delta.openvibe.website', '/')).text, 'DELTA');
+ const ready = (await t.api('GET', '/api/ready')).json();
+ const check = Array.isArray(ready.checks) ? ready.checks.find((c) => c.name === 'disk_headroom') : ready.checks.disk_headroom;
+ assert.ok(check && !check.ok && /new deploys are refused/.test(JSON.stringify(check)), JSON.stringify(ready));
+ } finally { t.ctx.config.uploads.minFreeBytes = saved; }
+ assert.strictEqual((await t.upload(alice, delta.id, { 'index.html': 'MORE' })).status, 201);
+ });
+
+ await check('one person owns at most SPACE_MAX_PROJECTS_PER_OWNER projects (each gets its own quotas)', async () => {
+ const r = await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'fifth' } });
+ assert.strictEqual(r.status, 429, r.text);
+ assert.match(r.json().detail, /you already own 4 projects/);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+/**
+ * Space's hosting capabilities and service manifest as openvibe-contracts released them (0.149.0) match what the code
+ * enforces and emits; and the outbox relay really delivers Space'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 { CAPABILITIES } = require('../server/auth/capabilities');
+const { EVENT_TYPES } = require('../server/events/outbox');
+
+(async () => {
+ const ids = Object.values(CAPABILITIES);
+ const caps = ids.map((id) => contracts.capabilities.get(id));
+ const manifest = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/manifests/services/space.json'), 'utf8'));
+
+ await check('every capability Space guards is released, active and owned by space', async () => {
+ for (const [i, c] of caps.entries()) {
+ assert.ok(c, `${ids[i]} is not in openvibe-contracts`);
+ assert.strictEqual(c.owner, 'space');
+ // Every capability Host guards is active (space.resource.read since openvibe-contracts 0.108.0).
+ assert.strictEqual(c.status, 'active', c.id);
+ }
+ });
+
+ await check('the released capabilities are exactly the ones the routes guard, and every route guards one', async () => {
+ assert.deepStrictEqual([...manifest.capabilities].sort(), [...ids].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);
+ const key = `${r.method} ${r.route}`;
+ assert.ok(cap.implementedBy.includes(key), `${key} missing from ${r.cap}.implementedBy`);
+ }
+ });
+
+ await check('the proposed service manifest declares every event the service emits', async () => {
+ assert.strictEqual(manifest.id, 'space');
+ for (const e of EVENT_TYPES) assert.ok(manifest.eventsProduced.includes(e), e);
+ 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 space.* 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:space') 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(), ['space.deploy.activated', 'space.deploy.created', 'space.deploy.failed']);
+ assert.ok(received.every((e) => e.source === 'space'));
+ assert.strictEqual((await t.ctx.outbox.status()).pending, 0);
+ await t.close();
+ await events.close();
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+'use strict';
+/** Socket-free Stage B smoke test against Space's full migrated PGlite schema. */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { testDb } = require('./helpers/db');
+const { createApp } = require('../server/app');
+const { load } = require('../server/hosting-config');
+
+(async () => {
+ const db = await testDb();
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-space-core-'));
+ try {
+ const auth = { verify: async () => null, keys: { loaded: () => false } };
+ const config = load({ NODE_ENV: 'test', BASE_URL: 'https://openvibe.space', SPACE_WORKER: 'off', SPACE_MIN_FREE_BYTES: '0' });
+ assert.strictEqual(config.publicHosting, false);
+ assert.strictEqual(config.sitesDomain, 'openvibe.website');
+ const app = await createApp({ db, auth, hostingConfig: config, storageDir: dir });
+ const h = app.locals.hosting;
+ const user = { kind: 'user', subject: 'usr_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', staff: false };
+ await assert.rejects(h.projects.create(user, { name: 'Blocked' }), (e) => e.status === 403 && e.code === 'space.not_launched');
+ user.staff = true;
+ const project = await h.projects.create(user, { name: 'Trial' });
+ const site = await h.sites.create(user, project, { name: 'trial-site' });
+ assert.strictEqual((await h.tenant.resolve('trial-site.openvibe.website')).kind, 'site');
+ assert.strictEqual((await h.tenant.resolve('trial-site.openvibe.space')).kind, 'unknown');
+ const pre = await h.deploys.precheck(user, site.id);
+ const first = await h.deploys.create(user, pre, [{ path: 'index.html', data: Buffer.from('<h1>First</h1>') }], { source: 'files', activate: true });
+ assert.strictEqual(first.deploy.state, 'ready');
+ assert.strictEqual(first.activated.changed, true);
+ const second = await h.deploys.create(user, await h.deploys.precheck(user, site.id), [{ path: 'index.html', data: Buffer.from('<h1>Second</h1>') }], { source: 'files', activate: true });
+ assert.notStrictEqual(first.deploy.manifest_sha256, second.deploy.manifest_sha256);
+ const switched = await h.deploys.activate(user, first.deploy.id, { expectedActive: second.deploy.id });
+ assert.strictEqual(switched.deploy_id, first.deploy.id);
+ assert.strictEqual((await h.sites.get(site.id)).active_deploy_id, first.deploy.id);
+ const rows = await db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all();
+ const types = rows.map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope).event_type);
+ for (const row of rows) {
+ const envelope = typeof row.envelope === 'string' ? JSON.parse(row.envelope) : row.envelope;
+ assert.strictEqual(require('openvibe-contracts').validate('events.event-envelope@1', envelope).valid, true);
+ assert.strictEqual(require('openvibe-contracts').validate(`${envelope.event_type}@1`, envelope.payload).valid, true, envelope.event_type);
+ }
+ assert.deepStrictEqual(types, ['space.deploy.created', 'space.deploy.activated', 'space.deploy.created', 'space.deploy.activated', 'space.deploy.activated']);
+ console.log('hosting-core: passed');
+ } finally {
+ await db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+})().catch((err) => { console.error(err); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+/**
+ * Regression (ADR-035): the Host dispatch middleware in server/app.js awaits tenant.resolve and
+ * tenant.handle, which read the database asynchronously. Express 4 does not catch a rejected async
+ * handler: a transient database error used to escape as an unhandled rejection and leave the request
+ * hanging with no response. It must reach the app's error handler instead (a logged 500).
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./stageb/boot');
+
+/** The response, or a failure the moment it is clear no response is coming. */
+function withDeadline(promise, what) {
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ const timer = setTimeout(() => reject(new Error(`${what}: no response after 3000ms — the rejection never reached the error handler`)), 3000);
+ promise.then(() => clearTimeout(timer), () => clearTimeout(timer));
+ }),
+ ]);
+}
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice');
+
+ await check('a failing Host resolve answers 500 instead of hanging the request', async () => {
+ const original = t.ctx.tenant.resolve;
+ t.ctx.tenant.resolve = async () => { throw new Error('transient database error'); };
+ try {
+ const r = await withDeadline(t.get('alice.openvibe.website', '/'), 'resolve');
+ assert.strictEqual(r.status, 500, `expected 500, got ${r.status}: ${r.text.slice(0, 120)}`);
+ assert.match(r.text, /Something went wrong/);
+ } finally {
+ t.ctx.tenant.resolve = original;
+ }
+ });
+
+ await check('a failing tenant handler answers 500 instead of hanging the request', async () => {
+ const project = await t.project(alice, 'Dispatch errors');
+ const site = await t.site(alice, project.id, 'alice');
+ await t.deploy(alice, site.id, { 'index.html': 'hello' });
+ const original = t.ctx.tenant.handle;
+ t.ctx.tenant.handle = async () => { throw new Error('transient database error'); };
+ try {
+ const r = await withDeadline(t.get('alice.openvibe.website', '/'), 'handle');
+ assert.strictEqual(r.status, 500, `expected 500, got ${r.status}: ${r.text.slice(0, 120)}`);
+ } finally {
+ t.ctx.tenant.handle = original;
+ }
+ });
+
+ await check('the site is served normally again once the failure passes', async () => {
+ const r = await t.get('alice.openvibe.website', '/');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.text, 'hello');
+ });
+
+ await t.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-space.${HOST}`);
+ assert.match(dom.instructions.verification.value, /^openvibe-space-verification=[0-9a-f]{40}$/);
+ assert.deepStrictEqual(dom.instructions.routing[0], { type: 'CNAME', name: HOST, value: 'alice-site.openvibe.website', 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-space.${HOST}`, ['openvibe-space-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-space.${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 = await t.events('space.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-space.${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.website', 'openvibe.space', 'openvibe.xyz', 'a.b.openvibe.media', 'openvibe.events', '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 = await 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-space.${HOST}`);
+ t.clock.advance(DAY + 1000);
+ await t.ctx.worker.domainTick();
+ assert.strictEqual((await t.ctx.domains.get(dom.id)).status, 'verified', 'still served during the grace period');
+ assert.ok((await 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((await 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,210 @@
@@ -0,0 +1,210 @@
+'use strict';
+/**
+ * Git deploys, Phase 1 (plan T12 Stage B): a site's Git source is public provenance (repository +
+ * branch) set by a maintainer; the project's own CI builds the commit and posts the output to
+ * POST /api/v1/sites/:id/source/deploys with an app deployer token. Host validates the files like any
+ * upload, stores a source=git deploy with an immutable space_deploy_git row and makes it the site's
+ * preview; it never activates on arrival, and every refusal leaves the active deploy untouched.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./stageb/boot');
+const { site: siteTar } = require('./stageb/tar');
+
+const SHA = 'a'.repeat(40);
+const SHA256 = '0123456789abcdef'.repeat(4);
+const SOURCE_KEYS = ['created_at', 'created_by', 'provider', 'ref', 'repo_url', 'site_id', 'updated_at', 'updated_by'];
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice');
+ const dora = t.user('dora');
+ const mallory = t.user('mallory');
+ const project = await t.project(alice, 'Git');
+ const site = await t.site(alice, project.id, 'gitsite');
+ const other = await t.site(alice, project.id, 'unlinked');
+ await t.api('PUT', `/api/v1/projects/${project.id}/members/${dora.subject}`, { as: alice, json: { role: 'deployer' } });
+ const live = await t.deploy(alice, site.id, { 'index.html': 'LIVE' });
+ await t.deploy(alice, other.id, { 'index.html': 'OTHER' });
+
+ const { ids } = require('openvibe-contracts');
+ const appId = ids.newId('app');
+ const ci = t.network.appToken(appId, ['space.site.manage', 'space.deploy.create']);
+ assert.strictEqual((await t.api('PUT', `/api/v1/projects/${project.id}/members/app:${appId}`, { as: alice, json: { role: 'deployer' } })).status, 200);
+
+ const db = t.ctx.store.db;
+ const pointers = async (id = site.id) => await db.prepare('SELECT active_deploy_id, preview_deploy_id FROM space_sites WHERE id = ?').get(id);
+ const putSource = (as, body, id = site.id) => t.api('PUT', `/api/v1/sites/${id}/source`, { as, json: body });
+ const ingest = (files, query, { as = ci, id = site.id } = {}) => t.api('POST', `/api/v1/sites/${id}/source/deploys?${query}`, { as, body: siteTar(files), headers: { 'content-type': 'application/gzip' } });
+ const assertSource = (s) => assert.deepStrictEqual(Object.keys(s).sort(), SOURCE_KEYS, `only the allowlisted source fields: ${JSON.stringify(s)}`);
+
+ await check('a maintainer connects a source (200); a deployer gets 403, a non-member 404, the read role can GET', async () => {
+ const r = await putSource(alice, { provider: 'github', repo_url: 'https://github.com/OpenVibers/site.git', ref: 'main' });
+ assert.strictEqual(r.status, 200, r.text);
+ const src = r.json().source;
+ assertSource(src);
+ assert.deepStrictEqual([src.provider, src.repo_url, src.ref, src.site_id], ['github', 'https://github.com/OpenVibers/site', 'main', site.id]);
+ const dep = await putSource(dora, { repo_url: 'https://github.com/x/y', ref: 'main' });
+ assert.strictEqual(dep.status, 403, dep.text);
+ assert.strictEqual(dep.json().code, 'project.role_insufficient');
+ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${site.id}/source`, { as: dora })).status, 403);
+ assert.strictEqual((await putSource(mallory, { repo_url: 'https://github.com/x/y', ref: 'main' })).status, 404);
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${site.id}/source`, { as: mallory })).status, 404);
+ const read = await t.api('GET', `/api/v1/sites/${site.id}/source`, { as: dora });
+ assert.strictEqual(read.status, 200, read.text);
+ assertSource(read.json().source);
+ assert.strictEqual(read.json().source.ref, 'main');
+ });
+
+ await check('a bad URL, provider or ref is 422 and changes nothing', async () => {
+ const bad = [
+ [{ repo_url: 'http://github.com/a/b', ref: 'main' }, 'source.repo_url'],
+ [{ repo_url: '[email protected]:a/b.git', ref: 'main' }, 'source.repo_url'],
+ [{ repo_url: 'ssh://github.com/a/b', ref: 'main' }, 'source.repo_url'],
+ [{ repo_url: 'https://user:[email protected]/a/b', ref: 'main' }, 'source.repo_url'],
+ [{ repo_url: 'https://github.com:8443/a/b', ref: 'main' }, 'source.repo_url'],
+ [{ repo_url: 'https://github.com/a/b?x=1', ref: 'main' }, 'source.repo_url'],
+ [{ repo_url: 'https://github.com/a/b#frag', ref: 'main' }, 'source.repo_url'],
+ [{ repo_url: 'https://github.com/a/b/c', ref: 'main' }, 'source.repo_url'],
+ [{ repo_url: 'https://evil.example/a/b', ref: 'main' }, 'source.repo_url'],
+ [{ repo_url: 'https://127.0.0.1/a/b', ref: 'main' }, 'source.repo_url'],
+ [{ provider: 'bitbucket', repo_url: 'https://github.com/a/b', ref: 'main' }, 'source.provider'],
+ [{ provider: 'gitlab', repo_url: 'https://github.com/a/b', ref: 'main' }, 'source.provider'],
+ [{ repo_url: 'https://github.com/a/b', ref: '' }, 'source.ref'],
+ [{ repo_url: 'https://github.com/a/b', ref: '-main' }, 'source.ref'],
+ [{ repo_url: 'https://github.com/a/b', ref: 'a..b' }, 'source.ref'],
+ [{ repo_url: 'https://github.com/a/b', ref: 'main~1' }, 'source.ref'],
+ [{ repo_url: 'https://github.com/a/b', ref: 'x.lock' }, 'source.ref'],
+ [{ repo_url: 'https://github.com/a/b', ref: 'a b' }, 'source.ref'],
+ [{ repo_url: 'https://github.com/a/b', ref: 'b'.repeat(201) }, 'source.ref'],
+ [{ repo_url: 'https://github.com/a/b', ref: 'main', token: 'ghp_secret' }, 'source.unknown_field'],
+ ];
+ for (const [body, code] of bad) {
+ const r = await putSource(alice, body);
+ assert.strictEqual(r.status, 422, `${JSON.stringify(body)}: ${r.status} ${r.text}`);
+ assert.strictEqual(r.json().code, code, `${JSON.stringify(body)}: ${r.text}`);
+ assert.ok(!r.text.includes('ghp_secret'));
+ }
+ const now = (await t.api('GET', `/api/v1/sites/${site.id}/source`, { as: alice })).json().source;
+ assert.strictEqual(now.repo_url, 'https://github.com/OpenVibers/site');
+ for (const ok of [{ repo_url: 'https://gitlab.com/g/p', ref: 'release/1.2' }, { repo_url: 'https://codeberg.org/o/r.git', ref: 'feature/x-y_z' }]) {
+ const r = await putSource(alice, ok, other.id);
+ assert.strictEqual(r.status, 200, r.text);
+ }
+ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${other.id}/source`, { as: alice })).status, 200);
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${other.id}/source`, { as: alice })).json().source, null);
+ });
+
+ let git = null;
+ await check('an app deployer ingests a build: source=git, provenance row, preview set, the old deploy still served', async () => {
+ const created = (await t.events('space.deploy.created')).length;
+ const r = await ingest({ 'index.html': 'FROM GIT' }, `ref=main&commit_sha=${SHA}`);
+ assert.strictEqual(r.status, 201, r.text);
+ const body = r.json();
+ git = body.deploy;
+ assert.strictEqual(git.source, 'git');
+ assert.strictEqual(git.state, 'ready');
+ assert.strictEqual(git.active, false);
+ assert.strictEqual(git.created_by, `app:${appId}`);
+ assert.deepStrictEqual(git.git, { provider: 'github', repo_url: 'https://github.com/OpenVibers/site', ref: 'main', commit_sha: SHA });
+ assert.strictEqual(body.activated, false);
+ assert.strictEqual(body.preview.deploy_id, git.id);
+ const row = await db.prepare('SELECT * FROM space_deploy_git WHERE deploy_id = ?').get(git.id);
+ assert.deepStrictEqual([row.provider, row.repo_url, row.ref, row.commit_sha], ['github', 'https://github.com/OpenVibers/site', 'main', SHA]);
+ assert.deepStrictEqual(await pointers(), { active_deploy_id: live.id, preview_deploy_id: git.id });
+ assert.strictEqual((await t.get('gitsite.openvibe.website', '/')).text, 'LIVE');
+ assert.strictEqual((await t.events('space.deploy.created')).length, created + 1);
+ assert.strictEqual((await t.events('space.deploy.created')).pop().payload.source, 'git');
+ const listed = (await t.api('GET', `/api/v1/sites/${site.id}/deploys`, { as: dora })).json().deploys.find((d) => d.id === git.id);
+ assert.strictEqual(listed.git.commit_sha, SHA);
+ const one = (await t.api('GET', `/api/v1/deploys/${git.id}`, { as: dora })).json().deploy;
+ assert.deepStrictEqual(Object.keys(one.git).sort(), ['commit_sha', 'provider', 'ref', 'repo_url']);
+ // A user Bearer token (a deployer) may ingest too; a 64-hex SHA-256 commit id is accepted.
+ const byUser = await ingest({ 'index.html': 'FROM GIT 2' }, `ref=main&commit_sha=${SHA256}`, { as: dora });
+ assert.strictEqual(byUser.status, 201, byUser.text);
+ assert.strictEqual(byUser.json().deploy.git.commit_sha, SHA256);
+ assert.strictEqual((await pointers()).active_deploy_id, live.id);
+ git = byUser.json().deploy;
+ });
+
+ await check('activate approves it: active_deploy_id flips, the preview clears, the site serves it', async () => {
+ const r = await t.api('POST', `/api/v1/deploys/${git.id}/activate`, { as: ci, json: { expected_active: live.id } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(await pointers(), { active_deploy_id: git.id, preview_deploy_id: null });
+ assert.strictEqual((await t.get('gitsite.openvibe.website', '/')).text, 'FROM GIT 2');
+ assert.strictEqual((await t.events('space.deploy.activated')).pop().payload.deploy_id, git.id);
+ });
+
+ await check('refusals leave active_deploy_id unchanged; an invalid file is a failed deploy + space.deploy.failed', async () => {
+ const before = await pointers();
+ const failedBefore = (await t.events('space.deploy.failed')).length;
+ const deploysBefore = (await db.prepare('SELECT COUNT(*) AS n FROM space_deploys').get()).n;
+
+ const bad = await ingest({ 'index.html': 'x', 'shell.php': '<?php system($_GET[1]);' }, `ref=main&commit_sha=${SHA}`);
+ assert.strictEqual(bad.status, 422, bad.text);
+ const failed = (await t.api('GET', `/api/v1/deploys/${bad.json().deploy_id}`, { as: alice })).json().deploy;
+ assert.deepStrictEqual([failed.state, failed.source], ['failed', 'git']);
+ assert.strictEqual((await t.events('space.deploy.failed')).length, failedBefore + 1);
+ assert.strictEqual(await db.prepare('SELECT 1 FROM space_deploy_git WHERE deploy_id = ?').get(failed.id), undefined, 'no provenance row for a refused deploy');
+
+ const mismatch = await ingest({ 'index.html': 'x' }, `ref=dev&commit_sha=${SHA}`);
+ assert.strictEqual(mismatch.status, 409, mismatch.text);
+ assert.strictEqual(mismatch.json().code, 'source.ref_mismatch');
+ const noRef = await ingest({ 'index.html': 'x' }, `commit_sha=${SHA}`);
+ assert.strictEqual(noRef.status, 422, noRef.text);
+ for (const sha of ['abc', 'A'.repeat(40), 'g'.repeat(40), 'a'.repeat(41), '']) {
+ const r = await ingest({ 'index.html': 'x' }, `ref=main&commit_sha=${sha}`);
+ assert.strictEqual(r.status, 422, `${sha}: ${r.text}`);
+ assert.strictEqual(r.json().code, 'source.commit_sha');
+ }
+ const unlinked = await ingest({ 'index.html': 'x' }, `ref=main&commit_sha=${SHA}`, { id: other.id, as: alice });
+ assert.strictEqual(unlinked.status, 409, unlinked.text);
+ assert.strictEqual(unlinked.json().code, 'source.not_connected');
+ const act = await ingest({ 'index.html': 'x' }, `ref=main&commit_sha=${SHA}&activate=1`);
+ assert.strictEqual(act.status, 422, act.text);
+ assert.strictEqual(act.json().code, 'source.activate');
+
+ assert.deepStrictEqual(await pointers(), before);
+ assert.strictEqual((await t.get('gitsite.openvibe.website', '/')).text, 'FROM GIT 2');
+ assert.strictEqual((await pointers(other.id)).active_deploy_id, (await db.prepare('SELECT id FROM space_deploys WHERE site_id = ? AND state = \'ready\'').get(other.id)).id);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM space_deploys').get()).n, deploysBefore + 1, 'only the invalid file left a (failed) deploy');
+ assert.strictEqual((await t.events('space.deploy.failed')).length, failedBefore + 1);
+ });
+
+ await check('activate as a multipart field is refused too, and a non-member cannot ingest', async () => {
+ const boundary = 'xGitBoundary';
+ const tar = siteTar({ 'index.html': 'MP' });
+ const body = Buffer.concat([
+ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="activate"\r\n\r\n1\r\n--${boundary}\r\nContent-Disposition: form-data; name="archive"; filename="site.tar.gz"\r\nContent-Type: application/gzip\r\n\r\n`),
+ tar, Buffer.from(`\r\n--${boundary}--\r\n`),
+ ]);
+ const before = await pointers();
+ const r = await t.api('POST', `/api/v1/sites/${site.id}/source/deploys?ref=main&commit_sha=${SHA}`, { as: ci, body, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } });
+ assert.strictEqual(r.status, 422, r.text);
+ assert.strictEqual(r.json().code, 'source.activate');
+ assert.strictEqual((await ingest({ 'index.html': 'x' }, `ref=main&commit_sha=${SHA}`, { as: mallory })).status, 404);
+ assert.deepStrictEqual(await pointers(), before);
+ });
+
+ await check('the dashboard form (form token + origin) saves and removes a source at maintain; a deployer cannot', async () => {
+ const { csrfToken } = require('../server/auth/forms');
+ const origin = { origin: 'https://openvibe.space' };
+ const form = (user, p, body) => t.api('POST', p, { session: user, form: { csrf: csrfToken({ formSecret: 'test-form-secret' }, { subject: user.subject }), ...body }, headers: origin });
+ const saved = await form(alice, `/sites/${other.id}/source`, { repo_url: 'https://codeberg.org/o/r', ref: 'pages' });
+ assert.strictEqual(saved.status, 303, saved.text);
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${other.id}/source`, { as: alice })).json().source.ref, 'pages');
+ const forged = await t.api('POST', `/sites/${other.id}/source`, { session: alice, form: { csrf: 'nope', repo_url: 'https://github.com/x/y', ref: 'main' }, headers: origin });
+ assert.notStrictEqual(forged.status, 303);
+ assert.strictEqual((await form(dora, `/sites/${other.id}/source/remove`, {})).status, 403);
+ assert.strictEqual((await form(alice, `/sites/${other.id}/source/remove`, {})).status, 303);
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${other.id}/source`, { as: alice })).json().source, null);
+ });
+
+ await check('space_deploy_git rows are immutable', async () => {
+ await assert.rejects(db.prepare("UPDATE space_deploy_git SET commit_sha = 'b' WHERE deploy_id = ?").run(git.id), /immutable/);
+ await assert.rejects(db.prepare("UPDATE space_deploy_git SET ref = 'dev' WHERE deploy_id = ?").run(git.id), /immutable/);
+ assert.strictEqual((await db.prepare('SELECT commit_sha FROM space_deploy_git WHERE deploy_id = ?').get(git.id)).commit_sha, SHA256);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,210 @@
@@ -0,0 +1,210 @@
+'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, the API,
+ * cross-origin reads from a tenant page (no CORS grant anywhere), existence oracles (ETag, Range),
+ * delegated service tokens, app principals and the dashboard: a tenant only ever reaches its own
+ * site, deploys and domains.
+ */
+const assert = require('assert');
+const fs = require('fs');
+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.website', '/')).text, 'ALPHA HOME');
+ assert.strictEqual((await t.get('beta.openvibe.website', '/')).text, 'BETA HOME');
+ assert.strictEqual((await t.get('alpha.openvibe.website', '/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.website/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', '/space.db', '/../space.db', '/%2e%2e/space.db',
+ ];
+ for (const p of targets) {
+ const r = await t.get('alpha.openvibe.website', 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.WEBSITE', 'ALPHA HOME'], ['alpha.openvibe.website.', 'ALPHA HOME'], ['alpha.openvibe.website:443', 'ALPHA HOME'],
+ ['beta.alpha.openvibe.website', null], ['alpha.beta.openvibe.website', null], ['alpha.openvibe.website.evil.example', null],
+ ['[email protected]', null], ['alpha.openvibe.website:80:beta', null], ['alpha.openvibe.website/beta', null],
+ ['beta', null], ['openvibe.space.beta', null], ['-beta.openvibe.website', null], ['%62eta.openvibe.website', 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.space', '/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.website', 'http://beta.openvibe.website/secret.html');
+ assert.strictEqual(r.status, 400);
+ assert.ok(!leaks(r));
+ const ok = await t.get('beta.openvibe.website', 'http://beta.openvibe.website/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', '/limits.json', '/css/space.css']) {
+ const r = await t.get('alpha.openvibe.website', 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('the dashboard answers /limits.json from config (WS-N task 7)', async () => {
+ const r = await t.api('GET', '/limits.json');
+ assert.strictEqual(r.status, 200);
+ const by = Object.fromEntries(r.json().limits.map((l) => [l.id, l]));
+ assert.deepStrictEqual([by.sites.production, by.sites.sandbox], [t.config.quotas.production.sites, t.config.quotas.sandbox.sites]);
+ assert.strictEqual(by.projects_per_owner.production, t.config.projects.maxPerOwner);
+ assert.strictEqual(by.storage_bytes.exceeded, '413 quota.storage');
+ });
+
+ 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.website', '/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.website', '/')).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.website', '/')));
+ });
+
+ 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 check('a tenant page\'s scripts cannot read another tenant\'s files: no CORS grant on tenant responses, preflights refused', async () => {
+ for (const origin of ['https://alpha.openvibe.website', 'null', 'https://evil.example']) {
+ const r = await t.get('beta.openvibe.website', '/secret.html', { headers: { origin } });
+ assert.strictEqual(r.status, 200);
+ for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith('access-control-'), `beta answered ${origin} with ${h}`);
+ const pre = await t.request({ method: 'OPTIONS', host: 'beta.openvibe.website', path: '/secret.html', headers: { origin, 'access-control-request-method': 'GET' } });
+ assert.strictEqual(pre.status, 405);
+ assert.ok(!Object.keys(pre.headers).some((h) => h.startsWith('access-control-')));
+ }
+ assert.strictEqual((await t.get('beta.openvibe.website', '/secret.html')).headers['cross-origin-opener-policy'], 'same-origin');
+ });
+
+ await check('the Host API grants CORS to no tenant origin (a tenant page cannot call it from a visitor\'s browser)', async () => {
+ for (const origin of ['https://alpha.openvibe.website', 'https://beta.openvibe.website', 'https://openvibe.space.evil.example']) {
+ const r = await t.api('GET', '/api/v1/projects', { as: bob, headers: { origin } });
+ assert.strictEqual(r.headers['access-control-allow-origin'], undefined, origin);
+ const pre = await t.request({ method: 'OPTIONS', host: 'openvibe.space', path: '/api/v1/projects', headers: { origin, 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });
+ assert.strictEqual(pre.headers['access-control-allow-origin'], undefined, `preflight from ${origin}`);
+ }
+ });
+
+ await check('no existence oracle: another tenant\'s sha256 as If-None-Match or a Range on alpha is still a 404', async () => {
+ const r = await t.get('alpha.openvibe.website', '/secret.html', { headers: { 'if-none-match': `"${secretSha}"` } });
+ assert.strictEqual(r.status, 404);
+ const r2 = await t.get('alpha.openvibe.website', `/${secretSha}`, { headers: { 'if-none-match': `"${secretSha}"`, range: 'bytes=0-3' } });
+ assert.strictEqual(r2.status, 404);
+ assert.ok(!leaks(r) && !leaks(r2));
+ });
+
+ await check('delegation and app principals stay inside their own project', async () => {
+ const codes = t.network.serviceToken('codes', ['space.site.manage', 'space.deploy.create', 'space.domain.manage']);
+ for (const p of [`/api/v1/projects/${pb.id}`, `/api/v1/deploys/${db.id}`, `/api/v1/deploys/${db.id}/log`, `/api/v1/sites/${beta.id}/deploys`]) {
+ const r = await t.api('GET', p, { as: codes, headers: { 'x-ov-subject': alice.subject } });
+ assert.strictEqual(r.status, 404, `svc:codes for alice → ${p} ${r.status}`);
+ assert.ok(!leaks(r) && !r.text.includes(secretSha));
+ }
+ const ci = t.network.appToken('app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', ['space.deploy.create', 'space.site.manage']);
+ const add = await t.api('PUT', `/api/v1/projects/${pa.id}/members/app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3`, { as: alice, json: { role: 'deployer' } });
+ assert.strictEqual(add.status, 200, add.text);
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/deploys`, { as: ci })).status, 200, 'the app reads its own project');
+ for (const p of [`/api/v1/deploys/${db.id}`, `/api/v1/sites/${beta.id}/deploys`]) assert.strictEqual((await t.api('GET', p, { as: ci })).status, 404, p);
+ const up = await t.api('POST', `/api/v1/sites/${beta.id}/deploys?activate=1`, { as: ci, body: require('./stageb/tar').site({ 'index.html': 'PWNED' }), headers: { 'content-type': 'application/gzip' } });
+ assert.strictEqual(up.status, 404);
+ // X-OV-Subject is honoured for first-party services only: an app cannot borrow bob's identity.
+ const borrow = await t.api('GET', `/api/v1/deploys/${db.id}`, { as: ci, headers: { 'x-ov-subject': bob.subject } });
+ assert.strictEqual(borrow.status, 400);
+ assert.strictEqual(borrow.json().code, 'subject.not_delegable');
+ assert.ok(!leaks(borrow));
+ assert.strictEqual((await t.get('beta.openvibe.website', '/')).text, 'BETA HOME');
+ });
+
+ await check('the dashboard (cookie session) shows a non-member nothing of another tenant: no names, files or hashes', async () => {
+ for (const p of [`/projects/${pb.id}`, `/sites/${beta.id}`, `/deploys/${db.id}`]) {
+ const r = await t.api('GET', p, { session: alice });
+ assert.strictEqual(r.status, 404, `${p} → ${r.status}`);
+ assert.ok(!/secret\.html|BETA|beta-example/.test(r.text) && !r.text.includes(secretSha), `${p} leaked`);
+ }
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+/** Public hosting remains gated while the engine works for OpenVibe staff. */
+const assert = require('assert');
+const { boot, check, done } = require('./stageb/boot');
+
+(async () => {
+ const t = await boot({ env: { SPACE_PUBLIC_HOSTING: 'off' } });
+ const alice = t.user('alice');
+ const staff = t.user('root', { role: 'admin' });
+ await check('the public front page explains the tested engine and launch gate', async () => {
+ const r = await t.api('GET', '/');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Static site hosting is built and tested/);
+ assert.match(r.text, /OpenVibe staff only until public launch/);
+ assert.match(r.text, /designated DMCA agent/);
+ assert.match(r.text, /Dynamic pages/);
+ });
+ await check('a person cannot create a project until launch', async () => {
+ const r = await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'Too early' } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'space.not_launched');
+ });
+ await check('the dashboard explains why the form is unavailable', async () => {
+ const r = await t.api('GET', '/dashboard', { session: alice });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /not open for hosting yet/);
+ assert.doesNotMatch(r.text, /<form[^>]+action="\/projects"/);
+ });
+ await check('staff can create a project', async () => {
+ assert.match((await t.project(staff, 'Staff test')).id, /^prj_/);
+ });
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
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, /^sit_/);
+ assert.strictEqual(site.hostname, 'alice.openvibe.website');
+ assert.strictEqual(site.url, 'https://alice.openvibe.website');
+ 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.website', 'default', 'verified']]);
+ });
+
+ await check('before any deploy the site answers with its own 404', async () => {
+ const r = await t.get('alice.openvibe.website', '/');
+ 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.website', '/');
+ 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.website', '/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.website', '/img/logo.svg');
+ assert.strictEqual(svg.headers['content-type'], 'image/svg+xml');
+ const cname = await t.get('alice.openvibe.website', '/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.website', '/style.css');
+ const again = await t.get('alice.openvibe.website', '/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.website', '/style.css', { method: 'HEAD' });
+ assert.strictEqual(head.status, 200);
+ assert.strictEqual(head.headers['content-length'], '6');
+ const range = await t.get('alice.openvibe.website', '/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.website', '/style.css', { headers: { range: 'bytes=10-20' } });
+ assert.strictEqual(bad.status, 416);
+ const redirect = await t.get('alice.openvibe.website', '/about?x=1');
+ assert.strictEqual(redirect.status, 301);
+ assert.strictEqual(redirect.headers.location, '/about/?x=1');
+ const about = await t.get('alice.openvibe.website', '/about/');
+ assert.strictEqual(about.text, '<p>about</p>');
+ const missing = await t.get('alice.openvibe.website', '/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.website', '/', { 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.website', '/')).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.website', '/')).text, '<h1>v2</h1>');
+ assert.strictEqual((await t.get('alice.openvibe.website', '/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.website', '/')).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.website', '/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.website', '/')).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 = await t.events('space.deploy.created');
+ const activated = await t.events('space.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, 'space');
+ 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.website', '/')).status, 404);
+ assert.match((await t.get('alice.openvibe.website', '/')).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,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+/** Space discovery describes staff static hosting without listing tenant or private pages. */
+const assert = require('assert');
+const { boot, check, done, DASHBOARD } = require('./stageb/boot');
+
+(async () => {
+ const t = await boot();
+ await check('llms documents describe Space and omit tenant pages', async () => {
+ for (const file of ['/llms.txt', '/llms-full.txt']) {
+ const r = await t.get(DASHBOARD, file);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers['content-type'], /^text\/plain/);
+ assert.match(r.text, /OpenVibe.Space/);
+ assert.match(r.text, /OpenVibe.Community/);
+ assert.doesNotMatch(r.text, /\/preview\/|\/dashboard\/|\/api\/v1\/projects/);
+ }
+ });
+ await check('the public page and sitemap stay on Space', async () => {
+ const r = await t.get(DASHBOARD, '/');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Static site hosting is built and tested/);
+ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.space\/">/);
+ const map = await t.get(DASHBOARD, '/sitemap.xml');
+ assert.strictEqual(map.status, 200);
+ assert.deepStrictEqual([...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]), ['https://openvibe.space/']);
+ });
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+'use strict';
+/**
+ * Preview deploys (plan T12 J4, decision D5). Uploading a deploy with ?preview=1 stores it with
+ * source 'preview' and makes it the site's live preview (space_sites.preview_deploy_id) WITHOUT
+ * activating it: the public site keeps serving its active deploy. A preview is served only on the
+ * dashboard host, at /preview/<deploy-id>/…, only to a member of the deploy's project, always
+ * noindex and never cached by a shared cache; it is never pinged to IndexNow and never appears in
+ * the site's sitemap. It stops being served when it expires, when the site deploys or rolls back,
+ * or when the preview is deleted, and it can only reach its own project's objects.
+ */
+const assert = require('assert');
+const { boot, check, done, DASHBOARD } = require('./stageb/boot');
+const { PREVIEW_TTL_MS } = require('../server/domain/deploys');
+
+/** A spy in place of IndexNow's HTTP send: records every pingSoon batch (the repo's test pattern). */
+function makeSpy() {
+ const pings = [];
+ return {
+ pings,
+ module: {
+ enabled: true,
+ keyFile: (_req, _res, next) => next(),
+ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },
+ ping: async () => ({ sent: 0, status: 0 }),
+ flush: async () => ({ sent: 0, status: 0 }),
+ },
+ };
+}
+
+(async () => {
+ const s = makeSpy();
+ const t = await boot({ indexnow: s.module });
+ const alice = t.user('alice');
+ const bob = t.user('bob');
+ const pa = await t.project(alice, 'A');
+ const pb = await t.project(bob, 'B');
+ const alpha = await t.site(alice, pa.id, 'alpha');
+ const beta = await t.site(bob, pb.id, 'beta');
+ await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA LIVE', 'about.html': 'ALPHA ABOUT' });
+ await t.deploy(bob, beta.id, { 'index.html': 'BETA LIVE', 'secret.txt': 'BETA SECRET' });
+ s.pings.length = 0;
+
+ const preview = (id, p = '/') => `/preview/${id}${p}`;
+ const asMember = (id, p, user) => t.get(DASHBOARD, preview(id, p), { session: user });
+ let pv = null;
+
+ await check('a preview is stored as source=preview, points the site at it, and never goes active', async () => {
+ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW', 'about.html': 'PREVIEW ABOUT' }, { activate: false, query: 'preview=1' });
+ assert.strictEqual(r.status, 201, r.text);
+ const body = r.json();
+ pv = body.deploy;
+ assert.strictEqual(pv.source, 'preview');
+ assert.strictEqual(body.activated, false, 'a preview is never activated');
+ assert.ok(body.preview && body.preview.deploy_id === pv.id, r.text);
+ assert.ok(body.preview.expires_at > t.clock.now());
+ const pub = await t.get('alpha.openvibe.website', '/');
+ assert.strictEqual(pub.status, 200);
+ assert.strictEqual(pub.text, 'ALPHA LIVE', 'the public site keeps serving its active deploy');
+ assert.ok(!s.pings.some((u) => u.includes('/preview/')), 'a preview is never pinged');
+ });
+
+ await check('a member views the preview on the dashboard: 200, noindex, no-store, no cookie', async () => {
+ const r = await asMember(pv.id, '/', alice);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.text, 'ALPHA PREVIEW');
+ assert.strictEqual(r.headers['x-robots-tag'], 'noindex, nofollow');
+ assert.match(r.headers['cache-control'], /no-store/);
+ assert.strictEqual(r.headers['set-cookie'], undefined);
+ assert.strictEqual(r.headers['x-openvibe-deploy'], pv.id);
+ // Tenant content on the dashboard's own origin is sandboxed into an opaque origin: it can
+ // never act as openvibe.space (no allow-same-origin, never the dashboard's own CSP).
+ assert.match(r.headers['content-security-policy'], /sandbox /);
+ assert.ok(!r.headers['content-security-policy'].includes('allow-same-origin'), 'the preview must not keep the dashboard origin');
+ assert.ok(r.headers['content-security-policy'].startsWith("default-src 'none'"), 'not the dashboard CSP');
+ const file = await asMember(pv.id, '/about.html', alice);
+ assert.strictEqual(file.status, 200);
+ assert.strictEqual(file.text, 'PREVIEW ABOUT');
+ const head = await t.get(DASHBOARD, preview(pv.id), { method: 'HEAD', session: alice });
+ assert.strictEqual(head.status, 200);
+ assert.strictEqual(head.text, '');
+ });
+
+ await check('non-members and signed-out callers cannot view a preview', async () => {
+ assert.strictEqual((await asMember(pv.id, '/', bob)).status, 404, 'another project member must not see it');
+ assert.strictEqual((await t.get(DASHBOARD, preview(pv.id))).status, 404, 'signed out must not see it');
+ assert.strictEqual((await t.get(DASHBOARD, preview(pv.id), { as: bob })).status, 404, 'a bearer token is not a dashboard session');
+ assert.strictEqual((await asMember('dpl_00000000000000000000000000', '/', alice)).status, 404);
+ assert.strictEqual((await asMember('not-an-id', '/', alice)).status, 404);
+ });
+
+ await check('a preview is never served on a public tenant host', async () => {
+ for (const host of ['alpha.openvibe.website', 'beta.openvibe.website']) {
+ const r = await t.get(host, preview(pv.id));
+ assert.strictEqual(r.status, 404, `${host} → ${r.status}`);
+ assert.ok(!r.text.includes('ALPHA PREVIEW'), host);
+ }
+ });
+
+ await check('isolation: another project\'s preview and another project\'s files are unreachable', async () => {
+ const b = await t.upload(bob, beta.id, { 'index.html': 'BETA PREVIEW' }, { activate: false, query: 'preview=1' });
+ assert.strictEqual(b.status, 201, b.text);
+ const bpv = b.json().deploy;
+ assert.strictEqual((await asMember(bpv.id, '/', alice)).status, 404, 'alice is not on beta');
+ assert.strictEqual((await asMember(bpv.id, '/', bob)).status, 200);
+ assert.strictEqual((await asMember(pv.id, '/secret.txt', alice)).status, 404, 'alpha cannot read beta\'s same-named file');
+ for (const p of ['/../secret.txt', '/%2e%2e/secret.txt', '/a%2fb', '/%00']) {
+ assert.strictEqual((await asMember(pv.id, p, alice)).status, 404, p);
+ }
+ });
+
+ await check('a rollback makes the live preview vanish', async () => {
+ await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA LIVE 2' });
+ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 2' }, { activate: false, query: 'preview=1' });
+ const p2 = r.json().deploy;
+ assert.strictEqual((await asMember(p2.id, '/', alice)).status, 200);
+ const rb = await t.api('POST', `/api/v1/sites/${alpha.id}/rollback`, { as: alice, json: {} });
+ assert.strictEqual(rb.status, 200, rb.text);
+ assert.strictEqual((await asMember(p2.id, '/', alice)).status, 404, 'a rollback supersedes the preview');
+ });
+
+ await check('an expired preview vanishes', async () => {
+ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 3' }, { activate: false, query: 'preview=1' });
+ const p3 = r.json().deploy;
+ assert.strictEqual((await asMember(p3.id, '/', alice)).status, 200);
+ t.clock.advance(PREVIEW_TTL_MS + 1000);
+ assert.strictEqual((await asMember(p3.id, '/', alice)).status, 404, 'an expired preview is not served');
+ });
+
+ await check('deleting the preview deploy makes it vanish', async () => {
+ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 4' }, { activate: false, query: 'preview=1' });
+ const p4 = r.json().deploy;
+ assert.strictEqual((await asMember(p4.id, '/', alice)).status, 200);
+ const del = await t.api('DELETE', `/api/v1/deploys/${p4.id}`, { as: alice, json: {} });
+ assert.strictEqual(del.status, 200, del.text);
+ assert.strictEqual((await asMember(p4.id, '/', alice)).status, 404, 'a deleted preview is not served');
+ });
+
+ await check('a preview is never listed in the sitemap and never pinged', async () => {
+ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 5', 'page.html': 'PAGE' }, { activate: false, query: 'preview=1' });
+ const p5 = r.json().deploy;
+ assert.strictEqual((await asMember(p5.id, '/page.html', alice)).status, 200);
+ s.pings.length = 0;
+ const sm = await t.get('alpha.openvibe.website', '/sitemap.xml');
+ assert.strictEqual(sm.status, 200, sm.text);
+ assert.ok(!sm.text.includes('/preview/'), 'the sitemap never lists a preview URL');
+ assert.ok(!sm.text.includes(p5.id), 'the sitemap never names a preview deploy');
+ assert.ok(!sm.text.includes('ALPHA PREVIEW'), 'the sitemap cannot leak draft content');
+ assert.deepStrictEqual(s.pings, [], 'viewing a preview pings nothing');
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,216 @@
@@ -0,0 +1,216 @@
+'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: { SPACE_MAX_UPLOAD_BYTES: String(64 * 1024), SPACE_MAX_UNPACKED_BYTES: String(1024 * 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.website', '/')).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 SPACE_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('decompression: an archive unpacks to at most SPACE_MAX_UNPACKED_BYTES, however large the storage quota', async () => {
+ // A few KiB of gzip that expands to 2 MiB (the default production quota would allow 1 GiB:
+ // a 1 MB request would otherwise inflate to 1 GiB in memory and block the event loop).
+ const zeros = Buffer.alloc(512 * 1024, 0);
+ const bomb = siteTar({ 'a.png': zeros, 'b.png': zeros, 'c.png': zeros, 'd.png': zeros });
+ assert.ok(bomb.length < 64 * 1024, 'well under the request limit');
+ const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body: bomb, headers: { 'content-type': 'application/gzip' } });
+ assert.strictEqual(r.status, 413, r.text);
+ assert.strictEqual(r.json().code, 'deploy.too_large');
+ });
+
+ 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.website', '/')).headers['x-robots-tag'], 'noindex, nofollow');
+ });
+
+ // ── Principals ──────────────────────────────────────────
+ const ALL = ['space.site.manage', 'space.deploy.create', 'space.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', ['space.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', ['space.*']);
+ 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 space.* 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((await t.events('space.deploy.activated')).pop().actor, { type: 'app', id: appId });
+ assert.strictEqual((await t.get('quota-site.openvibe.website', '/')).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('a FedCM ID assertion is not a session: a tenant page on <site>.openvibe.space cannot act as its visitor', async () => {
+ // Network signs FedCM assertions with the key and issuer of its access tokens, for any RP
+ // origin under an OpenVibe zone, tenant subdomains of openvibe.space included.
+ const assertion = t.network.fedcmAssertion(alice, 'https://evil-tenant.openvibe.website');
+ const list = await t.api('GET', '/api/v1/projects', { as: assertion });
+ assert.strictEqual(list.status, 401, list.text);
+ const del = await t.api('DELETE', `/api/v1/projects/${project.id}`, { as: assertion });
+ assert.strictEqual(del.status, 401, del.text);
+ const dash = await t.request({ method: 'GET', host: 'openvibe.space', path: '/', headers: { cookie: `ov_token=${assertion}` } });
+ assert.ok(!/Your projects/.test(dash.text), 'the dashboard does not accept it as a session either');
+ });
+
+ 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.space'], 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.website', '/')).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.website', 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: (await db.prepare('SELECT active_deploy_id FROM space_sites WHERE id = ?').get(site.id)).active_deploy_id, acts: (await db.prepare('SELECT COUNT(*) AS n FROM space_activations').get()).n, events: (await 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((await db.prepare('SELECT active_deploy_id FROM space_sites WHERE id = ?').get(site.id)).active_deploy_id, before.active);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM space_activations').get()).n, before.acts);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM event_outbox').get()).n, before.events);
+ assert.strictEqual((await t.get('flip.openvibe.website', '/')).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;
+ await assert.rejects(db.prepare("UPDATE space_deploys SET manifest = '{}' WHERE id = ?").run(v1.id), /immutable/);
+ await assert.rejects(db.prepare('UPDATE space_deploys SET total_bytes = 1 WHERE id = ?').run(v1.id), /immutable/);
+ await assert.rejects(db.prepare("UPDATE space_deploy_files SET sha256 = 'x' WHERE deploy_id = ?").run(v1.id), /immutable/);
+ await assert.rejects(db.prepare('DELETE FROM space_deploy_files WHERE deploy_id = ?').run(v1.id), /live deploy/);
+ await db.prepare("UPDATE space_deploys SET state = 'deleted' WHERE id = (SELECT id FROM space_deploys WHERE state = 'failed' LIMIT 1)").run();
+ await assert.rejects(db.prepare("UPDATE space_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.website', '/')).text, 'version-2');
+ assert.strictEqual((await t.get('flip.openvibe.website', '/only-v2.txt')).text, 'yes');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+/** Hosting responses do not expose secrets; Space's own auth protects dashboard forms. */
+const assert = require('assert');
+const { boot, check, done, SECRET } = require('./stageb/boot');
+const { tarball } = require('./stageb/tar');
+const { csrfToken } = require('../server/auth/forms');
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice');
+ let project, site;
+ await check('API, pages and events contain no upload or service secrets', async () => {
+ const bodies = [];
+ const keep = (r) => { bodies.push(r.text); return r; };
+ project = keep(await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'Secrets' } })).json().project;
+ site = keep(await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: alice, json: { name: 'secret-test' } })).json().site;
+ const deploy = keep(await t.upload(alice, site.id, { 'index.html': 'hello' })).json().deploy;
+ const bad = keep(await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body: tarball([
+ { name: 'index.html', content: 'x' }, { name: '.env', content: 'STRIPE_SECRET_KEY=sk_live_TOPSECRET123' },
+ ]), headers: { 'content-type': 'application/gzip' } }));
+ assert.strictEqual(bad.status, 422);
+ for (const route of ['/', '/dashboard', `/projects/${project.id}`, `/sites/${site.id}`, `/deploys/${deploy.id}`, '/api/ready', '/release.json']) {
+ keep(await t.api('GET', route, { session: alice }));
+ }
+ for (const route of [`/api/v1/projects/${project.id}`, `/api/v1/sites/${site.id}`, `/api/v1/deploys/${deploy.id}/log`]) keep(await t.api('GET', route, { as: alice }));
+ const all = bodies.join('\n') + JSON.stringify(await t.events());
+ for (const needle of [SECRET, 'test-form-secret', 'sk_live_TOPSECRET123', 'STRIPE_SECRET_KEY', 'OV_OAUTH_CLIENT_SECRET', 'SPACE_FORM_SECRET']) assert.ok(!all.includes(needle), needle);
+ });
+ await check('dashboard forms require Space session, matching origin and CSRF token', async () => {
+ const home = await t.api('GET', '/dashboard', { session: alice });
+ assert.strictEqual(home.status, 200);
+ assert.match(home.text, /Your projects/);
+ const csrf = csrfToken(t.config, { subject: alice.subject });
+ const action = `/projects/${project.id}/sites`;
+ const cross = await t.api('POST', action, { session: alice, form: { csrf, name: 'cross-site' }, headers: { origin: 'https://attacker.example' } });
+ assert.strictEqual(cross.status, 403);
+ const missing = await t.api('POST', action, { session: alice, form: { name: 'no-token' }, headers: { origin: 'https://openvibe.space' } });
+ assert.strictEqual(missing.status, 403);
+ const valid = await t.api('POST', action, { session: alice, form: { csrf, name: 'from-form' }, headers: { origin: 'https://openvibe.space' } });
+ assert.strictEqual(valid.status, 303);
+ });
+ await check('hosting API ignores browser cookies', async () => {
+ const r = await t.api('GET', '/api/v1/projects', { session: alice });
+ assert.strictEqual(r.status, 401);
+ });
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+'use strict';
+/**
+ * Per-site configuration (plan T12 J3, decision D4): response headers, local-only redirects and the
+ * SPA fallback, stored per site (space_site_config) and applied by http/tenant.js BEFORE the
+ * platform's headers. The platform's security, routing and caching headers can never be set through
+ * it (on write or from a smuggled row); a redirect target is always a local path; and a config is
+ * private to its site and project.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./stageb/boot');
+
+const EMPTY = { headers: {}, redirects: [], spa: false };
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice');
+ const bob = t.user('bob');
+ const staff = t.user('root', { role: 'admin' });
+ const pa = await t.project(alice, 'A');
+ const pb = await t.project(bob, 'B');
+ const alpha = await t.site(alice, pa.id, 'alpha');
+ const beta = await t.site(bob, pb.id, 'beta');
+ await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA HOME', 'about.html': 'ALPHA ABOUT', 'app.3f2a9c1b.js': 'js();', '404.html': 'ALPHA NOT FOUND' });
+ await t.deploy(bob, beta.id, { 'index.html': 'BETA HOME' });
+
+ const put = (as, id, json) => t.api('PUT', `/api/v1/sites/${id}/config`, { as, json });
+
+ await check('defaults: no row means no headers, no redirects, no SPA', async () => {
+ const r = await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: alice });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json().config, EMPTY);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM space_site_config').get()).n, 0);
+ });
+
+ await check('the API sets, reads and resets a config', async () => {
+ const set = await put(alice, alpha.id, {
+ headers: { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' },
+ redirects: [{ from: '/old', to: '/new', status: 308 }, { from: '/moved', to: '/about.html' }],
+ spa: true,
+ });
+ assert.strictEqual(set.status, 200, set.text);
+ assert.deepStrictEqual(set.json().config.headers, { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' });
+ assert.deepStrictEqual(set.json().config.redirects, [{ from: '/old', to: '/new', status: 308 }, { from: '/moved', to: '/about.html', status: 301 }]);
+ assert.strictEqual(set.json().config.spa, true);
+ assert.deepStrictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: alice })).json().config, set.json().config);
+ const del = await t.api('DELETE', `/api/v1/sites/${alpha.id}/config`, { as: alice });
+ assert.strictEqual(del.status, 200, del.text);
+ assert.deepStrictEqual(del.json().config, EMPTY);
+ assert.strictEqual((await t.get('alpha.openvibe.website', '/old')).status, 404, 'reset drops the redirect');
+ });
+
+ await check('configured headers ride on 200, the 404 and the 451 after a takedown', async () => {
+ assert.strictEqual((await put(alice, alpha.id, { headers: { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' } })).status, 200);
+ const ok = await t.get('alpha.openvibe.website', '/');
+ assert.strictEqual(ok.status, 200);
+ assert.strictEqual(ok.headers['x-frame-options'], 'SAMEORIGIN');
+ assert.strictEqual(ok.headers['x-content-language'], 'en');
+ assert.strictEqual(ok.headers['x-served-by'], 'OpenVibe.Space');
+ const nf = await t.get('alpha.openvibe.website', '/nope.html');
+ assert.strictEqual(nf.status, 404);
+ assert.strictEqual(nf.text, 'ALPHA NOT FOUND');
+ assert.strictEqual(nf.headers['x-frame-options'], 'SAMEORIGIN');
+ const tk = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'config test' } });
+ assert.strictEqual(tk.status, 201, tk.text);
+ const down = await t.get('alpha.openvibe.website', '/');
+ assert.strictEqual(down.status, 451);
+ assert.strictEqual(down.headers['x-frame-options'], 'SAMEORIGIN');
+ assert.strictEqual(down.headers['clear-site-data'], '"cache", "storage"');
+ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff })).status, 200);
+ assert.strictEqual((await t.get('alpha.openvibe.website', '/')).status, 200);
+ });
+
+ await check('reserved headers and header injection are refused on write', async () => {
+ for (const name of ['Content-Security-Policy', 'Strict-Transport-Security', 'Set-Cookie', 'X-Forwarded-For', 'X-Forwarded-Proto', 'Cache-Control', 'Content-Type', 'X-Content-Type-Options', 'Location', 'Service-Worker-Allowed', 'Alt-Svc']) {
+ const r = await put(alice, alpha.id, { headers: { [name]: 'x' } });
+ assert.strictEqual(r.status, 422, `${name} → ${r.status} ${r.text}`);
+ assert.strictEqual(r.json().code, 'site_config.reserved_header', name);
+ }
+ const inj = await put(alice, alpha.id, { headers: { 'X-Ok': 'a\r\nSet-Cookie: sid=1' } });
+ assert.strictEqual(inj.status, 422);
+ assert.strictEqual(inj.json().code, 'site_config.header_value');
+ assert.strictEqual((await t.get('alpha.openvibe.website', '/')).headers['set-cookie'], undefined);
+ });
+
+ await check('a reserved header smuggled into the row is never served (platform headers win)', async () => {
+ await t.ctx.store.db.prepare(`INSERT INTO space_site_config (site_id, headers, redirects, spa, updated_by, updated_at)
+ VALUES (?, ?::jsonb, ?::jsonb, false, ?, ?)
+ ON CONFLICT (site_id) DO UPDATE SET headers = EXCLUDED.headers`)
+ .run(alpha.id, JSON.stringify({
+ 'Content-Security-Policy': 'default-src *', 'Strict-Transport-Security': 'max-age=0',
+ 'Set-Cookie': 'sid=1', 'X-Forwarded-For': '1.2.3.4', 'X-Custom': 'kept',
+ }), JSON.stringify([]), 'test', t.clock.now());
+ const r = await t.get('alpha.openvibe.website', '/');
+ assert.match(r.headers['content-security-policy'], /default-src 'self'/);
+ assert.ok(!/default-src \*/.test(r.headers['content-security-policy']), 'the tenant CSP cannot be replaced');
+ assert.strictEqual(r.headers['strict-transport-security'], undefined);
+ assert.strictEqual(r.headers['set-cookie'], undefined);
+ assert.strictEqual(r.headers['x-forwarded-for'], undefined);
+ assert.strictEqual(r.headers['x-custom'], 'kept', 'a non-reserved header in the same row still applies');
+ });
+
+ await check('no open redirect: a foreign target is refused on write; a local one is kept', async () => {
+ for (const to of ['//evil.com', '/\\evil.com', 'https://evil.com', 'http:evil.com', '/\t/evil.com', '/%09/evil.com', '///evil.com', '/%2f%2fevil.com', '/\n/evil.com', 'javascript:alert(1)', ' //evil.com', '\\evil.com']) {
+ const r = await put(alice, alpha.id, { redirects: [{ from: '/go', to }] });
+ assert.strictEqual(r.status, 422, `${JSON.stringify(to)} → ${r.status} ${r.text}`);
+ assert.strictEqual(r.json().code, 'site_config.redirect');
+ }
+ assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: 'https://evil.com/x', to: '/x' }] })).status, 422, 'a foreign "from" is refused too');
+ assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: '/go', to: '/new', status: 200 }] })).status, 422, 'only 301/302/307/308');
+ assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: '/old', to: '/about.html', status: 308 }] })).status, 200);
+ const r = await t.get('alpha.openvibe.website', '/old');
+ assert.strictEqual(r.status, 308);
+ assert.strictEqual(r.headers.location, '/about.html');
+ });
+
+ await check('SPA fallback: extensionless paths serve index.html; an extension is still a 404', async () => {
+ assert.strictEqual((await put(alice, alpha.id, { spa: true })).status, 200, 'spa only');
+ for (const p of ['/app', '/app/dashboard', '/settings/']) {
+ const r = await t.get('alpha.openvibe.website', p);
+ assert.strictEqual(r.status, 200, `${p} → ${r.status}`);
+ assert.strictEqual(r.text, 'ALPHA HOME');
+ assert.strictEqual(r.headers['content-type'], 'text/html; charset=utf-8');
+ }
+ assert.strictEqual((await t.get('alpha.openvibe.website', '/about.html')).text, 'ALPHA ABOUT', 'a real file wins');
+ for (const p of ['/missing.js', '/app/missing.css', '/nope.json']) assert.strictEqual((await t.get('alpha.openvibe.website', p)).status, 404, p);
+ assert.strictEqual((await put(alice, alpha.id, { spa: false })).status, 200);
+ assert.strictEqual((await t.get('alpha.openvibe.website', '/app/dashboard')).status, 404, 'SPA off restores the 404');
+ });
+
+ await check('a config is private to its site and project (404 to non-members; no leak to another site)', async () => {
+ assert.strictEqual((await put(alice, alpha.id, { headers: { 'X-Alpha-Secret': 'alpha-only' } })).status, 200);
+ for (const [method, json] of [['GET', undefined], ['PUT', { headers: { 'X-Evil': '1' } }], ['DELETE', undefined]]) {
+ const r = await t.api(method, `/api/v1/sites/${alpha.id}/config`, { as: bob, json });
+ assert.strictEqual(r.status, 404, `${method} → ${r.status} ${r.text}`);
+ }
+ const b = await t.get('beta.openvibe.website', '/');
+ assert.strictEqual(b.text, 'BETA HOME');
+ assert.strictEqual(b.headers['x-alpha-secret'], undefined);
+ // A service token needs space.site.config AND the acting principal's project role.
+ const noCap = t.network.serviceToken('codes', ['space.site.manage']);
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: noCap, headers: { 'x-ov-subject': alice.subject } })).status, 403);
+ const stranger = t.network.serviceToken('codes', ['space.site.config']);
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: stranger, headers: { 'x-ov-subject': bob.subject } })).status, 404, 'a non-member service learns nothing');
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: stranger, headers: { 'x-ov-subject': alice.subject } })).status, 200);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * Tenant sitemap.xml and robots.txt when the deploy ships none: the generated sitemap lists the
+ * manifest's HTML pages (index → the directory URL, the error page left out) on the host the request
+ * came in on, so a verified custom domain gets its own copy under its own name; robots welcomes
+ * crawlers to a production site and keeps a sandbox out (Disallow: /). A file the tenant uploaded at
+ * either path always wins. Neither generated body carries a secret, and both say how to cache them.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./stageb/boot');
+
+const HOST = 'widgets.openvibe.website';
+const CUSTOM = 'www.widgets-example.org';
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice', { display_name: 'Alice' });
+ const project = await t.project(alice, 'Widgets');
+ const site = await t.site(alice, project.id, 'widgets');
+ const origin = `https://${HOST}`;
+
+ await t.deploy(alice, site.id, {
+ 'index.html': '<h1>Home</h1>',
+ 'about.html': '<h1>About</h1>',
+ 'docs/index.html': '<h1>Docs</h1>',
+ '404.html': '<h1>Gone</h1>',
+ 'style.css': 'body{}',
+ 'notes.txt': 'hi',
+ }, { activate: true });
+
+ await check('a generated sitemap lists the manifest HTML paths on this host (index → /, 404 left out)', async () => {
+ const r = await t.get(HOST, '/sitemap.xml');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.match(r.headers['content-type'], /application\/xml/);
+ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
+ assert.deepStrictEqual([...new Set(locs)].sort(), [`${origin}/`, `${origin}/about.html`, `${origin}/docs/`].sort());
+ });
+
+ await check('a generated robots.txt welcomes crawlers and names the sitemap', async () => {
+ const r = await t.get(HOST, '/robots.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers['content-type'], /text\/plain/);
+ assert.match(r.text, /^User-agent: \*\nAllow: \/\n/);
+ assert.ok(r.text.includes(`Sitemap: ${origin}/sitemap.xml`), r.text);
+ });
+
+ await check('a verified custom domain serves its own generated sitemap under its own name', async () => {
+ const add = await t.api('POST', `/api/v1/sites/${site.id}/domains`, { as: alice, json: { hostname: CUSTOM } });
+ assert.strictEqual(add.status, 201, add.text);
+ const dom = add.json().domain;
+ t.dns.set(`_openvibe-space.${CUSTOM}`, [dom.instructions.verification.value]);
+ const v = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });
+ assert.strictEqual(v.json().domain.status, 'verified', v.text);
+ const r = await t.get(CUSTOM, '/sitemap.xml');
+ assert.strictEqual(r.status, 200, r.text);
+ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
+ assert.deepStrictEqual([...new Set(locs)].sort(), [`https://${CUSTOM}/`, `https://${CUSTOM}/about.html`, `https://${CUSTOM}/docs/`].sort());
+ const rb = await t.get(CUSTOM, '/robots.txt');
+ assert.ok(rb.text.includes(`Sitemap: https://${CUSTOM}/sitemap.xml`), rb.text);
+ });
+
+ await check("the tenant's own sitemap.xml and robots.txt win", async () => {
+ await t.deploy(alice, site.id, {
+ 'index.html': '<h1>Home</h1>',
+ 'sitemap.xml': '<?xml version="1.0" encoding="UTF-8"?><urlset><url><loc>https://example.com/own</loc></url></urlset>',
+ 'robots.txt': 'User-agent: *\nDisallow: /private\n',
+ }, { activate: true });
+ const sm = await t.get(HOST, '/sitemap.xml');
+ assert.strictEqual(sm.status, 200);
+ assert.match(sm.headers['content-type'], /application\/xml/);
+ assert.ok(sm.text.includes('https://example.com/own'), sm.text);
+ assert.ok(!sm.text.includes('/about.html'), sm.text);
+ const rb = await t.get(HOST, '/robots.txt');
+ assert.strictEqual(rb.text, 'User-agent: *\nDisallow: /private\n');
+ assert.match(rb.headers['content-type'], /text\/plain/);
+ });
+
+ await check('a sandbox site generates Disallow: / (and stays noindex)', async () => {
+ const sandbox = await t.project(alice, 'Sandbox', { environment: 'sandbox' });
+ const sb = await t.site(alice, sandbox.id, 'playground');
+ await t.deploy(alice, sb.id, { 'index.html': '<h1>Play</h1>' }, { activate: true });
+ const r = await t.get(`${sb.name}.openvibe.website`, '/robots.txt');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.match(r.text, /^User-agent: \*\nDisallow: \/\n*$/);
+ assert.ok(!r.text.includes('Sitemap'), r.text);
+ assert.strictEqual(r.headers['x-robots-tag'], 'noindex, nofollow');
+ });
+
+ await check('generated files are cacheable, answer HEAD, and never carry a secret', async () => {
+ await t.deploy(alice, site.id, { 'index.html': '<h1>Home</h1>', 'about.html': '<h1>About</h1>' }, { activate: true });
+ const sm = await t.get(HOST, '/sitemap.xml');
+ const rb = await t.get(HOST, '/robots.txt');
+ assert.match(sm.headers['cache-control'], /max-age=/);
+ assert.match(rb.headers['cache-control'], /max-age=/);
+ const head = await t.get(HOST, '/sitemap.xml', { method: 'HEAD' });
+ assert.strictEqual(head.status, 200);
+ assert.strictEqual(head.text, '');
+ for (const r of [sm, rb]) {
+ for (const secretish of [t.SECRET, '_openvibe-space', 'openvibe-space-verification', 'prj_', 'sit_', 'dpl_', 'dom_']) {
+ assert.ok(!r.text.includes(secretish), `${secretish} in ${r.text}`);
+ }
+ assert.strictEqual(r.headers['set-cookie'], undefined);
+ assert.match(r.headers['content-security-policy'], /default-src 'self'/);
+ }
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,309 @@
@@ -0,0 +1,309 @@
+'use strict';
+/**
+ * Host's Media-backed object store (SPACE_OBJECT_STORE=media, plan T12): local disk is the read cache,
+ * OpenVibe.Media is the source of truth. Against a fake Media Object API v2 (test/stageb/media.js)
+ * and the same boot() as the other Stage B suites.
+ *
+ * - a deploy writes every object through to Media and serves from the local cache;
+ * - a deploy fails (502 storage.object_store) when Media refuses the write;
+ * - a local cache miss re-fetches from Media and verifies the sha256; a tampered object is refused;
+ * - identical bytes in one project are stored in Media once;
+ * - GC deletes a Media object only when no deploy references it;
+ * - project removal deletes the project's Media objects;
+ * - with SPACE_OBJECT_STORE unset nothing calls Media at all.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./stageb/boot');
+const { startMedia } = require('./stageb/media');
+const { createBlobStore, MEDIA_SCOPE } = require('../server/storage');
+const { createWorker } = require('../server/worker');
+
+const cachePath = (t, projectId, sha) => path.join(t.dir, 'objects', 'projects', projectId, sha.slice(0, 2), sha);
+const shaOf = (s) => crypto.createHash('sha256').update(s).digest('hex');
+/** A store built directly (not through the app) with the test's fake Media and mock Network. */
+const directStore = (t, media, { dir, isReferenced, fetchTimeoutMs } = {}) => createBlobStore(
+ dir || fs.mkdtempSync(path.join(t.dir, 'direct-')),
+ {
+ media: {
+ url: media.url, namespace: 'space', scope: MEDIA_SCOPE, clientId: 'space', clientSecret: t.SECRET,
+ tokenUrl: `${t.network.url}/oauth/token`,
+ ...(fetchTimeoutMs ? { fetchTimeoutMs } : {}),
+ },
+ isReferenced,
+ },
+);
+const filesOf = async (t, as, deployId) => (await t.api('GET', `/api/v1/deploys/${deployId}`, { as })).json().deploy.files;
+
+(async () => {
+ const media = await startMedia();
+ const t = await boot({ env: { SPACE_OBJECT_STORE: 'media', OV_MEDIA_URL: media.url } });
+ const alice = t.user('alice');
+
+ await check('booting with SPACE_OBJECT_STORE=media selects the Media store', async () => {
+ assert.strictEqual(t.config.objectStore.mode, 'media');
+ assert.strictEqual(t.config.media.url, media.url);
+ });
+
+ const pa = await t.project(alice, 'A');
+ const sa = await t.site(alice, pa.id, 'mediasite');
+ let firstFiles = [];
+ await check('a deploy writes every object through to Media, private and keyed per project', async () => {
+ const d = await t.deploy(alice, sa.id, { 'index.html': 'HELLO MEDIA', 'app.js': 'console.log(1)' });
+ firstFiles = await filesOf(t, alice, d.id);
+ assert.strictEqual(firstFiles.length, 2);
+ for (const f of firstFiles) {
+ const o = media.byHash(pa.id, f.sha256);
+ assert.ok(o, `Media holds ${f.path}`);
+ assert.strictEqual(o.visibility, 'private');
+ assert.strictEqual(o.lifecycle_status, 'ready');
+ assert.strictEqual(o.metadata.project_id, pa.id);
+ assert.ok(fs.existsSync(cachePath(t, pa.id, f.sha256)), `${f.path} is cached locally`);
+ }
+ assert.strictEqual((await t.get('mediasite.openvibe.website', '/')).text, 'HELLO MEDIA');
+ });
+
+ await check('a deploy fails when Media refuses the write (never a local-only success)', async () => {
+ const pb = await t.project(alice, 'B');
+ const sb = await t.site(alice, pb.id, 'refuses');
+ media.failUpload = true;
+ try {
+ const r = await t.upload(alice, sb.id, { 'index.html': 'NOPE' });
+ assert.strictEqual(r.status, 502, r.text);
+ assert.strictEqual(r.json().code, 'storage.object_store');
+ assert.ok(r.json().deploy_id, 'the failed deploy is recorded');
+ assert.strictEqual(media.readyFor(pb.id).length, 0, 'nothing reached Media as ready');
+ assert.strictEqual((await t.get('refuses.openvibe.website', '/')).status, 404, 'nothing is served');
+ } finally { media.failUpload = false; }
+ // The next deploy of the same project goes through once Media recovers.
+ await t.deploy(alice, sb.id, { 'index.html': 'RECOVERED' });
+ assert.strictEqual((await t.get('refuses.openvibe.website', '/')).text, 'RECOVERED');
+ });
+
+ await check('a local miss re-fetches from Media and verifies the sha256; tampered bytes are refused', async () => {
+ const sha = firstFiles.find((f) => f.path === 'index.html').sha256;
+ const local = cachePath(t, pa.id, sha);
+ fs.rmSync(local);
+ assert.ok(!fs.existsSync(local), 'evicted the local cache');
+ const ok = await t.get('mediasite.openvibe.website', '/');
+ assert.strictEqual(ok.status, 200, ok.text);
+ assert.strictEqual(ok.text, 'HELLO MEDIA');
+ assert.ok(fs.existsSync(local), 'a verified fetch is cached again');
+
+ const obj = media.byHash(pa.id, sha);
+ fs.rmSync(local);
+ media.tamper.add(obj.id);
+ const bad = await t.get('mediasite.openvibe.website', '/');
+ assert.strictEqual(bad.status, 500);
+ assert.ok(!bad.text.includes('HELLO MEDIA'), 'tampered bytes are never served');
+ assert.ok(!fs.existsSync(local), 'tampered bytes are never cached');
+ media.tamper.delete(obj.id);
+ });
+
+ await check('identical bytes in one project are stored in Media once', async () => {
+ const before = media.countInits();
+ const d = await t.deploy(alice, sa.id, { 'index.html': 'HELLO MEDIA', 'extra.txt': 'EXTRA' });
+ const files = await filesOf(t, alice, d.id);
+ assert.strictEqual(media.countInits(), before + 1, 'only the new file is uploaded; index.html is reused');
+ // One Media object per unique sha of the project (index.html, app.js, extra.txt).
+ const uniq = new Set([...firstFiles, ...files].map((f) => f.sha256));
+ assert.strictEqual(uniq.size, 3);
+ assert.strictEqual(media.readyFor(pa.id).length, uniq.size);
+ });
+
+ await check('GC deletes a Media object only when no remaining deploy references it', async () => {
+ const pc = await t.project(alice, 'C');
+ const sc = await t.site(alice, pc.id, 'gamma');
+ const g1 = await t.deploy(alice, sc.id, { 'a.html': 'AAA', 'shared.txt': 'SHARED' });
+ const g2 = await t.deploy(alice, sc.id, { 'b.html': 'BBB', 'shared.txt': 'SHARED' }); // now active
+ assert.ok(g2.active);
+ const f1 = await filesOf(t, alice, g1.id);
+ const shaA = f1.find((f) => f.path === 'a.html').sha256;
+ const shaShared = f1.find((f) => f.path === 'shared.txt').sha256;
+ const del = await t.api('DELETE', `/api/v1/deploys/${g1.id}`, { as: alice });
+ assert.strictEqual(del.status, 200, del.text);
+ assert.strictEqual(media.byHash(pc.id, shaA), null, 'the unreferenced object is deleted from Media');
+ assert.ok(media.byHash(pc.id, shaShared), 'an object a remaining deploy references is kept');
+ assert.strictEqual((await t.get('gamma.openvibe.website', '/shared.txt')).text, 'SHARED');
+ });
+
+ await check('project removal deletes the project\'s Media objects, and nobody else\'s', async () => {
+ const pd = await t.project(alice, 'D');
+ const sd = await t.site(alice, pd.id, 'removed');
+ const d1 = await t.deploy(alice, sd.id, { 'x.html': 'XXXX' });
+ const shaX = (await filesOf(t, alice, d1.id)).find((f) => f.path === 'x.html').sha256;
+ const pe = await t.project(alice, 'E');
+ const se = await t.site(alice, pe.id, 'kept');
+ const d2 = await t.deploy(alice, se.id, { 'y.html': 'YYYY' });
+ const shaY = (await filesOf(t, alice, d2.id)).find((f) => f.path === 'y.html').sha256;
+ assert.ok(media.byHash(pd.id, shaX) && media.byHash(pe.id, shaY));
+ const rm = await t.api('DELETE', `/api/v1/projects/${pd.id}`, { as: alice });
+ assert.strictEqual(rm.status, 200, rm.text);
+ assert.strictEqual(media.byHash(pd.id, shaX), null, 'the removed project\'s object is gone from Media');
+ assert.ok(media.byHash(pe.id, shaY), 'another project\'s object is untouched');
+ assert.strictEqual((await t.get('kept.openvibe.website', '/y.html')).text, 'YYYY');
+ });
+
+ await check('with SPACE_OBJECT_STORE unset nothing calls Media', async () => {
+ const media2 = await startMedia();
+ const t2 = await boot({ env: { OV_MEDIA_URL: media2.url } });
+ try {
+ const bob = t2.user('bob');
+ const p = await t2.project(bob, 'L');
+ const s = await t2.site(bob, p.id, 'localonly');
+ await t2.deploy(bob, s.id, { 'index.html': 'LOCAL ONLY' });
+ assert.strictEqual(media2.calls.length, 0, 'the local store never talks to Media');
+ assert.strictEqual((await t2.get('localonly.openvibe.website', '/')).text, 'LOCAL ONLY');
+ } finally { await t2.close(); await media2.close(); }
+ });
+
+ await check('a zero-byte file is stored locally only and never sent to Media', async () => {
+ const pz = await t.project(alice, 'Z');
+ const sz = await t.site(alice, pz.id, 'zero');
+ const before = media.countInits();
+ const d = await t.deploy(alice, sz.id, { 'index.html': 'ZERO SITE', 'empty.txt': '' });
+ const empty = (await filesOf(t, alice, d.id)).find((f) => f.path === 'empty.txt');
+ assert.strictEqual(empty.size, 0);
+ assert.strictEqual(media.countInits(), before + 1, 'only index.html is uploaded; the empty file is not');
+ assert.strictEqual(media.byHash(pz.id, empty.sha256), null, 'Media has no object for the empty file');
+ assert.ok(fs.existsSync(cachePath(t, pz.id, empty.sha256)), 'the empty file is in the local cache');
+ const r = await t.get('zero.openvibe.website', '/empty.txt');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.text, '');
+ });
+
+ await check('put uploads a fresh blob directly and looks up only an already-cached one', async () => {
+ const dir = fs.mkdtempSync(path.join(t.dir, 'direct-put-'));
+ const s1 = directStore(t, media, { dir });
+ const blob = Buffer.from('DIRECT UPLOAD');
+ const h = shaOf('DIRECT UPLOAD');
+ const lists0 = media.countLists();
+ await s1.put(pa.id, h, blob);
+ assert.strictEqual(media.countLists(), lists0, 'a fresh blob is uploaded without listing the namespace');
+ assert.ok(media.byHash(pa.id, h));
+ // A restart: a new store over the same warm cache, with no in-memory id map.
+ const s2 = directStore(t, media, { dir });
+ const inits = media.countInits();
+ const lists1 = media.countLists();
+ await s2.put(pa.id, h, blob);
+ assert.strictEqual(media.countInits(), inits, 'the cached blob is not uploaded a second time');
+ assert.ok(media.countLists() > lists1, 'the already-cached blob is looked up once');
+ assert.strictEqual(media.ready().filter((o) => o.metadata.project_id === pa.id && o.content_hash === h).length, 1);
+ });
+
+ await check('concurrent puts of one new sha upload it once', async () => {
+ const store = directStore(t, media, { dir: fs.mkdtempSync(path.join(t.dir, 'direct-race-')) });
+ const blob = Buffer.from('RACE');
+ const h = shaOf('RACE');
+ const inits = media.countInits();
+ const changed = await Promise.all([store.put(pa.id, h, blob), store.put(pa.id, h, blob), store.put(pa.id, h, blob)]);
+ assert.deepStrictEqual(changed, [true, false, false]);
+ assert.strictEqual(media.countInits(), inits + 1, 'three concurrent puts, one upload');
+ assert.strictEqual(media.ready().filter((o) => o.metadata.project_id === pa.id && o.content_hash === h).length, 1);
+ });
+
+ await check('remove deletes every Media object for a blob, not just the first', async () => {
+ const store = directStore(t, media, { dir: fs.mkdtempSync(path.join(t.dir, 'direct-dupes-')) });
+ const blob = Buffer.from('DUPLICATE');
+ const h = shaOf('DUPLICATE');
+ media.seed(pa.id, blob);
+ media.seed(pa.id, blob); // two pre-existing duplicates (from before puts were serialised)
+ await store.put(pa.id, h, blob); // plus the cache and one more object
+ const countOf = () => media.ready().filter((o) => o.metadata.project_id === pa.id && o.content_hash === h).length;
+ assert.strictEqual(countOf(), 3);
+ assert.strictEqual(await store.remove(pa.id, h), true);
+ assert.strictEqual(countOf(), 0, 'every duplicate is gone');
+ assert.ok(!fs.existsSync(store.pathFor(pa.id, h)), 'the cache file is gone');
+ });
+
+ await check('remove keeps a Media object and cache that space_blobs re-references mid-sweep', async () => {
+ let referenced = false;
+ const store = directStore(t, media, {
+ dir: fs.mkdtempSync(path.join(t.dir, 'direct-ref-')),
+ isReferenced: async () => referenced,
+ });
+ const blob = Buffer.from('RE-REFERENCED');
+ const h = shaOf('RE-REFERENCED');
+ await store.put(pa.id, h, blob);
+ referenced = true; // a concurrent deploy reused the sha after the caller decided it was unreferenced
+ assert.strictEqual(await store.remove(pa.id, h), false, 'remove reports nothing removed');
+ assert.ok(media.byHash(pa.id, h), 'the durable copy stays in Media');
+ assert.ok(fs.existsSync(store.pathFor(pa.id, h)), 'the cache stays');
+ referenced = false;
+ assert.strictEqual(await store.remove(pa.id, h), true);
+ assert.strictEqual(media.byHash(pa.id, h), null, 'once unreferenced, remove deletes it');
+ assert.ok(!fs.existsSync(store.pathFor(pa.id, h)));
+ });
+
+ await check('concurrent cache misses for one blob stream it in once and rename it in', async () => {
+ const store = directStore(t, media, { dir: fs.mkdtempSync(path.join(t.dir, 'direct-coalesce-')) });
+ const blob = Buffer.from('COALESCE ME');
+ const h = shaOf('COALESCE ME');
+ await store.put(pa.id, h, blob);
+ fs.rmSync(store.pathFor(pa.id, h));
+ const downloads = media.countDownloads();
+ const files = await Promise.all([store.ensure(pa.id, h), store.ensure(pa.id, h), store.ensure(pa.id, h)]);
+ assert.deepStrictEqual(files, [store.pathFor(pa.id, h), store.pathFor(pa.id, h), store.pathFor(pa.id, h)]);
+ assert.strictEqual(media.countDownloads(), downloads + 1, 'one download for three misses');
+ assert.strictEqual(fs.readFileSync(files[0], 'utf8'), 'COALESCE ME');
+ assert.deepStrictEqual(fs.readdirSync(store.tmpDir).filter((f) => f.endsWith('.part')), [], 'no temp file is left behind');
+ });
+
+ await check('a Media download past the fetch timeout fails cleanly and caches nothing', async () => {
+ const store = directStore(t, media, { dir: fs.mkdtempSync(path.join(t.dir, 'direct-slow-')), fetchTimeoutMs: 60 });
+ const blob = Buffer.from('SLOW OBJECT');
+ const h = shaOf('SLOW OBJECT');
+ media.seed(pa.id, blob);
+ media.delayContentMs = 400;
+ try {
+ await assert.rejects(() => store.ensure(pa.id, h), (e) => e.code === 'storage.media');
+ } finally { media.delayContentMs = 0; }
+ assert.ok(!fs.existsSync(store.pathFor(pa.id, h)), 'nothing is cached from a failed download');
+ });
+
+ await check('a failed deploy names the objects it left in the deploy log', async () => {
+ const ph = await t.project(alice, 'H');
+ const sh = await t.site(alice, ph.id, 'partial');
+ const shaA = shaOf('AAAA PARTIAL');
+ media.failUploadAfter = media.acceptedInits() + 1; // the first object lands, the second is refused
+ let r;
+ try { r = await t.upload(alice, sh.id, { 'a.txt': 'AAAA PARTIAL', 'b.txt': 'BBBB PARTIAL' }); }
+ finally { media.failUploadAfter = null; }
+ assert.strictEqual(r.status, 502, r.text);
+ assert.strictEqual(r.json().code, 'storage.object_store');
+ assert.ok(media.byHash(ph.id, shaA), 'the first object was left in Media');
+ const log = r.json().log.join('\n');
+ assert.ok(log.includes(shaA), `the deploy log names the leftover object: ${log}`);
+ });
+
+ await check('the object sweep logs a Media delete failure at warn instead of swallowing it', async () => {
+ const dir = fs.mkdtempSync(path.join(t.dir, 'sweep-'));
+ const file = path.join(dir, 'blob');
+ fs.writeFileSync(file, 'x');
+ fs.utimesSync(file, new Date(0), new Date(0));
+ const sha = 'a'.repeat(64);
+ const warnings = [];
+ const worker = createWorker({
+ config: { worker: { enabled: false } },
+ store: { db: { prepare: () => ({ get: async () => null }) } }, // no space_blobs row
+ domains: {},
+ blobs: {
+ list: function* () { yield { projectId: pa.id, sha256: sha, file }; },
+ sweepTmp: () => 0,
+ remove: async () => { const e = new Error('media down'); e.code = 'storage.media'; throw e; },
+ },
+ outbox: {},
+ log: { log() {}, error() {}, warn: (...a) => warnings.push(a.join(' ')) },
+ });
+ const removed = await worker.sweepObjects({ graceMs: 0 });
+ assert.strictEqual(removed, 0, 'a failed delete is not counted as removed');
+ assert.strictEqual(warnings.length, 1, `expected exactly one warn, got ${JSON.stringify(warnings)}`);
+ assert.ok(warnings[0].includes('Media') && warnings[0].includes(sha) && warnings[0].includes('media down'), warnings[0]);
+ });
+
+ await t.close();
+ await media.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
+'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 space.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');
+const { readArchive } = require('../server/artifacts/archive');
+
+(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 = (await t.events('space.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((await t.events('space.deploy.failed')).length, before + 1);
+ assert.strictEqual((await t.get('uploads.openvibe.website', '/')).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: '../../space.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('a large decompression leaves the event loop responsive', async () => {
+ const size = 32 * 1024 * 1024;
+ const raw = tarball([{ name: 'big.txt', content: Buffer.alloc(size, 97) }]);
+ assert.ok(raw.length < 1024 * 1024, 'the archive is small on the wire');
+ let resolved = false;
+ const pending = readArchive(raw, { maxTotalBytes: 64 * 1024 * 1024, maxFiles: 10, maxFileBytes: 64 * 1024 * 1024 })
+ .then((r) => { resolved = true; return r; });
+ assert.strictEqual(typeof pending.then, 'function', 'readArchive is asynchronous');
+ await new Promise((r) => setTimeout(r, 0));
+ assert.strictEqual(resolved, false, 'a timer fired while the archive was still decompressing');
+ const parsed = await pending;
+ assert.strictEqual(parsed.compressed, true);
+ assert.strictEqual(parsed.entries.length, 1);
+ assert.strictEqual(parsed.entries[0].data.length, size);
+ });
+
+ 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 await t.events('space.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,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+/**
+ * IndexNow (openvibe-shared/indexnow): INDEXNOW_KEY unset → the feature is off (no key route, nothing
+ * sent). With a key the key file is served at /<key>.txt as text/plain on every host, and a tenant
+ * site whose active deploy appears, changes or goes away pings the engines with the site's page and
+ * its sitemap. A ready deploy that is not active (a draft) never pings; a sandbox site (noindex) never
+ * pings.
+ */
+const assert = require('assert');
+const { boot, check, done, DASHBOARD } = require('./stageb/boot');
+
+const KEY = 'k'.repeat(32);
+
+/** A spy in place of the module's HTTP send: records every pingSoon batch (the repo's test pattern). */
+function makeSpy() {
+ const pings = [];
+ return {
+ pings,
+ module: {
+ enabled: true,
+ keyFile: (_req, _res, next) => next(),
+ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },
+ ping: async () => ({ sent: 0, status: 0 }),
+ flush: async () => ({ sent: 0, status: 0 }),
+ },
+ };
+}
+
+(async () => {
+ const off = await boot();
+ await check('without a key IndexNow is off: no key route and nothing sent', async () => {
+ assert.strictEqual(off.ctx.indexnow.enabled, false);
+ const res = await off.get(DASHBOARD, `/${KEY}.txt`);
+ assert.strictEqual(res.status, 404, res.text);
+ });
+ await off.close();
+
+ const on = await boot({ env: { INDEXNOW_KEY: KEY } });
+ await check('with a key the key file answers text/plain with the key (dashboard and tenant hosts)', async () => {
+ assert.strictEqual(on.ctx.indexnow.enabled, true);
+ const dash = await on.get(DASHBOARD, `/${KEY}.txt`);
+ assert.strictEqual(dash.status, 200, dash.text);
+ assert.match(dash.headers['content-type'], /text\/plain/);
+ assert.strictEqual(dash.text, KEY);
+ const tenant = await on.get('anything.openvibe.website', `/${KEY}.txt`);
+ assert.strictEqual(tenant.status, 200, tenant.text);
+ assert.strictEqual(tenant.text, KEY);
+ });
+ await on.close();
+
+ const s = makeSpy();
+ const t = await boot({ indexnow: s.module });
+ const alice = t.user('alice', { display_name: 'Alice' });
+ const project = await t.project(alice, 'Widgets');
+ const site = await t.site(alice, project.id, 'widgets');
+ const origin = 'https://widgets.openvibe.website';
+ let deploy;
+
+ await check('a ready deploy that is not active (a draft) never pings', async () => {
+ const r = await t.upload(alice, site.id, { 'index.html': '<h1>Widgets</h1>' }, { activate: false });
+ assert.strictEqual(r.status, 201, r.text);
+ deploy = r.json().deploy;
+ assert.deepStrictEqual(s.pings, []);
+ });
+
+ await check('activating the deploy pings the site page and the sitemap', async () => {
+ const r = await t.api('POST', `/api/v1/deploys/${deploy.id}/activate`, { as: alice, json: {} });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.json().changed, r.text);
+ assert.ok(s.pings.includes(`${origin}/`), JSON.stringify(s.pings));
+ assert.ok(s.pings.includes(`${origin}/sitemap.xml`), JSON.stringify(s.pings));
+ });
+
+ await check('a rollback pings the page and the sitemap again', async () => {
+ const r2 = await t.upload(alice, site.id, { 'index.html': '<h1>Widgets v2</h1>' }, { activate: true });
+ assert.strictEqual(r2.status, 201, r2.text);
+ s.pings.length = 0;
+ 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().changed, true);
+ assert.ok(s.pings.includes(`${origin}/`), JSON.stringify(s.pings));
+ assert.ok(s.pings.includes(`${origin}/sitemap.xml`), JSON.stringify(s.pings));
+ });
+
+ await check('deleting the site pings the page and the sitemap again', async () => {
+ s.pings.length = 0;
+ const r = await t.api('DELETE', `/api/v1/sites/${site.id}`, { as: alice, json: {} });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(s.pings.includes(`${origin}/`), JSON.stringify(s.pings));
+ assert.ok(s.pings.includes(`${origin}/sitemap.xml`), JSON.stringify(s.pings));
+ });
+
+ await check('a sandbox site (noindex) never pings, even when it activates', async () => {
+ const sandboxProject = await t.project(alice, 'Sandbox', { environment: 'sandbox' });
+ const sandboxSite = await t.site(alice, sandboxProject.id, 'playground');
+ s.pings.length = 0;
+ const r = await t.upload(alice, sandboxSite.id, { 'index.html': '<h1>Play</h1>' }, { activate: true });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual(s.pings, []);
+ });
+ await t.close();
+
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,225 @@
@@ -0,0 +1,225 @@
+'use strict';
+// Host's authority resource index (ADR-048 section 3, capability space.resource.read, plan T13 step 8):
+// GET /api/v1/resources pages common.resource-summary@1 for the resources Host owns - its tenant sites
+// (space.site, sit_), the immutable deploys of those sites (space.deploy, dpl_) and their default and
+// custom domains (space.domain, dom_) - as common.resource-list-result@1, and GET /api/v1/resources/:ovrn
+// reads one by its computed ovrn. ?project=&kind=&owner=&cursor=&limit= are honoured; ?project= is the tenancy
+// boundary, so a resource of another project is never returned, and Host lists no projects at all.
+// Every summary and every page is validated against the released schemas.
+const assert = require('assert');
+const { validate } = require('openvibe-contracts');
+const { boot, check, done } = require('./stageb/boot');
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice');
+ const bob = t.user('bob');
+ const prjA = (await t.project(alice, 'Resource A')).id;
+ const prjB = (await t.project(alice, 'Resource B')).id;
+ const prjC = (await t.project(bob, 'Resource C')).id;
+
+ // Sites: two in A, one in B and one owned by Bob in C (each mints a default domain).
+ const siteA1 = await t.site(alice, prjA, 'ri-a-one');
+ const siteA2 = await t.site(alice, prjA, 'ri-a-two');
+ const siteB = await t.site(alice, prjB, 'ri-b-one');
+ const siteC = await t.site(bob, prjC, 'ri-c-one');
+
+ // Deploys of A's first site: one ready, one refused (a failed deploy).
+ const readyDeploy = await t.deploy(alice, siteA1.id, { 'index.html': 'v1' });
+ const refusedUpload = await t.upload(alice, siteA1.id, { 'bad.php': 'x' });
+ assert.ok([413, 422].includes(refusedUpload.status), refusedUpload.text);
+ const failedDeploy = { id: refusedUpload.json().deploy_id };
+ assert.match(failedDeploy.id, /^dpl_/);
+
+ // A custom domain of A's first site, still waiting for its TXT record.
+ const custom = await t.api('POST', `/api/v1/sites/${siteA1.id}/domains`, { as: alice, json: { hostname: 'ri.example.org' } });
+ assert.strictEqual(custom.status, 201, custom.text);
+ const customDomain = custom.json().domain;
+
+ // The default domain each site create mints (its id is what the index lists).
+ const defaultDomains = [];
+ for (const [site, user] of [[siteA1, alice], [siteA2, alice], [siteB, alice], [siteC, bob]]) {
+ const seen = await t.api('GET', `/api/v1/sites/${site.id}`, { as: user });
+ assert.strictEqual(seen.status, 200, seen.text);
+ for (const d of seen.json().domains) if (d.kind === 'default') defaultDomains.push(d);
+ }
+ assert.strictEqual(defaultDomains.length, 4);
+
+ const resourcesOf = async (query, headers) => {
+ const r = await t.api('GET', `/api/v1/resources${query}`, headers);
+ assert.strictEqual(r.status, 200, `${query}: ${r.status} ${r.text}`);
+ const page = r.json();
+ assert.deepStrictEqual(Object.keys(page).sort(), ['next_cursor', 'resources'], 'the page carries only the contract fields');
+ const pageCheck = validate('common.resource-list-result@1', page);
+ assert.ok(pageCheck.valid, `${query}: ${JSON.stringify(pageCheck.errors)}`);
+ for (const s of page.resources) {
+ const v = validate('common.resource-summary@1', s);
+ assert.ok(v.valid, `${query} ${s.id}: ${JSON.stringify(v.errors)}`);
+ }
+ return page;
+ };
+
+ await check('auth: no token 401, a token without space.resource.read 403, with it 200', async () => {
+ const anonymous = await t.api('GET', '/api/v1/resources');
+ assert.strictEqual(anonymous.status, 401);
+ assert.strictEqual(anonymous.json().code, 'auth.required');
+ const person = await t.api('GET', '/api/v1/resources', { as: alice });
+ assert.strictEqual(person.status, 403, 'the index is first-party: a person holds no service capability');
+ assert.strictEqual(person.json().code, 'capability.denied');
+ const wrongAudience = t.network.sign({ sub: 'svc:services', aud: ['openvibe.blog'], cap: ['space.resource.read'] });
+ assert.strictEqual((await t.api('GET', '/api/v1/resources', { as: wrongAudience })).status, 401);
+ const noCap = t.network.serviceToken('codes', ['space.site.manage']);
+ const denied = await t.api('GET', '/api/v1/resources', { as: noCap });
+ assert.strictEqual(denied.status, 403);
+ assert.strictEqual(denied.json().code, 'capability.denied');
+ const allowed = await t.api('GET', '/api/v1/resources', { as: t.network.serviceToken('services', ['space.resource.read']) });
+ assert.strictEqual(allowed.status, 200, allowed.text);
+ });
+
+ const bearer = t.network.serviceToken('services', ['space.resource.read']);
+ const auth = { as: bearer };
+
+ await check('the page lists every kind Host owns, sorted by (kind, id), each summary valid', async () => {
+ const page = await resourcesOf('', auth);
+ const expected = [
+ { kind: 'space.deploy', id: readyDeploy.id }, { kind: 'space.deploy', id: failedDeploy.id },
+ { kind: 'space.domain', id: customDomain.id },
+ { kind: 'space.site', id: siteA1.id }, { kind: 'space.site', id: siteA2.id }, { kind: 'space.site', id: siteB.id }, { kind: 'space.site', id: siteC.id },
+ ];
+ const byId = new Map(page.resources.map((r) => [r.id, r]));
+ for (const d of defaultDomains) expected.push({ kind: 'space.domain', id: d.id });
+ expected.sort((x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
+ assert.deepStrictEqual(page.resources.map((r) => [r.kind, r.id]), expected.map((r) => [r.kind, r.id]), 'all three kinds, sorted by (kind, id)');
+ assert.deepStrictEqual([...new Set(page.resources.map((r) => r.service))], ['space']);
+ assert.strictEqual(page.next_cursor, null, 'one page holds the whole index');
+
+ const site = byId.get(siteA1.id);
+ assert.strictEqual(site.project_id, prjA, 'a summary carries its project');
+ assert.strictEqual(site.name, 'ri-a-one');
+ assert.strictEqual(site.state, 'active');
+ assert.deepStrictEqual(site.owner, { type: 'user', id: alice.subject }, 'a usr_ subject is the owner');
+ assert.strictEqual(byId.get(readyDeploy.id).state, 'ready');
+ assert.strictEqual(byId.get(failedDeploy.id).state, 'failed');
+ assert.strictEqual(byId.get(customDomain.id).name, 'ri.example.org');
+ assert.strictEqual(byId.get(customDomain.id).state, 'pending');
+ });
+
+ await check('every summary carries an OVRN composed by contracts.resources.nameOf', async () => {
+ const page = await resourcesOf('', auth);
+ for (const s of page.resources) {
+ assert.strictEqual(s.ovrn, `ovrn:space:${s.project_id}:${s.kind.split('.')[1]}/${s.id}`, s.id);
+ }
+ assert.strictEqual(page.resources.find((r) => r.id === readyDeploy.id).ovrn, `ovrn:space:${prjA}:deploy/${readyDeploy.id}`);
+ assert.strictEqual(page.resources.find((r) => r.id === siteB.id).ovrn, `ovrn:space:${prjB}:site/${siteB.id}`);
+ });
+
+ await check('?project= is the tenancy boundary: another project\'s rows are never returned', async () => {
+ const a = await resourcesOf(`?project=${prjA}`, auth);
+ const ids = a.resources.map((r) => r.id);
+ assert.ok(ids.includes(siteA1.id) && ids.includes(siteA2.id) && ids.includes(readyDeploy.id) && ids.includes(customDomain.id));
+ assert.ok(a.resources.every((r) => r.project_id === prjA), 'only project A');
+ assert.ok(!ids.includes(siteB.id), 'project B is never mixed in');
+ const b = await resourcesOf(`?project=${prjB}`, auth);
+ assert.deepStrictEqual([...new Set(b.resources.map((r) => r.project_id))], [prjB]);
+ assert.ok(b.resources.every((r) => r.id !== siteA1.id && r.id !== readyDeploy.id));
+ const empty = await resourcesOf('?project=prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', auth);
+ assert.deepStrictEqual(empty.resources, [], 'an unknown project has no resources');
+ // Host mints its own prj_ ids and lists no projects: no space.project kind exists.
+ assert.ok((await resourcesOf('?kind=space.project', auth)).resources.length === 0);
+ });
+
+ await check('?kind= narrows to one kind; an unknown kind is an empty page, not an error', async () => {
+ const sites = await resourcesOf('?kind=space.site', auth);
+ assert.deepStrictEqual(sites.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id, siteB.id, siteC.id].sort());
+ const deploys = await resourcesOf('?kind=space.deploy', auth);
+ assert.deepStrictEqual(deploys.resources.map((r) => r.id).sort(), [readyDeploy.id, failedDeploy.id].sort());
+ const domains = await resourcesOf('?kind=space.domain', auth);
+ assert.strictEqual(domains.resources.length, 5, "four default domains and A's custom one");
+ const scoped = await resourcesOf(`?project=${prjA}&kind=space.site`, auth);
+ assert.deepStrictEqual(scoped.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id].sort(), 'kind narrows within the project');
+ assert.deepStrictEqual((await resourcesOf('?kind=space.unknown', auth)).resources, []);
+ });
+
+ await check('?owner= filters every kind and combines with project, kind and cursor', async () => {
+ const all = (await resourcesOf('', auth)).resources;
+ const aliceRows = all.filter((r) => r.owner && r.owner.id === alice.subject);
+ const bobRows = all.filter((r) => r.owner && r.owner.id === bob.subject);
+ assert.deepStrictEqual(bobRows.map((r) => r.id).sort(), [siteC.id, defaultDomains.find((d) => d.site_id === siteC.id).id].sort());
+ const own = await resourcesOf(`?owner=${alice.subject}`, auth);
+ assert.deepStrictEqual(own.resources, aliceRows, 'only Alice-owned summaries in index order');
+ assert.deepStrictEqual((await resourcesOf(`?owner=${bob.subject}`, auth)).resources, bobRows, 'Bob owns a site and its default domain');
+ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject}&kind=space.site&project=${prjA}`, auth)).resources,
+ aliceRows.filter((r) => r.kind === 'space.site' && r.project_id === prjA));
+ assert.deepStrictEqual((await resourcesOf(`?owner=${bob.subject}&project=${prjA}`, auth)).resources, []);
+ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject}&project=${prjC}`, auth)).resources, []);
+ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject.replace(/^usr_/, 'agt_')}`, auth)).resources, []);
+ assert.deepStrictEqual((await resourcesOf('?owner=', auth)).resources, all, 'empty owner means no filter');
+
+ const seen = [];
+ let cursor = null;
+ let pages = 0;
+ for (;;) {
+ const page = await resourcesOf(`?owner=${alice.subject}&limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, auth);
+ assert.ok(page.resources.length <= 2);
+ seen.push(...page.resources);
+ if (page.next_cursor === null) break;
+ cursor = page.next_cursor;
+ if (++pages > 50) assert.fail('the owner cursor chain never ended');
+ }
+ assert.deepStrictEqual(seen, aliceRows, 'owner paging has no duplicates or gaps');
+ });
+
+ await check('the cursor pages the index without duplicates, gaps or reordering', async () => {
+ const whole = (await resourcesOf('', auth)).resources.map((r) => r.id);
+ const seen = [];
+ let cursor = null;
+ let pages = 0;
+ for (;;) {
+ const page = await resourcesOf(`?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, auth);
+ assert.ok(page.resources.length <= 2);
+ seen.push(...page.resources.map((r) => r.id));
+ if (page.next_cursor === null) break;
+ assert.ok(typeof page.next_cursor === 'string' && page.next_cursor !== '');
+ cursor = page.next_cursor;
+ if (++pages > 50) assert.fail('the cursor chain never ended');
+ }
+ assert.deepStrictEqual(seen, whole, 'no duplicates, none skipped, order preserved');
+ assert.deepStrictEqual((await resourcesOf('?limit=1000', auth)).resources.map((r) => r.id), whole, 'a large limit still pages one result');
+ });
+
+ await check('GET /:ovrn reads the one resource whose computed ovrn it is', async () => {
+ const page = await resourcesOf('', auth);
+ const one = await t.api('GET', `/api/v1/resources/${encodeURIComponent(`ovrn:space:${prjA}:site/${siteA1.id}`)}`, auth);
+ assert.strictEqual(one.status, 200, one.text);
+ assert.strictEqual(one.headers['cache-control'], 'private, max-age=60');
+ assert.ok(validate('common.resource-summary@1', one.json()).valid);
+ assert.deepStrictEqual(one.json(), page.resources.find((r) => r.id === siteA1.id), 'the same summary the list answers');
+
+ const missing = [
+ ['a project OVRN', `ovrn:space:${prjA}:project/${prjA}`],
+ ['an unknown type', `ovrn:space:${prjA}:widget/sit_01J8ZQ4Y7N3M2K1H0G9F8E7D6C`],
+ ["another service's OVRN", `ovrn:network:${prjA}:site/${siteA1.id}`],
+ ['the right id under another project', `ovrn:space:${prjB}:site/${siteA1.id}`],
+ ['an unknown id', `ovrn:space:${prjA}:site/sit_01J8ZQ4Y7N3M2K1H0G9F8E7D6C`],
+ ['a non-OVRN', 'nope'],
+ ];
+ for (const [why, name] of missing) {
+ const r = await t.api('GET', `/api/v1/resources/${encodeURIComponent(name)}`, auth);
+ assert.strictEqual(r.status, 404, `${why}: ${r.status} ${r.text}`);
+ assert.strictEqual(r.headers['content-type'], 'application/problem+json', why);
+ assert.strictEqual(r.json().code, 'resources.unknown_resource', why);
+ }
+ });
+
+ await check('a query that cannot be honoured is 400 resources.bad_query', async () => {
+ const bad = [['?project=nope', 'project not a prj_ id'], ['?owner=svc:live', 'owner not a subject id'], ['?owner=usr_short', 'owner too short'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
+ for (const [q, why] of bad) {
+ const r = await t.api('GET', `/api/v1/resources${q}`, auth);
+ assert.strictEqual(r.status, 400, why);
+ assert.strictEqual(r.json().code, 'resources.bad_query', why);
+ }
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+'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.website', '/index.html')
+ * t.dns.set('_openvibe-space.www.example.org', ['openvibe-space-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');
+
+// The Stage B suites deploy dozens of times a minute as one person: per-actor limits are tested on their own
+// (test/actor-limits.test.js), so they are off here unless a test turns them on.
+if (process.env.SPACE_ACTOR_LIMITS === undefined) process.env.SPACE_ACTOR_LIMITS = 'off';
+
+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-space-secret-value';
+const DASHBOARD = 'openvibe.space';
+
+async function boot(opts = {}) {
+ const network = await startNetwork();
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-space-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',
+ SPACE_SITES_DOMAIN: 'openvibe.website',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'space', OV_OAUTH_CLIENT_SECRET: SECRET, COOKIE_SECURE: 'false',
+ SPACE_FORM_SECRET: 'test-form-secret', SPACE_WORKER: 'off', SPACE_MIN_FREE_BYTES: '0',
+ SPACE_STORAGE_DIR: path.join(dir, 'objects'),
+ SPACE_PUBLIC_HOSTING: 'on', // the suites exercise the engine as launched; test/host-launch-switch.test.js covers off
+ ...(opts.env || {}),
+ };
+ const configLib = require('../../server/hosting-config');
+ // One database per boot (PGlite, or SPACE_TEST_STORE=pg: the containers); a restart keeps it, like a file did.
+ const db = await require('../helpers/db').testDb();
+ const testdb = { db, close: () => db.close() };
+ 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);
+ Object.assign(process.env, env);
+ for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
+ const { createApp } = require('../../server/app');
+ const app = await createApp({ db, hostingConfig: config, now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl, indexnow: opts.indexnow });
+ await app.locals.auth.ensureKey();
+ built = { app, ctx: app.locals.hosting };
+ 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(); }
+ 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}`;
+ // A dashboard session is Host's own cookie (server/auth/sso.js); ov_token is display-only.
+ if (o.session) headers.cookie = [headers.cookie, `ov_token=${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 = async (type = null) => (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope) : r.envelope) : 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 testdb.close(); 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,150 @@
@@ -0,0 +1,150 @@
+'use strict';
+/**
+ * An in-process stand-in for OpenVibe.Media's Object API v2, wide enough for Host's Media object
+ * store: init (POST /objects), the content PUT, complete, metadata GET, list, the signed download
+ * link, and delete — the routes OpenVibe.Media serves from server/objects/routes.js, served here
+ * from memory. It follows test/stageb/mocks.js (an HTTP server on a random port) and test/s3-mock.js
+ * (a recorded command log plus injectable failures).
+ *
+ * const media = await startMedia();
+ * media.url the base URL to give Host (OV_MEDIA_URL)
+ * media.objects Map id -> object (bytes, content_hash, metadata, lifecycle_status)
+ * media.calls every request { method, url, bytes, auth }
+ * media.failUpload = true every init answers 403 (a deploy must then fail)
+ * media.failUploadAfter = n refuse inits once n have succeeded (fail mid-deploy)
+ * media.delayContentMs = ms delay the signed content GET (a stalled download)
+ * media.failDelete = true every delete answers 500
+ * media.tamper.add(id) the signed content GET answers other bytes
+ * media.countInits() init requests so far
+ * media.acceptedInits() inits that were allowed (the rest were refused)
+ * media.countLists() namespace list requests so far
+ * media.countDownloads() signed content GETs so far
+ * media.seed(projectId, bytes) put a ready object straight into the fake, returns its id
+ * media.readyFor(projectId) ready objects whose metadata.project_id is that project
+ * media.byHash(projectId, sha) the ready object for that project and content hash
+ * await media.close()
+ */
+const http = require('http');
+const crypto = require('crypto');
+const { ids } = require('openvibe-contracts');
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+
+function startMedia({ app = 'space' } = {}) {
+ const objects = new Map();
+ const calls = [];
+ const media = { objects, calls, app, failUpload: false, failUploadAfter: null, delayContentMs: 0, failDelete: false, tamper: new Set() };
+ let acceptedInits = 0;
+ const basePath = `/api/v2/${encodeURIComponent(app)}/objects`;
+ const base = () => `http://127.0.0.1:${server.address().port}`;
+ const json = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ const publicShape = (o) => ({
+ id: o.id, app_id: app, namespace: o.namespace, kind: o.kind, visibility: o.visibility,
+ lifecycle_status: o.lifecycle_status, mime_type: o.mime_type, size_bytes: o.size_bytes,
+ content_hash: o.content_hash || null, metadata: o.metadata, public_url: null,
+ });
+
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ calls.push({ method: req.method, url: req.url, bytes: raw.length, auth: req.headers.authorization || null });
+ const p = new URL(req.url, 'http://localhost').pathname;
+ const rel = p === basePath ? '' : p.startsWith(`${basePath}/`) ? p.slice(basePath.length + 1) : null;
+ if (rel === null) return json(res, 404, { code: 'media.not_found' });
+
+ if (rel === '' && req.method === 'POST') {
+ if (media.failUpload || (media.failUploadAfter != null && acceptedInits >= media.failUploadAfter)) {
+ return json(res, 403, { code: 'media.object.denied', detail: 'the fake refuses every upload' });
+ }
+ acceptedInits++;
+ let body = {};
+ try { body = JSON.parse(raw.toString('utf8')); } catch { /* malformed: treat as empty */ }
+ const id = `med_${ids.ulid()}`;
+ const o = {
+ id, namespace: body.namespace || app, kind: body.kind || 'file', visibility: body.visibility || 'private',
+ lifecycle_status: 'uploading', mime_type: body.mime_type || null, size_bytes: Number(body.size_bytes) || 0,
+ content_hash: null, metadata: { ...(body.metadata || {}) }, bytes: null,
+ };
+ objects.set(id, o);
+ return json(res, 201, {
+ id, object: publicShape(o),
+ upload: { method: 'PUT', url: `${base()}${basePath}/${id}/content?token=tok`, token: 'tok', expires_at: new Date(Date.now() + 3600e3).toISOString(), max_bytes: o.size_bytes, content_type: o.mime_type },
+ });
+ }
+ if (rel === '' && req.method === 'GET') {
+ const all = [...objects.values()].filter((o) => o.lifecycle_status !== 'deleted');
+ return json(res, 200, { objects: all.map(publicShape), next_cursor: null, limit: 50 });
+ }
+
+ const m = /^([^/]+)(?:\/(.*))?$/.exec(rel);
+ const obj = m && objects.get(m[1]);
+ if (!obj) return json(res, 404, { code: 'media.object.not_found' });
+ const sub = m[2] || '';
+ if (sub === 'content' && req.method === 'PUT') {
+ obj.bytes = Buffer.from(raw);
+ obj.content_hash = sha256(obj.bytes);
+ obj.size_bytes = obj.bytes.length;
+ return json(res, 200, {});
+ }
+ if (sub === 'content' && req.method === 'GET') {
+ const sendContent = () => {
+ if (res.destroyed || res.writableEnded) return; // the client gave up (timeout)
+ if (media.tamper.has(obj.id)) { res.writeHead(200, { 'Content-Type': 'application/octet-stream' }); return res.end(Buffer.from('TAMPERED BYTES')); }
+ res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
+ return res.end(obj.bytes || Buffer.alloc(0));
+ };
+ if (media.delayContentMs) { setTimeout(sendContent, media.delayContentMs); return; }
+ return sendContent();
+ }
+ if (sub === 'complete' && req.method === 'POST') {
+ let body = {};
+ try { body = JSON.parse(raw.toString('utf8')); } catch { /* */ }
+ const expected = String(body.content_hash || '').toLowerCase();
+ if (expected && expected !== obj.content_hash) return json(res, 422, { code: 'media.object.hash_mismatch', detail: 'content hash mismatch' });
+ obj.lifecycle_status = 'ready';
+ return json(res, 200, publicShape(obj));
+ }
+ if (sub === 'download' && req.method === 'GET') {
+ if (obj.lifecycle_status !== 'ready') return json(res, 409, { code: 'media.object.not_ready' });
+ return json(res, 200, { url: `${base()}${basePath}/${obj.id}/content?token=signed`, expires_at: new Date(Date.now() + 300e3).toISOString(), public: false });
+ }
+ if (sub === '' && req.method === 'GET') return json(res, 200, publicShape(obj));
+ if (sub === '' && req.method === 'DELETE') {
+ if (media.failDelete) return json(res, 500, { code: 'media.object.delete_failed' });
+ obj.lifecycle_status = 'deleted';
+ return json(res, 200, publicShape(obj));
+ }
+ return json(res, 404, { code: 'media.not_found' });
+ });
+ });
+
+ return new Promise((resolve) => {
+ server.listen(0, '127.0.0.1', () => {
+ media.url = base();
+ media.close = () => new Promise((r) => server.close(r));
+ media.countInits = () => calls.filter((c) => c.method === 'POST' && new URL(c.url, 'http://x').pathname === basePath).length;
+ media.acceptedInits = () => acceptedInits;
+ media.countLists = () => calls.filter((c) => c.method === 'GET' && new URL(c.url, 'http://x').pathname === basePath).length;
+ media.countDownloads = () => calls.filter((c) => c.method === 'GET' && new URL(c.url, 'http://x').pathname.endsWith('/content')).length;
+ media.seed = (projectId, bytes) => {
+ const b = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
+ const h = sha256(b);
+ const id = `med_${ids.ulid()}`;
+ objects.set(id, {
+ id, namespace: app, kind: 'file', visibility: 'private', lifecycle_status: 'ready',
+ mime_type: 'application/octet-stream', size_bytes: b.length, content_hash: h,
+ metadata: { project_id: projectId, sha256: h }, bytes: b,
+ });
+ return id;
+ };
+ media.ready = () => [...objects.values()].filter((o) => o.lifecycle_status === 'ready');
+ media.readyFor = (projectId) => media.ready().filter((o) => o.metadata && o.metadata.project_id === projectId);
+ media.byHash = (projectId, sha) => media.ready().find((o) => o.metadata && o.metadata.project_id === projectId && o.content_hash === sha) || null;
+ resolve(media);
+ });
+ });
+}
+
+module.exports = { startMedia };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'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; authorization_code and
+ * refresh_token → user tokens for issueCode()/refreshToken()).
+ * 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-space-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 });
+ }
+ // Browser sign-in: a code from issueCode(), a refresh token from refreshToken() (single use, rotated).
+ const grant = body.grant_type === 'authorization_code' ? grants.get(`code:${body.code}`) : body.grant_type === 'refresh_token' ? grants.get(`rt:${body.refresh_token}`) : undefined;
+ if (grant === undefined && ['authorization_code', 'refresh_token'].includes(body.grant_type)) return json(400, { error: 'invalid_grant' });
+ if (grant) {
+ grants.delete(body.grant_type === 'authorization_code' ? `code:${body.code}` : `rt:${body.refresh_token}`);
+ return json(200, { access_token: userToken(grant), refresh_token: refreshToken(grant), token_type: 'Bearer', expires_in: 3600 });
+ }
+ 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);
+ }
+ const grants = new Map();
+ const issueCode = (u) => { const c = crypto.randomBytes(12).toString('hex'); grants.set(`code:${c}`, u); return c; };
+ const refreshToken = (u) => { const r = crypto.randomBytes(12).toString('hex'); grants.set(`rt:${r}`, u); return r; };
+ 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, audience: ['openvibe.network', 'openvibe.space'], expiresIn: '1h' });
+ }
+ /** A FedCM ID assertion as Network's /fedcm/assertion signs it (same key and issuer, aud = the RP origin). */
+ function fedcmAssertion(u, origin, nonce = 'n0nce') {
+ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), id: 1, subject_id: u.subject, username: u.username, display_name: u.display_name, nonce, typ: 'fedcm', jti: crypto.randomBytes(16).toString('hex') }, privatePem, { algorithm: 'RS256', issuer, audience: origin, expiresIn: 300 });
+ }
+ function serviceToken(client, cap, extra = {}) {
+ return sign({ sub: `svc:${client}`, aud: ['openvibe.space'], cap, ...extra });
+ }
+ // An app token carries its developer project and env, as Network's do (identity.service-token-claims 1.2.0).
+ function appToken(appId, cap, extra = {}) {
+ return sign({ sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.space'], cap, project_id: 'prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', env: 'production', ...extra });
+ }
+ return { ...srv, publicPem, addUser, userToken, issueCode, refreshToken, fedcmAssertion, 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 };