Skip to content

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

GET /api/apps/{app}/drives?branch=main

Response: 200 OK

{
  "app_name": "gallery",
  "environment": "dev",
  "branch": "main",
  "drives": [
    {"name": "media", "size_gb": 20, "used_bytes": 4194304}
  ],
  "total": 1
}

List entries

GET /api/apps/{app}/drives/{drive}/entries?path=photos&limit=1000
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

GET /api/apps/{app}/drives/{drive}/tree?path=&max_depth=16&limit=1000

Same shape as entries plus a max_depth query (1–32, default 16). Each entry carries path and size.

Stat

GET /api/apps/{app}/drives/{drive}/stat?path=photos/sunset.jpg

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

POST /api/apps/{app}/drives/{drive}/upload-url
{"filename": "sunset.jpg", "branch": "main", "size_bytes": 512000}

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

POST /api/apps/{app}/drives/{drive}/upload
{"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

GET /api/apps/{app}/drives/{drive}/upload/{action_id}
{"action_id": "3fa1...", "status": "drive_apply_completed", "error": null, "result": {"bytes": 512000}}

Download

POST /api/apps/{app}/drives/{drive}/download-url
{"path": "photos/sunset.jpg", "expires_in": 900}

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.

{"app_name": "gallery", "branch": "main", "drive": "media", "url": "https://...", "size": 512000}

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

POST /api/apps/{app}/drives/{drive}/rm
{"path": "photos/old.jpg", "recursive": false, "password": "•••••"}

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

POST /api/apps/{app}/drives/{drive}/purge
{"password": "•••••", "confirm_drive": "media"}

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

GET /api/apps/{app}/bucket?branch=main

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)

POST /api/apps/{app}/bucket/reveal?branch=main
{"confirm": "gallery"}

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

POST /api/apps/{app}/bucket/rotate?branch=main
{"confirm": "gallery"}

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 …). …"
}