Storage — Drives & Object Buckets¶
Two per-app storage surfaces:
- Drives — a shared POSIX filesystem mounted into the app's containers. Browse, upload, download, and manage files without shelling in.
- Object buckets — private, per-app object storage with tenant-owned scoped credentials.
Environment
None of these routes take an environment query parameter — the API
process is bound to a single environment and resolves it server-side. An
optional branch selects the app row; when omitted it resolves to the
app's production branch (drives are branch-scoped, so a dev-only app no
longer 404s on a main default).
The API never touches your files
Every read and write is executed by the host agent over a control-lane
RPC (DRIVE_QUERY) or a deploy-lane action (DRIVE_APPLY); the API
container is jailed from the filesystem and the bucket. A caller-input
refusal (undeclared drive, path traversal, not-found) is a 404, never
a 403 and never a confirmation that another tenant's data exists.
Drives¶
Drives are declared in the app's appmecca.yaml (storage.drives). All
paths are relative to the drive root and kernel-contained — a symlink cannot
escape the drive.
Endpoints¶
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/apps/{app}/drives |
read | List declared drives + usage |
GET |
/api/apps/{app}/drives/{drive}/entries |
read | List one directory |
GET |
/api/apps/{app}/drives/{drive}/tree |
read | Recursively walk a subtree |
GET |
/api/apps/{app}/drives/{drive}/stat |
read | Stat one entry (lstat) |
POST |
/api/apps/{app}/drives/{drive}/upload-url |
deploy | Mint a presigned POST for a direct-to-storage upload |
POST |
/api/apps/{app}/drives/{drive}/upload |
deploy | Queue the ingest (staging → drive) → 202 |
GET |
/api/apps/{app}/drives/{drive}/upload/{action_id} |
read | Poll an ingest |
POST |
/api/apps/{app}/drives/{drive}/download-url |
deploy | Stage drive → storage, return a presigned GET |
POST |
/api/apps/{app}/drives/{drive}/rm |
deploy | Delete a file / subtree (password) |
POST |
/api/apps/{app}/drives/{drive}/mkdir |
deploy | Create a directory |
POST |
/api/apps/{app}/drives/{drive}/move |
deploy | Move / rename |
POST |
/api/apps/{app}/drives/{drive}/copy |
deploy | Copy |
POST |
/api/apps/{app}/drives/{drive}/purge |
deploy | Empty a whole drive (password + confirm) |
List drives¶
Response: 200 OK
{
"app_name": "gallery",
"environment": "dev",
"branch": "main",
"drives": [
{"name": "media", "size_gb": 20, "used_bytes": 4194304}
],
"total": 1
}
List entries¶
| Query | Type | Default | Notes |
|---|---|---|---|
path |
string | "" |
Relative path within the drive |
limit |
int | 1000 |
1–10000; truncated flags when hit |
branch |
string | production | Branch selector |
timeout |
int | 30 |
5–120 seconds to wait for the agent RPC |
Response: 200 OK
{
"app_name": "gallery",
"environment": "dev",
"branch": "main",
"drive": "media",
"path": "photos",
"entries": [
{"name": "sunset.jpg", "type": "file", "size": 512000},
{"name": "thumbs", "type": "dir"}
],
"total": 2,
"truncated": false
}
Tree¶
Same shape as entries plus a max_depth query (1–32, default 16). Each
entry carries path and size.
Stat¶
Returns a single entry object (lstat — a final-component symlink is never
followed).
{
"app_name": "gallery",
"environment": "dev",
"branch": "main",
"drive": "media",
"path": "photos/sunset.jpg",
"entry": {"name": "sunset.jpg", "type": "file", "size": 512000, "mtime": "2026-07-21T09:00:00Z"}
}
Upload¶
Upload is a three-step flow: mint a presigned POST, upload the bytes straight to staging storage, then queue the agent to move the object onto the drive.
1. Mint a presigned POST
size_bytes is optional; a declared size over the 5 GiB single-part cap
is rejected early with 413. Returns the POST target, the form fields, and
the server-chosen upload_key — the client never proposes the key:
{
"app_name": "gallery",
"branch": "main",
"drive": "media",
"url": "https://...",
"fields": {"key": "uploads/...", "policy": "...", "x-amz-signature": "..."},
"upload_key": "uploads/dev/gallery/main/..."
}
2. POST the file bytes to url with fields — a standard S3-compatible
browser POST, so any HTTP client or <form> can do it.
3. Queue the ingest
{"upload_key": "uploads/...", "dest_path": "photos/sunset.jpg", "overwrite": false, "sha256": "abc..."}
The transfer is asynchronous — responds 202:
{
"app_name": "gallery",
"branch": "main",
"drive": "media",
"action_id": "3fa1...",
"status": "drive_apply_requested"
}
Overwriting an existing file requires overwrite: true; otherwise the ingest
completes with a 409-mapped refusal. The agent enforces the drive's declared
quota (size_gb) at ingest using the object's server-reported size, so a
client cannot under-declare its way past the cap.
Poll the ingest
{"action_id": "3fa1...", "status": "drive_apply_completed", "error": null, "result": {"bytes": 512000}}
Download¶
The agent stages the file out of the drive into object storage — this occupies
a deploy-worker slot, hence deploy scope — and returns a short-TTL presigned
GET. expires_in is clamped to 1–86400 seconds. An optional timeout query
parameter (default 120, range 5–300) bounds how long the API waits for the
agent.
Manage — mkdir / move / copy¶
Each returns {"ok": true, "detail": {...}} once the agent completes.
POST /api/apps/{app}/drives/{drive}/mkdir {"path": "photos/2026", "parents": true}
POST /api/apps/{app}/drives/{drive}/move {"src": "a.jpg", "dst": "photos/a.jpg"}
POST /api/apps/{app}/drives/{drive}/copy {"src": "a.jpg", "dst": "b.jpg"}
Delete a file / subtree¶
Destructive — password re-auth required. password is the caller's own
account password. Re-auth is session-only; an API-key caller receives
403. Deleting a directory needs recursive: true.
Purge a drive¶
Empties the entire drive. Requires both password re-auth (session-only,
403 for API-key callers) and confirm_drive matching the drive name exactly
(a mismatch is 400).
Large-subtree timeout
A recursive rm / copy / purge of a very large tree can outrun the
synchronous wait and return 504 — but the op still completes
agent-side, and every op is idempotent (a re-rm → 404, a re-ingest
without overwrite → 409), so a retry is safe.
Object buckets¶
Self-service, private, per-app object storage with tenant-owned scoped
credentials. Buckets are declared in appmecca.yaml
(storage.buckets: [{name: media}, …]). The platform's own master keys never
enter your container — only your app's scoped key, which can reach nothing but
your app's own buckets.
The endpoint speaks the S3 API, so any S3-compatible client library works unchanged.
Endpoints¶
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/apps/{app}/bucket |
read | List the app's buckets + access key id |
POST |
/api/apps/{app}/bucket/reveal |
password | Show the secret key exactly once |
POST |
/api/apps/{app}/bucket/rotate |
password | Rotate the app-wide key |
The two password-gated routes take no require_scope dependency — the
caller's own account password, re-verified per call, is the gate. It is
session-only: an API-key caller gets 403.
List buckets¶
Returns the bucket names, the access key id, and whether the secret has ever been revealed — never the secret itself.
{
"app_name": "gallery",
"branch": "main",
"iam_user": "appmecca-dev-tb-…",
"buckets": [
{"name": "media", "bucket": "appmecca-dev-a1b2c3...", "env_var": "APPMECCA_BUCKET_MEDIA_NAME"}
],
"region": "<storage region>",
"access_key_id": "AKIA...",
"secret_revealed": false,
"provisioned": true
}
Every bucket credential route acts on one branch's credential. branch may
be given in the query string or, on reveal and rotate, in the JSON body;
two different values are refused 422. Omit it to act on the production
branch. A branch that is not registered is refused 404 naming the branch —
it never falls back to production.
Reveal the secret (show-once)¶
Shows the branch's secret access key exactly once. Flow: branch → group
gate → session-only (API-key callers get 403) → typed app name → the
branch's stored credential is checked to belong to that branch's IAM user →
an atomic one-time compare-and-set on that branch's
bucket_secret_revealed_at. The response carries Cache-Control: no-store
and names the branch, IAM user and buckets it revealed.
{
"app_name": "gallery",
"branch": "main",
"iam_user": "appmecca-dev-tb-…",
"buckets": [{"name": "media", "bucket": "appmecca-dev-a1b2c3...", "env_var": "APPMECCA_BUCKET_MEDIA_NAME"}],
"region": "<storage region>",
"access_key_id": "AKIA...",
"secret_access_key": "...",
"note": "You will not see this secret again. Rotate to reveal a new one."
}
| Status | Cause |
|---|---|
| 409 | The branch's secret has already been revealed — rotate that branch to reveal a fresh one |
| 409 | The stored credential names another branch's IAM user — refused, nothing changed |
| 404 | The branch is not registered, or has no credential yet — deploy that branch first |
| 422 | branch given in both query and body with different values |
Rotate the key¶
Queues a DRIVE_APPLY bucket_rotate_key op carrying the branch (serialises
behind any in-flight deploy). The agent mints a new key for that branch's IAM
user only, writes it to that branch's stored credential, and clears that
branch's reveal latch.
The current key is not revoked
The key in use before the rotation stays active. It is removed only by
the next rotation of that branch. The response says so in
old_key_retirement: "not_revoked".
The agent queues the redeploy
After rotating, the agent queues a DEPLOY of the tag that branch is
already running (not the latest build — a rollback survives a
rotation), so containers re-inject the new key. Its outcome is recorded on
the action's stored result under redeploy:
{"queued": true, "action_id": "…"}{"queued": false, "reason": "nothing_running"}— nothing of container shape is running on that branch, so there is nothing to update{"queued": false, "error": "…", "hint": "…"}
The rotation itself completes in all three cases: the key has already changed, so a redeploy problem must not be reported as a failed rotation.
{
"app_name": "gallery",
"branch": "main",
"iam_user": "appmecca-dev-tb-…",
"buckets": [{"name": "media", "bucket": "appmecca-dev-a1b2c3...", "env_var": "APPMECCA_BUCKET_MEDIA_NAME"}],
"region": "<storage region>",
"action_id": "9c2d...",
"current_key_id": "AKIA...",
"old_key_retirement": "not_revoked",
"message": "Key rotation queued for branch main (IAM user …). …"
}