openvibers/openvibe.host Commits
Accept CI-built git deploys as previews with immutable commit provenance (#15)
Changes
@@@ -305,6 +305,8 @@ People present their Network user JWT as a Bearer token. Services and apps prese| `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) |@@@ -316,6 +318,41 @@ The four capability ids (`host.site.config` since v0.83.0) and the service manifActivation 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++```++### EventsThrough the `openvibe-sdk` v0.25.0 transactional outbox (`event_outbox`), inside the transaction that makes the change:@@@ -372,7 +409,7 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant- 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 not in `ovhost backup` (only `host.db`).--- No deploy of a Git repository (uploads only).++- 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.@@@ -390,6 +427,7 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant- **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.- **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.@@@ -39,7 +39,7 @@"deployed": "loopback only, not launched","placeholderInSites": true,"adoptedByServices": [],-- "updated": "2026-09-28",++ "updated": "2026-10-03","capabilitiesNote": "released in openvibe-contracts v0.24.0; v0.32.0 adds the four takedown routes to host.site.manage.implementedBy","launchReady": true,"features": [@@@ -53,6 +53,7 @@"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 (not launched)",++ "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)"]}@@@ -150,6 +150,8 @@ escape a directory: there is no directory.| **Certificate-validation files** (`.well-known/acme-challenge/`, `.well-known/pki-validation/`), which would let a tenant prove control of `<site>.openvibe.host` to a CA over HTTPS | **added:** refused in uploads and never served (`paths.js:49-53`); the operator issues every certificate | fixed (`1e48cb2`) || **Duplicate paths, file/directory conflicts, empty uploads** | refused (`validate.js:43,58`) | fixed || **A refused upload leaves partial state** | nothing is stored; the refusal is a `failed` deploy with its log and `host.deploy.failed`; the active deploy keeps serving | fixed |++| **Git deploys** (`POST /sites/:id/source/deploys`, Phase 1) | **added:** the build runs in the project's own CI; Host receives its output through the same reader, validator, quotas, upload gate and failure records as an upload, so a hostile build output is refused exactly like a hostile upload. Host never clones, fetches or runs anything, so there is no outbound request to a tenant-chosen URL (`security-ssrf.test.js` is unchanged) and no build to isolate. The ref must be the site's connected branch and the commit a full SHA; the deploy is always the site's preview and never moves `active_deploy_id` (`test/host-git-source.test.js`) | fixed by design |++| **A forged or stolen CI token** | the CI's token is the trust boundary: an app principal that is a `deployer` of the project (or a person's token), so it can do what any deployer can; a git deploy only reaches the preview, and going live needs the ordinary activate. The repository credential stays with the CI; Host stores only the public repo URL, branch and commit (`host_site_sources`, `host_deploy_git`), and a source write naming any other field is refused | accepted: rotate the app's client secret in Network; Run (plan T14) owns any in-Host build || **Filling the shared disk** | **added:** deploys are refused with `507 storage.host_full` while the object store's filesystem has less than `HOST_MIN_FREE_BYTES` (default 5 GiB) free (`deploys.js:78-86`); `/api/ready` reports `disk_headroom`. Serving is unaffected. See also §6. | fixed (`0ff29d5`) |## 3. Custom domains@@@ -272,8 +274,8 @@ the history of any site.| `/metrics` | direct loopback callers only; nginx returns 404 (`tenants.conf.tmpl:72`) | fixed || `/api/ready`, `/api/health`, `/release.json` | no secrets, no tenant data (`host-secrets-dashboard`) | fixed || Secrets in responses | allowlisted serializers (`server/http/serialize.js`); tested for the client secret, form secret, uploaded `.env` values and credential-like strings | fixed |--| Tenant secrets | Stage B stores none: no environment, build secrets or deploy keys | fixed by design |--| Tenant code execution | none: no build step, no server-side code | fixed by design |++| Tenant secrets | Stage B stores none: no environment, build secrets, deploy keys or repository credentials. A git source is a public repo URL and branch; the credential that reads the repository and the token that posts the build stay in the tenant's CI, and that CI token is the trust boundary | fixed by design |++| Tenant code execution | none: no build step, no server-side code. Git deploys are built in the tenant's own CI and arrive as files validated like an upload; Host never clones, fetches or runs tenant code (in-Host builds belong to Run, plan T14) | fixed by design || The tenant vhost install | transactional (`nginx -t`, restore on failure); **added:** removes the interim pending vhost in the same change (`lib/nginx.js:100-`, `nginx.tenants.replaces`) | fixed (`1e48cb2`) |## Residual risksnnew file mode 100644@@@ -0,0 +1,38 @@++-- phase: expand++-- Git deploys, Phase 1 (plan T12 Stage B): provenance and preview, with the build OUTSIDE Host.++-- A site may name the Git repository and branch its project's own CI builds from. That CI checks the++-- commit out, builds it and posts the output to POST /api/v1/sites/:id/source/deploys with the ref and++-- the commit it built; Host validates the files exactly like an upload and stores them as a deploy++-- with source 'git' that becomes the site's live preview, never its active deploy. Host never clones,++-- fetches, holds a credential or runs anything: these rows hold public provenance only.++--++-- host_site_sources the site's connected repository (one per site): an https URL on a known++-- provider and the branch the CI deploys from. No secret, no credential name.++-- host_deploy_git what a git deploy was built from, written in the same transaction as the++-- deploy and immutable afterwards, like the deploy's files.++ALTER TABLE host_deploys DROP CONSTRAINT host_deploys_source_check;++ALTER TABLE host_deploys ADD CONSTRAINT host_deploys_source_check CHECK (source IN ('archive', 'files', 'preview', 'git'));++++CREATE TABLE host_site_sources (++ site_id text COLLATE "C" PRIMARY KEY REFERENCES host_sites(id),++ provider text COLLATE "C" NOT NULL CHECK (provider IN ('github', 'gitlab', 'codeberg')),++ repo_url text COLLATE "C" NOT NULL,++ ref text COLLATE "C" NOT NULL,++ created_by text COLLATE "C",++ created_at bigint NOT NULL,++ updated_by text COLLATE "C",++ updated_at bigint NOT NULL++);++++CREATE TABLE host_deploy_git (++ deploy_id text COLLATE "C" PRIMARY KEY REFERENCES host_deploys(id),++ provider text COLLATE "C" NOT NULL,++ repo_url text COLLATE "C" NOT NULL,++ ref text COLLATE "C" NOT NULL,++ commit_sha text COLLATE "C" NOT NULL,++ created_at bigint NOT NULL++);++++-- A deploy's provenance is immutable, like its files.++CREATE FUNCTION host_deploy_git_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TRUE THEN RAISE EXCEPTION 'host: deploy provenance is immutable'; END IF; RETURN NEW; END $$;++CREATE TRIGGER host_deploy_git_immutable BEFORE UPDATE ON host_deploy_git FOR EACH ROW EXECUTE FUNCTION host_deploy_git_immutable();@@@ -33,6 +33,7 @@ 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 } = require('./auth/viewer');const { createHostSession } = require('./auth/sso');@@@ -112,6 +113,7 @@ async function createApp(opts = {}) {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,@@@ -131,7 +133,7 @@ async function createApp(opts = {}) {const uploadGate = createUploadGate(config.uploads.maxConcurrent);const worker = createWorker({ config, store, domains, blobs, outbox, log });-- const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, auth, viewers, tenant, worker, uploadGate, indexnow, log };++ const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, auth, viewers, tenant, worker, uploadGate, indexnow, log };const app = express();app.disable('x-powered-by');@@@ -16,6 +16,8 @@* 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@@@ -28,7 +30,7 @@ const { createDb } = require('openvibe-sdk/db');const MIGRATIONS = path.join(__dirname, '..', 'migrations');const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');--const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns'];++const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns', 'host_site_sources', 'host_deploy_git'];/*** The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database@@@ -17,8 +17,15 @@* 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.*-- * There is no build: Stage B serves the uploaded files as they are, and nothing is ever executed.++ * 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');@@@ -29,6 +36,9 @@ 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 }) {@@@ -40,8 +50,10 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, oif (!project || project.environment !== 'sandbox') announce(sites.defaultHostname(site.name), paths);};const q = {-- byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),-- forSite: db.prepare("SELECT * FROM host_deploys WHERE site_id = ? AND state <> 'deleted' ORDER BY created_at DESC, seq DESC LIMIT ?"),++ // 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 (?, ?, ?, ?, ?)'),@@@ -64,6 +76,8 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, oSELECT 1 FROM host_deploy_files f JOIN host_deploys d ON d.id = f.deploy_idWHERE 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) {@@@ -139,12 +153,15 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o* 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 } = {}) {++ 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.++ // 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 ? 'preview' : source;++ const storedSource = isPreview && !isGit ? 'preview' : source;if (isPreview) activate = false;const fail = async (status, code, problems) => {const r = await recordFailure(viewer, ctx, { source: storedSource, code, problems, notes, traceparent });@@@ -175,7 +192,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o...built.warnings.map((m) => ({ level: 'warn', message: m })),{ level: 'info', message: `stored ${fresh.size} new objects (${fmtBytes(newBytes)}); ${built.files.length - fresh.size} already stored for this project` },{ level: 'info', message: `manifest sha256 ${built.manifestSha256}` },-- { level: 'info', message: 'no build step: Stage B serves the uploaded files as they are; nothing was executed' },++ { 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;@@@ -183,6 +200,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, oawait 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 },@@@ -204,6 +222,36 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, oreturn { 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);@@@ -295,7 +343,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o}return {-- get, load, precheck, create, recordFailure, activate, rollback, remove, collectGarbage, unlinkBlobs, limitsFor,++ 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),nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * A site's Git source (plan T12 Stage B, git deploys Phase 1): the repository and branch the project's++ * own CI builds the site from. Host never clones, fetches or builds it and holds no credential for++ * it: the row is public provenance that a git deploy (deploys.ingestGit) must match. The CI token++ * that posts the build output is the trust boundary.++ *++ * repo_url https on the provider's own host (github.com, gitlab.com, codeberg.org), path++ * owner/repo with an optional .git; never userinfo, a port, a query or a fragment.++ * Stored canonically as https://<host>/<owner>/<repo>.++ * ref one branch name, by git check-ref-format's rules, at most 200 characters.++ *++ * Read at `read`, written and removed at `maintain`, through sites.load (404 to anyone else).++ */++const { ApiError } = require('../http/errors');++const { principalOf } = require('./access');++++const PROVIDERS = Object.freeze({ 'github.com': 'github', 'gitlab.com': 'gitlab', 'codeberg.org': 'codeberg' });++const PROVIDER_NAMES = new Set(Object.values(PROVIDERS));++const FIELDS = new Set(['provider', 'repo_url', 'ref']);++const MAX_URL = 300;++const MAX_REF = 200;++// Only these characters, so no userinfo (@), port (:), query (?), fragment (#), percent-encoding or space can hide in a URL.++const URL_RE = /^https:\/\/([A-Za-z0-9.-]+)\/([A-Za-z0-9][A-Za-z0-9_.-]{0,99})\/([A-Za-z0-9][A-Za-z0-9_.-]{0,99})$/;++const REF_CHARS_RE = /^[A-Za-z0-9._/+=,#-]+$/;++++const fail = (code, message) => { throw new ApiError(422, code, message); };++++/** -> { provider, repo_url } (canonical), or throws ApiError(422). */++function cleanRepoUrl(value, provider) {++ const s = typeof value === 'string' ? value.trim() : '';++ if (!s || s.length > MAX_URL) fail('source.repo_url', `repo_url must be an https URL of at most ${MAX_URL} characters`);++ const m = URL_RE.exec(s);++ if (!m) fail('source.repo_url', 'repo_url must be https://<provider host>/<owner>/<repo>[.git], with no user, port, query or fragment');++ const host = m[1].toLowerCase();++ const repo = m[3].endsWith('.git') ? m[3].slice(0, -4) : m[3];++ if (!PROVIDERS[host]) fail('source.repo_url', `repo_url must be on ${Object.keys(PROVIDERS).join(', ')}`);++ if (provider != null && provider !== '') {++ if (!PROVIDER_NAMES.has(provider)) fail('source.provider', `provider must be one of ${[...PROVIDER_NAMES].join(', ')}`);++ if (provider !== PROVIDERS[host]) fail('source.provider', `a ${provider} repository is not on ${host}`);++ }++ return { provider: PROVIDERS[host], repo_url: `https://${host}/${m[2]}/${repo}` };++}++++/** A branch name by git check-ref-format's rules (and a narrower character set), or throws ApiError(422). */++function cleanRef(value) {++ const s = typeof value === 'string' ? value.trim() : '';++ const bad = (why) => fail('source.ref', `ref must be a branch name: ${why}`);++ if (!s || s.length > MAX_REF) bad(`1–${MAX_REF} characters`);++ if (!REF_CHARS_RE.test(s)) bad('letters, digits and . _ / + = , # - only');++ if (s.startsWith('-')) bad('it may not start with "-"');++ if (s.includes('..')) bad('it may not contain ".."');++ if (s.startsWith('/') || s.endsWith('/') || s.includes('//')) bad('no leading, trailing or doubled "/"');++ if (s.endsWith('.')) bad('it may not end with "."');++ if (s === 'HEAD' || s.startsWith('refs/')) bad('name the branch itself, not HEAD or refs/…');++ for (const part of s.split('/')) {++ if (part.startsWith('.') || part.endsWith('.lock')) bad('no part may start with "." or end with ".lock"');++ }++ return s;++}++++/** Validates a PUT body -> { provider, repo_url, ref }, or throws ApiError(422). */++function normalize(input) {++ if (!input || typeof input !== 'object' || Array.isArray(input)) fail('source.invalid', 'the body must be { provider, repo_url, ref }');++ const extra = Object.keys(input).filter((k) => !FIELDS.has(k));++ // Refused, not ignored: a token or key sent here must never look as if Host kept it.++ if (extra.length) fail('source.unknown_field', `unknown field ${extra[0]}: a source is { provider, repo_url, ref } and holds no credential`);++ return { ...cleanRepoUrl(input.repo_url, input.provider), ref: cleanRef(input.ref) };++}++++function createSiteSources({ store, sites }) {++ const { db } = store;++ const q = {++ bySite: db.prepare('SELECT * FROM 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 };@@@ -99,6 +99,7 @@ function createSites({ store, config, access, projects, takedowns, indexnow = nuawait 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 }) {@@@ -9,9 +9,12 @@* 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)@@@ -26,7 +29,7 @@ const out = require('./serialize');const truthy = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v || '').toLowerCase());function createApi(ctx) {-- const { config, projects, sites, deploys, domains, siteConfig, viewers, takedowns } = 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).@@@ -143,10 +146,20 @@ function createApi(ctx) {};}));-- router.post('/sites/:id/deploys', guard('host.deploy.create'), async (req, res) => {++ /**++ * 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;@@@ -156,9 +169,16 @@ function createApi(ctx) {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: err.source || (multipart ? 'files' : 'archive'), code: err.code, problems: [{ code: err.code, message: err.message }], traceparent: tp(req) });++ 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);@@@ -172,7 +192,9 @@ function createApi(ctx) {} 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');@@@ -225,6 +247,23 @@ function createApi(ctx) {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;}@@@ -15,7 +15,7 @@ const { readUpload, UploadError } = require('./upload');const { ApiError, privateNoStore } = require('./errors');function createDashboard(ctx) {-- const { config, projects, sites, deploys, domains, siteConfig, viewers, access, log = console } = 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' });@@@ -92,6 +92,7 @@ function createDashboard(ctx) {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));}));@@@ -140,6 +141,15 @@ function createDashboard(ctx) {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.`);@@@ -68,11 +68,27 @@ function deploy(d, { active = false, files = null, log = 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) };}@@@ -96,4 +112,4 @@ function activation(a) {return { deploy_id: a.deploy_id, previous_deploy_id: a.previous_deploy_id || null, kind: a.kind, actor: a.actor, at: iso(a.created_at) };}--module.exports = { project, takedownOut, quotaOut, usageOut, site, deploy, logLine, domain, activation, iso };++module.exports = { project, takedownOut, quotaOut, usageOut, site, deploy, source, logLine, domain, activation, iso };@@@ -10,6 +10,7 @@* 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: [] }@@@ -19,7 +20,7 @@ const { readArchive, normaliseName, selectRoot, stripSharedTop, ArchiveError } =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']);++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 {@@@ -13,6 +13,20 @@ const { listen } = require('./stageb/mocks');const { CAPABILITIES } = require('../server/auth/capabilities');const { EVENT_TYPES } = require('../server/events/outbox');++/**++ * Routes Host guards today whose capability entries are only in OpenVibe.Contracts main (OpenVibe.Contracts#18++ * added the git-source routes for the next release; the pinned tag predates it). They are exempt from the++ * "the released manifest lists every route" half of the check until the pin reaches that release, and no++ * further: each key must be a real guarded route with the capability the release gives it, and must not be++ * in the pinned manifest — so when the pin is bumped the exemption turns red and has to be deleted.++ */++const PENDING_CONTRACTS_RELEASE = {++ 'GET /api/v1/sites/:id/source': 'host.site.manage',++ 'PUT /api/v1/sites/:id/source': 'host.site.manage',++ 'DELETE /api/v1/sites/:id/source': 'host.site.manage',++ 'POST /api/v1/sites/:id/source/deploys': 'host.deploy.create',++};++(async () => {const ids = Object.values(CAPABILITIES);const caps = ids.map((id) => contracts.capabilities.get(id));@@@ -35,8 +49,17 @@ const { EVENT_TYPES } = require('../server/events/outbox');for (const r of routes) {const cap = caps.find((c) => c.id === r.cap);assert.ok(cap, r.cap);-- assert.ok(cap.implementedBy.includes(`${r.method} ${r.route}`), `${r.method} ${r.route} missing from ${r.cap}.implementedBy`);++ const key = `${r.method} ${r.route}`;++ const pending = PENDING_CONTRACTS_RELEASE[key];++ if (pending != null) {++ assert.strictEqual(pending, r.cap, `${key} is exempt for ${pending}, not ${r.cap}`);++ assert.ok(caps.every((c) => !c.implementedBy.includes(key)), `${key} is released now: drop it from PENDING_CONTRACTS_RELEASE`);++ continue;++ }++ assert.ok(cap.implementedBy.includes(key), `${key} missing from ${r.cap}.implementedBy`);}++ const guarded = new Set(routes.map((r) => `${r.method} ${r.route}`));++ for (const key of Object.keys(PENDING_CONTRACTS_RELEASE)) assert.ok(guarded.has(key), `${key} is not a guarded route: drop it from PENDING_CONTRACTS_RELEASE`);});await check('the released service manifest declares every event the service emits', async () => {nnew file mode 100644@@@ -0,0 +1,210 @@++'use strict';++/**++ * Git deploys, Phase 1 (plan T12 Stage B): a site's Git source is public provenance (repository +++ * branch) set by a maintainer; the project's own CI builds the commit and posts the output to++ * POST /api/v1/sites/:id/source/deploys with an app deployer token. Host validates the files like any++ * upload, stores a source=git deploy with an immutable 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();++})();
@@ -305,6 +305,8 @@ People present their Network user JWT as a Bearer token. Services and apps prese
@@ -305,6 +305,8 @@ People present their Network user JWT as a Bearer token. Services and apps prese
| `GET/POST /sites/:id/deploys` · `GET /deploys/:id` · `GET /deploys/:id/log` | `host.deploy.create` | deployer |
| `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 |
| `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 |
| `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 |
| `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/POST /sites/:id/domains` · `POST /domains/:id/verify` · `DELETE /domains/:id` | `host.domain.manage` | maintainer (reads: member) |
@@ -316,6 +318,41 @@ The four capability ids (`host.site.config` since v0.83.0) and the service manif
@@ -316,6 +318,41 @@ The four capability ids (`host.site.config` since v0.83.0) and the service manif
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.
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
### Events
Through the `openvibe-sdk` v0.25.0 transactional outbox (`event_outbox`), inside the transaction that makes the change:
Through the `openvibe-sdk` v0.25.0 transactional outbox (`event_outbox`), inside the transaction that makes the change:
@@ -372,7 +409,7 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant
@@ -372,7 +409,7 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant
- Projects are created in Host. When Network has projects (ADR-014), Host should accept only Network project ids and read membership from Network.
- 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).
- `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 not in `ovhost backup` (only `host.db`).
- Tenant objects are not in `ovhost backup` (only `host.db`).
-- No deploy of a Git repository (uploads only).
+- 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).
- 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.
- The Codes portal (Wave 20) does not exist, so there is no public developer onboarding for Host yet.
@@ -390,6 +427,7 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant
@@ -390,6 +427,7 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant
- **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.
- **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.
- **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).
- **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.
- **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.
- **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.
- 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.
@@ -39,7 +39,7 @@
@@ -39,7 +39,7 @@
"deployed": "loopback only, not launched",
"deployed": "loopback only, not launched",
"placeholderInSites": true,
"placeholderInSites": true,
"adoptedByServices": [],
"adoptedByServices": [],
- "updated": "2026-09-28",
+ "updated": "2026-10-03",
"capabilitiesNote": "released in openvibe-contracts v0.24.0; v0.32.0 adds the four takedown routes to host.site.manage.implementedBy",
"capabilitiesNote": "released in openvibe-contracts v0.24.0; v0.32.0 adds the four takedown routes to host.site.manage.implementedBy",
"launchReady": true,
"launchReady": true,
"features": [
"features": [
@@ -53,6 +53,7 @@
@@ -53,6 +53,7 @@
"Prometheus alerts relay to Network",
"Prometheus alerts relay to Network",
"browser check and release-lifecycle acceptance suite",
"browser check and release-lifecycle acceptance suite",
"Stage B: tenant static sites with immutable deploys, activation, rollback, custom domains, quotas and takedowns (not launched)",
"Stage B: tenant static sites with immutable deploys, activation, rollback, custom domains, quotas and takedowns (not launched)",
+ "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)"
"per-actor limits on Stage B writes (openvibe-sdk/limits)"
]
]
}
}
@@ -150,6 +150,8 @@ escape a directory: there is no directory.
@@ -150,6 +150,8 @@ escape a directory: there is no directory.
| **Certificate-validation files** (`.well-known/acme-challenge/`, `.well-known/pki-validation/`), which would let a tenant prove control of `<site>.openvibe.host` to a CA over HTTPS | **added:** refused in uploads and never served (`paths.js:49-53`); the operator issues every certificate | fixed (`1e48cb2`) |
| **Certificate-validation files** (`.well-known/acme-challenge/`, `.well-known/pki-validation/`), which would let a tenant prove control of `<site>.openvibe.host` to a CA over HTTPS | **added:** refused in uploads and never served (`paths.js:49-53`); the operator issues every certificate | fixed (`1e48cb2`) |
| **Duplicate paths, file/directory conflicts, empty uploads** | refused (`validate.js:43,58`) | fixed |
| **Duplicate paths, file/directory conflicts, empty uploads** | refused (`validate.js:43,58`) | fixed |
| **A refused upload leaves partial state** | nothing is stored; the refusal is a `failed` deploy with its log and `host.deploy.failed`; the active deploy keeps serving | fixed |
| **A refused upload leaves partial state** | nothing is stored; the refusal is a `failed` deploy with its log and `host.deploy.failed`; the active deploy keeps serving | fixed |
+| **Git deploys** (`POST /sites/:id/source/deploys`, Phase 1) | **added:** the build runs in the project's own CI; Host receives its output through the same reader, validator, quotas, upload gate and failure records as an upload, so a hostile build output is refused exactly like a hostile upload. Host never clones, fetches or runs anything, so there is no outbound request to a tenant-chosen URL (`security-ssrf.test.js` is unchanged) and no build to isolate. The ref must be the site's connected branch and the commit a full SHA; the deploy is always the site's preview and never moves `active_deploy_id` (`test/host-git-source.test.js`) | fixed by design |
+| **A forged or stolen CI token** | the CI's token is the trust boundary: an app principal that is a `deployer` of the project (or a person's token), so it can do what any deployer can; a git deploy only reaches the preview, and going live needs the ordinary activate. The repository credential stays with the CI; Host stores only the public repo URL, branch and commit (`host_site_sources`, `host_deploy_git`), and a source write naming any other field is refused | accepted: rotate the app's client secret in Network; Run (plan T14) owns any in-Host build |
| **Filling the shared disk** | **added:** deploys are refused with `507 storage.host_full` while the object store's filesystem has less than `HOST_MIN_FREE_BYTES` (default 5 GiB) free (`deploys.js:78-86`); `/api/ready` reports `disk_headroom`. Serving is unaffected. See also §6. | fixed (`0ff29d5`) |
| **Filling the shared disk** | **added:** deploys are refused with `507 storage.host_full` while the object store's filesystem has less than `HOST_MIN_FREE_BYTES` (default 5 GiB) free (`deploys.js:78-86`); `/api/ready` reports `disk_headroom`. Serving is unaffected. See also §6. | fixed (`0ff29d5`) |
## 3. Custom domains
## 3. Custom domains
@@ -272,8 +274,8 @@ the history of any site.
@@ -272,8 +274,8 @@ the history of any site.
| `/metrics` | direct loopback callers only; nginx returns 404 (`tenants.conf.tmpl:72`) | fixed |
| `/metrics` | direct loopback callers only; nginx returns 404 (`tenants.conf.tmpl:72`) | fixed |
| `/api/ready`, `/api/health`, `/release.json` | no secrets, no tenant data (`host-secrets-dashboard`) | fixed |
| `/api/ready`, `/api/health`, `/release.json` | no secrets, no tenant data (`host-secrets-dashboard`) | fixed |
| Secrets in responses | allowlisted serializers (`server/http/serialize.js`); tested for the client secret, form secret, uploaded `.env` values and credential-like strings | fixed |
| Secrets in responses | allowlisted serializers (`server/http/serialize.js`); tested for the client secret, form secret, uploaded `.env` values and credential-like strings | fixed |
-| Tenant secrets | Stage B stores none: no environment, build secrets or deploy keys | fixed by design |
-| Tenant code execution | none: no build step, no server-side code | fixed by design |
+| Tenant secrets | Stage B stores none: no environment, build secrets, deploy keys or repository credentials. A git source is a public repo URL and branch; the credential that reads the repository and the token that posts the build stay in the tenant's CI, and that CI token is the trust boundary | fixed by design |
+| Tenant code execution | none: no build step, no server-side code. Git deploys are built in the tenant's own CI and arrive as files validated like an upload; Host never clones, fetches or runs tenant code (in-Host builds belong to Run, plan T14) | fixed by design |
| The tenant vhost install | transactional (`nginx -t`, restore on failure); **added:** removes the interim pending vhost in the same change (`lib/nginx.js:100-`, `nginx.tenants.replaces`) | fixed (`1e48cb2`) |
| The tenant vhost install | transactional (`nginx -t`, restore on failure); **added:** removes the interim pending vhost in the same change (`lib/nginx.js:100-`, `nginx.tenants.replaces`) | fixed (`1e48cb2`) |
## Residual risks
## Residual risks
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+-- phase: expand
+-- Git deploys, Phase 1 (plan T12 Stage B): provenance and preview, with the build OUTSIDE Host.
+-- A site may name the Git repository and branch its project's own CI builds from. That CI checks the
+-- commit out, builds it and posts the output to POST /api/v1/sites/:id/source/deploys with the ref and
+-- the commit it built; Host validates the files exactly like an upload and stores them as a deploy
+-- with source 'git' that becomes the site's live preview, never its active deploy. Host never clones,
+-- fetches, holds a credential or runs anything: these rows hold public provenance only.
+--
+-- host_site_sources the site's connected repository (one per site): an https URL on a known
+-- provider and the branch the CI deploys from. No secret, no credential name.
+-- host_deploy_git what a git deploy was built from, written in the same transaction as the
+-- deploy and immutable afterwards, like the deploy's files.
+ALTER TABLE host_deploys DROP CONSTRAINT host_deploys_source_check;
+ALTER TABLE host_deploys ADD CONSTRAINT host_deploys_source_check CHECK (source IN ('archive', 'files', 'preview', 'git'));
+
+CREATE TABLE host_site_sources (
+ site_id text COLLATE "C" PRIMARY KEY REFERENCES host_sites(id),
+ provider text COLLATE "C" NOT NULL CHECK (provider IN ('github', 'gitlab', 'codeberg')),
+ repo_url text COLLATE "C" NOT NULL,
+ ref text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C",
+ created_at bigint NOT NULL,
+ updated_by text COLLATE "C",
+ updated_at bigint NOT NULL
+);
+
+CREATE TABLE host_deploy_git (
+ deploy_id text COLLATE "C" PRIMARY KEY REFERENCES host_deploys(id),
+ provider text COLLATE "C" NOT NULL,
+ repo_url text COLLATE "C" NOT NULL,
+ ref text COLLATE "C" NOT NULL,
+ commit_sha text COLLATE "C" NOT NULL,
+ created_at bigint NOT NULL
+);
+
+-- A deploy's provenance is immutable, like its files.
+CREATE FUNCTION host_deploy_git_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TRUE THEN RAISE EXCEPTION 'host: deploy provenance is immutable'; END IF; RETURN NEW; END $$;
+CREATE TRIGGER host_deploy_git_immutable BEFORE UPDATE ON host_deploy_git FOR EACH ROW EXECUTE FUNCTION host_deploy_git_immutable();
@@ -33,6 +33,7 @@ const { createSites } = require('./domain/sites');
@@ -33,6 +33,7 @@ const { createSites } = require('./domain/sites');
const { createDeploys } = require('./domain/deploys');
const { createDeploys } = require('./domain/deploys');
const { createDomains } = require('./domain/domains');
const { createDomains } = require('./domain/domains');
const { createSiteConfig } = require('./domain/site-config');
const { createSiteConfig } = require('./domain/site-config');
+const { createSiteSources } = require('./domain/site-sources');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
const { createHostSession } = require('./auth/sso');
const { createHostSession } = require('./auth/sso');
@@ -112,6 +113,7 @@ async function createApp(opts = {}) {
@@ -112,6 +113,7 @@ async function createApp(opts = {}) {
const deploys = createDeploys({ store, config, access, projects, sites, blobs, outbox, takedowns, log, 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 domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log, indexnow: announce });
const siteConfig = createSiteConfig({ store });
const siteConfig = createSiteConfig({ store });
+ const siteSources = createSiteSources({ store, sites });
const auth = opts.auth || createSsoClient({
const auth = opts.auth || createSsoClient({
site: 'host',
site: 'host',
baseUrl: config.baseUrl,
baseUrl: config.baseUrl,
@@ -131,7 +133,7 @@ async function createApp(opts = {}) {
@@ -131,7 +133,7 @@ async function createApp(opts = {}) {
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const worker = createWorker({ config, store, domains, blobs, outbox, log });
const worker = createWorker({ config, store, domains, blobs, outbox, log });
- const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, auth, viewers, tenant, worker, uploadGate, indexnow, log };
+ const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, auth, viewers, tenant, worker, uploadGate, indexnow, log };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -16,6 +16,8 @@
@@ -16,6 +16,8 @@
* host_deploy_logs the upload/validation log of each deploy (there is no build in Stage B)
* 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_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_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
* event_outbox openvibe-sdk transactional outbox
*
*
* Immutability is enforced by the database itself: triggers refuse any UPDATE of a deploy's
* Immutability is enforced by the database itself: triggers refuse any UPDATE of a deploy's
@@ -28,7 +30,7 @@ const { createDb } = require('openvibe-sdk/db');
@@ -28,7 +30,7 @@ const { createDb } = require('openvibe-sdk/db');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
-const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns'];
+const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns', 'host_site_sources', 'host_deploy_git'];
/**
/**
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
@@ -17,8 +17,15 @@
@@ -17,8 +17,15 @@
* and is served only by the dashboard, to a member, at /preview/<deploy-id>/… (see
* 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
* 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.
* 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.
*
*
- * There is no build: Stage B serves the uploaded files as they are, and nothing is ever executed.
+ * 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 { newId, isId } = require('../ids');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
@@ -29,6 +36,9 @@ const DAY = 24 * 3600 * 1000;
@@ -29,6 +36,9 @@ const DAY = 24 * 3600 * 1000;
// How long a preview stays served. Bounded so a draft cannot linger on the dashboard forever; the
// How long a preview stays served. Bounded so a draft cannot linger on the dashboard forever; the
// uploader can always upload another one.
// uploader can always upload another one.
const PREVIEW_TTL_MS = 60 * 60 * 1000;
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`);
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 }) {
function createDeploys({ store, config = null, access, projects, sites, blobs, outbox, takedowns, log = console, indexnow = null }) {
@@ -40,8 +50,10 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
@@ -40,8 +50,10 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
if (!project || project.environment !== 'sandbox') announce(sites.defaultHostname(site.name), paths);
if (!project || project.environment !== 'sandbox') announce(sites.defaultHostname(site.name), paths);
};
};
const q = {
const q = {
- byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),
- forSite: db.prepare("SELECT * FROM host_deploys WHERE site_id = ? AND state <> 'deleted' ORDER BY created_at DESC, seq DESC LIMIT ?"),
+ // 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)
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)`),
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 (?, ?, ?, ?, ?)'),
insertFile: db.prepare('INSERT INTO host_deploy_files (deploy_id, path, sha256, size, content_type) VALUES (?, ?, ?, ?, ?)'),
@@ -64,6 +76,8 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
@@ -64,6 +76,8 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
SELECT 1 FROM host_deploy_files f JOIN host_deploys d ON d.id = f.deploy_id
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)`),
WHERE d.project_id = b.project_id AND f.sha256 = b.sha256)`),
dropBlob: db.prepare('DELETE FROM host_blobs WHERE project_id = ? AND 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) {
async function get(id) {
@@ -139,12 +153,15 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
@@ -139,12 +153,15 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
* entries: [{ path, data }] already normalised by the reader. -> { deploy, log, activated }
* 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.
* 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 } = {}) {
+ async function create(viewer, ctx, entries, { source, activate = false, preview = false, notes = [], traceparent, git = null } = {}) {
const { site, project, limits } = ctx;
const { site, project, limits } = ctx;
// A preview is never activated: it is the site's live preview (its pointer), not its active
// 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.
+ // 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 isPreview = preview === true;
- const storedSource = isPreview ? 'preview' : source;
+ const storedSource = isPreview && !isGit ? 'preview' : source;
if (isPreview) activate = false;
if (isPreview) activate = false;
const fail = async (status, code, problems) => {
const fail = async (status, code, problems) => {
const r = await recordFailure(viewer, ctx, { source: storedSource, code, problems, notes, traceparent });
const r = await recordFailure(viewer, ctx, { source: storedSource, code, problems, notes, traceparent });
@@ -175,7 +192,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
@@ -175,7 +192,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
...built.warnings.map((m) => ({ level: 'warn', message: m })),
...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: `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: `manifest sha256 ${built.manifestSha256}` },
- { level: 'info', message: 'no build step: Stage B serves the uploaded files as they are; nothing was executed' },
+ { 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;
let activated = null;
const previewExpiresAt = isPreview ? now + PREVIEW_TTL_MS : null;
const previewExpiresAt = isPreview ? now + PREVIEW_TTL_MS : null;
@@ -183,6 +200,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
@@ -183,6 +200,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
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 });
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 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);
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({
await outbox.emit({
event_type: 'host.deploy.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
event_type: 'host.deploy.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
subject: { type: 'deploy', id },
subject: { type: 'deploy', id },
@@ -204,6 +222,36 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
@@ -204,6 +222,36 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
return { deploy: await get(id), log: await q.logFor.all(id), activated, preview: isPreview ? { deploy_id: id, expires_at: previewExpiresAt } : null };
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. */
/** Inside a transaction. Compare-and-set on the pointer read in the same transaction. */
async function switchPointer(viewer, site, deploy, kind, { expectedActive, traceparent } = {}) {
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);
const current = await db.prepare('SELECT active_deploy_id, status FROM host_sites WHERE id = ?').get(site.id);
@@ -295,7 +343,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
@@ -295,7 +343,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
}
}
return {
return {
- get, load, precheck, create, recordFailure, activate, rollback, remove, collectGarbage, unlinkBlobs, limitsFor,
+ 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)),
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),
logOf: async (id) => await q.logFor.all(id),
filesOf: async (id) => await q.files.all(id),
filesOf: async (id) => await q.files.all(id),
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * A site's Git source (plan T12 Stage B, git deploys Phase 1): the repository and branch the project's
+ * own CI builds the site from. Host never clones, fetches or builds it and holds no credential for
+ * it: the row is public provenance that a git deploy (deploys.ingestGit) must match. The CI token
+ * that posts the build output is the trust boundary.
+ *
+ * repo_url https on the provider's own host (github.com, gitlab.com, codeberg.org), path
+ * owner/repo with an optional .git; never userinfo, a port, a query or a fragment.
+ * Stored canonically as https://<host>/<owner>/<repo>.
+ * ref one branch name, by git check-ref-format's rules, at most 200 characters.
+ *
+ * Read at `read`, written and removed at `maintain`, through sites.load (404 to anyone else).
+ */
+const { ApiError } = require('../http/errors');
+const { principalOf } = require('./access');
+
+const PROVIDERS = Object.freeze({ 'github.com': 'github', 'gitlab.com': 'gitlab', 'codeberg.org': 'codeberg' });
+const PROVIDER_NAMES = new Set(Object.values(PROVIDERS));
+const FIELDS = new Set(['provider', 'repo_url', 'ref']);
+const MAX_URL = 300;
+const MAX_REF = 200;
+// Only these characters, so no userinfo (@), port (:), query (?), fragment (#), percent-encoding or space can hide in a URL.
+const URL_RE = /^https:\/\/([A-Za-z0-9.-]+)\/([A-Za-z0-9][A-Za-z0-9_.-]{0,99})\/([A-Za-z0-9][A-Za-z0-9_.-]{0,99})$/;
+const REF_CHARS_RE = /^[A-Za-z0-9._/+=,#-]+$/;
+
+const fail = (code, message) => { throw new ApiError(422, code, message); };
+
+/** -> { provider, repo_url } (canonical), or throws ApiError(422). */
+function cleanRepoUrl(value, provider) {
+ const s = typeof value === 'string' ? value.trim() : '';
+ if (!s || s.length > MAX_URL) fail('source.repo_url', `repo_url must be an https URL of at most ${MAX_URL} characters`);
+ const m = URL_RE.exec(s);
+ if (!m) fail('source.repo_url', 'repo_url must be https://<provider host>/<owner>/<repo>[.git], with no user, port, query or fragment');
+ const host = m[1].toLowerCase();
+ const repo = m[3].endsWith('.git') ? m[3].slice(0, -4) : m[3];
+ if (!PROVIDERS[host]) fail('source.repo_url', `repo_url must be on ${Object.keys(PROVIDERS).join(', ')}`);
+ if (provider != null && provider !== '') {
+ if (!PROVIDER_NAMES.has(provider)) fail('source.provider', `provider must be one of ${[...PROVIDER_NAMES].join(', ')}`);
+ if (provider !== PROVIDERS[host]) fail('source.provider', `a ${provider} repository is not on ${host}`);
+ }
+ return { provider: PROVIDERS[host], repo_url: `https://${host}/${m[2]}/${repo}` };
+}
+
+/** A branch name by git check-ref-format's rules (and a narrower character set), or throws ApiError(422). */
+function cleanRef(value) {
+ const s = typeof value === 'string' ? value.trim() : '';
+ const bad = (why) => fail('source.ref', `ref must be a branch name: ${why}`);
+ if (!s || s.length > MAX_REF) bad(`1–${MAX_REF} characters`);
+ if (!REF_CHARS_RE.test(s)) bad('letters, digits and . _ / + = , # - only');
+ if (s.startsWith('-')) bad('it may not start with "-"');
+ if (s.includes('..')) bad('it may not contain ".."');
+ if (s.startsWith('/') || s.endsWith('/') || s.includes('//')) bad('no leading, trailing or doubled "/"');
+ if (s.endsWith('.')) bad('it may not end with "."');
+ if (s === 'HEAD' || s.startsWith('refs/')) bad('name the branch itself, not HEAD or refs/…');
+ for (const part of s.split('/')) {
+ if (part.startsWith('.') || part.endsWith('.lock')) bad('no part may start with "." or end with ".lock"');
+ }
+ return s;
+}
+
+/** Validates a PUT body -> { provider, repo_url, ref }, or throws ApiError(422). */
+function normalize(input) {
+ if (!input || typeof input !== 'object' || Array.isArray(input)) fail('source.invalid', 'the body must be { provider, repo_url, ref }');
+ const extra = Object.keys(input).filter((k) => !FIELDS.has(k));
+ // Refused, not ignored: a token or key sent here must never look as if Host kept it.
+ if (extra.length) fail('source.unknown_field', `unknown field ${extra[0]}: a source is { provider, repo_url, ref } and holds no credential`);
+ return { ...cleanRepoUrl(input.repo_url, input.provider), ref: cleanRef(input.ref) };
+}
+
+function createSiteSources({ store, sites }) {
+ const { db } = store;
+ const q = {
+ bySite: db.prepare('SELECT * FROM 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 };
@@ -99,6 +99,7 @@ function createSites({ store, config, access, projects, takedowns, indexnow = nu
@@ -99,6 +99,7 @@ function createSites({ store, config, access, projects, takedowns, indexnow = nu
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_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_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_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 }) {
async function remove(viewer, id, { deploys }) {
@@ -9,9 +9,12 @@
@@ -9,9 +9,12 @@
* PUT/DELETE /projects/:id/members/:principal host.site.manage
* PUT/DELETE /projects/:id/members/:principal host.site.manage
* sites GET/POST /projects/:id/sites · GET/DELETE /sites/:id 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/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
* deploys GET/POST /sites/:id/deploys · GET /deploys/:id · GET /deploys/:id/log
* POST /deploys/:id/activate · POST /sites/:id/rollback host.deploy.create
* POST /deploys/:id/activate · POST /sites/:id/rollback host.deploy.create
* DELETE /deploys/:id host.site.manage
* 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
* domains GET/POST /sites/:id/domains · POST /domains/:id/verify
* DELETE /domains/:id host.domain.manage
* DELETE /domains/:id host.domain.manage
* takedowns POST/DELETE /projects/:id/takedown · POST/DELETE /sites/:id/takedown host.site.manage (staff)
* takedowns POST/DELETE /projects/:id/takedown · POST/DELETE /sites/:id/takedown host.site.manage (staff)
@@ -26,7 +29,7 @@ const out = require('./serialize');
@@ -26,7 +29,7 @@ const out = require('./serialize');
const truthy = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v || '').toLowerCase());
const truthy = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v || '').toLowerCase());
function createApi(ctx) {
function createApi(ctx) {
- const { config, projects, sites, deploys, domains, siteConfig, viewers, takedowns } = ctx;
+ const { config, projects, sites, deploys, domains, siteConfig, siteSources, viewers, takedowns } = ctx;
const router = express.Router();
const router = express.Router();
// CORS for first-party browser origins presenting a Bearer token (never credentials/cookies).
// CORS for first-party browser origins presenting a Bearer token (never credentials/cookies).
@@ -143,10 +146,20 @@ function createApi(ctx) {
@@ -143,10 +146,20 @@ function createApi(ctx) {
};
};
}));
}));
- router.post('/sites/:id/deploys', guard('host.deploy.create'), async (req, res) => {
+ /**
+ * 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;
let release = null;
try {
try {
const pre = await deploys.precheck(req.viewer, req.params.id);
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();
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'); }
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;
let up;
@@ -156,9 +169,16 @@ function createApi(ctx) {
@@ -156,9 +169,16 @@ function createApi(ctx) {
if (!(err instanceof UploadError)) throw err;
if (!(err instanceof UploadError)) throw err;
if (err.closeConnection) res.set('Connection', 'close');
if (err.closeConnection) res.set('Connection', 'close');
const multipart = String(req.headers['content-type'] || '').startsWith('multipart/');
const multipart = String(req.headers['content-type'] || '').startsWith('multipart/');
- const r = await deploys.recordFailure(req.viewer, pre, { source: err.source || (multipart ? 'files' : 'archive'), code: err.code, problems: [{ code: err.code, message: err.message }], traceparent: tp(req) });
+ 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}`) } });
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);
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.
// 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 activate = !preview && truthy(up.fields.activate != null ? up.fields.activate : req.query.activate);
@@ -172,7 +192,9 @@ function createApi(ctx) {
@@ -172,7 +192,9 @@ function createApi(ctx) {
} finally {
} finally {
if (release) release();
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) => {
router.get('/deploys/:id', guard('host.deploy.create'), run(async (req) => {
const { deploy, site } = await deploys.load(req.viewer, req.params.id, 'read');
const { deploy, site } = await deploys.load(req.viewer, req.params.id, 'read');
@@ -225,6 +247,23 @@ function createApi(ctx) {
@@ -225,6 +247,23 @@ function createApi(ctx) {
return { config: await siteConfig.remove(site) };
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 }));
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;
return router;
}
}
@@ -15,7 +15,7 @@ const { readUpload, UploadError } = require('./upload');
@@ -15,7 +15,7 @@ const { readUpload, UploadError } = require('./upload');
const { ApiError, privateNoStore } = require('./errors');
const { ApiError, privateNoStore } = require('./errors');
function createDashboard(ctx) {
function createDashboard(ctx) {
- const { config, projects, sites, deploys, domains, siteConfig, viewers, access, log = console } = ctx;
+ const { config, projects, sites, deploys, domains, siteConfig, siteSources, viewers, access, log = console } = ctx;
const router = express.Router();
const router = express.Router();
const urlencoded = express.urlencoded({ extended: false, limit: '16kb' });
const urlencoded = express.urlencoded({ extended: false, limit: '16kb' });
@@ -92,6 +92,7 @@ function createDashboard(ctx) {
@@ -92,6 +92,7 @@ function createDashboard(ctx) {
deploys: await deploys.list(site.id, 50), activations: await deploys.activationsOf(site.id, 10),
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) })),
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) },
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));
}), takedownNotice(await ctx.takedowns.ofSite(site)) || notice(req));
}));
}));
@@ -140,6 +141,15 @@ function createDashboard(ctx) {
@@ -140,6 +141,15 @@ function createDashboard(ctx) {
await siteConfig.set(req.viewer, site, { headers, redirects, spa: req.body.spa === '1' });
await siteConfig.set(req.viewer, site, { headers, redirects, spa: req.body.spa === '1' });
return back(`/sites/${site.id}`, 'Site configuration saved.');
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) => {
router.post('/sites/:id/domains', action(async (req) => {
const { domain } = await domains.add(req.viewer, req.params.id, { hostname: req.body.hostname });
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.`);
return back(`/sites/${domain.site_id}`, `Added ${domain.hostname}: publish the DNS records below, then check.`);
@@ -68,11 +68,27 @@ function deploy(d, { active = false, files = null, log = null } = {}) {
@@ -68,11 +68,27 @@ function deploy(d, { active = false, files = null, log = null } = {}) {
failure_code: d.failure_code || null,
failure_code: d.failure_code || null,
created_by: d.created_by,
created_by: d.created_by,
created_at: iso(d.created_at),
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 })) } : {}),
...(files ? { files: files.map((f) => ({ path: f.path, sha256: f.sha256, size: f.size, content_type: f.content_type })) } : {}),
...(log ? { log: log.map(logLine) } : {}),
...(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) {
function logLine(l) {
return { level: l.level, message: l.message, at: iso(l.created_at) };
return { level: l.level, message: l.message, at: iso(l.created_at) };
}
}
@@ -96,4 +112,4 @@ function activation(a) {
@@ -96,4 +112,4 @@ function activation(a) {
return { deploy_id: a.deploy_id, previous_deploy_id: a.previous_deploy_id || null, kind: a.kind, actor: a.actor, at: iso(a.created_at) };
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, logLine, domain, activation, iso };
+module.exports = { project, takedownOut, quotaOut, usageOut, site, deploy, source, logLine, domain, activation, iso };
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
* a browser folder upload sends "<folder>/css/site.css" and strip=folder
* a browser folder upload sends "<folder>/css/site.css" and strip=folder
* removes that shared first segment)
* removes that shared first segment)
* optional fields: activate=1, root=<subdirectory to deploy>, strip=folder, csrf (dashboard)
* 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.
* Query parameters activate=1 and root=… work for both forms.
* -> { source: 'archive'|'files', entries: [{ path, data }], fields, notes: [] }
* -> { source: 'archive'|'files', entries: [{ path, data }], fields, notes: [] }
@@ -19,7 +20,7 @@ const { readArchive, normaliseName, selectRoot, stripSharedTop, ArchiveError } =
@@ -19,7 +20,7 @@ const { readArchive, normaliseName, selectRoot, stripSharedTop, ArchiveError } =
const { checkPath, PathError } = require('../artifacts/paths');
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 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']);
+const FIELD_NAMES = new Set(['activate', 'preview', 'mode', 'root', 'strip', 'csrf', 'expected_active', 'ref', 'commit_sha']);
const fmt = (n) => `${(n / 1048576).toFixed(1)} MiB`;
const fmt = (n) => `${(n / 1048576).toFixed(1)} MiB`;
class UploadError extends Error {
class UploadError extends Error {
@@ -13,6 +13,20 @@ const { listen } = require('./stageb/mocks');
@@ -13,6 +13,20 @@ const { listen } = require('./stageb/mocks');
const { CAPABILITIES } = require('../server/auth/capabilities');
const { CAPABILITIES } = require('../server/auth/capabilities');
const { EVENT_TYPES } = require('../server/events/outbox');
const { EVENT_TYPES } = require('../server/events/outbox');
+/**
+ * Routes Host guards today whose capability entries are only in OpenVibe.Contracts main (OpenVibe.Contracts#18
+ * added the git-source routes for the next release; the pinned tag predates it). They are exempt from the
+ * "the released manifest lists every route" half of the check until the pin reaches that release, and no
+ * further: each key must be a real guarded route with the capability the release gives it, and must not be
+ * in the pinned manifest — so when the pin is bumped the exemption turns red and has to be deleted.
+ */
+const PENDING_CONTRACTS_RELEASE = {
+ 'GET /api/v1/sites/:id/source': 'host.site.manage',
+ 'PUT /api/v1/sites/:id/source': 'host.site.manage',
+ 'DELETE /api/v1/sites/:id/source': 'host.site.manage',
+ 'POST /api/v1/sites/:id/source/deploys': 'host.deploy.create',
+};
+
(async () => {
(async () => {
const ids = Object.values(CAPABILITIES);
const ids = Object.values(CAPABILITIES);
const caps = ids.map((id) => contracts.capabilities.get(id));
const caps = ids.map((id) => contracts.capabilities.get(id));
@@ -35,8 +49,17 @@ const { EVENT_TYPES } = require('../server/events/outbox');
@@ -35,8 +49,17 @@ const { EVENT_TYPES } = require('../server/events/outbox');
for (const r of routes) {
for (const r of routes) {
const cap = caps.find((c) => c.id === r.cap);
const cap = caps.find((c) => c.id === r.cap);
assert.ok(cap, r.cap);
assert.ok(cap, r.cap);
- assert.ok(cap.implementedBy.includes(`${r.method} ${r.route}`), `${r.method} ${r.route} missing from ${r.cap}.implementedBy`);
+ const key = `${r.method} ${r.route}`;
+ const pending = PENDING_CONTRACTS_RELEASE[key];
+ if (pending != null) {
+ assert.strictEqual(pending, r.cap, `${key} is exempt for ${pending}, not ${r.cap}`);
+ assert.ok(caps.every((c) => !c.implementedBy.includes(key)), `${key} is released now: drop it from PENDING_CONTRACTS_RELEASE`);
+ continue;
+ }
+ assert.ok(cap.implementedBy.includes(key), `${key} missing from ${r.cap}.implementedBy`);
}
}
+ const guarded = new Set(routes.map((r) => `${r.method} ${r.route}`));
+ for (const key of Object.keys(PENDING_CONTRACTS_RELEASE)) assert.ok(guarded.has(key), `${key} is not a guarded route: drop it from PENDING_CONTRACTS_RELEASE`);
});
});
await check('the released service manifest declares every event the service emits', async () => {
await check('the released service manifest declares every event the service emits', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,210 @@
@@ -0,0 +1,210 @@
+'use strict';
+/**
+ * Git deploys, Phase 1 (plan T12 Stage B): a site's Git source is public provenance (repository +
+ * branch) set by a maintainer; the project's own CI builds the commit and posts the output to
+ * POST /api/v1/sites/:id/source/deploys with an app deployer token. Host validates the files like any
+ * upload, stores a source=git deploy with an immutable 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();
+})();