openvibers/openvibe.space Commits

Space: static hosting moves here from Host (space.* contracts, staff-only), with a dashboard design pass (#36)

OP OpenVibers committed 12 Oct 2026 · 17271f3a89 · 79 files, +8477 −251

Plan T20 step 2: the static hosting engine ("Stage B") moves from OpenVibe.Host into Space, which becomes the network's hosting product. Production Host had 0 projects, 0 sites and 0 deploys, so this is a code move with no data copy. **`SPACE_PUBLIC_HOSTING` stays off**: only OpenVibe staff create projects until the owner's launch gates clear (O15 go-ahead, O25 DMCA agent, the PSL entry for openvibe.website).

**The engine** (module layout kept, so it diffs file against file with Host's `server/`): projects and members, immutable content-addressed deploys, activation and one-step rollback, private previews, Git ingest, per-site headers, redirects and SPA fallback, custom domains with TXT verification (`_openvibe-space.<hostname>`), staff takedowns, Media-backed objects, tenant serving by Host header at `<site>.openvibe.website`, the resource index, the limits page, and the launch switch. Every `host.*` id is now `space.*`: capabilities, events, error codes, metrics, and the `SPACE_*` env. Id prefixes are unchanged.

- **Contracts:** pinned to openvibe-contracts **0.149.0** (Contracts#105), which released the five `space.*` capabilities and four events with payload schemas. `openvibe-contracts-check --service space` passes (5 capabilities enforced).
- **Migrations 0002–0005** (`-- phase: expand`): the engine's tables with Host's columns, constraints, indexes and immutability triggers, named `space_*`.
- **Order fixed during review:** the tenant dispatch now runs before helmet, `/metrics`, auth, the API and readiness, as it did in Host. Tenant requests count as one metrics route; `createHosting` mounts the dispatch and the routes separately.
- **Readiness** gains the engine's checks: storage, disk headroom (`SPACE_MIN_FREE_BYTES`), Valkey, the events relay and stuck domain checks.
- **Design pass:**
  - **Front page:** says what works and how a deploy works, shows the API and the quotas the engine enforces, and lists what comes next (dynamic pages and spaces are not built).
  - **Dashboard:** a real UI (`public/css/hosting.css`, dashboard pages only): project tiles, a sites list, quota meters, an upload card with choice-style outcomes, state badges, a DNS records table that stacks on phones, and danger zones. Every form keeps its action and fields, and it still works without JavaScript.
  - **Checks:** no horizontal scroll at 390 px on any page.
- User-visible messages and log tags no longer say "Host".
- README and STATUS describe the hosting product; the forum redirects are unchanged.

Production env already carries `SPACE_STORAGE_DIR=/var/lib/openvibe-space/objects` (the unit's StateDirectory) and `SPACE_PUBLIC_HOSTING=off`.

Tests: the full suite, 23/23 files (the engine's 19 hosting test files plus Space's own).

Changes

ddiff --git a/.env.example b/.env.example
iindex c93fc0b..3a5153a 100644
---- a/.env.example
++++ b/.env.example
@@@ -33,8 +33,52 @@ OV_COMMUNITY_URL=https://openvibe.community
# OpenVibe.Live — the legal pages the shared footer links
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.
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
ddiff --git a/.gitleaks.toml b/.gitleaks.toml
iindex c49b875..fe902fa 100644
---- a/.gitleaks.toml
++++ b/.gitleaks.toml
@@@ -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$''']
ddiff --git a/README.md b/README.md
iindex b5540be..d7c183a 100644
---- a/README.md
++++ b/README.md
@@@ -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.Community
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,
--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
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.
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
AGPL-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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 5b7c3ab..245d695 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/deploy/nginx/openvibe.space.conf b/deploy/nginx/openvibe.space.conf
iindex 22414fc..a415194 100644
---- a/deploy/nginx/openvibe.space.conf
++++ b/deploy/nginx/openvibe.space.conf
@@@ -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;
ddiff --git a/deploy/nginx/openvibe.website.conf b/deploy/nginx/openvibe.website.conf
nnew file mode 100644
iindex 0000000..c7fb47a
---- /dev/null
++++ b/deploy/nginx/openvibe.website.conf
@@@ -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;
++ }
++}
ddiff --git a/migrations/0002_initial.sql b/migrations/0002_initial.sql
nnew file mode 100644
iindex 0000000..cfe5385
---- /dev/null
++++ b/migrations/0002_initial.sql
@@@ -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
ddiff --git a/migrations/0003_site_config.sql b/migrations/0003_site_config.sql
nnew file mode 100644
iindex 0000000..23b7849
---- /dev/null
++++ b/migrations/0003_site_config.sql
@@@ -0,0 +1,15 @@
++-- phase: expand
++-- Per-site serving configuration (plan T12 J3, decision D4): the response headers, the redirects and
++-- the SPA fallback of ONE site, kept in PostgreSQL and applied in server/http/tenant.js BEFORE the
++-- platform's own headers. The platform's security, routing and caching headers (CSP, HSTS,
++-- Set-Cookie, X-Forwarded-*, Cache-Control, …) are reserved and can never be set through it, and a
++-- redirect target must be a local path, so a site can never become an open redirect. A site with no
++-- row here serves exactly as before (no headers, no redirects, no SPA fallback).
++CREATE TABLE 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
++);
ddiff --git a/migrations/0004_preview.sql b/migrations/0004_preview.sql
nnew file mode 100644
iindex 0000000..c4eff3c
---- /dev/null
++++ b/migrations/0004_preview.sql
@@@ -0,0 +1,16 @@
++-- phase: expand
++-- Preview deploys (plan T12 J4, decision D5): a preview is served by the dashboard only, at
++-- /preview/<deploy-id>/…, to a member of the deploy's project — never on a public host, never indexed.
++--
++-- source='preview' a deploy uploaded as a preview (its archive/files kind is in the upload log).
++-- preview pointer 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;
ddiff --git a/migrations/0005_git_sources.sql b/migrations/0005_git_sources.sql
nnew file mode 100644
iindex 0000000..a92d1ae
---- /dev/null
++++ b/migrations/0005_git_sources.sql
@@@ -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();
ddiff --git a/package-lock.json b/package-lock.json
iindex 846bd36..f8242f3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 2a67b84..7c5b063 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/public/css/hosting.css b/public/css/hosting.css
nnew file mode 100644
iindex 0000000..4dcbe7f
---- /dev/null
++++ b/public/css/hosting.css
@@@ -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; }
++}
ddiff --git a/public/css/space.css b/public/css/space.css
iindex 9d55bf2..7c37906 100644
---- a/public/css/space.css
++++ b/public/css/space.css
@@@ -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;
++}
ddiff --git a/server/app.js b/server/app.js
iindex 9f7f163..958a413 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 ─────────────────────────────────────────
ddiff --git a/server/artifacts/archive.js b/server/artifacts/archive.js
nnew file mode 100644
iindex 0000000..802604c
---- /dev/null
++++ b/server/artifacts/archive.js
@@@ -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 };
ddiff --git a/server/artifacts/paths.js b/server/artifacts/paths.js
nnew file mode 100644
iindex 0000000..f9c991f
---- /dev/null
++++ b/server/artifacts/paths.js
@@@ -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 };
ddiff --git a/server/artifacts/validate.js b/server/artifacts/validate.js
nnew file mode 100644
iindex 0000000..d89a77b
---- /dev/null
++++ b/server/artifacts/validate.js
@@@ -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 };
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
nnew file mode 100644
iindex 0000000..a94e0f5
---- /dev/null
++++ b/server/auth/capabilities.js
@@@ -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 };
ddiff --git a/server/auth/forms.js b/server/auth/forms.js
nnew file mode 100644
iindex 0000000..0143a99
---- /dev/null
++++ b/server/auth/forms.js
@@@ -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 };
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
nnew file mode 100644
iindex 0000000..6282b2d
---- /dev/null
++++ b/server/auth/viewer.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex c3eb6bb..79d79e7 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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 || '' },
};
ddiff --git a/server/db.js b/server/db.js
iindex 4db6ad7..2856ed0 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/discovery.js b/server/discovery.js
iindex f9ace55..7462318 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -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' },
ddiff --git a/server/domain/access.js b/server/domain/access.js
nnew file mode 100644
iindex 0000000..22b7e90
---- /dev/null
++++ b/server/domain/access.js
@@@ -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 };
ddiff --git a/server/domain/deploys.js b/server/domain/deploys.js
nnew file mode 100644
iindex 0000000..480081f
---- /dev/null
++++ b/server/domain/deploys.js
@@@ -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 };
ddiff --git a/server/domain/domains.js b/server/domain/domains.js
nnew file mode 100644
iindex 0000000..28082f1
---- /dev/null
++++ b/server/domain/domains.js
@@@ -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 };
ddiff --git a/server/domain/projects.js b/server/domain/projects.js
nnew file mode 100644
iindex 0000000..88a6f78
---- /dev/null
++++ b/server/domain/projects.js
@@@ -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 };
ddiff --git a/server/domain/site-config.js b/server/domain/site-config.js
nnew file mode 100644
iindex 0000000..0762c95
---- /dev/null
++++ b/server/domain/site-config.js
@@@ -0,0 +1,182 @@
++'use strict';
++/**
++ * Per-site serving configuration (plan T12 J3, decision D4): the response headers, the redirects and
++ * the SPA fallback of ONE site, kept in PostgreSQL and applied by server/http/tenant.js BEFORE the
++ * platform's own headers. The platform's security, routing and caching headers (CSP, HSTS,
++ * Set-Cookie, X-Forwarded-*, Cache-Control, …) are reserved and can never be set through it; a
++ * redirect target must be a local path, so a site can never become an open redirect. A site with no
++ * row serves exactly as before.
++ */
++const { ApiError } = require('../http/errors');
++const { principalOf } = require('./access');
++
++const MAX_HEADERS = 50;
++const MAX_HEADER_VALUE = 1024;
++const MAX_REDIRECTS = 200;
++const MAX_LOCATION = 1024;
++const HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;
++const REDIRECT_STATUS = new Set([301, 302, 307, 308]);
++
++// Headers the platform owns: a site's config can never set one. `X-Forwarded-*` is refused as a
++// prefix, so a site can never lie about the client to an upstream reading those headers.
++const RESERVED_HEADERS = new Set([
++ 'content-security-policy', 'content-security-policy-report-only', 'strict-transport-security',
++ 'set-cookie', 'set-cookie2', 'clear-site-data',
++ 'content-length', 'transfer-encoding', 'connection', 'keep-alive', 'trailer', 'upgrade', 'te', 'host', 'location',
++ 'content-type', 'content-disposition', 'content-encoding', 'content-range', 'accept-ranges',
++ 'cache-control', 'cdn-cache-control', 'etag', 'expires', 'last-modified', 'vary', 'age',
++ 'x-content-type-options', 'x-served-by', 'x-robots-tag', 'referrer-policy',
++ 'cross-origin-opener-policy', 'cross-origin-embedder-policy', 'cross-origin-resource-policy', 'permissions-policy',
++ // Scope and transport are the platform's too: a wider service-worker scope, or another host:port for this origin.
++ 'service-worker-allowed', 'alt-svc',
++]);
++const RESERVED_PREFIXES = ['x-forwarded-'];
++
++/** Why `name` cannot be configured, or null when it can. */
++function reservedReason(name) {
++ const lower = String(name).toLowerCase();
++ if (RESERVED_HEADERS.has(lower)) return `"${name}" is set by the platform and cannot be configured`;
++ if (RESERVED_PREFIXES.some((p) => lower.startsWith(p))) return `"${name}" is reserved (X-Forwarded-*) and cannot be configured`;
++ return null;
++}
++function isReservedHeader(name) { return reservedReason(name) !== null; }
++
++const fail = (code, message) => { throw new ApiError(422, code, message); };
++
++function cleanHeaderValue(name, value) {
++ const v = value == null ? '' : String(value);
++ if (v.length > MAX_HEADER_VALUE) fail('site_config.header_value', `the value of "${name}" is longer than ${MAX_HEADER_VALUE} characters`);
++ // eslint-disable-next-line no-control-regex
++ if (/[\u0000-\u001f\u007f]/.test(v)) fail('site_config.header_value', `the value of "${name}" contains a control character: a header value must be one line`);
++ return v;
++}
++
++/** name → value object of configurable headers, or throws ApiError(422). */
++function cleanHeaders(input) {
++ if (input == null) return {};
++ if (typeof input !== 'object' || Array.isArray(input)) fail('site_config.headers', 'headers must be an object of name → value');
++ const names = Object.keys(input);
++ if (names.length > MAX_HEADERS) fail('site_config.too_many', `a site may set at most ${MAX_HEADERS} headers`);
++ const out = {};
++ for (const name of names) {
++ if (!HEADER_NAME_RE.test(name)) fail('site_config.header_name', `"${name}" is not a valid header name`);
++ const reason = reservedReason(name);
++ if (reason) fail('site_config.reserved_header', reason);
++ out[name] = cleanHeaderValue(name, input[name]);
++ }
++ return out;
++}
++
++/** A redirect source or target must be a local path: no scheme, no host, no backslash (which browsers read as "/"). */
++function cleanLocalPath(value, field) {
++ const s = value == null ? '' : String(value).trim();
++ if (!s || s.length > MAX_LOCATION) fail('site_config.redirect', `${field} must be a path of 1–${MAX_LOCATION} characters`);
++ // eslint-disable-next-line no-control-regex
++ if (/[\u0000-\u001f\u007f]/.test(s)) fail('site_config.redirect', `${field} contains a control character`);
++ if (s.includes('\\')) fail('site_config.redirect', `${field} contains a backslash`);
++ if (s[0] !== '/') fail('site_config.redirect', `${field} must be a local path starting with "/"`);
++ if (s.startsWith('//')) fail('site_config.redirect', `${field} must be a local path, not a protocol-relative URL`);
++ let decoded;
++ try { decoded = decodeURIComponent(s); } catch { fail('site_config.redirect', `${field} is not valid percent-encoding`); }
++ // eslint-disable-next-line no-control-regex
++ if (decoded.includes('\\') || /[\u0000-\u001f\u007f]/.test(decoded) || /(^|\/)\/\//.test(decoded)) fail('site_config.redirect', `${field} must be a local path (it decoded to a scheme, a host or a control character)`);
++ return s;
++}
++
++/** [{ from, to, status }] of local-only redirects, or throws ApiError(422). */
++function cleanRedirects(input) {
++ if (input == null) return [];
++ if (!Array.isArray(input)) fail('site_config.redirects', 'redirects must be an array');
++ if (input.length > MAX_REDIRECTS) fail('site_config.too_many', `a site may have at most ${MAX_REDIRECTS} redirects`);
++ const seen = new Set();
++ const out = [];
++ for (const [i, r] of input.entries()) {
++ if (!r || typeof r !== 'object' || Array.isArray(r)) fail('site_config.redirect', `redirect ${i + 1} must be an object with "from" and "to"`);
++ const from = cleanLocalPath(r.from, `redirect ${i + 1} "from"`);
++ const to = cleanLocalPath(r.to, `redirect ${i + 1} "to"`);
++ if (seen.has(from)) fail('site_config.redirect', `two redirects have the same "from" path (${from})`);
++ seen.add(from);
++ const status = r.status == null ? 301 : Number(r.status);
++ if (!REDIRECT_STATUS.has(status)) fail('site_config.redirect', `redirect ${i + 1} status must be one of 301, 302, 307, 308`);
++ out.push({ from, to, status });
++ }
++ return out;
++}
++
++const cleanSpa = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v == null ? '' : v).toLowerCase());
++
++/** The whole config, cleaned. */
++function normalize(input = {}) {
++ return { headers: cleanHeaders(input.headers), redirects: cleanRedirects(input.redirects), spa: cleanSpa(input.spa) };
++}
++
++function defaults() { return { headers: {}, redirects: [], spa: false }; }
++
++// ── Dashboard text form (one "Name: value" per line; one "from -> to [status]" per line) ──
++function headersToText(headers) {
++ return Object.entries(headers || {}).map(([k, v]) => `${k}: ${v}`).join('\n');
++}
++function parseHeadersText(text) {
++ const obj = {};
++ for (const raw of String(text || '').split('\n')) {
++ const line = raw.trim();
++ if (!line) continue;
++ const i = line.indexOf(':');
++ if (i <= 0) fail('site_config.header_syntax', `each header line must be "Name: value" (got "${line.slice(0, 60)}")`);
++ const name = line.slice(0, i).trim();
++ if (name in obj) fail('site_config.header_syntax', `"${name}" is listed twice`);
++ obj[name] = line.slice(i + 1).trim();
++ }
++ return cleanHeaders(obj);
++}
++function redirectsToText(redirects) {
++ return (redirects || []).map((r) => `${r.from} -> ${r.to}${r.status && r.status !== 301 ? ` ${r.status}` : ''}`).join('\n');
++}
++function parseRedirectsText(text) {
++ const list = [];
++ for (const raw of String(text || '').split('\n')) {
++ const line = raw.trim();
++ if (!line) continue;
++ const m = /^(\S+)\s*->\s*(\S+)(?:\s+(\d{3}))?$/.exec(line);
++ if (!m) fail('site_config.redirect_syntax', `each redirect line must be "from -> to [status]" (got "${line.slice(0, 60)}")`);
++ list.push({ from: m[1], to: m[2], status: m[3] ? Number(m[3]) : 301 });
++ }
++ return cleanRedirects(list);
++}
++
++function createSiteConfig({ store }) {
++ const { db } = store;
++ const q = {
++ bySite: db.prepare('SELECT * FROM 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,
++};
ddiff --git a/server/domain/site-sources.js b/server/domain/site-sources.js
nnew file mode 100644
iindex 0000000..1ab8eb3
---- /dev/null
++++ b/server/domain/site-sources.js
@@@ -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 };
ddiff --git a/server/domain/sites.js b/server/domain/sites.js
nnew file mode 100644
iindex 0000000..f75367e
---- /dev/null
++++ b/server/domain/sites.js
@@@ -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 };
ddiff --git a/server/domain/takedowns.js b/server/domain/takedowns.js
nnew file mode 100644
iindex 0000000..4653b3f
---- /dev/null
++++ b/server/domain/takedowns.js
@@@ -0,0 +1,82 @@
++'use strict';
++/**
++ * Abuse takedowns, by OpenVibe staff only (Network role admin).
++ *
++ * A takedown stops a site, or every site of a project, from being served at once: its default and
++ * custom domains answer 451 with no tenant content. Unlike a delete, it KEEPS the deploys, their
++ * file rows, logs and objects, so a report can be reviewed and, where the law requires it, the
++ * material preserved and handed over. While a takedown is active the project's members cannot
++ * upload, activate, roll back or delete anything it covers; staff can still delete. Every takedown
++ * and every lift stays in 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 };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..37fae37
---- /dev/null
++++ b/server/events/outbox.js
@@@ -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 };
ddiff --git a/server/hosting-config.js b/server/hosting-config.js
nnew file mode 100644
iindex 0000000..097ca80
---- /dev/null
++++ b/server/hosting-config.js
@@@ -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 };
ddiff --git a/server/hosting.js b/server/hosting.js
nnew file mode 100644
iindex 0000000..e47fa6a
---- /dev/null
++++ b/server/hosting.js
@@@ -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 };
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
nnew file mode 100644
iindex 0000000..bb73c53
---- /dev/null
++++ b/server/http/actor-limits.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..3e77402
---- /dev/null
++++ b/server/http/api.js
@@@ -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 };
ddiff --git a/server/http/dashboard.js b/server/http/dashboard.js
nnew file mode 100644
iindex 0000000..176832b
---- /dev/null
++++ b/server/http/dashboard.js
@@@ -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 };
ddiff --git a/server/http/errors.js b/server/http/errors.js
nnew file mode 100644
iindex 0000000..d816746
---- /dev/null
++++ b/server/http/errors.js
@@@ -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 };
ddiff --git a/server/http/serialize.js b/server/http/serialize.js
nnew file mode 100644
iindex 0000000..5685cf2
---- /dev/null
++++ b/server/http/serialize.js
@@@ -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 };
ddiff --git a/server/http/tenant.js b/server/http/tenant.js
nnew file mode 100644
iindex 0000000..10a0b4f
---- /dev/null
++++ b/server/http/tenant.js
@@@ -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 };
ddiff --git a/server/http/upload.js b/server/http/upload.js
nnew file mode 100644
iindex 0000000..5ba17e0
---- /dev/null
++++ b/server/http/upload.js
@@@ -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 };
ddiff --git a/server/ids.js b/server/ids.js
nnew file mode 100644
iindex 0000000..51655c0
---- /dev/null
++++ b/server/ids.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex 0ada0b2..44bbb0a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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');
ddiff --git a/server/limits.js b/server/limits.js
nnew file mode 100644
iindex 0000000..4b4ffb8
---- /dev/null
++++ b/server/limits.js
@@@ -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 };
ddiff --git a/server/observability.js b/server/observability.js
iindex 1dbe1cd..7f597db 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -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 };
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
nnew file mode 100644
iindex 0000000..08f9094
---- /dev/null
++++ b/server/registry/resource-index.js
@@@ -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,
++};
ddiff --git a/server/render/hosting-layout.js b/server/render/hosting-layout.js
nnew file mode 100644
iindex 0000000..3b68505
---- /dev/null
++++ b/server/render/hosting-layout.js
@@@ -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 };
ddiff --git a/server/render/hosting-pages.js b/server/render/hosting-pages.js
nnew file mode 100644
iindex 0000000..7f5ac2d
---- /dev/null
++++ b/server/render/hosting-pages.js
@@@ -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 -&gt; to [status]</code></span><textarea name="redirects" rows="4" spellcheck="false" placeholder="/old -&gt; /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 };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex e10e830..facc0d4 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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>`,
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex f172965..55ce920 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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 });
ddiff --git a/server/storage.js b/server/storage.js
nnew file mode 100644
iindex 0000000..bf0b0b0
---- /dev/null
++++ b/server/storage.js
@@@ -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 };
ddiff --git a/server/worker.js b/server/worker.js
nnew file mode 100644
iindex 0000000..8b55f29
---- /dev/null
++++ b/server/worker.js
@@@ -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 };
ddiff --git a/test/boot.test.js b/test/boot.test.js
iindex c752a7c..0e2451c 100644
---- a/test/boot.test.js
++++ b/test/boot.test.js
@@@ -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"/);
});
ddiff --git a/test/home.test.js b/test/home.test.js
iindex bbe42be..a54b68e 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -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');
ddiff --git a/test/hosting-abuse.test.js b/test/hosting-abuse.test.js
nnew file mode 100644
iindex 0000000..f6f11b9
---- /dev/null
++++ b/test/hosting-abuse.test.js
@@@ -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();
++})();
ddiff --git a/test/hosting-contracts-events.test.js b/test/hosting-contracts-events.test.js
nnew file mode 100644
iindex 0000000..010c2e9
---- /dev/null
++++ b/test/hosting-contracts-events.test.js
@@@ -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();
++})();
ddiff --git a/test/hosting-core.test.js b/test/hosting-core.test.js
nnew file mode 100644
iindex 0000000..1dde7b3
---- /dev/null
++++ b/test/hosting-core.test.js
@@@ -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; });
ddiff --git a/test/hosting-dispatch-error.test.js b/test/hosting-dispatch-error.test.js
nnew file mode 100644
iindex 0000000..b1d27e1
---- /dev/null
++++ b/test/hosting-dispatch-error.test.js
@@@ -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();
++})();
ddiff --git a/test/hosting-domains.test.js b/test/hosting-domains.test.js
nnew file mode 100644
iindex 0000000..7e21c8e
---- /dev/null
++++ b/test/hosting-domains.test.js
@@@ -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();
++})();
ddiff --git a/test/hosting-git-source.test.js b/test/hosting-git-source.test.js
nnew file mode 100644
iindex 0000000..bc6f3c5
---- /dev/null
++++ b/test/hosting-git-source.test.js
@@@ -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();
++})();
ddiff --git a/test/hosting-isolation.test.js b/test/hosting-isolation.test.js
nnew file mode 100644
iindex 0000000..969d463
---- /dev/null
++++ b/test/hosting-isolation.test.js
@@@ -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();
++})();
ddiff --git a/test/hosting-launch-switch.test.js b/test/hosting-launch-switch.test.js
nnew file mode 100644
iindex 0000000..d13e7e7
---- /dev/null
++++ b/test/hosting-launch-switch.test.js
@@@ -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); });
ddiff --git a/test/hosting-lifecycle.test.js b/test/hosting-lifecycle.test.js
nnew file mode 100644
iindex 0000000..67e57ec
---- /dev/null
++++ b/test/hosting-lifecycle.test.js
@@@ -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();
++})();
ddiff --git a/test/hosting-llms.test.js b/test/hosting-llms.test.js
nnew file mode 100644
iindex 0000000..5883416
---- /dev/null
++++ b/test/hosting-llms.test.js
@@@ -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); });
ddiff --git a/test/hosting-preview.test.js b/test/hosting-preview.test.js
nnew file mode 100644
iindex 0000000..b8129d1
---- /dev/null
++++ b/test/hosting-preview.test.js
@@@ -0,0 +1,153 @@
++'use strict';
++/**
++ * Preview deploys (plan T12 J4, decision D5). Uploading a deploy with ?preview=1 stores it with
++ * source 'preview' and makes it the site's live preview (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); });
ddiff --git a/test/hosting-quota-auth.test.js b/test/hosting-quota-auth.test.js
nnew file mode 100644
iindex 0000000..86a9e90
---- /dev/null
++++ b/test/hosting-quota-auth.test.js
@@@ -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();
++})();
ddiff --git a/test/hosting-rollback.test.js b/test/hosting-rollback.test.js
nnew file mode 100644
iindex 0000000..75d6057
---- /dev/null
++++ b/test/hosting-rollback.test.js
@@@ -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();
++})();
ddiff --git a/test/hosting-secrets-dashboard.test.js b/test/hosting-secrets-dashboard.test.js
nnew file mode 100644
iindex 0000000..89dc791
---- /dev/null
++++ b/test/hosting-secrets-dashboard.test.js
@@@ -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); });
ddiff --git a/test/hosting-site-config.test.js b/test/hosting-site-config.test.js
nnew file mode 100644
iindex 0000000..5f693e1
---- /dev/null
++++ b/test/hosting-site-config.test.js
@@@ -0,0 +1,149 @@
++'use strict';
++/**
++ * Per-site configuration (plan T12 J3, decision D4): response headers, local-only redirects and the
++ * SPA fallback, stored per site (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();
++})();
ddiff --git a/test/hosting-sitemap.test.js b/test/hosting-sitemap.test.js
nnew file mode 100644
iindex 0000000..f725a22
---- /dev/null
++++ b/test/hosting-sitemap.test.js
@@@ -0,0 +1,109 @@
++'use strict';
++/**
++ * Tenant sitemap.xml and robots.txt when the deploy ships none: the generated sitemap lists the
++ * manifest's HTML pages (index → the directory URL, the error page left out) on the host the request
++ * came in on, so a verified custom domain gets its own copy under its own name; robots welcomes
++ * crawlers to a production site and keeps a sandbox out (Disallow: /). A file the tenant uploaded at
++ * either path always wins. Neither generated body carries a secret, and both say how to cache them.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./stageb/boot');
++
++const HOST = 'widgets.openvibe.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); });
ddiff --git a/test/hosting-storage-media.test.js b/test/hosting-storage-media.test.js
nnew file mode 100644
iindex 0000000..144f2e2
---- /dev/null
++++ b/test/hosting-storage-media.test.js
@@@ -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();
++})();
ddiff --git a/test/hosting-uploads.test.js b/test/hosting-uploads.test.js
nnew file mode 100644
iindex 0000000..0079113
---- /dev/null
++++ b/test/hosting-uploads.test.js
@@@ -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();
++})();
ddiff --git a/test/indexnow.test.js b/test/indexnow.test.js
nnew file mode 100644
iindex 0000000..0642f7e
---- /dev/null
++++ b/test/indexnow.test.js
@@@ -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); });
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..8bd8b94
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -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();
++})();
ddiff --git a/test/stageb/boot.js b/test/stageb/boot.js
nnew file mode 100644
iindex 0000000..e29f76e
---- /dev/null
++++ b/test/stageb/boot.js
@@@ -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 };
ddiff --git a/test/stageb/media.js b/test/stageb/media.js
nnew file mode 100644
iindex 0000000..13cb0ca
---- /dev/null
++++ b/test/stageb/media.js
@@@ -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 };
ddiff --git a/test/stageb/mocks.js b/test/stageb/mocks.js
nnew file mode 100644
iindex 0000000..634656a
---- /dev/null
++++ b/test/stageb/mocks.js
@@@ -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 };
ddiff --git a/test/stageb/tar.js b/test/stageb/tar.js
nnew file mode 100644
iindex 0000000..9c1e673
---- /dev/null
++++ b/test/stageb/tar.js
@@@ -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 };