Vars, Backups, Agents, Services, Database¶
Remaining data endpoints covering environment variables, backups, agent status, service management, and database operations.
Environment
No route on this page takes an environment parameter — the API process is
bound to a single environment and resolves it server-side.
Environment Variables¶
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/vars/{app_name}?branch= |
read | Get env vars |
PUT |
/api/vars/{app_name} |
deploy | Set env vars (merges with existing) |
DELETE |
/api/vars/{app_name} |
deploy | Remove a single env var |
GET |
/api/vars/{app_name}/copy-preview?from=&branch= |
read | What copying another branch's vars would do (key names only) |
POST |
/api/vars/{app_name}/copy-from-branch |
deploy | Copy vars from another branch, skipping or overwriting conflicts |
Variables are per branch. Each route takes the branch from the query
string (GET) or the body (PUT / DELETE), defaulting to main.
Get Variables¶
Returns the current environment variables for an app+branch. Returns an empty
variables object if none are set.
{
"app_name": "my-web-app",
"environment": "dev",
"variables": {"LOG_LEVEL": "info"},
"updated_by": "alice",
"updated_at": "2026-03-15T09:12:00Z"
}
⚠ updated_at / updated_by describe the WHOLE BLOB, never one variable
(BUGS #1236). app_env_vars stores one JSONB document per (app, branch) and
every writer MERGES into it, so setting any key refreshes the row stamp —
"when was SECRET_KEY set?" is not answerable from these two fields once a
sibling key has been touched. Surfaces that render them must say "variables
last changed", never "this variable set at". For per-key provenance use the
audit trail (below), which records the KEY NAMES of every set and unset.
⚠ updated_by is the authenticated principal, so anything written through
an admin session reads as platform-root@appmecca.internal regardless of which
human held it.
Set Variables¶
Request body:
Merges with existing variables. To replace a value, set it again. New values take effect on the app's next deploy.
Unset Variable¶
Request body:
Removes a single key.
Copy Variables From Another Branch¶
FEATURES #437. Two steps: preview, then apply with the version the preview returned.
{
"app_name": "my-web-app",
"environment": "dev",
"branch": "dev",
"source_branch": "main",
"new": ["SENTRY_DSN"],
"identical": ["LOG_LEVEL"],
"conflict": ["API_BASE"],
"destination_updated_at": "2026-09-17T10:04:11.482913+00:00"
}
- Key names only — values are never returned.
new: not on the destination.identical: same key, same value (nothing to do).conflict: same key, different value — the apply must sayskiporoverwrite.destination_updated_atisnullwhen the destination has no variables yet.
{
"branch": "dev",
"source_branch": "main",
"on_conflict": "skip",
"keys": ["SENTRY_DSN", "API_BASE"],
"expected_updated_at": "2026-09-17T10:04:11.482913+00:00"
}
Response: {"copied": [...], "overwritten": [...], "skipped": [...], "destination_updated_at": "..."}.
keysnarrows the copy to those source keys; omit it to copy every key.- ⚠ Send
expected_updated_atexactly as the preview returned it. Re-parsing or reformatting it (for example dropping the microseconds) makes every apply refuse. - The copy is recorded in the audit log (
env_vars_copied) with key names only.
Errors: 409 — the destination changed since the preview (preview again), or the
destination is this app's production branch (production can be copied from, never
into). 404 — either branch is not registered on this app in your group. 422 — source
and destination are the same branch.
Custom Domains¶
Moved to a dedicated page — see Custom Domains for the
two-record procedure, the records guidance block, apex handling, and the
NS-hosting endpoints.
System Domains & Static Sites¶
GET /api/system-domains (operator)¶
ONE row per hostname (FEATURES #415). The three source passes (target groups,
custom domains, enabled CloudFront distributions' aliases[0]) are merged on
fqdn: kinds / targets / service_names / app_names are sorted unique
lists; kind is custom when any source was a custom domain. The scalar
target and the cert fields come from the row that TERMINATES the hostname —
custom domain, else CloudFront, else the ALB — and app_name / service_name
are the first non-empty values; they remain for the monitor's Domains tab, and
new readers use the lists.
Rows with no hostname are dropped and counted in skipped_without_hostname.
An operator (platform-wide scope) indexes EVERY group's apps, so tenant
hostnames render in their real {slug}-{service}-{group} / path-routed
{slug}-{group} shape.
GET /api/static-sites¶
Each row now carries branch and group_slug (the row's own joined group, or
null — never the "system" hostname fallback), and
custom_domains[].fqdn is read from the custom_domains.domain column (it was
always null). A custom-domain row owned by a DIFFERENT group that shares the
canonical app_name (BUGS #692) is not badged; a legacy row with a NULL
group_id still is.
FEATURES #421 (6.5) — detail fields. Every row also carries what the edge is
actually doing. Each is null when the platform could not tell, and an API
predating 6.5 sends none of them:
| Field | Meaning |
|---|---|
live_commit |
The commit this distribution's own origin path resolves to — the edge's answer, not the registry's. null when the agent's config snapshot was not collected, when no origin is this row's own path (a branch can be shown a distribution it merely claimed), or when two of its origins disagree. |
edge_coverage |
standard / extended / global — the platform's vocabulary for the distribution's CloudFront price class. An unmapped class is returned RAW (PriceClass_*) rather than dropped. |
cache_mode |
normal / bypassed / unknown. The bypass marker is tri-state: unknown means the tag was never read (BUGS #1160), never "not bypassed". |
default_ttl |
The distribution's default TTL, or null when the config read failed. 0 is a real value, not an absence. |
pending_invalidation |
A deferred /* purge is still queued (BUGS #16). null = the queue could not be read — not "no purge queued". |
access |
public / internal / password / unknown — which gate, if any, stands in front of the app. |
distribution_enabled_while_gated |
true when a GATED app's distribution is still enabled. Expected for up to ~15 minutes after a visibility or password change, while DNS moves off the edge (BUGS #700); past that the private data plane did not install and the bundle may be reachable at the edge (BUGS #695). |
thumbnail_version |
This service's own deploy-preview version, for ?v= cache-busting. Never another service's. |
snapshot_age_s, stale |
How old the agent's CloudFront snapshot is, and whether it is past the staleness window. |
?include_stale=true KEEPS sites whose snapshot has aged out, flagged stale,
instead of omitting them — a list that shrinks as the sweep degrades reports a
smaller fleet rather than a stalled sweep. The response echoes stale_included,
so a client can tell whether the API honoured the request or predates it.
Without the parameter the behaviour is unchanged: fresh snapshots only.
⚠ current_commit_sha is always null — no migration creates that column.
live_commit is the field that answers what is the edge serving now.
App Backups¶
Whole-app backup bundles — the database and every declared drive in one manifest-verified archive.
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/apps/{app}/backups?branch=&limit= |
read | List bundles, newest first |
GET |
/api/apps/{app}/backups/manifest?bundle_prefix= |
read | Read one bundle's manifest |
GET |
/api/apps/{app}/backups/download?bundle_prefix= |
deploy | Presigned URLs for the bundle's objects |
GET |
/api/apps/{app}/recovery-points?branch=&since= |
read | Point-in-time recovery points |
POST |
/api/apps/{app}/backup |
deploy | Queue a bundle backup → 202 |
POST |
/api/apps/{app}/backups/{backup_id}/restore |
admin | Restore a bundle → 202 |
DELETE |
/api/apps/{app}/backups?bundle_prefix= |
admin | Delete every object in a bundle |
POST |
/api/apps/{app}/clone-from-branch |
admin (session) | Restore this branch's data from another branch → 202 |
A restore verifies the manifest's checksums before touching anything — a
corrupted bundle is refused and the app stays up. Restores of a multi-branch
app require an explicit branch: rather than guess which branch to overwrite,
the API returns 409 telling you to name one.
download is deploy scope, not read: the bundle is staged by the agent
and occupies a deploy-worker slot.
Restore From Another Branch¶
FEATURES #437. Queues a RESTORE that copies another branch's current data:
the agent backs up the destination (database and drives), takes a fresh backup of
the source, restores it, then merges buckets.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
branch |
string | yes | — | Destination branch. Never resolved. |
source_branch |
string | yes | — | Branch to copy from. |
components |
object | no | {db: true, drives: false, buckets: false} |
What to copy. |
overwrite_objects |
boolean | no | false |
Replace bucket objects that already exist. Nothing is ever deleted. |
confirm |
string | yes | — | Must equal the app name as written in the path. |
Session callers only (an API key gets 403). Refusals: 422 missing field,
same branch, nothing selected or wrong confirm; 404 a branch outside the
caller's group; 409 the destination is the production branch, or it has never
been deployed; 503 until every deployment agent can run the copy.
202 response: {app_name, environment, branch, source_branch, action,
action_id, status: "restore_requested", components}. Poll
GET /api/deployments/queue/{action_id}; its clone_result is
{source_branch, components, result}, where result —
{source_bundle_prefix, safety_bundle_prefix, drives_restored, buckets,
buckets_one_sided, buckets_source_not_provisioned} — is written on success and
failure (a failed copy names its safety bundle, the way back).
Agents¶
Operator-only
/api/agents and /api/agents/pool require platform-operator
privileges — a tenant caller gets 403. Only /api/agents/version is
read scope.
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/agents |
platform-operator | List agents |
GET |
/api/agents/pool |
platform-operator | Host pool summary |
GET |
/api/agents/versions |
platform-operator | Current + previous agent wheels |
GET |
/api/agents/{host_id}/journal |
platform-operator | Read a host's agent journal |
PUT |
/api/agents/version |
platform-operator | Record a published agent version |
GET |
/api/agents/version |
read | Get the published agent version |
POST |
/api/admin/fleet/reset-pioneer |
platform-operator | Clear a pioneer_failed self-update block (FEATURES #419) |
List Agents¶
Returns all agents with their heartbeat data, container counts, disk usage, and version info.
Each agent object includes a display_name derived server-side from the host's name tag (stored in agents.instance_name). The derivation strips the appmecca-{environment}- prefix and title-cases the kebab-case remainder: appmecca-dev-docker-host-a → Docker Host A. If the tag is missing or blank, display_name falls back to the last 8 characters of the instance id. UI clients should prefer display_name for presentation; instance_name (raw tag) and instance_id remain available for tooling that needs the exact source value.
The response also includes schedulable: bool and cordon_reason: str | null. schedulable=false means the placement engine will refuse to schedule new work on this host (running containers stay). cordon_reason is the free-text reason the operator passed to appmecca infra cordon, or null if none was given or the host is uncordoned. The same fields are returned by /api/agents/pool, which re-uses the same formatter.
Host facts (FEATURES #419). Every agent object also carries four tri-state fields — each is
null for an agent predating the feature or a fact that was not measured, never a default:
| Field | Type | Meaning |
|---|---|---|
agent_started_at |
ISO string | null | When the agent process started (column agents.started_at). |
api_deploy_responsible |
bool | null | true on the ONE platform host that bootstraps the API; null is unknown, never "not the platform host". |
host_facts |
object | null | Slow facts, allow-listed: os, kernel, docker_version, instance_type, az, ami, arch, booted_at. String values of 1–256 characters only; an absent key was never measured. |
host_load |
object | null | cpu_pct (0-100), load_1m, load_5m, load_15m, sampled_at. Emitted only when sampled in the same heartbeat as last_heartbeat (within 10 s either side, one clock); a stale sample is null, never re-served as current. An out-of-range cpu_pct is dropped, not clamped. |
Host Pool Status¶
Returns the same per-host detail as /api/agents plus a fleet-wide summary block (total / healthy host counts, aggregate memory, total containers) and an active_colors map.
FEATURES #419 adds three summary figures, all API-derived so a dashboard card and the host it links to
read one derivation:
cpu—{measured_hosts, avg_pct, busiest}over alive hosts with a freshhost_load.cpu_pct.avg_pct/busiestarenullwhen no host is measured (never0); render "of N measured".disk.fullest—{instance_id, display_name, percent}over alive hosts whose heartbeat actually reported a disk percentage, elsenull.agent_versions— a count per reported version string. It is not an ordering; the drift verdict is/api/health/summary→agents.version_consistent.
Reset Pioneer¶
Clears a pioneer_failed self-update block so the next agent tick re-attempts the pioneer claim — the
console's equivalent of appmecca agent reset-pioneer, but narrower: it releases only
update_status = 'pioneer_failed', in one compare-and-set statement. A stale pioneer_updating claim is
refused, because releasing it safely needs a heartbeat-age liveness judgement the console does not make;
use the CLI for that.
- 200
{"previous_status": "pioneer_failed", "previous_pioneer": "i-…", "published_version": "5.3.9"}— audited asagent_reset_pioneer(target_kind: platform). - 409
{"detail": {"code": "not_pioneer_failed", "update_status": "<live state or null>", "message": "…"}}— nothing is written or audited. Forpioneer_updatingthe message namesappmecca agent reset-pioneer -e <env>; if the fleet becamepioneer_failedbetween the reset and the status read, the message says so and asks for a retry. - 403 for any caller that is not a platform operator (a tenant group OWNER included).
active_colors — ground-truth blue/green signal per service:
{
"active_colors": {
"recipe-cookoff|api": "blue",
"recipe-cookoff|web": "green",
"livechat|backend": "blue"
}
}
Key shape is "{app_name}|{service_name}", value is "blue" or "green". The deployment agent stamps this atomically once every new target is confirmed healthy — so the map matches the traffic-switch instant, not whenever a downstream heuristic happens to notice.
Consumption model:
- Apps that have never completed a successful deploy, agents that never stamped a colour, and malformed values are filtered out server-side.
- The map is empty when nothing has a colour recorded.
- A query failure does not break the pool response —
active_colorsreturns{}and the rest of the response is unchanged.
Consumers build a Map<"{app}|{service}", "blue"|"green"> from this and use it as the authoritative active-side signal, falling back to a stable blue-default heuristic when a key is absent.
Get Agent Version¶
Returns the published agent version and its download URL for the environment.
Agent Journal¶
Reads a host's agent journal without an SSH session. host_id accepts a short
name, an instance id, or a private IP. lines is 1–5000 (default 200).
grep and level are applied by journald, so lines caps matching entries,
not raw log lines — lines=50&since=2 hours ago returns the 50 most recent
matches within that window. grep is a case-sensitive substring (it is
regex-escaped before journald sees it, so metacharacters are literal); level
is an exact match on INFO / WARNING / ERROR.
Response:
{
"records": [{"timestamp": "…", "level": "INFO", "message": "…", "unit": "…", "pid": "…"}],
"truncated": true,
"scanned": 2000,
"window_start": "2026-08-07T15:38:01+00:00",
"filters_pushed_down": true
}
records is ordered oldest-first. truncated reflects whether the cap was
actually reached — not whether the caller asked for the maximum. When it is
true, window_start is the timestamp of the oldest entry examined, so a caller
can tell "nothing matched in your whole window" from "I could only see back
to 15:38". filters_pushed_down is false on a host whose journald is too old
for server-side filtering (systemd < 237), where the cap applies to raw lines
and anything older was genuinely not searched.
Errors: 400 on a malformed query value (e.g. an embedded NUL), 500 if
journald itself fails, 504 on timeout. A host with no journalctl returns
200 with an empty records list.
Services¶
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/services/{app_name}?branch= |
read | Get service status |
POST |
/api/services/{app_name}/{service}/restart |
deploy | Restart one service → 202 |
POST |
/api/services/{app_name}/{service}/stop |
deploy | Stop one service → 202 |
POST |
/api/services/{app_name}/{service}/start |
deploy | Start one service → 202 |
POST |
/api/services/{app_name}/restart |
deploy | Restart every service → 202 |
POST |
/api/services/{app_name}/stop |
deploy | Stop every service → 202 |
POST |
/api/services/{app_name}/start |
deploy | Start every service → 202 |
Restart / stop / start queue actions for the agent to execute. Omit branch on
the status route and it resolves the app's production branch.
A static service (one whose status is edge) has no container, so these
routes refuse it with 422 and queue nothing. The detail names the service
and says to redeploy the app to republish it. An app-level action on an app
with both kinds queues only its container services; the response lists them in
services and names the static ones it skipped in skipped_static. If every
service it would reach is static, it answers 422.
What a service's status means¶
Each service's status answers one question: is this service running right
now? The answer comes from the agents' heartbeats. Every 30 seconds each host
reports the containers it is running. The deployment record is not used.
status |
Meaning |
|---|---|
running |
At least one live host reports this service, and that host's container list is current. |
not_running |
Every host was heard from, and none reports this service. |
unknown |
The platform cannot say. runtime.reason gives the cause. |
edge |
A static service. The CDN serves it, so it has no containers. |
not_running is a claim about every host at once, so it has strict
preconditions. If any one fails, the answer is unknown, never not_running.
An agent that has not reported for 60 seconds is not live.
Every service also carries a runtime object with exactly three keys:
| Key | Type | Meaning |
|---|---|---|
replicas_running |
integer | null |
For running, the number of hosts running this service. For not_running, 0. Otherwise null. |
hosts_unverified |
integer | null |
The number of live hosts that report this service from a container list that may be out of date. They are not counted in replicas_running. null for edge, and for the reasons read_failed, no_alive_agents and ambiguous_name, where the platform cannot count this app's hosts. |
reason |
string | null |
Set only when status is unknown. Otherwise null. |
Both counts are hosts, not containers. During a blue/green deploy the old and the new container are briefly on the same host, and they count once.
When status is unknown, reason is one of these. If more than one applies,
the first in the list is used:
reason |
Why the platform cannot say |
|---|---|
read_failed |
The host inventories could not be read. |
no_alive_agents |
No host is reporting. This is what a paused environment looks like. |
ambiguous_name |
More than one group has registered this app's name, so the reports cannot be attributed. |
fleet_below_floor |
A live host runs an agent too old to say whether its container list is current. |
inventory_stale |
A live host's container list is out of date because its Docker did not answer. |
host_silent |
A host has stopped reporting but has not yet been removed. It may still be running the service. |
A running answer needs only one current report, so a stale or silent host
does not change it. Such a host can only turn what would have been
not_running into unknown.
The same status and runtime appear on every response that lists services:
this route, GET /api/apps, GET /api/apps/{app_name} and the dashboard
overview. Every one of them computes the value the same way. A response
without the runtime object comes from an API that predates it, and clients
treat its status as unknown.
healthy and down are not values of this field. They are HTTP
health-check results, reported on /api/status/{app}.
Host-placement internals on the status response (which host is running what) are omitted for tenant callers — only a platform operator sees them.
Each service also carries two advisory fields, recorded by the agent once per deploy by checking what the container has:
| Field | Type | Meaning |
|---|---|---|
http_client |
"curl" | "python3" | "node" | "none" | null |
The HTTP client the test-email send will use in this service's container. null means not yet checked (the app has not deployed since this was added) or the check failed. |
capability_probed_at |
ISO-8601 string | null |
When that check ran. null whenever http_client is null. |
These are hints for display only. Every test send checks the container again. The aggregated dashboard overview carries the same two fields per service.
Database Management¶
| Method | Path | Scope | Description |
|---|---|---|---|
POST |
/api/db/{app_name}/upload-url |
deploy | Mint a presigned POST for a dump upload |
POST |
/api/db/{app_name}/restore-upload |
deploy | Restore the DB from an uploaded dump → 202 |
GET |
/api/db/{app_name}/restore-upload/{action_id} |
deploy | Poll a restore-from-upload |
GET |
/api/db/hosts |
platform-operator | Database host inventory |
GET |
/api/db/hosts/{host_id} |
platform-operator | One host's status |
GET |
/api/db/hosts/{host_id}/databases |
platform-operator | Per-database sizes + connection counts |
Database lifecycle is implicit
There are no create / drop / backup / restore routes under /api/db.
A database is provisioned automatically on the first deploy of an app
whose appmecca.yaml sets database.enabled: true, and is dropped (after
a safety backup) when the app is deleted. Backups live on the app-backup
surface above, not here.
Restore from an uploaded dump¶
Bring an existing Postgres database onto the platform: upload a pg_dump,
then destructively replace the app's database with it. The API never handles
the bytes — the browser or CLI uploads straight to object storage using a
presigned POST policy the agent mints, which pins the exact key and caps the
size at 5 GiB.
1. Mint the upload URL
confirm is the app name as you addressed it, typed back. It is optional here
and is a courtesy check for the human — it fails a mistyped name before a
multi-gigabyte upload — but it is not the boundary: the restore route checks
it again, independently. Both write routes accept session callers only; an
API key gets 403. A declared size over 5 GiB returns 413; an agent
failure returns 502. Returns the upload target, the server-chosen upload
key, and the branch that key was minted for.
Which branch — resolved, never guessed
branch is optional on both write routes. An explicit branch is used
as given. Omit it and the server uses the app's only registered
branch; an app registered on more than one branch is refused 409 —
"pass -b/--branch to choose which branch to restore into" — before any
upload URL is minted or any restore queued, because a restore replaces a
whole database and replacing the wrong branch's is the one mistake that
cannot be undone. Pass the branch the upload-url response returns into
the restore call rather than resolving twice.
2. POST the dump bytes to the returned upload URL.
3. Queue the restore
{"upload_key": "uploads/dev/gallery/main/...", "branch": "main", "quiesce": false, "confirm": "gallery"}
confirm is required here and is checked server-side — a foot-gun guard
against the wrong window or the mis-click, not an authentication control. The
agent safety-backs-up the current database first, so a wrong file is
recoverable. Responds 202:
{
"app_name": "gallery",
"environment": "dev",
"branch": "main",
"action": "db_restore_upload",
"action_id": "7c1a...",
"status": "db_restore_upload_requested"
}
The upload_key is validated twice. It must fall under the gated app's own
uploads prefix (uploads/{env}/{app}/) — a key pointing at another tenant's
upload returns 404, never reading a foreign dump into this app's database.
And its branch segment must match the branch the restore targets — a dump
uploaded for one branch is refused 409 rather than restored into another.
Poll the restore
branch is optional here and only authorises the read — the action id already
names the restore.
{
"action_id": "7c1a...",
"app_name": "gallery",
"status": "db_restore_upload_completed",
"terminal": true,
"error": null,
"result": {"tables": [{"name": "photos", "rows": 1200}]},
"created_at": "2026-07-21T09:00:00Z",
"updated_at": "2026-07-21T09:02:30Z"
}
Cron Jobs¶
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/apps/{app_name}/cron?branch= |
read | List cron jobs |
GET |
/api/apps/{app_name}/cron/history?job=&limit=&branch= |
read | Execution history |
POST |
/api/apps/{app_name}/cron/{job_name}/trigger |
deploy | Trigger job immediately |
PATCH |
/api/apps/{app_name}/cron/{job_name} |
deploy | Enable or disable a job |
List Cron Jobs¶
Returns all cron jobs declared in the app's appmecca.yaml cron section, along with their current enabled/disabled status, schedule, last execution time, and next scheduled run.
Execution History¶
Returns recent cron executions under an executions array. Optional query
parameters: job (filter by job name), limit (default 50, max 200),
branch.
Trigger Job¶
No request body. Queues an immediate execution of the named cron job, bypassing its schedule.
{
"status": "triggered",
"task_id": "b1c2d3e4-…",
"job_name": "cleanup-old-sessions",
"url": "https://my-web-app-web-acme.dev.appmecca.net/_cron/cleanup-old-sessions"
}
Errors:
400 Bad Request— the job has no resolved URL, i.e. the app is not deployed.404 Not Found— no such job on that app.
Poll the returned task_id via GET /api/tasks/{task_id}.
Enable / Disable Job¶
Request body:
Toggles the job's enabled state. Disabled jobs are skipped by the scheduler but remain in the configuration. Returns the updated job record.
One-off Commands (exec)¶
Run a command inside a deployed container without shelling into a host.
| Method | Path | Scope | Description |
|---|---|---|---|
POST |
/api/apps/{app_name}/exec |
deploy | Queue a command → 202 |
GET |
/api/apps/{app_name}/exec?branch=&limit= |
read | Recent exec history |
GET |
/api/exec/{action_id} |
read | Status, exit code, duration |
GET |
/api/exec/{action_id}/logs?after_id= |
read | Captured stdout/stderr |
The command is queued for the agent, which runs it in the app's container and
streams the output back. Poll /api/exec/{action_id} for the exit code and
/api/exec/{action_id}/logs for output — pass after_id to fetch only new
lines when following. action_id accepts a prefix of at least 8 hex
characters.
Container Stats¶
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/stats/summary?app_name=&branch= |
read | Fleet or single-app snapshot |
GET |
/api/stats/{app_name}?service_name=&since=&branch= |
read | Recent per-container memory / CPU |
GET |
/api/stats/{app_name}/daily?service_name=&since=&branch= |
read | Daily aggregates |
since is a relative range (1h, 6h, 24h on the detail route; 7d,
30d on the daily route). Omit branch to span every branch. With no
app_name, the summary is filtered to the apps the caller can see.
Debug Dump¶
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/apps/{app_name}/debug?branch=&reveal= |
read | One-shot diagnostic dump |
GET |
/api/apps/{app_name}/debug/container-env/{service_name} |
admin | Live container environment |
The dump aggregates configured variables, recent deploy errors, per-service
health, resolved URLs, and a configured-vs-actual environment-key drift
report. Sensitive values are redacted to a hash by default at every scope;
reveal=true returns cleartext and is admin-only and audited.
container-env inspects the running container and is therefore admin-only —
it queues an inspection action for the agent and waits for the result.
Deploy Previews¶
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/apps/{app_name}/thumbnail?branch=&v= |
read | The app's preview image |
GET |
/api/apps/{app_name}/services/{service_name}/thumbnail?branch=&v= |
read | One service's preview image |
POST |
/api/apps/{app_name}/thumbnail/recapture?branch= |
deploy | Force a fresh capture now |
A multi-front-door app stores one image per primary service. v is an opaque
cache-busting key — pass the preview's capture timestamp (surfaced as
thumbnail_version on the service) and the response earns a long-lived
immutable cache; omit it and the response is cached only briefly. Never invent
a v: a wrong-but-present value pins a stale image.
recapture is the "Regenerate preview" control. It is single-flighted per app
and branch, so a burst of clicks returns busy rather than piling up work.
Tasks¶
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/tasks/{task_id} |
read | Get async task status and result |
Returns the current state of a background task — e.g. a cron job triggered via
the API. The response carries task_id and status
(PENDING/STARTED/SUCCESS/FAILURE), plus result or error once the
task has finished.
Admin — Drive Storage Grants¶
Platform-operator endpoints that set a group's drive-storage entitlement.
Above the per-app free allowance (default 5 GB) a group's apps can only declare
drives if the group holds a grant, and the group-wide declared total must fit
inside min(grant, 100). 100 GB is the hard ceiling for every tenant.
| Method | Path | Scope | Description |
|---|---|---|---|
POST |
/api/admin/groups/{slug}/storage-grant?gb={1..100} |
platform-admin | Set the group's storage grant (GiB). 409 on the system group. |
DELETE |
/api/admin/groups/{slug}/storage-grant |
platform-admin | Clear the grant (back to the free allowance). Idempotent. |
POST returns {"slug": "...", "storage_grant_gb": N}; DELETE returns {"slug": "...", "storage_grant_gb": null}. gb outside 1..100 → 422; unknown slug → 404. Running apps are unaffected — only the group's next build/deploy re-gates. The gate lives on the deploy queue-writes and on the agent's build→deploy step; an over-entitlement deploy returns 409 with a message naming the shortfall. CLI: appmecca auth group storage-grant <slug> --gb N / storage-revoke <slug>.
Admin — Search (operator ⌘K)¶
GET /api/admin/search¶
Platform-operator only (require_platform_operator() — a platform-admin
session or a legacy platform-admin api-key; a tenant OWNER session is
403, anonymous 401). FEATURES #416 P1.
| Query | Rule |
|---|---|
q |
trimmed; 2–128 characters and no control characters, else 422 |
limit |
per section; default 8, 1–25, else 422 |
{ "query": "gtu",
"sections": {
"users": {"items": [{"id", "email", "name", "disabled"}], "truncated": false, "error": null},
"groups": {"items": [{"slug", "name", "personal", "system", "deleted_at"}], "truncated": false, "error": null},
"apps": {"items": [{"app_name", "slug", "branch", "group_slug"}], "truncated": false, "error": null},
"hosts": {"items": [{"instance_id", "instance_name", "private_ip"}], "truncated": false, "error": null},
"domains": {"items": [{"domain", "app_name", "status"}], "truncated": false, "error": null},
"actions": {"items": [{"action_id", "action_type", "app_name", "branch", "status"}], "truncated": false, "error": null},
"distributions": {"items": [{"distribution_id", "comment"}], "truncated": false, "error": null} } }
- Each section is independently tri-state. A section that fails answers
items: nullwitherror: "could not search <kind>"while the others still answer;items: []is a successful search that matched nothing. A client must never render a failed section as "nothing matches". - Substring match is
ILIKE '%' || q || '%' ESCAPE '\'on the escaped value, so%,_and\inqare matched literally. actionsis a PREFIX match that only runs whenqis id-shaped (hex + hyphen, at least 8 characters). Any otherqanswersactions.items: []without querying — never a LIKE on free text.- Soft-deleted groups are included, with
deleted_atset: a soft delete keeps the slug consumed, and a deleted group named in an audit row is exactly what an operator looks up. truncatedistruewhen the section had more thanlimitmatches.- One pool connection per request; each section statement is bounded at 2 s and the request at 5 s — a section not run before the deadline is reported failed, never empty.
- Unfiltered by construction — the route never resolves a group scope, so
every group's rows are searched. Not searched in P1: platform-derived
hostnames (the computed
/api/system-domainslist), onlycustom_domains. - Never edge-cached (
no-cachetier, declared above the/api/admin/*catch-all).
Admin — Users, groups and security posture (FEATURES #420)¶
Operator routes behind the console's Users, Groups and Security pages. Each answers 401 to an anonymous caller and 403 to anyone who is not a platform operator — a tenant group OWNER included. User detail, a user's sessions and log out everywhere need a platform-admin session: an api-key is refused. Activity, group apps and posture also admit the legacy platform-admin api-key (admin scope, no group binding).
| Method | Path | Returns |
|---|---|---|
GET |
/api/admin/users/{user_id} |
One user: the account, is_platform_admin (the verdict), is_platform_root (by id), memberships[] in live groups and auth_methods; no hash or token column. 404 no such user; 422 a non-UUID path. |
GET |
/api/admin/users/{user_id}/sessions |
The user's live sessions, most recently used first; is_current is true only on the caller's own. An unknown id answers []. |
GET |
/api/admin/users/{user_id}/activity |
What the user did (as=actor, default) or what was done to them (as=target). |
DELETE |
/api/admin/users/{user_id}/sessions |
Log the user out everywhere. |
GET |
/api/admin/groups/{slug}/apps |
The group's app rows, one per branch. 404 unless a live group has the slug — a soft-deleted group 404s here although GET /api/admin/groups?include_deleted=true lists it. |
GET |
/api/admin/security/posture |
Four posture sections. |
Activity matches actor_user_id, or for as=target target_kind = 'user'
with target_id equal to the id — never an email. limit 1–200 (default 50),
action exact, before the previous page's next_cursor: a malformed cursor —
including one with no UTC offset, or a time that cannot be converted to UTC — is
422, never page one. An unknown id is not a 404: a deleted user's
as=target feed still answers.
Log out everywhere revokes, in one transaction, every unrevoked session of
the user and expires the CLI sign-ins they approved but have not claimed. 200
carries revoked, session_ids (live sessions only) and ended_current_session,
audited as admin_user_sessions_revoke_all. 409 confirm_ends_current_session
when the target is the caller without ?confirm_ends_current_session=true —
nothing revoked or audited. 404 unknown id; 422 a non-UUID path; platform-root is allowed.
Posture sections are read independently. Each carries error (null when
read); a failed section omits its payload rather than answering an empty
list. canary.state is clean, failing or no_result — no canary result has
been recorded, which is not clean. secret_rotation carries items[] (overdue
secrets), checked_at (the check's last healthy run; a skipped run counts),
last_result and stale — true when the check has had no healthy run for two
days or its last run skipped, so an empty items list is then unverified, not
"nothing overdue".
Additions to existing routes. GET /api/admin/users gains access_level
and is_platform_root; GET /api/admin/sessions gains kind;
GET /api/admin/groups gains member_count, owner_email, storage_grant_gb
and app_count, and accepts slug (exact, case-insensitive; composes with
include_deleted, limit and offset); GET /api/v1/auth/api-keys gains
group_slug. The user writes accept a user id as well as an email.
Refusals added on existing routes¶
Status · code |
Route | Meaning |
|---|---|---|
409 protected_target (reason: self · platform_root) |
user lock, limit, disable, delete | The target is the caller or platform-root, decided by id. Unlock and enable are never refused. |
403 system group memberships are managed by platform admin |
remove member, change role, create invite | The group is the system group. |
409 slug_has_dependants (apps, app_total, ns_zones, zone_total, message) |
PATCH /api/groups/{slug} with a new slug |
The group has apps (any branch or status) or NS-hosted zones, counted by group id; name lists capped at 50, totals exact. |
409 group_busy |
PATCH and DELETE /api/groups/{slug}; app registration (POST /api/apps, POST /api/builds); ns-setup; DELETE /api/admin/groups/{slug}; DELETE /api/admin/users/{email_or_id} |
Another change to the group held its lock for more than 5 seconds, or the database aborted this request to break a deadlock with one. Nothing was written. Retry. |
409 group_renamed_during_registration (group_slug) |
app registration | The group's slug changed while the app's name was being derived; retry against group_slug. |
409 group_deleted_during_registration |
app registration | The group row was deleted under the insert. |
404 no group with slug '<slug>' |
app registration, ns-setup |
The group was deleted while the request waited for its lock. Nothing was written. |
403 access_limited |
every POST, PUT, PATCH or DELETE behind an operator gate except POST /api/admin/config/diff, a preview that writes nothing (BUGS #1450); every group and invite write, POST /api/groups, …/leave and POST /api/invites/{token}/accept |
The caller's account is limited to read-only; the message names appmecca auth user unlock. |
403 Account disabled |
every route behind an operator gate | The operator's account is disabled. |
Admin — Operator write audit trail¶
Every platform-operator write route (POST/PUT/PATCH/DELETE gated by
require_platform_operator or require_platform_admin) records one
audit_events row after it succeeds — FEATURES #416 P1. Failed writes are not
recorded, and of the refusals only the two below are. Rows are read through the
existing group audit endpoint:
# Operator: the system group's view is UNFILTERED across groups, so operator
# rows with a NULL group_id (user, host, platform actions) appear here.
curl -H "Authorization: Bearer $OP" \
"https://api.qa.appmecca.net/api/groups/system/audit?action=host_"
| Field | Value |
|---|---|
action |
a fixed vocabulary, e.g. user_lock, group_delete, api_key_rotate, host_cordon, host_drain, fleet_rebalance, platform_config_write, app_purge, app_pitr_restore, status_check_delete |
actor_email / actor_api_key_prefix |
the session user, or — for a legacy platform-admin api-key — the key prefix only |
target_kind |
user, group, app, host, domain, action, distribution, session, api_key, config, platform |
target_id |
the target's id (a user's UUID, never their email; an api key's prefix; a host's instance id) |
group_id |
the owning group when the target belongs to one, else null |
detail_json |
action-specific facts plus "admin": true; a secret-shaped key — key, confirm, anything ending _hash, or containing token, secret, password, passwd, private_key or api_key, at any depth — is stored as "<redacted>" |
client_ip |
the leftmost X-Forwarded-For entry when it is an IP address (a :port suffix is stripped), else the connection address, else null |
Refused attempts (FEATURES #420). Two refusals record a row, before the
error, under the route's own action with detail_json.outcome: "refused" and a
reason: self or platform_root for protected_target (on user_lock,
user_limit, user_disable, user_delete), and system_group for the
system-group refusal (on group_member_remove, group_member_role,
group_invite_create). No other refusal writes anything.
Tenant group writes (FEATURES #420). The group routes record one row, with
the group's group_id, for every write that changed something — group_rename,
group_transfer, group_delete, group_member_remove, group_member_role,
group_invite_create (never the token), group_invite_revoke — whoever the
caller is; a rename, delete, member removal, invite revoke or role change that changes nothing records nothing. detail_json.admin is true only for a
platform operator.
Operator rows on a group's own audit (FEATURES #420). A caller who is not a
platform operator gets every detail_json.admin: true row of
GET /api/groups/{slug}/audit with actor_user_id, actor_email,
actor_api_key_prefix, client_ip and user_agent set to null (the
dashboard shows a platform administrator), and the actor filters compare the
same blanked values, so they never match such a row. Operators see every field.
Exact filters (FEATURES #418)¶
GET /api/groups/{slug}/audit accepts five more optional filters. Each is
ANDed after the group predicate, so it can only narrow what the caller
could already see:
| Param | Match |
|---|---|
actor_user_id |
exact user UUID |
actor_email_exact |
exact, case-insensitive email (the existing actor param stays a substring match — ann@x matches joann@x) |
group |
a group slug, honoured only on a platform operator's unfiltered system view; resolved against every group row, soft-deleted included. An unknown slug returns zero rows, never an unfiltered listing. On any other group's page it is ignored — the path already names the group. |
since |
created_at >= since (ISO 8601) |
until |
created_at < until (ISO 8601) |
⚠ An older API ignores unknown params and answers WIDER, so a client that cares about the narrowing must re-filter the rows it receives. The operator console's audit page does.
e2e_session_minted rows now carry the target user's id in target_id (the
email moved into detail_json.email). POST /api/admin/fleet/rebalance records a row
only when execute is true; dry runs of reap-orphans and
visibility/rewrite record nothing.
Group lifecycle — the two deletes and the slug¶
| Method | Path | Scope | Description |
|---|---|---|---|
POST |
/api/groups |
session (any user) | Create a group; caller becomes OWNER. 409 if the slug is reserved, held by a live group, or held by a soft-deleted one. 503 if the platform's reserved-hostname map is unreadable — see below. |
PATCH |
/api/groups/{slug} |
OWNER | Rename. Same three 409 cases on a new slug, and the same 503. A new slug is also refused 409 slug_has_dependants while the group has apps or NS-hosted zones; changing only name is not gated. 409 group_busy while another change holds the group's lock. A request that changes nothing returns 200 and writes nothing. |
DELETE |
/api/groups/{slug} |
OWNER | Soft delete — stamps deleted_at. 204. Personal + system groups refused. 404 for the loser of two concurrent deletes; 409 group_busy while another change holds the group's lock. |
DELETE |
/api/admin/groups/{slug}?force= |
platform-admin | Hard delete — removes the row. 409 if the group has apps and force is unset. 409 group_busy if another change holds the group's lock for more than 5 seconds. |
The 503 on name minting is a fault about US, never a verdict about the name (BUGS #1003)
Every endpoint that mints a name the platform routes on — group create,
group rename, app registration, and signup's personal-group slug — checks the
candidate against a reserved set derived from hostname-aliases.yaml. If that
file cannot be read, the check cannot be made, and the endpoint answers
503.
⚠ It deliberately does NOT answer 409 "reserved". Treating every
candidate as reserved while the map is unreadable would be the same outage
wearing a lying diagnosis — the name is not reserved, our configuration is
unreadable — and it would send the caller off to pick a different name that
would be refused identically. So the two refusals stay distinguishable:
409 means your name is unavailable, 503 means we could not check.
Read endpoints are unaffected: hostname resolution keeps its fail-open
behaviour and simply emits the ordinary {app}-{service} form, so an
unreadable map degrades URLs, never availability. Retrying is worthwhile —
the failed read is not cached, so the next request re-attempts it.
groups.slug carries a plain UNIQUE constraint that spans soft-deleted rows,
so a soft delete keeps the name reserved while making the group invisible to
every read. The 409 body distinguishes the two cases, because they need
different actions:
{"detail": "group slug 'acme' is already taken"}
{"detail": "group slug 'acme' is held by a deleted group. Slugs are identity
(a canonical app name is '{group}-{app}'), so a soft delete keeps
its slug reserved. A platform operator can release it with
`appmecca auth group delete acme`."}
Before BUGS #751 both cases returned a raw 500 naming the Postgres constraint, and the admin hard delete — the path that releases the name — also 500ed for any group with audit history, so the slug was unrecoverable. A concurrent create of the same fresh slug is likewise a 409 rather than a 500; the pre-check cannot close that race, only the constraint can.
The hard delete preserves audit_events rows with group_id nulled
(ON DELETE SET NULL) — they are not cascaded away.
Internal-Only Repositories (No REST Routes)¶
These repositories are used internally by the deploy service and are not exposed via REST:
| Repository | Table | Purpose |
|---|---|---|
PortRepository |
service_ports |
Host-port allocation (7001-8999) with advisory locks |
TargetGroupRepository |
target_groups |
Routing target name/identifier mapping |
DeployLockRepository |
deploy_locks |
Atomic deploy locks with TTL expiry |