Skip to content

Authentication

All endpoints (except /api/health and /api/docs) require a credential.

Two credential shapes

The Authorization header accepts either of two token shapes, dispatched on the prefix:

Prefix Kind Minted by
am_k1_ API key appmecca api-key create (an administrator operation), or the environment's root key
ses_ Session token mecca login (also what the dashboard's session cookie translates to)
am_k1_a3b7c9d2e5f8g1h4j6k8m0n3p5q7r9s2t4
ses_KZ1q8vN2p...

A session token carries the signed-in user's identity, so it also resolves group membership and roles; an API key resolves to whichever group (if any) it is bound to.

Sending the credential

Include it in the Authorization header:

# With Bearer prefix (recommended)
curl -H "Authorization: Bearer am_k1_abc123..." https://api-url/api/apps

# Without Bearer prefix (also accepted)
curl -H "Authorization: am_k1_abc123..." https://api-url/api/apps

# A session token works the same way
curl -H "Authorization: Bearer ses_abc123..." https://api-url/api/apps

Selecting a group

Most app-scoped routes resolve which group you are acting as, in this order:

  1. ?group=<slug> query parameter
  2. X-AppMecca-Group: <slug> header
  3. The group an API key is bound to
  4. Your personal group

A request for an app you cannot reach returns 404, never 403 — the API never confirms that another tenant's app exists.

Scope Hierarchy

Each key has a scope that determines what it can access:

Scope Level Can Do
admin 3 Everything — create keys, manage infrastructure, all operations
deploy 2 Trigger builds, deploy apps, manage services, set env vars
read 1 Read-only — list apps, view builds, check status

Scopes are hierarchical: an admin key can do everything a deploy key can, and a deploy key can do everything a read key can.

Error Responses

401 Unauthorized

Returned when:

  • No Authorization header is present
  • The key doesn't have the am_k1_ prefix
  • The key hash doesn't match any active record in the database
  • The key has been revoked (revoked_at IS NOT NULL)
{
    "detail": "Invalid or revoked API key"
}

403 Forbidden

Returned when the key is valid but the scope is insufficient:

{
    "detail": "Scope 'deploy' required, you have 'read'"
}

Key Storage

Keys are stored as SHA-256 hashes in the api_keys table; session tokens are hashed into the sessions table. The raw credential is never stored — only the prefix (for identification) and the hash (for validation).

The last_used_at timestamp is updated on every successful authentication.

Root Key Seeding

Each environment's root API key lives in the platform secret store. On first deployment it can be seeded into the api_keys table automatically.

WebSocket Tickets

A browser cannot attach an Authorization header (or read an HttpOnly session cookie) when opening a WebSocket. To authenticate a socket without exposing a credential to browser JavaScript, the API issues short-lived, single-use tickets. Every ticket is stored with a 60-second TTL and consumed atomically on first use, so a captured ticket is dead the moment it is redeemed.

There are two ticket flavours, for two different sockets.

POST /api/auth/deploy-ws-ticket — tenant deploy + preview events

Mints a ticket for your group's live deploy feed. Any authenticated member of the selected group may call it; the group is taken from the membership-checked resolution path (?group=<slug> 404s a non-member) and baked into the ticket at mint time, so a tenant can never subscribe to another group's channel.

{
  "ticket": "<url-safe token>",
  "ws_url": "wss://api.<env>.appmecca.net/api/ws/deploys",
  "expires_in": 60
}
Status Cause
400 No deliberate group selected — the channel is per-group, so pass ?group=<slug> for a group you belong to
401 Anonymous
503 Live updates unavailable; clients fall back to polling

Connect with GET /api/ws/deploys?ticket=<ticket>. The handler re-validates the channel, group and environment bound into the ticket before subscribing.

POST /api/auth/ws-ticket — platform notifications (operators)

Mints a ticket for the platform-wide notifications socket. Platform-operator gated — an ordinary tenant gets 403 before any ticket is minted, mirroring GET /api/notifications.

{
  "ticket": "<url-safe token>",
  "ws_url": "wss://api.<env>.appmecca.net/api/ws/notifications",
  "expires_in": 60
}
  • ws_url is empty when the API has no domain configured (local dev); the client then derives the URL from PUBLIC_API_URL.
  • Returns 503 when the live-update backend is unavailable — clients fall back to REST polling.

GET /api/ws/notifications

Accepts two credentials:

  • ?ticket=<ticket> — consumed atomically, so a captured ticket is dead after first use and expires after 60 s regardless. The bound payload must carry scope == "admin" and match the API's environment, or the socket closes with code 4403.
  • ?api_key=<admin key> — the admin API-key path, used by the monitor SPA and admin tooling.

Missing both → close 4401. Invalid/expired/replayed ticket or non-admin → close 4403.

On connect the server sends {"event": "ready"}, then {"event": "ping"} every 30 seconds as keepalive. The protocol is server → client only.