# HTTP API

The HTTP API of the Gone one-time secret sharing service. The machine-readable OpenAPI specification is [openapi.yaml](https://github.com/haukened/gone/blob/main/docs/openapi.yaml); this page summarizes the same API in prose. The encryption format, link fragment, and client rules are specified in [protocol.md](https://getgone.dev/docs/protocol/index.md). The command-line client is documented in [cli.md](https://getgone.dev/docs/cli/index.md).

## Design Goals
- **Minimal surface**: Three secret operations (create, claim, acknowledge), two sender operations (status, revoke), secret requests (create, check, reply, status, claim, acknowledge, cancel), the operator policy, and health probes.
- **Zero knowledge**: The server only ever sees ciphertext. Encryption, decryption, and the key stay in the browser.
- **Streaming-friendly**: Ciphertext is sent and returned as raw `application/octet-stream`, not wrapped in JSON.
- **Deterministic deletion**: Retrieval is two-phase. `GET` claims the secret and `DELETE` acknowledges receipt, so a secret is deleted only after the recipient has it, or when the claim lease lapses. It is never served to a second party.
- **Explicit limits**: The service enforces `MaxBytes` (default 10 MiB, covering the message and all attachments combined), and TTL must fall within the configured `[MinTTL, MaxTTL]`.
- **Opaque IDs**: 128-bit random, 32 lowercase hex characters; never guessable or sequential.

## Endpoints Overview
| Method | Path | Purpose |
| ------ | ---- | ------- |
| POST | `/api/secret` | Create a secret (returns ID, expiry & manage token) |
| GET | `/api/secret/{id}` | Claim a secret, or re-fetch an existing claim (returns ciphertext + claim token) |
| DELETE | `/api/secret/{id}` | Acknowledge receipt; permanently deletes the secret |
| GET | `/api/secret/{id}/status` | Sender: is the secret still waiting? (`X-Gone-Manage`) |
| POST | `/api/secret/{id}/revoke` | Sender: delete the secret before it is opened (`X-Gone-Manage`) |
| POST | `/api/request` | Requester: open a secret request (`X-Gone-TTL`, no body; returns ID, expiry, manage token & fill token) |
| GET | `/api/request/{id}` | Reply page: can this request still be answered? (`X-Gone-Fill`) |
| PUT | `/api/request/{id}/reply` | Reply page: send the one reply, encrypted to the requester (`X-Gone-Fill`) |
| GET | `/api/request/{id}/status` | Requester: waiting or ready? (`X-Gone-Manage`) |
| GET | `/api/request/{id}/reply` | Requester: claim the reply (`X-Gone-Manage`, plus `X-Gone-Claim` on a retry) |
| DELETE | `/api/request/{id}/reply` | Requester: acknowledge the reply; permanently deletes it (`X-Gone-Claim`) |
| POST | `/api/request/{id}/revoke` | Requester: cancel the request, or delete an unopened reply (`X-Gone-Manage`) |
| GET | `/api/policy` | The operator policy clients honour before encrypting: `{"passphrase_required": bool, "max_files": int, "sign_in_required": bool}`. Never cached and not rate limited. See [Policies](https://getgone.dev/docs/configuration/index.md#policies). |
| GET | `/brand/logo` | The operator's logo, when `GONE_BRAND_LOGO` is set (`image/svg+xml` or `image/png`; otherwise the HTML 404 page). An SVG carries its own sandboxed CSP. GET and HEAD only. See [Branding your instance](https://getgone.dev/docs/branding/index.md). |
| GET | `/brand/accent.css` | The operator's accent colours as CSS custom properties, when `GONE_BRAND_ACCENT` is set (otherwise the HTML 404 page). GET and HEAD only. |
| GET | `/healthz` | Liveness check (`200`, text `ok`). GET and HEAD only. |
| GET | `/i18n/{tag}.json` | The web pages' message catalog for one language (`en`, `es`, `fr`, `de`, `pt-BR`), used to switch language in place. With the current `?v=` hash it may be cached for a year. |
| GET | `/readyz` | Readiness check: the database answers a ping and the blob directory is readable (`200`, text `ready`; otherwise `503` with JSON `{"error":"not ready"}`). GET and HEAD only. The result is cached for 2 seconds, and the blob directory check opens the directory and reads at most one entry. |

Other methods on `/api/secret/{id}` return `405` with `Allow: GET, DELETE`. The same applies to `/status` (`Allow: GET`) and `/revoke` (`Allow: POST`), to `/api/request` (`Allow: POST`), `/api/request/{id}` and its `/status` (`Allow: GET`) and `/revoke` (`Allow: POST`), and to `/api/request/{id}/reply` (`Allow: GET, PUT, DELETE`). `/api/secret` answers `405` with `Allow: POST` and `/api/policy` with `Allow: GET`, and `/brand/logo`, `/brand/accent.css`, `/healthz` and `/readyz` answer anything but GET and HEAD with `405` and `Allow: GET, HEAD`. Unknown `/api/` paths return a JSON `404`. A trailing slash is never part of a route: `/api/secret/{id}/` and `/api/request/{id}/reply/` are `404`, not aliases. Page routes without an ID (`/secret/`, `/manage/`, `/reply/`, `/request/`) return the HTML 404 page.

Pages are rendered in the language chosen by the `gone_lang` cookie, else the best match for `Accept-Language`, else English. Page responses carry `Content-Language` and `Vary: Cookie, Accept-Language`. The API itself is not translated: error bodies are always English, and the pages map status codes to their own messages.

The branding files and static assets are cached for a year when requested with the `?v=` fingerprint the pages use, and for 5 minutes otherwise.

Metrics are **not** served on the public listener. When both `GONE_METRICS_ADDR` and `GONE_METRICS_TOKEN` are set, a separate listener serves `GET /metrics` in the Prometheus text exposition format (version 0.0.4) and `GET /` as a JSON snapshot. Every request must carry `Authorization: Bearer <token>`: a missing or wrong token gets `401` with an empty body on any path, checked before routing. With the token, other paths return `404` and other methods `405`. If either variable is missing, metrics are disabled. See [metrics.md](https://getgone.dev/docs/metrics/index.md).

## Creation Workflow
1. The client builds the plaintext (see [Payload Format](#payload-format)) and encrypts it locally with AES-256-GCM, producing ciphertext, a version, and a nonce.
2. The client sends the ciphertext body with these headers:
   - `X-Gone-Version` (canonical decimal, no sign or leading zeros; `1`, or `2` for a passphrase-protected secret)
   - `X-Gone-Nonce` (exactly 16 unpadded base64url characters encoding the 12-byte GCM IV)
   - `X-Gone-TTL` (Go duration, e.g. `15m`)
   - `Content-Length` (required; chunked uploads are rejected)

   Each `X-Gone-*` header must appear exactly once.
3. The server validates size and TTL, rejects version `1` with `403` when the operator requires a passphrase (`GONE_REQUIRE_PASSPHRASE`), issues an ID, and stores the ciphertext inline in SQLite (≤ `GONE_INLINE_MAX_BYTES`) or as a filesystem blob.
4. Response: `201` with JSON `{ "id": "<32-hex>", "expires_at": "RFC3339", "manage_token": "<43-char base64url>" }`. The server keeps only a SHA-256 hash of the manage token.
5. The client builds the share link `/secret/{id}#v1:<base64url-key>` (or `#v2:` with a passphrase). The key lives only in the URL fragment, which browsers never send to the server. The passphrase never appears in the link.
6. The client also builds the sender's private manage link `/manage/{id}#<manage_token>`. It cannot decrypt anything; it can only check on or revoke the secret.

## Sender Workflow (Status + Revoke)
The manage page reads the token from the URL fragment and sends it only in the `X-Gone-Manage` header.

1. **Status.** `GET /api/secret/{id}/status` returns `200` with `{ "state": "pending", "created_at", "expires_at" }` while the secret is waiting, including while a recipient's claim lease is active. Checking status never claims or deletes anything.
2. **Revoke.** `POST /api/secret/{id}/revoke` deletes the secret and returns `204`. Revoke wins over an active claim: the recipient's acknowledgement then fails. A recipient who already downloaded the ciphertext still holds it, so revoke is only reliable before the link is opened.
3. **No tombstones.** Once a secret is opened, revoked, or expired, nothing about it remains. Status and revoke return the same `404` for all of these cases, for unknown IDs, and for a wrong token. The sender cannot tell "opened" from "expired" or "revoked", and nobody else can learn anything.

## Request Workflow
A secret request runs the other way: the person who needs a secret makes a link, and the person who has it answers. The cryptography is protocol v3 ([protocol.md](https://getgone.dev/docs/protocol/index.md) §4.3, §8.6).

1. **Create.** The requester's browser makes an ECDH P-256 key pair and keeps the private key, non-extractable, in IndexedDB. It sends `POST /api/request` with `X-Gone-TTL` and no body. Response: `201` with `{ "id", "expires_at", "manage_token", "fill_token" }`. The server stores only SHA-256 hashes of both tokens, and never sees the public key or the requester's label.
2. **Reply link.** The browser builds `/reply/{id}#v3:<base64url public key>.<fill_token>` for the requester to send to whoever has the secret.
3. **Check.** The reply page validates the fragment, then sends `GET /api/request/{id}` with `X-Gone-Fill`. `200 { "state": "open", "expires_at" }` while it can be answered, `404` otherwise. The check is read-only.
4. **Reply.** The reply page encrypts the message and files to the public key and sends `PUT /api/request/{id}/reply` with `X-Gone-Version: 3`, `X-Gone-Nonce`, `X-Gone-Fill` and `Content-Length`. Response: `201 { "expires_at" }`. Each request takes exactly one reply; the reply is then kept for the request's TTL again, counted from when it was sent.
5. **Status.** The requester's page polls `GET /api/request/{id}/status` with `X-Gone-Manage`: `{ "state": "waiting" | "ready", "created_at", "expires_at" }`. Status is read-only, so polling costs no writes.
6. **Open.** `GET /api/request/{id}/reply` with `X-Gone-Manage` claims the reply exactly like a secret claim (same headers and lease), and `DELETE /api/request/{id}/reply` with `X-Gone-Claim` deletes it. `GET /api/secret/{id}` never returns a reply, so the ID alone can't burn it.
7. **Cancel.** `POST /api/request/{id}/revoke` with `X-Gone-Manage` deletes an open request or an unopened reply (`204`).
8. **No tombstones.** Expired, cancelled, answered, opened and unknown requests, and wrong tokens, all return the same `404`.

## Consumption Workflow (Claim + Acknowledge)
1. **Claim.** The client sends `GET /api/secret/{id}` without an `X-Gone-Claim` header. If the secret exists, is unexpired, and is unclaimed, the server atomically marks it claimed and generates a random claim token. Only a SHA-256 hash of the token is stored.
2. **Response:** `200` with the ciphertext body and headers:
   - `X-Gone-Version`, `X-Gone-Nonce`
   - `X-Gone-Claim`: opaque claim token (43-char base64url)
   - `X-Gone-Claim-Expires`: RFC 3339 UTC lease deadline (`GONE_CLAIM_LEASE`, default `2m`, max `15m`)
   - `Content-Length`, `Content-Type: application/octet-stream`, `Cache-Control: no-store`
3. **Verify and decrypt.** The client checks that it received exactly `Content-Length` bytes, then decrypts. AES-GCM authentication detects any truncation or tampering.
4. **Retry (optional).** If the download was interrupted, the client repeats `GET /api/secret/{id}` with `X-Gone-Claim: <token>` while the lease is still valid. Only the token holder can re-fetch.
5. **Acknowledge.** The client sends `DELETE /api/secret/{id}` with `X-Gone-Claim: <token>`. The server permanently deletes the record and blob and returns `204 No Content`.
6. **Lease expiry.** If no acknowledgement arrives before the lease ends, the janitor deletes the secret (counted by `secrets_claims_expired_total`). It is never re-served to anyone else.
7. Any later request, whether a fresh claim of a claimed or deleted secret or a wrong token, returns `404`.

## Payload Format
The server treats the body as opaque ciphertext. Inside the encryption:
- **Text only:** the plaintext is the raw UTF-8 message.
- **With attachments (envelope v2):** `"GONE2\0\0\0"` magic (8 bytes), a big-endian `u32` header length, a JSON header `{ "v": 2, "msg": <message byte length>, "files": [{ "name", "type", "size" }] }`, then the message bytes, then each file's bytes in order.

File names, types, and sizes are therefore encrypted along with the contents. Decoders sanitize file names and map MIME types onto an allowlist (see [protocol.md §6](https://getgone.dev/docs/protocol/index.md#6-sanitization)). The web client allows up to 10 files of any type, or fewer when the operator sets `GONE_MAX_FILES`. The server cannot check that limit, since the file count is encrypted; see [Policies](https://getgone.dev/docs/configuration/index.md#policies). The message and all files share the `MaxBytes` limit, which includes the 16-byte GCM tag. Encryption uses AES-256-GCM with AAD `gone:v1`. With a passphrase (protocol v2), the AES key also depends on the passphrase through PBKDF2-SHA-256 and HKDF, and the body starts with a 21-byte authenticated header that the server stores without parsing (see [protocol.md §4.2](https://getgone.dev/docs/protocol/index.md#42-protocol-version-2-passphrase)).

## Error Mapping
All errors are JSON `{ "error": "<message>" }`.

| Condition | Status | Body `error` |
| --------- | ------ | ------------ |
| `X-Correlation-ID` present but not a UUID (any route) | 400 | `invalid correlation id` |
| Invalid ID format | 400 | `invalid id` |
| Malformed or repeated claim token (DELETE without/with bad `X-Gone-Claim`, or bad token on GET retry; the header must appear at most once) | 400 | `invalid claim` |
| Missing, repeated, or malformed `X-Gone-Manage` on status/revoke | 400 | `invalid manage token` |
| Missing, repeated, or malformed `X-Gone-Fill` on a request check or reply | 400 | `invalid fill token` |
| Missing, empty, or repeated `X-Gone-Version` / `X-Gone-Nonce` / `X-Gone-TTL` | 400 | `missing required headers` |
| Non-canonical or unsupported version (on a reply, anything but `3`) | 400 | `invalid version` |
| Nonce not 16 base64url characters (12 bytes) | 400 | `invalid nonce` |
| Unparseable TTL | 400 | `invalid ttl` |
| TTL outside `[MinTTL, MaxTTL]` | 400 | `ttl invalid` |
| Unparseable `Content-Length` | 400 | `invalid content length` |
| Body ends before `Content-Length` bytes arrived (create or reply) | 400 | `short body` |
| Any other malformed create request | 400 | `bad request` |
| Create (`POST /api/secret` or `POST /api/request`) without proof of sign-in when the operator set `GONE_AUTH` (sends `WWW-Authenticate: Bearer realm="gone"`). See [sign-in.md](https://getgone.dev/docs/sign-in/index.md). | 401 | `sign in required` |
| Version `1` on create when the operator requires a passphrase (`GONE_REQUIRE_PASSPHRASE`; replies are exempt) | 403 | `passphrase required` |
| Not found / expired / claimed by someone else / wrong token / lease lapsed / opened or revoked (status, revoke) | 404 | `not found` |
| Method not allowed | 405 | `method not allowed` |
| Missing `Content-Length` on create or on a reply (`PUT /api/request/{id}/reply`) | 411 | `content length required` |
| Size > `MaxBytes` (create or reply) | 413 | `size exceeded` |
| Per-client rate limit exceeded (sets `Retry-After`) | 429 | `rate limited` |
| Internal failure | 500 | `internal` |
| Storage overloaded: no write turn within 5s (sets `Retry-After`; nothing changed, safe to retry) | 503 | `busy` |

If the client disconnects or times out while the server is working, the request is recorded as `499` and logged at debug level only; no client sees it, and it is never an error-level log line.

Secret replies are a different resource from secrets: `DELETE /api/secret/{id}`, `/status` and `/revoke` return `404` for a request's reply, so a claim or manage token from the request side can only be used on `/api/request/{id}/...`.

## Rate Limiting
Each client address has two token buckets: one for creating secrets (`POST /api/secret`, `GONE_RATE_CREATE`) and one for reading them (`GET` and `DELETE /api/secret/{id}`, plus `/status` and `/revoke`, `GONE_RATE_READ`). Requests follow the same split: `POST /api/request` and `PUT /api/request/{id}/reply` (which uploads a body) use the create bucket, and every other `/api/request/` call uses the read bucket. Both buckets hold up to `GONE_RATE_BURST` tokens and refill steadily at the configured rate. Health probes, pages, and static assets are not limited. `/readyz` is cheap by construction (cached for 2 seconds, one directory read); a language header is parsed only up to its first 16 entries.

- Over budget: `429` with `Retry-After: <seconds>` and `{ "error": "rate limited" }`. The request body is never read.
- Clients are keyed by IPv4 `/32` or IPv6 `/64`.
- The client address is the TCP peer. `X-Forwarded-For` is honored only when the peer is in `GONE_TRUSTED_PROXIES`; it is then walked right to left, skipping trusted hops, and the first untrusted address is used.
- Counters are per process. With several replicas, the effective budget is multiplied by the replica count.
- Each IPv6 `/48` (or IPv4 `/24`) may hold at most 256 tracked clients, and the table holds at most 100,000. Beyond either cap, clients share overflow buckets grouped by that parent network, so one large allocation cannot crowd out other users.
- Rejections are counted in the `gone_rate_limited_create_total` and `gone_rate_limited_read_total` metrics (`rate_limited_create_total` and `rate_limited_read_total` in the JSON snapshot).
- Request activity is counted in `requests_created_total`, `requests_filled_total`, `requests_opened_total`, `requests_cancelled_total` and `requests_expired_total`.
- Client addresses are never logged; only the correlation ID and scope are.

## Security Headers
Every response carries:
- `X-Content-Type-Options: nosniff`
- `Referrer-Policy: no-referrer`
- `X-Robots-Tag: noindex`: search engines don't list any page
- `Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`
- `X-Correlation-ID`: the request's value if it parses as a UUID (returned in canonical form), or a generated UUID v4. A value that isn't a UUID is rejected with `400` on every route.

Responses default to `Cache-Control: no-store` and `Pragma: no-cache`. Static assets under `/static/` override it: the pages link them with a content hash (`?v=<hash>`), and those URLs get `Cache-Control: public, max-age=31536000, immutable`; a bare URL gets `public, max-age=300` so an upgrade is picked up quickly. Message catalogs under `/i18n/` follow the same rule, keyed on the catalog's own hash. The `Pragma: no-cache` header still appears on both.

## Future Extensions (Non-Breaking)
- Optional JSON POST mode with metadata wrapper.

## Non-Goals
- Secret re-use or updates.
- Listing secrets.
- Multi-consume semantics.
- Server-side encryption (handled entirely client-side).

The full list of features Gone has decided against, with the reasons and when to revisit them, is in [rejected.md](https://getgone.dev/docs/rejected/index.md).

## Implementation Notes
- The inline vs external threshold (`GONE_INLINE_MAX_BYTES`, default 8 KiB) is not exposed via the API.
- `X-Gone-Nonce` must decode (base64url, unpadded) to exactly 12 bytes; anything else is `400 invalid nonce`.
- The duration regex in the spec mirrors a subset of Go's `time.ParseDuration`.

Refer to `openapi.yaml` for the machine-readable schema.
