Environments API
The server routes and command line behind environments: status, branches on the remote, plans that promote or sync content, and the checks that guard them.
Part of the API contract (docs/API.md: its conventions, prefix and errors apply here). The design and the reasons are
in docs/ENVIRONMENTS.md; this file is the server's routes and shapes. Server code: packages/server/src/env/.
An environment is a branch plus a server. Each server knows which one it is (environment in its config.json,
below), shares one git remote with the others, and keeps its own users, Inbox, audit log and backups. A promotion or a
sync is a plan made and run on the target server: production runs "staging → production", staging runs
"my-change → staging" and "production → staging" (sync). Nothing reaches the target's branch until a plan runs.
Server config
"environment": {
"name": "staging", "branch": "staging", "upstream": "main", "promoteTo": "production",
"ribbon": "#b45309", "remote": "origin",
"peers": { "production": { "branch": "main", "origin": "https://cms.example.com" } },
"previews": { "branches": true, "host": "{branch}.staging.example.com" },
"mail": "actor", "gate": true, "keep": []
},
"ai": { "provider": "anthropic", "model": "claude-sonnet-5-5", "keyFile": "/etc/markstage/ai.key" }
- No
environment: the server is production, on the branch it has checked out. peers: the other environments' branches (so a branch is named "staging", not "a branch") and their Markstage origins (for the handoff link).promoteTomust be a peer.- Outside production (
namenotproduction), by default:- every answer says
X-Robots-Tag: noindex, nofollow(the API, the editor, forms, the gate); mail: "actor"mails only the people an alert is about (who ran the job), never shared addresses or roles;mail: "sink"requiresnotify.email.hostto be this machine (a mail catcher);- a site form stores the message in this environment's Inbox, mails nobody, sends no thank-you and answers
{"message": "Test form: not sent. …", "test": true}; - the CDN purge, IndexNow, Slack and the alerts' webhook are off, unless
keeplists them (purge,indexNow,slack,webhook); so are the site's webhooks (docs/API-WEBHOOKS.md): their deliveries are kept asheldand never sent, unlesskeeplistswebhooks; gate: trueputs the site's hosts behind sign-in ("The site gate" below).
- every answer says
ai: the AI wording of a plan's summary. The key isMARKSTAGE_AI_KEYor thekeyFile(outside the repo), read when it is used; with no key the facts alone show, with no error.keyin config.json is refused. The same block sets the AI suggestions (perMinute,perDay; the same key and model): docs/API-AI.md.
This environment
GET env[?fetch=1](signed in; a token needsenv:read).fetch=1fetches the remote first: only for owners, administrators and people an allowing rule names for promote or sync (canFetchsays so; anyone else gets 403), and at most once per 30 s for the whole server (asked sooner, the last fetch stands).remote.errorholds git's own words for owners and administrators only; everyone else is told the remote could not be reached.{name, production, configured, branch, upstream, promoteTo, ribbon, head, live, upstreamHead, ahead, behind, // against the upstream: here only / to sync down remote: {name, ok, lastFetchAt, error, head, ahead, behind}, // against this branch on the remote peers: [{name, branch, origin}], previews: {on, host} | null, mail, gate, handoff: {to, url} | null, canRollback, canFetch}handoff.urlopens the target's Environments page with the plan filled in, in the fragment only (no secret):<peer origin>/markstage/#/environments?plan=promote&source=<branch>&sha=<sha>; the editor adds&reason=.GET env/branches→{branches: [{name, sha, at, author, subject, env, ahead, behind}], remote, lastFetch}: the branches on the remote, newest first;envis the peer it belongs to, this one, orbranch.
Plans
POST env/plans {action, source?, to?, reason?}→202 {plan, job}.action: "promote"bringssource(a branch on the remote) here;"sync"bringssource(default: the upstream) here.to, when given, must be this environment.- A dry run of the rules (files not known yet) comes first: if no rule could ever allow it,
403 {error: "rule", rule, words}, auditedenv.promote/env.syncwithok: false. - The job (kind
env-plan) fetches, compares (git merge-base,git diff), merges with no working copy (git merge-tree), and merges conflicted pages block by block (Markdown: frontmatter leaf by leaf, a save-time stamp such asmodifiedtakes the later; the body by block) or leaf by leaf (JSON). What both sides changed differently is a conflict. With none open, it makes the merge commit (on no branch), builds it in a throwaway work tree (the site'snode_moduleslinked in), runs the checks, compares the build's pages with the live build (verify.public), takes screenshots, and writes the summary.
GET env/plans[?status=]→{plans}(newest first, at most 200; files as counts per group, conflicts without their texts).GET env/plans/:pid→ the plan:{id, action, from: {env, branch, sha}, to: {env, branch, sha}, base, merged, author: {id, name}, createdAt, expiresAt, status, note, error, version, reason, job, runJob, runBy: {id, name} | null, finishedAt, newer, files: {count, groups: {pages, templates, styles, config, media, other: [{path, status, kinds, group, from?}]}, autoMerged: [path]}, conflicts: [{id, path, entry: {collection, id, title, label} | null, kind, words, ours, theirs, merged, resolved, take}], checks: [{id, label, blocking, status: passed|failed|warned|skipped, words, entries?}] | null, pages: {changed, added, removed, removedWithoutRedirect} | null, shots: {status: done|not-available|failed, words, pages: [{path, kind, shots: [{width, before, after}]}]} | null, summary: {status: facts|ai|dropped, text, words, facts, ai: {model, dropped?, why?} | null} | null, decision: {effect: allow|needs|deny, rule: {id, name} | null, words, missing: [{kind, words}]}, approvals: [{userId, name, role, sha, note, at, current}], link}status:preparing,conflicts,ready,blocked(a blocking check failed),running,done,failed,cancelled,stale(this environment's branch moved since: re-plan),expired(not run within 7 days).notesays why in words.decisionis for the person asking (their role now, the approvals, the checks, the time).kindof a conflict:text,blocks(n places),removed-here,removed-incoming,added-both.oursis this environment's text,theirsthe incoming one (each cut at 400 KB).newer: the source branch has moved on since the plan was made; the plan still promotes exactlyfrom.sha.checks:build, each site check (config.checks) on every changed entry (after-build ones on the build), andredirects(a removed page must have a redirect stub in the new build).
GET env/plans/:pid/shots/:file→ a PNG, by its name asshotsgives it (<n>-<width>-before|after.png, such as1-1440-after.png). Shots need Playwright on the server (playwrightor@playwright/test); without itshots.statusisnot-availableand says so. At most 40 pages, at 1440 and 390 wide; the browser is closed when they are done.POST env/plans/:pid/resolve {conflictId, take: ours|theirs|text, text?, version}(the plan's author, an administrator or the owner). When none is open, the plan is checked again ({plan, job}); earlier approvals no longer match the new merge.POST env/plans/:pid/approve {sha, note?}:shamust be the plan'smerged(409 withcurrentif it changed); the author cannot approve (403). Whether an approval counts is the rules' business (the approver's role when the plan runs).POST env/plans/:pid/run {reason?, version}→202 {plan, job}.- The rules are asked again now:
403 {error: "rule", rule, words, missing}, audited, when they refuse (a freeze that began after the plan, a missing approval or reason, a changed role).409withcurrentwhen the plan is notready(stale, expired, conflicts, blocked). - The job (kind
env-promoteorenv-sync) asks the rules once more, fast-forwards this branch to the merge commit (refused if the branch moved), builds, runs the after-build checks, deploys, and pushes the branch to the remote. If a step fails before the site is live, the branch goes back (git reset --keep) and the live site is unchanged. A job started with a token stops at its next step when the token is revoked. - Audited
env.promote.start, thenenv.promote/env.sync{from, to, merged, release, branch, reason}; alertsenv.promoted, orenv.promote-failed(to who ran it and the plan's author).
- The rules are asked again now:
POST env/plans/:pid/cancel {version?}(author, administrator, owner).
Rollback
POST env/rollback {reason?}→202 {job, from, to}(the rules'rollbackaction;409with no rollback command or no earlier release). The job puts the release before back live (commands.rollback), then commits on this branch the files of that release (a new commit: history is kept), so the branch equals the live site; it rebuilds the build folder and pushes. Auditedenv.rollback {from, to, revert, reason}; alertsite.rollback.
Rules
Kept in <dataDir>/promotion-rules.json (0600); the starter rules apply until someone saves. The shape and how they
are applied: docs/ENVIRONMENTS.md "Promotion rules" and the header of src/env/rules.mjs.
GET env/rules→{version, saved, groups, rules, starter, kinds, actions, envs, people}.PUT env/rules {version, groups, rules}(owner, administrator; never with a token) → the view.400in plain words,409 stalewithcurrent. Auditedenv.rules {added, removed, changed, groups}; theenv.rules-changedalert is a safety alert.POST env/rules/check {action, from?, to?, userId?, files?: [path], at?, reason?, checks?, rules?}→{effect, rule, words, missing, trace: [{rule, matched, why}], user, files}: what the rules (or therulessent, unsaved) say. Another person's answer is for administrators.
Personal tokens (the laptop CLI, and AI agents)
GET me/tokens→{tokens: [{id, name, scopes, createdAt, expiresAt, lastUsedAt, revokedAt, status}], scopes, mcpScopes, groups, maxDays};POST me/tokens {name, scopes, days: 1..90}→201 {token, secret}(the secret once,mst_…);DELETE me/tokens/:tidrevokes. A browser session only: a token cannot manage tokens.- Two kinds, never mixed in one token (
400"A token is for the command line (env:…) or for an AI agent (mcp:…), not both: make two."): the command line's,scopes[env:read, env:promote], reaching/env/*; and an AI agent's,mcpScopes[mcp:read, mcp:draft](mcp:draftbringsmcp:read), reaching/mcponly (403elsewhere).groupssays so:{cli: {scopes, reaches: '/env'}, mcp: {scopes, reaches: '/mcp'}}. The agent's kind is docs/API-MCP.md; the rest of this section is the command line's. In the editor, Settings → Tokens makes both kinds: "New command line token" (#/settings/tokens?new=cli) and "New AI assistant token" (?new=mcp). Authorization: Bearer mst_…reaches/env/*only, as its person, with no Origin or CSRF check (a browser never sends it on its own).GETneedsenv:read, a writeenv:promote. A revoked, expired or unknown token is401(auditedsecurity.token); 20 misses from one address in 10 minutes and it waits (429). Each use keepslastUsedAtand the address. A disabled account's tokens stop working.
Branch previews (staging)
GET env/previews→{on, host, previews: [{slug, branch, sha, status: building|ready|failed, url, createdAt, builtAt, job, error}]}.POST env/previews {branch}→202 {preview, job}(rules:previewfrombranch). The branch is checked out in<dataDir>/env/previews/<slug>and built; it is served athttps://<slug>.<previews host>/behind the gate.DELETE env/previews/:slug(who made it, administrator, owner).
The site gate (outside production)
The site's hosts (siteHosts) and the preview hosts answer only people with an account on this server:
- Without the cookie, a page answers
303to<CMS>/markstage/gate?host=<host>&to=<path>. Signed in there, a page posts a one-time ticket (1 minute) tohttps://<host>/markstage/gate-enter, which setsms_gate(HttpOnly, SameSite=Lax, 12 hours at most, signed for that host only) and returns to the page. The cookie is bound to the CMS session it was opened from: signing out of the CMS, a revoked or timed-out session, or a disabled account ends it. - A file the build links to outside itself (a symlink, which a branch could add) is never served.
GET /markstage/gate-authon the host answers204or401, for nginxauth_requestwhen nginx serves the build itself. Otherwise Markstage serves the build (verify.public, or the preview's) to a signed-in person.
Command line
- On the server:
markstage-server env status,env sync [--run] --as <email>,env promote <branch> [--run] [--reason "…"] --as <email>. The same rules, as that person; refused while the running server has a job going. - On a laptop (
@markstage/astro, node and git only):markstage pull [--from main],markstage login <url> [--token-stdin],markstage status,markstage promote <branch> <env> [--run] [--reason "…"]. The token is kept in~/.config/markstage/credentials(0600) and never printed. The address is the editor's: a server with anotherbasePathis named by its path (https://example.com/cms/), kept with the token.markstage loginkeeps a command line token only (an agent's token cannot reach/env);markstage mcp loginandmarkstage mcpare the agent's (API-MCP.md).
Proof
packages/server/test/env-flow.test.mjs, env-safety.test.mjs, env-cli.test.mjs and env-rules.test.mjs: two
servers (production, staging) on one bare remote and a developer's clone, through the HTTP API and both command
lines. They cover: a template change and a new page promoted to staging and on to production; a sync down that keeps
a staging-only page; a conflict named in words and resolved each way (ours, theirs, a hand-merged text) and a page
both sides edited in different places merged with no conflict; a failed check that blocks; a failed build that leaves
production and its branch unchanged; a rollback after which the branch equals what is live and is pushed; a person
the rules do not allow refused in words and audited; a freeze that begins between plan and run refusing the run; a
stale and an expired plan that cannot run; staging mailing nobody, its forms saying "Test form: not sent", noindex on
every answer, the site gate and a branch preview; tokens (scopes, env routes only, 90 days, revoked mid-job, rate
limit); and the AI summary with a stubbed model (dropped when it states a number the facts do not have).
Source: docs/API-ENV.md on GitHub. Something unclear? Open an issue.