openvibers/openvibe.host Commits

Host: the static hosting engine moved to OpenVibe.Space; its URLs answer 410 with a link (#84)

OP OpenVibers committed 12 Oct 2026 · e9b9120dd0 · 80 files, +465 −8591

Plan T20: the static hosting engine (Stage B) now lives in OpenVibe.Space (Space#36, live since today, staff-only; contracts 0.149.0 released its `space.*` ids). Host is the hosting comparison catalog plus the `ovhost` operator CLI, so the engine's copy here goes. Production Host had 0 projects, 0 sites and 0 deploys.

- **Removed:**
  - The code: `server/domain/`, `server/artifacts/`, the resource index, `server/storage.js`, the engine's HTTP routes, dashboard, worker jobs and takedown and domain code.
  - Its tests: `test/host-*`, `test/stageb/`. The catalog's boot helper moved to `test/helpers/`.
  - Its env keys, its event types (`host.deploy.*`, `host.domain.verified`; the catalog's `host.index_document.*` stay), its per-actor limit buckets, and `busboy`.
- **410 Gone with a link to openvibe.space** for `/hosting`, `/projects*`, `/sites*`, `/deploys*`, `/domains*`, `/preview*`, the matching `/api/v1/*` routes (problem JSON `host.moved_to_space`) and any `*.openvibe.host` tenant host. During review I made the route patterns lookaheads: Express's `app.use` skipped `/sites/<id>` when the pattern consumed the `/`, so `test/engine-moved.test.js` failed.
- **Migration 0008** (`-- phase: contract`, after 0007) drops the engine's tables and functions once the expand window has passed; the catalog's tables stay.
- **Front page and nav:** no "Static hosting" entry. One line sends anyone who wants to host a site to OpenVibe.Space.
- **README:** Codex's draft cut it from 595 to 50 lines, dropping the operator documentation. It is rebuilt from the original, with every ovhost section kept (using ovhost, deploys, backups, restore drills, inventory, security) and only the Stage B parts replaced. A "Static hosting moved" section lists what answers 410, and the Capabilities section lists what a Contracts release can retire.
- `service-kit.test.js` now asserts the new truth: with no uploads, requests time out at 30 s instead of 10 minutes.

Tests: the full suite, 48/48 files.

Changes

ddiff --git a/.env.example b/.env.example
iindex a03cb1d..586b07a 100644
---- a/.env.example
++++ b/.env.example
@@@ -1,131 +1,31 @@
--# ═══════════════════════════════════════════════════════════════
--# OpenVibe.Host Stage B (tenant static hosting): environment (names only; never commit values)
--# Copy to .env for development. Production: /etc/openvibe/host.env (0600).
--# Stage A (the ovhost CLI) reads no environment beyond OVHOST_INVENTORY.
--# ═══════════════════════════════════════════════════════════════
--
--# Server
++# OpenVibe.Host catalog service. Production: /etc/openvibe/host.env.
PORT=4910
HOST=127.0.0.1
NODE_ENV=development
--# The dashboard + API origin (production: https://openvibe.host)
BASE_URL=http://localhost:4910
--# Proxy hops in front of Node that set X-Forwarded-For. nginx resolves the Cloudflare hop itself
--# (realip) and sends only $remote_addr, so there is one: nginx → Node = 1.
TRUST_PROXY=1
--
--# Tenant sites are served at <site>.<HOST_SITES_DOMAIN>. It must be its own registrable domain
--# (never under openvibe.network / openvibe.live). Development: localhost (http://<site>.localhost:4910)
--HOST_SITES_DOMAIN=openvibe.host
--
--# Public hosting is owner-gated (plan T20/O34). Off: only OpenVibe staff can create projects and the front page
--# says hosting is not open yet. Set to on at launch.
--HOST_PUBLIC_HOSTING=off
--
--# PostgreSQL database and content-addressed objects (the production unit sets both under
--# /var/lib/openvibe-host-api)
--# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh host: the runtime role through PgBouncer,
--# the owner on a direct connection (migrations), and Valkey (shared limit counters).
--# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
DATABASE_URL=
DATABASE_DIRECT_URL=
++#HOST_PGLITE_DIR=
VALKEY_URL=
VALKEY_PREFIX=
--HOST_STORAGE_DIR=./data/objects
--
--# Object storage. Unset/empty: local disk only (data/objects), exactly as before. `media`: every
--# deploy object is also written through to OpenVibe.Media (private, Host's namespace `host`, keyed
--# per project) and local disk becomes the read cache. Needs OV_MEDIA_URL and OV_OAUTH_CLIENT_SECRET
--# (Host's Network service principal with media.object.upload/read/list/delete for namespace `host.*`,
--# and a `host` tenant in Media).
--HOST_OBJECT_STORE=
--OV_MEDIA_URL=http://127.0.0.1:4100
--#OV_MEDIA_NAMESPACE=host
--
--# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
--# OAuth client `host` (redirect https://openvibe.host/auth/callback). The same client is Host's
--# service principal svc:host for client-credentials grants (see README, "Grants").
OV_OAUTH_CLIENT_ID=host
OV_OAUTH_CLIENT_SECRET=
#OV_OAUTH_REDIRECT_URI=
--# Cookies: Secure (and __Host- prefixed) by default in production; false for plain-http development
#COOKIE_SECURE=false
--# Signs the dashboard form tokens (CSRF). Unset: random per process.
HOST_FORM_SECRET=
--
--# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set
--#EVENTS_URL=http://127.0.0.1:4300
++#EVENTS_URL=
#EVENTS_RELAY_INTERVAL_MS=2000
--
--# Largest request body for one deploy (bytes; nginx allows 110m on the upload routes)
--#HOST_MAX_UPLOAD_BYTES=104857600
--# Largest size an uploaded archive may unpack to (default 256 MiB; the storage quota also applies).
--#HOST_MAX_UNPACKED_BYTES=268435456
--# Uploads read and validated at the same time, service-wide; each is held in memory (up to the two
--# limits above), so this bounds what concurrent uploads can cost. Others get 503 + Retry-After.
--#HOST_MAX_CONCURRENT_UPLOADS=2
--# Deploys are refused (507) while the object store's filesystem has less than this free: the disk
--# is shared with every other service on the host. Serving is never affected. Default 5 GiB.
--#HOST_MIN_FREE_BYTES=5368709120
--# Per-actor limits on dashboard and API writes (server/http/actor-limits.js, roadmap WS-R task 4): per minute and per
--# hour for a signed-in person or a service; deploy uploads, domain checks and new projects have tighter numbers.
--# HOST_ACTOR_LIMITS=off turns them off (a rollback lever).
#HOST_LIMITS_MINUTE=120
#HOST_LIMITS_HOUR=3000
#HOST_ACTOR_LIMITS=on
--# Projects one person may own; each gets the per-project quotas below. Default 10.
--#HOST_MAX_PROJECTS_PER_OWNER=10
--
--# Per-project quota defaults (a staff override per project wins). Production projects:
--#HOST_QUOTA_STORAGE_BYTES=1073741824
--#HOST_QUOTA_DEPLOYS_PER_DAY=50
--#HOST_QUOTA_MAX_FILES=10000
--#HOST_QUOTA_MAX_FILE_BYTES=26214400
--#HOST_QUOTA_SITES=10
--#HOST_QUOTA_CUSTOM_DOMAINS=5
--# Sandbox projects:
--#HOST_SANDBOX_QUOTA_STORAGE_BYTES=104857600
--#HOST_SANDBOX_QUOTA_DEPLOYS_PER_DAY=20
--#HOST_SANDBOX_QUOTA_MAX_FILES=2000
--#HOST_SANDBOX_QUOTA_MAX_FILE_BYTES=10485760
--#HOST_SANDBOX_QUOTA_SITES=3
--#HOST_SANDBOX_QUOTA_CUSTOM_DOMAINS=0
--
--# Custom domains: what the CNAME points at (default <site>.<HOST_SITES_DOMAIN>), origin addresses
--# shown for apex domains, resolvers for TXT checks, timings
--#HOST_CNAME_TARGET=
--#HOST_ORIGIN_IPV4=
--#HOST_ORIGIN_IPV6=
--#HOST_DNS_SERVERS=1.1.1.1,8.8.8.8
--#HOST_DOMAIN_RECHECK_MS=600000
--#HOST_DOMAIN_PENDING_DAYS=7
--#HOST_DOMAIN_LAPSE_DAYS=7
--# Extra domains nobody may claim, and extra reserved site names (comma-separated)
--#HOST_RESERVED_DOMAINS=
--#HOST_RESERVED_SITE_NAMES=
--
--# Background worker (domain checks, orphan-object sweep, outbox prune); off: HOST_WORKER=off
#HOST_WORKER=on
#HOST_WORKER_ID=
--
--# Browser origins that may call /api/v1 with a Bearer Network JWT (never cookies)
--#API_CORS_ORIGINS=https://openvibe.network,https://openvibe.codes
--
--# release.json (openvibe-shared/release): the deployed commit when git is not available
--#RELEASE_COMMIT=
--#MIN_CLIENT_RELEASE=
--
--# Sourced hosting catalog: off by default. The URL is the internal OpenVibe.Sources API.
--# Signed sources.item.* deliveries arrive at /internal/catalog/events when secrets are configured.
HOST_CATALOG_INGEST=off
HOST_CATALOG_SOURCES_URL=
HOST_CATALOG_WEBHOOK_SECRETS=
--
--# Optional catalog discussion threads. Internal Community API; unset disables Discussion blocks.
HOST_COMMUNITY_URL=
--# Public Community address used for the join link.
OV_COMMUNITY_URL=https://openvibe.community
--# OpenVibe.Watch's public site: a plan page links a prefilled watch on the plan's own API ("Tell me when this price changes").
OV_WATCH_URL=https://openvibe.watch
ddiff --git a/README.md b/README.md
iindex c625ce2..ddf1da0 100644
---- a/README.md
++++ b/README.md
@@@ -1,10 +1,10 @@
# OpenVibe.Host
--> The network's deployment/control plane first; then isolated hosting for community sites, bots and mods.
++> Hosting, compared honestly: plans side by side with dated, sourced prices. And the network's operator plane, `ovhost`.
--**Status:** alpha. Stage A (operator plane) is a CLI, `ovhost`, tested against a fake host. It is installed on the production host at `/usr/local/bin/ovhost` with inventory `/etc/openvibe/host.json`; it deploys services, runs nightly backups, and performs restore drills. Repository deploy scripts are thin wrappers around `ovhost deploy|rollback|plan`: [docs/deploy-strategies.md](docs/deploy-strategies.md). Stage B (tenant static hosting) serves the dashboard, API, front page, and tenant sites. See [docs/launch.md](docs/launch.md) and [docs/threat-review.md](docs/threat-review.md). Stage C (sandboxed user code) is **not started**.
--**Domain:** `openvibe.host` (dashboard and API) and `*.openvibe.host` (tenant sites). Launched on 2026-10-07 (see [Launch rule](#launch-rule)); OpenVibe.Sites no longer serves it.
--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.3 and §15.17; roadmap Wave 21.
++**Status:** alpha. Stage A (operator plane) is a CLI, `ovhost`, tested against a fake host. It is installed on the production host at `/usr/local/bin/ovhost` with inventory `/etc/openvibe/host.json`; it deploys services, runs nightly backups, and performs restore drills. Repository deploy scripts are thin wrappers around `ovhost deploy|rollback|plan`: [docs/deploy-strategies.md](docs/deploy-strategies.md). The Host service on port 4910 serves the hosting comparison catalog at openvibe.host. Stage B (tenant static hosting) **moved to OpenVibe.Space on 2026-10-12** (see [Static hosting moved](#static-hosting-moved)). Stage C (sandboxed user code) is **not started**.
++**Domain:** `openvibe.host` (the catalog). `*.openvibe.host` served Stage B's tenant sites; any request there now answers 410 with a link to OpenVibe.Space.
++**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.3 and §15.17; roadmap Wave 21; T20 (Host, Space and Community re-split, 2026-10-08).
**License:** AGPL-3.0 (same as every OpenVibe service).
## The hosting catalog (openvibe.host's front page)
@@@ -14,7 +14,7 @@ Plan T20 step 3 (owner, 2026-10-08): openvibe.host is **hosting compared**: a mo
- the day each price was checked, and a link to its source;
- price changes kept as a history per plan.
--OpenVibe's own static hosting (Stage B below) has its page at `/hosting`; it is still not open to the public.
++OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-moved)); the front page links it in one line.
- **Where plans come from.**
- OpenVibe.Sources items in category `hosting` (official provider APIs), read with `openvibe-publishing/ingest`: an event consumer at `/internal/catalog/events` plus a cursor pull. The switch is `HOST_CATALOG_INGEST` (default off) with `HOST_CATALOG_SOURCES_URL`.
@@@ -31,6 +31,20 @@ OpenVibe's own static hosting (Stage B below) has its page at `/hosting`; it is
- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id` and `/catalog/promos`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
- **Discussion.** Active public provider and plan pages lazily resolve Community threads with refs `{ service: 'host', type: 'provider', id: provider.id }` and `{ service: 'host', type: 'plan', id: plan.id }` (plus a display label), then render recent comments with a link to the thread on OpenVibe.Community. Host stores only the thread reference. `HOST_COMMUNITY_URL` enables the internal client; `OV_COMMUNITY_URL` sets the public link. With the internal URL unset, no Discussion block appears. Hidden providers and hidden or gone plans have no public page; status changes hide or reopen existing threads after commit. Host's Network service principal needs `community.comment.write` and `community.comment.moderate` for audience `openvibe.community`.
++## Static hosting moved
++
++The static hosting engine (Stage B: projects, sites, immutable content-addressed deploys, activation and rollback, previews, Git ingest, per-site configuration, custom domains, takedowns, the resource index and the dashboard) moved to **[OpenVibe.Space](https://openvibe.space)** on 2026-10-12 (plan T20 step 2), where sites are served at `<name>.openvibe.website` under `space.*` capabilities and events (openvibe-contracts 0.149.0). Production Host had 0 projects, 0 sites and 0 deploys, so no data moved.
++
++What is left here answers **410 Gone** with a link to Space:
++
++| Request | Answer |
++| --- | --- |
++| `/hosting`, `/projects*`, `/sites*`, `/deploys*`, `/domains*`, `/preview*` | a short page linking OpenVibe.Space |
++| `/api/v1/projects*`, `/api/v1/sites*`, `/api/v1/deploys*`, `/api/v1/domains*`, `/api/v1/resources*` | problem JSON `{ "code": "host.moved_to_space", "url": "https://openvibe.space" }` |
++| any `*.openvibe.host` tenant host | a short page linking OpenVibe.Space |
++
++Migration `0008_drop_hosting_engine.sql` (`-- phase: contract`, after `0007`) drops the engine's tables once the expand window has passed; the catalog's tables stay.
++
## Purpose
Repository deploy scripts call ovhost. Stage A applies the deploy rules for every service on the host:
@@@ -58,7 +72,7 @@ Stage A adds a few safety rules of its own:
## Owns
- Stage A: the host inventory (services, units, env names, ports, probes, drain policy), environment validation, release install and rollback for git-checkout services, readiness and drain orchestration, the release log, certificate inventory, nginx vhost rendering and transactional install, config snapshots, scheduled backups (pgBackRest verification and logical PostgreSQL dumps) with retention and encrypted off-host copies, restore drills.
--- Stage B: tenant projects (keyed by project id), static sites, immutable content-addressed deploy artifacts, activation and rollback, default and custom domains (DNS TXT verification), per-project quotas, upload/validation logs, the tenant vhosts (`ovhost nginx tenants`).
++- The hosting comparison catalog: providers, plans, price history, promotions and their sources (migrations 0005-0007), and the catalog's Search documents (`host.index_document.*`).
- Later (Stage C): sandbox profiles, budgets, secret references, outbound policy.
- Host roles, provisioned from code so any machine can take them: the **data role** (`roles/data/`, ADR-035): PostgreSQL 18, PgBouncer, pgBackRest, Valkey and their exporters, plus `add-service.sh` giving each service its database, roles and Valkey user. `ovhost data provision <svc>` runs `add-service.sh` from the installed CLI, redacting every URL and password: [docs/data-provisioning.md](docs/data-provisioning.md).
@@@ -67,48 +81,43 @@ Stage A adds a few safety rules of its own:
- Product business logic. Host calls product hooks (`build`) and reads product endpoints (`/api/ready`, `/api/streams`). It never embeds product rules.
- Secrets. `ovhost` reads service env files only to learn variable **names** and whether each one is empty. It never stores, prints or snapshots a value. The one exception is its own `/etc/openvibe/backup.env` (root-only): it reads the `BACKUP_*` values there to reach the backup bucket, and never prints or stores them.
- Platform-wide credentials for hosted code (never).
--- Certificate issuance/renewal (certbot does this today). Stage A only takes inventory; Stage B never handles a certificate or key through its API.
--- Projects as an identity concept: ADR-014 puts projects in OpenVibe.Network. Until Network has them, Host creates a `prj_` project for its owner and records `network_project_id` when one is given.
--- Tenant secrets. Stage B stores none (no environment variables, no build secrets, no deploy keys).
++- Certificate issuance/renewal (certbot does this today). Stage A only takes inventory;
++- Static hosting: OpenVibe.Space owns it since 2026-10-12.
## Depends on
--- OpenVibe.Contracts (`openvibe-contracts` v0.107.0; `host.*` capabilities and the `host` manifest were released in v0.24.0, v0.32.0 added the takedown routes to `host.site.manage`, v0.83.0 added `host.site.config`, and v0.107.0 adds the planned `host.resource.read` and the git-source routes to `host.site.manage`/`host.deploy.create`): service manifests (vhost rendering, snapshots, the first-party domain list), ids, problem+json, service-token verification, capability checks, the `common.resource-summary@1`/`common.resource-list-result@1` schemas and the `contracts.resources` OVRN helpers (ADR-048).
--- OpenVibe.Network (Stage B): SSO for the dashboard, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay.
--- OpenVibe.Events (Stage B): `host.*` events through the `openvibe-sdk` v0.44.0 transactional outbox (openvibe-sdk/limits for the per-actor limits).
--- `openvibe-shared` v3.0.1 (Stage B): the OpenVibe Frame (Host's own pages are composed with `openvibe-shared/shell`), legal pages, `/release.json`, `/metrics`, `/api/ready`.
--- OpenVibe.Media (Stage B, opt-in): with `HOST_OBJECT_STORE=media`, deploy objects are written through to Media's Object API v2 (`openvibe-sdk/media`) and local disk becomes the read cache; unset keeps them on local disk (see [Storage](#storage)).
++- OpenVibe.Contracts (`openvibe-contracts`, pinned below): `host.catalog.manage`, the catalog's `host.index_document.*` events, and `host.release.published` from `ovhost announce`.
++- OpenVibe.Network: SSO for catalog staff, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay and `ovhost alerts relay`.
++- OpenVibe.Events: the catalog's events through the `openvibe-sdk` transactional outbox, and `ovhost announce`.
++- OpenVibe.Sources (optional): price ingestion for the catalog (`HOST_CATALOG_INGEST`); OpenVibe.Search: the catalog's pages as documents; OpenVibe.Community (optional): discussion on provider and plan pages; OpenVibe.Watch: the "Tell me when this price changes" link on plan pages (a link, no call).
++- `openvibe-shared` (the OpenVibe Frame, legal pages, `/release.json`, `/metrics`, `/api/ready`) and `openvibe-publishing` (ingest and the search feed).
## Capabilities
--Implemented here (the service manifest's `capabilities`, Stage B's API, audience `openvibe.host`):
--`host.site.manage` (sites, members, takedowns), `host.deploy.create` (uploads, activation, rollback),
--`host.domain.manage` (custom domains), `host.site.config` (headers, redirects, SPA fallback), and
--`host.catalog.manage` (staff-only hosting catalog writes). See
--[Grants and registration](#grants-and-registration-for-the-lead).
++Implemented here (the service manifest's `capabilities`, audience `openvibe.host`): `host.catalog.manage`
++(staff-only catalog writes: providers, plans, promotions).
--`host.resource.read` (first-party) is the authority resource index Host serves for OpenVibe.Services'
--fan-out ([ADR-048](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-048-services-control-plane.md)
--section 3, plan T13 step 8): `GET /api/v1/resources` lists Host's sites, deploys and domains as a page of
--`common.resource-summary@1` and `GET /api/v1/resources/:ovrn` reads one by its OVRN, exactly as
--OpenVibe.Network's index does. Host ships it; OpenVibe.Contracts keeps the capability `planned` with no
--`implementedBy` routes until the deploy, and flips it afterwards.
++Retired with the engine (OpenVibe.Space implements the `space.*` equivalents since openvibe-contracts 0.149.0;
++a Contracts release retires these): capabilities `host.site.manage`, `host.site.config`, `host.deploy.create`,
++`host.domain.manage`, `host.resource.read`, and events `host.deploy.created`, `host.deploy.activated`,
++`host.deploy.failed`, `host.domain.verified`.
Called elsewhere, as the service principal `host` (the OAuth client `host`; `ovhost` reads the same
credentials from `/etc/openvibe/host.env`):
| Service | Grant | Used by |
|---|---|---|
--| OpenVibe.Events | `events.event.publish` | Stage B's outbox (`host.deploy.*`, `host.domain.verified`) and `ovhost announce` (`host.release.published`) |
++| OpenVibe.Events | `events.event.publish` | the catalog's outbox (`host.index_document.*`) and `ovhost announce` (`host.release.published`) |
| OpenVibe.Network | `network.operator.alert` | `ovhost alerts relay` (Prometheus alerts that page the operator) |
| OpenVibe.Network | `network.status.incident` | `ovhost incident` and `ovhost maintenance` (the public status page) |
++| OpenVibe.Community | `community.comment.write`, `community.comment.moderate` | the catalog's discussion threads |
## Stages
| Stage | Scope | State |
|---|---|---|
| **A: operator plane** | `ovhost` CLI and library: inventory, validate, plan, deploy (`--wait-idle`/`--force`), automatic rollback, rollback, status, releases, certs, nginx render/install, snapshot, backup, restore drill. No daemon and no server. Port **4910** is reserved for a later operator API. | **alpha**: written and tested (fake host, plus the real executor against temp HTTP/git). Installed on the host: deploys every service, runs the nightly backups and the restore drills. |
--| **B: tenant static hosting** | The Host API service (port 4910): projects, sites, immutable content-addressed deploys, activation and rollback, `<site>.openvibe.host` and TXT-verified custom domains, quotas, upload logs, events, a server-rendered dashboard; tenant vhosts via `ovhost nginx tenants`. | **alpha**: written and tested (`npm test`, against a temp database, a mock Network and a DNS table). Runs on the host on `:4910` behind nginx; launched at openvibe.host on 2026-10-07. |
++| **B: tenant static hosting** | Projects, sites, immutable deploys, rollback, previews, Git ingest, custom domains. | **moved to OpenVibe.Space** on 2026-10-12 (T20 step 2); old URLs here answer 410. |
| **C: sandboxed user code** | Isolation profiles, CPU/memory/time/network/storage budgets, secret references, outbound policy, metering, kill/revoke without touching platform services. | **not started**, deliberately. Blocked until isolation and metering are proven. Nothing in Stage B runs tenant code. |
Stage A also provides incident and maintenance controls (`ovhost incident|maintenance|freeze`) and release notifications (`host.release.published`). Restore drills are described in [docs/restore-drills.md](docs/restore-drills.md).
@@@ -160,7 +169,7 @@ ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--
ovhost releases <service> [--limit <n>]
ovhost certs [--warn-days <n>]
ovhost nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
--ovhost nginx tenants <service> [--wildcard-cert <name|dir>] [--install] Stage B tenant vhosts
++ovhost nginx tenants <service> [--wildcard-cert <name|dir>] [--install] tenant vhosts for a service whose inventory has `nginx.tenants`
ovhost snapshot <service> [--out <file>]
ovhost backup <service>
ovhost backup --all [--offsite] [--logical] [--no-prune] [--keep-daily <n>] [--keep-weekly <n>]
@@@ -250,7 +259,7 @@ Two things deploy from this repository:
then `ovhost --version` and `sudo ovhost capabilities` (ovhost does not deploy itself; an update is
undone by checking out the previous commit there). Its timers (`openvibe-backup`, `openvibe-alerts`, `openvibe-browsercheck`,
`openvibe-devpath`, `openvibe-toolsjob`) are unit files in [deploy/systemd/](deploy/systemd/).
--- **The Stage B service** deploys like every other service, with `sudo ovhost deploy host` (strategy
++- **The catalog service** deploys like every other service, with `sudo ovhost deploy host` (strategy
`git-checkout`: fetch, fast-forward `/opt/openvibe.host`, install on a lockfile change, restart, wait for `/api/ready`).
The unit is `openvibe-host.service` on `127.0.0.1:4910`, the env file `/etc/openvibe/host.env`. Its state is
`/var/lib/openvibe-host-api` (not ovhost's own `/var/lib/openvibe-host`).
@@@ -273,268 +282,12 @@ This was done on 2026-09-23 (the CLI and inventory are installed); the steps sta
4. **Run as root:** `sudo ovhost …`. `ovhost` drops to `ubuntu` for git/npm (`runuser`) and needs root for `systemctl`, `nginx -t`, reading `/etc/openvibe/*.env` (names only) and writing vhosts. For least privilege, give operator accounts a sudoers rule for `/usr/local/bin/ovhost` alone instead of a general `ALL`. Do **not** grant it to the `ubuntu` service user if that account has no sudo today.
5. **First run:** `sudo ovhost status`, then `sudo ovhost validate <each service>`, then `sudo ovhost plan live`, all read-only apart from `git fetch`. Adopt `deploy` one service at a time, starting with one that has no protected sessions.
6. **Scheduled backups:** see [docs/backups.md](docs/backups.md#installing-on-the-host-operator) (bucket, key, `/etc/openvibe/backup.env`, `openvibe-backup.timer`).
--7. **Updating the CLI itself:** a deploy of this repository replaces `/opt/openvibe.host` (the Stage B service), not the root-owned install at `/usr/local/lib/openvibe-host`, so after a merge to `main` run `sudo ovhost self-update`. It finds its own install directory from its real path (`/usr/local/bin/ovhost` → `/usr/local/lib/openvibe-host/bin/ovhost`), fetches `origin main`, refuses a dirty tree, a branch other than `main` or a divergence, merges `--ff-only` and runs `npm ci --omit=dev --no-audit --no-fund` there; `--dry-run` prints the plan first. It restarts nothing — the next `ovhost` invocation runs the new code. Without it the installed CLI silently runs behind `main` (on 2026-10-02 it was 16 commits behind: no `env-names --set`, no `logs`, no Bot vhost).
--
--## Stage B: tenant static hosting
--
--A Node service (`server/`, Express 4, PostgreSQL, port **4910**, service id `host`) that hosts static sites for OpenVibe projects. Every request is dispatched on its `Host` header first:
--
--| Host | What answers |
--|---|---|
--| `openvibe.host` (the `BASE_URL` host) and loopback | the dashboard, `/api/v1`, `/auth/*`, `/api/ready`, `/api/health`, `/release.json`, `/limits.json` (the default project limits, from config), `/metrics` (direct loopback only), legal pages, and the crawl files `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` (`text/plain`, cached like the sitemap; they list only Host's own public pages: the front page, `/updates` and the legal pages, never a customer site, a preview or an operator path) |
--| `<site>.openvibe.host` (one label) | that site's active deploy, and nothing else: no API, no sign-in, no cookies |
--| a custom domain with `status = verified` | the site it was verified for |
--| anything else (including pending, failed or lapsed custom domains, and `a.b.openvibe.host`) | `404 Unknown host`, with no tenant content |
--
--### Model
--
--- **Projects** (`prj_<ULID>`) have an owner (a `usr_` subject), an environment (`production` or `sandbox`), members with a role (`owner` > `maintainer` > `deployer`), and optionally the `network_project_id` of the OpenVibe.Network project (ADR-014: Network owns projects; until it has them, Host creates one for its owner). Every Host row and every stored object is keyed by the project id.
--- **Sites** (`sit_<ULID>`) have a name (one DNS label, 3–40 characters, reserved names refused) that is their default host, `<name>.openvibe.host`, and a pointer to the active deploy. A deleted site's name is held for 30 days so nobody else can serve content on links that still point there. (The id prefix is `sit_` since 2026-10-07, ADR-048's three-letter rule; it was `site_`, renamed with no conversion because production held no sites.)
--- **Deploys** (`dpl_<ULID>`) are immutable artifacts: a manifest (`host.deploy-manifest@1`: path, sha256, size and content type of every file, file count, total bytes) with its own sha256, file rows, and the upload/validation log. Database triggers refuse any update of a deploy's artifact columns or file rows. The bytes are content-addressed per project: `<HOST_STORAGE_DIR>/projects/<prj_…>/<aa>/<sha256>`, with `HOST_OBJECT_STORE=media` writing them through to OpenVibe.Media as well ([Storage](#storage)).
--- **Domains**: every site has its default domain. A custom domain is added as `pending` and becomes `verified` (and served) once `_openvibe-host.<hostname>` has the TXT record `openvibe-host-verification=<token>`. A verified name belongs to one site. OpenVibe domains (every domain in the released service manifests, `openvibe.<tld>`, the sites domain itself) can never be claimed. The worker re-checks pending domains (they fail after `HOST_DOMAIN_PENDING_DAYS`) and re-checks verified ones daily: a TXT record gone for `HOST_DOMAIN_LAPSE_DAYS` lapses the domain and it stops being served.
--- **Quotas** per project, enforced by Host: storage bytes (objects stored once per project, so re-uploading unchanged files costs nothing), deploys per rolling 24 hours (checked before the body is read; failed uploads count), files per deploy, bytes per file, sites, custom domains. A deploy's own size before deduplication may not exceed the storage quota. Defaults come from `HOST_QUOTA_*` / `HOST_SANDBOX_QUOTA_*`; staff override them per project. Sandbox projects get smaller quotas, no custom domains, and `X-Robots-Tag: noindex`.
--- **Build logs**: Stage B has no build step. Each deploy's log records what was received, every validation problem, what was stored, the manifest digest and the activation, and says that nothing was executed. Refused uploads are recorded as `failed` deploys, so their logs are visible too.
--
--### Storage
--
--Deploy objects are content-addressed and stored **per project** (ADR-014):
--`<HOST_STORAGE_DIR>/projects/<prj_…>/<aa>/<sha256>`. The same bytes uploaded by two projects are
--stored twice, and storage is accounted and deleted per project. Every read goes through
--`server/storage.js` and is keyed by a validated project id and the manifest's sha256, so a request
--can only ever name its own project's objects.
--
--With **`HOST_OBJECT_STORE=media`** (opt-in; unset is the local store above, unchanged) local disk
--becomes the read cache and **OpenVibe.Media** is the source of truth:
--
--- every object is written through to Media's Object API v2 (`openvibe-sdk/media`
-- `createObjectsClient`) as a **private** object in Host's own namespace (`OV_MEDIA_NAMESPACE`,
-- default `host`), keyed per project by `metadata.project_id` with the file's sha256 as
-- `content_hash`. A deploy is not reported as stored until Media acknowledged every object; a Media
-- failure fails the deploy (`502 storage.object_store`) with its log, never a silent local-only
-- success;
--- serving reads the local cache; a miss re-fetches the object from Media, verifies its sha256 and
-- only then caches and serves it (a mismatch is a `500`, never served);
--- an object is deleted from Media exactly where the local store would delete the blob (deploy GC,
-- project removal, the hourly orphan sweep), and the delete re-checks `host_blobs` just before it
-- runs, so an object a deploy re-referenced while the delete was in flight keeps its Media copy;
--- an empty file (0 bytes) is stored in the local cache only: Media's upload refuses empty
-- objects, and the serving path never asks Media for a size-0 file;
--- the hourly orphan sweep walks the **local cache files** only, so a Media object with no cache
-- file (for example a duplicate left by two uploads that raced before puts were serialised) is
-- not reaped by the sweep; project removal deletes it, and puts are serialised per
-- `(project, sha256)` so two deploys sharing a new sha do not create a duplicate in the first
-- place.
--
--Host authenticates to Media with its Network service principal (client credentials, audience
--`openvibe.media`), which must hold `media.object.upload`, `media.object.read`, `media.object.list`
--and `media.object.delete` for the `host` namespace and everything below it (`host.*`), and a `host`
--tenant must exist in Media. `OV_MEDIA_URL` is the internal Media base URL (default
--`http://127.0.0.1:4100`). `ovhost backup host` still archives the local cache; in the Media mode the
--Media objects are the durable copy.
--
--### Uploads
--
--`POST /api/v1/sites/:id/deploys[?activate=1][&root=dist]` accepts a tar or tar.gz archive (`Content-Type: application/gzip`, `application/x-tar`, `application/octet-stream`) or `multipart/form-data` (`archive=<one archive>` or `files=<file>…`, each part's filename being the path; `strip=folder` removes a browser folder upload's shared top folder). The archive is parsed in memory and never extracted. The whole upload is refused, and recorded as a failed deploy, when any of these is true:
--
--- a path is absolute, contains `..`, `.`, an empty segment, a backslash, a control or non-ASCII character, or goes more than 32 levels deep; a pax or GNU long-name header that rewrites a name gets the same checks;
--- an entry is a symbolic link, a hard link, a device, a FIFO or any other non-regular entry (never followed and never skipped);
--- a hidden file or directory other than `.well-known/` is present (`.env`, `.git/`, `.htaccess`, …);
--- a file is server-side code or an executable (`php`, `cgi`, `pl`, `py`, `sh`, `asp(x)`, `jsp`, `shtml`, `exe`, `jar`, …) or looks like a credential or database (`pem`, `key`, `p12`, `env`, `sqlite`, …);
--- a file type is not on the allowlist (HTML, CSS, JavaScript, JSON, source maps, web manifests, text, Markdown, CSV, XML/RSS/Atom, WebVTT, SVG, PNG, JPEG, GIF, WebP, AVIF, ICO, BMP, fonts, MP4/WebM/Ogg video, MP3/Ogg/Opus/WAV/M4A/FLAC audio, PDF, WebAssembly, glTF, zip; extension-less files are plain text);
--- two entries have the same path, a path is both a file and a directory, or there are no files;
--- a limit is exceeded: request size (`HOST_MAX_UPLOAD_BYTES`, the connection is closed rather than drained), files, bytes per file, total bytes (decompression stops at `HOST_MAX_UNPACKED_BYTES`, default 256 MiB, or the storage quota if smaller, so a gzip bomb cannot fill memory), or storage.
--
--### Serving
--
--Requests resolve their site once from the `Host` header, read the site's active deploy pointer once, and then read only that deploy's immutable rows. The file's bytes come from the blob store keyed by the site's project id and the manifest's sha256 — the local disk cache, or, with `HOST_OBJECT_STORE=media`, the object fetched back from Media and sha256-verified ([Storage](#storage)) — never from the URL, so no path, encoding or `Host` trick can reach another project's objects. Absolute-form request targets must name the same host as the `Host` header.
--
--- `GET`/`HEAD` only (405 otherwise). `/` and `/dir/` serve `index.html`; `/page` also tries `page.html`; `/dir` redirects to `/dir/` when `dir/index.html` exists.
--- `Content-Type` from the manifest; strong `ETag` (the sha256) with `304` on `If-None-Match`; single byte ranges (`206`/`416`).
--- `Cache-Control: public, max-age=31536000, immutable` for fingerprinted asset names (`app.3f2a9c1b.js`, `index-BdK3x9aQ.js`), `public, max-age=0, must-revalidate` for everything else, so an activation or rollback shows at once.
--- `X-Content-Type-Options: nosniff`, a strict CSP (`default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; … object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'`), `Referrer-Policy`, `Cross-Origin-Opener-Policy: same-origin`, a restrictive `Permissions-Policy`. Never a `Set-Cookie`.
--- A missing file serves the deploy's own `404.html` with status 404, or a plain Host 404 page.
--
--**Why a separate registrable domain.** Tenant pages are arbitrary HTML and JavaScript. They live under `openvibe.host`, never under `openvibe.network` or `openvibe.live`, so no OpenVibe session cookie can ever reach them (`ovhost nginx tenants` refuses a sites domain under a first-party domain). The dashboard shares `openvibe.host` with the tenants, which makes tenant pages *same-site* with it. So:
--
--- the dashboard session is only Host's own `__Host-ov_host_session` cookie (HttpOnly, Secure, `Path=/`, no `Domain`; a subdomain cannot set it), written and cleared alongside the SDK's `ov_token` at sign-in, refresh and sign-out (`server/auth/sso.js`, read at `server/auth/viewer.js:99-103`). The plain `ov_token` cookie the shared navbar reads is display-only and is never trusted, a sign-in callback must match Host's own `__Host-ov_host_flow` state, and a refresh never changes the signed-in account. With `COOKIE_SECURE=false` (plain-http development) the names drop the `__Host-` prefix and are not Secure;
--- every dashboard form needs the dashboard's own `Origin` **and** a form token (an HMAC of the signed-in subject); `SameSite` alone would not stop a tenant page;
--- `/api/v1` ignores cookies entirely: `Authorization: Bearer` only;
--- nginx strips `Cookie` and `Authorization` from tenant requests, hides `Set-Cookie` from tenant responses and allows only `GET`/`HEAD` with a 1 KB body limit.
--
--### API (`/api/v1`)
--
--People present their Network user JWT as a Bearer token. Services and apps present a Network client-credentials token for audience `openvibe.host`. Each route checks exactly one capability for service tokens, and every caller is also judged by its project role. A first-party service (`svc:…`) may act for a person with `X-OV-Subject: usr_…`; an app (`app:app_…`) is a principal and always acts as itself, so it must be added to a project as a member (for example as `deployer`, for CI). A token with the claim `env: "sandbox"` is refused on production projects (ADR-014). Callers who are not members get 404 for everything in a project.
--
--| Route | Capability | Least role |
--|---|---|---|
--| `GET/POST /projects` · `GET/DELETE /projects/:id` | `host.site.manage` | member / owner (delete) |
--| `GET /projects/:id/quota` · `PUT /projects/:id/quota` | `host.site.manage` | member / Network staff (PUT) |
--| `PUT/DELETE /projects/:id/members/:principal` | `host.site.manage` | owner |
--| `GET/POST /projects/:id/sites` · `GET/DELETE /sites/:id` | `host.site.manage` | member / maintainer |
--| `GET/POST /sites/:id/deploys` · `GET /deploys/:id` · `GET /deploys/:id/log` | `host.deploy.create` | deployer |
--| `POST /deploys/:id/activate` · `POST /sites/:id/rollback` (`{ deploy_id?, expected_active? }`) | `host.deploy.create` | deployer |
--| `DELETE /deploys/:id` (not the active one) | `host.site.manage` | maintainer |
--| `GET/PUT/DELETE /sites/:id/source` (`{ provider, repo_url, ref }`) | `host.site.manage` | maintainer (GET: member) |
--| `POST /sites/:id/source/deploys?ref=…&commit_sha=…` (the upload body; always a preview) | `host.deploy.create` | deployer |
--| `POST/DELETE /projects/:id/takedown` · `POST/DELETE /sites/:id/takedown` | `host.site.manage` | Network staff only |
--| `GET/POST /sites/:id/domains` · `POST /domains/:id/verify` · `DELETE /domains/:id` | `host.domain.manage` | maintainer (reads: member) |
--| `GET /resources[?project=&kind=&cursor=&limit=]` · `GET /resources/:ovrn` (the resource index, ADR-048) | `host.resource.read` | first-party service only |
--
--Errors are RFC 9457 problems (`application/problem+json`, with the legacy `error` field). A refused upload answers 413/422 with `deploy_id` and the log lines; `503 upload.busy` (with `Retry-After`) when `HOST_MAX_CONCURRENT_UPLOADS` uploads are already being validated; `507 storage.host_full` while the disk has less than `HOST_MIN_FREE_BYTES` free. Network staff (`role: admin`) can read every project, site, deploy and log, delete sites, projects and domains, set quotas, and **take a site or project down** (451 on every host, content kept for review, members see the reason and cannot publish or delete around it); they cannot publish into a tenant's site.
--
--The five capability ids (`host.site.config` since v0.83.0; `host.resource.read` planned since v0.107.0) and the service manifest are released in `openvibe-contracts`, which Host pins, and the CI contract check is blocking; `test/host-contracts-events.test.js` holds the routes, guards and emitted events to the released manifests.
--
--#### The resource index (ADR-048 section 3)
--
--`GET /api/v1/resources` lists the resources Host owns as a page of `common.resource-summary@1`
--(`common.resource-list-result@1`: `{ resources, next_cursor }`), and `GET /api/v1/resources/:ovrn` reads
--one by its computed OVRN. It is the same path, shape and paging every authority answers, so
--OpenVibe.Services can fan out over all of them and merge:
--
--| Kind | Rows | Name | State |
--|---|---|---|---|
--| `host.site` (`sit_`) | `host_sites` | the site's label (`<name>.openvibe.host`) | `active` / `deleted` |
--| `host.deploy` (`dpl_`) | `host_deploys` | — | `ready` / `failed` / `deleted` |
--| `host.domain` (`dom_`) | `host_domains` | the hostname it answers on | `pending` / `verified` / `failed` / `lapsed` |
--
--Every summary carries `service: "host"`, the `project_id` of the project that owns it, the `created_by`
--person as `owner` when it is a `usr_` subject, and its `ovrn` computed by
--`contracts.resources.nameOf` — `ovrn:host:<prj_…>:site/<sit_…>`, `:deploy/<dpl_…>`,
--`:domain/<dom_…>`. Host lists no projects (only Network lists projects): Host mints its own `prj_` ids
--until it adopts Network projects (ADR-014). `?project=prj_…` is the tenancy boundary — with it only
--that project's rows answer, and one of another project is never returned; without it the first-party
--caller sees every Host-owned resource. `?kind=` narrows to one kind, `?cursor=` is an opaque keyset
--position and `?limit=` is 1–1000 (default 100). A query that cannot be honoured is `400
--resources.bad_query`; an OVRN that names nothing is `404 resources.unknown_resource`. The index is
--first-party: only a service token holding `host.resource.read` is admitted (a person gets 403, no token
--401), and `test/resource-index.test.js` validates every summary and page against the released schemas.
--
--### Activation and rollback
--
--Activation is one PostgreSQL transaction: a compare-and-set on the site's `active_deploy_id`, an activation record and the `host.deploy.activated` event. If any part fails, none of it happens. `expected_active` turns a racing operator's switch into a 409. Rollback goes to a named ready deploy of the same site, or to the most recent previously active one that still exists. The active deploy cannot be deleted. Deleting another deploy removes its objects when no other deploy of the project uses them.
--
--### Git deploys (Phase 1)
--
--A site can be connected to a Git repository, but **the build runs in the project's own CI, never in Host**. Host never clones or fetches the repository, holds no credential for it, and executes nothing; the CI's token is the trust boundary.
--
--- **Connect.** A maintainer sets the site's source: `PUT /api/v1/sites/:id/source` with `{ "provider": "github", "repo_url": "https://github.com/<owner>/<repo>", "ref": "main" }`. `repo_url` must be `https` on the provider's own host (`github.com`, `gitlab.com`, `codeberg.org`) with the path `owner/repo` and an optional `.git`, and no user, port, query or fragment; `ref` must be a branch name by `git check-ref-format`'s rules (at most 200 characters, no `..`, no leading `-`). Any other field (a token, a key) is refused with 422. The row (`host_site_sources`) is public provenance; the read role can GET it.
--- **Deploy.** The CI checks out the commit, builds, and posts the output to `POST /api/v1/sites/:id/source/deploys?ref=<branch>&commit_sha=<full sha>` with the same body and limits as an upload (`ref` and `commit_sha` may also be multipart fields). It authenticates as an app (`app:app_…`, a Network client-credentials token for audience `openvibe.host` with `host.deploy.create`) that is a `deployer` of the project, or with a person's Bearer token. A site with no source answers `409 source.not_connected`, another branch `409 source.ref_mismatch`, a commit that is not 40 or 64 lowercase hex characters `422 source.commit_sha`, and `activate` `422 source.activate`. The files then go through exactly the upload validation; a refused file is a failed deploy with `host.deploy.failed`.
--- **Preview, then approve.** An accepted build is stored as a deploy with `source: "git"` and an immutable `host_deploy_git` row (provider, repo URL, ref, commit), written in the same transaction, and becomes the site's live preview at `/preview/<deploy-id>/` (one hour, members only). It never touches the active deploy. A deployer approves it with the ordinary `POST /api/v1/deploys/:id/activate`; responses show the provenance as `deploy.git`.
--
--A minimal GitHub Actions workflow (the app's client id and secret are repository secrets; Host never sees them):
--
--```yaml
--name: Deploy to OpenVibe Host
--on:
-- push:
-- branches: [main]
--jobs:
-- deploy:
-- runs-on: ubuntu-latest
-- steps:
-- - uses: actions/checkout@v4
-- - run: npm ci && npm run build # your build; the output is in dist/
-- - name: Post the build as a preview
-- env:
-- CLIENT_ID: ${{ secrets.OPENVIBE_CLIENT_ID }}
-- CLIENT_SECRET: ${{ secrets.OPENVIBE_CLIENT_SECRET }}
-- SITE_ID: ${{ vars.OPENVIBE_SITE_ID }}
-- run: |
-- TOKEN=$(curl -fsS https://openvibe.network/oauth/token \
-- -d grant_type=client_credentials -d audience=openvibe.host \
-- -d client_id="$CLIENT_ID" -d client_secret="$CLIENT_SECRET" | jq -r .access_token)
-- tar -czf site.tar.gz -C dist .
-- curl -fsS -X POST "https://openvibe.host/api/v1/sites/$SITE_ID/source/deploys?ref=${GITHUB_REF_NAME}&commit_sha=${GITHUB_SHA}" \
-- -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/gzip' --data-binary @site.tar.gz
--```
--
--### Events
--
--Through the `openvibe-sdk` v0.44.0 transactional outbox (`event_outbox`), inside the transaction that makes the change:
--
--| Event | When | Payload |
--|---|---|---|
--| `host.deploy.created` | an upload became a ready deploy | `project_id, site_id, site, source, file_count, total_bytes, manifest_sha256` |
--| `host.deploy.activated` | a site's active deploy changed | `project_id, site_id, site, deploy_id, previous_deploy_id, rollback` |
--| `host.deploy.failed` | an upload was refused | `project_id, site_id, site, code, problems` (codes only, never file contents) |
--| `host.domain.verified` | a custom domain's TXT record was found | `project_id, site_id, site, hostname` |
--
--The relay publishes with Host's service token (`events.event.publish`, audience `openvibe.events`) only when `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set. Otherwise rows wait and `/api/ready` says the relay is off.
--
--These tenant events have subject `deploy` and visibility `internal`; `host.deploy.activated@1` in openvibe-contracts 0.58.0 is the tenant shape only. Releases of network services are a separate event type: the operator plane (`ovhost`) publishes `host.release.published` with subject `release` and visibility `public` ([docs/release-notifications.md](docs/release-notifications.md)).
--
--### Dashboard
--
--Server-rendered pages with the OpenVibe Frame (`openvibe-shared` v3.0.1 `shell.page`: navbar and theme loader from the Network, `<noscript>` navigation, SSR footer, app icon, legal pages). Every action is a plain form, so it works without JavaScript: projects, quotas and usage, members, sites, folder or archive upload, deploys with activate/rollback/delete, activation history, upload logs and file lists, domains with their DNS records and a "check DNS now" button. Pages are `private, no-store`, `noindex`, `frame-ancestors 'none'`.
--
--### Observability
--
--`GET /api/ready` (openvibe-shared/ready): `db` and `storage` are required; `network_jwks`, `events_relay` and `domain_checks` are optional and report degradation. `GET /release.json`. `GET /metrics` for direct loopback callers only (nginx also returns 404): HTTP golden signals by route template (tenant requests are one `tenant_site` series), process metrics, release info and `host_sites`.
--
--### IndexNow (openvibe-shared/indexnow)
--
--`INDEXNOW_KEY` (`openvibe-shared` v2.2.0) turns on IndexNow. The key file is served at `/<key>.txt` on every host (the dashboard and each tenant site, so an engine can verify a ping that names a tenant host). When a tenant site's active deploy appears, changes (a new deploy or a rollback) or goes away (the site or its project is deleted), the site's page and its `/sitemap.xml` are queued to `api.indexnow.org`; a verified custom domain is queued when it starts serving. The module batches and debounces (one POST per 30 s window), filters to https URLs on the pinged host and never throws, so a failed ping cannot fail a deploy. A ready deploy that is not active (a draft) and a sandbox site (already `noindex`) never ping. Unset → off: no key route is mounted and nothing is sent. Tests and drills never set it (the drill's override in [`host.example.json`](host.example.json) sets `INDEXNOW_KEY` empty).
--
--### Grants and registration (for the lead)
--
--- OpenVibe.Network OAuth client **`host`**, redirect `https://openvibe.host/auth/callback`, scope `profile theme`. The same client is the service principal `svc:host`.
--- Grant `[host, events.event.publish, openvibe.events]`.
--- With `HOST_OBJECT_STORE=media`: grants `[host, media.object.upload, openvibe.media]`, `[host, media.object.read, openvibe.media]`, `[host, media.object.list, openvibe.media]` and `[host, media.object.delete, openvibe.media]`, covering the `host` namespace and its subtree (`host.*`), plus a `host` tenant in Media. Network-side; Host asks for the verbs as its scope.
--- Callers of Host get `[<client>, host.site.manage | host.deploy.create | host.domain.manage, openvibe.host]` as needed. None exist yet (Codes, the expected first caller, has not adopted Host yet).
--- The resource index is read the other way round: **OpenVibe.Services** calls `GET /api/v1/resources` on `svc:services` with `host.resource.read` for audience `openvibe.host`, without `X-OV-Subject` (the capability is first-party and answers the whole index).
--- Released in `openvibe-contracts` v0.107.0 (CI contract check blocking); v0.32.0 added the takedown routes to `host.site.manage.implementedBy` and v0.107.0 added the git-source routes. `host.resource.read` stays `planned` with an empty `implementedBy` until Opus flips it after the deploy.
--
--### Deploying Stage B (for the operator)
--
--The service runs on the host (loopback, since 2026-09-23) behind openvibe.host, and the tenant vhost of step 9 is installed. Public hosting stays off (`HOST_PUBLIC_HOSTING`, Host#47): only staff create projects until the owner's launch gates clear (plan T20).
--
--1. **DNS.** `openvibe.host` and `*.openvibe.host`: `A`/`AAAA` records to the host. If the zone is on Cloudflare, keep `*.openvibe.host` **DNS-only** (grey cloud), or set `HOST_CNAME_TARGET` to a DNS-only name: custom domains in other accounts cannot CNAME to a proxied hostname (Cloudflare error 1014). Set `HOST_ORIGIN_IPV4`/`HOST_ORIGIN_IPV6` if tenants should be told the addresses for apex domains.
--2. **Wildcard certificate.** A DNS-01 challenge is required for `*.openvibe.host`, for example `certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/.secrets/certbot-cloudflare.ini -d openvibe.host -d '*.openvibe.host'`. The credentials file is root-only (0600), outside every repository, and never passed to Host. The certificate lands in `/etc/letsencrypt/live/openvibe.host/`; another location is passed with `--wildcard-cert <name|dir>`.
--3. **Network.** Register the OAuth client `host` and the grant above.
--4. **Code and state.** `sudo -u ubuntu git clone https://github.com/OpenVibers/OpenVibe.Host /opt/openvibe.host && cd /opt/openvibe.host && sudo -u ubuntu npm ci --omit=dev --no-audit --no-fund`. This checkout is the service; the root-owned `/usr/local/lib/openvibe-host` stays the `ovhost` CLI. The unit's state directory is `/var/lib/openvibe-host-api` (database and objects), deliberately **not** `/var/lib/openvibe-host`, which is `ovhost`'s own root-only state.
--5. **Environment.** `/etc/openvibe/host.env` (0600), from [`.env.example`](.env.example): at least `BASE_URL=https://openvibe.host`, `HOST_SITES_DOMAIN=openvibe.host`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID=host`, `OV_OAUTH_CLIENT_SECRET`, `HOST_FORM_SECRET`, `EVENTS_URL`, `INDEXNOW_KEY` (optional).
--6. **Unit.** `sudo cp deploy/systemd/openvibe-host.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-host`, then `curl -s http://127.0.0.1:4910/api/ready`.
--7. **nginx.** Add the `host` entry from [`host.example.json`](host.example.json) to `/etc/openvibe/host.json`, update `/usr/local/lib/openvibe-host`, then `sudo ovhost nginx tenants host` (review) and `sudo ovhost nginx tenants host --install` (writes `openvibe.host.conf` and `openvibe.host-custom-domains.conf`, `nginx -t`, reload; restored on failure). [`deploy/nginx/openvibe.host.conf`](deploy/nginx/openvibe.host.conf) is a reference copy.
--8. **Certificates.** `sudo ovhost certs renew --install` renews the existing lineages with `certbot renew` (the wildcard) and issues `certbot certonly --webroot -w /var/www/certbot -d <hostname>` for every verified custom domain that still has no certificate (the generated port-80 block already answers the ACME challenge), then re-renders the tenant vhosts through the same transactional install. A failure leaves that domain HTTP-only and fails the command; the others still proceed. `ovhost certs renew` without `--install` only reviews. `deploy/systemd/openvibe-certs.timer` runs it twice a day (the [unit](deploy/systemd/openvibe-certs.service) runs as root; certificates never pass through the Host API). The manual equivalent is `sudo ovhost nginx tenants host` to list the domains still waiting, then `certbot certonly` and `ovhost nginx tenants host --install` for each.
--9. **Launch.** Follow [docs/launch.md](docs/launch.md). Until then [`deploy/nginx/openvibe.host-tenants-pending.conf`](deploy/nginx/openvibe.host-tenants-pending.conf) answers every `*.openvibe.host` name with a 404 over the wildcard certificate (installed 2026-09-23; without it the wildcard fell through to nginx's default server). `ovhost nginx tenants host --install` removes it in the same `nginx -t` and reload that installs the tenant vhost (`nginx.tenants.replaces`), and puts it back if the test fails.
--
--### Not done yet (Stage B)
--
--- Uploads are held in memory while they are validated (bounded by `HOST_MAX_UPLOAD_BYTES` and `HOST_MAX_UNPACKED_BYTES`), so the service needs that much headroom per concurrent upload.
--- Objects are on the service's local disk by default; there is no replication. `HOST_OBJECT_STORE=media` (opt-in) additionally writes every object through to OpenVibe.Media and keeps local disk as the read cache. `ovhost backup host` archives `/var/lib/openvibe-host-api/objects` (the inventory's `objects` entry) alongside `ov_host`, uploads the encrypted archive off-host, and a restore drill extracts it and checks every blob's sha256. The database (`ov_host` on the host's data role, ADR-035; schema in [migrations/](migrations/)) is backed up with the others by pgBackRest.
--- Certificates for custom domains are issued and installed by `ovhost certs renew --install` (step 8) on the `openvibe-certs.timer`; certbot stays outside the Host service and no key file is read.
--- Projects are created in Host. When Network has projects (ADR-014), Host should accept only Network project ids and read membership from Network.
--- `openvibe.host` is not on the Public Suffix List, so a tenant page can still set `Domain=openvibe.host` cookies (a cookie bomb breaks the dashboard and other tenant sites for that visitor). The protections above do not depend on it. [docs/threat-review.md §5](docs/threat-review.md#5-the-public-suffix-list-question) records the decision (launch without it) and the recommended follow-up (dashboard off the tenant zone, then list the zone).
--- Tenant objects are in `ovhost backup`: the inventory declares them as `objects` (`/var/lib/openvibe-host-api/objects`), the backup archives the tree, the off-host copy encrypts it, and a drill extracts it and checks every blob's sha256. With `HOST_OBJECT_STORE=media`, this archives the read cache and Media holds the durable objects.
--- Git deploys are Phase 1 only (see [Git deploys](#git-deploys-phase-1)): the project's own CI builds and posts the output. Host never clones, fetches, holds a repository credential or builds; in-Host builds belong to Run (plan T14). There is no webhook: a push reaches Host only through the CI's call.
--- Tenant sites publish their own sitemap/robots/feed; when a deploy ships no `sitemap.xml` or `robots.txt`, Host generates one (the manifest's HTML pages on the host the request came in on, so a verified custom domain gets its own `sitemap.xml`; sandbox projects get `Disallow: /` and are `noindex`). A tenant's own file at either path always wins. The dashboard host serves `/robots.txt` and `/sitemap.xml` (front page and legal pages).
--- The Codes portal (Wave 20) does not exist, so there is no public developer onboarding for Host yet.
--
--### Acceptance (what `npm test` demonstrates for Stage B)
--
--- **Tenant isolation** (`test/host-isolation.test.js`): 25 path tricks (`..`, `%2e%2e`, `%2f`, `%5c`, `%00`, double encoding, absolute object-store paths, sha256 paths, `/host.db`) and 14 `Host` header tricks never return another tenant's bytes; absolute-form targets that disagree with `Host` are refused; tenant hosts never reach the API, auth, metrics or readiness; identical bytes are stored per project; every API route answers 404 to non-members; the API ignores cookies.
--- **Traversal and links refused** (`test/host-uploads.test.js`): `..`, absolute, backslash, non-ASCII, pax and GNU long-name overrides, symlinks, hard links, devices, FIFOs, hidden files, server-side code, credentials, unknown types, duplicates, conflicts, corrupt archives and a 40 MB gzip bomb. Each becomes a failed deploy with a log and `host.deploy.failed`, nothing is stored, and the active deploy keeps serving.
--- **Quotas** (`test/host-quota-auth.test.js`): deploys per day (rolling), deduplicated storage, files, bytes per file, request size, sites, custom domains, sandbox limits; service-token audience/capability, `X-OV-Subject` delegation, app principals, sandbox tokens, mods, staff.
--- **Atomic rollback** (`test/host-rollback.test.js`): 320 requests while the pointer flips 40 times all return one whole deploy; a failure inside the switch changes nothing; `expected_active` conflicts; database-level immutability; the pointer survives a restart.
--- **Custom domains** (`test/host-domains.test.js`): not served until verified; wrong or missing TXT stays pending; first verified proof wins; OpenVibe domains refused; removal and site deletion stop serving; background verification, pending expiry and lapse.
--- **No secrets in responses** (`test/host-secrets-dashboard.test.js`): no API, dashboard, readiness or event body contains the OAuth client secret, the form secret, an uploaded `.env`'s values or any credential-like string; response objects are allowlisted; plus the dashboard (chrome, forms, CSRF against same-site tenant origins, planted cookies) and machine endpoints.
--- **Lifecycle, headers, events** (`test/host-lifecycle.test.js`, `test/host-contracts-events.test.js`): content types, ETag/304, ranges, immutable caching, CSP, nosniff, custom 404, 405; valid event envelopes delivered by the relay with a Network service token; the proposals match the guarded routes.
--- **Tenant vhosts** (`test/nginx-tenants.test.js`): the wildcard vhost with the certificate path parameter, HTTPS only for verified domains that have a certificate, hostile database values refused before they reach nginx, transactional install that removes the pending vhost in the same change, `www.` redirect, client address headers from `$remote_addr` only, no key file read.
--- **Abuse controls** (`test/host-abuse.test.js`): staff takedowns (451 with Clear-Site-Data on every host, content kept, members cannot publish or delete around them, lift), certificate-validation paths refused, uploads validated two at a time (503 + Retry-After), the disk floor (507), the per-person project cap, fingerprinted assets capped at an hour at a CDN.
--- **IndexNow** (`test/indexnow.test.js`): off without `INDEXNOW_KEY` (no key route, nothing sent); with a key the key file is served at `/<key>.txt` as `text/plain` on the dashboard and on tenant hosts; activating a deploy pings the site page and its sitemap, a rollback or a site deletion pings again, a ready-but-not-active deploy and a sandbox site never ping.
--- **Tenant sitemap/robots** (`test/host-sitemap.test.js`): a deploy that ships none gets a generated `sitemap.xml` (the manifest's HTML pages, `index.html` → the directory URL, the error page left out, on the host the request came in on — so a verified custom domain gets its own) and `robots.txt` (crawlers welcomed and the sitemap named; a sandbox site gets `Disallow: /` and stays `noindex`); a file the tenant uploaded at either path wins; correct content types and caching; HEAD works; no secret in the body.
--- **Preview deploys** (`test/host-preview.test.js`): `POST /sites/:id/deploys?preview=1` stores a deploy with `source=preview` and points the site at it without activating (the public site keeps serving its active deploy); `/preview/<deploy-id>/…` on the dashboard is served to a project member only — non-members and signed-out callers get a plain 404, a public tenant host never serves it, every response is `noindex, no-store`, and the response's CSP sandboxes the tenant content into an opaque origin (no `allow-same-origin`) so it can never act as `openvibe.host`; a preview is never pinged to IndexNow and never listed in the site's sitemap; it vanishes when it expires, when the site deploys or rolls back, or when it is deleted; and it serves only its own project's objects (a same-named file of another project is not reachable).
--- **Git deploys** (`test/host-git-source.test.js`): a maintainer connects a source, a deployer gets 403 and a non-member 404, a bad URL, provider or ref is 422 and a credential field is refused; an app deployer's ingest is a `source=git` deploy with its `host_deploy_git` row and the site's preview while the public host keeps serving the active deploy; activate flips the pointer and clears the preview; an invalid file (a failed deploy and `host.deploy.failed`), a ref mismatch, an unconnected site, a bad SHA and `activate=1` all leave `active_deploy_id` unchanged; `host_deploy_git` rows are immutable; source responses hold only the allowlisted fields.
--- **Resource index** (`test/resource-index.test.js`): no token 401, a person 403, a service token without `host.resource.read` 403 and with it 200; every summary and every page validates against `common.resource-summary@1` and `common.resource-list-result@1`; all three kinds (sites, deploys, domains) with their states, names, owners and OVRNs; `?project=` never returns another project's rows; `?kind=` narrows and an unknown kind is an empty page; the cursor pages the whole index with no duplicate, gap or reordering; `GET /:ovrn` reads the one resource whose computed OVRN it is (a wrong project, another service's OVRN, an unknown id and a non-OVRN are 404 `resources.unknown_resource`); a bad query is 400 `resources.bad_query`.
--- **Home-page size budget** (`test/perf-budget.test.js`): the server on a fresh database, the home page measured with `openvibe-shared/perf-budget` — html 15.9 KB (4.3 br), js 4 files 212.2 KB (49.9 br), css 1 file 3.1 KB (0.9 br), 0 external — within the committed budgets.
--- The isolation test also covers cross-origin reads from a tenant page (no CORS grant anywhere), ETag/Range existence oracles, delegated service tokens, app principals and the dashboard.
--
--Not demonstrated yet: any of this on the production host.
++7. **Updating the CLI itself:** a deploy of this repository replaces `/opt/openvibe.host` (the catalog service), not the root-owned install at `/usr/local/lib/openvibe-host`, so after a merge to `main` run `sudo ovhost self-update`. It finds its own install directory from its real path (`/usr/local/bin/ovhost` → `/usr/local/lib/openvibe-host/bin/ovhost`), fetches `origin main`, refuses a dirty tree, a branch other than `main` or a divergence, merges `--ff-only` and runs `npm ci --omit=dev --no-audit --no-fund` there; `--dry-run` prints the plan first. It restarts nothing — the next `ovhost` invocation runs the new code. Without it the installed CLI silently runs behind `main` (on 2026-10-02 it was 16 commits behind: no `env-names --set`, no `logs`, no Bot vhost).
## Security
--Reporting a vulnerability: [SECURITY.md](SECURITY.md). Stage B's threat review, with the decisions it
--records: [docs/threat-review.md](docs/threat-review.md). The rules the code keeps:
++Reporting a vulnerability: [SECURITY.md](SECURITY.md). Stage B's threat review (now Space's engine) stays in
++[docs/threat-review.md](docs/threat-review.md) for the record. The rules the code keeps:
- **Secrets.** `ovhost` reads service env files for variable **names** and emptiness only; it never
stores, prints or snapshots a value. The one exception is its own root-only `/etc/openvibe/backup.env`.
@@@ -542,11 +295,9 @@ records: [docs/threat-review.md](docs/threat-review.md). The rules the code keep
- **Operator plane.** `ovhost` runs as root and drops to the checkout owner for git, npm, builds and
PostgreSQL; it never stops or restarts a socket unit except the release-layout rebind; a protected-session
probe that cannot answer counts as sessions active; tracked local changes block a deploy.
--- **Tenants (Stage B).** Static files only: no tenant code runs, no tenant secret is stored, uploads are
-- validated and content-addressed, tenant hosts get no CORS grant, staff takedowns answer 451. Custom
-- domains are proven by DNS TXT through the configured resolver, and certificates never pass through
-- the API. Dashboard and API auth is Network SSO and service tokens with the `host.*` capabilities.
--- **Egress.** Stage B calls only its configured Network and Events hosts; `ovhost` calls the host's own
++- **Catalog.** Public pages and reads are signed-out and cacheable; staff writes need `host.catalog.manage` and a
++ Network staff user; affiliate links are marked and never change the order. Engine URLs answer 410.
++- **Egress.** The service calls only its configured Network, Events, Sources and Community hosts; `ovhost` calls the host's own
services, Network, Events, Prometheus and the backup bucket.
## Development
@@@ -554,10 +305,10 @@ records: [docs/threat-review.md](docs/threat-review.md). The rules the code keep
```
npm test # every test, each file in its own process (Node 22)
node test/deploy.test.js # one file
--npm run dev # Stage B service on http://localhost:4910 (HOST_SITES_DOMAIN=localhost serves sites at http://<site>.localhost:4910)
++npm run dev # the catalog service on http://localhost:4910
```
--Stage B tests (`test/host-*.test.js`) boot the service on a temp database and object store with a controllable clock, a mock Network (real RS256 keys) and a DNS table, and talk to it over raw HTTP so they can send any `Host` header and request target.
++The service tests (`test/catalog-*.test.js`, `test/engine-moved.test.js`) boot it on a temp PGlite database with `test/helpers/boot.js` (a mock Network with real RS256 keys) and talk to it over raw HTTP.
Stage A: everything that touches the system goes through the executor (`lib/executor.js`). The tests replace it with `test/fake-host.js`, an in-memory host with git repos, systemd units, npm, nginx, ss, HTTP endpoints and PostgreSQL. No test runs systemctl, git, npm, nginx or curl against the real machine. `test/executor.test.js` covers the real executor only where that is safe: HTTP to a local server and temp files.
@@@ -578,10 +329,8 @@ The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches
registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
implemented service.
--**Stage B against this rule (2026-09-23):** every item is met in code and tests. The evidence per
--item, the exact launch steps (tenant vhost with the pending vhost removed in the same change, Sites,
--Network registry), verification and rollback are in [docs/launch.md](docs/launch.md); the threat
--review is [docs/threat-review.md](docs/threat-review.md). The launch has not been run.
++**Stage B** never launched publicly here: it moved to OpenVibe.Space on 2026-10-12, where the same rule and
++the owner's gates (go-ahead, a DMCA agent, a Public Suffix List entry for openvibe.website) apply.
---
@@@ -593,3 +342,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
- openvibe-shared: v4.0.3
- openvibe-publishing: v1.4.0
<!-- versions:end -->
++---
++
++Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
ddiff --git a/STATUS.json b/STATUS.json
iindex a1e8b3e..3e752f5 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,12 +8,11 @@
"/llms.txt",
"/llms-full.txt"
],
-- "wave": "W21 Stage A + Stage B",
++ "wave": "T20 hosting compared",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "runtime": "node 22; Stage A: CLI (ovhost); Stage B: express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9 service",
++ "runtime": "node 22; Express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + optional Valkey",
"port": 4910,
"domain": "openvibe.host",
-- "tenantDomain": "*.openvibe.host",
"contracts": "openvibe-contracts v0.145.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.0.3",
@@@ -23,35 +22,20 @@
"openvibe-shared v4.0.3"
],
"capabilities": [
-- "host.site.manage",
-- "host.deploy.create",
-- "host.domain.manage",
-- "host.site.config",
-- "host.resource.read",
"host.catalog.manage"
],
"capabilitiesRegistered": true,
-- "serviceManifestRegistered": "host manifest v0.2.0 released (openvibe-contracts v0.24.0+), with the four host.* events",
"events": [
-- "host.deploy.created",
-- "host.deploy.activated",
-- "host.deploy.failed",
-- "host.domain.verified",
++ "host.index_document.upserted",
++ "host.index_document.deleted",
"host.release.published"
],
"stages": {
-- "A": "alpha: ovhost CLI installed on the production host 2026-09-23 (/usr/local/bin/ovhost, inventory /etc/openvibe/host.json); every service deploys with `sudo ovhost deploy <svc>` since the 2026-09-27 cutover (roadmap WS-N task 11: Live, Tools, Sites, Games and OpenRestream through the release-layout, multi-app, static-build and pnpm-build strategies); nightly backups, restore drills (19 passed 2026-09-23, docs/restore-drills.md), release notifications, incidents and maintenance, the alerts relay; the D41 deploy-and-rollback proof (scripts/d41-proof.sh) is written and tested",
-- "B": "launched 2026-10-07 at openvibe.host (dashboard, API, front page) and *.openvibe.host (tenant sites); docs/launch.md, threat review in docs/threat-review.md",
-- "C": "not started (deliberately: blocked until isolation and metering are proven)"
++ "A": "alpha: ovhost CLI installed on the production host 2026-09-23 (/usr/local/bin/ovhost, inventory /etc/openvibe/host.json); every service deploys with `sudo ovhost deploy <svc>` since the 2026-09-27 cutover (roadmap WS-N task 11: Live, Tools, Sites, Games and OpenRestream through the release-layout, multi-app, static-build and pnpm-build strategies); nightly backups, restore drills (19 passed 2026-09-23, docs/restore-drills.md), release notifications, incidents and maintenance, the alerts relay; the D41 deploy-and-rollback proof (scripts/d41-proof.sh) is written and tested"
},
-- "artifactStorage": "local disk, content-addressed per project; HOST_OBJECT_STORE=media writes objects through to OpenVibe.Media with local disk as the read cache (opt-in, off by default)",
-- "customDomainCertificates": "operator-issued (certbot), not automated",
"installedOnHost": true,
-- "deployed": "production: openvibe.host and *.openvibe.host since 2026-10-07",
-- "placeholderInSites": false,
++ "deployed": "openvibe.host catalog",
"adoptedByServices": [],
-- "capabilitiesNote": "released in openvibe-contracts v0.24.0; v0.32.0 adds the four takedown routes to host.site.manage.implementedBy; v0.83.0 adds host.site.config; v0.107.0 adds the git-source routes and the planned host.resource.read (Host ships the resource index at GET /api/v1/resources and GET /api/v1/resources/:ovrn; OpenVibe.Contracts flips the capability to active, with the implementedBy routes, only after the deploy)",
-- "launchReady": true,
"features": [
"ovhost: inventory, validate, plan, deploy with automatic rollback, rollback, status, releases",
"deploy strategies: git-checkout, multi-app, static-build, pnpm-build, release-layout",
@@@ -62,11 +46,25 @@
"release notifications (host.release.published), incidents, maintenance windows and freezes",
"Prometheus alerts relay to Network",
"browser check and release-lifecycle acceptance suite",
-- "Stage B: tenant static sites with immutable deploys, activation, rollback, custom domains, quotas and takedowns",
-- "the authority resource index (ADR-048 section 3, plan T13 step 8): GET /api/v1/resources pages Host's sites (host.site, sit_), the immutable deploys of those sites (host.deploy, dpl_) and their default and custom domains (host.domain, dom_) as common.resource-summary@1, and GET /api/v1/resources/:ovrn reads one by its OVRN, for OpenVibe.Services' fan-out; guarded by the first-party host.resource.read, scoped by ?project= and paginated with an opaque cursor",
-- "Stage B git deploys, Phase 1: a site's Git source (repo + branch); the tenant's own CI posts each build, which lands as a source=git preview with immutable commit provenance and goes live only through activate (Host never clones, fetches, holds a credential or builds)",
-- "per-actor limits on Stage B writes (openvibe-sdk/limits)",
-- "catalog provider and plan discussion threads on OpenVibe.Community (optional HOST_COMMUNITY_URL; server-rendered comments, after-commit visibility sync)"
++ "catalog provider and plan discussion threads on OpenVibe.Community (optional HOST_COMMUNITY_URL; server-rendered comments, after-commit visibility sync)",
++ "hosting comparison catalog with providers, plans, promotions, price history and discussion"
],
++ "retired": {
++ "movedTo": "https://openvibe.space",
++ "date": "2026-10-12",
++ "capabilities": [
++ "host.site.manage",
++ "host.site.config",
++ "host.deploy.create",
++ "host.domain.manage",
++ "host.resource.read"
++ ],
++ "events": [
++ "host.deploy.created",
++ "host.deploy.activated",
++ "host.deploy.failed",
++ "host.domain.verified"
++ ]
++ },
"updated": "2026-10-12"
}
ddiff --git a/deploy/nginx/openvibe.host.conf b/deploy/nginx/openvibe.host.conf
iindex 5045140..19b6ec8 100644
---- a/deploy/nginx/openvibe.host.conf
++++ b/deploy/nginx/openvibe.host.conf
@@@ -1,31 +1,19 @@
--## REFERENCE copy of `ovhost nginx tenants host` output with no custom domains (regenerate it with
--## that command; install with --install, which also writes the custom-domain vhost and removes
--## openvibe.host-tenants-pending.conf in the same nginx -t and reload).
--## ═══════════════════════════════════════════════════════════════
--## openvibe.host and *.openvibe.host — generated by `ovhost nginx tenants host`
--##
--## OpenVibe.Host Stage B (tenant static hosting), port 4910.
--## openvibe.host the dashboard and the Host API (/api/v1), uploads up to 110m
--## *.openvibe.host tenant sites: GET/HEAD only, cookies stripped both ways
--## Wildcard certificate: /etc/letsencrypt/live/openvibe.host (issued by the operator with a DNS-01 challenge; see README).
--## `--install` writes /etc/nginx/sites-available/openvibe.host.conf, links it, and reloads nginx only if `nginx -t` passes.
--## ═══════════════════════════════════════════════════════════════
++## OpenVibe.Host hosting comparison catalog on openvibe.host.
limit_req_zone $binary_remote_addr zone=ovhost_api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=ovhost_auth:10m rate=10r/m;
--limit_req_zone $binary_remote_addr zone=ovhost_sites:20m rate=50r/s;
# ── HTTP → HTTPS ───────────────────────────────────────────
server {
listen 80;
listen [::]:80;
-- server_name openvibe.host *.openvibe.host;
++ server_name openvibe.host;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
--# ── Dashboard + API ────────────────────────────────────────
++# ── Catalog + API ────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
@@@ -57,16 +45,6 @@ server {
proxy_pass http://127.0.0.1:4910;
}
-- # Deploy uploads (API and dashboard form): large bodies, streamed to the service.
-- location ~ ^/(api/v1/)?sites/[A-Za-z0-9_]+/deploys$ {
-- limit_req zone=ovhost_api burst=10 nodelay;
-- client_max_body_size 110m;
-- proxy_request_buffering off;
-- proxy_read_timeout 600s;
-- proxy_send_timeout 600s;
-- proxy_pass http://127.0.0.1:4910;
-- }
--
location /api/ {
limit_req zone=ovhost_api burst=30 nodelay;
proxy_pass http://127.0.0.1:4910;
@@@ -95,40 +73,3 @@ server {
add_header Strict-Transport-Security "max-age=31536000" always;
return 301 https://openvibe.host$request_uri;
}
--
--# ── Tenant sites ───────────────────────────────────────────
--server {
-- listen 443 ssl;
-- listen [::]:443 ssl;
-- http2 on;
-- server_name *.openvibe.host;
--
-- ssl_certificate /etc/letsencrypt/live/openvibe.host/fullchain.pem;
-- ssl_certificate_key /etc/letsencrypt/live/openvibe.host/privkey.pem;
-- ssl_protocols TLSv1.2 TLSv1.3;
--
-- access_log /var/log/nginx/sites.openvibe.host.access.log;
-- error_log /var/log/nginx/sites.openvibe.host.error.log;
--
-- # Static files only: no request bodies, no cookies in either direction.
-- client_max_body_size 1k;
-- add_header Strict-Transport-Security "max-age=31536000" always;
--
-- location / {
-- limit_except GET HEAD { deny all; }
-- limit_req zone=ovhost_sites burst=200 nodelay;
-- proxy_pass http://127.0.0.1:4910;
-- 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 CF-Connecting-IP $remote_addr;
-- proxy_set_header X-Forwarded-Proto $scheme;
-- proxy_hide_header Set-Cookie;
-- proxy_read_timeout 60s;
-- }
--
--}
ddiff --git a/host.example.json b/host.example.json
iindex c208ba8..2a95b8b 100644
---- a/host.example.json
++++ b/host.example.json
@@@ -893,7 +893,7 @@
}
},
"host": {
-- "_note": "OpenVibe.Host Stage B (tenant static hosting): the Host API service on port 4910. Not deployed as of 22 Sep 2026. Its content-addressed objects live in /var/lib/openvibe-host-api/objects (`HOST_STORAGE_DIR`), which is NOT ovhost's own stateDir (/var/lib/openvibe-host); its database is the PostgreSQL database ov_host. Tenant vhosts: `ovhost nginx tenants host`.",
++ "_note": "OpenVibe.Host hosting comparison catalog on port 4910; PostgreSQL database ov_host.",
"repo": "/opt/openvibe.host",
"units": [
"openvibe-host.service"
@@@ -908,8 +908,7 @@
"OV_NETWORK_URL",
"OV_OAUTH_CLIENT_ID",
"OV_OAUTH_CLIENT_SECRET",
-- "HOST_FORM_SECRET",
-- "HOST_SITES_DOMAIN"
++ "HOST_FORM_SECRET"
]
},
"port": 4910,
@@@ -929,27 +928,6 @@
"database": "ov_host"
}
],
-- "objects": [
-- {
-- "name": "objects",
-- "path": "/var/lib/openvibe-host-api/objects"
-- }
-- ],
-- "nginx": {
-- "tenants": {
-- "sitesDomain": "openvibe.host",
-- "dashboardDomain": "openvibe.host",
-- "wildcardCert": "openvibe.host",
-- "database": {
-- "engine": "postgresql",
-- "database": "ov_host"
-- },
-- "maxUpload": "110m",
-- "replaces": [
-- "openvibe.host-tenants-pending.conf"
-- ]
-- }
-- },
"drill": {
"port": 14910,
"databases": {
@@@ -964,38 +942,23 @@
"HOST_WORKER": "off",
"EVENTS_URL": "",
"OV_OAUTH_CLIENT_SECRET": "",
-- "INDEXNOW_KEY": "",
-- "HOST_STORAGE_DIR": "{tmp}/objects",
"VALKEY_URL": "redis://127.0.0.1:9/0"
},
-- "requires": [
-- {
-- "file": "server/config.js",
-- "contains": "INDEXNOW_KEY"
-- }
-- ],
-- "dirs": [
-- "{tmp}/objects"
-- ],
"counts": [
{
"db": "host",
-- "table": "host_projects"
-- },
-- {
-- "db": "host",
-- "table": "host_sites"
++ "table": "catalog_providers"
},
{
"db": "host",
-- "table": "host_deploys"
++ "table": "catalog_plans"
},
{
"db": "host",
-- "table": "host_domains"
++ "table": "catalog_promos"
}
],
-- "_notes": "Restores the ov_host database and the archived `objects` directory into {tmp}/objects (HOST_STORAGE_DIR), checking every blob's sha256; tenant content is still not compared (every /api/v1 read needs auth). HOST_WORKER=off stops DNS rechecks and the object sweep."
++ "_notes": "Restores the ov_host catalog database and checks provider, plan and promotion counts."
}
},
"billing": {
ddiff --git a/migrations/0008_drop_hosting_engine.sql b/migrations/0008_drop_hosting_engine.sql
nnew file mode 100644
iindex 0000000..9053fcd
---- /dev/null
++++ b/migrations/0008_drop_hosting_engine.sql
@@@ -0,0 +1,22 @@
++-- phase: contract
++-- after: 0007
++-- Static hosting moved to OpenVibe.Space. No production Host projects, sites or deploys remain.
++DROP TABLE IF EXISTS host_deploy_git;
++DROP TABLE IF EXISTS host_site_sources;
++DROP TABLE IF EXISTS host_site_config;
++DROP TABLE IF EXISTS host_deploy_files;
++DROP TABLE IF EXISTS host_deploy_logs;
++DROP TABLE IF EXISTS host_activations;
++DROP TABLE IF EXISTS host_domains;
++DROP TABLE IF EXISTS host_takedowns;
++DROP TABLE IF EXISTS host_quotas;
++DROP TABLE IF EXISTS host_project_members;
++DROP TABLE IF EXISTS host_deploys;
++DROP TABLE IF EXISTS host_sites;
++DROP TABLE IF EXISTS host_blobs;
++DROP TABLE IF EXISTS host_projects;
++DROP FUNCTION IF EXISTS host_deploy_git_immutable();
++DROP FUNCTION IF EXISTS host_deploy_files_delete_only_deleted();
++DROP FUNCTION IF EXISTS host_deploy_files_immutable();
++DROP FUNCTION IF EXISTS host_deploys_no_undelete();
++DROP FUNCTION IF EXISTS host_deploys_immutable();
ddiff --git a/package-lock.json b/package-lock.json
iindex ddaec70..ee272e6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -10,7 +10,6 @@
"license": "AGPL-3.0-only",
"dependencies": {
"@aws-sdk/client-s3": "^3.1138.0",
-- "busboy": "^1.6.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
@@@ -523,17 +522,6 @@
"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",
@@@ -1756,14 +1744,6 @@
"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 dd684b7..fd881cd 100644
---- a/package.json
++++ b/package.json
@@@ -2,7 +2,7 @@
"name": "openvibe-host",
"version": "0.3.0",
"private": true,
-- "description": "OpenVibe.Host: the operator plane (Stage A, the ovhost CLI) and tenant static hosting (Stage B, the Host API service on port 4910): immutable content-addressed deploys, per-site domains, quotas and rollback.",
++ "description": "OpenVibe.Host: hosting plans compared, with sourced prices and the ovhost operator CLI.",
"main": "lib/index.js",
"bin": {
"ovhost": "bin/ovhost"
@@@ -19,14 +19,13 @@
"systemd",
"nginx",
"operator",
-- "static-hosting"
++ "hosting-comparison"
],
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Host",
"dependencies": {
"@aws-sdk/client-s3": "^3.1138.0",
-- "busboy": "^1.6.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
ddiff --git a/server/app.js b/server/app.js
iindex 36130ad..b944aad 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -1,18 +1,6 @@
'use strict';
--/**
-- * OpenVibe.Host Stage B: Express app factory. server/index.js listens and starts the workers;
-- * tests build their own instance with a temp database and storage, an injectable clock, a DNS
-- * resolver table and a mock Network.
-- *
-- * Every request is dispatched on its Host header FIRST:
-- *
-- * <site>.<sitesDomain> / verified custom domain → tenant static files (http/tenant.js) and
-- * nothing else: no API, no auth, no cookies
-- * the dashboard host (BASE_URL) and loopback → dashboard pages, /api/v1, /auth, /api/ready,
-- * /release.json, /limits.json, /metrics (loopback only)
-- * anything else → 404 unknown host
-- */
++/** OpenVibe.Host catalog HTTP app. */
const path = require('path');
const express = require('express');
const cookieParser = require('cookie-parser');
@@@ -21,28 +9,15 @@ const contracts = require('openvibe-contracts');
const sharedMetrics = require('openvibe-shared/metrics');
const cache = require('openvibe-shared/cache-policy');
const sharedSeo = require('openvibe-shared/seo');
--const { createIndexNow } = require('openvibe-shared/indexnow');
--
++const frame = require('openvibe-shared/frame');
++const showcase = require('openvibe-shared/showcase');
++const ovServe = require('openvibe-shared/serve');
const configLib = require('./config');
const { openStore } = require('./db');
--const { createBlobStore } = require('./storage');
const { createHostOutbox } = require('./events/outbox');
--const { createAccess } = require('./domain/access');
--const { createTakedowns } = require('./domain/takedowns');
--const { createProjects } = require('./domain/projects');
--const { createSites } = require('./domain/sites');
--const { createDeploys } = require('./domain/deploys');
--const { createDomains } = require('./domain/domains');
--const { createSiteConfig } = require('./domain/site-config');
--const { createSiteSources } = require('./domain/site-sources');
const { createSsoClient } = require('openvibe-sdk/sso');
--const { createViewerResolver, guard } = require('./auth/viewer');
++const { createViewerResolver } = require('./auth/viewer');
const { createHostSession } = require('./auth/sso');
--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 { createHostReadiness } = require('./observability');
const { createWorker } = require('./worker');
const { createCatalog } = require('./catalog/store');
@@@ -51,167 +26,71 @@ const { createCatalogIngest } = require('./catalog/ingest');
const { createCatalogSearch } = require('./catalog/search-index');
const { createCatalogApi, createCatalogPages } = require('./http/catalog');
const { renderPage, assetVersion, setRelease } = require('./render/layout');
--const pages = require('./render/pages');
++const catalogRender = require('./render/catalog');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
--
--const DASHBOARD_CSP = {
++const SPACE_URL = 'https://openvibe.space';
++const MOVED = 'Static hosting moved to OpenVibe.Space.';
++const DASHBOARD_CSP_HEADER = Object.entries({
'default-src': ["'self'"],
-- // The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.
'script-src': ["'self'", "'unsafe-inline'", 'https://openvibe.network'],
'style-src': ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
'font-src': ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
'img-src': ["'self'", 'data:', 'https:'],
-- // openvibe.events: release notifications (release-watch's EventSource, openvibe-shared 1.17).
'connect-src': ["'self'", 'https://openvibe.network', 'https://openvibe.events'],
'frame-src': ["'self'", 'https://openvibe.network'],
-- 'frame-ancestors': ["'none'"],
-- 'object-src': ["'none'"],
-- 'base-uri': ["'self'"],
++ 'frame-ancestors': ["'none'"], 'object-src': ["'none'"], 'base-uri': ["'self'"],
'form-action': ["'self'", 'https://openvibe.network'],
--};
--const DASHBOARD_CSP_HEADER = Object.entries(DASHBOARD_CSP).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ');
++}).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ');
--/**
-- * opts: config, store, now (clock), fetchImpl, auth (an openvibe-sdk/sso client),
-- * resolver ({ resolveTxt }), log
-- */
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const log = opts.log || console;
-- // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
const store = opts.store || await openStore(config, { now: opts.now, log });
-- // The object store: local disk, written through to OpenVibe.Media when HOST_OBJECT_STORE=media.
-- // The Media client is built here (not at module load) and fetches a token lazily; unset config
-- // leaves media null and the store is exactly the local one.
-- const mediaStore = 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`,
-- ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
-- } : null;
-- // The Media delete re-checks this just before it runs: a deploy may have re-used the sha since
-- // the caller decided the blob was unreferenced, and Media holds the durable copy.
-- const blobReferenced = store.db.prepare('SELECT 1 FROM host_blobs WHERE project_id = ? AND sha256 = ?');
-- const blobs = createBlobStore(opts.storageDir || config.storageDir, {
-- media: mediaStore,
-- isReferenced: async (projectId, sha256) => Boolean(await blobReferenced.get(projectId, sha256)),
-- });
require('./http/errors').setLogger(log);
--
-- // IndexNow (openvibe-shared/indexnow): created once at boot from INDEXNOW_KEY. Unset → off, nothing
-- // mounted, nothing sent; tests and drills never set it. The key file is served on every host (it is
-- // mounted below, before the tenant dispatch), and one module per public host keeps a tenant site's
-- // URL on its own host, which the protocol (and the module) requires. Tests inject a single object
-- // through opts.indexnow (a spy) and every ping goes through it.
-- const indexnow = opts.indexnow !== undefined ? opts.indexnow : createIndexNow({
-- host: config.baseUrl, key: config.indexnow.key, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}), log,
-- });
-- const indexnowHosts = new Map();
-- function indexnowFor(hostname) {
-- if (!indexnowHosts.has(hostname)) indexnowHosts.set(hostname, createIndexNow({
-- host: hostname, key: config.indexnow.key, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}), log,
-- }));
-- return indexnowHosts.get(hostname);
-- }
-- /**
-- * Queue a page that appeared, changed or left the index: one absolute URL per path on `hostname`,
-- * sent as a debounced batch. With an injected module (tests) it is used for every host. Never throws.
-- */
-- function announce(hostname, paths) {
-- if (!indexnow.enabled || !hostname) return;
-- const u = new URL(config.baseUrl);
-- const origin = `${u.protocol}//${hostname}${u.port ? `:${u.port}` : ''}`;
-- const target = opts.indexnow !== undefined ? indexnow : indexnowFor(hostname);
-- target.pingSoon(paths.map((p) => new URL(p, origin).href));
-- }
--
const outbox = createHostOutbox({ db: store.db, config, fetchImpl: opts.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: opts.resolver, log, indexnow: announce });
-- const siteConfig = createSiteConfig({ store });
-- const siteSources = createSiteSources({ store, sites });
const auth = opts.auth || createSsoClient({
-- site: 'host',
-- baseUrl: config.baseUrl,
-- clientId: config.oauth.clientId,
-- clientSecret: config.oauth.clientSecret,
-- redirectUri: config.oauth.redirectUri,
-- scope: config.oauth.scope,
-- networkUrl: config.networkUrl,
-- networkInternalUrl: config.networkInternalUrl,
-- issuer: config.networkUrl,
-- secureCookies: config.cookies.secure,
-- log,
++ site: 'host', baseUrl: config.baseUrl, clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret, redirectUri: config.oauth.redirectUri,
++ scope: config.oauth.scope, networkUrl: config.networkUrl,
++ networkInternalUrl: config.networkInternalUrl, issuer: config.networkUrl,
++ secureCookies: config.cookies.secure, log,
...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}),
});
const viewers = createViewerResolver({ auth, config, log });
-- const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
-- const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const community = createCommunity({ store, config, fetchImpl: opts.fetchImpl, log });
const catalog = createCatalog({ store, community, log });
const catalogIngest = createCatalogIngest({ config, store, catalog, fetchImpl: opts.fetchImpl });
const catalogSearch = createCatalogSearch({ config, store, outbox, log });
-- catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); }); // the worker's own switch: off in tests and drills
-- const worker = createWorker({ config, store, domains, blobs, outbox, catalogIngest, catalogSearch, log });
--
-- const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, catalog, community, catalogIngest, catalogSearch, auth, viewers, tenant, worker, uploadGate, indexnow, log };
--
++ catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
++ const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });
++ const ctx = { config, store, outbox, catalog, community, catalogIngest, catalogSearch, auth, viewers, worker, log };
const app = express();
app.disable('x-powered-by');
app.disable('etag');
app.set('trust proxy', config.trustProxy);
--
const release = require('openvibe-shared/release').createRelease({ service: 'host', root: path.join(__dirname, '..') });
setRelease(release.release);
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);
-- // Per-actor limits on writes (server/http/actor-limits.js; roadmap WS-R task 4), one budget for the API and the dashboard.
-- // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
++ ctx.stopMetrics = proc.stop;
const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
ctx.valkey = valkey;
ctx.actorLimits = require('./http/actor-limits').createHostActorLimits({ registry, valkey });
sharedMetrics.releaseInfo(registry, { service: 'host', release: release.release });
-- registry.gauge({
-- name: 'host_sites', help: 'Active tenant sites',
-- collect: async () => [{ labels: {}, value: (await store.db.prepare("SELECT COUNT(*) AS n FROM host_sites WHERE status = 'active'").get()).n }],
-- });
-- ctx.stopMetrics = proc.stop;
app.locals.metrics = registry;
app.locals.ctx = ctx;
--
-- app.use(httpMetrics.middleware);
--
-- // GET /<key>.txt — the IndexNow key file (openvibe-shared/indexnow). Served before the host dispatch
-- // so it answers on the dashboard host and on every tenant host (an engine fetches it from the host a
-- // ping names). Mounted only with a key; it answers that one path and falls through for everything else.
-- if (indexnow.enabled) app.use(indexnow.keyFile);
--
-- // ── Host dispatch: tenant sites never reach anything below ──
-- // resolve/handle read the database (ADR-035: async), so a rejection here must reach the error
-- // handler below: Express 4 does not catch a rejected async handler, which would hang the request.
-- app.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) {
-- req.hostTarget = req.hostTarget || 'unknown';
-- return next(err);
-- }
++ app.use(sharedMetrics.httpMetrics(registry).middleware);
++ const catalogHost = new URL(config.baseUrl).hostname.toLowerCase();
++ app.use((req, res, next) => {
++ let hostname;
++ try { hostname = new URL(`http://${req.headers.host}`).hostname.toLowerCase(); }
++ catch { return res.status(400).send('Invalid Host header'); }
++ if (hostname === catalogHost || hostname === 'localhost' || hostname === '127.0.0.1') return next();
++ if (hostname.endsWith(`.${catalogHost}`)) return res.status(410).type('html').send(`<h1>Static hosting moved</h1><p>${MOVED} <a href="${SPACE_URL}">Visit OpenVibe.Space</a>.</p>`);
++ return res.status(404).send('Unknown host');
});
--
app.use(contracts.http.middleware());
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', DASHBOARD_CSP_HEADER);
@@@ -222,142 +101,72 @@ async function createApp(opts = {}) {
next();
});
app.use(cookieParser());
--
-- // ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-host', version: VERSION }));
-- // GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
-- release.mount(app, { registry: registry });
-- // GET /limits.json: the developer limits enforced here, from config (WS-N task 7; Codes renders them).
-- require('./limits').mountLimits(app, config);
-- const readiness = createHostReadiness({ store, blobs, outbox, jwksUrl: `${config.networkInternalUrl}/api/.well-known/jwks`, release: release.release, minFreeBytes: () => config.uploads.minFreeBytes, valkey: ctx.valkey });
-- app.get('/api/ready', readiness.handler);
++ release.mount(app, { registry });
++ app.get('/api/ready', createHostReadiness({ store, outbox, jwksUrl: `${config.networkInternalUrl}/api/.well-known/jwks`, release: release.release, valkey }).handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
-- // The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites
-- // never reach this app's routes; http/tenant.js serves a deploy's own robots.txt/sitemap.xml, or a
-- // generated one when the deploy ships none.
const legalPaths = require('openvibe-shared/legal').PATHS;
-- app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
-- .send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Allow: /hosting$', 'Allow: /catalog$', 'Allow: /catalog/*', 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
++ app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Allow: /catalog$', 'Allow: /catalog/*', 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
app.get('/sitemap.xml', async (_req, res, next) => {
try {
-- const providers = await catalog.providers();
-- const plans = await catalog.sitemapPlans();
-- // A catalog page's lastmod is the newest price check it shows: a plan's own, a provider's latest plan, and the
-- // newest of all for the catalog and the front page. Static pages carry none.
-- const day = (ms) => (Number.isFinite(Number(ms)) && ms != null ? new Date(Number(ms)).toISOString().slice(0, 10) : undefined);
-- const latest = (rows) => rows.reduce((m, p) => Math.max(m, Number(p.checked_at) || 0), 0) || undefined;
-- const newest = day(latest(plans));
-- const urls = [
-- { path: '/', lastmod: newest }, { path: '/hosting' }, ...legalPaths.map((path) => ({ path })),
-- { path: '/catalog', lastmod: newest }, { path: '/catalog/promos' }, { path: '/catalog/providers', lastmod: newest },
-- ...providers.map((v) => ({ path: `/catalog/providers/${v.slug}`, lastmod: day(latest(plans.filter((p) => p.provider_slug === v.slug))) })),
-- ...plans.map((p) => ({ path: `/catalog/plans/${p.id}`, lastmod: day(p.checked_at) })),
-- ];
-- res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
-- .send(sharedSeo.sitemapXml(urls.map((u) => ({ loc: `${config.baseUrl}${u.path}`, lastmod: u.lastmod }))));
++ const [providers, plans] = await Promise.all([catalog.providers(), catalog.sitemapPlans()]);
++ const day = (ms) => Number.isFinite(Number(ms)) && ms != null ? new Date(Number(ms)).toISOString().slice(0, 10) : undefined;
++ const latest = (rows) => day(rows.reduce((m, p) => Math.max(m, Number(p.checked_at) || 0), 0) || undefined);
++ const urls = [{ path: '/', lastmod: latest(plans) }, ...legalPaths.map((p) => ({ path: p })), { path: '/catalog', lastmod: latest(plans) }, { path: '/catalog/promos' }, { path: '/catalog/providers', lastmod: latest(plans) },
++ ...providers.map((p) => ({ path: `/catalog/providers/${p.slug}`, lastmod: latest(plans.filter((v) => v.provider_slug === p.slug)) })),
++ ...plans.map((p) => ({ path: `/catalog/plans/${p.id}`, lastmod: day(p.checked_at) }))];
++ res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.sitemapXml(urls.map((u) => ({ loc: `${config.baseUrl}${u.path}`, lastmod: u.lastmod }))));
} catch (err) { next(err); }
});
-- // /llms.txt and /llms-full.txt (llmstxt.org): a map of Host's own public pages only. A tenant site, a preview
-- // URL, the dashboard, /api and /internal are never listed (a tenant's own /llms.txt is its deploy's file).
-- const SITE_SUMMARY = 'OpenVibe.Host: hosting plans compared (cloud, VPS and dedicated), every price dated and sourced and never ranked by who pays; and OpenVibe\'s own static hosting (alpha, not open to the public yet).';
-- const legalTitles = require('openvibe-shared/legal').TITLES;
++ const summary = 'OpenVibe.Host compares cloud, VPS and dedicated hosting plans with dated and sourced prices.';
const publicPages = [
-- { title: 'OpenVibe.Host', url: '/', text: 'The front page of OpenVibe.Host: hosting plans compared. Cloud, VPS and dedicated plans from providers\' official APIs (through OpenVibe.Sources) and staff entries that name their source, with the price per month, per vCPU and per GB, the day each price was checked and its source link. The order comes only from the sort a visitor picks; affiliate links are marked and disclosed.' },
-- { title: 'Static hosting', url: '/hosting', text: 'OpenVibe.Host\'s own static site hosting: upload a folder or a .tar.gz, every deploy kept as an immutable artifact, served on a <site>.openvibe.host address or a verified custom domain, rolled back in one step. Not open to the public yet: only OpenVibe staff create projects until the launch review.' },
-- { title: 'Compare hosting plans', url: '/catalog', text: 'The comparison table: filter by kind, vCPU, RAM, disk, price, provider, location and currency; sort by monthly price or by price per vCPU, per GB of RAM or per GB of disk.' },
++ { title: 'OpenVibe.Host', url: '/', text: summary },
++ { title: 'Compare hosting plans', url: '/catalog', text: 'Filter and compare hosting plans by price and resources.' },
{ title: 'Hosting providers', url: '/catalog/providers', text: 'Every provider in the catalog.' },
-- { title: 'Hosting promotions', url: '/catalog/promos', text: 'Hosting promotions running now, with codes and end dates as the providers state them.' },
-- { title: 'What shipped on OpenVibe.Host', url: '/updates', text: 'The public update log for OpenVibe.Host: what shipped, newest first.' },
-- ...legalPaths.map((p) => ({ title: legalTitles[p.slice(1)] || p.slice(1), url: p, text: `The ${legalTitles[p.slice(1)] || p.slice(1)} of OpenVibe.Host.` })),
-- ];
-- const catalogPublicPages = async () => [
-- ...((await catalog.providers()).map((p) => ({ title: p.name, url: `/catalog/providers/${p.slug}`, text: `Hosting plans from ${p.name}, with prices and sources.` }))),
-- ...((await catalog.sitemapPlans()).map((p) => ({ title: `Hosting plan ${p.id}`, url: `/catalog/plans/${p.id}`, text: 'Sourced hosting plan with a checked price and price history.' }))),
++ { title: 'Hosting promotions', url: '/catalog/promos', text: 'Current provider promotions.' },
++ { title: 'What shipped on OpenVibe.Host', url: '/updates', text: 'The public update log.' },
++ ...legalPaths.map((p) => ({ title: p.slice(1), url: p, text: `The ${p.slice(1)} of OpenVibe.Host.` })),
];
-- app.get('/llms.txt', async (_req, res, next) => { try { res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({
-- name: 'OpenVibe.Host',
-- summary: SITE_SUMMARY,
-- details: 'The sites Host serves belong to their owners and are not listed here; the dashboard, sign-in and the API are per-person.',
-- sections: [
-- { title: 'Public pages', links: [...publicPages, ...await catalogPublicPages()].map((p) => ({ title: p.title, url: `${config.baseUrl}${p.url}` })) },
-- { title: 'Machine-readable', links: [
-- { title: 'Sitemap', url: `${config.baseUrl}/sitemap.xml`, note: 'the public pages' },
-- { title: 'robots.txt', url: `${config.baseUrl}/robots.txt` },
-- { title: 'llms-full.txt', url: `${config.baseUrl}/llms-full.txt`, note: 'this same map with a description of each public page' },
-- ] },
-- ],
-- })); } catch (err) { next(err); } });
-- app.get('/llms-full.txt', async (_req, res, next) => { try { res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsFull({
-- site: { name: 'OpenVibe.Host', url: config.baseUrl },
-- summary: SITE_SUMMARY,
-- base: config.baseUrl,
-- maxBytes: 512 * 1024,
-- sections: [{ title: 'Public pages', pages: [...publicPages, ...await catalogPublicPages()] }],
-- })); } catch (err) { next(err); } });
--
-- // ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
++ const allPages = async () => [...publicPages,
++ ...(await catalog.providers()).map((p) => ({ title: p.name, url: `/catalog/providers/${p.slug}`, text: `Hosting plans from ${p.name}.` })),
++ ...(await catalog.sitemapPlans()).map((p) => ({ title: `Hosting plan ${p.id}`, url: `/catalog/plans/${p.id}`, text: 'Sourced plan and price history.' }))];
++ app.get('/llms.txt', async (_req, res, next) => { try { res.type('text/plain').send(sharedSeo.llmsTxt({ name: 'OpenVibe.Host', summary, sections: [{ title: 'Public pages', links: (await allPages()).map((p) => ({ title: p.title, url: `${config.baseUrl}${p.url}` })) }] })); } catch (err) { next(err); } });
++ app.get('/llms-full.txt', async (_req, res, next) => { try { res.type('text/plain').send(sharedSeo.llmsFull({ site: { name: 'OpenVibe.Host', url: config.baseUrl }, summary, base: config.baseUrl, maxBytes: 512 * 1024, sections: [{ title: 'Public pages', pages: await allPages() }] })); } catch (err) { next(err); } });
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth', createHostSession({ auth, config }).router(express));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'host', service: 'host', host: 'openvibe.host', name: 'OpenVibe.Host', profile: 'ugc' })); }
--
-- // ── Static assets (content-hashed ?v= → immutable) ──────
-- // This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
app.use('/shared', require('openvibe-shared/serve').handler());
-- app.use(express.static(PUBLIC_DIR, {
-- index: false, redirect: false, etag: true,
-- setHeaders(res, filePath) {
-- const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
-- const v = res.req && res.req.query && res.req.query.v;
-- res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
-- },
-- }));
--
-- // ── Resource index (ADR-048 section 3, plan T13 step 8) ──
-- // The resources Host owns (sites, deploys, domains) as common.resource-summary@1, for OpenVibe.Services'
-- // fan-out. Its own router, mounted here and not in http/api.js: it is the same path and shape every
-- // authority answers (Network's index), and it is first-party — the capability is host.resource.read.
-- app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }),
-- viewers.middleware('api'), resourceIndex.router({ guard: guard('host.resource.read') }));
--
-- // Signed Sources events; disabled unless ingest and webhook secrets are configured.
++ app.use(express.static(PUBLIC_DIR, { index: false, redirect: false, etag: true, setHeaders(res, filePath) {
++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!res.req.query.v && res.req.query.v === assetVersion(rel) }));
++ } }));
app.post('/internal/catalog/events', express.raw({ type: '*/*', limit: '256kb' }), async (req, res) => {
try { const result = await catalogIngest.consume(req.body, req.headers); res.status(result.status).json(result); }
catch (err) { require('./http/errors').sendError(res, req, err, log); }
});
--
-- // ── API ─────────────────────────────────────────────────
app.use('/api/v1/catalog', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createCatalogApi(ctx));
-- app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
--
-- // ── Preview deploys (plan T12 J4) ───────────────────────
-- // A member views a site's live preview deploy at /preview/<deploy-id>/… — dashboard host only (the
-- // dispatch above sends every tenant host to tenant.handle, so this never answers on a public host),
-- // session-authenticated, membership-checked in tenant.preview, noindex and never cached.
-- app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => {
-- res.set('Cache-Control', cache.htmlHeaders({ private: true }));
-- Promise.resolve(tenant.preview(req, res)).catch(next);
-- });
--
++ const movedApi = (req, res) => res.status(410).type('application/problem+json').json({ code: 'host.moved_to_space', title: 'Static hosting moved', detail: MOVED, url: SPACE_URL });
++ const movedPage = (req, res) => res.status(410).type('html').send(renderPage({ title: 'Static hosting moved', body: `<h1>Static hosting moved</h1><p>${MOVED} <a href="${SPACE_URL}">Visit OpenVibe.Space</a>.</p>`, config, path: req.originalUrl }));
++ // Lookaheads: app.use strips what the path matched, and a consumed '/' would make Express skip the route.
++ app.use(/^\/api\/v1\/(?:projects|sites|deploys|domains|resources)(?=\/|$)/, movedApi);
++ app.use(/^\/(?:hosting|projects|sites|deploys|domains|preview)(?=\/|$)/, movedPage);
app.use('/catalog', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createCatalogPages(ctx));
--
-- // ── Dashboard ───────────────────────────────────────────
-- app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
-- app.use((req, res) => {
-- res.set('Cache-Control', cache.htmlHeaders({ private: true }));
-- if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });
-- res.status(404).type('html').send(renderPage({ title: 'Not found', body: pages.errorPage({ status: 404, message: 'There is no page at this address.' }), viewer: req.viewer, config, path: req.originalUrl }));
++ app.get('/providers', (_req, res) => res.redirect(301, '/catalog/providers'));
++ app.get('/promotions', (_req, res) => res.redirect(301, '/catalog/promos'));
++ app.get('/updates', (_req, res) => res.type('html').send(renderPage({ title: 'What shipped on OpenVibe.Host', body: frame.updatesBody({ service: 'host', siteName: 'OpenVibe.Host' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, config, path: '/updates', indexable: true })));
++ app.get('/', viewers.middleware('dashboard'), async (req, res, next) => {
++ try {
++ const [result, providers, promos, stats] = await Promise.all([catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats()]);
++ res.type('html').send(renderPage({ body: catalogRender.front({ plans: result.plans, providers, promos, stats }), viewer: req.viewer, config, path: '/', indexable: true, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: catalogRender.jsonLd.front(config.baseUrl, result.plans) }));
++ } catch (err) { next(err); }
});
--
-- // eslint-disable-next-line no-unused-vars
++ app.use((req, res) => req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }) : res.status(404).type('html').send(renderPage({ title: 'Not found', body: '<h1>Not found</h1>', config, path: req.originalUrl })));
app.use((err, req, res, _next) => {
log.error('[Host]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
-- res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
-- res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
++ res.status(500).type('text/plain').send('Something went wrong on our side.');
});
--
return { app, ctx };
}
--
module.exports = { createApp, DASHBOARD_CSP_HEADER };
ddiff --git a/server/artifacts/archive.js b/server/artifacts/archive.js
ddeleted file mode 100644
iindex 802604c..0000000
---- a/server/artifacts/archive.js
++++ /dev/null
@@@ -1,164 +0,0 @@
--'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
ddeleted file mode 100644
iindex 4383772..0000000
---- a/server/artifacts/paths.js
++++ /dev/null
@@@ -1,106 +0,0 @@
--'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.host: the operator issues every certificate.
-- if (segments[0] === '.well-known' && CA_VALIDATION.has(String(segments[1]).toLowerCase())) {
-- throw new PathError('path.reserved', `"${segments.slice(0, 2).join('/')}/" is reserved: certificate validation files are never published from a site`);
-- }
-- return input;
--}
--
--// Server-side code and credentials: refused with an explicit reason.
--const REFUSED = new Map([
-- ...['php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'cgi', 'fcgi', 'pl', 'pm', 'py', 'pyc', 'rb', 'sh', 'bash', 'zsh', 'ksh', 'csh',
-- 'asp', 'aspx', 'ascx', 'ashx', 'asmx', 'jsp', 'jspx', 'cfm', 'cfc', 'shtml', 'shtm', 'stm', 'ssi', 'lua', 'ps1', 'psm1', 'bat', 'cmd',
-- 'exe', 'dll', 'so', 'dylib', 'bin', 'elf', 'jar', 'war', 'ear', 'class', 'htaccess', 'htpasswd']
-- .map((e) => [e, 'server-side code or an executable: Host serves static files only and never runs anything']),
-- ...['pem', 'key', 'p12', 'pfx', 'jks', 'keystore', 'env', 'npmrc', 'pgpass', 'netrc', 'kdbx', 'sqlite', 'sqlite3', 'db']
-- .map((e) => [e, 'looks like a credential or a database: never publish secrets in a static site']),
--]);
--
--const TYPES = {
-- html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
-- css: 'text/css; charset=utf-8',
-- js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', cjs: 'text/javascript; charset=utf-8',
-- json: 'application/json; charset=utf-8', map: 'application/json; charset=utf-8',
-- webmanifest: 'application/manifest+json; charset=utf-8',
-- txt: 'text/plain; charset=utf-8', md: 'text/markdown; charset=utf-8', csv: 'text/csv; charset=utf-8',
-- xml: 'application/xml; charset=utf-8', rss: 'application/rss+xml; charset=utf-8', atom: 'application/atom+xml; charset=utf-8',
-- vtt: 'text/vtt; charset=utf-8',
-- svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
-- avif: 'image/avif', ico: 'image/x-icon', bmp: 'image/bmp',
-- woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
-- mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', m4v: 'video/mp4',
-- mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', m4a: 'audio/mp4', flac: 'audio/flac',
-- pdf: 'application/pdf', wasm: 'application/wasm',
-- glb: 'model/gltf-binary', gltf: 'model/gltf+json',
-- zip: 'application/zip',
--};
--// Extension-less files (CNAME, LICENSE, robots-like files) are served as plain text.
--const NO_EXTENSION = 'text/plain; charset=utf-8';
--
--/** -> { contentType } or { refused: reason } */
--function contentTypeFor(p) {
-- const base = p.slice(p.lastIndexOf('/') + 1);
-- const dot = base.lastIndexOf('.');
-- if (dot <= 0) return { contentType: NO_EXTENSION };
-- const ext = base.slice(dot + 1).toLowerCase();
-- if (REFUSED.has(ext)) return { refused: REFUSED.get(ext), code: 'deploy.file_refused' };
-- if (TYPES[ext]) return { contentType: TYPES[ext] };
-- return { refused: `".${ext}" is not an allowed file type`, code: 'deploy.type_not_allowed' };
--}
--
--/**
-- * Fingerprinted asset names (bundler output) are cached as immutable: a hash-like segment of 8–64
-- * characters with at least one digit right before the extension (app.3f2a9c1b.js, index-BdK3x9aQ.js).
-- */
--function isHashedAsset(p) {
-- const m = /[.-]([A-Za-z0-9_]{8,64})\.[A-Za-z0-9]+$/.exec(p);
-- return Boolean(m && /[0-9]/.test(m[1]) && /[A-Za-z]/.test(m[1]) && !/\.html?$/i.test(p));
--}
--
--module.exports = { checkPath, contentTypeFor, isHashedAsset, PathError, TYPES, REFUSED, MAX_PATH };
ddiff --git a/server/artifacts/validate.js b/server/artifacts/validate.js
ddeleted file mode 100644
iindex a86954e..0000000
---- a/server/artifacts/validate.js
++++ /dev/null
@@@ -1,78 +0,0 @@
--'use strict';
--/**
-- * Turn uploaded entries into an immutable deploy manifest, or explain every reason it cannot be one.
-- *
-- * manifest = { schema: 'host.deploy-manifest@1', file_count, total_bytes,
-- * files: [{ path, sha256, size, content_type }] } (sorted by path)
-- * manifest_sha256 = sha256 of that JSON: identical content gives an identical digest.
-- *
-- * Every problem is collected (up to 50) so the upload log lists them all at once.
-- */
--const crypto = require('crypto');
--const { checkPath, contentTypeFor, PathError } = require('./paths');
--const { normaliseName } = require('./archive');
--
--const MAX_PROBLEMS = 50;
--
--function sha256(data) {
-- return crypto.createHash('sha256').update(data).digest('hex');
--}
--
--/**
-- * entries: [{ path, data }]. limits: { maxFiles, maxFileBytes, maxTotalBytes }.
-- * -> { ok: true, manifest, manifestSha256, files, warnings } | { ok: false, code, problems, warnings }
-- */
--function buildManifest(entries, limits) {
-- const problems = [];
-- const warnings = [];
-- const files = [];
-- const seen = new Set();
-- let total = 0;
-- const problem = (code, message) => { if (problems.length < MAX_PROBLEMS) problems.push({ code, message }); };
--
-- if (!entries.length) problem('deploy.empty', 'the upload contains no files');
-- if (entries.length > limits.maxFiles) problem('quota.max_files', `${entries.length} files; this project may deploy at most ${limits.maxFiles}`);
--
-- for (const e of entries) {
-- const raw = normaliseName(e.path);
-- let p;
-- try { p = checkPath(raw); } catch (err) {
-- if (err instanceof PathError) { problem(err.code === 'path.traversal' || err.code === 'path.absolute' ? 'deploy.path_traversal' : 'deploy.invalid_path', `${JSON.stringify(String(e.path).slice(0, 200))}: ${err.message}`); continue; }
-- throw err;
-- }
-- if (seen.has(p)) { problem('deploy.duplicate_path', `"${p}" appears twice`); continue; }
-- seen.add(p);
-- const t = contentTypeFor(p);
-- if (t.refused) { problem(t.code, `"${p}": ${t.refused}`); continue; }
-- if (e.data.length > limits.maxFileBytes) { problem('quota.max_file_bytes', `"${p}" is ${e.data.length} bytes; the limit per file is ${limits.maxFileBytes}`); continue; }
-- total += e.data.length;
-- files.push({ path: p, sha256: sha256(e.data), size: e.data.length, content_type: t.contentType, data: e.data });
-- }
-- if (total > limits.maxTotalBytes) problem('deploy.too_large', `the files add up to ${total} bytes; at most ${limits.maxTotalBytes} are allowed`);
--
-- // A path cannot be both a file and a directory ("a" and "a/b.html").
-- for (const p of seen) {
-- const parts = p.split('/');
-- for (let i = 1; i < parts.length; i++) {
-- const dir = parts.slice(0, i).join('/');
-- if (seen.has(dir)) problem('deploy.path_conflict', `"${dir}" is both a file and a directory`);
-- }
-- }
--
-- if (problems.length) {
-- const first = problems[0].code;
-- return { ok: false, code: first, problems, warnings };
-- }
-- files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
-- if (!seen.has('index.html')) warnings.push('there is no index.html at the root: the site\'s home page will be the 404 page');
-- const manifest = {
-- schema: 'host.deploy-manifest@1',
-- file_count: files.length,
-- total_bytes: total,
-- files: files.map(({ path, sha256: h, size, content_type }) => ({ path, sha256: h, size, content_type })),
-- };
-- const json = JSON.stringify(manifest);
-- return { ok: true, manifest, manifestJson: json, manifestSha256: sha256(json), files, warnings };
--}
--
--module.exports = { buildManifest, sha256 };
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
iindex 170a3cc..ea3298d 100644
---- a/server/auth/capabilities.js
++++ b/server/auth/capabilities.js
@@@ -1,31 +1,6 @@
'use strict';
--
--/**
-- * Capability checks for service tokens (audience openvibe.host), by openvibe-contracts' manifests:
-- *
-- * host.site.manage projects, members, sites, quotas (read), deleting deploys
-- * host.deploy.create upload deploys, list them and their logs, activate, roll back
-- * host.domain.manage add, verify and remove custom domains
-- * host.site.config read, set and reset a site's response headers, redirects and SPA fallback
-- * host.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.
-- */
++/** Capability checks for the catalog API. */
const { capabilities } = require('openvibe-contracts');
--
--const CAPABILITIES = Object.freeze({
-- SITE_MANAGE: 'host.site.manage',
-- DEPLOY_CREATE: 'host.deploy.create',
-- DOMAIN_MANAGE: 'host.domain.manage',
-- SITE_CONFIG: 'host.site.config',
-- RESOURCE_READ: 'host.resource.read',
-- CATALOG_MANAGE: 'host.catalog.manage',
--});
--
--/** → { allowed, code, reason } (capabilities.check()). */
--function checkCapability(claims, capabilityId) {
-- return capabilities.check(claims, capabilityId);
--}
--
++const CAPABILITIES = Object.freeze({ CATALOG_MANAGE: 'host.catalog.manage' });
++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
iindex 39d081d..df32406 100644
---- a/server/auth/forms.js
++++ b/server/auth/forms.js
@@@ -1,14 +1,6 @@
'use strict';
--/**
-- * Dashboard form protection. SameSite=Lax is NOT enough here: tenant sites on <site>.openvibe.host
-- * are the same site as openvibe.host, so their pages could submit forms to the dashboard with the
-- * session cookie attached. Every dashboard POST therefore needs both:
-- *
-- * 1. an Origin (or, without one, a Referer) equal to the dashboard's own origin, and
-- * 2. a form token: an HMAC of the signed-in subject under HOST_FORM_SECRET (a random per-process
-- * key when unset, so forms opened before a restart must be reloaded).
-- */
++/** Catalog form protection: same-origin request and subject-bound CSRF token. */
const crypto = require('crypto');
const fallback = crypto.randomBytes(32).toString('hex');
@@@ -24,7 +16,7 @@ function checkCsrf(config, viewer, token) {
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
}
--/** The request came from a dashboard page (not from a tenant page or another site). */
++/** The request came from the catalog origin. */
function sameOrigin(config, req) {
const expected = new URL(config.baseUrl).origin;
const origin = req.get('origin');
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex 132a3aa..046169e 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -1,30 +1,6 @@
'use strict';
--/**
-- * The dashboard's own session cookie, around the openvibe-sdk/sso /auth router.
-- *
-- * openvibe-sdk/sso keeps the session in `ov_token`: host-only, not HttpOnly (the shared navbar reads
-- * it). Tenant sites are served at <site>.openvibe.host, the same site as the dashboard, so a tenant
-- * page can plant `ov_token=<its owner's own valid token>; Domain=openvibe.host; Path=/<longer path>`
-- * and the browser sends it ahead of the real one (cookie tossing). Host therefore trusts only its own
-- * cookie, which no subdomain can set or overwrite:
-- *
-- * __Host-ov_host_session HttpOnly, Secure, Path=/, no Domain, SameSite=Lax; the verified access
-- * token, written whenever the SDK writes ov_token after a sign-in
-- * (callback, FedCM) or a refresh, and cleared whenever it clears ov_token
-- * (sign-out, a refresh the Network refuses).
-- * __Host-ov_host_flow the OAuth state of a sign-in started here; /auth/callback requires it,
-- * so a planted ov_oauth_state/ov_oauth_verifier cannot complete the
-- * attacker's own sign-in in the victim's browser.
-- *
-- * ov_refresh (Path=/auth) can be planted too: a refresh renews the session only for the subject the
-- * session already holds, and never creates one. Inside /auth the SDK sees the trusted session as
-- * ov_token (/auth/me and the silent sign-in check never read a planted one). ov_token stays
-- * display-only for the shared navbar.
-- *
-- * Development (COOKIE_SECURE=false, plain http): browsers refuse __Host- cookies without Secure, so
-- * the names drop the prefix (ov_host_session, ov_host_flow) and are not Secure.
-- */
++/** Host-only, HttpOnly session and OAuth flow cookies around openvibe-sdk/sso. */
const crypto = require('crypto');
const { COOKIES, decodeJwtPayload, parseCookies } = require('openvibe-sdk/sso');
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex 639efbe..332128f 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -1,24 +1,6 @@
'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_host_session cookie (./sso.js). ov_token (openvibe-sdk/sso)
-- * is display-only for the shared navbar: a tenant page on <site>.openvibe.host 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.host. A first-party service
-- * (svc:) may name the person it acts for with X-OV-Subject; an app (app:) always acts as
-- * itself. `env` is the token's environment claim ("sandbox" is refused on production
-- * projects).
-- *
-- * Identity never comes from a request body or query. A bad token is refused (problem+json),
-- * never downgraded to anonymous.
-- */
++/** Resolve catalog API Bearer tokens and page sessions into req.viewer. */
const contracts = require('openvibe-contracts');
const { claimsToUser, decodeJwtPayload } = require('openvibe-sdk/sso');
const { verifyServiceToken } = require('openvibe-sdk/auth');
@@@ -76,10 +58,8 @@ function createViewerResolver({ auth, config, log = console }) {
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.host) 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.
++ // FedCM ID assertions use the same issuer as access tokens, but are only swapped
++ // at /oauth/token and must never be accepted as sessions.
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;
ddiff --git a/server/config.js b/server/config.js
iindex a475c88..91a9036 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -1,38 +1,17 @@
'use strict';
--/**
-- * OpenVibe.Host Stage B (tenant static hosting) configuration. Every value comes from the
-- * environment (production: /etc/openvibe/host.env, see .env.example). Only environment variable
-- * NAMES appear in code and docs; secret values are never logged or returned.
-- *
-- * load(env) is pure so tests can build a config without touching process.env.
-- */
++/** OpenVibe.Host catalog service configuration. */
require('dotenv').config();
const trim = (s) => String(s || '').replace(/\/+$/, '');
const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
--const MiB = 1024 * 1024;
function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
const port = int(env.PORT, 4910);
const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.host' : `http://localhost:${port}`));
-- const dashboardHost = new URL(baseUrl).hostname.toLowerCase();
-- // Tenant sites are served at <site>.<sitesDomain>. It must be a registrable domain of its own,
-- // never a subdomain of openvibe.network / openvibe.live (their cookies must never reach tenants).
-- const sitesDomain = String(env.HOST_SITES_DOMAIN || 'openvibe.host').toLowerCase().replace(/^\.+|\.+$/g, '');
--
-- const quota = (prefix, defaults) => ({
-- storageBytes: int(env[`${prefix}STORAGE_BYTES`], defaults.storageBytes),
-- deploysPerDay: int(env[`${prefix}DEPLOYS_PER_DAY`], defaults.deploysPerDay),
-- maxFiles: int(env[`${prefix}MAX_FILES`], defaults.maxFiles),
-- maxFileBytes: int(env[`${prefix}MAX_FILE_BYTES`], defaults.maxFileBytes),
-- sites: int(env[`${prefix}SITES`], defaults.sites),
-- customDomains: int(env[`${prefix}CUSTOM_DOMAINS`], defaults.customDomains),
-- });
--
return {
service: 'host',
port,
@@@ -40,8 +19,6 @@ function load(env = process.env) {
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).
@@@ -49,23 +26,6 @@ function load(env = process.env) {
// 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.HOST_PGLITE_DIR || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:host:' },
-- // Content-addressed deploy objects, one directory per project (tenancy keyed by project id).
-- storageDir: env.HOST_STORAGE_DIR || './data/objects',
--
-- // Tenant objects: local disk is the store and the read cache. HOST_OBJECT_STORE=media writes
-- // every object through to OpenVibe.Media as well (Host's own namespace `host`, 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
-- // Host's usual Network service principal (OV_OAUTH_CLIENT_ID/SECRET), audience openvibe.media.
-- objectStore: {
-- mode: String(env.HOST_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 || 'host').trim() || 'host',
-- 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'),
@@@ -79,12 +39,6 @@ function load(env = process.env) {
// Signs the dashboard form tokens (CSRF). Unset: a random per-process key.
formSecret: env.HOST_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 host.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 || ''),
@@@ -97,57 +51,13 @@ function load(env = process.env) {
// OpenVibe.Watch's public site: a plan page links a prefilled watch on the plan's own API (no call is made).
watch: { publicUrl: trim(env.OV_WATCH_URL || 'https://openvibe.watch') },
-- uploads: {
-- // Largest request body accepted for one deploy (the compressed archive or all multipart parts).
-- maxUploadBytes: int(env.HOST_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.HOST_MAX_UNPACKED_BYTES, 256 * MiB),
-- // Uploads read and validated at the same time, service-wide (each is held in memory).
-- maxConcurrent: int(env.HOST_MAX_CONCURRENT_UPLOADS, 2),
-- // 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.HOST_MIN_FREE_BYTES, 5 * 1024 * MiB),
-- },
-- // Projects one person may own (each gets the per-project quotas below).
-- projects: {
-- maxPerOwner: int(env.HOST_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 HOST_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.
catalog: { ingest: String(env.HOST_CATALOG_INGEST || 'off').trim().toLowerCase() === 'on', sourcesUrl: trim(env.HOST_CATALOG_SOURCES_URL || ''), webhookSecrets: list(env.HOST_CATALOG_WEBHOOK_SECRETS) },
-- publicHosting: String(env.HOST_PUBLIC_HOSTING || 'off').trim().toLowerCase() === 'on',
--
-- // Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
-- quotas: {
-- production: quota('HOST_QUOTA_', { storageBytes: 1024 * MiB, deploysPerDay: 50, maxFiles: 10000, maxFileBytes: 25 * MiB, sites: 10, customDomains: 5 }),
-- sandbox: quota('HOST_SANDBOX_QUOTA_', { storageBytes: 100 * MiB, deploysPerDay: 20, maxFiles: 2000, maxFileBytes: 10 * MiB, sites: 3, customDomains: 0 }),
-- },
--
-- domains: {
-- // What a custom domain's CNAME points at. Default: the site's own <site>.<sitesDomain>.
-- cnameTarget: String(env.HOST_CNAME_TARGET || '').toLowerCase() || null,
-- originIpv4: env.HOST_ORIGIN_IPV4 || null,
-- originIpv6: env.HOST_ORIGIN_IPV6 || null,
-- dnsServers: list(env.HOST_DNS_SERVERS),
-- recheckIntervalMs: int(env.HOST_DOMAIN_RECHECK_MS, 10 * 60 * 1000),
-- pendingDays: int(env.HOST_DOMAIN_PENDING_DAYS, 7),
-- lapseDays: int(env.HOST_DOMAIN_LAPSE_DAYS, 7),
-- extraReserved: list(env.HOST_RESERVED_DOMAINS).map((d) => d.toLowerCase()),
-- },
--
-- sites: { extraReservedNames: list(env.HOST_RESERVED_SITE_NAMES).map((d) => d.toLowerCase()) },
worker: {
enabled: env.HOST_WORKER !== 'off',
id: env.HOST_WORKER_ID || `host-${process.pid}`,
},
-- // Browser origins that may call /api/v1 with a Bearer Network JWT (never cookies).
-- apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network,https://openvibe.codes'),
};
}
ddiff --git a/server/db.js b/server/db.js
iindex a77c797..d41d37c 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -1,28 +1,6 @@
'use strict';
--/**
-- * Host's own PostgreSQL database (ADR-035, roadmap WS-X2): the schema is migrations/NNNN_*.sql, applied at boot. Nothing here is shared with
-- * another service.
-- *
-- * host_projects tenant projects: owner subject (usr_…), environment (production|sandbox),
-- * network_project_id once OpenVibe.Network has projects (ADR-014)
-- * host_project_members principals with a role on a project: usr_… or app:app_…/svc:… principals
-- * host_quotas per-project overrides of the configured quota defaults (staff only)
-- * host_sites a site = a name (<name>.openvibe.host) + the pointer to its active deploy
-- * host_deploys IMMUTABLE artifacts: manifest (sha256 per file), totals, state
-- * host_deploy_files path → sha256/size/content type per deploy (immutable)
-- * host_blobs which content-addressed objects a project stores (storage accounting)
-- * host_activations every pointer switch (activate / rollback), for audit and rollback targets
-- * host_deploy_logs the upload/validation log of each deploy (there is no build in Stage B)
-- * host_domains default <site>.openvibe.host and custom domains (TXT-verified)
-- * host_takedowns staff takedowns of a site or a project (serving stops; content is kept)
-- * host_site_sources the Git repository + branch a site's own CI deploys from (public, no credential)
-- * host_deploy_git what a git deploy was built from: repository, ref, commit (immutable)
-- * event_outbox openvibe-sdk transactional outbox
-- *
-- * Immutability is enforced by the database itself: triggers refuse any UPDATE of a deploy's
-- * artifact columns or of its file rows, and file rows can only be removed once the deploy is deleted.
-- */
++/** OpenVibe.Host catalog database. Applied migrations are immutable. */
const fs = require('fs');
const path = require('path');
const { createDb } = require('openvibe-sdk/db');
@@@ -31,7 +9,7 @@ const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
--const CHARTER_TABLES = ['host_catalog_discussion_refs', 'host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns', 'host_site_sources', 'host_deploy_git', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor'];
++const CHARTER_TABLES = ['host_catalog_discussion_refs', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor'];
/**
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
ddiff --git a/server/domain/access.js b/server/domain/access.js
ddeleted file mode 100644
iindex a8dde88..0000000
---- a/server/domain/access.js
++++ /dev/null
@@@ -1,83 +0,0 @@
--'use strict';
--/**
-- * Who may do what on a project.
-- *
-- * principal a browser user: its usr_ subject. A first-party service token (svc:…): the person
-- * named by X-OV-Subject, else the service itself. An app token (app:app_…): the app
-- * itself — apps are principals (ADR-014) and never act as a person here.
-- * roles owner > maintainer > deployer, per project (host_project_members).
-- * deployer upload deploys, list them and their logs, activate, roll back
-- * maintainer + sites, custom domains, delete deploys
-- * owner + delete the project, manage members
-- * staff Network admins: read everything, delete sites/projects/domains, set quotas; they
-- * cannot publish into a tenant's site.
-- *
-- * A caller who is not a member gets 404 for the project and everything in it (existence is not
-- * revealed). A sandbox token (claim env = "sandbox") is refused on production projects.
-- */
--const { ApiError } = require('../http/errors');
--
--const RANK = { deployer: 1, maintainer: 2, owner: 3 };
--const NEED = { read: 1, deploy: 1, maintain: 2, own: 3 };
--
--function principalOf(viewer) {
-- if (!viewer) return null;
-- if (viewer.kind === 'user') return viewer.subject || null;
-- if (viewer.kind === 'service') {
-- if (viewer.subject && /^svc:/.test(viewer.service)) return viewer.subject;
-- return viewer.service;
-- }
-- return null;
--}
--
--/** The person accountable for an action: a usr_ subject when there is one. */
--function actorOf(viewer) {
-- return principalOf(viewer) || 'anonymous';
--}
--
--function actorRef(viewer) {
-- const p = principalOf(viewer);
-- if (p && p.startsWith('usr_')) return { type: 'user', id: p };
-- if (p && p.startsWith('app:')) return { type: 'app', id: p.slice(4) };
-- if (p && p.startsWith('svc:')) return { type: 'service', id: p.slice(4) };
-- return { type: 'service', id: 'host' };
--}
--
--function createAccess({ store }) {
-- const { db } = store;
-- const memberStmt = db.prepare('SELECT role FROM host_project_members WHERE project_id = ? AND principal = ?');
--
-- 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
ddeleted file mode 100644
iindex d8f528e..0000000
---- a/server/domain/deploys.js
++++ /dev/null
@@@ -1,372 +0,0 @@
--'use strict';
--/**
-- * Deploys: immutable artifacts, the upload/validation log, activation and rollback.
-- *
-- * upload entries → validate (paths, types, sizes, quotas) → objects written to the project's
-- * content-addressed store → ONE transaction: deploy row + file rows + blob rows + log +
-- * host.deploy.created (+ activation when asked). A refused upload is recorded as a
-- * failed deploy with its log and host.deploy.failed, so the owner can see why.
-- * activate the site's active_deploy_id pointer changes in one transaction together with the
-- * activation record and host.deploy.activated. A request reads the pointer once and then
-- * only immutable rows, so it sees the old deploy or the new one, never a mix.
-- * rollback the same switch, to a named deploy or to the one that was active before.
-- * delete only a deploy that is not active; its objects are removed when no other deploy of
-- * the project uses them.
-- * preview a deploy uploaded as a preview (source 'preview'): it becomes the site's ONE live
-- * preview (host_sites.preview_deploy_id + preview_expires_at) instead of going active,
-- * and is served only by the dashboard, to a member, at /preview/<deploy-id>/… (see
-- * server/http/tenant.js). Any pointer switch clears it (so a deploy or rollback makes it
-- * vanish), it expires on its own, and it is never announced to search engines.
-- * 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 host_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 host_deploys d LEFT JOIN host_deploy_git g ON g.deploy_id = d.id WHERE d.id = ?`),
-- forSite: db.prepare(`SELECT d.*, ${GIT_COLS} FROM host_deploys d LEFT JOIN host_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 host_deploys (id, project_id, site_id, state, source, manifest, manifest_sha256, file_count, total_bytes, new_bytes, failure_code, created_by, created_at)
-- VALUES (@id, @project_id, @site_id, @state, @source, @manifest, @manifest_sha256, @file_count, @total_bytes, @new_bytes, @failure_code, @created_by, @created_at)`),
-- insertFile: db.prepare('INSERT INTO host_deploy_files (deploy_id, path, sha256, size, content_type) VALUES (?, ?, ?, ?, ?)'),
-- insertBlob: db.prepare('INSERT INTO host_blobs (project_id, sha256, size, created_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING'),
-- hasBlob: db.prepare('SELECT 1 FROM host_blobs WHERE project_id = ? AND sha256 = ?'),
-- storageUsed: db.prepare('SELECT COALESCE(SUM(size), 0)::bigint AS n FROM host_blobs WHERE project_id = ?'),
-- deploysSince: db.prepare('SELECT COUNT(*) AS n FROM host_deploys WHERE project_id = ? AND created_at > ?'),
-- logLine: db.prepare('INSERT INTO host_deploy_logs (deploy_id, level, message, created_at) VALUES (?, ?, ?, ?)'),
-- logFor: db.prepare('SELECT level, message, created_at FROM host_deploy_logs WHERE deploy_id = ? ORDER BY id'),
-- files: db.prepare('SELECT path, sha256, size, content_type FROM host_deploy_files WHERE deploy_id = ? ORDER BY path'),
-- setPointer: db.prepare('UPDATE host_sites SET active_deploy_id = ?, updated_at = ? WHERE id = ? AND status = \'active\' AND active_deploy_id IS NOT DISTINCT FROM ?'),
-- setPreview: db.prepare('UPDATE host_sites SET preview_deploy_id = ?, preview_expires_at = ? WHERE id = ?'),
-- clearPreview: db.prepare('UPDATE host_sites SET preview_deploy_id = NULL, preview_expires_at = NULL WHERE id = ?'),
-- clearPreviewIf: db.prepare('UPDATE host_sites SET preview_deploy_id = NULL, preview_expires_at = NULL WHERE id = ? AND preview_deploy_id = ?'),
-- activation: db.prepare('INSERT INTO host_activations (site_id, deploy_id, previous_deploy_id, kind, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)'),
-- activations: db.prepare('SELECT deploy_id, previous_deploy_id, kind, actor, created_at FROM host_activations WHERE site_id = ? ORDER BY id DESC LIMIT ?'),
-- markDeleted: db.prepare("UPDATE host_deploys SET state = 'deleted', deleted_at = ? WHERE id = ?"),
-- dropFiles: db.prepare('DELETE FROM host_deploy_files WHERE deploy_id = ?'),
-- orphans: db.prepare(`SELECT sha256 FROM host_blobs b WHERE b.project_id = ? AND NOT EXISTS (
-- SELECT 1 FROM host_deploy_files f JOIN host_deploys d ON d.id = f.deploy_id
-- WHERE d.project_id = b.project_id AND f.sha256 = b.sha256)`),
-- dropBlob: db.prepare('DELETE FROM host_blobs WHERE project_id = ? AND sha256 = ?'),
-- sourceOf: db.prepare('SELECT provider, repo_url, ref FROM host_site_sources WHERE site_id = ?'),
-- insertGit: db.prepare('INSERT INTO host_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('[Host] could not read free disk space:', err.message); }
-- if (free < minFree) {
-- log.warn(`[Host] uploads refused: ${free} bytes free, HOST_MIN_FREE_BYTES is ${minFree}`);
-- throw new ApiError(507, 'storage.host_full', 'Host 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 host.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: 'host.deploy.failed', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
-- subject: { type: 'deploy', id },
-- payload: { project_id: project.id, site_id: site.id, site: site.name, code, problems: problems.slice(0, 10).map((p) => p.code) },
-- }, { traceparent });
-- });
-- outbox.kick();
-- return { deploy: 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('[Host] 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 Host by the project\'s CI; Host stored the files as they are and executed nothing' : 'no build step: Stage B serves the uploaded files as they are; nothing was executed' },
-- ];
-- let activated = null;
-- const previewExpiresAt = isPreview ? now + PREVIEW_TTL_MS : null;
-- await store.tx(async () => {
-- await q.insert.run({ id, project_id: project.id, site_id: site.id, state: 'ready', source: 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: 'host.deploy.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
-- subject: { type: 'deploy', id },
-- payload: { project_id: project.id, site_id: site.id, site: site.name, source: 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 host.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 host_sites WHERE id = ?').get(site.id);
-- if (!current || current.status !== 'active') throw new ApiError(404, 'site.not_found', 'no such site');
-- if (deploy.state !== 'ready' || deploy.site_id !== site.id) throw new ApiError(409, 'deploy.not_ready', 'only a ready deploy of this site can be activated');
-- if (expectedActive !== undefined && (expectedActive || null) !== current.active_deploy_id) {
-- throw new ApiError(409, 'site.active_changed', 'the active deploy changed since you looked', { active_deploy_id: current.active_deploy_id });
-- }
-- if (current.active_deploy_id === deploy.id) return { changed: false, deploy_id: deploy.id, previous_deploy_id: deploy.id };
-- const now = store.now();
-- const r = await q.setPointer.run(deploy.id, now, site.id, current.active_deploy_id);
-- if (r.changes !== 1) throw new ApiError(409, 'site.active_changed', 'the active deploy changed during the switch');
-- await q.activation.run(site.id, deploy.id, current.active_deploy_id, kind, principalOf(viewer) || 'host', now);
-- // A real deploy or rollback supersedes any pending preview: it stops being served at once.
-- await q.clearPreview.run(site.id);
-- await outbox.emit({
-- event_type: 'host.deploy.activated', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
-- subject: { type: 'deploy', id: deploy.id },
-- 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 host_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('[Host] 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 host_sites WHERE id = ?').get(site.id)).active_deploy_id;
-- if (active === deploy.id) throw new ApiError(409, 'deploy.active', 'this deploy is being served: activate another one first');
-- 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
ddeleted file mode 100644
iindex 1cc10dc..0000000
---- a/server/domain/domains.js
++++ /dev/null
@@@ -1,225 +0,0 @@
--'use strict';
--/**
-- * Domains: every site answers at its default <name>.<sitesDomain>; a custom domain answers only
-- * once its owner has proven control with a DNS TXT record:
-- *
-- * _openvibe-host.<hostname> TXT "openvibe-host-verification=<token>"
-- *
-- * The resolver is injectable (tests use a table; production uses node:dns with a timeout). A
-- * verified hostname belongs to exactly one site (unique index), first-party domains and the sites
-- * domain itself can never be claimed, and sandbox projects cannot add custom domains.
-- *
-- * Verified custom domains are re-checked daily: when the TXT record has been gone for
-- * HOST_DOMAIN_LAPSE_DAYS the domain lapses and stops being served (whoever controls the name now
-- * may claim it). Pending domains that never verify fail after HOST_DOMAIN_PENDING_DAYS.
-- *
-- * TLS for custom domains is an operator step (ovhost nginx tenants + certbot); no certificate or
-- * key ever passes through this API.
-- */
--const crypto = require('crypto');
--const dns = require('dns');
--const contracts = require('openvibe-contracts');
--const { newId, isId } = require('../ids');
--const { ApiError } = require('../http/errors');
--const { principalOf, actorRef } = require('./access');
--
--const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
--const HOSTNAME_RE = new RegExp(`^(?:${LABEL}\\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$`);
--const CHALLENGE_PREFIX = '_openvibe-host';
--const TXT_PREFIX = 'openvibe-host-verification=';
--const DAY = 24 * 3600 * 1000;
--const MIN_RECHECK_MS = 5000;
--
--function isHostname(h) {
-- return typeof h === 'string' && h.length <= 253 && HOSTNAME_RE.test(h);
--}
--
--/** First-party domains from the released service manifests, plus configured extras. */
--function firstPartyDomains(config) {
-- const out = new Set(['openvibe.network', 'openvibe.live', 'openvibe.media', 'openvibe.community', 'openvibe.tools', config.sitesDomain, config.dashboardHost]);
-- try {
-- const m = contracts.services.manifests;
-- for (const s of Array.isArray(m) ? m : Object.values(m || {})) for (const d of s.domains || []) out.add(String(d).toLowerCase());
-- } catch { /* the defaults above still apply */ }
-- for (const d of config.domains.extraReserved) out.add(d);
-- return [...out].filter(Boolean);
--}
--
--function defaultResolver(servers) {
-- const r = new dns.promises.Resolver({ timeout: 5000, tries: 2 });
-- if (servers && servers.length) r.setServers(servers);
-- return { resolveTxt: (name) => r.resolveTxt(name) };
--}
--
--function createDomains({ store, config, access, projects, sites, outbox, resolver = null, log = console, 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 host_domains WHERE id = ?'),
-- forSite: db.prepare("SELECT * FROM host_domains WHERE site_id = ? ORDER BY kind DESC, created_at, seq"),
-- verifiedByHost: db.prepare("SELECT * FROM host_domains WHERE hostname = ? AND status = 'verified'"),
-- onSite: db.prepare('SELECT * FROM host_domains WHERE site_id = ? AND hostname = ?'),
-- insert: db.prepare(`INSERT INTO host_domains (id, project_id, site_id, hostname, kind, status, token, created_by, created_at)
-- VALUES (?, ?, ?, ?, 'custom', 'pending', ?, ?, ?)`),
-- checked: db.prepare('UPDATE host_domains SET last_checked_at = ?, last_error = ?, check_count = check_count + 1 WHERE id = ?'),
-- verified: db.prepare("UPDATE host_domains SET status = 'verified', verified_at = ?, last_checked_at = ?, last_error = NULL, check_count = check_count + 1, record_missing_since = NULL WHERE id = ? AND status IN ('pending','failed','lapsed')"),
-- setStatus: db.prepare('UPDATE host_domains SET status = ?, last_error = ? WHERE id = ?'),
-- missing: db.prepare('UPDATE host_domains SET record_missing_since = COALESCE(record_missing_since, ?), last_checked_at = ?, last_error = ?, check_count = check_count + 1 WHERE id = ?'),
-- present: db.prepare('UPDATE host_domains SET record_missing_since = NULL, last_checked_at = ?, last_error = NULL, check_count = check_count + 1 WHERE id = ?'),
-- remove: db.prepare('DELETE FROM host_domains WHERE id = ?'),
-- duePending: db.prepare("SELECT * FROM host_domains WHERE kind = 'custom' AND status = 'pending' AND COALESCE(last_checked_at, 0) < ? ORDER BY COALESCE(last_checked_at, 0) LIMIT ?"),
-- dueVerified: db.prepare("SELECT * FROM host_domains WHERE kind = 'custom' AND status = 'verified' AND COALESCE(last_checked_at, 0) < ? ORDER BY COALESCE(last_checked_at, 0) LIMIT ?"),
-- };
--
-- const challengeName = (hostname) => `${CHALLENGE_PREFIX}.${hostname}`;
--
-- 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: Host 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: 'host.domain.verified', actor: viewer ? actorRef(viewer) : { type: 'service', id: 'host' }, visibility: 'internal', priority: 'low',
-- subject: { type: 'domain', id: domain.id },
-- payload: { project_id: domain.project_id, site_id: domain.site_id, site: site.name, hostname: domain.hostname },
-- }, { traceparent });
-- });
-- } catch (err) {
-- if (err instanceof ApiError && err.code === 'domain.taken') { 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(`[Host] 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 host_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
ddeleted file mode 100644
iindex 97ae4ca..0000000
---- a/server/domain/projects.js
++++ /dev/null
@@@ -1,174 +0,0 @@
--'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 host_projects WHERE id = ?'),
-- forPrincipal: db.prepare(`SELECT p.*, m.role FROM host_projects p JOIN host_project_members m ON m.project_id = p.id
-- WHERE m.principal = ? AND p.status = 'active' ORDER BY p.created_at DESC, p.seq DESC LIMIT 200`),
-- all: db.prepare("SELECT p.*, NULL AS role FROM host_projects p WHERE p.status = 'active' ORDER BY p.created_at DESC, p.seq DESC LIMIT 200"),
-- ownedCount: db.prepare("SELECT COUNT(*) AS n FROM host_projects WHERE owner_subject = ? AND status = 'active'"),
-- insert: db.prepare(`INSERT INTO host_projects (id, network_project_id, owner_subject, name, environment, status, created_at, updated_at)
-- VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`),
-- addMember: db.prepare(`INSERT INTO host_project_members (project_id, principal, role, added_by, created_at) VALUES (?, ?, ?, ?, ?)
-- ON CONFLICT (project_id, principal) DO UPDATE SET role = excluded.role`),
-- removeMember: db.prepare('DELETE FROM host_project_members WHERE project_id = ? AND principal = ?'),
-- members: db.prepare('SELECT principal, role, added_by, created_at FROM host_project_members WHERE project_id = ? ORDER BY created_at'),
-- quotaRow: db.prepare('SELECT * FROM host_quotas WHERE project_id = ?'),
-- storageUsed: db.prepare('SELECT COALESCE(SUM(size), 0)::bigint AS n, COUNT(*) AS c FROM host_blobs WHERE project_id = ?'),
-- deploysSince: db.prepare('SELECT COUNT(*) AS n FROM host_deploys WHERE project_id = ? AND created_at > ?'),
-- siteCount: db.prepare("SELECT COUNT(*) AS n FROM host_sites WHERE project_id = ? AND status = 'active'"),
-- customDomainCount: db.prepare("SELECT COUNT(*) AS n FROM host_domains WHERE project_id = ? AND kind = 'custom' AND status IN ('pending','verified')"),
-- markDeleted: db.prepare("UPDATE host_projects SET status = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?"),
-- };
--
-- 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, 'host.not_launched', 'OpenVibe.Host 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 Host 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 host_quotas (project_id, storage_bytes, deploys_per_day, max_files, max_file_bytes, sites, custom_domains, updated_by, updated_at)
-- VALUES (@project_id, @storage_bytes, @deploys_per_day, @max_files, @max_file_bytes, @sites, @custom_domains, @updated_by, @updated_at)
-- ON CONFLICT (project_id) DO UPDATE SET storage_bytes = excluded.storage_bytes, deploys_per_day = excluded.deploys_per_day,
-- max_files = excluded.max_files, max_file_bytes = excluded.max_file_bytes, sites = excluded.sites,
-- custom_domains = excluded.custom_domains, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
-- .run({ project_id: project.id, ...next, updated_by: principalOf(viewer), updated_at: store.now() });
-- return 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 host_deploys SET state = 'deleted', deleted_at = ? WHERE project_id = ? AND state <> 'deleted'").run(now, project.id);
-- await db.prepare("DELETE FROM host_deploy_files WHERE deploy_id IN (SELECT id FROM host_deploys WHERE project_id = ?)").run(project.id);
-- await db.prepare('DELETE FROM host_blobs WHERE project_id = ?').run(project.id);
-- await db.prepare('DELETE FROM host_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('[Host] 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
ddeleted file mode 100644
iindex 310b752..0000000
---- a/server/domain/site-config.js
++++ /dev/null
@@@ -1,182 +0,0 @@
--'use strict';
--/**
-- * Per-site serving configuration (plan T12 J3, decision D4): the response headers, the redirects and
-- * the SPA fallback of ONE site, kept in PostgreSQL and applied by server/http/tenant.js BEFORE the
-- * platform's own headers. The platform's security, routing and caching headers (CSP, HSTS,
-- * Set-Cookie, X-Forwarded-*, Cache-Control, …) are reserved and can never be set through it; a
-- * redirect target must be a local path, so a site can never become an open redirect. A site with no
-- * row serves exactly as before.
-- */
--const { ApiError } = require('../http/errors');
--const { principalOf } = require('./access');
--
--const MAX_HEADERS = 50;
--const MAX_HEADER_VALUE = 1024;
--const MAX_REDIRECTS = 200;
--const MAX_LOCATION = 1024;
--const HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;
--const REDIRECT_STATUS = new Set([301, 302, 307, 308]);
--
--// Headers the platform owns: a site's config can never set one. `X-Forwarded-*` is refused as a
--// prefix, so a site can never lie about the client to an upstream reading those headers.
--const RESERVED_HEADERS = new Set([
-- 'content-security-policy', 'content-security-policy-report-only', 'strict-transport-security',
-- 'set-cookie', 'set-cookie2', 'clear-site-data',
-- 'content-length', 'transfer-encoding', 'connection', 'keep-alive', 'trailer', 'upgrade', 'te', 'host', 'location',
-- 'content-type', 'content-disposition', 'content-encoding', 'content-range', 'accept-ranges',
-- 'cache-control', 'cdn-cache-control', 'etag', 'expires', 'last-modified', 'vary', 'age',
-- 'x-content-type-options', 'x-served-by', 'x-robots-tag', 'referrer-policy',
-- 'cross-origin-opener-policy', 'cross-origin-embedder-policy', 'cross-origin-resource-policy', 'permissions-policy',
-- // Scope and transport are the platform's too: a wider service-worker scope, or another host:port for this origin.
-- 'service-worker-allowed', 'alt-svc',
--]);
--const RESERVED_PREFIXES = ['x-forwarded-'];
--
--/** Why `name` cannot be configured, or null when it can. */
--function reservedReason(name) {
-- const lower = String(name).toLowerCase();
-- if (RESERVED_HEADERS.has(lower)) return `"${name}" is set by the platform and cannot be configured`;
-- if (RESERVED_PREFIXES.some((p) => lower.startsWith(p))) return `"${name}" is reserved (X-Forwarded-*) and cannot be configured`;
-- return null;
--}
--function isReservedHeader(name) { return reservedReason(name) !== null; }
--
--const fail = (code, message) => { throw new ApiError(422, code, message); };
--
--function cleanHeaderValue(name, value) {
-- const v = value == null ? '' : String(value);
-- if (v.length > MAX_HEADER_VALUE) fail('site_config.header_value', `the value of "${name}" is longer than ${MAX_HEADER_VALUE} characters`);
-- // eslint-disable-next-line no-control-regex
-- if (/[\u0000-\u001f\u007f]/.test(v)) fail('site_config.header_value', `the value of "${name}" contains a control character: a header value must be one line`);
-- return v;
--}
--
--/** name → value object of configurable headers, or throws ApiError(422). */
--function cleanHeaders(input) {
-- if (input == null) return {};
-- if (typeof input !== 'object' || Array.isArray(input)) fail('site_config.headers', 'headers must be an object of name → value');
-- const names = Object.keys(input);
-- if (names.length > MAX_HEADERS) fail('site_config.too_many', `a site may set at most ${MAX_HEADERS} headers`);
-- const out = {};
-- for (const name of names) {
-- if (!HEADER_NAME_RE.test(name)) fail('site_config.header_name', `"${name}" is not a valid header name`);
-- const reason = reservedReason(name);
-- if (reason) fail('site_config.reserved_header', reason);
-- out[name] = cleanHeaderValue(name, input[name]);
-- }
-- return out;
--}
--
--/** A redirect source or target must be a local path: no scheme, no host, no backslash (which browsers read as "/"). */
--function cleanLocalPath(value, field) {
-- const s = value == null ? '' : String(value).trim();
-- if (!s || s.length > MAX_LOCATION) fail('site_config.redirect', `${field} must be a path of 1–${MAX_LOCATION} characters`);
-- // eslint-disable-next-line no-control-regex
-- if (/[\u0000-\u001f\u007f]/.test(s)) fail('site_config.redirect', `${field} contains a control character`);
-- if (s.includes('\\')) fail('site_config.redirect', `${field} contains a backslash`);
-- if (s[0] !== '/') fail('site_config.redirect', `${field} must be a local path starting with "/"`);
-- if (s.startsWith('//')) fail('site_config.redirect', `${field} must be a local path, not a protocol-relative URL`);
-- let decoded;
-- try { decoded = decodeURIComponent(s); } catch { fail('site_config.redirect', `${field} is not valid percent-encoding`); }
-- // eslint-disable-next-line no-control-regex
-- if (decoded.includes('\\') || /[\u0000-\u001f\u007f]/.test(decoded) || /(^|\/)\/\//.test(decoded)) fail('site_config.redirect', `${field} must be a local path (it decoded to a scheme, a host or a control character)`);
-- return s;
--}
--
--/** [{ from, to, status }] of local-only redirects, or throws ApiError(422). */
--function cleanRedirects(input) {
-- if (input == null) return [];
-- if (!Array.isArray(input)) fail('site_config.redirects', 'redirects must be an array');
-- if (input.length > MAX_REDIRECTS) fail('site_config.too_many', `a site may have at most ${MAX_REDIRECTS} redirects`);
-- const seen = new Set();
-- const out = [];
-- for (const [i, r] of input.entries()) {
-- if (!r || typeof r !== 'object' || Array.isArray(r)) fail('site_config.redirect', `redirect ${i + 1} must be an object with "from" and "to"`);
-- const from = cleanLocalPath(r.from, `redirect ${i + 1} "from"`);
-- const to = cleanLocalPath(r.to, `redirect ${i + 1} "to"`);
-- if (seen.has(from)) fail('site_config.redirect', `two redirects have the same "from" path (${from})`);
-- seen.add(from);
-- const status = r.status == null ? 301 : Number(r.status);
-- if (!REDIRECT_STATUS.has(status)) fail('site_config.redirect', `redirect ${i + 1} status must be one of 301, 302, 307, 308`);
-- out.push({ from, to, status });
-- }
-- return out;
--}
--
--const cleanSpa = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v == null ? '' : v).toLowerCase());
--
--/** The whole config, cleaned. */
--function normalize(input = {}) {
-- return { headers: cleanHeaders(input.headers), redirects: cleanRedirects(input.redirects), spa: cleanSpa(input.spa) };
--}
--
--function defaults() { return { headers: {}, redirects: [], spa: false }; }
--
--// ── Dashboard text form (one "Name: value" per line; one "from -> to [status]" per line) ──
--function headersToText(headers) {
-- return Object.entries(headers || {}).map(([k, v]) => `${k}: ${v}`).join('\n');
--}
--function parseHeadersText(text) {
-- const obj = {};
-- for (const raw of String(text || '').split('\n')) {
-- const line = raw.trim();
-- if (!line) continue;
-- const i = line.indexOf(':');
-- if (i <= 0) fail('site_config.header_syntax', `each header line must be "Name: value" (got "${line.slice(0, 60)}")`);
-- const name = line.slice(0, i).trim();
-- if (name in obj) fail('site_config.header_syntax', `"${name}" is listed twice`);
-- obj[name] = line.slice(i + 1).trim();
-- }
-- return cleanHeaders(obj);
--}
--function redirectsToText(redirects) {
-- return (redirects || []).map((r) => `${r.from} -> ${r.to}${r.status && r.status !== 301 ? ` ${r.status}` : ''}`).join('\n');
--}
--function parseRedirectsText(text) {
-- const list = [];
-- for (const raw of String(text || '').split('\n')) {
-- const line = raw.trim();
-- if (!line) continue;
-- const m = /^(\S+)\s*->\s*(\S+)(?:\s+(\d{3}))?$/.exec(line);
-- if (!m) fail('site_config.redirect_syntax', `each redirect line must be "from -> to [status]" (got "${line.slice(0, 60)}")`);
-- list.push({ from: m[1], to: m[2], status: m[3] ? Number(m[3]) : 301 });
-- }
-- return cleanRedirects(list);
--}
--
--function createSiteConfig({ store }) {
-- const { db } = store;
-- const q = {
-- bySite: db.prepare('SELECT * FROM host_site_config WHERE site_id = ?'),
-- upsert: db.prepare(`INSERT INTO host_site_config (site_id, headers, redirects, spa, updated_by, updated_at)
-- VALUES (?, ?::jsonb, ?::jsonb, ?, ?, ?)
-- ON CONFLICT (site_id) DO UPDATE SET headers = EXCLUDED.headers, redirects = EXCLUDED.redirects,
-- spa = EXCLUDED.spa, updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at`),
-- remove: db.prepare('DELETE FROM host_site_config WHERE site_id = ?'),
-- };
--
-- const rowToConfig = (row) => (row
-- ? { headers: row.headers && typeof row.headers === 'object' && !Array.isArray(row.headers) ? row.headers : {}, redirects: Array.isArray(row.redirects) ? row.redirects : [], spa: Boolean(row.spa) }
-- : defaults());
--
-- async function getForSite(siteId) { return rowToConfig(await q.bySite.get(siteId)); }
--
-- async function set(viewer, site, input) {
-- const cfg = normalize(input);
-- await q.upsert.run(site.id, JSON.stringify(cfg.headers), JSON.stringify(cfg.redirects), cfg.spa, principalOf(viewer), store.now());
-- return await getForSite(site.id);
-- }
--
-- async function remove(site) { await q.remove.run(site.id); return defaults(); }
--
-- return {
-- getForSite, set, remove, defaults, normalize, cleanHeaders, cleanRedirects,
-- headersToText, parseHeadersText, redirectsToText, parseRedirectsText,
-- };
--}
--
--module.exports = {
-- createSiteConfig, normalize, defaults, isReservedHeader, cleanHeaders, cleanRedirects,
-- parseHeadersText, parseRedirectsText, headersToText, redirectsToText,
-- RESERVED_HEADERS, RESERVED_PREFIXES, REDIRECT_STATUS, MAX_HEADERS, MAX_REDIRECTS,
--};
ddiff --git a/server/domain/site-sources.js b/server/domain/site-sources.js
ddeleted file mode 100644
iindex 1a5affb..0000000
---- a/server/domain/site-sources.js
++++ /dev/null
@@@ -1,108 +0,0 @@
--'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 host_site_sources WHERE site_id = ?'),
-- upsert: db.prepare(`INSERT INTO host_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 host_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
ddeleted file mode 100644
iindex 893116e..0000000
---- a/server/domain/sites.js
++++ /dev/null
@@@ -1,124 +0,0 @@
--'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 host_sites WHERE id = ?'),
-- byName: db.prepare("SELECT * FROM host_sites WHERE name = ? AND status = 'active'"),
-- held: db.prepare("SELECT project_id FROM host_sites WHERE name = ? AND status = 'deleted' AND deleted_at > ? ORDER BY deleted_at DESC LIMIT 1"),
-- forProject: db.prepare("SELECT * FROM host_sites WHERE project_id = ? AND status = 'active' ORDER BY created_at, seq"),
-- insert: db.prepare(`INSERT INTO host_sites (id, project_id, name, active_deploy_id, status, created_by, created_at, updated_at)
-- VALUES (?, ?, ?, NULL, 'active', ?, ?, ?)`),
-- insertDefaultDomain: db.prepare(`INSERT INTO host_domains (id, project_id, site_id, hostname, kind, status, token, created_by, created_at, verified_at)
-- VALUES (?, ?, ?, ?, 'default', 'verified', NULL, ?, ?, ?)`),
-- markDeleted: db.prepare("UPDATE host_sites SET status = 'deleted', active_deploy_id = NULL, 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 host_deploys SET state = 'deleted', deleted_at = ? WHERE site_id = ? AND state <> 'deleted'").run(now, site.id);
-- await db.prepare('DELETE FROM host_deploy_files WHERE deploy_id IN (SELECT id FROM host_deploys WHERE site_id = ?)').run(site.id);
-- await db.prepare('DELETE FROM host_domains WHERE site_id = ?').run(site.id);
-- await db.prepare('DELETE FROM host_site_config WHERE site_id = ?').run(site.id);
-- await db.prepare('DELETE FROM host_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
ddeleted file mode 100644
iindex 5c0e09b..0000000
---- a/server/domain/takedowns.js
++++ /dev/null
@@@ -1,82 +0,0 @@
--'use strict';
--/**
-- * Abuse takedowns, by OpenVibe staff only (Network role admin).
-- *
-- * A takedown stops a site, or every site of a project, from being served at once: its default and
-- * custom domains answer 451 with no tenant content. Unlike a delete, it KEEPS the deploys, their
-- * file rows, logs and objects, so a report can be reviewed and, where the law requires it, the
-- * material preserved and handed over. While a takedown is active the project's members cannot
-- * upload, activate, roll back or delete anything it covers; staff can still delete. Every takedown
-- * and every lift stays in host_takedowns.
-- *
-- * Members see that their site is taken down, and why (site/project `takedown` in the API).
-- */
--const { ApiError } = require('../http/errors');
--const { principalOf } = require('./access');
--
--const REASON_MAX = 500;
--
--function createTakedowns({ store }) {
-- const { db } = store;
-- const q = {
-- forSite: db.prepare(`SELECT target_kind, reason, created_at FROM host_takedowns
-- WHERE lifted_at IS NULL AND ((target_kind = 'site' AND target_id = ?) OR (target_kind = 'project' AND target_id = ?))
-- ORDER BY target_kind = 'project' DESC, created_at LIMIT 1`),
-- forProject: db.prepare("SELECT target_kind, reason, created_at FROM host_takedowns WHERE lifted_at IS NULL AND target_kind = 'project' AND target_id = ?"),
-- anyInProject: db.prepare(`SELECT target_kind, reason, created_at FROM host_takedowns t
-- WHERE lifted_at IS NULL AND ((target_kind = 'project' AND target_id = @p)
-- OR (target_kind = 'site' AND target_id IN (SELECT id FROM host_sites WHERE project_id = @p AND status = 'active')))
-- LIMIT 1`),
-- insert: db.prepare('INSERT INTO host_takedowns (target_kind, target_id, reason, created_by, created_at) VALUES (?, ?, ?, ?, ?)'),
-- lift: db.prepare('UPDATE host_takedowns SET lifted_by = ?, lifted_at = ?, lift_note = ? WHERE target_kind = ? AND target_id = ? AND lifted_at IS NULL'),
-- };
--
-- const shape = (row) => (row ? { scope: row.target_kind, reason: row.reason, since: row.created_at } : null);
--
-- /** The takedown that covers a site (its own, or its project's), or null. */
-- const ofSite = 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
iindex 41a2ee9..80e32b8 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -1,24 +1,9 @@
'use strict';
--/**
-- * Host → OpenVibe.Events through the openvibe-sdk service outbox (ADR-004).
-- *
-- * host.deploy.created an upload became an immutable, ready deploy
-- * host.deploy.activated a site's active deploy changed (payload.rollback marks a rollback)
-- * host.deploy.failed an upload was refused (payload.code, problem codes; no file contents)
-- * host.domain.verified a custom domain's TXT record was found; the domain is now served
-- * host.index_document.upserted|deleted a hosting catalog page in OpenVibe.Search (server/catalog/search-index.js)
-- *
-- * 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
-- * Host's service token (events.event.publish, audience openvibe.events) only when EVENTS_URL and
-- * OV_OAUTH_CLIENT_SECRET are set; otherwise rows wait in event_outbox and /api/ready reports the
-- * relay as off. The undeclared event types below are refused by emit().
-- */
++/** Catalog search indexing events through the OpenVibe.Events outbox. */
const { createServiceOutbox } = require('openvibe-sdk/events');
--const EVENT_TYPES = Object.freeze(['host.deploy.created', 'host.deploy.activated', 'host.deploy.failed', 'host.domain.verified',
-- 'host.index_document.upserted', 'host.index_document.deleted']);
++const EVENT_TYPES = Object.freeze(['host.index_document.upserted', 'host.index_document.deleted']);
function createHostOutbox({ db, config, fetchImpl, now, log = console }) {
return createServiceOutbox({
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex 8432715..cfbe2ba 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -1,13 +1,4 @@
--/**
-- * 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 HOST_LIMITS_MINUTE / HOST_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
-- * host_rate_limited_total{limit,window}. Counters live in this process.
-- */
++/** Per-actor limits for catalog writes. */
'use strict';
const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/limits');
@@@ -15,14 +6,10 @@ const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/lim
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 = [
-- ['host.deploy.upload', /^POST$/, /^\/sites\/[^/]+\/deploys\/?$/, { minute: 10, hour: 100 }],
-- ['host.domain.verify', /^POST$/, /^\/domains\/[^/]+\/verify$/, { minute: 10, hour: 100 }],
-- ['host.project.create', /^POST$/, /^\/projects\/?$/, { minute: 5, hour: 30 }],
--];
++const ROUTES = [];
function createHostActorLimits({ env = process.env, registry = null, now, valkey = null } = {}) {
-- // HOST_ACTOR_LIMITS=off: a rollback lever (and what the Stage B suites use: they deploy dozens of times a minute).
++ // HOST_ACTOR_LIMITS=off disables catalog write limits.
if (/^(off|0|false)$/i.test(String(env.HOST_ACTOR_LIMITS || ''))) return function noLimits(req, res, next) { next(); };
const refused = registry && typeof registry.counter === 'function'
? registry.counter({ name: 'host_rate_limited_total', help: 'Writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
@@@ -45,7 +32,7 @@ function createHostActorLimits({ env = process.env, registry = null, now, valkey
if (refused) refused.inc({ limit: e.name, window: e.window });
},
});
-- const write = limits('host.write');
++ const write = limits('host.catalog.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) {
ddiff --git a/server/http/api.js b/server/http/api.js
ddeleted file mode 100644
iindex 1b70e58..0000000
---- a/server/http/api.js
++++ /dev/null
@@@ -1,271 +0,0 @@
--'use strict';
--/**
-- * /api/v1 — Host's API for people (a Network user JWT as a Bearer token) and principals (Network
-- * client-credentials tokens for audience openvibe.host). Cookies are ignored here: tenant pages
-- * on <site>.openvibe.host are same-site with openvibe.host, so cookie-authenticated API calls could
-- * be forged from any tenant page.
-- *
-- * projects GET/POST /projects · GET/DELETE /projects/:id · GET/PUT /projects/:id/quota
-- * PUT/DELETE /projects/:id/members/:principal host.site.manage
-- * sites GET/POST /projects/:id/sites · GET/DELETE /sites/:id host.site.manage
-- * GET/PUT/DELETE /sites/:id/config (headers, redirects, SPA fallback) host.site.config
-- * GET/PUT/DELETE /sites/:id/source (Git repository + branch) host.site.manage
-- * deploys GET/POST /sites/:id/deploys · GET /deploys/:id · GET /deploys/:id/log
-- * POST /deploys/:id/activate · POST /sites/:id/rollback host.deploy.create
-- * DELETE /deploys/:id host.site.manage
-- * POST /sites/:id/source/deploys (the project's CI posts a build; always a preview)
-- * host.deploy.create
-- * domains GET/POST /sites/:id/domains · POST /domains/:id/verify
-- * DELETE /domains/:id host.domain.manage
-- * takedowns POST/DELETE /projects/:id/takedown · POST/DELETE /sites/:id/takedown host.site.manage (staff)
-- */
--const express = require('express');
--const contracts = require('openvibe-contracts');
--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('host.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('host.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('host.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('host.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('host.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('host.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('host.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('host.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('host.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('host.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('host.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('host.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('host.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('host.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('host.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('host.site.manage'), run(async (req) => await sites.remove(req.viewer, req.params.id, { deploys })));
--
-- // ── Deploys ─────────────────────────────────────────────
-- router.get('/sites/:id/deploys', guard('host.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', 'Host is validating other uploads right now; try again in 30 seconds'); }
-- let up;
-- try {
-- up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
-- } 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('host.deploy.create'), (req, res) => receive(req, res));
--
-- router.get('/deploys/:id', guard('host.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('host.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('host.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('host.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('host.site.manage'), run(async (req) => await deploys.remove(req.viewer, req.params.id)));
--
-- // ── Domains ─────────────────────────────────────────────
-- router.get('/sites/:id/domains', guard('host.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('host.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('host.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('host.domain.manage'), run(async (req) => await domains.remove(req.viewer, req.params.id)));
--
-- // ── Per-site configuration: headers, redirects, SPA fallback (host.site.config) ──
-- // Applied by http/tenant.js before the platform's headers; reserved headers are refused on write.
-- router.get('/sites/:id/config', guard('host.site.config'), run(async (req) => {
-- const { site } = await sites.load(req.viewer, req.params.id, 'read');
-- return { config: await siteConfig.getForSite(site.id) };
-- }));
-- router.put('/sites/:id/config', guard('host.site.config'), jsonBody, run(async (req) => {
-- const { site } = await sites.load(req.viewer, req.params.id, 'maintain');
-- return { config: await siteConfig.set(req.viewer, site, req.body || {}) };
-- }));
-- router.delete('/sites/:id/config', guard('host.site.config'), run(async (req) => {
-- const { site } = await sites.load(req.viewer, req.params.id, 'maintain');
-- return { config: await siteConfig.remove(site) };
-- }));
--
-- // ── Git source: the repository + branch the project's own CI deploys from (host.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('host.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('host.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('host.site.manage'), run(async (req) => {
-- await siteSources.remove(req.viewer, req.params.id);
-- return { source: null };
-- }));
-- router.post('/sites/:id/source/deploys', guard('host.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
ddeleted file mode 100644
iindex caecf0a..0000000
---- a/server/http/dashboard.js
++++ /dev/null
@@@ -1,242 +0,0 @@
--'use strict';
--/**
-- * The dashboard: server-rendered pages for projects, sites, deploys (with rollback), domains
-- * (with DNS instructions), quotas and upload logs. Every action is a POST form that must come from
-- * the dashboard's own origin and carry the form token (see auth/forms.js for why SameSite is not
-- * enough on openvibe.host). Every response is private and never cached by a shared cache.
-- */
--const express = require('express');
--const ovServe = require('openvibe-shared/serve');
--const frame = require('openvibe-shared/frame');
--const { renderPage } = require('../render/layout');
--const pages = require('../render/pages');
--const catalogPages = require('../render/catalog');
--const showcase = require('openvibe-shared/showcase');
--const { limitsOf } = require('../limits');
--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('[Host dashboard]', err && err.stack ? err.stack : err);
-- return page(req, res, 500, 'Error', pages.errorPage({ status: 500, message: 'Something went wrong on our side. Try again in a moment.' }));
-- }
--
-- const view = (fn) => async (req, res) => {
-- try { await fn(req, res); } catch (err) { fail(req, res, err); }
-- };
--
-- /** POST guard: signed in, same origin, valid form token (urlencoded forms). */
-- const action = (fn) => [urlencoded, async (req, res) => {
-- try {
-- if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
-- if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) throw new ApiError(403, 'form.invalid', 'this form expired or did not come from this dashboard; reload the page and try again');
-- const to = await fn(req, res);
-- if (to && !res.headersSent) res.redirect(303, to);
-- } catch (err) { fail(req, res, err); }
-- }];
--
-- const notice = (req) => {
-- const n = req.query.notice;
-- return typeof n === 'string' && n.length < 300 ? { kind: req.query.kind === 'error' ? 'error' : 'info', text: n } : null;
-- };
-- /** 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.Host: the shared update log every OpenVibe site has.
-- router.get('/updates', view((req, res) => page(req, res, 200, 'What shipped on OpenVibe.Host', frame.updatesBody({ service: 'host', siteName: 'OpenVibe.Host' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, null, { indexable: true })));
-- router.get('/hosting', view(async (req, res) => {
-- page(req, res, 200, 'Static hosting', pages.signedOut({ limits: limitsOf(config), limitsUrl: `${config.baseUrl}/limits.json`, launched: config.publicHosting }), null,
-- { indexable: !req.query || !Object.keys(req.query).length, styles: [showcase.STYLESHEET] });
-- }));
--
-- router.get('/', view(async (req, res) => {
-- // The signed-out front page is the public page of openvibe.host (sitemap.xml): indexable. It leads with the
-- // hosting catalog (plan T20 step 3: openvibe.host is hosting compared); OpenVibe's own static hosting has
-- // its page at /hosting. Everything behind sign-in stays noindex and private.
-- if (req.viewer.kind !== 'user') {
-- const [result, providers, promos, stats] = await Promise.all([ctx.catalog.plans({}), ctx.catalog.providers(), ctx.catalog.promos(), ctx.catalog.stats()]);
-- return page(req, res, 200, null, catalogPages.front({ plans: result.plans, providers, promos, stats, hostingLaunched: config.publicHosting }), null,
-- { indexable: !req.query || !Object.keys(req.query).length, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: catalogPages.jsonLd.front(config.baseUrl, result.plans) });
-- }
-- 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('/', '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', 'Host is validating other uploads right now; try again in 30 seconds'); }
-- let up;
-- try {
-- up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
-- } 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/serialize.js b/server/http/serialize.js
ddeleted file mode 100644
iindex 5685cf2..0000000
---- a/server/http/serialize.js
++++ /dev/null
@@@ -1,115 +0,0 @@
--'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
ddeleted file mode 100644
iindex 4a8bc24..0000000
---- a/server/http/tenant.js
++++ /dev/null
@@@ -1,436 +0,0 @@
--'use strict';
--/**
-- * Serving tenant sites by Host header.
-- *
-- * <name>.<sitesDomain> the site called <name> (one label; anything deeper is unknown)
-- * <verified custom domain> the site it was verified for (pending, failed and lapsed: unknown)
-- * anything else 404 "unknown host" with no tenant content
-- *
-- * A request resolves its site ONCE from the Host header, reads the site's active deploy pointer
-- * ONCE, and from then on reads only immutable rows of that deploy (host_deploy_files). The file's
-- * bytes come from the blob store keyed by the project id and the manifest's hash — the local disk
-- * cache, or, with HOST_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 (host_sites.preview_deploy_id) on the dashboard
-- * host only, to a member of its project, at /preview/<deploy-id>/… — always noindex, never cached by
-- * a shared cache, and never announced to a search engine (plan T12 J4, decision D5).
-- */
--const fs = require('fs');
--const { sitemapXml, robotsTxt } = require('openvibe-shared/seo');
--const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');
--const { isHostname } = require('../domain/domains');
--const { isReservedHeader } = require('../domain/site-config');
--const { isId } = require('../ids');
--
--const TENANT_CSP = [
-- "default-src 'self'",
-- "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.host. Its CSP
-- * sandboxes the document into an opaque origin (there is deliberately no `allow-same-origin`), so
-- * every tenant script, style and asset it loads runs without access to the dashboard's cookies,
-- * storage or DOM (the "active content runs only on the tenant's own origin" control, docs/threat-review.md §5).
-- */
--function previewCsp(origin) {
-- return [
-- "default-src 'none'",
-- `script-src ${origin}`,
-- `style-src ${origin} 'unsafe-inline'`,
-- `img-src ${origin} data: https:`,
-- `font-src ${origin} data:`,
-- `media-src ${origin} https:`,
-- `connect-src ${origin}`,
-- "object-src 'none'",
-- "base-uri 'none'",
-- "form-action 'none'",
-- "frame-ancestors 'none'",
-- 'sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
-- ].join('; ');
--}
--
--/** Host header → lowercase host name without port or trailing dot; null when it is not one. */
--function normaliseHost(raw) {
-- if (typeof raw !== 'string' || !raw || raw.length > 260) return null;
-- let h = raw.trim().toLowerCase();
-- if (h.startsWith('[')) {
-- const end = h.indexOf(']');
-- return end > 0 ? h.slice(0, end + 1) : null;
-- }
-- const colon = h.indexOf(':');
-- if (colon >= 0) {
-- if (!/^\d{1,5}$/.test(h.slice(colon + 1))) return null;
-- h = h.slice(0, colon);
-- }
-- if (h.endsWith('.')) h = h.slice(0, -1);
-- if (!h || !/^[a-z0-9.-]+$/.test(h)) return null;
-- return h;
--}
--
--function baseHeaders(res, { sandbox = false } = {}) {
-- res.setHeader('X-Content-Type-Options', 'nosniff');
-- res.setHeader('Content-Security-Policy', TENANT_CSP);
-- res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
-- res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
-- res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()');
-- res.setHeader('X-Served-By', 'OpenVibe.Host');
-- if (sandbox) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
--}
--
--function plain(res, status, text, extra = {}) {
-- res.statusCode = status;
-- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
-- res.setHeader('Cache-Control', 'no-store');
-- res.setHeader('X-Content-Type-Options', 'nosniff');
-- for (const [k, v] of Object.entries(extra)) res.setHeader(k, v);
-- res.end(text);
--}
--
--/** Apply a site's configured headers, never a reserved one (defense in depth: they are refused on write too). */
--function applyConfiguredHeaders(res, headers) {
-- for (const [name, value] of Object.entries(headers || {})) {
-- if (isReservedHeader(name)) continue;
-- try { res.setHeader(name, value); } catch { /* a name or value Node refuses is simply not set */ }
-- }
--}
--
--function createTenantServer({ store, config, blobs, takedowns = null, siteConfig = null, access = null, log = console }) {
-- const { db } = store;
-- const q = {
-- siteByName: db.prepare("SELECT s.*, p.environment FROM host_sites s JOIN host_projects p ON p.id = s.project_id WHERE s.name = ? AND s.status = 'active' AND p.status = 'active'"),
-- siteByCustom: db.prepare(`SELECT s.*, p.environment FROM host_domains d JOIN host_sites s ON s.id = d.site_id JOIN host_projects p ON p.id = s.project_id
-- WHERE d.hostname = ? AND d.kind = 'custom' AND d.status = 'verified' AND s.status = 'active' AND p.status = 'active'`),
-- file: db.prepare(`SELECT f.sha256, f.size, f.content_type, d.project_id FROM host_deploy_files f JOIN host_deploys d ON d.id = f.deploy_id
-- WHERE f.deploy_id = ? AND f.path = ? AND d.state = 'ready'`),
-- htmlFiles: db.prepare("SELECT path FROM host_deploy_files WHERE deploy_id = ? AND content_type LIKE 'text/html%' ORDER BY path"),
-- deployById: db.prepare('SELECT id, site_id, project_id, state FROM host_deploys WHERE id = ?'),
-- siteById: db.prepare('SELECT * FROM host_sites WHERE id = ?'),
-- projectById: db.prepare('SELECT id, status, environment FROM host_projects WHERE id = ?'),
-- };
-- const suffix = `.${config.sitesDomain}`;
-- let previewOrigin = "'self'";
-- try { previewOrigin = new URL(config.baseUrl).origin; } catch { /* keep 'self' */ }
-- const PREVIEW_CSP = previewCsp(previewOrigin);
--
-- /**
-- * -> { kind: 'dashboard' } | { kind: 'site', site } | { kind: 'unknown', host }
-- */
-- 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('[Host] 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('[Host] object missing or unreadable:', row.project_id, row.sha256, err.code || err.message);
-- if (!res.headersSent) { res.removeHeader('Content-Length'); plain(res, 500, 'This file could not be read. Try again later.'); } else res.destroy();
-- });
-- stream.pipe(res);
-- }
--
-- /** 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.Host</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.Host 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 (host_sites.preview_deploy_id) while that preview
-- * has not expired; once it expires, the site deploys or rolls back, or the preview is deleted, the
-- * address is a plain 404. Every response is noindex, no-store and never announced to a search
-- * engine, and the bytes come from the deploy's own project and manifest — never from the URL —
-- * exactly as in handle().
-- */
-- async function preview(req, res) {
-- // Never the dashboard's own CSP: the preview is sandboxed into an opaque origin (see previewCsp).
-- res.setHeader('Content-Security-Policy', PREVIEW_CSP);
-- res.setHeader('X-Robots-Tag', 'noindex, nofollow');
-- res.setHeader('Cache-Control', 'private, no-store');
-- const miss = () => plain(res, 404, 'No preview is served at this address.');
-- const viewer = req.viewer;
-- if (!access || !viewer || viewer.kind === 'anonymous') return miss();
-- if (req.method !== 'GET' && req.method !== 'HEAD') return plain(res, 405, 'Static preview: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });
-- const raw = req.originalUrl || req.url || '';
-- const cut = raw.indexOf('?');
-- const m = /^\/preview\/([^/?#]+)(\/[^?#]*)?$/.exec(cut >= 0 ? raw.slice(0, cut) : raw);
-- if (!m || !isId('deploy', m[1])) return miss();
-- const deploy = await q.deployById.get(m[1]);
-- if (!deploy || deploy.state !== 'ready') return miss();
-- const site = await q.siteById.get(deploy.site_id);
-- if (!site || site.status !== 'active' || site.project_id !== deploy.project_id) return miss();
-- // Only the site's ONE live preview is served, and only until it expires.
-- if (site.preview_deploy_id !== deploy.id || !site.preview_expires_at || site.preview_expires_at <= store.now()) return miss();
-- const project = await q.projectById.get(deploy.project_id);
-- try { await access.authorize(project, viewer, 'read'); } catch { return miss(); }
-- if (takedowns && await takedowns.ofSite(site)) {
-- return plain(res, 451, 'This site is unavailable: OpenVibe.Host staff took it down after a report.', { 'Clear-Site-Data': '"cache", "storage"' });
-- }
-- const c = candidates(m[2] || '/');
-- if (!c) return miss();
-- for (const p of c.list) {
-- const row = await q.file.get(deploy.id, p);
-- if (row && row.project_id === deploy.project_id) {
-- return send(req, res, site, row, { cache: 'private, no-store, max-age=0', deployId: deploy.id });
-- }
-- }
-- return miss();
-- }
--
-- return { resolve, handle, preview, unknownHost, normaliseHost, candidates, TENANT_CSP };
--}
--
--module.exports = { createTenantServer, normaliseHost, TENANT_CSP };
ddiff --git a/server/http/upload.js b/server/http/upload.js
ddeleted file mode 100644
iindex f6c195e..0000000
---- a/server/http/upload.js
++++ /dev/null
@@@ -1,197 +0,0 @@
--'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 HOST_MAX_UPLOAD_BYTES, plus up to HOST_MAX_UNPACKED_BYTES once unpacked), so without a cap
-- * a handful of accounts uploading together could exhaust the host's memory. enter() -> release()
-- * or null when every slot is taken (the caller answers 503 with Retry-After before reading a byte).
-- */
--function createUploadGate(max) {
-- const limit = Math.max(1, Number(max) || 1);
-- let inFlight = 0;
-- return {
-- enter() {
-- if (inFlight >= limit) return null;
-- inFlight++;
-- let released = false;
-- return () => { if (!released) { released = true; inFlight--; } };
-- },
-- get inFlight() { return inFlight; },
-- limit,
-- };
--}
--
--module.exports = { readUpload, UploadError, createUploadGate };
ddiff --git a/server/ids.js b/server/ids.js
iindex 43fbfb5..5696610 100644
---- a/server/ids.js
++++ b/server/ids.js
@@@ -1,13 +1,8 @@
'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
-- */
++/** Catalog identifiers: <prefix>_<ULID>. */
const { ids } = require('openvibe-contracts');
--const PREFIX = { project: 'prj', site: 'sit', deploy: 'dpl', domain: 'dom', catalogProvider: 'hpv', catalogPlan: 'hpl', catalogPromo: 'hpr' };
++const PREFIX = { catalogProvider: 'hpv', catalogPlan: 'hpl', catalogPromo: 'hpr' };
const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));
ddiff --git a/server/index.js b/server/index.js
iindex e8fc4ab..e137529 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1,12 +1,6 @@
'use strict';
--/**
-- * OpenVibe.Host Stage B — process entry. `node server/index.js`
-- * Listens on PORT (4910) behind nginx (deploy/nginx). Serves the dashboard and API on BASE_URL's
-- * host and tenant sites on <site>.<HOST_SITES_DOMAIN> and verified custom domains. Starts the
-- * outbox relay (when EVENTS_URL and the client secret are set) and the worker (domain checks,
-- * object sweep).
-- */
++/** OpenVibe.Host catalog service entry point. */
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
@@@ -15,11 +9,11 @@ const { app, ctx } = await createApp();
const { config } = ctx;
const server = app.listen(config.port, config.host, () => {
-- console.log(`[Host] ${config.nodeEnv} on http://${config.host}:${config.port} → dashboard ${config.baseUrl}, sites *.${config.sitesDomain} (db ${config.db.url ? 'PostgreSQL' : 'embedded PGlite'}, objects ${ctx.blobs.root})`);
++ console.log(`[Host] ${config.nodeEnv} on http://${config.host}:${config.port} → catalog ${config.baseUrl} (db ${config.db.url ? 'PostgreSQL' : 'embedded PGlite'})`);
console.log(`[Host] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}`);
});
server.keepAliveTimeout = 65_000;
--server.requestTimeout = 10 * 60_000; // large uploads on slow links
++server.requestTimeout = 30_000;
ctx.outbox.start();
ctx.worker.start();
ddiff --git a/server/limits.js b/server/limits.js
ddeleted file mode 100644
iindex ef4ac6f..0000000
---- a/server/limits.js
++++ /dev/null
@@@ -1,39 +0,0 @@
--'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
-- * host_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: 'host.site.manage', unit, production: p[key], sandbox: s[key], exceeded });
-- return {
-- service: 'host',
-- scope: 'per project and environment; projects per owner',
-- limits: [
-- { id: 'projects_per_owner', label: 'Projects one person owns', capability: 'host.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 314cd96..160652a 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -1,19 +1,10 @@
'use strict';
--/**
-- * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
-- *
-- * db required a real query on Host's PostgreSQL database (the charter tables answer)
-- * storage required the object store directory is writable (uploads and serving need it)
-- * network_jwks optional the Network signing key has loaded; without it tenant sites still
-- * serve, but nobody can sign in and service tokens are refused (503)
-- * events_relay optional the outbox relay is configured and has no rejected rows
-- * domain_checks optional no custom domain is stuck: pending checks are running
-- */
++/** Catalog and outbox readiness. */
const { createReadiness } = require('openvibe-shared/ready');
const { jwksClient } = require('openvibe-sdk/auth');
const { CHARTER_TABLES } = require('./db');
--function createHostReadiness({ store, blobs, outbox, jwksUrl = null, release = null, minFreeBytes = () => 0, valkey = null }) {
++function createHostReadiness({ store, outbox, jwksUrl = null, release = null, valkey = null }) {
const { db } = store;
return createReadiness({
service: 'host',
@@@ -30,16 +21,6 @@ function createHostReadiness({ store, blobs, outbox, jwksUrl = null, release = n
return missing.length ? `missing ${missing.join(', ')} (migrations did not run)` : { ok: true, detail: r.detail };
},
},
-- { 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 = minFreeBytes();
-- return free >= floor ? { ok: true, detail: { free_bytes: free } } : `${free} bytes free, below HOST_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: 'network_jwks', required: false,
@@@ -60,13 +41,6 @@ function createHostReadiness({ store, blobs, outbox, jwksUrl = null, release = n
return { ok: true, detail: { pending: s.pending } };
},
},
-- {
-- name: 'domain_checks', required: false,
-- check: async () => {
-- const stale = (await db.prepare("SELECT COUNT(*) AS n FROM host_domains WHERE kind = 'custom' AND status = 'pending' AND COALESCE(last_checked_at, created_at) < ?").get(store.now() - 3600 * 1000)).n;
-- return stale ? `${stale} pending custom domains not checked in the last hour (is the worker on?)` : true;
-- },
-- },
],
});
}
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
ddeleted file mode 100644
iindex 7ed561b..0000000
---- a/server/registry/resource-index.js
++++ /dev/null
@@@ -1,205 +0,0 @@
--'use strict';
--/**
-- * Host's authority resource index (ADR-048 section 3; capability host.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.Host 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 = 'host';
--const SITE_KIND = 'host.site';
--const DEPLOY_KIND = 'host.deploy';
--const DOMAIN_KIND = 'host.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 host_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 host_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 host_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: host.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 host_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 host_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 host_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: 'host_sites', summary: siteSummary },
-- deploy: { table: 'host_deploys', summary: deploySummary },
-- domain: { table: 'host_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.host', 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/catalog.js b/server/render/catalog.js
iindex b17e8fe..f2e0667 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -253,8 +253,8 @@ const jsonLd = {
},
};
--/** The signed-out front page: the comparison first, then OpenVibe's own static hosting (still in review). */
--function front({ plans, providers, promos: live, stats, hostingLaunched }) {
++/** The front page of the hosting comparison. */
++function front({ plans, providers, promos: live, stats }) {
const aside = plans.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
<p class="hc-aside-h">Lowest monthly prices right now</p>
<ol>${plans.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>
@@@ -280,11 +280,7 @@ function front({ plans, providers, promos: live, stats, hostingLaunched }) {
{ icon: 'ov:account', title: 'Ranked by you', text: 'No paid placement. Affiliate links are marked, disclosed beside the link, and never change the order.' },
],
}) + (live.length ? `<section class="sc-sec"><h2>Promotions running now</h2>${promoCards(live.slice(0, 3))}<p class="hc-more">${link('/catalog/promos', 'All promotions →')}</p></section>` : '')
-- + `<section class="sc-sec hc-own" id="static-hosting"><h2>OpenVibe's own static hosting</h2>
--<p>${hostingLaunched
-- ? 'Publish a static site from a folder or a .tar.gz: every deploy is kept as an immutable artifact, served at &lt;site&gt;.openvibe.host and on domains you verify, and rolled back in one step.'
-- : 'The engine is built and tested (immutable deploys, one-step rollback, custom domains), but it is <strong>not open to the public yet</strong>: abuse handling and a designated DMCA agent come first. Until then only OpenVibe staff create projects.'}</p>
--<p>${link('/hosting', 'How it works and its limits →')}</p></section>`;
++ + `<p class="hc-own">Want to host a site with OpenVibe? ${link('https://openvibe.space', 'Visit OpenVibe.Space')}.</p>`;
}
module.exports = { front, catalog, providerList, provider, plan, promos, planTable, jsonLd, DISCLOSURE, money, bytesMb, sizeGb };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex a510e5e..f8e908b 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -14,10 +14,8 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const NETWORK_URL = 'https://openvibe.network';
--// The catalog first (openvibe.host is hosting compared, plan T20 step 3); a signed-in person's projects after it.
--const NAV_LINKS = (signedIn) => [{ label: 'Compare', href: '/catalog' }, { label: 'Promotions', href: '/catalog/promos' },
-- signedIn ? { label: 'Projects', href: '/' } : { label: 'Static hosting', href: '/hosting' }];
--const DESCRIPTION = 'OpenVibe.Host: hosting plans compared, every price dated and sourced; and OpenVibe\'s own static hosting (alpha).';
++const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Promotions', href: '/catalog/promos' }];
++const DESCRIPTION = 'OpenVibe.Host: hosting plans compared, every price dated and sourced.';
const SITE_NAME = 'OpenVibe.Host';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@@ -65,7 +63,7 @@ function renderPage(o) {
const notice = o.notice ? `<p class="notice notice-${esc(o.notice.kind || 'info')}" role="status">${esc(o.notice.text)}</p>` : '';
// shell.page (openvibe-shared/shell) writes the document, the SEO head, the theme-loader, navbar.js and
// footer.js with the navbar init, the noscript nav and the SSR footer; the rest of the head and the body
-- // are this site's own. Only Host's own pages come through here, never a tenant site (http/tenant.js).
++ // are the catalog's own pages.
const canonical = o.indexable ? `${o.config.baseUrl}${o.path && o.path.split('?')[0] !== '/' ? o.path.split('?')[0] : '/'}` : undefined;
return shell.page({
name: SITE_NAME, service: 'host', lang: 'en',
ddiff --git a/server/render/pages.js b/server/render/pages.js
ddeleted file mode 100644
iindex 768a8b1..0000000
---- a/server/render/pages.js
++++ /dev/null
@@@ -1,185 +0,0 @@
--'use strict';
--/**
-- * Dashboard page bodies. Every value is escaped; every action is a POST form carrying the form
-- * token, so the dashboard works without JavaScript.
-- */
--const showcase = require('openvibe-shared/showcase');
--const { esc } = require('./layout');
--
--const bytes = (n) => (n >= 1073741824 ? `${(n / 1073741824).toFixed(2)} GiB` : n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);
--const when = (ms) => (ms ? `<time datetime="${new Date(ms).toISOString()}">${esc(new Date(ms).toISOString().replace('T', ' ').slice(0, 16))} UTC</time>` : '');
--const short = (id) => esc(String(id || '').slice(0, 12));
--
--function form(action, csrf, inner, { cls = '', confirm = null, enctype = null } = {}) {
-- return `<form method="post" action="${esc(action)}"${cls ? ` class="${cls}"` : ''}${enctype ? ` enctype="${enctype}"` : ''}>`
-- + `<input type="hidden" name="csrf" value="${esc(csrf)}">${inner}`
-- + `${confirm ? `<label class="confirm"><input type="checkbox" name="confirm" value="yes" required> ${esc(confirm)}</label>` : ''}</form>`;
--}
--
--/** A limit as the front page prints it: bytes in binary units, 0 as "none" (the environment allows none). */
--const limitValue = (n, unit) => (n === 0 ? 'none' : unit === 'bytes' ? bytes(n) : Number(n).toLocaleString('en-US'));
--
--/**
-- * The signed-out front page: the public page of openvibe.host, built with openvibe-shared/showcase. The limits table
-- * is limitsOf(config), the same object /limits.json serves, so the page never promises more than Host enforces.
-- * Until public hosting is launched (config.publicHosting) the page says so and invites no one to publish.
-- */
--function signedOut({ limits, limitsUrl, launched = false } = {}) {
-- return showcase.hero(launched ? {
-- eyebrow: 'OpenVibe.Host · alpha',
-- title: 'Publish a static site.', accent: 'Roll back in one step.',
-- lede: 'Upload a folder or a .tar.gz of HTML, CSS, JavaScript, images and fonts. Every deploy is kept as an immutable, content-addressed artifact; the active one is served at <site>.openvibe.host and on custom domains you verify.',
-- actions: [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2F', primary: true }, { label: 'See the limits', href: '#limits' }],
-- note: 'Alpha. Static files only: there is no build step and no server-side code, and nothing you upload is ever executed. Hosted bots, mods and apps are not available.',
-- } : {
-- eyebrow: 'OpenVibe.Host · not open yet',
-- title: 'Static sites, every deploy kept.', accent: 'Opening after review.',
-- lede: 'The hosting engine is built and tested: upload a folder or a .tar.gz, every deploy is kept as an immutable, content-addressed artifact, and the active one is served at <site>.openvibe.host and on custom domains you verify. It is not open to the public yet.',
-- actions: [{ label: 'What it will do', href: '#what-you-get', primary: true }, { label: 'See the limits', href: '#limits' }],
-- note: 'Public hosting opens after a launch review: abuse handling and a designated DMCA agent come first. Until then only OpenVibe staff can create projects.',
-- }) + showcase.features({
-- id: 'what-you-get',
-- title: launched ? 'What you get' : 'What it will do',
-- items: [
-- { icon: 'ov:upload', title: 'Upload from the browser or the API', text: 'A folder or an archive from the dashboard, or POST it to /api/v1. Archives are checked in memory and never extracted.' },
-- { icon: 'ov:history', title: 'Every deploy kept', text: 'Deploys never change once made. Activate an earlier one to roll back, from the dashboard or the API.' },
-- { icon: 'ov:dns', title: 'Your own domain', text: 'Add a domain and prove it with a DNS TXT record. It is served once verified and re-checked every day.' },
-- { icon: 'ov:account', title: 'Projects for a team', text: 'Owners add maintainers and members per project. Sandbox projects have smaller quotas and stay out of search.' },
-- ],
-- }) + showcase.steps({
-- title: launched ? 'Get a site online' : 'How it will work',
-- items: [
-- { title: 'Sign in', text: 'With your OpenVibe account.' },
-- { title: 'Create a project', text: 'Production, or a sandbox to try things out.' },
-- { title: 'Add a site', text: 'It gets its own name under openvibe.host.' },
-- { title: 'Upload a deploy', text: 'Activate it, and roll back to any earlier deploy whenever you need to.' },
-- ],
-- }) + (limits ? showcase.limits({
-- id: 'limits',
-- title: 'Limits',
-- lede: 'The defaults a new project starts with, by environment (the first row counts the projects one person owns).',
-- columns: ['Production', 'Sandbox'],
-- rows: limits.limits.map((l) => ({ label: l.label, values: [limitValue(l.production, l.unit), limitValue(l.sandbox, l.unit)] })),
-- source: limitsUrl,
-- }) : '');
--}
--
--function home({ projects, csrf, canCreate = true }) {
-- const rows = projects.map((p) => `<tr><td><a href="/projects/${esc(p.id)}">${esc(p.name)}</a></td><td>${esc(p.environment)}</td><td>${esc(p.role || '')}</td><td>${when(p.created_at)}</td></tr>`).join('');
-- return `<h1>Your projects</h1>
--${projects.length ? `<table><thead><tr><th>Project</th><th>Environment</th><th>Your role</th><th>Created</th></tr></thead><tbody>${rows}</tbody></table>` : '<p>You have no projects yet.</p>'}
--<h2>New project</h2>
--${!canCreate ? '<p>OpenVibe.Host 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>' : form('/projects', csrf, `<label>Name <input name="name" required maxlength="80"></label>
--<label>Environment <select name="environment"><option value="production">production</option><option value="sandbox">sandbox (smaller quotas, noindex, no custom domains)</option></select></label>
--<button type="submit">Create project</button>`)}`;
--}
--
--function quotaTable(quota, usage) {
-- const row = (label, used, limit, fmt = (x) => esc(x)) => `<tr><th scope="row">${label}</th><td>${fmt(used)}</td><td>${fmt(limit)}</td></tr>`;
-- return `<table class="quota"><thead><tr><th></th><th>Used</th><th>Limit</th></tr></thead><tbody>
--${row('Storage', usage.storageBytes, quota.storageBytes, bytes)}
--${row('Deploys in the last 24 h', usage.deploysLast24h, quota.deploysPerDay)}
--${row('Sites', usage.sites, quota.sites)}
--${row('Custom domains', usage.customDomains, quota.customDomains)}
--<tr><th scope="row">Files per deploy</th><td></td><td>${esc(quota.maxFiles)}</td></tr>
--<tr><th scope="row">Largest file</th><td></td><td>${bytes(quota.maxFileBytes)}</td></tr>
--</tbody></table>`;
--}
--
--function project({ project: p, role, sites, quota, usage, members, csrf, siteUrl }) {
-- const siteRows = sites.map((s) => `<tr><td><a href="/sites/${esc(s.id)}">${esc(s.name)}</a></td><td><a href="${esc(siteUrl(s))}" rel="noopener">${esc(siteUrl(s))}</a></td><td>${s.active_deploy_id ? `<code>${short(s.active_deploy_id)}</code>` : '<em>nothing published</em>'}</td></tr>`).join('');
-- const memberRows = members.map((m) => `<tr><td><code>${esc(m.principal)}</code></td><td>${esc(m.role)}</td><td>${role === 'owner' && m.principal !== p.owner_subject
-- ? form(`/projects/${p.id}/members/remove`, csrf, `<input type="hidden" name="principal" value="${esc(m.principal)}"><button type="submit" class="link">Remove</button>`, { cls: 'inline' }) : ''}</td></tr>`).join('');
-- const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';
-- return `<p class="crumbs"><a href="/">Projects</a> ›</p>
--<h1>${esc(p.name)}</h1>
--<p class="meta"><code>${esc(p.id)}</code> · ${esc(p.environment)} · your role: ${esc(role)}${p.network_project_id ? ` · Network project <code>${esc(p.network_project_id)}</code>` : ''}</p>
--<h2>Sites</h2>
--${sites.length ? `<table><thead><tr><th>Site</th><th>Address</th><th>Active deploy</th></tr></thead><tbody>${siteRows}</tbody></table>` : '<p>No sites yet.</p>'}
--${canMaintain ? form(`/projects/${p.id}/sites`, csrf, `<label>New site name <input name="name" required pattern="[a-z0-9-]{3,40}" maxlength="40" placeholder="my-site"></label><button type="submit">Create site</button>`) : ''}
--<h2>Quotas</h2>
--${quotaTable(quota, usage)}
--<h2>Members</h2>
--<table><thead><tr><th>Principal</th><th>Role</th><th></th></tr></thead><tbody>${memberRows}</tbody></table>
--${role === 'owner' ? form(`/projects/${p.id}/members`, csrf, `<label>Principal <input name="principal" required placeholder="usr_… or app:app_…"></label>
--<label>Role <select name="role"><option value="deployer">deployer</option><option value="maintainer">maintainer</option></select></label><button type="submit">Add or change</button>`) : ''}
--${role === 'owner' || role === 'staff' ? `<h2>Delete project</h2>${form(`/projects/${p.id}/delete`, csrf, '<button type="submit" class="danger">Delete this project, its sites and every deploy</button>', { confirm: 'I understand every site stops at once' })}` : ''}`;
--}
--
--function domainBlock(d, instructions, csrf) {
-- const status = d.status === 'verified' ? '<span class="ok">verified: served</span>' : `<span class="warn">${esc(d.status)}: not served</span>`;
-- let body = `<li><strong>${esc(d.hostname)}</strong> · ${esc(d.kind)} · ${status}${d.last_error ? ` · <span class="muted">${esc(d.last_error)}</span>` : ''}`;
-- if (instructions) {
-- body += `<details${d.status === 'verified' ? '' : ' open'}><summary>DNS records</summary><table class="dns"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>`
-- + `<tr><td>${esc(instructions.verification.type)}</td><td><code>${esc(instructions.verification.name)}</code></td><td><code>${esc(instructions.verification.value)}</code></td><td>${esc(instructions.verification.note)}</td></tr>`
-- + instructions.routing.map((r) => `<tr><td>${esc(r.type)}</td><td><code>${esc(r.name)}</code></td><td><code>${esc(r.value)}</code></td><td>${esc(r.note)}</td></tr>`).join('')
-- + `</tbody></table><p class="muted">${esc(instructions.tls)}</p></details>`;
-- if (d.status !== 'verified') body += form(`/domains/${d.id}/verify`, csrf, '<button type="submit">Check DNS now</button>', { cls: 'inline' });
-- body += form(`/domains/${d.id}/delete`, csrf, '<button type="submit" class="link danger">Remove</button>', { cls: 'inline' });
-- }
-- return `${body}</li>`;
--}
--
--function site({ site: s, project: p, role, deploys, activations, domains, siteConfig, csrf, url, now = Date.now() }) {
-- const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';
-- const canDeploy = role !== 'staff';
-- // The site's one live preview (plan T12 J4): served to project members on this dashboard until it expires.
-- const previewId = s.preview_deploy_id && Number(s.preview_expires_at) > now ? s.preview_deploy_id : null;
-- const previewHref = (id) => `/preview/${encodeURIComponent(id)}/`;
-- const rows = deploys.map((d) => {
-- const active = d.id === s.active_deploy_id;
-- const previewing = d.id === previewId;
-- const actions = [];
-- if (previewing && d.state === 'ready') actions.push(`<a href="${esc(previewHref(d.id))}" target="_blank" rel="noopener">Open preview</a>`);
-- if (canDeploy && d.state === 'ready' && !active) actions.push(form(`/deploys/${d.id}/activate`, csrf, `<input type="hidden" name="expected_active" value="${esc(s.active_deploy_id || '')}"><button type="submit">Activate</button>`, { cls: 'inline' }));
-- if (canMaintain && !active) actions.push(form(`/deploys/${d.id}/delete`, csrf, '<button type="submit" class="link danger">Delete</button>', { cls: 'inline' }));
-- return `<tr${active ? ' class="active"' : ''}><td><a href="/deploys/${esc(d.id)}"><code>${short(d.id)}</code></a>${active ? ' <strong>active</strong>' : ''}${previewing ? ' <strong class="preview">preview</strong>' : ''}</td><td>${esc(d.state)}${d.failure_code ? ` (<code>${esc(d.failure_code)}</code>)` : ''}</td><td>${esc(d.file_count)}</td><td>${bytes(d.total_bytes)}</td><td>${when(d.created_at)}</td><td>${actions.join(' ')}</td></tr>`;
-- }).join('');
-- const history = activations.slice(0, 10).map((a) => `<li>${when(a.created_at)} ${esc(a.kind)} → <code>${short(a.deploy_id)}</code>${a.previous_deploy_id ? ` (was <code>${short(a.previous_deploy_id)}</code>)` : ''} by <code>${esc(a.actor)}</code></li>`).join('');
-- return `<p class="crumbs"><a href="/">Projects</a> › <a href="/projects/${esc(p.id)}">${esc(p.name)}</a> ›</p>
--<h1>${esc(s.name)}</h1>
--<p class="meta"><a href="${esc(url)}" rel="noopener">${esc(url)}</a> · <code>${esc(s.id)}</code></p>
--${canDeploy ? `<h2>Upload a deploy</h2>
--<p class="muted">Static files only: HTML, CSS, JavaScript, JSON, images, fonts, audio, video, PDF, WebAssembly. No build runs and nothing is executed. Hidden files (except <code>.well-known/</code>), links and server-side code are refused.</p>
--${form(`/sites/${s.id}/deploys`, csrf, `<fieldset><legend>A folder</legend><input type="file" name="files" multiple webkitdirectory><input type="hidden" name="strip" value="folder"></fieldset>
--<fieldset><legend>…or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar"></fieldset>
--<label>Deploy from subfolder (optional) <input name="root" placeholder="dist"></label>
--<fieldset class="choice"><legend>When it is ready</legend>
--<label class="confirm"><input type="radio" name="mode" value="activate" checked> Activate it</label>
--<label class="confirm"><input type="radio" name="mode" value="ready"> Keep it ready (activate later)</label>
--<label class="confirm"><input type="radio" name="mode" value="preview"> Preview only: a private address for members of this project, for one hour</label>
--</fieldset>
--<button type="submit">Upload</button>`, { enctype: 'multipart/form-data' })}` : ''}
--<h2>Deploys</h2>
--${previewId ? `<p class="notice">A preview of <code>${short(previewId)}</code> is open to members of this project at <a href="${esc(previewHref(previewId))}" target="_blank" rel="noopener">${esc(previewHref(previewId))}</a> until ${when(Number(s.preview_expires_at))}. It is never indexed, and activating or rolling back ends it.</p>` : ''}
--${deploys.length ? `<table><thead><tr><th>Deploy</th><th>State</th><th>Files</th><th>Size</th><th>Uploaded</th><th></th></tr></thead><tbody>${rows}</tbody></table>` : '<p>No deploys yet.</p>'}
--${canDeploy && s.active_deploy_id ? form(`/sites/${s.id}/rollback`, csrf, `<input type="hidden" name="expected_active" value="${esc(s.active_deploy_id)}"><button type="submit">Roll back to the previous deploy</button>`) : ''}
--${history ? `<h3>Activation history</h3><ul class="history">${history}</ul>` : ''}
--<h2>Domains</h2>
--<ul class="domains">${domains.map((d) => domainBlock(d.domain, d.instructions, csrf)).join('')}</ul>
--${canMaintain && p.environment !== 'sandbox' ? form(`/sites/${s.id}/domains`, csrf, '<label>Custom domain <input name="hostname" required placeholder="www.example.org"></label><button type="submit">Add domain</button>') : ''}
--${canMaintain ? `<h2>Per-site configuration</h2>
--<p class="muted">Headers, redirects and an SPA fallback, applied to every response of this site. Platform headers (Content-Security-Policy, Strict-Transport-Security, Set-Cookie, X-Forwarded-*, caching) cannot be set here, and a redirect target must be a local path.</p>
--${form(`/sites/${s.id}/config`, csrf, `<label>Headers (one per line: <code>Name: value</code>)<textarea name="headers" rows="4" spellcheck="false" placeholder="X-Frame-Options: SAMEORIGIN">${esc(siteConfig.headersText)}</textarea></label>
--<label>Redirects (one per line: <code>from -&gt; to [status]</code>)<textarea name="redirects" rows="4" spellcheck="false" placeholder="/old -&gt; /new 301">${esc(siteConfig.redirectsText)}</textarea></label>
--<label class="confirm"><input type="checkbox" name="spa" value="1"${siteConfig.spa ? ' checked' : ''}> Serve index.html for extensionless paths (single-page app)</label>
--<button type="submit">Save configuration</button>`)}` : ''}
--${canMaintain ? `<h2>Delete site</h2>${form(`/sites/${s.id}/delete`, csrf, '<button type="submit" class="danger">Delete this site</button>', { confirm: `${s.name} stops being served at once` })}` : ''}`;
--}
--
--function deploy({ deploy: d, site: s, project: p, files, log, active }) {
-- const logLines = log.map((l) => `<li class="log-${esc(l.level)}"><span class="level">${esc(l.level)}</span> ${esc(l.message)}</li>`).join('');
-- const fileRows = files.slice(0, 2000).map((f) => `<tr><td><code>${esc(f.path)}</code></td><td>${esc(f.content_type)}</td><td>${bytes(f.size)}</td><td><code title="${esc(f.sha256)}">${esc(f.sha256.slice(0, 12))}</code></td></tr>`).join('');
-- return `<p class="crumbs"><a href="/">Projects</a> › <a href="/projects/${esc(p.id)}">${esc(p.name)}</a> › <a href="/sites/${esc(s.id)}">${esc(s.name)}</a> ›</p>
--<h1>Deploy <code>${short(d.id)}</code></h1>
--<p class="meta">${esc(d.state)}${active ? ' · <strong>active</strong>' : ''} · ${esc(d.file_count)} files · ${bytes(d.total_bytes)} · ${when(d.created_at)} · by <code>${esc(d.created_by)}</code>${d.manifest_sha256 ? ` · manifest <code>${esc(d.manifest_sha256.slice(0, 16))}</code>` : ''}</p>
--<h2>Upload log</h2>
--<ul class="log">${logLines}</ul>
--${files.length ? `<h2>Files</h2><table><thead><tr><th>Path</th><th>Type</th><th>Size</th><th>sha256</th></tr></thead><tbody>${fileRows}</tbody></table>${files.length > 2000 ? `<p>…and ${files.length - 2000} more.</p>` : ''}` : ''}`;
--}
--
--function errorPage({ status, message }) {
-- return `<h1>${status === 404 ? 'Not found' : status === 403 ? 'Not allowed' : status === 401 ? 'Sign in first' : 'Something went wrong'}</h1><p>${esc(message)}</p><p><a href="/">Back to your projects</a></p>`;
--}
--
--module.exports = { signedOut, home, project, site, deploy, errorPage, bytes };
ddiff --git a/server/storage.js b/server/storage.js
ddeleted file mode 100644
iindex ca162f2..0000000
---- a/server/storage.js
++++ /dev/null
@@@ -1,371 +0,0 @@
--'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 (HOST_OBJECT_STORE):
-- *
-- * local (default, and what an unset HOST_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 host_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 Host's Network service principal must be granted for audience openvibe.media, in Host's own
--// namespace (`host` / `host.*`). 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 host_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('HOST_OBJECT_STORE=media needs OV_MEDIA_URL (the internal OpenVibe.Media base URL)');
-- if (!media.clientSecret) throw new Error('HOST_OBJECT_STORE=media needs OV_OAUTH_CLIENT_SECRET: Host 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 || 'host',
-- 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. host_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 `HOST_OBJECT_STORE=media` switch); without it the local store
-- * behaves exactly as before. opts.isReferenced is the Media store's host_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
iindex 27e26c8..a7de8ad 100644
---- a/server/worker.js
++++ b/server/worker.js
@@@ -1,70 +1,17 @@
'use strict';
--/**
-- * Background work in the Host process (a few timers; nothing here executes tenant content):
-- *
-- * domain checks every HOST_DOMAIN_RECHECK_MS: pending custom domains are checked again
-- * (they fail after HOST_DOMAIN_PENDING_DAYS); verified ones are re-checked daily
-- * and lapse when their TXT record has been gone for HOST_DOMAIN_LAPSE_DAYS
-- * object sweep hourly: objects on disk that no deploy row references (an upload that failed
-- * after writing objects) and interrupted temp files are removed after an hour
-- * outbox prune daily: sent events older than seven days
-- */
--const fs = require('fs');
--function createWorker({ config, store, domains, blobs, outbox, catalogIngest, catalogSearch, log = console }) {
++/** Background work for catalog ingestion, search indexing and the events outbox. */
++function createWorker({ config, outbox, catalogIngest, catalogSearch, log = console }) {
const timers = [];
-- let checking = false;
--
-- async function domainTick() {
-- if (checking) return null;
-- checking = true;
-- try {
-- const s = await domains.recheck();
-- if (s.verified) outbox.kick();
-- return s;
-- } catch (err) {
-- log.error('[Host] domain checks failed:', err.message);
-- return null;
-- } finally { checking = false; }
-- }
--
-- /** Remove objects no row references, once they are older than `graceMs`. */
-- async function sweepObjects({ graceMs = 3600 * 1000 } = {}) {
-- const known = store.db.prepare('SELECT 1 FROM host_blobs WHERE project_id = ? AND sha256 = ?');
-- let removed = 0;
-- for (const o of blobs.list()) {
-- if (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('[Host] 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('[Host] object sweep failed:', err.message); } });
every(24 * 3600 * 1000, async () => { try { await outbox.outbox.prune(); } catch (err) { log.warn('[Host] outbox prune failed:', err.message); } });
-- setTimeout(domainTick, 5000).unref();
-- // The catalog in OpenVibe.Search: a minute after boot, hourly, and after each pull (only changes are sent).
-- const searchSweep = async () => { if (!catalogSearch) return; try { await catalogSearch.sweep(); } catch (err) { log.warn('[Host] catalog search sweep failed:', err.message); } };
-- if (catalogSearch) { setTimeout(searchSweep, 60_000).unref(); every(3600 * 1000, searchSweep); }
-- if (catalogIngest && catalogIngest.enabled) {
++ const searchSweep = async () => { try { await catalogSearch.sweep(); } catch (err) { log.warn('[Host] catalog search sweep failed:', err.message); } };
++ setTimeout(searchSweep, 60_000).unref();
++ every(3600 * 1000, searchSweep);
++ if (catalogIngest.enabled) {
const pull = async () => { try { await catalogIngest.pull(); } catch (err) { log.warn('[Host] catalog pull failed:', err.message); } await searchSweep(); };
every(5 * 60_000, pull);
setTimeout(pull, 5000).unref();
@@@ -73,5 +20,4 @@ function createWorker({ config, store, domains, blobs, outbox, catalogIngest, ca
stop() { for (const t of timers) clearInterval(t); timers.length = 0; },
};
}
--
module.exports = { createWorker };
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex 997cada..a196487 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -1,7 +1,7 @@
'use strict';
// Per-actor limits on Host's writes (server/http/actor-limits.js; roadmap WS-R task 4): a signed-in person is counted
// by subject and a service by its principal; past the limit 429 rate_limited with Retry-After before the route runs,
--// while another actor passes; deploy uploads have their own tighter number; reads and anonymous callers are never
++// while another actor passes; catalog writes share one bucket; reads and anonymous callers are never
// counted; the window reopens.
// node test/actor-limits.test.js
const assert = require('assert');
@@@ -29,21 +29,21 @@ console.warn = () => {};
return { status: r.status, retry: r.headers.get('retry-after'), body: await r.json().catch(() => ({})) };
};
try {
-- for (let i = 0; i < 5; i++) assert.strictEqual((await call('POST', '/sites/s1/rollback', 'usr_a')).status, 200);
++ for (let i = 0; i < 5; i++) assert.strictEqual((await call('POST', '/catalog/manage/providers', 'usr_a')).status, 200);
const before = ran;
-- const r = await call('POST', '/sites/s1/rollback', 'usr_a');
++ const r = await call('POST', '/catalog/manage/providers', 'usr_a');
assert.deepStrictEqual([r.status, r.body.code, Number(r.retry) > 0, ran], [429, 'rate_limited', true, before]);
-- assert.strictEqual((await call('POST', '/sites/s1/rollback', 'svc:tools')).status, 200, 'a service is its own actor');
++ assert.strictEqual((await call('POST', '/catalog/manage/providers', 'svc:tools')).status, 200, 'a service is its own actor');
for (let i = 0; i < 10; i++) {
-- assert.strictEqual((await call('GET', '/projects', 'usr_a')).status, 200, 'reads are never counted');
-- assert.strictEqual((await call('POST', '/projects')).status, 200, 'anonymous callers keep the address limit');
++ assert.strictEqual((await call('GET', '/catalog/providers', 'usr_a')).status, 200, 'reads are never counted');
++ assert.strictEqual((await call('POST', '/catalog/providers')).status, 200, 'anonymous callers keep the address limit');
}
t += 60 * 1000;
const ups = [];
-- for (let i = 0; i < 6; i++) ups.push((await call('POST', '/sites/s1/deploys', 'usr_b')).status);
++ for (let i = 0; i < 6; i++) ups.push((await call('POST', '/catalog/manage/providers', 'usr_b')).status);
assert.deepStrictEqual(ups.slice(0, 5), [200, 200, 200, 200, 200]);
t += 60 * 1000;
-- assert.strictEqual((await call('POST', '/sites/s1/rollback', 'usr_a')).status, 200, 'the next minute');
++ assert.strictEqual((await call('POST', '/catalog/manage/providers', 'usr_a')).status, 200, 'the next minute');
} finally {
server.close();
}
ddiff --git a/test/archive.test.js b/test/archive.test.js
ddeleted file mode 100644
iindex 35c790c..0000000
---- a/test/archive.test.js
++++ /dev/null
@@@ -1,90 +0,0 @@
--'use strict';
--/**
-- * ovhost archive: retired data files are encrypted and copied off-host
-- * under <prefix>-archive/, checked, listed and restored, and never deleted locally. Refused: open files, symlinks, paths outside the allowed roots, relative paths.
-- * Nothing leaves the process (an in-memory S3).
-- */
--const assert = require('assert');
--const crypto = require('crypto');
--const { scenario, test, runTests, SECRET } = require('./helpers');
--const { main } = require('../lib/cli');
--const { mockS3 } = require('./s3-mock');
--const { refusal } = require('../lib/archive');
--
--const KEY_HEX = crypto.createHash('sha256').update('archive test key').digest('hex');
--const S3_SECRET = `${SECRET}-s3`;
--
--function archiveHost() {
-- const host = scenario();
-- host.put('/etc/openvibe/backup.env', [
-- 'BACKUP_S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com',
-- 'BACKUP_S3_BUCKET=openvibe-backups-test',
-- 'BACKUP_S3_PREFIX=openvibe-backups',
-- 'BACKUP_S3_KEY_ID=004keyid0000000000000001',
-- `BACKUP_S3_SECRET="${S3_SECRET}"`,
-- 'BACKUP_ENCRYPTION_KEY_FILE=/etc/openvibe/backup.key',
-- '',
-- ].join('\n'), { mode: 0o600, owner: 'root' });
-- host.put('/etc/openvibe/backup.key', `${KEY_HEX}\n`, { mode: 0o600, owner: 'root' });
-- host.put('/opt/backups/old/hobostreamer.tar', Buffer.from(`old site ${'x'.repeat(9000)}`), { owner: 'root' });
-- host.put('/var/lib/openvibe-chat/live-snap-a.tar', Buffer.from(`snapshot ${'y'.repeat(3000)}`), { owner: 'root' });
-- host.put('/var/lib/openvibe-chat/in-use.tar', 'open', { owner: 'root' });
-- host.put('/etc/passwd-copy.tar', 'nope', { owner: 'root' });
-- host.openFiles = new Set(['/var/lib/openvibe-chat/in-use.tar']);
-- host.s3 = mockS3();
-- host.cli = async (...argv) => {
-- const lines = [];
-- const code = await main(argv, { exec: host.exec, out: (s) => lines.push(s), env: {}, s3: () => host.s3 });
-- return { code, out: lines.join('\n') };
-- };
-- return host;
--}
--
--const noSecrets = (text) => { for (const s of [S3_SECRET, SECRET, KEY_HEX]) assert.ok(!String(text).includes(s), 'a secret value leaked'); };
--
--runTests([
-- test('push encrypts, uploads under the archive prefix, checks, records, and deletes nothing', async () => {
-- const host = archiveHost();
-- const r = await host.cli('archive', 'push', '/opt/backups/old/hobostreamer.tar', '/var/lib/openvibe-chat/live-snap-a.tar', '--note', 'H14 first pass');
-- assert.strictEqual(r.code, 0, r.out);
-- noSecrets(r.out);
-- assert.match(r.out, /ARCHIVE ok: 2 file\(s\) at s3:\/\/openvibe-backups-test\/openvibe-backups-archive\/.+\/\d{8}-\d{6}\/.*Nothing was deleted/);
-- const keys = [...host.s3.objects.keys()];
-- assert.ok(keys.every((k) => k.startsWith('openvibe-backups-archive/')), 'outside the backup runs prefix (retention never prunes it)');
-- const obj = keys.find((k) => k.endsWith('opt__backups__old__hobostreamer.tar.ovbk'));
-- assert.ok(obj, keys.join(', '));
-- assert.ok(!host.s3.objects.get(obj).includes(Buffer.from('old site')), 'the bytes are encrypted');
-- assert.ok(host.read('/opt/backups/old/hobostreamer.tar') !== null && host.read('/var/lib/openvibe-chat/live-snap-a.tar') !== null, 'the local files stay');
-- const manifestKey = keys.find((k) => k.endsWith('/manifest.json'));
-- const m = JSON.parse(host.s3.objects.get(manifestKey).toString('utf8'));
-- assert.deepStrictEqual(m.files.map((f) => f.path), ['/opt/backups/old/hobostreamer.tar', '/var/lib/openvibe-chat/live-snap-a.tar']);
-- assert.strictEqual(m.note, 'H14 first pass');
-- assert.ok(m.hmac && m.files.every((f) => /^[0-9a-f]{64}$/.test(f.plainSha256)));
-- const rec = [...host.files.keys()].find((k) => k.startsWith('/var/lib/openvibe-host/archives/'));
-- assert.ok(rec, 'a local record');
-- noSecrets(host.read(rec));
--
-- const list = await host.cli('archive', 'list');
-- assert.strictEqual(list.code, 0);
-- assert.match(list.out, /\d{8}-\d{6}\s+2 file\(s\).*H14 first pass/);
--
-- const st = m.stamp;
-- const back = await host.cli('archive', 'restore', st, '/opt/backups/old/hobostreamer.tar', '--out', '/var/lib/openvibe-restore/h14');
-- assert.strictEqual(back.code, 0, back.out);
-- assert.strictEqual(host.read('/var/lib/openvibe-restore/h14/hobostreamer.tar'), host.read('/opt/backups/old/hobostreamer.tar'), 'restored byte for byte');
-- const again = await host.cli('archive', 'restore', st, '/opt/backups/old/hobostreamer.tar', '--out', '/var/lib/openvibe-restore/h14');
-- assert.notStrictEqual(again.code, 0, 'restore never overwrites');
-- }),
--
-- test('refusals: open files, symlinks, outside roots and relative paths', async () => {
-- const host = archiveHost();
-- await host.exec.symlink('/opt/backups/old/hobostreamer.tar', '/opt/backups/old/link.tar');
-- for (const [file, why] of [['/opt/backups/old/link.tar', /not a regular file/], ['/var/lib/openvibe-chat/in-use.tar', /open by a running process/], ['/etc/passwd-copy.tar', /outside/], ['relative.tar', /absolute/], ['/opt/backups/../etc/x.tar', /normalised/], ['/opt/backups/none.tar', /no such file/]]) {
-- const r = await host.cli('archive', 'push', '/opt/backups/old/hobostreamer.tar', file);
-- assert.strictEqual(r.code, 1, `${file}: ${r.out}`);
-- assert.match(r.out, why, file);
-- assert.strictEqual(host.s3.objects.size, 0);
-- }
-- }),
--
--]);
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
iindex 8f453b3..70316d4 100644
---- a/test/asset-cache.test.js
++++ b/test/asset-cache.test.js
@@@ -8,7 +8,7 @@
const assert = require('assert');
const cache = require('openvibe-shared/cache-policy');
const { assetVersion } = require('../server/render/layout');
--const { boot, check, done, DASHBOARD } = require('./stageb/boot');
++const { boot, check, done, DASHBOARD } = require('./helpers/boot');
const ASSET = 'css/host.css';
ddiff --git a/test/catalog-discussion.test.js b/test/catalog-discussion.test.js
iindex e02b5f8..5d1d64b 100644
---- a/test/catalog-discussion.test.js
++++ b/test/catalog-discussion.test.js
@@@ -1,6 +1,6 @@
'use strict';
const assert = require('assert');
--const { boot, check, done } = require('./stageb/boot');
++const { boot, check, done } = require('./helpers/boot');
(async () => {
const calls = [];
ddiff --git a/test/catalog-ingest.test.js b/test/catalog-ingest.test.js
iindex 4ea50d8..d392102 100644
---- a/test/catalog-ingest.test.js
++++ b/test/catalog-ingest.test.js
@@@ -7,7 +7,7 @@ const { createCatalog } = require('../server/catalog/store');
const { createCatalogIngest } = require('../server/catalog/ingest');
const { mapItem } = require('../server/catalog/mapping');
const accountData = require('../server/catalog/account-data');
--const { check, done } = require('./stageb/boot');
++const { check, done } = require('./helpers/boot');
const at = '2026-10-11T12:00:00Z';
// The shape the Sources `api` adapter stores: the plan id as identity, Linode's label as title, the mapped extras flat in fields.
const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repeat(26)}`, source_key: key, category: 'hosting', kind: 'plan', identity: id, canonical_url: null, title, summary: null, authors: [], published_at: null, source_updated_at: null, fields, revision: 1, provenance: { retrieved_at: time, first_seen_at: at, content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'api@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null }, removed: null });
ddiff --git a/test/catalog-manage.test.js b/test/catalog-manage.test.js
iindex ae98c89..a114345 100644
---- a/test/catalog-manage.test.js
++++ b/test/catalog-manage.test.js
@@@ -1,6 +1,6 @@
'use strict';
const assert = require('assert');
--const { boot, check, done } = require('./stageb/boot');
++const { boot, check, done } = require('./helpers/boot');
(async () => {
const t = await boot();
const staff = t.user('catalog-editor', { role: 'admin' });
ddiff --git a/test/catalog-public.test.js b/test/catalog-public.test.js
iindex 5186dea..262d31f 100644
---- a/test/catalog-public.test.js
++++ b/test/catalog-public.test.js
@@@ -1,6 +1,6 @@
'use strict';
const assert = require('assert');
--const { boot, check, done } = require('./stageb/boot');
++const { boot, check, done } = require('./helpers/boot');
(async () => {
const t = await boot();
const staff = t.user('catalog-public-editor', { role: 'admin' });
ddiff --git a/test/catalog-search.test.js b/test/catalog-search.test.js
iindex 8d0b242..1ad9171 100644
---- a/test/catalog-search.test.js
++++ b/test/catalog-search.test.js
@@@ -6,7 +6,7 @@
*/
const assert = require('assert');
const contracts = require('openvibe-contracts');
--const { boot, check, done } = require('./stageb/boot');
++const { boot, check, done } = require('./helpers/boot');
(async () => {
const t = await boot();
ddiff --git a/test/cli-inventory.test.js b/test/cli-inventory.test.js
iindex 13f841c..291d468 100644
---- a/test/cli-inventory.test.js
++++ b/test/cli-inventory.test.js
@@@ -47,7 +47,8 @@ runTests([
const m = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/manifests/services/host.json'), 'utf8'));
contracts.assertValid('registry.service-manifest', m);
assert.strictEqual(m.id, 'host');
-- assert.deepStrictEqual([...m.capabilities].sort(), ['host.catalog.manage', 'host.deploy.create', 'host.domain.manage', 'host.resource.read', 'host.site.config', 'host.site.manage'], 'Stage A has no API; the Stage B service enforces these six (host.resource.read: the resource index, openvibe-contracts 0.107.0; host.catalog.manage: the hosting catalog, 0.139.0)');
++ assert.ok(m.capabilities.includes('host.catalog.manage'), 'the released manifest includes catalog management');
++ assert.deepStrictEqual(require('../STATUS.json').capabilities, ['host.catalog.manage'], 'Host now implements only catalog management');
}),
test('as root, an inventory that is not root-owned or is writable by others is refused', async () => {
ddiff --git a/test/engine-moved.test.js b/test/engine-moved.test.js
nnew file mode 100644
iindex 0000000..ebeedc2
---- /dev/null
++++ b/test/engine-moved.test.js
@@@ -0,0 +1,44 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { renderPage } = require('../server/render/layout');
++const catalogRender = require('../server/render/catalog');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('old pages answer 410 with the Space link', async () => {
++ for (const path of ['/hosting', '/projects', '/sites/site_123', '/deploys/dpl_123', '/preview/dpl_123']) {
++ const r = await t.api('GET', path);
++ assert.strictEqual(r.status, 410, path);
++ assert.match(r.text, /https:\/\/openvibe\.space/);
++ assert.match(r.text, /Static hosting moved/);
++ }
++ const tenant = await t.get('old.openvibe.host', '/index.html');
++ assert.strictEqual(tenant.status, 410);
++ assert.match(tenant.text, /https:\/\/openvibe\.space/);
++ });
++ await check('old APIs answer 410 problem JSON', async () => {
++ for (const path of ['/api/v1/projects', '/api/v1/sites/site_123/deploys', '/api/v1/domains/dom_123/verify', '/api/v1/resources']) {
++ const r = await t.api('POST', path);
++ assert.strictEqual(r.status, 410, path);
++ assert.strictEqual(r.json().code, 'host.moved_to_space');
++ assert.strictEqual(r.json().url, 'https://openvibe.space');
++ }
++ });
++ await check('navigation has no static hosting entry', async () => {
++ const html = renderPage({ title: 'Catalog', body: '<p>Catalog</p>', config: t.config, path: '/' });
++ assert.doesNotMatch(html, /Static hosting/);
++ assert.match(html, /Compare/);
++ });
++ await check('front page has one Space link', async () => {
++ const body = catalogRender.front({ plans: [], providers: [], promos: [], stats: { lastChecked: null } });
++ assert.match(body, /Want to host a site with OpenVibe/);
++ assert.strictEqual((body.match(/https:\/\/openvibe\.space/g) || []).length, 1);
++ const r = await t.api('GET', '/');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /https:\/\/openvibe\.space/);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..a909cf6
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -0,0 +1,100 @@
++'use strict';
++/** Boot the catalog with a temporary PGlite database and mock Network. */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const { startNetwork } = require('./network');
++const { cookieNames } = require('../../server/auth/sso');
++
++function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
++ let t = start;
++ return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };
++}
++
++const SECRET = 'shh-host-secret-value';
++const DASHBOARD = 'openvibe.host';
++
++async function boot(opts = {}) {
++ const network = await startNetwork();
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-host-test-'));
++ const clock = opts.clock || makeClock();
++ const env = {
++ NODE_ENV: 'test', PORT: '0', BASE_URL: `https://${DASHBOARD}`, TRUST_PROXY: '1',
++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
++ OV_OAUTH_CLIENT_ID: 'host', OV_OAUTH_CLIENT_SECRET: SECRET, COOKIE_SECURE: 'false',
++ HOST_FORM_SECRET: 'test-form-secret', HOST_WORKER: 'off',
++ ...(opts.env || {}),
++ };
++ const configLib = require('../../server/config');
++ const { createApp } = require('../../server/app');
++ const { createStore } = require('../../server/db');
++ // One database per boot; a restart keeps it.
++ const testdb = await require('./db').testDb();
++ const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
++
++ let server = null;
++ let built = null;
++ const t = { network, clock, dir, env, SECRET };
++
++ async function start() {
++ const config = configLib.load(env);
++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, fetchImpl: opts.fetchImpl });
++ server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ t.port = server.address().port;
++ t.app = built.app;
++ t.ctx = built.ctx;
++ t.config = config;
++ }
++ async function stop() {
++ if (server) { server.closeAllConnections(); await new Promise((r) => server.close(r)); }
++ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.stopMetrics(); }
++ 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, `${cookieNames(t.config).session}=${network.userToken(o.session)}`].filter(Boolean).join('; ');
++ let body = o.body;
++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
++ if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
++ if (body != null && headers['content-length'] == null) headers['content-length'] = Buffer.byteLength(body);
++ const req = http.request({ host: '127.0.0.1', port: t.port, method: o.method || 'GET', path: o.path, headers, setHost: o.host === undefined }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => {
++ const buf = Buffer.concat(chunks);
++ const text = buf.toString('utf8');
++ resolve({ status: res.statusCode, headers: res.headers, body: buf, text, json() { return JSON.parse(text); } });
++ });
++ });
++ req.on('error', reject);
++ if (body != null) req.write(body);
++ req.end();
++ });
++ }
++
++ t.request = request;
++ t.api = (method, p, o = {}) => request({ ...o, method, host: DASHBOARD, path: p });
++ t.get = (host, p, o = {}) => request({ ...o, method: o.method || 'GET', host, path: p });
++
++ t.user = (name, extra) => network.addUser(name, extra);
++ 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/helpers/network.js b/test/helpers/network.js
nnew file mode 100644
iindex 0000000..853917e
---- /dev/null
++++ b/test/helpers/network.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-host-secret-value') return json(401, { error: 'invalid_client' });
++ if (body.grant_type === 'client_credentials') {
++ let scope = body.scope;
++ if (scope && typeof scope === 'object') scope = Object.values(scope).join(' ');
++ const cap = String(scope || '').split(/\s+/).filter(Boolean);
++ return json(200, { access_token: sign({ sub: `svc:${body.client_id}`, actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });
++ }
++ // 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.host'], 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.host'], 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.host'], 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/host-abuse.test.js b/test/host-abuse.test.js
ddeleted file mode 100644
iindex 1be8239..0000000
---- a/test/host-abuse.test.js
++++ /dev/null
@@@ -1,195 +0,0 @@
--'use strict';
--/**
-- * Abuse controls (docs/threat-review.md): staff takedowns stop serving at once and keep the content
-- * for review; members cannot publish, switch or delete around a takedown; certificate-validation
-- * paths cannot be published; uploads are validated a few at a time, so concurrent uploads cannot
-- * exhaust memory; fingerprinted assets leave a shared CDN within the hour.
-- */
--const assert = require('assert');
--const fs = require('fs');
--const http = require('http');
--const path = require('path');
--const { boot, check, done } = require('./stageb/boot');
--const { site: siteTar } = require('./stageb/tar');
--
--(async () => {
-- const t = await boot({ env: { HOST_MAX_CONCURRENT_UPLOADS: '1', HOST_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-host.www.alpha-example.org", [domain.instructions.verification.value]);
-- assert.strictEqual((await t.api('POST', `/api/v1/domains/${domain.id}/verify`, { as: alice })).json().domain.status, 'verified');
-- const objectsOf = (p) => { const out = []; const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) walk(path.join(d, e.name)); else out.push(e.name); } }; walk(path.join(t.dir, 'objects', 'projects', p)); return out.sort(); };
--
-- await check('fingerprinted assets: immutable for browsers, at most an hour at a shared CDN', async () => {
-- const r = await t.get('alpha.openvibe.host', '/app.3f2a9c1b.js');
-- await t.api('POST', `/api/v1/deploys/${d1.id}/activate`, { as: alice });
-- const r1 = await t.get('alpha.openvibe.host', '/app.3f2a9c1b.js');
-- assert.strictEqual(r.status, 404, 'not in the active deploy yet');
-- assert.strictEqual(r1.headers['cache-control'], 'public, max-age=31536000, immutable');
-- assert.strictEqual(r1.headers['cdn-cache-control'], 'public, max-age=3600');
-- assert.strictEqual((await t.get('alpha.openvibe.host', '/')).headers['cdn-cache-control'], undefined);
-- await t.api('POST', `/api/v1/deploys/${d2.id}/activate`, { as: alice });
-- });
--
-- await check('only staff take a site down; members and strangers cannot', async () => {
-- const mine = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: alice, json: { reason: 'self' } });
-- assert.strictEqual(mine.status, 403);
-- assert.strictEqual(mine.json().code, 'auth.staff_only');
-- const stranger = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: t.user('mallory'), json: { reason: 'x' } });
-- assert.strictEqual(stranger.status, 404, 'a stranger learns nothing');
-- const noReason = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: {} });
-- assert.strictEqual(noReason.status, 422);
-- });
--
-- const objectsBefore = objectsOf(pa.id);
-- await check('a takedown stops the default and custom domains at once (451, no tenant bytes) and keeps everything', async () => {
-- const r = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'phishing report #42' } });
-- assert.strictEqual(r.status, 201, r.text);
-- assert.strictEqual(r.json().takedown.reason, 'phishing report #42');
-- for (const host of ['alpha.openvibe.host', 'www.alpha-example.org']) {
-- for (const p of ['/', '/index.html', '/app.3f2a9c1b.js', '/404.html', '/missing']) {
-- const g = await t.get(host, p);
-- assert.strictEqual(g.status, 451, `${host}${p}`);
-- assert.ok(!/ALPHA|js\(\)/.test(g.text), 'no tenant content');
-- assert.strictEqual(g.headers['cache-control'], 'no-store');
-- assert.strictEqual(g.headers.etag, undefined);
-- assert.strictEqual(g.headers['clear-site-data'], '"cache", "storage"', 'the visitor\'s browser drops what the site left behind');
-- }
-- }
-- assert.strictEqual((await t.get('other.openvibe.host', '/')).text, 'OTHER', 'a site-level takedown leaves the project\'s other sites up');
-- assert.deepStrictEqual(objectsOf(pa.id), objectsBefore, 'every object kept');
-- const files = await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: staff });
-- assert.strictEqual(files.json().deploy.files.length, 2, 'file rows kept for review');
-- assert.strictEqual((await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'again' } })).status, 409);
-- });
--
-- await check('members see the takedown and why, and cannot publish, switch or delete around it', async () => {
-- const s = await t.api('GET', `/api/v1/sites/${alpha.id}`, { as: alice });
-- assert.deepStrictEqual([s.json().site.takedown.scope, s.json().site.takedown.reason], ['site', 'phishing report #42']);
-- const up = await t.upload(alice, alpha.id, { 'index.html': 'EVADE' });
-- assert.strictEqual(up.status, 403);
-- assert.strictEqual(up.json().code, 'site.taken_down');
-- for (const [method, p, json] of [
-- ['POST', `/api/v1/deploys/${d1.id}/activate`, {}], ['POST', `/api/v1/sites/${alpha.id}/rollback`, {}],
-- ['DELETE', `/api/v1/deploys/${d1.id}`], ['DELETE', `/api/v1/sites/${alpha.id}`], ['DELETE', `/api/v1/projects/${pa.id}`],
-- ]) {
-- const r = await t.api(method, p, { as: alice, json });
-- assert.ok([403, 409].includes(r.status), `${method} ${p} → ${r.status} ${r.text}`);
-- assert.strictEqual(r.json().code, 'site.taken_down');
-- }
-- const page = await t.api('GET', `/sites/${alpha.id}`, { session: alice });
-- assert.match(page.text, /taken down by OpenVibe staff: phishing report #42/);
-- assert.deepStrictEqual(objectsOf(pa.id), objectsBefore);
-- });
--
-- await check('staff lift a takedown: the same content serves again; members only lift nothing', async () => {
-- assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: alice })).status, 403);
-- const r = await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { note: 'reviewed: false positive' } });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.strictEqual((await t.get('alpha.openvibe.host', '/')).text, 'ALPHA TWO');
-- assert.strictEqual((await t.get('www.alpha-example.org', '/')).text, 'ALPHA TWO');
-- assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff })).status, 404);
-- const rows = await t.ctx.store.db.prepare('SELECT target_kind, reason, (lifted_at IS NOT NULL)::int AS lifted, lift_note FROM host_takedowns').all();
-- assert.deepStrictEqual(rows, [{ target_kind: 'site', reason: 'phishing report #42', lifted: 1, lift_note: 'reviewed: false positive' }], 'the history is kept');
-- });
--
-- await check('a project takedown covers every site of the project; staff can still delete', async () => {
-- const r = await t.api('POST', `/api/v1/projects/${pa.id}/takedown`, { as: staff, json: { reason: 'spam network' } });
-- assert.strictEqual(r.status, 201, r.text);
-- assert.strictEqual((await t.get('alpha.openvibe.host', '/')).status, 451);
-- assert.strictEqual((await t.get('other.openvibe.host', '/')).status, 451);
-- const p = await t.api('GET', `/api/v1/projects/${pa.id}`, { as: alice });
-- assert.strictEqual(p.json().project.takedown.scope, 'project');
-- assert.strictEqual(p.json().sites[0].takedown.scope, 'project');
-- const list = await t.api('GET', '/api/v1/projects', { as: alice });
-- assert.strictEqual(list.json().projects[0].takedown.reason, 'spam network');
-- assert.strictEqual((await t.upload(alice, other.id, { 'index.html': 'x' })).status, 403);
-- const del = await t.api('DELETE', `/api/v1/sites/${other.id}`, { as: staff });
-- assert.strictEqual(del.status, 200, del.text);
-- assert.strictEqual((await t.get('other.openvibe.host', '/')).status, 404);
-- });
--
-- await check('certificate-validation paths are never published (no tenant can get a certificate for its host name)', async () => {
-- const pb = await t.project(alice, 'B');
-- const beta = await t.site(alice, pb.id, 'beta');
-- for (const p of ['.well-known/acme-challenge/token123', '.well-known/pki-validation/fileauth.txt', '.well-known/ACME-CHALLENGE/x']) {
-- const r = await t.upload(alice, beta.id, { 'index.html': 'B', [p]: 'proof' });
-- assert.strictEqual(r.status, 422, `${p} → ${r.status}`);
-- assert.ok(r.json().log.some((l) => /reserved/.test(l)), r.text);
-- }
-- await t.deploy(alice, beta.id, { 'index.html': 'B', '.well-known/security.txt': 'Contact: mailto:[email protected]' });
-- assert.strictEqual((await t.get('beta.openvibe.host', '/.well-known/security.txt')).status, 200, 'other .well-known files still publish');
-- assert.strictEqual((await t.get('beta.openvibe.host', '/.well-known/acme-challenge/token123')).status, 404);
-- });
--
-- await check('uploads are validated a few at a time: a second concurrent upload gets 503 + Retry-After and is not a failed deploy', async () => {
-- const pc = await t.project(alice, 'C');
-- const gamma = await t.site(alice, pc.id, 'gamma');
-- const body = siteTar({ 'index.html': 'GAMMA' });
-- const failedBefore = (await t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM host_deploys WHERE state = 'failed'").get()).n;
-- // The first upload sends its headers and half its body, then waits.
-- let firstResponse;
-- let finish;
-- const first = new Promise((resolve) => {
-- const req = http.request({ host: '127.0.0.1', port: t.port, method: 'POST', path: `/api/v1/sites/${gamma.id}/deploys?activate=1`, headers: { host: 'openvibe.host', authorization: `Bearer ${t.network.userToken(alice)}`, 'content-type': 'application/gzip', 'content-length': body.length } }, (res) => {
-- const chunks = [];
-- res.on('data', (c) => chunks.push(c));
-- res.on('end', () => { firstResponse = { status: res.statusCode, text: Buffer.concat(chunks).toString() }; resolve(); });
-- });
-- req.write(body.subarray(0, Math.floor(body.length / 2)));
-- finish = () => req.end(body.subarray(Math.floor(body.length / 2)));
-- });
-- await new Promise((r) => setTimeout(r, 150));
-- assert.strictEqual(t.ctx.uploadGate.inFlight, 1);
-- const second = await t.upload(alice, gamma.id, { 'index.html': 'SECOND' });
-- assert.strictEqual(second.status, 503, second.text);
-- assert.strictEqual(second.json().code, 'upload.busy');
-- assert.strictEqual(second.headers['retry-after'], '30');
-- finish();
-- await first;
-- assert.strictEqual(firstResponse.status, 201, firstResponse.text);
-- assert.strictEqual(t.ctx.uploadGate.inFlight, 0, 'the slot is released');
-- assert.strictEqual((await t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM host_deploys WHERE state = 'failed'").get()).n, failedBefore, 'a busy refusal is not the tenant\'s failed deploy');
-- const third = await t.upload(alice, gamma.id, { 'index.html': 'THIRD' });
-- assert.strictEqual(third.status, 201, third.text);
-- // A refused upload releases its slot too.
-- const bad = await t.upload(alice, gamma.id, { '../x.html': 'no' });
-- assert.strictEqual(bad.status, 422);
-- assert.strictEqual(t.ctx.uploadGate.inFlight, 0);
-- });
--
-- await check('the shared disk: below HOST_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.host_full');
-- assert.strictEqual((await t.get('delta.openvibe.host', '/')).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 HOST_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/host-contracts-events.test.js b/test/host-contracts-events.test.js
ddeleted file mode 100644
iindex 1ad2dd4..0000000
---- a/test/host-contracts-events.test.js
++++ /dev/null
@@@ -1,82 +0,0 @@
--'use strict';
--/**
-- * Host's capabilities and service manifest as openvibe-contracts released them match what the code
-- * enforces and emits; and the outbox relay really delivers Host's events to OpenVibe.Events with a
-- * Network service token.
-- */
--const assert = require('assert');
--const fs = require('fs');
--const path = require('path');
--const contracts = require('openvibe-contracts');
--const { boot, check, done } = require('./stageb/boot');
--const { listen } = require('./stageb/mocks');
--const { 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/host.json'), 'utf8'));
--
-- await check('every capability Host guards is released, active and owned by host', async () => {
-- for (const [i, c] of caps.entries()) {
-- assert.ok(c, `${ids[i]} is not in openvibe-contracts`);
-- assert.strictEqual(c.owner, 'host');
-- // Every capability Host guards is active (host.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 released service manifest declares every event the service emits', async () => {
-- assert.strictEqual(manifest.id, 'host');
-- 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 host.* events to OpenVibe.Events with a Network service token', async () => {
-- const received = [];
-- let network;
-- const events = await listen((req, raw, json) => {
-- if (req.url !== '/api/v1/events' || req.method !== 'POST') return json(404, {});
-- const token = String(req.headers.authorization || '').slice(7);
-- const v = contracts.serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.events' });
-- if (!v.ok || !v.claims.cap.includes('events.event.publish') || v.claims.sub !== 'svc:host') return json(403, { code: 'capability.denied' });
-- const body = JSON.parse(raw);
-- const list = body.events || [body];
-- for (const e of list) { const val = contracts.validate('events.event-envelope@1', e); if (!val.valid) return json(422, { errors: val.errors }); received.push(e); }
-- const results = list.map((e, i) => ({ event_id: e.event_id, seq: received.length - list.length + i + 1, duplicate: false }));
-- return json(200, body.events ? { results } : results[0]);
-- });
-- const t = await boot({ env: { EVENTS_URL: events.url } });
-- network = t.network;
-- const alice = t.user('alice');
-- const p = await t.project(alice, 'E');
-- const s = await t.site(alice, p.id, 'events-site');
-- await t.deploy(alice, s.id, { 'index.html': 'x' });
-- await t.upload(alice, s.id, { 'bad.php': 'x' });
-- await t.ctx.outbox.outbox.flush();
-- assert.deepStrictEqual(received.map((e) => e.event_type).sort(), ['host.deploy.activated', 'host.deploy.created', 'host.deploy.failed']);
-- assert.ok(received.every((e) => e.source === 'host'));
-- assert.strictEqual((await t.ctx.outbox.status()).pending, 0);
-- await t.close();
-- await events.close();
-- });
--
-- done();
--})();
ddiff --git a/test/host-dispatch-error.test.js b/test/host-dispatch-error.test.js
ddeleted file mode 100644
iindex b31ad28..0000000
---- a/test/host-dispatch-error.test.js
++++ /dev/null
@@@ -1,60 +0,0 @@
--'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.host', '/'), '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.host', '/'), '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.host', '/');
-- assert.strictEqual(r.status, 200);
-- assert.strictEqual(r.text, 'hello');
-- });
--
-- await t.close();
-- done();
--})();
ddiff --git a/test/host-domains.test.js b/test/host-domains.test.js
ddeleted file mode 100644
iindex c087436..0000000
---- a/test/host-domains.test.js
++++ /dev/null
@@@ -1,141 +0,0 @@
--'use strict';
--/**
-- * Custom domains are served only after DNS TXT verification, belong to one site, can never be an
-- * OpenVibe domain, lapse when the proof disappears, and never carry certificates through the API.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./stageb/boot');
--
--const DAY = 24 * 3600 * 1000;
--
--(async () => {
-- const t = await boot();
-- const alice = t.user('alice');
-- const bob = t.user('bob');
-- const pa = await t.project(alice, 'A');
-- const pb = await t.project(bob, 'B');
-- const sa = await t.site(alice, pa.id, 'alice-site');
-- const sb = await t.site(bob, pb.id, 'bob-site');
-- await t.deploy(alice, sa.id, { 'index.html': 'ALICE' });
-- await t.deploy(bob, sb.id, { 'index.html': 'BOB' });
-- const HOST = 'www.alice-example.org';
-- let dom;
--
-- await check('adding a custom domain returns the TXT and routing records; it is NOT served yet', async () => {
-- const r = await t.api('POST', `/api/v1/sites/${sa.id}/domains`, { as: alice, json: { hostname: 'WWW.Alice-Example.org.' } });
-- assert.strictEqual(r.status, 201, r.text);
-- dom = r.json().domain;
-- assert.strictEqual(dom.hostname, HOST);
-- assert.strictEqual(dom.status, 'pending');
-- assert.strictEqual(dom.served, false);
-- assert.strictEqual(dom.instructions.verification.type, 'TXT');
-- assert.strictEqual(dom.instructions.verification.name, `_openvibe-host.${HOST}`);
-- assert.match(dom.instructions.verification.value, /^openvibe-host-verification=[0-9a-f]{40}$/);
-- assert.deepStrictEqual(dom.instructions.routing[0], { type: 'CNAME', name: HOST, value: 'alice-site.openvibe.host', note: 'for a subdomain such as www.example.org' });
-- assert.ok(!/BEGIN|privkey|fullchain/.test(r.text), 'no certificate material');
-- const g = await t.get(HOST, '/');
-- assert.strictEqual(g.status, 404);
-- assert.match(g.text, /Unknown host/);
-- });
--
-- await check('verification: missing record and a wrong value keep it pending (and unserved)', async () => {
-- let r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });
-- assert.strictEqual(r.json().domain.status, 'pending');
-- assert.match(r.json().domain.last_error, /no TXT record/);
-- t.clock.advance(10_000);
-- t.dns.set(`_openvibe-host.${HOST}`, ['openvibe-host-verification=wrong']);
-- r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });
-- assert.strictEqual(r.json().domain.status, 'pending');
-- assert.match(r.json().domain.last_error, /not the expected value/);
-- t.clock.advance(10_000);
-- t.dns.set('__fail__', true);
-- r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });
-- assert.match(r.json().domain.last_error, /DNS lookup failed \(ETIMEOUT\)/);
-- t.dns.delete('__fail__');
-- assert.strictEqual((await t.get(HOST, '/')).status, 404);
-- });
--
-- await check('another tenant can claim the same name while pending, but only the first verified proof wins', async () => {
-- const b = await t.api('POST', `/api/v1/sites/${sb.id}/domains`, { as: bob, json: { hostname: HOST } });
-- assert.strictEqual(b.status, 201);
-- t.clock.advance(10_000);
-- t.dns.set(`_openvibe-host.${HOST}`, [dom.instructions.verification.value, 'v=spf1 -all']);
-- const r = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });
-- assert.strictEqual(r.json().domain.status, 'verified');
-- assert.strictEqual(r.json().domain.served, true);
-- const ev = await t.events('host.domain.verified');
-- assert.strictEqual(ev.length, 1);
-- assert.deepStrictEqual(ev[0].payload, { project_id: pa.id, site_id: sa.id, site: 'alice-site', hostname: HOST });
-- const g = await t.get(HOST, '/');
-- assert.strictEqual(g.text, 'ALICE');
-- assert.match(g.headers['content-security-policy'], /default-src 'self'/);
-- // Bob publishes his own proof afterwards: too late.
-- t.dns.set(`_openvibe-host.${HOST}`, [b.json().domain.instructions.verification.value]);
-- const bv = await t.api('POST', `/api/v1/domains/${b.json().domain.id}/verify`, { as: bob });
-- assert.strictEqual(bv.json().domain.status, 'failed');
-- assert.match(bv.json().domain.last_error, /verified for another site first/);
-- assert.strictEqual((await t.get(HOST, '/')).text, 'ALICE');
-- const again = await t.api('POST', `/api/v1/sites/${sb.id}/domains`, { as: bob, json: { hostname: 'apex-free.example.org' } });
-- assert.strictEqual(again.status, 201);
-- const taken = await t.api('POST', `/api/v1/projects/${pb.id}/sites`, { as: bob, json: { name: 'bob-two' } });
-- const dup = await t.api('POST', `/api/v1/sites/${taken.json().site.id}/domains`, { as: bob, json: { hostname: HOST } });
-- assert.strictEqual(dup.status, 409);
-- assert.strictEqual(dup.json().code, 'domain.taken');
-- });
--
-- await check('OpenVibe domains, the sites domain and non-hostnames can never be claimed', async () => {
-- for (const h of ['openvibe.live', 'evil.openvibe.network', 'x.openvibe.host', 'openvibe.host', 'openvibe.xyz', 'a.b.openvibe.media', '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-host.${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/host-git-source.test.js b/test/host-git-source.test.js
ddeleted file mode 100644
iindex 4db8df7..0000000
---- a/test/host-git-source.test.js
++++ /dev/null
@@@ -1,210 +0,0 @@
--'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 host_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, ['host.site.manage', 'host.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 host_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('host.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 host_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.host', '/')).text, 'LIVE');
-- assert.strictEqual((await t.events('host.deploy.created')).length, created + 1);
-- assert.strictEqual((await t.events('host.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.host', '/')).text, 'FROM GIT 2');
-- assert.strictEqual((await t.events('host.deploy.activated')).pop().payload.deploy_id, git.id);
-- });
--
-- await check('refusals leave active_deploy_id unchanged; an invalid file is a failed deploy + host.deploy.failed', async () => {
-- const before = await pointers();
-- const failedBefore = (await t.events('host.deploy.failed')).length;
-- const deploysBefore = (await db.prepare('SELECT COUNT(*) AS n FROM host_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('host.deploy.failed')).length, failedBefore + 1);
-- assert.strictEqual(await db.prepare('SELECT 1 FROM host_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.host', '/')).text, 'FROM GIT 2');
-- assert.strictEqual((await pointers(other.id)).active_deploy_id, (await db.prepare('SELECT id FROM host_deploys WHERE site_id = ? AND state = \'ready\'').get(other.id)).id);
-- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM host_deploys').get()).n, deploysBefore + 1, 'only the invalid file left a (failed) deploy');
-- assert.strictEqual((await t.events('host.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.host' };
-- 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('host_deploy_git rows are immutable', async () => {
-- await assert.rejects(db.prepare("UPDATE host_deploy_git SET commit_sha = 'b' WHERE deploy_id = ?").run(git.id), /immutable/);
-- await assert.rejects(db.prepare("UPDATE host_deploy_git SET ref = 'dev' WHERE deploy_id = ?").run(git.id), /immutable/);
-- assert.strictEqual((await db.prepare('SELECT commit_sha FROM host_deploy_git WHERE deploy_id = ?').get(git.id)).commit_sha, SHA256);
-- });
--
-- await t.close();
-- done();
--})();
ddiff --git a/test/host-isolation.test.js b/test/host-isolation.test.js
ddeleted file mode 100644
iindex a82f40b..0000000
---- a/test/host-isolation.test.js
++++ /dev/null
@@@ -1,210 +0,0 @@
--'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.host', '/')).text, 'ALPHA HOME');
-- assert.strictEqual((await t.get('beta.openvibe.host', '/')).text, 'BETA HOME');
-- assert.strictEqual((await t.get('alpha.openvibe.host', '/secret.html')).status, 404);
-- });
--
-- await check('path tricks on alpha never reach beta\'s files or anyone\'s object store', async () => {
-- const targets = [
-- '/../secret.html', '/../beta/secret.html', '/%2e%2e/secret.html', '/%2E%2E%2Fsecret.html', '/..%2fsecret.html', '/..%2Fbeta%2Fsecret.html',
-- '/.%2e/secret.html', '/%252e%252e/secret.html', '//beta.openvibe.host/secret.html', '/./secret.html', '/dir/../secret.html',
-- '/%00secret.html', '/secret.html%00.html', '/..\\secret.html', '/%5c..%5csecret.html', '/.well-known/../secret.html',
-- `/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`, `/${secretSha}`, `/objects/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`,
-- `/../../objects/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`, `/%2e%2e/%2e%2e/objects/projects/${pb.id}/${secretSha.slice(0, 2)}/${secretSha}`,
-- '/dir/inner.html', '/host.db', '/../host.db', '/%2e%2e/host.db',
-- ];
-- for (const p of targets) {
-- const r = await t.get('alpha.openvibe.host', p);
-- assert.ok(!leaks(r), `${p} leaked: ${r.status} ${r.text.slice(0, 80)}`);
-- assert.ok([400, 404].includes(r.status), `${p} → ${r.status}`);
-- assert.ok(!r.body.includes(Buffer.from('SQLite format')), `${p} returned the database`);
-- }
-- });
--
-- await check('Host header tricks resolve to exactly one site or to "unknown host"', async () => {
-- const cases = [
-- ['ALPHA.OPENVIBE.HOST', 'ALPHA HOME'], ['alpha.openvibe.host.', 'ALPHA HOME'], ['alpha.openvibe.host:443', 'ALPHA HOME'],
-- ['beta.alpha.openvibe.host', null], ['alpha.beta.openvibe.host', null], ['alpha.openvibe.host.evil.example', null],
-- ['[email protected]', null], ['alpha.openvibe.host:80:beta', null], ['alpha.openvibe.host/beta', null],
-- ['beta', null], ['openvibe.host.beta', null], ['-beta.openvibe.host', null], ['%62eta.openvibe.host', null], ['', null],
-- ];
-- for (const [host, want] of cases) {
-- const r = await t.get(host, '/secret.html');
-- assert.ok(!leaks(r), `Host ${JSON.stringify(host)} leaked beta`);
-- if (want) { const home = await t.get(host, '/'); assert.strictEqual(home.text, want, host); }
-- }
-- const dash = await t.get('openvibe.host', '/secret.html');
-- assert.ok(!leaks(dash), 'the dashboard host does not serve tenant files');
-- });
--
-- await check('an absolute-form request target must match the Host header', async () => {
-- const r = await t.get('alpha.openvibe.host', 'http://beta.openvibe.host/secret.html');
-- assert.strictEqual(r.status, 400);
-- assert.ok(!leaks(r));
-- const ok = await t.get('beta.openvibe.host', 'http://beta.openvibe.host/secret.html');
-- assert.strictEqual(ok.text, 'BETA SECRET', 'a consistent absolute-form request is fine');
-- });
--
-- await check('tenant hosts never reach the API, auth, metrics or readiness', async () => {
-- for (const p of ['/api/v1/projects', '/api/ready', '/metrics', '/auth/login', '/release.json', '/limits.json', '/css/host.css']) {
-- const r = await t.get('alpha.openvibe.host', p, { as: bob });
-- assert.strictEqual(r.status, 404, p);
-- assert.ok(!/projects|"ready"|http_requests_total|oauth/.test(r.text), `${p} leaked: ${r.text.slice(0, 100)}`);
-- }
-- });
--
-- await check('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.host', '/shared.txt')).text, SHARED, 'bob\'s copy still serves');
-- });
--
-- await check('the API hides other tenants\' projects, sites, deploys and domains (404, not 403)', async () => {
-- const dom = await t.api('POST', `/api/v1/sites/${beta.id}/domains`, { as: bob, json: { hostname: 'www.beta-example.org' } });
-- const domainId = dom.json().domain.id;
-- const probes = [
-- ['GET', `/api/v1/projects/${pb.id}`], ['GET', `/api/v1/projects/${pb.id}/sites`], ['GET', `/api/v1/projects/${pb.id}/quota`],
-- ['POST', `/api/v1/projects/${pb.id}/sites`, { name: 'hijack' }], ['DELETE', `/api/v1/projects/${pb.id}`],
-- ['GET', `/api/v1/sites/${beta.id}`], ['DELETE', `/api/v1/sites/${beta.id}`], ['GET', `/api/v1/sites/${beta.id}/deploys`],
-- ['POST', `/api/v1/sites/${beta.id}/rollback`, {}], ['GET', `/api/v1/deploys/${db.id}`], ['GET', `/api/v1/deploys/${db.id}/log`],
-- ['POST', `/api/v1/deploys/${db.id}/activate`, {}], ['DELETE', `/api/v1/deploys/${db.id}`], ['GET', `/api/v1/sites/${beta.id}/domains`],
-- ['POST', `/api/v1/sites/${beta.id}/domains`, { hostname: 'x.example.org' }], ['POST', `/api/v1/domains/${domainId}/verify`], ['DELETE', `/api/v1/domains/${domainId}`],
-- ['PUT', `/api/v1/projects/${pb.id}/members/${alice.subject}`, { role: 'owner' }],
-- ];
-- for (const [method, p, json] of probes) {
-- const r = await t.api(method, p, { as: alice, json });
-- assert.strictEqual(r.status, 404, `${method} ${p} → ${r.status} ${r.text}`);
-- assert.ok(!/BETA|beta-example/.test(r.text));
-- }
-- const up = await t.upload(alice, beta.id, { 'index.html': 'PWNED' });
-- assert.strictEqual(up.status, 404);
-- assert.strictEqual((await t.get('beta.openvibe.host', '/')).text, 'BETA HOME');
-- const list = (await t.api('GET', '/api/v1/projects', { as: alice })).json().projects.map((p) => p.id);
-- assert.deepStrictEqual(list, [pa.id]);
-- });
--
-- await check('a tenant cannot point its own site at another tenant\'s deploy', async () => {
-- const r = await t.api('POST', `/api/v1/sites/${alpha.id}/rollback`, { as: alice, json: { deploy_id: db.id } });
-- assert.strictEqual(r.status, 404);
-- assert.ok(!leaks(await t.get('alpha.openvibe.host', '/')));
-- });
--
-- await check('the API ignores cookies: a session cookie alone is anonymous (tenant pages are same-site)', async () => {
-- const r = await t.api('POST', '/api/v1/projects', { session: alice, json: { name: 'csrf' } });
-- assert.strictEqual(r.status, 401);
-- const g = await t.api('GET', `/api/v1/projects/${pa.id}`, { session: alice });
-- assert.strictEqual(g.status, 401);
-- });
--
-- await check('a garbage bearer token is refused, not treated as anonymous', async () => {
-- const r = await t.api('GET', '/api/v1/projects', { as: 'not-a-jwt' });
-- assert.strictEqual(r.status, 401);
-- assert.strictEqual(r.headers['content-type'], 'application/problem+json');
-- });
--
-- await check('a tenant page\'s scripts cannot read another tenant\'s files: no CORS grant on tenant responses, preflights refused', async () => {
-- for (const origin of ['https://alpha.openvibe.host', 'null', 'https://evil.example']) {
-- const r = await t.get('beta.openvibe.host', '/secret.html', { headers: { origin } });
-- assert.strictEqual(r.status, 200);
-- for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith('access-control-'), `beta answered ${origin} with ${h}`);
-- const pre = await t.request({ method: 'OPTIONS', host: 'beta.openvibe.host', path: '/secret.html', headers: { origin, 'access-control-request-method': 'GET' } });
-- assert.strictEqual(pre.status, 405);
-- assert.ok(!Object.keys(pre.headers).some((h) => h.startsWith('access-control-')));
-- }
-- assert.strictEqual((await t.get('beta.openvibe.host', '/secret.html')).headers['cross-origin-opener-policy'], 'same-origin');
-- });
--
-- await check('the Host API grants CORS to no tenant origin (a tenant page cannot call it from a visitor\'s browser)', async () => {
-- for (const origin of ['https://alpha.openvibe.host', 'https://beta.openvibe.host', 'https://openvibe.host.evil.example']) {
-- const r = await t.api('GET', '/api/v1/projects', { as: bob, headers: { origin } });
-- assert.strictEqual(r.headers['access-control-allow-origin'], undefined, origin);
-- const pre = await t.request({ method: 'OPTIONS', host: 'openvibe.host', path: '/api/v1/projects', headers: { origin, 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });
-- assert.strictEqual(pre.headers['access-control-allow-origin'], undefined, `preflight from ${origin}`);
-- }
-- });
--
-- await check('no existence oracle: another tenant\'s sha256 as If-None-Match or a Range on alpha is still a 404', async () => {
-- const r = await t.get('alpha.openvibe.host', '/secret.html', { headers: { 'if-none-match': `"${secretSha}"` } });
-- assert.strictEqual(r.status, 404);
-- const r2 = await t.get('alpha.openvibe.host', `/${secretSha}`, { headers: { 'if-none-match': `"${secretSha}"`, range: 'bytes=0-3' } });
-- assert.strictEqual(r2.status, 404);
-- assert.ok(!leaks(r) && !leaks(r2));
-- });
--
-- await check('delegation and app principals stay inside their own project', async () => {
-- const codes = t.network.serviceToken('codes', ['host.site.manage', 'host.deploy.create', 'host.domain.manage']);
-- for (const p of [`/api/v1/projects/${pb.id}`, `/api/v1/deploys/${db.id}`, `/api/v1/deploys/${db.id}/log`, `/api/v1/sites/${beta.id}/deploys`]) {
-- const r = await t.api('GET', p, { as: codes, headers: { 'x-ov-subject': alice.subject } });
-- assert.strictEqual(r.status, 404, `svc:codes for alice → ${p} ${r.status}`);
-- assert.ok(!leaks(r) && !r.text.includes(secretSha));
-- }
-- const ci = t.network.appToken('app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', ['host.deploy.create', 'host.site.manage']);
-- const add = await t.api('PUT', `/api/v1/projects/${pa.id}/members/app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3`, { as: alice, json: { role: 'deployer' } });
-- assert.strictEqual(add.status, 200, add.text);
-- assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/deploys`, { as: ci })).status, 200, 'the app reads its own project');
-- for (const p of [`/api/v1/deploys/${db.id}`, `/api/v1/sites/${beta.id}/deploys`]) assert.strictEqual((await t.api('GET', p, { as: ci })).status, 404, p);
-- const up = await t.api('POST', `/api/v1/sites/${beta.id}/deploys?activate=1`, { as: ci, body: require('./stageb/tar').site({ 'index.html': 'PWNED' }), headers: { 'content-type': 'application/gzip' } });
-- assert.strictEqual(up.status, 404);
-- // X-OV-Subject is honoured for first-party services only: an app cannot borrow bob's identity.
-- const borrow = await t.api('GET', `/api/v1/deploys/${db.id}`, { as: ci, headers: { 'x-ov-subject': bob.subject } });
-- assert.strictEqual(borrow.status, 400);
-- assert.strictEqual(borrow.json().code, 'subject.not_delegable');
-- assert.ok(!leaks(borrow));
-- assert.strictEqual((await t.get('beta.openvibe.host', '/')).text, 'BETA HOME');
-- });
--
-- await check('the dashboard (cookie session) shows a non-member nothing of another tenant: no names, files or hashes', async () => {
-- for (const p of [`/projects/${pb.id}`, `/sites/${beta.id}`, `/deploys/${db.id}`]) {
-- const r = await t.api('GET', p, { session: alice });
-- assert.strictEqual(r.status, 404, `${p} → ${r.status}`);
-- assert.ok(!/secret\.html|BETA|beta-example/.test(r.text) && !r.text.includes(secretSha), `${p} leaked`);
-- }
-- });
--
-- await t.close();
-- done();
--})();
ddiff --git a/test/host-launch-switch.test.js b/test/host-launch-switch.test.js
ddeleted file mode 100644
iindex e89a00c..0000000
---- a/test/host-launch-switch.test.js
++++ /dev/null
@@@ -1,61 +0,0 @@
--'use strict';
--/**
-- * Public hosting is owner-gated (plan T20/O34): until HOST_PUBLIC_HOSTING=on, the front page says hosting is not
-- * open yet and invites no one to publish, a person cannot create a project (403 host.not_launched), the dashboard
-- * shows why instead of the form, and OpenVibe staff can still create projects to test the engine.
-- */
--const assert = require('assert');
--const { boot, check, done, DASHBOARD } = require('./stageb/boot');
--const { csrfToken } = require('../server/auth/forms');
--
--(async () => {
-- const t = await boot({ env: { HOST_PUBLIC_HOSTING: 'off' } });
-- const alice = t.user('alice');
-- const staff = t.user('root', { role: 'admin' });
--
-- await check('the signed-out front page leads with the catalog and says static hosting is not open yet', async () => {
-- const front = await t.api('GET', '/');
-- assert.strictEqual(front.status, 200);
-- assert.match(front.text, /Hosting, compared honestly/);
-- assert.match(front.text, /not open to the public yet/);
-- assert.match(front.text, /href="\/hosting"/);
-- assert.doesNotMatch(front.text, /class="sc-btn sc-primary" href="\/auth\/login/, 'no primary sign-in-to-publish call');
-- });
--
-- await check('/hosting says hosting is not open yet and invites no one to publish', async () => {
-- const r = await t.api('GET', '/hosting');
-- assert.strictEqual(r.status, 200);
-- assert.match(r.text, /not open yet/);
-- assert.match(r.text, /launch review/);
-- assert.match(r.text, /How it will work/);
-- assert.doesNotMatch(r.text, /Get a site online/);
-- assert.doesNotMatch(r.text, /class="sc-btn sc-primary" href="\/auth\/login/, 'no primary sign-in-to-publish call');
-- assert.match(r.text, /id="limits"/, 'the limits the engine enforces are still published');
-- });
--
-- 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, 'host.not_launched');
-- });
--
-- await check('the signed-in dashboard explains instead of offering the form, and its form post is refused', async () => {
-- const r = await t.api('GET', '/', { session: alice });
-- assert.strictEqual(r.status, 200);
-- assert.match(r.text, /Your projects/);
-- assert.match(r.text, /not open for hosting yet/);
-- assert.doesNotMatch(r.text, /<form[^>]+action="\/projects"/);
-- const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
-- const post = await t.api('POST', '/projects', { session: alice, form: { csrf, name: 'Too early', environment: 'production' }, headers: { origin: `https://${DASHBOARD}` } });
-- assert.ok(!/\/projects\/prj_/.test(post.headers.location || ''), `no project page to go to: ${post.status} ${post.headers.location}`);
-- const mine = (await t.api('GET', '/api/v1/projects', { as: alice })).json().projects;
-- assert.strictEqual(mine.length, 0, 'nothing was created');
-- });
--
-- await check('OpenVibe staff can still create a project to test the engine', async () => {
-- const p = await t.project(staff, 'Staff test');
-- assert.match(p.id, /^prj_/);
-- });
--
-- done();
--})();
ddiff --git a/test/host-lifecycle.test.js b/test/host-lifecycle.test.js
ddeleted file mode 100644
iindex f909cfa..0000000
---- a/test/host-lifecycle.test.js
++++ /dev/null
@@@ -1,191 +0,0 @@
--'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.host');
-- assert.strictEqual(site.url, 'https://alice.openvibe.host');
-- const r = await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice });
-- assert.deepStrictEqual(r.json().domains.map((d) => [d.hostname, d.kind, d.status]), [['alice.openvibe.host', 'default', 'verified']]);
-- });
--
-- await check('before any deploy the site answers with its own 404', async () => {
-- const r = await t.get('alice.openvibe.host', '/');
-- assert.strictEqual(r.status, 404);
-- assert.match(r.text, /has not published anything yet/);
-- assert.strictEqual(r.headers['x-content-type-options'], 'nosniff');
-- });
--
-- await check('a tar.gz deploy is validated, stored content-addressed, activated and served with correct headers', async () => {
-- const r = await t.upload(alice, site.id, {
-- 'index.html': '<!doctype html><h1>v1</h1>', 'about/index.html': '<p>about</p>', 'assets/app.3f2a9c1b.js': 'console.log(1)',
-- 'style.css': 'body{}', 'img/logo.svg': '<svg xmlns="http://www.w3.org/2000/svg"/>', '404.html': '<h1>custom 404 v1</h1>', 'CNAME': 'alice.example',
-- });
-- assert.strictEqual(r.status, 201, r.text);
-- const body = r.json();
-- d1 = body.deploy;
-- assert.match(d1.id, /^dpl_/);
-- assert.strictEqual(d1.state, 'ready');
-- assert.strictEqual(d1.active, true);
-- assert.strictEqual(body.activated, true);
-- assert.strictEqual(d1.file_count, 7);
-- assert.match(d1.manifest_sha256, /^[0-9a-f]{64}$/);
-- assert.ok(d1.log.some((l) => /nothing was executed/.test(l.message)));
-- const page = await t.get('alice.openvibe.host', '/');
-- assert.strictEqual(page.status, 200);
-- assert.strictEqual(page.text, '<!doctype html><h1>v1</h1>');
-- assert.strictEqual(page.headers['content-type'], 'text/html; charset=utf-8');
-- assert.strictEqual(page.headers['x-content-type-options'], 'nosniff');
-- assert.match(page.headers['content-security-policy'], /default-src 'self'; script-src 'self'/);
-- assert.match(page.headers['content-security-policy'], /object-src 'none'/);
-- assert.strictEqual(page.headers['cache-control'], 'public, max-age=0, must-revalidate');
-- assert.match(page.headers.etag, /^"[0-9a-f]{64}"$/);
-- assert.strictEqual(page.headers['set-cookie'], undefined, 'tenant responses never set cookies');
-- assert.strictEqual(page.headers['x-openvibe-deploy'], d1.id);
-- const js = await t.get('alice.openvibe.host', '/assets/app.3f2a9c1b.js');
-- assert.strictEqual(js.headers['content-type'], 'text/javascript; charset=utf-8');
-- assert.strictEqual(js.headers['cache-control'], 'public, max-age=31536000, immutable');
-- const svg = await t.get('alice.openvibe.host', '/img/logo.svg');
-- assert.strictEqual(svg.headers['content-type'], 'image/svg+xml');
-- const cname = await t.get('alice.openvibe.host', '/CNAME');
-- assert.strictEqual(cname.headers['content-type'], 'text/plain; charset=utf-8');
-- });
--
-- await check('ETag revalidation (304), HEAD, byte ranges, directory index and redirect, custom 404, 405', async () => {
-- const first = await t.get('alice.openvibe.host', '/style.css');
-- const again = await t.get('alice.openvibe.host', '/style.css', { headers: { 'if-none-match': first.headers.etag } });
-- assert.strictEqual(again.status, 304);
-- assert.strictEqual(again.body.length, 0);
-- const head = await t.get('alice.openvibe.host', '/style.css', { method: 'HEAD' });
-- assert.strictEqual(head.status, 200);
-- assert.strictEqual(head.headers['content-length'], '6');
-- const range = await t.get('alice.openvibe.host', '/style.css', { headers: { range: 'bytes=0-3' } });
-- assert.strictEqual(range.status, 206);
-- assert.strictEqual(range.text, 'body');
-- assert.strictEqual(range.headers['content-range'], 'bytes 0-3/6');
-- const bad = await t.get('alice.openvibe.host', '/style.css', { headers: { range: 'bytes=10-20' } });
-- assert.strictEqual(bad.status, 416);
-- const redirect = await t.get('alice.openvibe.host', '/about?x=1');
-- assert.strictEqual(redirect.status, 301);
-- assert.strictEqual(redirect.headers.location, '/about/?x=1');
-- const about = await t.get('alice.openvibe.host', '/about/');
-- assert.strictEqual(about.text, '<p>about</p>');
-- const missing = await t.get('alice.openvibe.host', '/nope.html');
-- assert.strictEqual(missing.status, 404);
-- assert.strictEqual(missing.text, '<h1>custom 404 v1</h1>', 'the deploy\'s own 404.html');
-- assert.match(missing.headers['content-security-policy'], /default-src 'self'/);
-- const post = await t.get('alice.openvibe.host', '/', { method: 'POST', body: 'x' });
-- assert.strictEqual(post.status, 405);
-- assert.strictEqual(post.headers.allow, 'GET, HEAD');
-- });
--
-- await check('a second deploy (not activated) leaves the site unchanged; activate switches; rollback goes back', async () => {
-- d2 = await t.deploy(alice, site.id, { 'index.html': '<h1>v2</h1>', '404.html': 'v2 404' }, { activate: false });
-- assert.strictEqual(d2.active, false);
-- assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<!doctype html><h1>v1</h1>');
-- const a = await t.api('POST', `/api/v1/deploys/${d2.id}/activate`, { as: alice, json: {} });
-- assert.strictEqual(a.status, 200, a.text);
-- assert.deepStrictEqual(a.json(), { active_deploy_id: d2.id, previous_deploy_id: d1.id, changed: true });
-- assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<h1>v2</h1>');
-- assert.strictEqual((await t.get('alice.openvibe.host', '/style.css')).status, 404, 'files of the old deploy are gone from the site');
-- const rb = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: {} });
-- assert.strictEqual(rb.status, 200, rb.text);
-- assert.strictEqual(rb.json().active_deploy_id, d1.id);
-- assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<!doctype html><h1>v1</h1>');
-- const rb2 = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: { deploy_id: d2.id } });
-- assert.strictEqual(rb2.json().active_deploy_id, d2.id);
-- const list = (await t.api('GET', `/api/v1/sites/${site.id}/deploys`, { as: alice })).json();
-- assert.strictEqual(list.active_deploy_id, d2.id);
-- assert.deepStrictEqual(list.deploys.map((d) => d.id), [d2.id, d1.id]);
-- assert.deepStrictEqual(list.activations.map((a2) => a2.kind), ['rollback', 'rollback', 'activate', 'activate']);
-- });
--
-- await check('multipart files upload with a browser folder name stripped, and a subfolder root', async () => {
-- const boundary = '----hostboundary';
-- const part = (name, filename, content) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"${filename ? `; filename="${filename}"` : ''}\r\n${filename ? 'Content-Type: application/octet-stream\r\n' : ''}\r\n${content}\r\n`;
-- const body = part('strip', null, 'folder') + part('files', 'mysite/index.html', '<h1>v3</h1>') + part('files', 'mysite/css/a.css', 'a{}') + `--${boundary}--\r\n`;
-- const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys?activate=1`, { as: alice, body, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } });
-- assert.strictEqual(r.status, 201, r.text);
-- assert.strictEqual(r.json().deploy.source, 'files');
-- assert.ok(r.json().deploy.log.some((l) => /removed the uploaded folder name "mysite\/"/.test(l.message)));
-- assert.strictEqual((await t.get('alice.openvibe.host', '/css/a.css')).text, 'a{}');
-- const rooted = await t.upload(alice, site.id, { 'README.md': 'x', 'dist/index.html': '<h1>dist</h1>' }, { query: 'root=dist' });
-- assert.strictEqual(rooted.status, 201, rooted.text);
-- assert.strictEqual(rooted.json().deploy.file_count, 1);
-- assert.strictEqual((await t.get('alice.openvibe.host', '/')).text, '<h1>dist</h1>');
-- });
--
-- await check('the deploy record: files with sha256 and type, and the upload log', async () => {
-- const r = await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: alice });
-- const files = r.json().deploy.files;
-- assert.ok(files.find((f) => f.path === 'about/index.html' && /^[0-9a-f]{64}$/.test(f.sha256) && f.content_type === 'text/html; charset=utf-8'));
-- const log = await t.api('GET', `/api/v1/deploys/${d1.id}/log`, { as: alice });
-- assert.ok(log.json().log.some((l) => /validated 7 files/.test(l.message)));
-- });
--
-- await check('events: created/activated in the outbox with valid envelopes and no file contents', async () => {
-- const contracts = require('openvibe-contracts');
-- const created = await t.events('host.deploy.created');
-- const activated = await t.events('host.deploy.activated');
-- assert.ok(created.length >= 4);
-- assert.ok(activated.some((e) => e.payload.rollback === true));
-- for (const e of [...created, ...activated]) {
-- const v = contracts.validate('events.event-envelope@1', e);
-- assert.ok(v.valid, JSON.stringify(v.errors));
-- assert.strictEqual(e.source, 'host');
-- assert.deepStrictEqual(e.actor, { type: 'user', id: alice.subject });
-- assert.ok(!JSON.stringify(e).includes('<h1>'));
-- }
-- });
--
-- await check('deleting: the active deploy is refused; an old one is removed with its unshared objects', async () => {
-- const list = (await t.api('GET', `/api/v1/sites/${site.id}/deploys`, { as: alice })).json();
-- const active = list.active_deploy_id;
-- const refused = await t.api('DELETE', `/api/v1/deploys/${active}`, { as: alice });
-- assert.strictEqual(refused.status, 409);
-- assert.strictEqual(refused.json().code, 'deploy.active');
-- const before = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;
-- const del = await t.api('DELETE', `/api/v1/deploys/${d1.id}`, { as: alice });
-- assert.strictEqual(del.status, 200, del.text);
-- assert.ok(del.json().objects_removed > 0);
-- const after = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;
-- assert.ok(after < before);
-- assert.strictEqual((await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: alice })).status, 404);
-- const rb = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: { deploy_id: d1.id } });
-- assert.strictEqual(rb.status, 404, 'a deleted deploy cannot come back');
-- });
--
-- await check('deleting the site stops serving at once and frees the name only for its own project for 30 days', async () => {
-- const del = await t.api('DELETE', `/api/v1/sites/${site.id}`, { as: alice });
-- assert.strictEqual(del.status, 200, del.text);
-- assert.strictEqual((await t.get('alice.openvibe.host', '/')).status, 404);
-- assert.match((await t.get('alice.openvibe.host', '/')).text, /Unknown host/);
-- const bob = t.user('bob');
-- const bp = await t.project(bob, 'Bob');
-- const taken = await t.api('POST', `/api/v1/projects/${bp.id}/sites`, { as: bob, json: { name: 'alice' } });
-- assert.strictEqual(taken.status, 409);
-- assert.strictEqual(taken.json().code, 'site.name_held');
-- const mine = await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: alice, json: { name: 'alice' } });
-- assert.strictEqual(mine.status, 201);
-- });
--
-- await t.close();
-- done();
--})();
ddiff --git a/test/host-llms.test.js b/test/host-llms.test.js
ddeleted file mode 100644
iindex 802b752..0000000
---- a/test/host-llms.test.js
++++ /dev/null
@@@ -1,67 +0,0 @@
--'use strict';
--/**
-- * /llms.txt and /llms-full.txt (openvibe-shared/seo, llmstxt.org) list Host's own public pages only:
-- * text/plain, cached like the sitemap, never a customer site, a preview URL, /internal or the dashboard.
-- * Host's own pages come through openvibe-shared/shell, so the public front page keeps its title,
-- * canonical and robots and now carries the shell's markers (the runtime boot, the Open Graph head).
-- */
--const assert = require('assert');
--const { boot, check, done, DASHBOARD } = require('./stageb/boot');
--
--(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');
-- await t.deploy(alice, site.id, { 'index.html': '<h1>Widgets</h1>' }, { activate: true });
-- const sitemap = await t.get(DASHBOARD, '/sitemap.xml');
--
-- for (const file of ['/llms.txt', '/llms-full.txt']) {
-- await check(`${file} is 200 text/plain, cached like the sitemap, and lists only Host's public pages`, async () => {
-- const r = await t.get(DASHBOARD, file);
-- assert.strictEqual(r.status, 200, r.text);
-- assert.match(r.headers['content-type'], /^text\/plain/);
-- assert.strictEqual(r.headers['cache-control'], sitemap.headers['cache-control']);
-- assert.match(r.text, /^# OpenVibe\.Host/);
-- const urls = new Set([...r.text.matchAll(/https?:\/\/[^\s)>\]]+/g)].map((m) => m[0]));
-- for (const url of urls) assert.match(url, /^https:\/\/openvibe\.host\//, `${url} is not a Host page`);
-- for (const page of ['/', '/updates', '/terms', '/privacy', '/dmca']) assert.ok(urls.has(`https://openvibe.host${page}`), `${page} missing`);
-- for (const hidden of ['widgets', 'Widgets', 'alice', 'preview', '/internal', '/api/', '/projects', '/sites/', '/auth/']) {
-- assert.ok(!r.text.includes(hidden), `${hidden} in ${file}`);
-- }
-- });
-- }
--
-- await check('/llms-full.txt carries a description of each public page and stays within 512 KiB', async () => {
-- const r = await t.get(DASHBOARD, '/llms-full.txt');
-- assert.match(r.text, /## Public pages/);
-- assert.match(r.text, /immutable artifact/);
-- assert.ok(Buffer.byteLength(r.text) <= 512 * 1024);
-- });
--
-- await check("the public front page goes through the shell: title, canonical, robots and the shell's markers", async () => {
-- const r = await t.api('GET', '/');
-- assert.strictEqual(r.status, 200, r.text);
-- assert.match(r.text, /^<!doctype html>/i);
-- assert.match(r.text, /<title>OpenVibe\.Host<\/title>/);
-- assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.host\/">/);
-- assert.match(r.text, /<meta name="robots" content="index, follow">/);
-- assert.match(r.text, /<meta property="og:site_name" content="OpenVibe\.Host">/);
-- assert.match(r.text, /<script src="\/shared\/web-runtime\.js\?v=[0-9a-f]+" defer><\/script>/);
-- assert.match(r.text, /OVWebRuntime\.create\(/);
-- assert.match(r.text, /<main id="main" class="page">/);
-- });
--
-- await check('the updates page keeps its suffixed title and canonical; a private page stays noindex without one', async () => {
-- const u = await t.api('GET', '/updates');
-- assert.match(u.text, /<title>What shipped on OpenVibe\.Host · OpenVibe\.Host<\/title>/);
-- assert.match(u.text, /<link rel="canonical" href="https:\/\/openvibe\.host\/updates">/);
-- const nf = await t.api('GET', '/no-such-page');
-- assert.strictEqual(nf.status, 404);
-- assert.match(nf.text, /<meta name="robots" content="noindex, nofollow">/);
-- assert.ok(!/rel="canonical"/.test(nf.text));
-- });
--
-- await t.close();
-- done();
--})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/host-preview.test.js b/test/host-preview.test.js
ddeleted file mode 100644
iindex ba6de5d..0000000
---- a/test/host-preview.test.js
++++ /dev/null
@@@ -1,153 +0,0 @@
--'use strict';
--/**
-- * Preview deploys (plan T12 J4, decision D5). Uploading a deploy with ?preview=1 stores it with
-- * source 'preview' and makes it the site's live preview (host_sites.preview_deploy_id) WITHOUT
-- * activating it: the public site keeps serving its active deploy. A preview is served only on the
-- * dashboard host, at /preview/<deploy-id>/…, only to a member of the deploy's project, always
-- * noindex and never cached by a shared cache; it is never pinged to IndexNow and never appears in
-- * the site's sitemap. It stops being served when it expires, when the site deploys or rolls back,
-- * or when the preview is deleted, and it can only reach its own project's objects.
-- */
--const assert = require('assert');
--const { boot, check, done, DASHBOARD } = require('./stageb/boot');
--const { PREVIEW_TTL_MS } = require('../server/domain/deploys');
--
--/** A spy in place of IndexNow's HTTP send: records every pingSoon batch (the repo's test pattern). */
--function makeSpy() {
-- const pings = [];
-- return {
-- pings,
-- module: {
-- enabled: true,
-- keyFile: (_req, _res, next) => next(),
-- pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },
-- ping: async () => ({ sent: 0, status: 0 }),
-- flush: async () => ({ sent: 0, status: 0 }),
-- },
-- };
--}
--
--(async () => {
-- const s = makeSpy();
-- const t = await boot({ indexnow: s.module });
-- const alice = t.user('alice');
-- const bob = t.user('bob');
-- const pa = await t.project(alice, 'A');
-- const pb = await t.project(bob, 'B');
-- const alpha = await t.site(alice, pa.id, 'alpha');
-- const beta = await t.site(bob, pb.id, 'beta');
-- await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA LIVE', 'about.html': 'ALPHA ABOUT' });
-- await t.deploy(bob, beta.id, { 'index.html': 'BETA LIVE', 'secret.txt': 'BETA SECRET' });
-- s.pings.length = 0;
--
-- const preview = (id, p = '/') => `/preview/${id}${p}`;
-- const asMember = (id, p, user) => t.get(DASHBOARD, preview(id, p), { session: user });
-- let pv = null;
--
-- await check('a preview is stored as source=preview, points the site at it, and never goes active', async () => {
-- const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW', 'about.html': 'PREVIEW ABOUT' }, { activate: false, query: 'preview=1' });
-- assert.strictEqual(r.status, 201, r.text);
-- const body = r.json();
-- pv = body.deploy;
-- assert.strictEqual(pv.source, 'preview');
-- assert.strictEqual(body.activated, false, 'a preview is never activated');
-- assert.ok(body.preview && body.preview.deploy_id === pv.id, r.text);
-- assert.ok(body.preview.expires_at > t.clock.now());
-- const pub = await t.get('alpha.openvibe.host', '/');
-- assert.strictEqual(pub.status, 200);
-- assert.strictEqual(pub.text, 'ALPHA LIVE', 'the public site keeps serving its active deploy');
-- assert.ok(!s.pings.some((u) => u.includes('/preview/')), 'a preview is never pinged');
-- });
--
-- await check('a member views the preview on the dashboard: 200, noindex, no-store, no cookie', async () => {
-- const r = await asMember(pv.id, '/', alice);
-- assert.strictEqual(r.status, 200, r.text);
-- assert.strictEqual(r.text, 'ALPHA PREVIEW');
-- assert.strictEqual(r.headers['x-robots-tag'], 'noindex, nofollow');
-- assert.match(r.headers['cache-control'], /no-store/);
-- assert.strictEqual(r.headers['set-cookie'], undefined);
-- assert.strictEqual(r.headers['x-openvibe-deploy'], pv.id);
-- // Tenant content on the dashboard's own origin is sandboxed into an opaque origin: it can
-- // never act as openvibe.host (no allow-same-origin, never the dashboard's own CSP).
-- assert.match(r.headers['content-security-policy'], /sandbox /);
-- assert.ok(!r.headers['content-security-policy'].includes('allow-same-origin'), 'the preview must not keep the dashboard origin');
-- assert.ok(r.headers['content-security-policy'].startsWith("default-src 'none'"), 'not the dashboard CSP');
-- const file = await asMember(pv.id, '/about.html', alice);
-- assert.strictEqual(file.status, 200);
-- assert.strictEqual(file.text, 'PREVIEW ABOUT');
-- const head = await t.get(DASHBOARD, preview(pv.id), { method: 'HEAD', session: alice });
-- assert.strictEqual(head.status, 200);
-- assert.strictEqual(head.text, '');
-- });
--
-- await check('non-members and signed-out callers cannot view a preview', async () => {
-- assert.strictEqual((await asMember(pv.id, '/', bob)).status, 404, 'another project member must not see it');
-- assert.strictEqual((await t.get(DASHBOARD, preview(pv.id))).status, 404, 'signed out must not see it');
-- assert.strictEqual((await t.get(DASHBOARD, preview(pv.id), { as: bob })).status, 404, 'a bearer token is not a dashboard session');
-- assert.strictEqual((await asMember('dpl_00000000000000000000000000', '/', alice)).status, 404);
-- assert.strictEqual((await asMember('not-an-id', '/', alice)).status, 404);
-- });
--
-- await check('a preview is never served on a public tenant host', async () => {
-- for (const host of ['alpha.openvibe.host', 'beta.openvibe.host']) {
-- const r = await t.get(host, preview(pv.id));
-- assert.strictEqual(r.status, 404, `${host} → ${r.status}`);
-- assert.ok(!r.text.includes('ALPHA PREVIEW'), host);
-- }
-- });
--
-- await check('isolation: another project\'s preview and another project\'s files are unreachable', async () => {
-- const b = await t.upload(bob, beta.id, { 'index.html': 'BETA PREVIEW' }, { activate: false, query: 'preview=1' });
-- assert.strictEqual(b.status, 201, b.text);
-- const bpv = b.json().deploy;
-- assert.strictEqual((await asMember(bpv.id, '/', alice)).status, 404, 'alice is not on beta');
-- assert.strictEqual((await asMember(bpv.id, '/', bob)).status, 200);
-- assert.strictEqual((await asMember(pv.id, '/secret.txt', alice)).status, 404, 'alpha cannot read beta\'s same-named file');
-- for (const p of ['/../secret.txt', '/%2e%2e/secret.txt', '/a%2fb', '/%00']) {
-- assert.strictEqual((await asMember(pv.id, p, alice)).status, 404, p);
-- }
-- });
--
-- await check('a rollback makes the live preview vanish', async () => {
-- await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA LIVE 2' });
-- const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 2' }, { activate: false, query: 'preview=1' });
-- const p2 = r.json().deploy;
-- assert.strictEqual((await asMember(p2.id, '/', alice)).status, 200);
-- const rb = await t.api('POST', `/api/v1/sites/${alpha.id}/rollback`, { as: alice, json: {} });
-- assert.strictEqual(rb.status, 200, rb.text);
-- assert.strictEqual((await asMember(p2.id, '/', alice)).status, 404, 'a rollback supersedes the preview');
-- });
--
-- await check('an expired preview vanishes', async () => {
-- const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 3' }, { activate: false, query: 'preview=1' });
-- const p3 = r.json().deploy;
-- assert.strictEqual((await asMember(p3.id, '/', alice)).status, 200);
-- t.clock.advance(PREVIEW_TTL_MS + 1000);
-- assert.strictEqual((await asMember(p3.id, '/', alice)).status, 404, 'an expired preview is not served');
-- });
--
-- await check('deleting the preview deploy makes it vanish', async () => {
-- const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 4' }, { activate: false, query: 'preview=1' });
-- const p4 = r.json().deploy;
-- assert.strictEqual((await asMember(p4.id, '/', alice)).status, 200);
-- const del = await t.api('DELETE', `/api/v1/deploys/${p4.id}`, { as: alice, json: {} });
-- assert.strictEqual(del.status, 200, del.text);
-- assert.strictEqual((await asMember(p4.id, '/', alice)).status, 404, 'a deleted preview is not served');
-- });
--
-- await check('a preview is never listed in the sitemap and never pinged', async () => {
-- const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 5', 'page.html': 'PAGE' }, { activate: false, query: 'preview=1' });
-- const p5 = r.json().deploy;
-- assert.strictEqual((await asMember(p5.id, '/page.html', alice)).status, 200);
-- s.pings.length = 0;
-- const sm = await t.get('alpha.openvibe.host', '/sitemap.xml');
-- assert.strictEqual(sm.status, 200, sm.text);
-- assert.ok(!sm.text.includes('/preview/'), 'the sitemap never lists a preview URL');
-- assert.ok(!sm.text.includes(p5.id), 'the sitemap never names a preview deploy');
-- assert.ok(!sm.text.includes('ALPHA PREVIEW'), 'the sitemap cannot leak draft content');
-- assert.deepStrictEqual(s.pings, [], 'viewing a preview pings nothing');
-- });
--
-- await t.close();
-- done();
--})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/host-quota-auth.test.js b/test/host-quota-auth.test.js
ddeleted file mode 100644
iindex eef9bf3..0000000
---- a/test/host-quota-auth.test.js
++++ /dev/null
@@@ -1,216 +0,0 @@
--'use strict';
--/**
-- * Quotas are enforced by Host (ADR-014) and principals are judged by capability AND project role:
-- * deploys per day, storage (deduplicated), files per deploy, file size, upload size, sites, custom
-- * domains, sandbox limits; service tokens, app principals, sandbox tokens, staff.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./stageb/boot');
--const { site: siteTar } = require('./stageb/tar');
--
--const DAY = 24 * 3600 * 1000;
--
--(async () => {
-- const t = await boot({ env: { HOST_MAX_UPLOAD_BYTES: String(64 * 1024), HOST_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.host', '/')).text, 'v2');
-- t.clock.advance(DAY + 1000);
-- assert.strictEqual((await t.upload(alice, site.id, { 'index.html': 'v5' })).status, 201);
-- await setQuota({ deploys_per_day: null });
-- });
--
-- await check('storage quota counts only new bytes (identical content is stored once per project)', async () => {
-- const big = 'x'.repeat(3000);
-- await setQuota({ storage_bytes: 7000 });
-- const used0 = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;
-- const first = await t.upload(alice, site.id, { 'index.html': big });
-- assert.strictEqual(first.status, 201, first.text);
-- assert.strictEqual(first.json().deploy.new_bytes, 3000);
-- const same = await t.upload(alice, site.id, { 'index.html': big, 'copy.html': big });
-- assert.strictEqual(same.status, 201, same.text);
-- assert.strictEqual(same.json().deploy.new_bytes, 0, 'already stored');
-- const over = await t.upload(alice, site.id, { 'index.html': 'y'.repeat(4100) });
-- assert.strictEqual(over.status, 413);
-- assert.strictEqual(over.json().code, 'quota.storage');
-- const tooBig = await t.upload(alice, site.id, { 'a.html': big, 'b.html': big, 'c.html': big });
-- assert.strictEqual(tooBig.status, 413, 'a deploy (before deduplication) is never larger than the storage quota');
-- assert.strictEqual(tooBig.json().code, 'deploy.too_large');
-- const used1 = (await t.api('GET', `/api/v1/projects/${project.id}/quota`, { as: alice })).json().usage.storage_bytes;
-- assert.strictEqual(used1 - used0, 3000);
-- await setQuota({ storage_bytes: null });
-- });
--
-- await check('files per deploy and bytes per file', async () => {
-- await setQuota({ max_files: 2, max_file_bytes: 100 });
-- const many = await t.upload(alice, site.id, { 'a.html': '1', 'b.html': '2', 'c.html': '3' });
-- assert.strictEqual(many.status, 413);
-- assert.strictEqual(many.json().code, 'quota.max_files');
-- const large = await t.upload(alice, site.id, { 'a.html': 'z'.repeat(101) });
-- assert.strictEqual(large.status, 413);
-- assert.strictEqual(large.json().code, 'quota.max_file_bytes');
-- await setQuota({ max_files: null, max_file_bytes: null });
-- });
--
-- await check('request size: a body over HOST_MAX_UPLOAD_BYTES is refused (413) and the connection closed', async () => {
-- const crypto = require('crypto');
-- const noise = crypto.randomBytes(80 * 1024); // incompressible
-- const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body: siteTar({ 'blob.bin.png': noise }), headers: { 'content-type': 'application/gzip' } });
-- assert.strictEqual(r.status, 413);
-- assert.strictEqual(r.json().code, 'upload.too_large');
-- assert.strictEqual(r.headers.connection, 'close');
-- });
--
-- await check('decompression: an archive unpacks to at most HOST_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.host', '/')).headers['x-robots-tag'], 'noindex, nofollow');
-- });
--
-- // ── Principals ──────────────────────────────────────────
-- const ALL = ['host.site.manage', 'host.deploy.create', 'host.domain.manage'];
--
-- await check('service tokens: audience and capability are checked per route', async () => {
-- const wrongAud = t.network.sign({ sub: 'svc:codes', aud: ['openvibe.blog'], cap: ALL });
-- assert.strictEqual((await t.api('GET', '/api/v1/projects', { as: wrongAud })).status, 401);
-- const noCap = t.network.serviceToken('codes', ['host.deploy.create']);
-- const r = await t.api('POST', '/api/v1/projects', { as: noCap, json: { name: 'x' }, headers: { 'x-ov-subject': alice.subject } });
-- assert.strictEqual(r.status, 403);
-- assert.strictEqual(r.json().code, 'capability.denied');
-- const family = t.network.serviceToken('codes', ['host.*']);
-- const ok = await t.api('GET', `/api/v1/projects/${project.id}`, { as: family, headers: { 'x-ov-subject': alice.subject } });
-- assert.strictEqual(ok.status, 200, 'a host.* grant covers the family');
-- });
--
-- await check('a first-party service acts for a person (X-OV-Subject) and is judged by that person\'s role', async () => {
-- const codes = t.network.serviceToken('codes', ALL);
-- const created = await t.api('POST', '/api/v1/projects', { as: codes, json: { name: 'via codes', network_project_id: 'nprj_123' }, headers: { 'x-ov-subject': alice.subject } });
-- assert.strictEqual(created.status, 201, created.text);
-- assert.strictEqual(created.json().project.owner, alice.subject);
-- assert.strictEqual(created.json().project.network_project_id, 'nprj_123');
-- const dup = await t.api('POST', '/api/v1/projects', { as: codes, json: { name: 'dup', network_project_id: 'nprj_123' }, headers: { 'x-ov-subject': alice.subject } });
-- assert.strictEqual(dup.status, 409);
-- const noPerson = await t.api('POST', '/api/v1/projects', { as: codes, json: { name: 'orphan' } });
-- assert.strictEqual(noPerson.status, 403, 'a project is owned by a person');
-- const stranger = t.user('mallory');
-- const other = await t.api('GET', `/api/v1/projects/${project.id}`, { as: codes, headers: { 'x-ov-subject': stranger.subject } });
-- assert.strictEqual(other.status, 404, 'the capability alone does not open someone else\'s project');
-- });
--
-- await check('apps are principals: a deployer app deploys, cannot manage, cannot act as a person', async () => {
-- const { ids } = require('openvibe-contracts');
-- const appId = ids.newId('app');
-- const ci = t.network.appToken(appId, ALL);
-- assert.strictEqual((await t.api('GET', `/api/v1/sites/${site.id}/deploys`, { as: ci })).status, 404, 'not a member yet');
-- const add = await t.api('PUT', `/api/v1/projects/${project.id}/members/app:${appId}`, { as: alice, json: { role: 'deployer' } });
-- assert.strictEqual(add.status, 200, add.text);
-- const up = await t.api('POST', `/api/v1/sites/${site.id}/deploys?activate=1`, { as: ci, body: siteTar({ 'index.html': 'from CI' }), headers: { 'content-type': 'application/gzip' } });
-- assert.strictEqual(up.status, 201, up.text);
-- assert.strictEqual(up.json().deploy.created_by, `app:${appId}`);
-- assert.deepStrictEqual((await t.events('host.deploy.activated')).pop().actor, { type: 'app', id: appId });
-- assert.strictEqual((await t.get('quota-site.openvibe.host', '/')).text, 'from CI');
-- const mk = await t.api('POST', `/api/v1/projects/${project.id}/sites`, { as: ci, json: { name: 'ci-site' } });
-- assert.strictEqual(mk.status, 403);
-- assert.strictEqual(mk.json().code, 'project.role_insufficient');
-- const imp = await t.api('GET', `/api/v1/projects/${project.id}`, { as: ci, headers: { 'x-ov-subject': alice.subject } });
-- assert.strictEqual(imp.status, 400);
-- assert.strictEqual(imp.json().code, 'subject.not_delegable');
-- const owner = await t.api('PUT', `/api/v1/projects/${project.id}/members/app:${appId}`, { as: alice, json: { role: 'owner' } });
-- assert.strictEqual(owner.status, 422, 'only a person can own a project');
-- });
--
-- await check('a sandbox credential is refused on a production project', async () => {
-- const sandboxTok = t.network.serviceToken('codes', ALL, { env: 'sandbox' });
-- const r = await t.api('GET', `/api/v1/projects/${project.id}`, { as: sandboxTok, headers: { 'x-ov-subject': alice.subject } });
-- assert.strictEqual(r.status, 403);
-- assert.strictEqual(r.json().code, 'environment.sandbox_token');
-- const p = await t.api('POST', '/api/v1/projects', { as: sandboxTok, json: { name: 'prod?' }, headers: { 'x-ov-subject': alice.subject } });
-- assert.strictEqual(p.status, 403);
-- const sb = await t.api('POST', '/api/v1/projects', { as: sandboxTok, json: { name: 'sb', environment: 'sandbox' }, headers: { 'x-ov-subject': alice.subject } });
-- assert.strictEqual(sb.status, 201);
-- });
--
-- await check('a FedCM ID assertion is not a session: a tenant page on <site>.openvibe.host 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.host included.
-- const assertion = t.network.fedcmAssertion(alice, 'https://evil-tenant.openvibe.host');
-- 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.host', 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.host'], cap: ALL });
-- assert.strictEqual((await t.api('GET', '/api/v1/projects', { as: mod })).status, 403);
-- });
--
-- await check('staff read and delete for abuse handling but cannot publish into a tenant site', async () => {
-- assert.strictEqual((await t.api('GET', `/api/v1/projects/${project.id}`, { as: staff })).status, 200);
-- const up = await t.upload(staff, site.id, { 'index.html': 'staff content' });
-- assert.strictEqual(up.status, 404);
-- assert.notStrictEqual((await t.get('quota-site.openvibe.host', '/')).text, 'staff content');
-- });
--
-- await t.close();
-- done();
--})();
ddiff --git a/test/host-rollback.test.js b/test/host-rollback.test.js
ddeleted file mode 100644
iindex d2ca68f..0000000
---- a/test/host-rollback.test.js
++++ /dev/null
@@@ -1,105 +0,0 @@
--'use strict';
--/**
-- * Activation and rollback are one atomic pointer switch: under concurrent traffic every response is
-- * one whole deploy (never a mix, never an error); a failure inside the switch changes nothing;
-- * artifacts are immutable at the database level; the pointer survives a restart.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./stageb/boot');
--
--(async () => {
-- const t = await boot();
-- const alice = t.user('alice');
-- const project = await t.project(alice, 'R');
-- const site = await t.site(alice, project.id, 'flip');
-- const v1 = await t.deploy(alice, site.id, { 'index.html': 'version-1', 'v.txt': '1' });
-- const v2 = await t.deploy(alice, site.id, { 'index.html': 'version-2', 'v.txt': '2', 'only-v2.txt': 'yes' }, { activate: false });
-- const version = { [v1.id]: '1', [v2.id]: '2' };
--
-- await check('under concurrent traffic every response is exactly one deploy while the pointer flips 40 times', async () => {
-- let flips = 0;
-- let stop = false;
-- const flipper = (async () => {
-- while (!stop && flips < 40) {
-- const target = flips % 2 === 0 ? v2.id : v1.id;
-- const r = await t.api('POST', `/api/v1/deploys/${target}/activate`, { as: alice, json: {} });
-- assert.strictEqual(r.status, 200, r.text);
-- flips++;
-- }
-- })();
-- const seen = { 1: 0, 2: 0 };
-- const readers = Array.from({ length: 8 }, async () => {
-- for (let i = 0; i < 40; i++) {
-- const p = i % 2 ? '/v.txt' : '/';
-- const r = await t.get('flip.openvibe.host', p);
-- assert.strictEqual(r.status, 200, `${p}: ${r.status}`);
-- const v = version[r.headers['x-openvibe-deploy']];
-- assert.ok(v, 'the response names a deploy of this site');
-- assert.strictEqual(p === '/' ? r.text : `version-${r.text}`, `version-${v}`, 'the bytes belong to the deploy the response names');
-- seen[v]++;
-- }
-- });
-- await Promise.all(readers);
-- stop = true;
-- await flipper;
-- assert.ok(flips >= 10, `flipped ${flips} times`);
-- assert.ok(seen[1] + seen[2] === 320);
-- });
--
-- await check('a failure inside the switch leaves the pointer, history and outbox untouched', async () => {
-- await t.api('POST', `/api/v1/deploys/${v1.id}/activate`, { as: alice, json: {} });
-- const db = t.ctx.store.db;
-- const before = { active: (await db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id)).active_deploy_id, acts: (await db.prepare('SELECT COUNT(*) AS n FROM host_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 host_sites WHERE id = ?').get(site.id)).active_deploy_id, before.active);
-- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM host_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.host', '/')).text, 'version-1');
-- });
--
-- await check('expected_active guards against racing operators (409 with the current pointer)', async () => {
-- const r = await t.api('POST', `/api/v1/deploys/${v2.id}/activate`, { as: alice, json: { expected_active: v2.id } });
-- assert.strictEqual(r.status, 409);
-- assert.strictEqual(r.json().code, 'site.active_changed');
-- assert.strictEqual(r.json().active_deploy_id, v1.id);
-- const ok = await t.api('POST', `/api/v1/sites/${site.id}/rollback`, { as: alice, json: { expected_active: v1.id } });
-- assert.strictEqual(ok.status, 200);
-- assert.strictEqual(ok.json().active_deploy_id, v2.id);
-- });
--
-- await check('rollback with no earlier deploy says so; a failed deploy cannot be activated', async () => {
-- const s2 = await t.site(alice, project.id, 'single');
-- await t.deploy(alice, s2.id, { 'index.html': 'only' });
-- const r = await t.api('POST', `/api/v1/sites/${s2.id}/rollback`, { as: alice, json: {} });
-- assert.strictEqual(r.status, 409);
-- assert.strictEqual(r.json().code, 'deploy.no_previous');
-- const bad = await t.upload(alice, s2.id, { 'x.php': '<?php' });
-- const id = bad.json().deploy_id;
-- const act = await t.api('POST', `/api/v1/deploys/${id}/activate`, { as: alice, json: {} });
-- assert.strictEqual(act.status, 409);
-- assert.strictEqual(act.json().code, 'deploy.not_ready');
-- });
--
-- await check('artifacts are immutable in the database itself', async () => {
-- const db = t.ctx.store.db;
-- await assert.rejects(db.prepare("UPDATE host_deploys SET manifest = '{}' WHERE id = ?").run(v1.id), /immutable/);
-- await assert.rejects(db.prepare('UPDATE host_deploys SET total_bytes = 1 WHERE id = ?').run(v1.id), /immutable/);
-- await assert.rejects(db.prepare("UPDATE host_deploy_files SET sha256 = 'x' WHERE deploy_id = ?").run(v1.id), /immutable/);
-- await assert.rejects(db.prepare('DELETE FROM host_deploy_files WHERE deploy_id = ?').run(v1.id), /live deploy/);
-- await db.prepare("UPDATE host_deploys SET state = 'deleted' WHERE id = (SELECT id FROM host_deploys WHERE state = 'failed' LIMIT 1)").run();
-- await assert.rejects(db.prepare("UPDATE host_deploys SET state = 'ready' WHERE state = 'deleted'").run(), /cannot become ready/);
-- });
--
-- await check('the active pointer survives a restart', async () => {
-- await t.restart();
-- assert.strictEqual((await t.get('flip.openvibe.host', '/')).text, 'version-2');
-- assert.strictEqual((await t.get('flip.openvibe.host', '/only-v2.txt')).text, 'yes');
-- });
--
-- await t.close();
-- done();
--})();
ddiff --git a/test/host-secrets-dashboard.test.js b/test/host-secrets-dashboard.test.js
ddeleted file mode 100644
iindex fa4cd83..0000000
---- a/test/host-secrets-dashboard.test.js
++++ /dev/null
@@@ -1,287 +0,0 @@
--'use strict';
--/**
-- * "Deployment secrets never appear in public project state" (Wave 21 exit criterion), and the
-- * server-rendered dashboard: shared chrome, forms without JavaScript, CSRF that holds even though
-- * tenant sites are same-site, planted cookies ignored, readiness/release/metrics.
-- */
--const assert = require('assert');
--const { boot, check, done, SECRET } = require('./stageb/boot');
--const { tarball } = require('./stageb/tar');
--const { csrfToken } = require('../server/auth/forms');
--const { cookieNames } = require('../server/auth/sso');
--
--const ORIGIN = 'https://openvibe.host';
--
--(async () => {
-- const t = await boot();
-- const alice = t.user('alice');
-- const staff = t.user('ops', { role: 'admin' });
-- const bodies = [];
-- const keep = (r) => { bodies.push(r.text); return r; };
--
-- await check('no project, site, deploy, domain, quota or log response carries a secret or environment value', async () => {
-- const p = keep(await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'Secrets' } })).json().project;
-- const s = keep(await t.api('POST', `/api/v1/projects/${p.id}/sites`, { as: alice, json: { name: 'secret-test' } })).json().site;
-- const d = keep(await t.upload(alice, s.id, { 'index.html': 'hi', 'app.9f8e7d6c.js': 'x' })).json().deploy;
-- const bad = keep(await t.api('POST', `/api/v1/sites/${s.id}/deploys`, { as: alice, body: tarball([{ name: 'index.html', content: 'x' }, { name: '.env', content: 'STRIPE_SECRET_KEY=sk_live_TOPSECRET123\nDATABASE_URL=postgres://u:p@h/db' }]), headers: { 'content-type': 'application/gzip' } }));
-- assert.strictEqual(bad.status, 422);
-- keep(await t.api('POST', `/api/v1/sites/${s.id}/domains`, { as: alice, json: { hostname: 'www.secret-example.org' } }));
-- for (const pth of [`/api/v1/projects`, `/api/v1/projects/${p.id}`, `/api/v1/projects/${p.id}/quota`, `/api/v1/sites/${s.id}`, `/api/v1/sites/${s.id}/deploys`,
-- `/api/v1/deploys/${d.id}`, `/api/v1/deploys/${d.id}/log`, `/api/v1/deploys/${bad.json().deploy_id}/log`, `/api/v1/sites/${s.id}/domains`]) {
-- keep(await t.api('GET', pth, { as: alice }));
-- }
-- keep(await t.api('GET', `/api/v1/projects/${p.id}`, { as: staff }));
-- for (const pth of ['/api/ready', '/release.json', '/api/health']) keep(await t.api('GET', pth));
-- keep(await t.api('GET', '/', { session: alice }));
-- keep(await t.api('GET', `/projects/${p.id}`, { session: alice }));
-- keep(await t.api('GET', `/sites/${s.id}`, { session: alice }));
-- keep(await t.api('GET', `/deploys/${d.id}`, { session: alice }));
-- keep(await t.api('GET', `/deploys/${bad.json().deploy_id}`, { session: alice }));
-- const all = bodies.join('\n');
-- for (const needle of [SECRET, 'test-form-secret', 'sk_live_TOPSECRET123', 'postgres://', 'client_secret', 'OV_OAUTH_CLIENT_SECRET', 'HOST_FORM_SECRET', 'BEGIN PRIVATE', 'privkey', 'password']) {
-- assert.ok(!all.includes(needle), `a response contains ${needle}`);
-- }
-- const deployKeys = Object.keys(d).sort();
-- assert.deepStrictEqual(deployKeys, ['active', 'created_at', 'created_by', 'failure_code', 'file_count', 'id', 'log', 'manifest_sha256', 'new_bytes', 'project_id', 'site_id', 'source', 'state', 'total_bytes'].sort());
-- const events = JSON.stringify(await t.events());
-- assert.ok(!events.includes('sk_live') && !events.includes(SECRET));
-- });
--
-- await check('the signed-out home page is server-rendered with the shared chrome and useful without JavaScript', async () => {
-- const r = await t.api('GET', '/');
-- assert.strictEqual(r.status, 200);
-- assert.match(r.text, /<script src="\/shared\/navbar\.js\?v=[0-9a-f]{12}" defer>/);
-- assert.match(r.text, /<noscript><nav aria-label="Site"/);
-- assert.match(r.text, /id="ov-footer"/);
-- assert.match(r.text, /Sign in with OpenVibe/);
-- assert.match(r.text, /Hosting, compared honestly/, 'the front page leads with the catalog');
-- const h = await t.api('GET', '/hosting');
-- assert.strictEqual(h.status, 200);
-- assert.match(h.text, /nothing you upload is ever executed/);
-- // The limits table is the one /limits.json serves (limitsOf(config)), never a second copy.
-- assert.match(h.text, /id="limits"/);
-- assert.match(h.text, /Files in one deploy/);
-- assert.match(r.text, /href="\/shared\/showcase\.css\?v=[0-9a-f]{12}"/);
-- assert.match(r.headers['content-security-policy'], /frame-ancestors 'none'/);
-- // Release notifications: release-watch's EventSource on the Events realtime stream (openvibe-shared 1.17).
-- assert.match(r.headers['content-security-policy'], /connect-src 'self' https:\/\/openvibe\.network https:\/\/openvibe\.events;/);
-- assert.strictEqual(r.headers['x-frame-options'], 'DENY');
-- assert.strictEqual(r.headers['cache-control'], 'private, no-store');
-- // The public front page is indexable, with a canonical URL; nothing behind sign-in is.
-- assert.match(r.text, /<meta name="robots" content="index, follow">/);
-- assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.host\/">/);
-- const signedIn = await t.api('GET', '/', { session: alice });
-- assert.match(signedIn.text, /<meta name="robots" content="noindex, nofollow">/);
-- assert.ok(!/rel="canonical"/.test(signedIn.text));
-- const withQuery = await t.api('GET', '/?notice=hi');
-- assert.match(withQuery.text, /<meta name="robots" content="noindex, nofollow">/);
-- });
--
-- await check('robots.txt and sitemap.xml: the front page, static hosting, the catalog and the legal pages only; tenant sites keep their own', async () => {
-- const robots = await t.api('GET', '/robots.txt');
-- assert.strictEqual(robots.status, 200);
-- assert.strictEqual(robots.text, 'User-agent: *\nAllow: /$\nAllow: /terms$\nAllow: /privacy$\nAllow: /dmca$\nAllow: /hosting$\nAllow: /catalog$\nAllow: /catalog/*\nDisallow: /\n\nSitemap: https://openvibe.host/sitemap.xml\n');
-- const map = await t.api('GET', '/sitemap.xml');
-- assert.strictEqual(map.status, 200);
-- assert.match(map.headers['content-type'], /application\/xml/);
-- const locs = [...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
-- assert.deepStrictEqual(locs, ['https://openvibe.host/', 'https://openvibe.host/hosting', 'https://openvibe.host/terms', 'https://openvibe.host/privacy', 'https://openvibe.host/dmca',
-- 'https://openvibe.host/catalog', 'https://openvibe.host/catalog/promos', 'https://openvibe.host/catalog/providers']);
-- for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);
-- });
--
-- await check('the dashboard session is Host\'s own cookie; a forged ov_token is not a session', async () => {
-- // openvibe-sdk/sso keeps its session in ov_token (the shared navbar reads it); Host trusts only its own cookie.
-- const forged = 'not.a.jwt';
-- const r = await t.api('GET', '/', { headers: { cookie: `ov_token=${forged}` } });
-- assert.match(r.text, /Sign in with OpenVibe/);
-- const me = await t.api('GET', '/auth/me', { headers: { cookie: `ov_token=${forged}` } });
-- assert.strictEqual(me.status, 401);
-- // /api/v1 never reads a cookie: only a Bearer token authenticates.
-- const api = await t.api('GET', '/api/v1/projects', { headers: { cookie: `ov_token=${t.network.userToken(alice)}` } });
-- assert.strictEqual(api.status, 401);
-- });
--
-- const SESSION = cookieNames(t.config).session; // ov_host_session here (COOKIE_SECURE=false); __Host- in production
-- const mallory = t.user('mallory');
-- const malloryProjects = async () => (await t.api('GET', '/api/v1/projects', { as: mallory })).json().projects.length;
-- const setCookies = (r) => [].concat(r.headers['set-cookie'] || []);
-- const cookieValue = (r, name) => { const c = setCookies(r).find((x) => x.startsWith(`${name}=`)); return c === undefined ? undefined : decodeURIComponent(c.slice(name.length + 1).split(';')[0]); };
--
-- await check('cookie tossing: a planted, validly signed ov_token of another user is not a session, alone or next to a real one', async () => {
-- // A tenant page on <site>.openvibe.host can set ov_token=<its owner's token>; Domain=openvibe.host; Path=/<longer path>:
-- // the browser sends it first. It verifies, and it is still ignored.
-- const planted = `ov_token=${t.network.userToken(mallory)}`;
-- const alone = await t.api('GET', '/', { headers: { cookie: planted } });
-- assert.match(alone.text, /Sign in with OpenVibe/);
-- assert.strictEqual((await t.api('GET', '/auth/me', { headers: { cookie: planted } })).status, 401);
-- const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: mallory.subject });
-- const post = await t.api('POST', '/projects', { form: { csrf, name: 'Tossed', environment: 'production' }, headers: { cookie: planted, origin: ORIGIN } });
-- assert.ok(!/\/projects\/prj_/.test(post.headers.location || ''), post.text);
-- assert.strictEqual(await malloryProjects(), 0);
--
-- const both = await t.api('GET', '/', { session: alice, headers: { cookie: planted } });
-- assert.match(both.text, /Your projects/);
-- assert.ok(!/mallory/.test(both.text), 'the page is alice\'s, not mallory\'s');
-- const me = await t.api('GET', '/auth/me', { session: alice, headers: { cookie: planted } });
-- assert.strictEqual(me.json().user.subject_id, alice.subject);
-- const csrfA = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
-- const asAlice = await t.api('POST', '/projects', { session: alice, form: { csrf: csrfA, name: 'Tossed but mine', environment: 'production' }, headers: { cookie: planted, origin: ORIGIN } });
-- assert.strictEqual(asAlice.status, 303, asAlice.text);
-- assert.strictEqual(await malloryProjects(), 0, 'nothing lands in mallory\'s account');
-- const mine = (await t.api('GET', '/api/v1/projects', { as: alice })).json().projects;
-- assert.ok(mine.some((p) => p.name === 'Tossed but mine'));
-- for (const p of mine.filter((x) => x.name === 'Tossed but mine')) await t.api('DELETE', `/api/v1/projects/${p.id}`, { as: alice });
-- });
--
-- await check('sign-in issues the HttpOnly session cookie; a planted OAuth state cannot finish another account\'s sign-in', async () => {
-- const login = await t.api('GET', '/auth/login');
-- assert.strictEqual(login.status, 302);
-- const state = new URL(login.headers.location).searchParams.get('state');
-- assert.strictEqual(cookieValue(login, 'ov_host_flow'), state);
-- const flow = `ov_host_flow=${state}; ov_oauth_state=${state}; ov_oauth_verifier=${cookieValue(login, 'ov_oauth_verifier')}`;
-- const cb = await t.api('GET', `/auth/callback?code=${t.network.issueCode(alice)}&state=${state}`, { headers: { cookie: flow } });
-- assert.strictEqual(cb.status, 302, cb.text);
-- const set = setCookies(cb).find((c) => c.startsWith(`${SESSION}=`));
-- assert.match(set, /; Path=\/;/);
-- assert.match(set, /; HttpOnly/);
-- assert.match(set, /; SameSite=Lax/);
-- assert.ok(!/Domain=/i.test(set), set);
-- const dash = await t.api('GET', '/', { headers: { cookie: `${SESSION}=${cookieValue(cb, SESSION)}` } });
-- assert.match(dash.text, /Your projects/);
--
-- // Mallory starts a sign-in of her own, plants her state and verifier (Path=/auth/callback) and sends alice to the callback.
-- const theirs = await t.api('GET', '/auth/login');
-- const s2 = new URL(theirs.headers.location).searchParams.get('state');
-- const tossed = `ov_oauth_state=${s2}; ov_oauth_verifier=${cookieValue(theirs, 'ov_oauth_verifier')}`;
-- for (const cookie of [tossed, `${tossed}; ov_host_flow=${state}`]) {
-- const r = await t.api('GET', `/auth/callback?code=${t.network.issueCode(mallory)}&state=${s2}`, { headers: { cookie } });
-- assert.strictEqual(r.status, 400, r.text);
-- assert.strictEqual(cookieValue(r, SESSION), undefined);
-- }
-- });
--
-- await check('refresh renews the session of the same account only; sign-out clears it', async () => {
-- const session = `${SESSION}=${t.network.userToken(alice)}`;
-- const ok = await t.api('POST', '/auth/refresh', { headers: { cookie: `${session}; ov_refresh=${t.network.refreshToken(alice)}` } });
-- assert.strictEqual(ok.status, 200, ok.text);
-- assert.strictEqual(require('openvibe-sdk/sso').decodeJwtPayload(cookieValue(ok, SESSION)).subject_id, alice.subject);
-- // A planted ov_refresh of mallory's (Path=/auth/refresh), next to alice's session or alone, never becomes a session.
-- for (const cookie of [`${session}; ov_refresh=${t.network.refreshToken(mallory)}`, `ov_refresh=${t.network.refreshToken(mallory)}`]) {
-- const r = await t.api('POST', '/auth/refresh', { headers: { cookie } });
-- assert.strictEqual(r.status, 401, r.text);
-- assert.strictEqual(cookieValue(r, SESSION), undefined);
-- assert.strictEqual(cookieValue(r, 'ov_token'), undefined);
-- assert.strictEqual(cookieValue(r, 'ov_refresh'), undefined);
-- }
-- const out = await t.api('GET', '/auth/logout', { headers: { cookie: session } });
-- assert.strictEqual(out.status, 302);
-- assert.strictEqual(cookieValue(out, SESSION), '');
-- assert.match(setCookies(out).find((c) => c.startsWith(`${SESSION}=`)), /Expires=Thu, 01 Jan 1970/);
-- });
--
-- await check('in production the session cookie is __Host- prefixed and Secure', async () => {
-- assert.deepStrictEqual(cookieNames({ cookies: { secure: true } }), { session: '__Host-ov_host_session', flow: '__Host-ov_host_flow' });
-- // The guard around a stand-in SDK router whose callback writes ov_token, as setSession does.
-- const express = require('express');
-- const app = express();
-- app.use(require('cookie-parser')());
-- const auth = { router: (ex) => ex.Router().get('/callback', (_req, res) => { res.cookie('ov_token', 'tok', {}); res.send('ok'); }) };
-- app.use('/auth', require('../server/auth/sso').createHostSession({ auth, config: { cookies: { secure: true } } }).router(express));
-- const srv = await new Promise((resolve) => { const x = app.listen(0, '127.0.0.1', () => resolve(x)); });
-- try {
-- const r = await fetch(`http://127.0.0.1:${srv.address().port}/auth/callback?state=s1`, { headers: { cookie: '__Host-ov_host_flow=s1' } });
-- assert.strictEqual(r.status, 200);
-- const set = r.headers.getSetCookie().find((c) => c.startsWith('__Host-ov_host_session='));
-- assert.match(set, /^__Host-ov_host_session=tok; Max-Age=\d+; Path=\/; Expires=[^;]+; HttpOnly; Secure; SameSite=Lax$/);
-- } finally { await new Promise((resolve) => srv.close(resolve)); }
-- });
--
-- let projectId, siteId;
-- await check('forms: create a project and a site (Origin + form token), then upload a folder and see the log', async () => {
-- const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
-- const p = await t.api('POST', '/projects', { session: alice, form: { csrf, name: 'Dash project', environment: 'production' }, headers: { origin: ORIGIN } });
-- assert.strictEqual(p.status, 303, p.text);
-- projectId = p.headers.location.match(/\/projects\/(prj_[0-9A-Z]+)/)[1];
-- const page = await t.api('GET', `/projects/${projectId}`, { session: alice });
-- assert.match(page.text, /Dash project/);
-- assert.match(page.text, /Deploys in the last 24 h/);
-- const s = await t.api('POST', `/projects/${projectId}/sites`, { session: alice, form: { csrf, name: 'dash-site' }, headers: { origin: ORIGIN } });
-- assert.strictEqual(s.status, 303, s.text);
-- siteId = s.headers.location.match(/\/sites\/(sit_[0-9A-Z]+)/)[1];
-- const boundary = 'dashBOUNDARY';
-- const part = (name, filename, content) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"${filename ? `; filename="${filename}"` : ''}\r\n\r\n${content}\r\n`;
-- const body = part('csrf', null, csrf) + part('strip', null, 'folder') + part('activate', null, '1') + part('files', 'site/index.html', 'DASH OK') + `--${boundary}--\r\n`;
-- const up = await t.api('POST', `/sites/${siteId}/deploys`, { session: alice, body, headers: { origin: ORIGIN, 'content-type': `multipart/form-data; boundary=${boundary}` } });
-- assert.strictEqual(up.status, 303, up.text);
-- assert.match(up.headers.location, /^\/deploys\/dpl_/);
-- const log = await t.api('GET', up.headers.location.split('?')[0], { session: alice });
-- assert.match(log.text, /Upload log/);
-- assert.match(log.text, /nothing was executed/);
-- assert.strictEqual((await t.get('dash-site.openvibe.host', '/')).text, 'DASH OK');
-- const sitePage = await t.api('GET', `/sites/${siteId}`, { session: alice });
-- assert.match(sitePage.text, /Upload a deploy/);
-- assert.match(sitePage.text, /webkitdirectory/);
-- const bad = await t.api('POST', `/sites/${siteId}/deploys`, { session: alice, body: part('csrf', null, csrf) + part('files', 'x.php', '<?php') + `--${boundary}--\r\n`, headers: { origin: ORIGIN, 'content-type': `multipart/form-data; boundary=${boundary}` } });
-- assert.strictEqual(bad.status, 303);
-- assert.match(decodeURIComponent(bad.headers.location), /Upload refused/);
-- // "Preview only": the site keeps its active deploy; the site page links the member to the private preview.
-- assert.match(sitePage.text, /name="mode" value="preview"/);
-- const pv = await t.api('POST', `/sites/${siteId}/deploys`, { session: alice, body: part('csrf', null, csrf) + part('strip', null, 'folder') + part('mode', null, 'preview') + part('files', 'site/index.html', 'DASH PREVIEW') + `--${boundary}--\r\n`, headers: { origin: ORIGIN, 'content-type': `multipart/form-data; boundary=${boundary}` } });
-- assert.strictEqual(pv.status, 303, pv.text);
-- assert.match(decodeURIComponent(pv.headers.location), /Deployed as a preview/);
-- const pvId = pv.headers.location.match(/\/deploys\/(dpl_[0-9A-Z]+)/)[1];
-- assert.strictEqual((await t.get('dash-site.openvibe.host', '/')).text, 'DASH OK', 'a preview never goes live');
-- const withPreview = await t.api('GET', `/sites/${siteId}`, { session: alice });
-- assert.ok(withPreview.text.includes(`href="/preview/${pvId}/"`), 'the site page links the live preview');
-- assert.match(withPreview.text, /<strong class="preview">preview<\/strong>/);
-- const seen = await t.api('GET', `/preview/${pvId}/`, { session: alice });
-- assert.strictEqual(seen.status, 200, seen.text);
-- assert.strictEqual(seen.text, 'DASH PREVIEW');
-- });
--
-- await check('forms: domain instructions, rollback, and refusal of cross-origin posts even with a valid token', async () => {
-- const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
-- const d = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { csrf, hostname: 'www.dash-example.org' }, headers: { origin: ORIGIN } });
-- assert.strictEqual(d.status, 303, d.text);
-- const page = await t.api('GET', `/sites/${siteId}`, { session: alice });
-- assert.match(page.text, /_openvibe-host\.www\.dash-example\.org/);
-- assert.match(page.text, /openvibe-host-verification=[0-9a-f]{40}/);
-- assert.match(page.text, /pending: not served/);
-- const tenant = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { csrf, hostname: 'evil.example.org' }, headers: { origin: 'https://attacker.openvibe.host' } });
-- assert.strictEqual(tenant.status, 403, 'a tenant page on the same site cannot drive the dashboard');
-- const noToken = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { hostname: 'evil.example.org' }, headers: { origin: ORIGIN } });
-- assert.strictEqual(noToken.status, 403);
-- const noOrigin = await t.api('POST', `/sites/${siteId}/domains`, { session: alice, form: { csrf, hostname: 'evil.example.org' } });
-- assert.strictEqual(noOrigin.status, 403);
-- const anon = await t.api('POST', '/projects', { form: { name: 'x' }, headers: { origin: ORIGIN } });
-- assert.strictEqual(anon.status, 303);
-- assert.match(anon.headers.location, /^\/auth\/login/);
-- });
--
-- await check('machine endpoints: /api/ready is truthful, /release.json, /metrics only for direct loopback', async () => {
-- const ready = await t.api('GET', '/api/ready');
-- assert.strictEqual(ready.status, 200, ready.text);
-- const body = ready.json();
-- assert.strictEqual(body.ready, true);
-- const names = body.checks.map ? body.checks.map((c) => c.name) : Object.keys(body.checks);
-- for (const n of ['db', 'storage', 'network_jwks', 'events_relay', 'domain_checks']) assert.ok(names.includes(n), n);
-- assert.match(ready.text, /relay off/);
-- const rel = await t.api('GET', '/release.json');
-- assert.strictEqual(rel.json().service, 'host');
-- assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json()).errors, []);
-- assert.strictEqual(rel.json().metrics_url, '/release-metrics');
-- const m = await t.api('GET', '/metrics');
-- assert.strictEqual(m.status, 200);
-- assert.match(m.text, /http_requests_total\{method="GET",route="tenant_site"/);
-- assert.match(m.text, /host_sites \d+/);
-- const proxied = await t.api('GET', '/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });
-- assert.strictEqual(proxied.status, 404);
-- const unknown = await t.get('nothing.example.net', '/');
-- assert.strictEqual(unknown.status, 404);
-- assert.match(unknown.text, /Unknown host/);
-- });
--
-- await t.close();
-- done();
--})();
ddiff --git a/test/host-site-config.test.js b/test/host-site-config.test.js
ddeleted file mode 100644
iindex f6c61f7..0000000
---- a/test/host-site-config.test.js
++++ /dev/null
@@@ -1,149 +0,0 @@
--'use strict';
--/**
-- * Per-site configuration (plan T12 J3, decision D4): response headers, local-only redirects and the
-- * SPA fallback, stored per site (host_site_config) and applied by http/tenant.js BEFORE the
-- * platform's headers. The platform's security, routing and caching headers can never be set through
-- * it (on write or from a smuggled row); a redirect target is always a local path; and a config is
-- * private to its site and project.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./stageb/boot');
--
--const EMPTY = { headers: {}, redirects: [], spa: false };
--
--(async () => {
-- const t = await boot();
-- const alice = t.user('alice');
-- const bob = t.user('bob');
-- const staff = t.user('root', { role: 'admin' });
-- const pa = await t.project(alice, 'A');
-- const pb = await t.project(bob, 'B');
-- const alpha = await t.site(alice, pa.id, 'alpha');
-- const beta = await t.site(bob, pb.id, 'beta');
-- await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA HOME', 'about.html': 'ALPHA ABOUT', 'app.3f2a9c1b.js': 'js();', '404.html': 'ALPHA NOT FOUND' });
-- await t.deploy(bob, beta.id, { 'index.html': 'BETA HOME' });
--
-- const put = (as, id, json) => t.api('PUT', `/api/v1/sites/${id}/config`, { as, json });
--
-- await check('defaults: no row means no headers, no redirects, no SPA', async () => {
-- const r = await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: alice });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual(r.json().config, EMPTY);
-- assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM host_site_config').get()).n, 0);
-- });
--
-- await check('the API sets, reads and resets a config', async () => {
-- const set = await put(alice, alpha.id, {
-- headers: { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' },
-- redirects: [{ from: '/old', to: '/new', status: 308 }, { from: '/moved', to: '/about.html' }],
-- spa: true,
-- });
-- assert.strictEqual(set.status, 200, set.text);
-- assert.deepStrictEqual(set.json().config.headers, { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' });
-- assert.deepStrictEqual(set.json().config.redirects, [{ from: '/old', to: '/new', status: 308 }, { from: '/moved', to: '/about.html', status: 301 }]);
-- assert.strictEqual(set.json().config.spa, true);
-- assert.deepStrictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: alice })).json().config, set.json().config);
-- const del = await t.api('DELETE', `/api/v1/sites/${alpha.id}/config`, { as: alice });
-- assert.strictEqual(del.status, 200, del.text);
-- assert.deepStrictEqual(del.json().config, EMPTY);
-- assert.strictEqual((await t.get('alpha.openvibe.host', '/old')).status, 404, 'reset drops the redirect');
-- });
--
-- await check('configured headers ride on 200, the 404 and the 451 after a takedown', async () => {
-- assert.strictEqual((await put(alice, alpha.id, { headers: { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' } })).status, 200);
-- const ok = await t.get('alpha.openvibe.host', '/');
-- assert.strictEqual(ok.status, 200);
-- assert.strictEqual(ok.headers['x-frame-options'], 'SAMEORIGIN');
-- assert.strictEqual(ok.headers['x-content-language'], 'en');
-- assert.strictEqual(ok.headers['x-served-by'], 'OpenVibe.Host');
-- const nf = await t.get('alpha.openvibe.host', '/nope.html');
-- assert.strictEqual(nf.status, 404);
-- assert.strictEqual(nf.text, 'ALPHA NOT FOUND');
-- assert.strictEqual(nf.headers['x-frame-options'], 'SAMEORIGIN');
-- const tk = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'config test' } });
-- assert.strictEqual(tk.status, 201, tk.text);
-- const down = await t.get('alpha.openvibe.host', '/');
-- assert.strictEqual(down.status, 451);
-- assert.strictEqual(down.headers['x-frame-options'], 'SAMEORIGIN');
-- assert.strictEqual(down.headers['clear-site-data'], '"cache", "storage"');
-- assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff })).status, 200);
-- assert.strictEqual((await t.get('alpha.openvibe.host', '/')).status, 200);
-- });
--
-- await check('reserved headers and header injection are refused on write', async () => {
-- for (const name of ['Content-Security-Policy', 'Strict-Transport-Security', 'Set-Cookie', 'X-Forwarded-For', 'X-Forwarded-Proto', 'Cache-Control', 'Content-Type', 'X-Content-Type-Options', 'Location', 'Service-Worker-Allowed', 'Alt-Svc']) {
-- const r = await put(alice, alpha.id, { headers: { [name]: 'x' } });
-- assert.strictEqual(r.status, 422, `${name} → ${r.status} ${r.text}`);
-- assert.strictEqual(r.json().code, 'site_config.reserved_header', name);
-- }
-- const inj = await put(alice, alpha.id, { headers: { 'X-Ok': 'a\r\nSet-Cookie: sid=1' } });
-- assert.strictEqual(inj.status, 422);
-- assert.strictEqual(inj.json().code, 'site_config.header_value');
-- assert.strictEqual((await t.get('alpha.openvibe.host', '/')).headers['set-cookie'], undefined);
-- });
--
-- await check('a reserved header smuggled into the row is never served (platform headers win)', async () => {
-- await t.ctx.store.db.prepare(`INSERT INTO host_site_config (site_id, headers, redirects, spa, updated_by, updated_at)
-- VALUES (?, ?::jsonb, ?::jsonb, false, ?, ?)
-- ON CONFLICT (site_id) DO UPDATE SET headers = EXCLUDED.headers`)
-- .run(alpha.id, JSON.stringify({
-- 'Content-Security-Policy': 'default-src *', 'Strict-Transport-Security': 'max-age=0',
-- 'Set-Cookie': 'sid=1', 'X-Forwarded-For': '1.2.3.4', 'X-Custom': 'kept',
-- }), JSON.stringify([]), 'test', t.clock.now());
-- const r = await t.get('alpha.openvibe.host', '/');
-- assert.match(r.headers['content-security-policy'], /default-src 'self'/);
-- assert.ok(!/default-src \*/.test(r.headers['content-security-policy']), 'the tenant CSP cannot be replaced');
-- assert.strictEqual(r.headers['strict-transport-security'], undefined);
-- assert.strictEqual(r.headers['set-cookie'], undefined);
-- assert.strictEqual(r.headers['x-forwarded-for'], undefined);
-- assert.strictEqual(r.headers['x-custom'], 'kept', 'a non-reserved header in the same row still applies');
-- });
--
-- await check('no open redirect: a foreign target is refused on write; a local one is kept', async () => {
-- for (const to of ['//evil.com', '/\\evil.com', 'https://evil.com', 'http:evil.com', '/\t/evil.com', '/%09/evil.com', '///evil.com', '/%2f%2fevil.com', '/\n/evil.com', 'javascript:alert(1)', ' //evil.com', '\\evil.com']) {
-- const r = await put(alice, alpha.id, { redirects: [{ from: '/go', to }] });
-- assert.strictEqual(r.status, 422, `${JSON.stringify(to)} → ${r.status} ${r.text}`);
-- assert.strictEqual(r.json().code, 'site_config.redirect');
-- }
-- assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: 'https://evil.com/x', to: '/x' }] })).status, 422, 'a foreign "from" is refused too');
-- assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: '/go', to: '/new', status: 200 }] })).status, 422, 'only 301/302/307/308');
-- assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: '/old', to: '/about.html', status: 308 }] })).status, 200);
-- const r = await t.get('alpha.openvibe.host', '/old');
-- assert.strictEqual(r.status, 308);
-- assert.strictEqual(r.headers.location, '/about.html');
-- });
--
-- await check('SPA fallback: extensionless paths serve index.html; an extension is still a 404', async () => {
-- assert.strictEqual((await put(alice, alpha.id, { spa: true })).status, 200, 'spa only');
-- for (const p of ['/app', '/app/dashboard', '/settings/']) {
-- const r = await t.get('alpha.openvibe.host', p);
-- assert.strictEqual(r.status, 200, `${p} → ${r.status}`);
-- assert.strictEqual(r.text, 'ALPHA HOME');
-- assert.strictEqual(r.headers['content-type'], 'text/html; charset=utf-8');
-- }
-- assert.strictEqual((await t.get('alpha.openvibe.host', '/about.html')).text, 'ALPHA ABOUT', 'a real file wins');
-- for (const p of ['/missing.js', '/app/missing.css', '/nope.json']) assert.strictEqual((await t.get('alpha.openvibe.host', p)).status, 404, p);
-- assert.strictEqual((await put(alice, alpha.id, { spa: false })).status, 200);
-- assert.strictEqual((await t.get('alpha.openvibe.host', '/app/dashboard')).status, 404, 'SPA off restores the 404');
-- });
--
-- await check('a config is private to its site and project (404 to non-members; no leak to another site)', async () => {
-- assert.strictEqual((await put(alice, alpha.id, { headers: { 'X-Alpha-Secret': 'alpha-only' } })).status, 200);
-- for (const [method, json] of [['GET', undefined], ['PUT', { headers: { 'X-Evil': '1' } }], ['DELETE', undefined]]) {
-- const r = await t.api(method, `/api/v1/sites/${alpha.id}/config`, { as: bob, json });
-- assert.strictEqual(r.status, 404, `${method} → ${r.status} ${r.text}`);
-- }
-- const b = await t.get('beta.openvibe.host', '/');
-- assert.strictEqual(b.text, 'BETA HOME');
-- assert.strictEqual(b.headers['x-alpha-secret'], undefined);
-- // A service token needs host.site.config AND the acting principal's project role.
-- const noCap = t.network.serviceToken('codes', ['host.site.manage']);
-- assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: noCap, headers: { 'x-ov-subject': alice.subject } })).status, 403);
-- const stranger = t.network.serviceToken('codes', ['host.site.config']);
-- assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: stranger, headers: { 'x-ov-subject': bob.subject } })).status, 404, 'a non-member service learns nothing');
-- assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: stranger, headers: { 'x-ov-subject': alice.subject } })).status, 200);
-- });
--
-- await t.close();
-- done();
--})();
ddiff --git a/test/host-sitemap.test.js b/test/host-sitemap.test.js
ddeleted file mode 100644
iindex d47a71d..0000000
---- a/test/host-sitemap.test.js
++++ /dev/null
@@@ -1,109 +0,0 @@
--'use strict';
--/**
-- * Tenant sitemap.xml and robots.txt when the deploy ships none: the generated sitemap lists the
-- * manifest's HTML pages (index → the directory URL, the error page left out) on the host the request
-- * came in on, so a verified custom domain gets its own copy under its own name; robots welcomes
-- * crawlers to a production site and keeps a sandbox out (Disallow: /). A file the tenant uploaded at
-- * either path always wins. Neither generated body carries a secret, and both say how to cache them.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./stageb/boot');
--
--const HOST = 'widgets.openvibe.host';
--const CUSTOM = 'www.widgets-example.org';
--
--(async () => {
-- const t = await boot();
-- const alice = t.user('alice', { display_name: 'Alice' });
-- const project = await t.project(alice, 'Widgets');
-- const site = await t.site(alice, project.id, 'widgets');
-- const origin = `https://${HOST}`;
--
-- await t.deploy(alice, site.id, {
-- 'index.html': '<h1>Home</h1>',
-- 'about.html': '<h1>About</h1>',
-- 'docs/index.html': '<h1>Docs</h1>',
-- '404.html': '<h1>Gone</h1>',
-- 'style.css': 'body{}',
-- 'notes.txt': 'hi',
-- }, { activate: true });
--
-- await check('a generated sitemap lists the manifest HTML paths on this host (index → /, 404 left out)', async () => {
-- const r = await t.get(HOST, '/sitemap.xml');
-- assert.strictEqual(r.status, 200, r.text);
-- assert.match(r.headers['content-type'], /application\/xml/);
-- const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
-- assert.deepStrictEqual([...new Set(locs)].sort(), [`${origin}/`, `${origin}/about.html`, `${origin}/docs/`].sort());
-- });
--
-- await check('a generated robots.txt welcomes crawlers and names the sitemap', async () => {
-- const r = await t.get(HOST, '/robots.txt');
-- assert.strictEqual(r.status, 200);
-- assert.match(r.headers['content-type'], /text\/plain/);
-- assert.match(r.text, /^User-agent: \*\nAllow: \/\n/);
-- assert.ok(r.text.includes(`Sitemap: ${origin}/sitemap.xml`), r.text);
-- });
--
-- await check('a verified custom domain serves its own generated sitemap under its own name', async () => {
-- const add = await t.api('POST', `/api/v1/sites/${site.id}/domains`, { as: alice, json: { hostname: CUSTOM } });
-- assert.strictEqual(add.status, 201, add.text);
-- const dom = add.json().domain;
-- t.dns.set(`_openvibe-host.${CUSTOM}`, [dom.instructions.verification.value]);
-- const v = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });
-- assert.strictEqual(v.json().domain.status, 'verified', v.text);
-- const r = await t.get(CUSTOM, '/sitemap.xml');
-- assert.strictEqual(r.status, 200, r.text);
-- const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
-- assert.deepStrictEqual([...new Set(locs)].sort(), [`https://${CUSTOM}/`, `https://${CUSTOM}/about.html`, `https://${CUSTOM}/docs/`].sort());
-- const rb = await t.get(CUSTOM, '/robots.txt');
-- assert.ok(rb.text.includes(`Sitemap: https://${CUSTOM}/sitemap.xml`), rb.text);
-- });
--
-- await check("the tenant's own sitemap.xml and robots.txt win", async () => {
-- await t.deploy(alice, site.id, {
-- 'index.html': '<h1>Home</h1>',
-- 'sitemap.xml': '<?xml version="1.0" encoding="UTF-8"?><urlset><url><loc>https://example.com/own</loc></url></urlset>',
-- 'robots.txt': 'User-agent: *\nDisallow: /private\n',
-- }, { activate: true });
-- const sm = await t.get(HOST, '/sitemap.xml');
-- assert.strictEqual(sm.status, 200);
-- assert.match(sm.headers['content-type'], /application\/xml/);
-- assert.ok(sm.text.includes('https://example.com/own'), sm.text);
-- assert.ok(!sm.text.includes('/about.html'), sm.text);
-- const rb = await t.get(HOST, '/robots.txt');
-- assert.strictEqual(rb.text, 'User-agent: *\nDisallow: /private\n');
-- assert.match(rb.headers['content-type'], /text\/plain/);
-- });
--
-- await check('a sandbox site generates Disallow: / (and stays noindex)', async () => {
-- const sandbox = await t.project(alice, 'Sandbox', { environment: 'sandbox' });
-- const sb = await t.site(alice, sandbox.id, 'playground');
-- await t.deploy(alice, sb.id, { 'index.html': '<h1>Play</h1>' }, { activate: true });
-- const r = await t.get(`${sb.name}.openvibe.host`, '/robots.txt');
-- assert.strictEqual(r.status, 200, r.text);
-- assert.match(r.text, /^User-agent: \*\nDisallow: \/\n*$/);
-- assert.ok(!r.text.includes('Sitemap'), r.text);
-- assert.strictEqual(r.headers['x-robots-tag'], 'noindex, nofollow');
-- });
--
-- await check('generated files are cacheable, answer HEAD, and never carry a secret', async () => {
-- await t.deploy(alice, site.id, { 'index.html': '<h1>Home</h1>', 'about.html': '<h1>About</h1>' }, { activate: true });
-- const sm = await t.get(HOST, '/sitemap.xml');
-- const rb = await t.get(HOST, '/robots.txt');
-- assert.match(sm.headers['cache-control'], /max-age=/);
-- assert.match(rb.headers['cache-control'], /max-age=/);
-- const head = await t.get(HOST, '/sitemap.xml', { method: 'HEAD' });
-- assert.strictEqual(head.status, 200);
-- assert.strictEqual(head.text, '');
-- for (const r of [sm, rb]) {
-- for (const secretish of [t.SECRET, '_openvibe-host', 'openvibe-host-verification', 'prj_', '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/host-storage-media.test.js b/test/host-storage-media.test.js
ddeleted file mode 100644
iindex b1b3496..0000000
---- a/test/host-storage-media.test.js
++++ /dev/null
@@@ -1,309 +0,0 @@
--'use strict';
--/**
-- * Host's Media-backed object store (HOST_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 HOST_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: 'host', scope: MEDIA_SCOPE, clientId: 'host', 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: { HOST_OBJECT_STORE: 'media', OV_MEDIA_URL: media.url } });
-- const alice = t.user('alice');
--
-- await check('booting with HOST_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.host', '/')).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.host', '/')).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.host', '/')).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.host', '/');
-- 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.host', '/');
-- 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.host', '/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.host', '/y.html')).text, 'YYYY');
-- });
--
-- await check('with HOST_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.host', '/')).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.host', '/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 host_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 host_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/host-uploads.test.js b/test/host-uploads.test.js
ddeleted file mode 100644
iindex 95af22e..0000000
---- a/test/host-uploads.test.js
++++ /dev/null
@@@ -1,146 +0,0 @@
--'use strict';
--/**
-- * Upload validation: path traversal, links and special files, hidden files, server-side code,
-- * credentials, bad archives and gzip bombs are refused as a whole. A refused upload is a failed
-- * deploy with a log and host.deploy.failed; nothing is stored and the active deploy is unchanged.
-- */
--const assert = require('assert');
--const fs = require('fs');
--const path = require('path');
--const zlib = require('zlib');
--const { boot, check, done } = require('./stageb/boot');
--const { tarball } = require('./stageb/tar');
--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('host.deploy.failed')).length;
-- const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys?activate=1`, { as: alice, body: raw || tarball(entries, { gzip }), headers: { 'content-type': contentType || 'application/gzip' } });
-- assert.ok([413, 422].includes(r.status), `${code}: status ${r.status} ${r.text}`);
-- const body = r.json();
-- assert.strictEqual(body.code, code, `expected ${code}, got ${body.code}: ${body.detail}`);
-- assert.match(body.deploy_id, /^dpl_/);
-- assert.ok(body.log.some((l) => /deploy refused/.test(l)));
-- const failed = (await t.api('GET', `/api/v1/deploys/${body.deploy_id}`, { as: alice })).json().deploy;
-- assert.strictEqual(failed.state, 'failed');
-- assert.strictEqual(failed.failure_code, code);
-- assert.strictEqual(failed.file_count, 0);
-- assert.strictEqual((await t.events('host.deploy.failed')).length, before + 1);
-- assert.strictEqual((await t.get('uploads.openvibe.host', '/')).text, 'GOOD', 'the active deploy is unchanged');
-- assert.strictEqual(countObjects(), baseline, 'nothing was stored');
-- return body;
-- }
--
-- await check('path traversal in names: "..", absolute, embedded, backslash, pax and GNU long-name overrides', async () => {
-- await refused([{ name: '../evil.html', content: 'x' }], 'deploy.path_traversal');
-- await refused([{ name: '/etc/passwd', content: 'x' }], 'deploy.path_traversal');
-- await refused([{ name: 'a/../../b.html', content: 'x' }], 'deploy.path_traversal');
-- await refused([{ name: 'ok.html', content: 'x' }, { name: 'sub/./x.html', content: 'x' }], 'deploy.path_traversal');
-- await refused([{ name: '..\\..\\evil.html', content: 'x' }], 'deploy.invalid_path');
-- await refused([{ name: 'innocent.html', content: 'x', pax: { path: '../../etc/cron.d/x.html' } }], 'deploy.path_traversal');
-- await refused([{ name: 'innocent.html', content: 'x', longName: `${'a'.repeat(120)}/../../../evil.html` }], 'deploy.path_traversal');
-- await refused([{ name: 'a//b.html', content: 'x' }], 'deploy.invalid_path');
-- await refused([{ name: 'café.html', content: 'x' }], 'deploy.invalid_path');
-- });
--
-- await check('symbolic links, hard links, devices and FIFOs are refused, never followed or skipped', async () => {
-- await refused([{ name: 'index.html', content: 'x' }, { name: 'passwd.html', type: '2', linkname: '/etc/passwd' }], 'deploy.link_refused');
-- await refused([{ name: 'up.html', type: '2', linkname: '../../host.db' }], 'deploy.link_refused');
-- await refused([{ name: 'index.html', content: 'x' }, { name: 'hard.html', type: '1', linkname: 'index.html' }], 'deploy.link_refused');
-- await refused([{ name: 'tty.html', type: '3' }], 'deploy.special_file_refused');
-- await refused([{ name: 'disk.html', type: '4' }], 'deploy.special_file_refused');
-- await refused([{ name: 'pipe.html', type: '6' }], 'deploy.special_file_refused');
-- await refused([{ name: 'x.html', content: 'x', pax: { linkpath: '/etc/passwd' } }], 'deploy.link_refused');
-- });
--
-- await check('hidden files, server-side code, credentials and unknown types are refused with a reason', async () => {
-- const env = await refused([{ name: 'index.html', content: 'x' }, { name: '.env', content: 'API_KEY=sk_live_should_never_be_served' }], 'deploy.invalid_path');
-- assert.match(env.detail, /hidden file/);
-- await refused([{ name: '.git/config', content: '[core]' }], 'deploy.invalid_path');
-- const php = await refused([{ name: 'index.php', content: '<?php system($_GET["c"]); ?>' }], 'deploy.file_refused');
-- assert.match(php.detail, /never runs anything/);
-- await refused([{ name: 'cgi-bin/run.cgi', content: '#!/bin/sh' }], 'deploy.file_refused');
-- await refused([{ name: 'page.shtml', content: '<!--#exec cmd="id" -->' }], 'deploy.file_refused');
-- await refused([{ name: 'deploy.sh', content: 'rm -rf /' }], 'deploy.file_refused');
-- await refused([{ name: 'server.key', content: '-----BEGIN PRIVATE KEY-----' }], 'deploy.file_refused');
-- await refused([{ name: 'config.env', content: 'SECRET=1' }], 'deploy.file_refused');
-- await refused([{ name: 'tool.exe', content: 'MZ' }], 'deploy.file_refused');
-- await refused([{ name: 'thing.xyz', content: '?' }], 'deploy.type_not_allowed');
-- const ok = await t.upload(alice, site.id, { '.well-known/security.txt': 'Contact: mailto:[email protected]', 'index.html': 'GOOD2' }, { activate: false });
-- assert.strictEqual(ok.status, 201, '.well-known/ is published');
-- baseline = countObjects();
-- });
--
-- await check('duplicates, file/directory conflicts, empty uploads and broken archives', async () => {
-- await refused([{ name: 'a.html', content: '1' }, { name: './a.html', content: '2' }], 'deploy.duplicate_path');
-- await refused([{ name: 'docs', content: '1' }, { name: 'docs/index.html', content: '2' }], 'deploy.path_conflict');
-- await refused([], 'deploy.empty');
-- await refused(null, 'archive.corrupt', { raw: Buffer.from('this is not an archive at all, not even close'.repeat(20)) });
-- await refused(null, 'archive.corrupt', { raw: Buffer.concat([Buffer.from([0x1f, 0x8b]), Buffer.from('garbage')]) });
-- const tar = tarball([{ name: 'a.html', content: 'x'.repeat(2000) }], { gzip: false });
-- await refused(null, 'archive.corrupt', { raw: zlib.gzipSync(tar.subarray(0, 1200)) });
-- const corrupt = Buffer.from(tarball([{ name: 'a.html', content: 'x' }], { gzip: false }));
-- corrupt[10] ^= 0xff; // breaks the header checksum
-- await refused(null, 'archive.corrupt', { raw: corrupt, contentType: 'application/x-tar' });
-- });
--
-- await check('a gzip bomb stops at the project limit instead of filling memory', async () => {
-- await t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: t.user('staff', { role: 'admin' }), json: { storage_bytes: 1024 * 1024 } });
-- const bomb = tarball([{ name: 'zeros.txt', content: Buffer.alloc(40 * 1024 * 1024, 0) }]);
-- assert.ok(bomb.length < 100 * 1024, 'a small upload');
-- const body = await refused(null, 'deploy.too_large', { raw: bomb });
-- assert.match(body.detail, /expands beyond|add up to/);
-- await t.api('PUT', `/api/v1/projects/${project.id}/quota`, { as: t.user('staff2', { role: 'admin' }), json: { storage_bytes: null } });
-- });
--
-- await check('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('host.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
ddeleted file mode 100644
iindex a1f03be..0000000
---- a/test/indexnow.test.js
++++ /dev/null
@@@ -1,104 +0,0 @@
--'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.host', `/${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.host';
-- 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
ddeleted file mode 100644
iindex b27575e..0000000
---- a/test/resource-index.test.js
++++ /dev/null
@@@ -1,225 +0,0 @@
--'use strict';
--// Host's authority resource index (ADR-048 section 3, capability host.resource.read, plan T13 step 8):
--// GET /api/v1/resources pages common.resource-summary@1 for the resources Host owns - its tenant sites
--// (host.site, sit_), the immutable deploys of those sites (host.deploy, dpl_) and their default and
--// custom domains (host.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 host.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: ['host.resource.read'] });
-- assert.strictEqual((await t.api('GET', '/api/v1/resources', { as: wrongAudience })).status, 401);
-- const noCap = t.network.serviceToken('codes', ['host.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', ['host.resource.read']) });
-- assert.strictEqual(allowed.status, 200, allowed.text);
-- });
--
-- const bearer = t.network.serviceToken('services', ['host.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: 'host.deploy', id: readyDeploy.id }, { kind: 'host.deploy', id: failedDeploy.id },
-- { kind: 'host.domain', id: customDomain.id },
-- { kind: 'host.site', id: siteA1.id }, { kind: 'host.site', id: siteA2.id }, { kind: 'host.site', id: siteB.id }, { kind: 'host.site', id: siteC.id },
-- ];
-- const byId = new Map(page.resources.map((r) => [r.id, r]));
-- for (const d of defaultDomains) expected.push({ kind: 'host.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))], ['host']);
-- 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:host:${s.project_id}:${s.kind.split('.')[1]}/${s.id}`, s.id);
-- }
-- assert.strictEqual(page.resources.find((r) => r.id === readyDeploy.id).ovrn, `ovrn:host:${prjA}:deploy/${readyDeploy.id}`);
-- assert.strictEqual(page.resources.find((r) => r.id === siteB.id).ovrn, `ovrn:host:${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 host.project kind exists.
-- assert.ok((await resourcesOf('?kind=host.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=host.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=host.deploy', auth);
-- assert.deepStrictEqual(deploys.resources.map((r) => r.id).sort(), [readyDeploy.id, failedDeploy.id].sort());
-- const domains = await resourcesOf('?kind=host.domain', auth);
-- assert.strictEqual(domains.resources.length, 5, "four default domains and A's custom one");
-- const scoped = await resourcesOf(`?project=${prjA}&kind=host.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=host.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=host.site&project=${prjA}`, auth)).resources,
-- aliceRows.filter((r) => r.kind === 'host.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:host:${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:host:${prjA}:project/${prjA}`],
-- ['an unknown type', `ovrn:host:${prjA}:widget/sit_01J8ZQ4Y7N3M2K1H0G9F8E7D6C`],
-- ["another service's OVRN", `ovrn:network:${prjA}:site/${siteA1.id}`],
-- ['the right id under another project', `ovrn:host:${prjB}:site/${siteA1.id}`],
-- ['an unknown id', `ovrn:host:${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/security-ssrf.test.js b/test/security-ssrf.test.js
ddeleted file mode 100644
iindex 01d647f..0000000
---- a/test/security-ssrf.test.js
++++ /dev/null
@@@ -1,85 +0,0 @@
--'use strict';
--/**
-- * Host fetches no URL or host a tenant chose (roadmap WS-R task 5, the SSRF class). The one place a
-- * tenant names a host is a custom domain, and it is proven by a DNS TXT record read through the
-- * configured resolver, never by a request to the domain: this suite adds custom domains that name
-- * or resolve to internal addresses in every spelling (loopback, decimal, IPv6, metadata, private
-- * ranges, internal names), verifies and rechecks them, serves requests with those Host headers and
-- * absolute-form targets, and records every outbound request of the process: none may go to any of
-- * them. (host-isolation.test.js pins that a tenant reaches only its own site through Host headers,
-- * paths and absolute-form targets.) And a ratchet over the files in server/ that make outbound
-- * requests.
-- */
--const assert = require('assert');
--const fs = require('fs');
--const net = require('net');
--const path = require('path');
--
--const outbound = [];
--const realFetch = globalThis.fetch;
--globalThis.fetch = (url, opts) => { outbound.push(String((url && url.url) || url)); return realFetch(url, opts); };
--const connects = [];
--const realConnect = net.Socket.prototype.connect;
--net.Socket.prototype.connect = function (...args) {
-- const o = Array.isArray(args[0]) ? args[0][0] : args[0];
-- if (o && typeof o === 'object' && o.host) connects.push(`${o.host}:${o.port}`);
-- return realConnect.apply(this, args);
--};
--
--const { boot, check, done } = require('./stageb/boot');
--
--(async () => {
-- const t = await boot();
-- const alice = t.user('alice');
-- const p = await t.project(alice, 'A');
-- const s = await t.site(alice, p.id, 'alice-site');
-- await t.deploy(alice, s.id, { 'index.html': 'ALICE' });
--
-- await check('custom domains naming internal hosts are refused or proven by TXT only, never requested', async () => {
-- const before = connects.length;
-- const names = ['www.alice-example.org', 'localhost', '127.0.0.1', '2130706433', '0x7f000001', '[::1]', '::ffff:127.0.0.1', '169.254.169.254', '10.0.0.1', 'metadata.google.internal',
-- 'admin.localhost', 'intranet.local', 'db.internal', '127.0.0.1.nip.io'];
-- const added = [];
-- for (const hostname of names) {
-- const r = await t.api('POST', `/api/v1/sites/${s.id}/domains`, { as: alice, json: { hostname } });
-- if (r.status !== 201) continue;
-- added.push(hostname);
-- const dom = r.json().domain;
-- if (t.dns) t.dns.set(`_openvibe-host.${dom.hostname}`, [dom.instructions.verification.value]);
-- await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice, json: {} });
-- await t.get(dom.hostname, '/');
-- }
-- for (const target of ['http://127.0.0.1:1/', 'http://169.254.169.254/latest/meta-data/', 'http://[::1]/']) {
-- await t.request({ method: 'GET', host: 'alice-site.openvibe.host', path: target });
-- }
-- assert.ok(added.includes('www.alice-example.org'), `a real domain was added and verified (control): ${added.join(', ')}`);
-- assert.ok(t.dnsCalls.length > 0, 'verification asked the resolver for TXT records');
-- // The only outbound requests are to the stand-in Network (keys, tokens); every connection goes
-- // to the dashboard itself or to it.
-- const mine = new URL(t.network.url);
-- assert.deepStrictEqual(outbound.filter((u) => !u.startsWith(t.network.url)), [], 'Host requested something other than Network');
-- const allowed = new Set([String(t.port), mine.port]);
-- const odd = connects.slice(before).filter((c) => !(/^(127\.0\.0\.1|::1|localhost):/.test(c) && allowed.has(c.split(':').pop())));
-- assert.deepStrictEqual(odd, [], 'a connection went to a tenant-named host');
-- });
--
-- await check('ratchet: no file in server/ makes an outbound request of its own (they go through openvibe-sdk)', () => {
-- const root = path.join(__dirname, '..');
-- const found = [];
-- const walk = (dir) => {
-- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
-- const f = path.join(dir, e.name);
-- if (e.isDirectory()) walk(f);
-- else if (e.name.endsWith('.js')) {
-- const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/.*$/gm, '$1');
-- if (/(^|[^.\w])fetch\(|\bhttps?\.(get|request)\(|new WebSocket\(|\bnet\.connect\(|require\(['"](axios|got|node-fetch|undici)['"]\)/m.test(src)) found.push(path.relative(root, f));
-- }
-- }
-- };
-- walk(path.join(root, 'server'));
-- assert.deepStrictEqual(found.sort(), [], 'a new outbound request site: a tenant-chosen host goes through openvibe-shared/egress; then add the file here with where it goes');
-- });
--
-- await t.close();
-- done();
--})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
iindex 7cd3bba..313db51 100644
---- a/test/service-kit.test.js
++++ b/test/service-kit.test.js
@@@ -15,7 +15,7 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
--const { check, done } = require('./stageb/boot');
++const { check, done } = require('./helpers/boot');
const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');
@@@ -39,12 +39,12 @@ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'u
assert.ok(/deadlineExitCode:\s*0/.test(src), 'deadlineExitCode is not 0');
});
-- await check('the worker stop is a stop step and the database closes after the drain; the 10 min upload timeout is kept', () => {
++ await check('the worker stop is a stop step and the database closes after the drain; with no uploads, requests time out at 30 s', () => {
const stop = src.indexOf('() => ctx.worker.stop()');
const close = src.indexOf('() => ctx.store.close()');
assert.ok(stop !== -1 && close !== -1, 'worker.stop / store.close steps not found');
assert.ok(stop < close, 'worker.stop must be a stop step (before the drain and the close steps)');
-- assert.ok(/server\.requestTimeout\s*=\s*10\s*\*\s*60_000/.test(src), 'server.requestTimeout is no longer 10 * 60_000');
++ assert.ok(/server\.requestTimeout\s*=\s*30_000/.test(src), 'server.requestTimeout is not 30_000 (Host takes no uploads since the engine moved to OpenVibe.Space)');
});
// Boot the real entry point on a temp PGlite database, worker and limits off; main() reads process.env.
ddiff --git a/test/stageb/boot.js b/test/stageb/boot.js
ddeleted file mode 100644
iindex 7271f0b..0000000
---- a/test/stageb/boot.js
++++ /dev/null
@@@ -1,150 +0,0 @@
--'use strict';
--/**
-- * Boots Host Stage B on a temp database and object store, with a controllable clock, a DNS table
-- * for custom-domain checks and a mock Network, and returns a raw HTTP client that can send any Host
-- * header and any request target (encoded traversal, absolute-form, …).
-- *
-- * const t = await boot();
-- * await t.api('POST', '/api/v1/projects', { as: user, json: { name: 'x' } })
-- * await t.get('mysite.openvibe.host', '/index.html')
-- * t.dns.set('_openvibe-host.www.example.org', ['openvibe-host-verification=…'])
-- */
--const fs = require('fs');
--const os = require('os');
--const path = require('path');
--const http = require('http');
--const { startNetwork } = require('./mocks');
--const { site: siteTar } = require('./tar');
--const { cookieNames } = require('../../server/auth/sso');
--
--// 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.HOST_ACTOR_LIMITS === undefined) process.env.HOST_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-host-secret-value';
--const DASHBOARD = 'openvibe.host';
--
--async function boot(opts = {}) {
-- const network = await startNetwork();
-- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-host-test-'));
-- const clock = opts.clock || makeClock();
-- const dns = new Map();
-- const dnsCalls = [];
-- const resolver = {
-- async resolveTxt(name) {
-- dnsCalls.push(name);
-- if (dns.get('__fail__')) { const e = new Error('timeout'); e.code = 'ETIMEOUT'; throw e; }
-- if (!dns.has(name)) { const e = new Error(`queryTxt ENOTFOUND ${name}`); e.code = 'ENOTFOUND'; throw e; }
-- return dns.get(name).map((v) => [v]);
-- },
-- };
-- const env = {
-- NODE_ENV: 'test', PORT: '0', BASE_URL: `https://${DASHBOARD}`, TRUST_PROXY: '1',
-- HOST_SITES_DOMAIN: 'openvibe.host',
-- OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
-- OV_OAUTH_CLIENT_ID: 'host', OV_OAUTH_CLIENT_SECRET: SECRET, COOKIE_SECURE: 'false',
-- HOST_FORM_SECRET: 'test-form-secret', HOST_WORKER: 'off', HOST_MIN_FREE_BYTES: '0',
-- HOST_STORAGE_DIR: path.join(dir, 'objects'),
-- HOST_PUBLIC_HOSTING: 'on', // the suites exercise the engine as launched; test/host-launch-switch.test.js covers off
-- ...(opts.env || {}),
-- };
-- const configLib = require('../../server/config');
-- const { createApp } = require('../../server/app');
-- const { createStore } = require('../../server/db');
-- // One database per boot (PGlite, or HOST_TEST_STORE=pg: the containers); a restart keeps it, like a file did.
-- const testdb = await require('../helpers/db').testDb();
-- const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
--
-- let server = null;
-- let built = null;
-- const t = { network, clock, dns, dnsCalls, dir, env, SECRET };
--
-- async function start() {
-- const config = configLib.load(env);
-- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl, indexnow: opts.indexnow });
-- server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
-- t.port = server.address().port;
-- t.app = built.app;
-- t.ctx = built.ctx;
-- t.config = config;
-- }
-- async function stop() {
-- if (server) { server.closeAllConnections(); await new Promise((r) => server.close(r)); }
-- if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.stopMetrics(); }
-- 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, `${cookieNames(t.config).session}=${network.userToken(o.session)}`].filter(Boolean).join('; ');
-- let body = o.body;
-- if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
-- if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
-- if (body != null && headers['content-length'] == null) headers['content-length'] = Buffer.byteLength(body);
-- const req = http.request({ host: '127.0.0.1', port: t.port, method: o.method || 'GET', path: o.path, headers, setHost: o.host === undefined }, (res) => {
-- const chunks = [];
-- res.on('data', (c) => chunks.push(c));
-- res.on('end', () => {
-- const buf = Buffer.concat(chunks);
-- const text = buf.toString('utf8');
-- resolve({ status: res.statusCode, headers: res.headers, body: buf, text, json() { return JSON.parse(text); } });
-- });
-- });
-- req.on('error', reject);
-- if (body != null) req.write(body);
-- req.end();
-- });
-- }
--
-- t.request = request;
-- t.api = (method, p, o = {}) => request({ ...o, method, host: DASHBOARD, path: p });
-- t.get = (host, p, o = {}) => request({ ...o, method: o.method || 'GET', host, path: p });
--
-- /** Outbox rows as parsed envelopes. */
-- t.events = 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
ddeleted file mode 100644
iindex f625270..0000000
---- a/test/stageb/media.js
++++ /dev/null
@@@ -1,150 +0,0 @@
--'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 = 'host' } = {}) {
-- 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
ddeleted file mode 100644
iindex 853917e..0000000
---- a/test/stageb/mocks.js
++++ /dev/null
@@@ -1,84 +0,0 @@
--'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-host-secret-value') return json(401, { error: 'invalid_client' });
-- if (body.grant_type === 'client_credentials') {
-- let scope = body.scope;
-- if (scope && typeof scope === 'object') scope = Object.values(scope).join(' ');
-- const cap = String(scope || '').split(/\s+/).filter(Boolean);
-- return json(200, { access_token: sign({ sub: `svc:${body.client_id}`, actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });
-- }
-- // 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.host'], 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.host'], 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.host'], 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
ddeleted file mode 100644
iindex 9c1e673..0000000
---- a/test/stageb/tar.js
++++ /dev/null
@@@ -1,67 +0,0 @@
--'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 };