# Clip API

Browser extensions and native share targets save links to a Sponge account with a **clip token**. A clip
lands in the same save queue as the web library's Save a link, and a connected Sponge device captures it. A clip token can only queue links and
name the account. It cannot read the library, sync, publish or change the account.

Origin: `https://account.sponge.computer`. Every error body is `{ "error": code }`, using the codes listed at the end.

## Clients

| `client` | Default name        | Connects through                                          |
| -------- | ------------------- | --------------------------------------------------------- |
| `chrome` | `Sponge for Chrome` | `chrome.identity.launchWebAuthFlow`                       |
| `safari` | `Sponge for Safari` | `ASWebAuthenticationSession`, callback scheme `sponge`    |
| `ios`    | `Sponge for iPhone` | `ASWebAuthenticationSession`, callback scheme `sponge`    |
| `mac`    | `Sponge for Mac`    | `ASWebAuthenticationSession`, callback scheme `sponge`    |

## Tokens

A token is `spk_` followed by 32 random bytes in base64url, 47 characters in all, matching
`/^spk_[A-Za-z0-9_-]{43}$/`. Sponge keeps only a keyed hash of it, so the token is shown once, when it is
minted. Tokens do not expire. A token stops working in any of these cases:

- It is revoked under Connected apps on the account page.
- The account's Sponge key is replaced.
- The account is disabled.
- It is evicted. An account holds at most **20** tokens; minting a 21st removes the oldest in the same
  write.

A rejected token is always `401 { "error": "invalid_token" }`. When a client gets that answer, it should forget the
token and connect again.

## Connect flow

```
GET /connect?client=chrome&state=<state>&redirect_uri=<redirect>
```

The query must hold exactly these three parameters:

- `state`: 8–256 characters from `[A-Za-z0-9._~-]`. The client generates it and checks it when the redirect comes
  back.
- `redirect_uri`:
  - For `chrome`, the value of `chrome.identity.getRedirectURL(path)`, that is
    `https://<32-letter extension id>.chromiumapp.org/<path>`. The path can use only `[A-Za-z0-9._~/-]`, up to 64
    characters, with no query and no `..`. When `CLIP_EXTENSION_IDS` is set (a comma-separated Pages variable),
    the extension ID must be one of them.
  - For `safari`, `ios` and `mac`, exactly `sponge://connect`.

Any other query is refused with 400 and the page is not shown. The client never chooses an arbitrary redirect.

When the person is signed out, the page signs them in first: with a Sponge key, a passkey or an email code, or by
creating an account. The connect question then appears on the same page. It reads "Connect Sponge for Chrome?" and
offers **Allow** or **Cancel**.

- **Allow** mints a token (`POST /api/clip/tokens`), then the page navigates to
  `<redirect_uri>#token=<spk_…>&state=<state>`.
- **Cancel** navigates to `<redirect_uri>#error=access_denied&state=<state>`.

The result travels in the fragment, so it never reaches a server. The page sends no `Referer` and cannot be framed.

## Cookie routes (the account page)

These routes need the account session cookie. A `POST` or `DELETE` also needs `Origin: https://account.sponge.computer`
and `X-CSRF-Token: <session csrf>`, as other web writes do. They are limited to 20 changes a minute per account.

### `POST /api/clip/tokens`

Body: `{ "client": "chrome" | "safari" | "ios" | "mac", "name"?: string }`. The `name` is up to 64 characters and
may not contain control characters. Without one, the client's default name is used.

Reply: `201 { "token": "spk_…", "id": "<22 chars>" }`.

### `GET /api/clip/tokens`

Reply: `200 { "tokens": [{ "id", "client", "name", "createdAt", "lastUsedAt" }] }`, newest first. Both dates are
ISO 8601. `lastUsedAt` is null until the token's first use, then is updated to the minute, at most once a minute.

### `DELETE /api/clip/tokens/:id`

Reply: `200 { "ok": true }`. An unknown ID, or another account's, gets `404 not_found`.

## Bearer routes (clients)

Send `Authorization: Bearer spk_…`. These routes never accept cookies.

CORS:

- Extension origins get their `Origin` echoed in `Access-Control-Allow-Origin` with `Vary: Origin`. The accepted
  forms are `chrome-extension://<32 letters a–p>` and `safari-web-extension://<id>`.
- A caller that sends no `Origin` (a native app or a share extension) is served without CORS headers.
- Any other origin, web pages included, gets `403 origin_not_allowed`.
- `Access-Control-Allow-Credentials` is never sent.
- `OPTIONS` answers the preflight with 204. It allows `Authorization, Content-Type` and the route's method.

### `POST /api/clip/save`

Body (`Content-Type: application/json`, at most 4 MiB + 64 KiB):

```json
{ "url": "https://…", "title"?: "…", "selection"?: "…", "html"?: "…" }
```

| Field       | Rule                                                        | What happens to it today                                                 |
| ----------- | ----------------------------------------------------------- | ------------------------------------------------------------------------ |
| `url`       | Required. A credential-free http(s) URL; the fragment is dropped (`normalizeSaveUrl`). | Queued.                                         |
| `title`     | String, ≤ 1000 characters.                                  | Validated, then dropped. The device takes the page's own title when it captures. |
| `selection` | String, ≤ 20 000 characters.                                | Kept as the save's **note**. Control and format characters become spaces, it is trimmed, and it is cut to 500 characters, ending in `…` when cut. |
| `html`      | String, ≤ 4 MiB of UTF-8, with no NUL.                      | Validated, then dropped. The device captures the page itself.            |

Any other field gets `400 invalid_request`. The save queue carries only a URL and a note, so `title` and `html` are
accepted now so that clients need not change when the queue can carry them.

Reply: `201 { "request": WebSaveRequest, "existing": false }` when the link was queued, or
`200 { "request": …, "existing": true }` with the open request that already holds the same URL. `request` is the
web's own save request, described in [Ways to save](/docs/saving#how-the-queue-works).

Limits:

- 30 clips a minute per account (`429 rate_limited`, with `Retry-After`).
- The queue's own limit of 500 open links (`409 queue_full`).
- With Sponge Plus billing, the free plan's item limit: `402 { "error": "limit_reached", "upgradeUrl": "https://account.sponge.computer/plus" }`.

### `GET /api/clip/me`

Reply: `200 { "email": string | null }`. The email is null for an account that signs in only with its key or a
passkey. Once Sponge Plus is for sale (billing configured), the reply also carries `plan`: `"plus"` for a paid or
comped account, `"free"` for one under the free plan's item limit. Before that, every account is unlimited and
`plan` is absent.

## Errors

| Status | `error`              | When                                                        |
| ------ | -------------------- | ----------------------------------------------------------- |
| 400    | `invalid_url`        | `url` is missing, not http(s), or carries credentials       |
| 400    | `invalid_request`    | Unknown fields, wrong types or bad JSON                     |
| 401    | `invalid_token`      | Missing, malformed, revoked, evicted or replaced token      |
| 401    | `sign_in_required`   | Cookie routes, when signed out                              |
| 402    | `limit_reached`      | The free plan is full; see `upgradeUrl`                     |
| 403    | `origin_not_allowed` | An origin these routes do not serve                         |
| 403    | `csrf_not_accepted`  | Cookie routes, with a missing or stale CSRF token           |
| 404    | `not_found`          |                                                             |
| 405    | `method_not_allowed` |                                                             |
| 409    | `queue_full`         | 500 links already wait for a device                         |
| 413    | `request_too_large`  | The body or `html` is over its limit                        |
| 415    | `json_required`      |                                                             |
| 429    | `rate_limited`       | With `Retry-After`                                          |
| 503    | `unavailable`        | With `Retry-After`; try again                               |

## How a clip runs

1. Pages hashes the bearer token and looks it up in D1, together with its enabled account. It then applies the
   rate limits.
2. Pages forwards `{ url, selection }` to the account's Durable Object. The forward carries the token and its
   keyed hash, because the object holds no `AUTH_SECRET`.
3. The object checks that the token's directory row belongs to the account it holds. It then runs
   `createSaveRequest` in a batch guarded on the account being enabled.

An account Pages still serves itself (`storage='d1'`) runs `createSaveRequest` in Pages instead, in a batch guarded
on the token row.
