Skip to content

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

GET /api/vars/{app_name}?branch={branch}

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

PUT /api/vars/{app_name}

Request body:

{
    "branch": "main",
    "variables": {"KEY": "value", "SECRET": "s3cret"}
}

Merges with existing variables. To replace a value, set it again. New values take effect on the app's next deploy.

Unset Variable

DELETE /api/vars/{app_name}

Request body:

{
    "branch": "main",
    "key": "SECRET"
}

Removes a single key.

Copy Variables From Another Branch

FEATURES #437. Two steps: preview, then apply with the version the preview returned.

GET /api/vars/{app_name}/copy-preview?from={source_branch}&branch={destination_branch}
{
    "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 say skip or overwrite.
  • destination_updated_at is null when the destination has no variables yet.
POST /api/vars/{app_name}/copy-from-branch
{
    "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": "..."}.

  • keys narrows the copy to those source keys; omit it to copy every key.
  • ⚠ Send expected_updated_at exactly 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

POST /api/apps/{app}/clone-from-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

GET /api/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

GET /api/agents/pool

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 fresh host_load.cpu_pct. avg_pct/busiest are null when no host is measured (never 0); render "of N measured".
  • disk.fullest — {instance_id, display_name, percent} over alive hosts whose heartbeat actually reported a disk percentage, else null.
  • 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

POST /api/admin/fleet/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 as agent_reset_pioneer (target_kind: platform).
  • 409 {"detail": {"code": "not_pioneer_failed", "update_status": "<live state or null>", "message": "…"}} — nothing is written or audited. For pioneer_updating the message names appmecca agent reset-pioneer -e <env>; if the fleet became pioneer_failed between 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_colors returns {} 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

GET /api/agents/version

Returns the published agent version and its download URL for the environment.

Agent Journal

GET /api/agents/{host_id}/journal?lines=200&since=30%20min%20ago&grep=&level=

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

POST /api/db/{app_name}/upload-url
{"filename": "prod.dump", "size_bytes": 104857600, "confirm": "gallery"}

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

POST /api/db/{app_name}/restore-upload
{"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

GET /api/db/{app_name}/restore-upload/{action_id}

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

GET /api/apps/{app_name}/cron

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.

{
    "app_name": "my-web-app",
    "environment": "dev",
    "jobs": [ /* … */ ],
    "total": 1
}

Execution History

GET /api/apps/{app_name}/cron/history?limit=50

Returns recent cron executions under an executions array. Optional query parameters: job (filter by job name), limit (default 50, max 200), branch.

Trigger Job

POST /api/apps/{app_name}/cron/{job_name}/trigger

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

PATCH /api/apps/{app_name}/cron/{job_name}

Request body:

{
    "enabled": false
}

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: null with error: "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 \ in q are matched literally.
  • actions is a PREFIX match that only runs when q is id-shaped (hex + hyphen, at least 8 characters). Any other q answers actions.items: [] without querying — never a LIKE on free text.
  • Soft-deleted groups are included, with deleted_at set: a soft delete keeps the slug consumed, and a deleted group named in an audit row is exactly what an operator looks up.
  • truncated is true when the section had more than limit matches.
  • 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-domains list), only custom_domains.
  • Never edge-cached (no-cache tier, 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