# sharefiles.eu REST API

The sharefiles.eu REST API lets you upload, download, and manage files
programmatically. Files are stored as binary blobs and always served as raw
bytes — the API never returns base64 or data URLs.

> There is also an S3-compatible API at `/s3/` (AWS4-HMAC-SHA256, S3 XML). This
> document covers the native REST API only.

## Base URL

```
https://sharefiles.eu
```

All paths below are relative to this base URL. When self-hosting, substitute
your own origin.

## Authentication

Most programmatic endpoints authenticate with an **API key** passed as a Bearer
token:

```
Authorization: Bearer sk_your_api_key_here
```

- API keys always start with `sk_`.
- Create and revoke keys in the dashboard under **Settings → API Keys**.
- The key is shown only once at creation time — store it securely.
- File download endpoints (`/api/files/...`, `/api/download/...`) accept **either**
  an API key **or** a logged-in browser session.
- Upload via the browser endpoint (`/api/upload`) requires a session; for
  programmatic uploads use `/api/cli/upload` with an API key.

### Permissions

Each key carries one or more permission scopes:

| Scope            | Grants                                             |
| ---------------- | -------------------------------------------------- |
| `full`           | Everything                                         |
| `read`           | Read/download any file                             |
| `write`          | Upload/create in any directory                     |
| `read:<bucket>`  | Read only within the named bucket (directory)      |
| `write:<bucket>` | Write only within the named bucket (directory)     |
| `replicate`      | Node-to-node replication endpoints                 |

A bucket is the same thing as a top-level directory.

### Folder-scoped keys

When you create an API key (or an S3 credential) you may optionally pick a **root
folder**. If set, that key can **only read and write inside that folder and its
subfolders** — never anything above it or in sibling folders. Leave it unset for
unrestricted (whole-account) access.

This is enforced everywhere a key is accepted:

- Upload/`send` into an out-of-scope directory is rejected; `POST /api/cli/upload`'s
  `dir` path resolves **relative to the scoped folder** (an empty `dir` uploads into
  the folder itself), so a scoped key can never create or reach a top-level/sibling
  folder.
- Download (`/api/files/{id}`, `/api/download/{dirId}`), version listing, share-link
  creation, the directory listing, and the sync manifest/subscriptions all hide or
  refuse anything outside the folder (out-of-scope resources read as `404`).
- **S3 API:** a scoped key may only use the bucket that **is** its scoped folder
  (and its sub-paths); it cannot list, create, or touch other buckets. For this
  reason, scope S3 credentials to a **top-level folder** (= a bucket).

### Rate limiting

Each API key is limited to **1000 requests per hour**. Exceeding the limit
returns `429 Too Many Requests`:

```json
{ "error": "Rate limit exceeded (1000 requests per hour)" }
```

### Errors

Authentication failures return `401`:

```json
{ "error": "Invalid or missing API key" }
```

Errors are returned as JSON with an `error` field. Binary endpoints return the
appropriate HTTP status with no body on failure (`401`, `404`).

---

## Files

### Upload files

```
POST /api/cli/upload
Authorization: Bearer sk_...
Content-Type: multipart/form-data
```

Upload one or more files in a single request. This is the endpoint the
`sharefiles` CLI uses.

**Form fields**

| Field              | Type   | Required | Description                                                              |
| ------------------ | ------ | -------- | ------------------------------------------------------------------------ |
| `files`            | File[] | yes      | One or more files (repeat the field for multiple files)                  |
| `dir`              | string | no       | Target directory path (default `uploads`). Nest with `/` (e.g. `a/b/c`); created if absent |
| `expiresInMinutes` | number | no       | Expire the returned `directUrl` after N minutes. **Omit = never expires**|
| `maxRequests`      | number | no       | Cap the `directUrl` to N downloads. **Omit = unlimited**                 |
| `singleUse`        | bool   | no       | Shortcut for `maxRequests=1`                                             |
| `password`         | string | no       | Require this password on the `directUrl` (supplied as `?password=`)      |
| `publicKey`        | string | no       | Encrypt every file with this RSA public key (PEM/SPKI) before storage    |
| `generateKeypair`  | bool   | no       | Server mints an RSA-4096 keypair, encrypts with it, returns the private key **once** |
| `preserve`         | bool   | no       | Set the **no-delete** lock on every uploaded file (see *No-delete protection*) |

The share-link options (`expiresInMinutes`, `maxRequests`/`singleUse`, `password`)
control the public `directUrl` and apply to **every** file in the request — the same
controls the web UI offers. They're all optional: **by default the `directUrl` is
permanent and unlimited.** Set whichever you want; it's up to you.

#### Encryption (optional)

Files can be encrypted **before storage** using hybrid encryption (a random
AES-256-GCM key per file, wrapped with your RSA key via RSA-OAEP). Pick **one** of:

- **Provide a public key** — send `publicKey` (RSA PEM, SPKI). The server encrypts
  with it and never sees the private key. You decrypt downloads yourself.
- **Let the server generate a keypair** — send `generateKeypair=true`. The server
  creates an RSA-4096 pair, encrypts with the public half, stores the public key, and
  returns the **private key once** in the response (`encryption.privateKey`). It is
  **never stored** — save it now or the files become permanently undecryptable.

If neither is set, files are stored unencrypted — **unless the target directory has
encryption enabled**, in which case the directory's key is used automatically.

Encrypted files download as raw ciphertext (`application/octet-stream`); decrypt
client-side with the matching private key. The encrypted blob layout is
`[4-byte length][RSA-wrapped AES key][12-byte IV][ciphertext][16-byte GCM tag]`.

`dir` may be a **nested path** like `a/b/c` — each `/`-separated segment must match
`^[a-zA-Z0-9._-]+$`, and any missing folder along the way is created. A plain name
(e.g. `uploads`) is just a single top-level folder. See *Folders & directories* below.

**Response** `200 OK`

```json
{
  "files": [
    {
      "name": "screenshot.png",
      "id": "f_8h2k9d…",
      "versionGroupId": "f_8h2k9d…",
      "url": "https://sharefiles.eu/api/files/f_8h2k9d…",
      "directUrl": "https://sharefiles.eu/direct/abc123xyz",
      "expiresAt": null,
      "maxRequests": null,
      "passwordProtected": false,
      "isEncrypted": false,
      "encryptionKeyId": null,
      "size": 145238
    }
  ]
}
```

When encryption is used, each file gains `isEncrypted: true` and an
`encryptionKeyId`, and a top-level `encryption` object is returned. With
`generateKeypair=true` it also contains the one-time `privateKey`:

```json
{
  "files": [ { "...": "...", "isEncrypted": true, "encryptionKeyId": "k_…" } ],
  "encryption": {
    "keyId": "k_…",
    "algorithm": "RSA+AES-256-GCM",
    "generated": true,
    "privateKey": "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----\n"
  }
}
```

> **Save `encryption.privateKey` immediately.** It is returned only in this one
> response and is never stored — without it, the uploaded bytes cannot be decrypted.

Each uploaded file returns two ways to fetch it back:

| Field            | What it is                                 | Auth required     | Expires?                                          |
| ---------------- | ------------------------------------------ | ----------------- | ------------------------------------------------- |
| `id`             | The **permanent** file id                  | —                 | Never                                             |
| `url`            | `GET /api/files/{id}` (absolute)           | **Yes** (API key) | Never — canonical authenticated link              |
| `directUrl`      | Public `/direct/{token}` share link        | No (unless password set) | **However you configured it** (default: never) |
| `versionGroupId` | Stable id for the file's version history   | —                 | Never                                             |
| `expiresAt`      | When `directUrl` expires (`null` = never)  | —                 | —                                                 |
| `maxRequests`    | Download cap on `directUrl` (`null` = unlimited) | —           | —                                                 |
| `passwordProtected` | Whether `directUrl` needs a password    | —                 | —                                                 |

`directUrl` is the public link you hand out — its lifetime, download cap, and
password are exactly what you passed in the upload (and `null`/absent means
permanent/unlimited/no-password). `url`/`id` are the private, API-key download
handle that always works. The token in `directUrl` is **not** the file id, so don't
parse an id out of it. Files themselves never expire — only a `directUrl` you chose
to time-limit does.

`id` and `versionGroupId` are equal on first upload. Uploading a file with the same
name into the same directory later creates a **new version** with a new `id` but the
**same `versionGroupId`** (see *List file versions*).

**Example**

```bash
curl -X POST https://sharefiles.eu/api/cli/upload \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -F "dir=my-project" \
  -F "files=@report.pdf" \
  -F "files=@diagram.png"
```

### Upload a single file (versioning, client encryption)

```
POST /api/upload
Authorization: Bearer sk_...   (or a browser session)
Content-Type: multipart/form-data
```

Uploads **one** file and returns its permanent id plus version and replication
details. Use this (rather than `/api/cli/upload`) when you want the version number
back, are pushing a new version of an existing file, or are uploading
already-encrypted bytes into an encrypted directory.

**Form fields**

| Field             | Type    | Required | Description                                                        |
| ----------------- | ------- | -------- | ------------------------------------------------------------------ |
| `file`            | File    | yes      | The single file to upload                                          |
| `directoryId`     | string  | no       | Target directory **id** (omit for the account root)                |
| `clientEncrypted` | boolean | no       | `true` if the bytes are already client-side encrypted              |
| `encryptionKeyId` | string  | no       | The key id used, when `clientEncrypted=true`                       |

Uploading the same name into the same directory supersedes the previous current
version (a new `id`, same `versionGroupId`, `version` incremented).

**Response** `200 OK`

```json
{
  "success": true,
  "id": "f_8h2k9d…",
  "version": 1,
  "versionGroupId": "f_8h2k9d…",
  "url": "/api/files/f_8h2k9d…",
  "replication": { "required": 2, "confirmed": 1, "satisfied": true }
}
```

`replication` is `null` when no replication policy applies. Fetch the bytes with
`GET /api/files/{id}` — permanent, same as above.

### Download a file

```
GET /api/files/{fileId}
Authorization: Bearer sk_...   (or a browser session)
```

Returns the raw file bytes with the correct `Content-Type`.

**Query parameters**

| Param      | Description                                                    |
| ---------- | ------------------------------------------------------------- |
| `download` | `1` forces a download (attachment); `0`/omitted serves inline |

**Response headers**

| Header               | Description                                  |
| -------------------- | -------------------------------------------- |
| `Content-Type`       | The file's MIME type                         |
| `Content-Length`     | File size in bytes                           |
| `Content-Disposition`| `inline` or `attachment`                     |
| `X-Replica-Count`    | Confirmed remote replicas                    |
| `X-Required-Replicas`| Replicas required by policy                  |
| `X-Replica-Satisfied`| `true` when replication policy is satisfied  |

Encrypted files are served as-is (`application/octet-stream`); decrypt
client-side with your private key.

Optional, for browser UIs: with `download=1`, a `dlt=<token>` parameter
(1–64 chars of `A-Z a-z 0-9 _ -`) makes the response also set the cookie
`sf_dl_<token>=1` (`Path=/; Max-Age=60; SameSite=Lax`). It arrives with the
response headers, so a page polling `document.cookie` knows the browser's
download has started. Invalid tokens are ignored; without `dlt` nothing changes.
`GET /api/download/{dirId}` (folder `.tgz`) accepts it too.

**Example**

```bash
curl -L -H "Authorization: Bearer sk_your_api_key_here" \
  "https://sharefiles.eu/api/files/FILE_ID?download=1" -o file.bin
```

### Path-based access (list, read, exists, delete)

`GET /api/files/{id}` addresses a file by its opaque id. For tools that think in
**paths** (backup targets, sync clients, anything that wants a plain key→bytes
store) the following endpoints address files by their **directory path**, with no
need to know the id. Together with path-based upload (`POST /api/cli/upload` with
a `dir` path) they form a complete **read / write / exists / list / delete** store.

All of them accept an **API key** (or a logged-in session) and respect a key's
folder scope: a `path`/`dir` is resolved **relative to the key's scoped folder**
(an empty value is that folder itself), and anything out of scope or non-existent
reads as `404`. Each `/`-separated segment must match `^[a-zA-Z0-9._-]+$`.

> **Write / overwrite semantics.** Writing the same name into the same path
> **supersedes** the current file as a new version (one current file per path) —
> this holds for `POST /api/cli/upload` (by `dir` path), `POST /api/upload` (by
> `directoryId`), and the S3 `PutObject` alike. Reads and listings are
> **newest-wins** and return that current version; older versions stay available
> via *List file versions*. **Delete clears the whole path** — every version (and
> any legacy duplicate) — so a later read returns `404`.

#### Write a file by path (streaming, no size limit)

```
PUT /api/files/by-path?path=<path>
Authorization: Bearer sk_...
Content-Type: <the file's type>      # optional; inferred from the extension otherwise
<raw file bytes as the request body>
```

Uploads the **raw request body** as the file at `<path>`. The body is **streamed
straight to storage** (hashed and sized on the fly) — it is never buffered whole
in memory, so this is the way to push **multi-GB** files without RAM blowups and
with no `MAX_FILE_SIZE` cap. Missing folders in the path are created; writing the
same path supersedes the current file as a **new version**.

**Response** `200 OK`

```json
{ "success": true, "id": "f_…", "version": 2, "versionGroupId": "f_…",
  "size": 314572800, "sha256": "…", "url": "/api/files/f_…" }
```

```bash
curl -X PUT --data-binary @backup.tar.zst \
  -H "Authorization: Bearer sk_your_api_key_here" \
  "https://sharefiles.eu/api/files/by-path?path=backups/2026-06-17/backup.tar.zst"
```

> The multipart upload endpoints (`POST /api/cli/upload`, `POST /api/upload`)
> still buffer in memory; for very large files prefer this streaming `PUT`.

#### Conditional writes (compare-and-swap)

Every file's **version number is its ETag**. `GET`/`HEAD …/by-path` (and
`GET /api/files/{id}`) return it as `ETag: "<version>"`, and `PUT`/`DELETE
…/by-path` accept the standard HTTP precondition headers so you can do
**optimistic concurrency** (compare-and-swap) — the primitive for building
leases, locks, leader election, atomic counters and "create once" on top of the
store. **A write with no precondition header keeps the default newest-wins
behaviour**, so this is purely additive.

| Precondition                       | Query alias                    | Write succeeds only if…                    |
|------------------------------------|--------------------------------|--------------------------------------------|
| `If-Match: "42"`                   | `?ifMatch=42` / `?ifVersion=42`| the current version is exactly **42**      |
| `If-Match: *`                      | `?ifMatch=*`                   | a file **exists** at the path              |
| `If-None-Match: *`                 | `?ifAbsent=1`                  | **no** file exists at the path (create-once)|

When the precondition is not met the write is rejected with **`412 Precondition
Failed`** and the live version (as JSON and an `ETag` header) so you can retry:

```json
{ "error": "version_mismatch", "expectedVersion": 42, "currentVersion": 43 }
{ "error": "already_exists",   "currentVersion": 7 }
{ "error": "not_found",        "expectedVersion": 42 }
```

The check is atomic: SQLite serializes writes, so of any number of racing
`If-Match: "v"` writers **exactly one wins** the swap (the rest get `412`), and of
racing `If-None-Match: *` writers exactly one creates the file. A successful write
returns the new version in both the body and the `ETag` header.

```bash
# compare-and-set: replace the lease only if we still hold version 42
curl -X PUT --data-binary @lease.json \
  -H "Authorization: Bearer sk_..." -H 'If-Match: "42"' \
  "https://sharefiles.eu/api/files/by-path?path=leases/job-123"
#   200 → { "success": true, "version": 43, ... }   (ETag: "43")
#   412 → { "error": "version_mismatch", "currentVersion": 57 }

# create-once: acquire the lease only if nobody holds it (leader election)
curl -X PUT --data-binary @lease.json \
  -H "Authorization: Bearer sk_..." -H 'If-None-Match: *' \
  "https://sharefiles.eu/api/files/by-path?path=leases/job-123"

# release safely: delete only if we still hold version 43
curl -X DELETE -H "Authorization: Bearer sk_..." -H 'If-Match: "43"' \
  "https://sharefiles.eu/api/files/by-path?path=leases/job-123"
```

> **Read the version cheaply** with `HEAD …/by-path` — the `ETag` response header
> is the current version, no body transferred. `DELETE` with `If-Match: "v"` only
> clears the path when the current version is still `v` (`deleteIfVersion`); an
> unconditional `DELETE` still clears the whole path as before.

#### Placeholder files ("touch") — a file that exists before its content

```
PUT /api/files/by-path?path=<path>&placeholder=1     # no body needed
```

Creates an **empty file** at `<path>`: it shows up in listings, resolves by path,
and — the point — supports **share links and notification rules like any other
file**, but has **no content and no versions yet** (`version` is `0`). Missing
folders in the path are created. Create-once: `409` if a file already lives at
the path.

**Response** `200 OK` (`ETag: "0"`)

```json
{ "success": true, "id": "f_…", "version": 0, "versionGroupId": "f_…",
  "size": 0, "sha256": null, "placeholder": true, "url": "/api/files/f_…" }
```

The intended workflow — wire everything up first, receive content later:

```bash
# 1. touch the file (nothing uploaded yet)
curl -X PUT -H "Authorization: Bearer sk_..." \
  "https://sharefiles.eu/api/files/by-path?path=drops/report.json&placeholder=1"

# 2. mint links on it: a WRITE link for whoever will produce the file,
#    a READ link for whoever consumes it
curl -X POST https://sharefiles.eu/api/share-links \
  -H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \
  -d '{"fileId": "f_…", "writable": true}'
curl -X POST https://sharefiles.eu/api/share-links \
  -H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \
  -d '{"fileId": "f_…"}'

# 3. (optional) webhook the moment content arrives
curl -X POST https://sharefiles.eu/api/notifications/rules \
  -H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \
  -d '{"resourceType": "file", "resourceId": "f_…", "events": ["update"],
       "channel": "webhook", "webhookUrl": "https://example.com/hook"}'

# 4. the producer uploads through the write link (or you PUT to the path):
#    that upload becomes v1, the webhook fires, and the read link starts
#    serving it immediately
curl -X POST --data-binary @report.json https://sharefiles.eu/direct/<writeToken>
```

Semantics while the file is still empty:

- **Reads answer `404`** (there are no bytes) tagged **`X-Placeholder: 1`** with
  **`ETag: "0"`** — so a poller can tell *"exists but empty"* from *"no such
  file"*. This applies to `GET`/`HEAD …/by-path`, `GET /api/files/{id}`, and
  share-link `?raw=1`/`?dl=1` fetches. A browser opening a share link gets a
  friendly *"nothing uploaded yet"* page instead (with the upload control on a
  writable link).
- Share-link **views/downloads of an empty placeholder are never counted**
  against `maxViews`/`maxRequests` — polling an empty link can't exhaust it
  before content exists.
- A link minted on the placeholder — even the default `pinned` mode — **follows
  the file's latest version** once content arrives (a pin to "nothing" would be
  useless), so links handed out up front just start working.
- `If-Match: "0"` on a `PUT` means **"write only while the file is still
  empty"** — the create-once race for *filling* a placeholder. (`If-None-Match:
  *` fails on a placeholder: the file *exists*.)
- The first real upload (any path: raw `PUT`, multipart, writable link, S3
  gateway) supersedes the placeholder as **version 1** and fires the usual
  `update` notifications.
- Listings mark these entries with `"placeholder": true` (and `size: 0`,
  `sha256/fileHash: null`).

CLI shortcut: `sharefiles touch drops/report.json`, then
`sharefiles share <fileId> --writable`.

#### List a directory

```
GET /api/files?dir=<path>&prefix=<optional>&q=&sort=&order=&page=&pageSize=
Authorization: Bearer sk_...
```

Lists the **current** version of every file directly in `<path>`, plus its
immediate subfolders. Omit `dir` (or pass empty) for the account root — or, with a
folder-scoped key, the key's folder. `prefix` filters files by name prefix.

The listing is **paged**: subfolders first, then files, at most `pageSize` entries
(folders + files) per response. A call without paging parameters gets the first
1000 entries — check `hasMore` (or `totalFiles` / `totalDirectories`) and fetch
`page=2, 3, …` for big folders.

| Param      | Default | Meaning                                                                 |
| ---------- | ------- | ----------------------------------------------------------------------- |
| `page`     | `1`     | 1-based page number (past the end → the last page)                      |
| `pageSize` | `1000`  | entries per page, max `5000`                                            |
| `q`        | —       | case-insensitive substring of the name (files and folders); `%`/`_` are literal |
| `sort`     | `name`  | `name` (case-insensitive) \| `date` (last upload) \| `size`; folders sort by name (or date) |
| `order`    | `asc`   | `asc` \| `desc` (an explicit `sort` defaults to name A→Z, date/size newest/biggest first) |

Order is stable (ties broken by id), so consecutive pages never overlap or skip.
Keep requesting `page+1` while `hasMore` is true:

```bash
page=1
while :; do
  r=$(curl -s -H "Authorization: Bearer $KEY" "https://sharefiles.eu/api/files?dir=photos&page=$page&pageSize=1000")
  echo "$r" | jq -r '.files[].path'
  [ "$(echo "$r" | jq .hasMore)" = true ] || break
  page=$((page+1))
done
```

**Response** `200 OK`

```json
{
  "dir": "project/build",
  "page": 1,
  "pageSize": 1000,
  "totalFiles": 1,
  "totalDirectories": 1,
  "hasMore": false,
  "files": [
    {
      "id": "f_8h2k9d…",
      "name": "app.tar.gz",
      "path": "project/build/app.tar.gz",
      "size": 10485760,
      "mimeType": "application/gzip",
      "isEncrypted": false,
      "fileHash": "sha256…",
      "version": 3,
      "versionGroupId": "f_1a2b…",
      "url": "/api/files/f_8h2k9d…",
      "updatedAt": "2026-06-07T00:00:00.000Z"
    }
  ],
  "directories": [{ "name": "logs", "path": "project/build/logs" }]
}
```

#### Read / check a file by path

```
GET  /api/files/by-path?path=<path>      # raw bytes (add &download=1 to force attachment)
HEAD /api/files/by-path?path=<path>      # 200 + the same headers, no body (existence check)
Authorization: Bearer sk_...
```

`GET` returns the current version's bytes with the same headers as
`GET /api/files/{id}` (`Content-Type`, `Content-Length`, `X-Replica-*`,
`X-Virus-Scan`, …) plus an `ETag: "<version>"` (the compare-and-swap validator —
see *Conditional writes*). `HEAD` is `200` with those headers (including `ETag`)
when the file exists, `404` when it doesn't — a cheap `exists()` and the cheapest
way to read the current version.

```bash
curl -fsSL -H "Authorization: Bearer sk_..." \
  "https://sharefiles.eu/api/files/by-path?path=project/build/app.tar.gz" -o app.tar.gz
```

#### Delete a file

```
DELETE /api/files/by-path?path=<path>    # by path
DELETE /api/files/{fileId}               # by id
Authorization: Bearer sk_...
```

`DELETE …/by-path` is **recursive on the path**:

- If `path` points at a **file**, it clears the whole path — every version and
  every same-name copy — so the path is empty afterwards (a later read `404`s),
  returning `{ "success": true, "deleted": N }`.
- If `path` points at a **directory**, the entire subtree is deleted (all files,
  all versions, and every subfolder), returning
  `{ "success": true, "deletedFiles": N, "deletedDirs": M }`. (The account root /
  a scoped key's own folder cannot be deleted this way → `400`.)

`DELETE /api/files/{fileId}` removes one specific file, promoting the previous
version of that file to current if one exists, and returns
`{ "success": true, "id": "f_…" }`. A file or directory under a **no-delete** lock
is only tombstoned in all cases, with its bytes kept for admin revival (see
*No-delete protection*).

Add an `If-Match: "<version>"` header (or `?ifMatch=<version>`) to make the
by-path delete **conditional**: it clears the path only when the current version
is still that version, otherwise `412 Precondition Failed` with the live version
(`deleteIfVersion` — safe lease release). `If-Match: *` requires the path to point
at an existing file. See *Conditional writes (compare-and-swap)* above.

```bash
curl -X DELETE -H "Authorization: Bearer sk_..." \
  "https://sharefiles.eu/api/files/by-path?path=project/build/app.tar.gz"
```

> **Prefer the S3 API for bulk/standard tooling.** If you already use S3 clients
> (`aws-cli`, `rclone`, any AWS SDK), point them at the S3-compatible endpoint at
> `/s3/` instead — top-level folders are buckets and it implements
> `ListObjectsV2`, `GetObject`, `HeadObject`, `PutObject` and `DeleteObject`.

### List file versions

```
GET /api/files/{fileId}/versions
Authorization: Bearer sk_...   (or a browser session)
```

Every upload to the same path creates a new version. Pass any version's ID to
retrieve the full history (newest first).

**Response** `200 OK`

```json
{
  "fileId": "FILE_ID",
  "versionGroupId": "GROUP_ID",
  "versions": [
    {
      "id": "VERSION_ID",
      "version": 2,
      "originalName": "report.pdf",
      "mimeType": "application/pdf",
      "fileSize": 204812,
      "isEncrypted": false,
      "isCurrent": true,
      "fileHash": "sha256...",
      "url": "/api/files/VERSION_ID",
      "createdAt": "2026-06-07T00:00:00.000Z"
    }
  ]
}
```

### Download a directory as an archive

```
GET /api/download/{dirId}
Authorization: Bearer sk_...   (or a browser session)
```

Downloads a directory (and its subdirectories) as a gzipped tar archive. Use
the literal `root` as `dirId` to archive files at the account root.

**Response** `200 OK` — `application/x-gzip`, `Content-Disposition: attachment; filename="<dir>.tgz"`

### Folders & directories

Files always live in a directory, and directories **nest** (a directory has a
`parentId`; a root/top-level directory has `parentId: null`). A top-level directory
is the same thing as an S3 "bucket".

**List your directories** — to discover folder ids and the hierarchy:

```
GET /api/directories
Authorization: Bearer sk_...
```

```json
{ "directories": [
  { "id": "DIR1", "name": "project",  "parentId": null,   "isInbox": false },
  { "id": "DIR2", "name": "invoices", "parentId": "DIR1", "isInbox": false }
] }
```

**Putting a file in a folder:**

| You want…                                       | How                                                                                  |
| ----------------------------------------------- | ------------------------------------------------------------------------------------ |
| A file in a folder **by path** (nested OK)      | `POST /api/cli/upload` with `dir=a/b/c` — missing folders are created automatically  |
| A file in a known folder **by id** (nested OK)  | `POST /api/upload` with `directoryId=<id>` (get the id from `GET /api/directories`)   |
| The account root                                | `POST /api/upload` with no `directoryId`                                             |

So you can create and fill a nested folder tree in one shot — just upload to a
`dir` path. Each `/`-separated segment matches `^[a-zA-Z0-9._-]+$`.

**Note:** there is no dedicated endpoint to create an *empty* folder via the native
REST API — folders come into existence when you upload into them (or you can create
them in the dashboard / via the S3 API, whose object keys may also contain `/`).

### Rename & edit metadata

Renaming is a **metadata-only** change: stored bytes are keyed by file id, so a
rename never moves data, and existing share links, direct links and version
history keep working. For a file the change applies to its **whole version
group**, so history stays together and the file remains one entry at its path.

```
PATCH /api/files/{fileId}          { "name": "report-final.pdf" }
PATCH /api/directories/{dirId}     { "name": "archive-2026" }
Authorization: Bearer sk_...
Content-Type: application/json
```

| Field | Applies to | Notes |
|-------|-----------|-------|
| `name` | files + directories | Must match `^[a-zA-Z0-9._-]+$` (the same rule as a path segment, so the result stays addressable by path). Max 255 chars |
| `description` | files + directories | Free text, max 2000 chars. `null` or `""` clears it |
| `mimeType` | files only | e.g. `text/csv` — fixes a wrongly detected content type; it's what a download is served as |

Send any combination; omitted fields are left alone. At least one is required
(`400` otherwise).

- **`409`** when another live file/folder in the same parent already has that name.
- **`403`** when renaming the Inbox (the app resolves it as a fixture — it can
  still be given a description).
- **`404`** when the resource isn't yours (or is outside a scoped key's subtree).
- Renaming a **folder** leaves its contents in place: children hang off the parent
  id, so the whole subtree just answers on the new path.

```bash
curl -X PATCH https://sharefiles.eu/api/files/FILE_ID \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name":"q1-report.csv","description":"Signed off by finance","mimeType":"text/csv"}'
```

**Response** `200 OK` →
`{ "success": true, "id": "FILE_ID", "name": "q1-report.csv", "changed": ["description","mimeType","name"] }`

Read a directory's own record (including its description) with
`GET /api/directories/{dirId}`.

> **Moving** a file/folder to a different parent is a separate operation — see the
> dashboard's Move action, or `PUT /api/files/by-path` to write it at a new path.

### No-delete protection

A file or directory can be flagged **no-delete**. While the flag is on, deleting that
resource (via the dashboard, `DELETE` on the S3 API, etc.) only **tombstones** it — it
disappears from listings but its stored bytes are **kept**, and it stays revivable.
The lock on a **directory is recursive**: it protects every file and subdirectory inside
it, including ones added later.

Reviving a tombstoned, protected resource is **staff-only**: a regular user requests it,
and an administrator restores it (an admin within their tenant, a superadmin globally)
from the dashboard's **Deleted items** view. Setting the flag via the API or the UI has
the same effect.

```
PATCH /api/files/{fileId}/preserve
PATCH /api/directories/{dirId}/preserve
Authorization: Bearer sk_...
Content-Type: application/json

{ "preserve": true }
```

Body must contain a boolean `preserve` (`true` = protect, `false` = remove protection).
The file form applies to every version in the file's group. You can also set the flag at
upload time with `preserve=true` on `POST /api/cli/upload`.

```bash
curl -X PATCH https://sharefiles.eu/api/files/FILE_ID/preserve \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"preserve":true}'
```

**Response** `200 OK` → `{ "success": true, "fileId": "FILE_ID", "preserve": true }`.

---

## Locks (exclusive, max 5 minutes)

A **lock** is a short-lived, exclusive claim on a file or a folder: *"I'm working
on `report.docx` for the next two minutes — nobody else may change it."* One live
lock per resource. Locks are **enforced**: while a lock is live, **every write to
the locked resource is refused with `423 Locked`** unless the request presents
the lock's `lockId` — uploads and new versions, placeholders, renames, moves,
deletes — through the REST API, the S3 API, writable share links **and** the
dashboard. Reads are never blocked.

- **Acquire** returns a secret **`lockId`**. Knowing the `lockId` is what proves
  you own the lock: present it as an **`X-Lock-Id: <lockId>` header** (or
  `?lockId=` / a `lockId` form field) on your writes and they go through.
- **A file lock follows the file across versions.** It is keyed on the file's
  version group, so uploading a new version while you hold the lock (with your
  `lockId`) keeps that same lock on the new version — nothing to re-acquire.
- **A folder lock covers everything inside it**, recursively, including files
  and subfolders that are created while the lock is held. Every file in a locked
  folder reports itself as locked (`inherited: true`), and nothing inside can be
  changed or locked separately.
- While a lock is live, every further acquire — of the same resource, of
  anything inside a locked folder, or of a folder that contains a locked item —
  answers **`409 already_locked`** with the lock's expiry (never its `lockId`).
- A lock lives for **`ttl` seconds** (default **60**, **maximum 300 = 5 minutes**
  — larger values are rejected with `400`, not clamped). When the TTL runs out
  the lock **unlocks by itself**; nothing needs to clean it up.
- A live lock can be **extended**, but **only by its holder**: the same account,
  presenting the `lockId`. Extending resets the expiry to *now + ttl* (again ≤ 5
  minutes from now), so a long job keeps the lock by extending it regularly.
- A lock can be **released** early (holder only), and anyone with access to the
  resource can read its **status**.

| Endpoint                          | Purpose                                              |
|-----------------------------------|------------------------------------------------------|
| `POST /api/locks`                 | acquire → `201 { lockId, expiresAt, … }` / `409`     |
| `GET /api/locks?path=…`           | status of a file or folder (own or inherited lock)   |
| `GET /api/locks/{lockId}`         | status of one specific lock (holder only)            |
| `PATCH /api/locks/{lockId}`       | extend (holder only) → new `expiresAt` / `410` if expired |
| `DELETE /api/locks/{lockId}`      | release early (holder only)                          |

All of them take an **API key** (or a logged-in session) and respect a key's
folder scope. The resource is selected by **exactly one** of `path` (a file path
first, else a folder path — relative to the key's scope), `fileId` or `dirId`,
given as JSON body fields on `POST`/`PATCH` or as query parameters on `GET`. The
account root itself cannot be locked.

```bash
# 1. acquire a 2-minute lock on a file (path resolves the file first, then a folder)
curl -X POST https://sharefiles.eu/api/locks \
  -H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \
  -d '{"path":"reports/q3.xlsx","ttl":120}'
#   201 → { "success": true, "lockId": "lock_x7…", "locked": true, "name": "q3.xlsx",
#           "resourceType": "file", "resourceId": "GROUP_ID", "ttlSeconds": 120,
#           "lockedAt": "2026-09-16T14:00:00.000Z", "expiresAt": "2026-09-16T14:02:00.000Z",
#           "remainingSeconds": 120, "extendedCount": 0 }
#   409 → { "error": "already_locked", "locked": true, "inherited": false, "via": "self",
#           "expiresAt": "2026-09-16T14:01:30.000Z", "remainingSeconds": 90, … }
#   409 → { "error": "already_locked", "inherited": true, "via": "ancestor",
#           "lockedVia": { "resourceType": "directory", "resourceId": "DIR_ID" }, … }   (a folder above it is locked)
#   400 → { "error": "ttl must be at most 300 seconds (5 minutes)", "maxTtlSeconds": 300 }

# a folder lock, by id, with the default 60 s TTL — covers the whole subtree
curl -X POST https://sharefiles.eu/api/locks \
  -H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \
  -d '{"dirId":"DIR_ID"}'

# 2. write while holding the lock: present the lockId, the new version stays under the same lock
curl -X PUT --data-binary @q3.xlsx -H "Authorization: Bearer sk_..." \
  -H "X-Lock-Id: lock_x7…" \
  "https://sharefiles.eu/api/files/by-path?path=reports/q3.xlsx"
#   200 → { "success": true, "version": 8, ... }
# …the same write WITHOUT the lockId (anyone else, any other client):
#   423 → { "error": "locked", "message": "This file is locked (expires in 87s). …",
#           "via": "self", "lockedVia": { "resourceType": "file", "resourceId": "GROUP_ID" },
#           "expiresAt": "…", "remainingSeconds": 87 }

# 3. is it locked? (anyone with access to the resource)
curl -H "Authorization: Bearer sk_..." \
  "https://sharefiles.eu/api/locks?path=reports/q3.xlsx"
#   → { "locked": true, "inherited": false, "name": "q3.xlsx", "resourceType": "file",
#       "resourceId": "GROUP_ID", "lockedAt": "…", "expiresAt": "…", "remainingSeconds": 87, "extendedCount": 0 }
#   → { "locked": true, "inherited": true, "lockedVia": { "resourceType": "directory", "resourceId": "DIR_ID" }, … }
#   → { "locked": false, "resourceType": "file", "resourceId": "GROUP_ID", "name": "q3.xlsx" }

# 4. keep it: extend to 5 more minutes from now — holder only, lockId required
curl -X PATCH https://sharefiles.eu/api/locks/lock_x7… \
  -H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \
  -d '{"ttl":300}'
#   200 → { "success": true, "lockId": "lock_x7…", "locked": true, "ttlSeconds": 300,
#           "expiresAt": "2026-09-16T14:06:30.000Z", "remainingSeconds": 300, "extendedCount": 1 }
#   410 → { "error": "lock_expired", … }        (too late — acquire a new one)
#   404 → { "error": "lock_not_found" }         (unknown lockId, already released, or not yours)

# check one specific lock by its id (holder only)
curl -H "Authorization: Bearer sk_..." https://sharefiles.eu/api/locks/lock_x7…
#   → { "lockId": "lock_x7…", "locked": true, "expired": false, "expiresAt": "…", … }

# 5. done early: release it — holder only
curl -X DELETE -H "Authorization: Bearer sk_..." https://sharefiles.eu/api/locks/lock_x7…
#   200 → { "success": true, "lockId": "lock_x7…", "released": true, "wasLive": true, … }
```

**Where locks show up.** `GET`/`HEAD …/by-path` on a locked file add
`X-Locked: 1`, `X-Lock-Expires: <ISO time>` and `X-Lock-Via: file|directory`.
A directory listing (`GET /api/files?dir=…`) flags each locked entry with
`"locked": true, "lockExpiresAt": "…", "lockInherited": bool`, and carries the
same fields at the top level when the listed folder itself is (or sits inside)
a locked folder — in which case **every** entry is flagged.

**Which writes are refused (423) without the lockId.** On a locked **file**:
`PUT`/`DELETE …/by-path`, `POST /api/upload`, `POST /api/cli/upload`,
`PATCH`/`DELETE /api/files/{id}`, `POST /api/send`, S3 `PutObject`/`DeleteObject`
(as an S3 `<Error><Code>Locked</Code>`), writable-link `POST`/`DELETE`, transfer
codes, and the dashboard's upload / rename / move / delete / restore-version.
On a locked **folder**: all of the above for anything inside it (new names
included), plus creating subfolders and renaming / moving / deleting the folder
itself. Deleting, moving or renaming a folder is also refused while **anything
inside it** is locked by someone else. Background jobs respect locks too: the
auto-cleanup sweeper skips a locked file until its lock expires. The lockId
travels as `X-Lock-Id: <lockId>` (any HTTP surface), `?lockId=`, or a `lockId`
form field (multipart uploads / dashboard actions).

**No API key? Use the link.** A writable `/direct/{token}` link can acquire,
extend, release and inspect locks on its own URL (`?lock=1` / `?unlock=1`) — see
*Locks through a writable link*.

Acquire and extend are **atomic** (single SQLite statements against the
resource's unique lock slot): of any number of clients racing to lock the same
file, **exactly one** gets a `201`, the rest get `409`. An expired lock's slot is
simply taken over by the next acquirer. Locks belong to the account; a
folder-scoped key can only see and use locks on resources inside its folder.
`ttl` may also be passed as a query parameter (`?ttl=120`) on `POST` and `PATCH`.

> **Locks vs. conditional writes.** A lock says *"nobody but me may write this
> for the next N seconds"* and is enforced server-side. `If-Match` / `If-None-Match`
> (see *Conditional writes* above) say *"write only if the file hasn't changed
> since I read it"*. Use a lock to keep others out during a multi-step edit; use
> a conditional write for lock-free optimistic updates. They combine fine.

---

## Versionless mode (keep only the latest version)

By default every upload of an existing path/name creates a **new version** and the
old ones are kept (and count toward your storage). **Versionless mode** switches
that off per file or per directory: only the latest version is ever stored — each
new upload **permanently deletes** the superseded versions and reclaims their bytes.

```
PATCH /api/files/{fileId}/versionless
PATCH /api/directories/{dirId}/versionless
Authorization: Bearer sk_...
Content-Type: application/json

{ "versionless": true }
```

Body must contain a boolean `versionless` (`true` = keep only the latest version,
`false` = back to full versioning; the default is full versioning).

- The **file** form applies to the file's whole version group, turning it **on
  prunes the existing older versions immediately**, and the setting follows the
  file onto future versions. The response reports `pruned` — the number of
  versions deleted.
- The **directory** form is **recursive** (the whole subtree, including files
  added later). Existing files' old versions are reclaimed as each file next
  receives an upload — there is no immediate mass prune.
- The **no-delete lock wins**: preserve-protected files/versions are never pruned.
  Placeholder (v0 "touch") rows are also kept — they hold no bytes.
- You can also set the flag at upload time with `versionless=true` on
  `POST /api/cli/upload`.

```bash
curl -X PATCH https://sharefiles.eu/api/files/FILE_ID/versionless \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"versionless":true}'
```

**Response** `200 OK` → `{ "success": true, "fileId": "FILE_ID", "versionless": true, "pruned": 3 }`.

---

## Auto-cleanup (remove files older than N days)

A **per-directory** retention window: when set, any file in the directory whose
**latest version** is older than the configured number of days is **permanently
deleted** by an hourly background sweep. This is a real delete — the stored
bytes are reclaimed from every storage backend (Bunny/S3) and the local blob,
and the normal `delete` notification/webhook fires. Uploading a new version of
a file resets its clock (age is measured from the latest version's upload time).

```
PATCH /api/directories/{dirId}/auto-cleanup
Authorization: Bearer sk_...
Content-Type: application/json

{ "autoCleanupDays": 30 }
```

Body must contain `autoCleanupDays`: an integer between 1 and 3650, or `null`
(or `0`) to disable. The default is off.

- **Recursive**: applies to the whole subtree. A subdirectory with its **own**
  `autoCleanupDays` takes over for its subtree (nearest configured ancestor
  wins).
- **The no-delete lock wins**: auto-cleanup **cannot be enabled** on a
  directory that has no-delete protection, its own or inherited — the request
  answers `409 Conflict`. Enabling protection on a directory switches its
  auto-cleanup **off**, protected subdirectories are skipped by the sweep, and
  individually protected files are never removed.
- Placeholder ("touch", v0) files are never swept — they hold no bytes.
- The sweep runs hourly by default (`AUTO_CLEANUP_CHECK_HOURS`), so removal can
  lag the configured age by up to one interval.

```bash
curl -X PATCH https://sharefiles.eu/api/directories/DIR_ID/auto-cleanup \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"autoCleanupDays":30}'

# disable again
curl -X PATCH https://sharefiles.eu/api/directories/DIR_ID/auto-cleanup \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"autoCleanupDays":null}'
```

**Response** `200 OK` → `{ "success": true, "dirId": "DIR_ID", "autoCleanupDays": 30 }`.
`409 Conflict` when the directory has no-delete protection (own or inherited).

---

## Version retention (GFS history per folder)

By default every upload of an existing name adds a version and **all versions
are kept forever**. A **per-directory** retention policy thins that history to a
grandfather-father-son schedule; superseded versions outside it are
**permanently deleted** (bytes reclaimed from every storage backend and the
local blob):

- every version younger than `keepAllHours` is kept;
- up to `hourlyHours` old, the newest version of each clock hour is kept;
- up to `dailyDays` old, the newest version of each (UTC) day is kept;
- anything older is pruned.

Windows are ages measured from now. The **current** version is never pruned,
whatever its age.

```
PATCH /api/directories/{dirId}/version-retention
Authorization: Bearer sk_...
Content-Type: application/json

{ "versionRetention": { "keepAllHours": 2, "hourlyHours": 48, "dailyDays": 30 } }
```

Body must contain `versionRetention`: an object with three integers
(`keepAllHours` 0–8760, `hourlyHours` 0–8760, `dailyDays` 0–3650), or `null` to
remove the directory's own policy (it then inherits again). The default is no
policy (keep everything).

- **Recursive**: applies to every file in the subtree. A subdirectory with its
  **own** policy wins for its subtree (nearest configured ancestor wins).
- Enforced **right after every new upload** of a file (all upload routes:
  dashboard, API, by-path, S3, writable direct links) and by an **hourly sweep**
  (`AUTO_CLEANUP_CHECK_HOURS`), so a folder that stops receiving uploads still
  ages out.
- **Versionless wins**: a versionless file/folder keeps only its latest version.
- **The no-delete lock wins**: files under no-delete protection (own or
  inherited) keep their whole history, and individually protected versions are
  never pruned. Placeholder ("touch", v0) rows are never pruned.
- Allowed for the directory's owner (a scoped API key only inside its scope)
  and for an admin with an unscoped session or key.
- `GET /api/directories/{dirId}` returns the directory's own `versionRetention`.

```bash
curl -X PATCH https://sharefiles.eu/api/directories/DIR_ID/version-retention \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"versionRetention":{"keepAllHours":2,"hourlyHours":48,"dailyDays":30}}'

# remove the folder's own policy again
curl -X PATCH https://sharefiles.eu/api/directories/DIR_ID/version-retention \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"versionRetention":null}'
```

**Response** `200 OK` →
`{ "success": true, "dirId": "DIR_ID", "versionRetention": { "keepAllHours": 2, "hourlyHours": 48, "dailyDays": 30 } }`.
`400` for an invalid body, `403` outside an API key's scope, `404` when the
directory does not exist or is not yours.

**Version-flood alarm.** Independently of any policy, a file that receives more
than `VERSION_FLOOD_PER_HOUR` (default 120) new versions within an hour is
logged as `[sharefiles] ERROR version flood <path> <n>/h by <link|user>` and
reported as an error event (issue tracker, plus SauroMON event
`sharefiles.version_flood` when `SAUROMON_KEY` is set) — at most once per hour per file.

---

## Off-site backups (backup sites)

Admins can add any number of **backup sites** under Admin → Storage: an
**S3-compatible bucket**, an **FTPS** server (explicit or implicit TLS; plain FTP
is not offered) or an **SFTP** server (password or private key; the SSH host key
is pinned on first connect and a changed key is refused). The connection is tested
when the site is saved. Every site type gets exactly the same encrypted objects. A folder then asks for **N backup copies** (0 … number of
sites): every file version in it — and in its subfolders — is copied to the
first N enabled sites (by site priority). **Paying members only.**

- **Always encrypted**: each site has its own AES-256-GCM key and every copy is
  sealed with it — on top of any client-side encryption the file already had
  (so encrypted files end up double encrypted).
- **Append-only**: sharefiles never deletes from a backup site. Deleting a file
  or folder, lowering the copies, or the admin removing a site leaves the copies
  in place, and they stay listable/restorable.
- **Full first sync**: raising a folder's copies (e.g. to use a newly added site)
  makes the next sync copy *everything* in the folder to the new site. Syncs run
  every `BACKUP_SITES_SYNC_MINUTES` (default 10) and right after a change.
- **Stops paying** → existing copies stay, but nothing new is backed up and
  restores answer `402 Payment Required` until the plan is active again.

### Set a folder's copies

```
GET   /api/directories/{dirId}/backup
PATCH /api/directories/{dirId}/backup     { "backupCopies": 2 }
```

`backupCopies`: integer `0` (off) … number of backup sites, or `null` to inherit
from the parent folder (a root folder with `null` = off). `400` above the site
count, `402` when turning it on without a paid plan. Both return the status:

```json
{
  "dirId": "DIR_ID", "backupCopies": 2, "effectiveCopies": 2, "inheritedFrom": null,
  "paying": true, "active": true, "maxCopies": 3, "totalFiles": 120,
  "sites": [
    { "id": "SITE_A", "name": "B2 Frankfurt", "used": true, "filesBackedUp": 120 },
    { "id": "SITE_B", "name": "R2", "used": true, "filesBackedUp": 87 },
    { "id": "SITE_C", "name": "Wasabi", "used": false, "filesBackedUp": 0 }
  ]
}
```

### List backups (includes deleted files)

```
GET /api/backups?dirId=DIR_ID        # or ?path=Photos/2026, ?fileId=FILE_ID, or nothing = all
```

```json
{
  "paying": true, "canRestore": true,
  "sites": [{ "id": "SITE_A", "name": "B2 Frankfurt" }],
  "backups": [{
    "fileId": "F1", "versionGroupId": "G1", "version": 3, "name": "report.pdf",
    "path": "Work/2026", "directoryId": "D1", "size": 48213, "mimeType": "application/pdf",
    "isEncrypted": false, "backedUpAt": "2026-09-25T10:00:00.000Z",
    "existsInSharefiles": false,
    "copies": [{ "backupId": "B1", "siteId": "SITE_A", "siteName": "B2 Frankfurt" }]
  }]
}
```

`existsInSharefiles: false` = the file was deleted here; restoring brings it back.
Folder-scoped API keys only see backups taken inside their folder.

### Restore

```
POST /api/backups/restore     { "backupId": "B1" }
                              { "fileId": "F1", "siteId": "SITE_A"?, "targetDirectoryId": "D2"? }
```

Restores the version as the **newest version** of the same-named file in its
original folder (re-created from the saved path if the folder is gone), or in
`targetDirectoryId`. The bytes are checked against the original SHA-256. Tries
the next site holding a copy if one fails. `201` →
`{ "success": true, "fileId", "version", "versionGroupId", "directoryId", "path", "name" }`.
`402` without a paid plan, `404` unknown backup, `423` target locked (send
`X-Lock-Id`), `502` no site could deliver the copy.

```bash
curl -X PATCH https://sharefiles.eu/api/directories/DIR_ID/backup \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" -d '{"backupCopies":2}'

curl "https://sharefiles.eu/api/backups?path=Work/2026" -H "Authorization: Bearer sk_your_api_key_here"

curl -X POST https://sharefiles.eu/api/backups/restore \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" -d '{"backupId":"B1"}'
```

**Layout (disaster recovery)** — in the bucket, or under the server folder for
FTPS/SFTP: `{prefix}/{userId}/{versionGroupId}/{fileId}.bin`
(data) and `.meta` (JSON metadata: name, folder path, version, hash, encryption
info). Both use the format `"PFBK"` · `0x01` · 12-byte IV · AES-256-GCM
ciphertext · 16-byte tag, with the site key an admin can reveal in Admin → Storage.

---

## Share links

Shareable, unauthenticated download URLs for a file or directory you own. Each
link can be **single-use**, **time-limited**, and/or **password-protected**.

A link's protections (single-use, expiry, **password**) are all set **when you
create it** via `POST /api/share-links` — there is no separate "set password"
call. To password-protect a link, include a `password` in the create body; the
recipient then supplies it when downloading (see *Use a share link* below).

### Create a share link

```
POST /api/share-links
Authorization: Bearer sk_...
Content-Type: application/json
```

**Body**

| Field              | Type    | Notes                                                        |
| ------------------ | ------- | ------------------------------------------------------------ |
| `fileId`           | string  | Share a file (provide this **or** `directoryId`)             |
| `directoryId`      | string  | Share a directory (served as a `.tgz`)                       |
| `singleUse`        | boolean | If true, the link works for exactly one download             |
| `maxRequests`      | number  | Explicit **download** cap (overrides `singleUse`); omit for unlimited. See *What counts as a download vs a view* |
| `maxViews`         | number  | **View** cap — preview / folder-browse page renders; omit for unlimited |
| `expiresInMinutes` | number  | Time limit; omit for no expiry                               |
| `password`         | string  | Require this password to download (and to write, if writable)|
| `writable`         | boolean | **Opt-in, default `false`.** Also allow `POST`/`DELETE` on the link **without an API key** (see *Writable links*) |
| `allowDelete`      | boolean | **Writable links only, default `true`.** `false` = drop box: uploads accepted, every `DELETE` answers `403`. See *Guard rails on a writable link* |
| `protectRoot`      | boolean | **Writable directory links only, default `true`.** Keeps the shared folder itself from being deleted through the link. `false` restores the old "bare `DELETE` wipes the share" behaviour |
| `ownUploadsOnly`   | boolean | **Writable directory links only, default `true`.** Restricts updates/deletes to files uploaded through this same link; pre-existing content is read-only to it |
| `maxSizeMb`        | number  | **Writable links only.** Per-file upload cap in MB. Omit (or `≤ 0`) for **unlimited**. Over-cap uploads get `413` and the stream is aborted (never written to disk). |
| `versionMode`      | string  | **File links only.** `pinned` (default — always serve the exact version shared), `latest` (always the current version), or `expose` (current + let the holder list/fetch any version). See *File versions through a share link*. |
| `label`            | string  | Optional human-readable label                                |

**Quick presets** (used by the apps' one-click "Quick link" buttons): time limits
`expiresInMinutes` = `60` (1 hour) / `480` (8 hours) / `1440` (24 hours), or request
caps `maxRequests` = `1` / `3` / `10`. Mix and match as you like.

**Response** `200 OK`

```json
{
  "token": "abc123…",
  "url": "https://sharefiles.eu/direct/abc123…",
  "expiresAt": "2026-06-08T00:00:00.000Z",
  "singleUse": true,
  "maxViews": null,
  "passwordProtected": true,
  "writable": false,
  "allowDelete": true,
  "protectRoot": true,
  "ownUploadsOnly": true,
  "maxSizeMb": null,
  "versionMode": "pinned"
}
```

**Example**

```bash
curl -X POST https://sharefiles.eu/api/share-links \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"fileId":"FILE_ID","singleUse":true,"expiresInMinutes":60,"password":"hunter2"}'
```

### Use a share link

```
GET /direct/{token}
GET /direct/{token}/{filename}     # same link — the filename segment is decorative
GET /direct/{token}/{path...}      # directory link only: a path inside the share
```

No authentication. A previewable file renders a preview page in the browser;
everything else returns the raw file bytes (or a `.tgz` for a directory).

**Viewer vs bytes (content negotiation).** A file URL (single-file link, or
`?file=…` on a folder link) returns the HTML preview page **only to a browser
navigation**: `Sec-Fetch-Mode: navigate` (or, from clients that don't send
`Sec-Fetch-*`, `Sec-Fetch-Dest: document` or an `Accept` listing `text/html`).
Every other request — no `Accept`, `*/*`, `application/json`, `fetch()`, curl,
node/undici, Deno, `Range` requests — gets the raw bytes with the file's real
`Content-Type` and `Content-Length` (a `Range` gets `206`). So a stored direct
link can be fetched programmatically with no flags, no cookie, and no redirect:

```bash
curl -s "https://sharefiles.eu/direct/abc123/output.json"   # → the JSON itself
```

`?raw=1` / `?dl=1` / `?download=1` always force the bytes; `?view=1` always
forces the preview page. Responses carry
`Vary: Accept, Sec-Fetch-Mode, Sec-Fetch-Dest`. A negotiated byte fetch counts
like a bare `?raw=1` (one download).

**Original filename.** Downloads are served with a
`Content-Disposition: attachment; filename="…"` header carrying the file's
original name, and every link URL the API/UI returns for a **file** includes
that name as a trailing path segment (`/direct/{token}/report.pdf`). The
server ignores the segment — any (or no) trailing name works — but tools that
name downloads after the URL instead of the header (`wget`, `curl -O`) then
save as `report.pdf` rather than the token. With curl, prefer `-OJ` (or the
suffixed URL) over `-O` on a bare token URL.

**What counts as a download vs a view.** Every link keeps two counters, capped
independently:

| Counter | Incremented by | Cap |
|---------|----------------|-----|
| **Downloads** (`requestCount`) | `?dl=1`, a bare `?raw=1` (how other apps hotlink the bytes), a flag-less byte fetch by a non-browser client, directory `.tgz`, writable-link uploads (`POST`), sub-link mints (`&mint=1`) | `maxRequests` / `singleUse` |
| **Views** (`viewCount`) | Rendering the preview page or the HTML folder-browse page | `maxViews` |

The preview page's own media sub-request (`?raw=1&inline=1`) is part of the
already-counted view and is free, as are JSON listings (`?dir=/…`, `?versions=1`)
and mid-file `Range` continuations of a raw fetch (video seeking). Hitting
**either** cap makes the whole link unavailable.

- **Expired** → `410 Link expired`
- **Download or view limit reached** → `410 Link exhausted`
- **Password-protected**: supply the password as `?password=...` or the
  `X-Share-Password` header. A browser with no password is shown an unlock form;
  a wrong password returns `401`. The password check happens **before** anything is
  counted, so a wrong attempt never burns a single-use link.

```bash
curl -L "https://sharefiles.eu/direct/abc123?password=hunter2" -o file.bin
# or:
curl -L -H "X-Share-Password: hunter2" https://sharefiles.eu/direct/abc123 -o file.bin
```

### File versions through a share link

putfile keeps a **version history** for every file (each re-upload to the same
path is a new version sharing one `versionGroupId` — see *List file versions*). A
**file** share link decides how it exposes that history via `versionMode`, chosen
when you create the link. It has **no effect on directory links**, which always
serve current versions.

| `versionMode` | What `GET /direct/{token}` serves | Version browsing |
| ------------- | --------------------------------- | ---------------- |
| `pinned` *(default)* | The **exact version** the link was minted against, even after newer versions are uploaded. This is the historical behavior, so **every pre-existing link is unchanged**. | — |
| `latest`      | The **current version** of the file, always (follows new uploads). | — |
| `expose`      | The current version by default, **plus** version listing and selection (below). | ✅ |

> **Writable file links are never pinned.** A link created with `writable: true`
> exists to be written through, so on it `pinned` behaves as `latest`: a `GET`
> always serves the current version — including versions just uploaded through
> the link itself. An explicit `expose` is kept.

**On an `expose` link only:**

- **List versions** — `GET /direct/{token}?versions=1` returns JSON (a free
  request — never consumes a use):

  ```json
  {
    "versionGroupId": "f_8h2k9d…",
    "versions": [
      { "version": 3, "size": 10485760, "sha256": "…", "isCurrent": true,
        "createdAt": "2026-07-08T…",
        "viewUrl": "/direct/abc123?version=3",
        "url":     "/direct/abc123?version=3&dl=1" },
      { "version": 2, "size": 10485000, "sha256": "…", "isCurrent": false, "createdAt": "…", "viewUrl": "…", "url": "…" },
      { "version": 1, "size": 9000000,  "sha256": "…", "isCurrent": false, "createdAt": "…", "viewUrl": "…", "url": "…" }
    ]
  }
  ```

- **Fetch a specific version** — add `?version=N` to any `GET /direct/{token}`
  request; it composes with the usual params: `?version=2` previews it (counts a
  view), `?version=2&dl=1` or `?version=2&raw=1` fetches its bytes (counts a
  download). A password-protected link still needs `?password=` or the
  `X-Share-Password` header. An unknown version number → `404 Version not found`.

`?versions=1` and `?version=N` are **ignored on `pinned` / `latest` links** — a
link never exposes history you didn't opt into. To browse versions behind a login
instead, use the authenticated `GET /api/files/{fileId}/versions`.

```bash
# create a link that exposes the file's whole version history
curl -X POST https://sharefiles.eu/api/share-links \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"fileId":"FILE_ID","versionMode":"expose"}'

# list the versions, then download v2
curl "https://sharefiles.eu/direct/abc123?versions=1"
curl -L "https://sharefiles.eu/direct/abc123?version=2&dl=1" -o v2.bin
```

### Browse a shared directory (JSON, by path)

A **directory** link (`directoryId`) can be walked **by path** and read as JSON,
so a program can enumerate the shared tree and grab individual files without
scraping the HTML browse page. Paths are **relative to the shared folder** and use
a **leading slash** (`/` = the shared root, `/logs`, `/logs/app.log`). Every path
is resolved **scoped to the share**, so a path can never escape the shared folder;
segments must match `^[a-zA-Z0-9._-]+$`.

A `dir=`/`file=` value that starts with `/` is a path; anything else is still
treated as an **id** (the HTML browse is unchanged). Add `format=json` to force a
JSON listing even for an id-keyed `dir=`.

| Request | Returns |
|---------|---------|
| `GET /direct/{token}?dir=/`            | JSON listing of the shared root (free) |
| `GET /direct/{token}?dir=/logs`        | JSON listing of that sub-folder (free) |
| `GET /direct/{token}?dir=/logs/app.log`| JSON **metadata** for that one file (the "or 1 file" case, free) |
| `GET /direct/{token}?file=/logs/app.log`        | preview page (counts a **view**) or download |
| `GET /direct/{token}?file=/logs/app.log&raw=1`  | the file's bytes (counts a **download**) |
| `GET /direct/{token}?file=/logs/app.log&dl=1`   | download the file (counts a **download**) |
| `GET /direct/{token}?tgz=1&dir=/logs`           | `.tgz` of that sub-folder (counts a **download**) |

A listing entry carries the item's `name`, `path` (its leading-slash path, or
`null` if a name in the chain isn't path-addressable — then use the URLs, which
fall back to ids), and ready-to-use URLs:

```json
{
  "path": "/logs",
  "writable": false,
  "tgz": "/direct/abc123?tgz=1&dir=/logs",
  "directories": [
    { "name": "archive", "type": "directory", "path": "/logs/archive",
      "list": "/direct/abc123?dir=/logs/archive",
      "tgz":  "/direct/abc123?tgz=1&dir=/logs/archive" }
  ],
  "files": [
    { "name": "app.log", "type": "file", "path": "/logs/app.log",
      "size": 20480, "sha256": "…", "mimeType": "text/plain", "encrypted": false,
      "view":     "/direct/abc123?file=/logs/app.log",
      "download": "/direct/abc123?file=/logs/app.log&dl=1",
      "raw":      "/direct/abc123?file=/logs/app.log&raw=1" }
  ]
}
```

On a **writable** link (see below), each entry (and the single-file metadata) also
carries a **`mint`** URL that mints a **standalone public link** for that file or
sub-folder — see *Mint a sub-link from a directory link*. JSON listing and metadata
are **free** (never counted); the HTML browse page counts a **view**.

```bash
# walk the tree as JSON, then pull one file
curl "https://sharefiles.eu/direct/abc123?dir=/"
curl "https://sharefiles.eu/direct/abc123?dir=/logs"
curl -L "https://sharefiles.eu/direct/abc123?file=/logs/app.log&dl=1" -o app.log
```

#### Browser view: README rendering and static-site hosting

These two apply only when a **browser** opens a **directory** link (they change
nothing for the JSON API, `curl`, or any request carrying an explicit selector).

**README.** If the folder being listed contains a `README.md` (any case; also
`README`, `.markdown`), it is rendered under the file listing the way a
repository host shows it — headings, tables, task lists, fenced code, and
` ```mermaid ` blocks as diagrams. Relative links and images in the README are
rewritten to point back into the share, so `![logo](img/logo.png)` and
`[changelog](./CHANGELOG.md)` both resolve. Anything that would escape the share,
or whose name isn't path-addressable, is dropped rather than linked. READMEs over
512 KB are listed as ordinary files instead.

**Static sites.** If the folder contains an `index.html`, the link serves that
page instead of the file listing, and `/direct/{token}/{path}` serves any file in
the share by path — so a folder of HTML, CSS, images and JS is a working site:

```
GET /direct/{token}                       → index.html at the shared root
GET /direct/{token}/about.html            → that page
GET /direct/{token}/assets/site.css       → that stylesheet
GET /direct/{token}/docs/                 → docs/index.html if present, else a listing
GET /direct/{token}?list=1                → force the file listing instead
```

Served HTML gets a `<base href>` pointing at its own folder in the share, so
**relative** URLs (`./contact.html`, `img/logo.png`, `../style.css`) resolve to
real files. **Root-absolute** URLs (`/style.css`) cannot work — the share does not
own the domain root — so author links relatively. A document that declares its own
`<base>` is left untouched.

Pages are served **sandboxed** (`Content-Security-Policy: sandbox …`): the site
renders and its scripts, forms and popups work, but it runs in an opaque origin
and cannot read cookies, `localStorage`, or anything else belonging to the host.
That is also what makes it safe to open an uploaded `.html` from any share link.

Fetching a page counts one **view**; its assets are free. On a
password-protected link, the first request carrying `?password=` sets a
path-scoped `HttpOnly` cookie so the page's assets load without it.

### Writable links (POST / DELETE — no API key)

If a link was created with `writable: true`, the **same `/direct/{token}` URL**
also accepts `POST` (write) and `DELETE` — with **no API key**, just the token
(and the password, if set). This makes a share link a tiny drop-box or a
deletable handle. **Default is off**; on a read-only link `POST`/`DELETE` return
`403`. All the usual options still apply — expiry, use cap (`POST` counts as a
use), and password (required for writes too). **Deleting the shared resource
auto-deactivates the link.**

**Write — `POST /direct/{token}`**

- **File link:** the body becomes a **new version** of that file (raw bytes, or
  multipart `file`). → `{ "success": true, "name": "...", "version": N, "sha256": "..." }`
- **Directory link:** `multipart/form-data` with one or more `files` (and an
  optional `path` sub-folder within the shared directory, created if missing). →
  `{ "success": true, "uploaded": [ { "name": "...", "size": N, "sha256": "...", "version": N } ] }`.

**Conditional writes on a writable link (compare-and-swap, no API key).**
`POST /direct/{token}` accepts the same preconditions as the API-key surface —
`If-Match` / `If-None-Match` headers, or the `?ifVersion=` / `?ifMatch=` /
`?ifAbsent=1` query aliases (use the query form with `multipart/form-data`, where
setting headers is awkward). Reads through the link (`?file=…&raw=1`) return the
file's version as an `ETag`, so a plain direct link is a complete
compare-and-swap store: leases, locks, leader election, atomic counters.

The commit is the same single guarded SQL statement the API-key path uses, so of
N racing writers **exactly one wins** and a reader never sees a half-written
version. A failed precondition returns **412** with the live version:

```bash
# create-once (leader election): only if nothing is at the path yet
curl -X POST -F "files=@leader.json" -F "path=cluster" \
  "https://sharefiles.eu/direct/TOKEN?ifAbsent=1"
#   200 → { "success": true, "uploaded": [ { "name": "leader.json", "version": 1 } ] }   (ETag: "1")
#   412 → { "error": "already_exists", "currentVersion": 1 }

# compare-and-set: renew the lease only if we still hold version 7
curl -X POST -F "files=@leader.json" -F "path=cluster" \
  "https://sharefiles.eu/direct/TOKEN?ifVersion=7"
#   412 → { "error": "version_mismatch", "expectedVersion": 7, "currentVersion": 9 }

# read the current version (the ETag) to compare-and-set against
curl -sI "https://sharefiles.eu/direct/TOKEN?file=/cluster/leader.json&raw=1" | grep -i etag
```

A precondition names **one** path, so a conditional upload must carry exactly one
file — a multi-file batch with a precondition is rejected with `400` rather than
having it silently applied to the first file.

**Upload from the preview page (no curl needed).** On a writable link the
browser preview page shows an **"Upload new version"** button next to Download.
It POSTs the picked file through the exact same contract (raw body on a file
link; multipart `files` + `path` with the same filename on a folder share, which
supersedes the file) and reloads onto the new latest. An upload from the page
counts as a use, like any `POST`. Two supporting behaviors: a writable link
serves the **latest** version (see *File versions through a share link*), and a
**non-previewable** file on a writable link still renders the page in a browser
(clients not sending `Accept: text/html`, e.g. curl, keep getting the bytes
directly).

**Mint a download link in the same call (`?links=1`).** Add **`?links=1`** to also
get a public **download URL per uploaded file** (in each entry for a directory
link; on the top-level response for a file link). The minted link is the *only*
thing you need to hand out — no API key — and you choose its properties right here:

| Param | On the minted link |
|-------|--------------------|
| *(none)* | **Permanent & unlimited** (the default) |
| `maxRequests=N` / `singleUse=true` | **Download-capped** (`singleUse` = 1) |
| `maxViews=N` | **View-capped** (preview / browse page renders) |
| `expiresInMinutes=N` | **Time-limited** |
| `linkPassword=secret` | **Password-protected** — distinct from `password`, which unlocks the *writable* link itself |

Each `?links=1` entry echoes `url`, `expiresAt`, `maxRequests`, `maxViews`, and
`passwordProtected`. Params may be query string or multipart fields. (This is how
`build:apps` publishes binaries through one writable link and captures their
URLs + hashes.)

```bash
# replace a file-link's contents (no minted link)
curl -X POST "https://sharefiles.eu/direct/TOKEN?password=hunter2" \
  --data-binary @report.pdf

# drop files into a directory-link AND mint a permanent public link per file
curl -X POST "https://sharefiles.eu/direct/TOKEN?links=1" \
  -F "path=incoming" -F "files=@a.txt" -F "files=@b.txt"

# mint a timed (24h), 10-download, password-protected link per file
curl -X POST "https://sharefiles.eu/direct/TOKEN?links=1&expiresInMinutes=1440&maxRequests=10&linkPassword=s3cret" \
  -F "files=@a.txt"
# → { "success": true, "uploaded": [
#       { "name": "a.txt", "size": 12, "sha256": "…",
#         "url": "https://sharefiles.eu/direct/xyz789",
#         "expiresAt": "2026-06-28T12:00:00.000Z", "maxRequests": 10, "passwordProtected": true } ] }
```

**Mint a sub-link from a directory link (`GET …&mint=1`).** On a **writable**
directory link you can mint a **standalone public link for an existing file or
sub-folder** inside the share — without uploading anything — by adding **`mint=1`**
to the same `dir=`/`file=` selector used to browse it (id or `/path`, see *Browse a
shared directory*). This is a `GET`. It's **writable-only** (a read-only link
returns `403`) and **consumes one use**, so a capped link can't spawn unlimited
children. The minted link's properties come from the same query params as
`?links=1` (`maxRequests`/`singleUse`/`expiresInMinutes`/`linkPassword`/
`versionMode`; omit for permanent + unlimited). The response is the usual minted
shape: `{ "url", "expiresAt", "maxRequests", "maxViews", "passwordProtected" }`.

```bash
# mint a permanent public link for one file inside the shared folder
curl "https://sharefiles.eu/direct/TOKEN?file=/logs/app.log&mint=1&password=hunter2"
# → { "url": "https://sharefiles.eu/direct/xyz789", "expiresAt": null, "maxRequests": null, "passwordProtected": false }

# mint a 1-hour, single-download link for a whole sub-folder (served as .tgz)
curl "https://sharefiles.eu/direct/TOKEN?dir=/logs&mint=1&expiresInMinutes=60&singleUse=true"
```

**Delete — `DELETE /direct/{token}`**

**To delete one entry you must name it with `?path=<name>`.** Query params that
this endpoint does not act on are **rejected with `400`**, never ignored — in
particular `?file=` and `?dir=` (those are *GET* selectors) do not delete
anything. A `DELETE` naming nothing does **not** mean "delete everything".

| Request | What it deletes | Response |
|---------|-----------------|----------|
| `DELETE /direct/{token}` on a **file** link | that file — **every version and every same-name copy** at its path | `{ "success": true, "deleted": N }` |
| `DELETE /direct/{token}?path=<name>` on a **directory** link | just that one **file** within the shared folder (link stays active) | `{ "success": true, "deleted": N }` |
| `DELETE /direct/{token}?path=<subdir>` on a **directory** link | just that one **sub-folder**, recursively (link stays active) | `{ "success": true, "deletedFiles": N, "deletedDirs": M }` |
| `DELETE /direct/{token}` on a **directory** link (no `?path`) | nothing — **`403`** while `protectRoot` is on (the default) | `{ "error": "Refusing to delete the shared folder itself…" }` |
| any `DELETE` carrying an unsupported param | nothing — **`400`** with a hint | `{ "error": "DELETE does not accept \"file\"…" }` |

`?path=` resolution is scoped to the link's folder, so it can never reach anything
outside the share.

```bash
# Delete ONE file inside the shared directory (link stays usable).
curl -X DELETE "https://sharefiles.eu/direct/TOKEN?path=tiny.txt"

# Delete one nested file:
curl -X DELETE "https://sharefiles.eu/direct/TOKEN?path=reports/q1.pdf"

# Rejected (400) — ?file= is a GET selector and deletes nothing:
curl -X DELETE "https://sharefiles.eu/direct/TOKEN?file=/tiny.txt"
```

### Locks through a writable link (no API key)

A writable link can take, extend, release and inspect locks **on its own URL** —
the holder needs nothing but the link (plus its password, if any). It is the same
lock as `/api/locks` (same 5-minute cap, same `423` on every write path, same
`X-Lock-Id` to write through it); it just lives on the link:

| Call | Does |
|------|------|
| `POST /direct/{token}?lock=1[&ttl=60]`                     | **Acquire** → `201 { lockId, expiresAt, … }`; `409 already_locked` when held |
| `POST /direct/{token}?lock=1` + `X-Lock-Id` (or `&lockId=`) | **Extend** that lock → `200` (`410 lock_expired` once it has lapsed) |
| `DELETE /direct/{token}?unlock=1` + `X-Lock-Id` (or `&lockId=`) | **Release** early → `200 { released: true }`. Deletes nothing but the lock |
| `GET /direct/{token}?lock=1`                               | **Status** → `{ locked, inherited, expiresAt, remainingSeconds, … }`; send your `X-Lock-Id` to also get `"yours": true\|false`. Works on read-only links too; never returns a lockId |

**What gets locked.** A **file link** always locks its one file. A **directory
link** locks the entry named by `?path=<path inside the share>` — or, equivalently,
by the URL path: `POST /direct/{token}/reports/q1.pdf?lock=1`. A path resolves as
a file first, then a folder (a folder lock covers everything beneath it). The
target must exist. With no path the target is the shared folder itself.

**Guard rails** (so an anonymous holder can't freeze what it couldn't write):
acquire / extend / release need a **writable** link; with `ownUploadsOnly` (the
default) a link may only lock files **it uploaded itself**, or folders holding
nothing else; with `protectRoot` (the default) the **shared folder itself can't be
locked**, only entries inside it. Releasing works even on an `allowDelete:false`
drop box. Lock calls are **free** — they never consume `maxRequests` / `maxViews`.

Locks taken through a link belong to the **link owner's account**, so they
interoperate both ways: the owner's API key can present / extend / release a
link-taken lock via `/api/locks/{lockId}`, and a lockId the owner hands to a link
holder works through the link — as long as the locked resource lies inside the
share. A lockId for anything outside the share answers `404` here.

```bash
# 1. Lock a file in the shared folder for 2 minutes.
LOCK=$(curl -s -X POST "https://sharefiles.eu/direct/TOKEN?lock=1&path=state.json&ttl=120" | jq -r .lockId)

# 2. Write through the lock (header, ?lockId=, or a multipart `lockId` field).
curl -X POST "https://sharefiles.eu/direct/TOKEN" -H "X-Lock-Id: $LOCK" -F "files=@state.json"

# 3. Still busy? Extend. Done? Release.
curl -X POST   "https://sharefiles.eu/direct/TOKEN?lock=1&ttl=120" -H "X-Lock-Id: $LOCK"
curl -X DELETE "https://sharefiles.eu/direct/TOKEN?unlock=1"       -H "X-Lock-Id: $LOCK"

# Anyone with the link can see it is locked (never by whom, never the lockId):
curl "https://sharefiles.eu/direct/TOKEN/state.json?lock=1"
```

The link's JSON listings (`?json=1`, `?dir=/path`) flag locked entries exactly
like `GET /api/files`: `"locked": true, "lockExpiresAt": "…", "lockInherited": bool`.
A `DELETE` accepts `?lockId=` alongside `?path=` / `?password=`.

### Guard rails on a writable link

A writable link is a bearer credential: anyone holding the URL can write within
the shared resource. Three switches bound what it can reach. **All three default
to ON**, including for links minted before they existed — you must opt out
explicitly to get the looser behaviour.

| Field | Default | Off means |
|-------|---------|-----------|
| `allowDelete` | `true` | `false` = a pure **drop box**: uploads accepted, every `DELETE` answers `403` |
| `protectRoot` | `true` | `false` = a `DELETE` with no `?path` again erases the **whole shared folder** and deactivates the link |
| `ownUploadsOnly` | `true` | `false` = the link may also modify/delete content it did **not** upload |

- **`ownUploadsOnly`** is the strongest of the three: the link may only update or
  delete files that were uploaded **through that same link** (tracked per file).
  Anything already in the folder — or added via the dashboard, an API key, S3, or
  sync — stays downloadable but is otherwise untouchable, answering `403`.
  Uploading a **new** file is always allowed, and a **placeholder** the owner
  created ahead of time can always receive its first upload. It applies to
  **directory links only**; a single-file link targets the one file the owner
  picked when minting it.
- Deleting a **sub-folder** requires that *everything* inside it came through the
  link — one foreign file makes the whole folder off limits.
- Deleting the shared folder itself is the owner's call: do it from the dashboard
  or with an API key (`DELETE /api/files/by-path`).
- A no-delete (preserve) lock still wins over all of this: protected resources are
  tombstoned, not purged.

Combine with a `password`, `expiresInMinutes`, `maxRequests`, and/or `maxSizeMb`
for a link that is safe to hand out.

```bash
# A drop box that can receive files but can never remove anything:
curl -X POST https://sharefiles.eu/api/share-links \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"directoryId":"DIR_ID","writable":true,"allowDelete":false,"password":"hunter2"}'
```

---

## Offline sync

Flag files/directories for offline use and mirror them to local storage. The flag
is **per user** (a collaborator can sync a shared item independently of the owner).
There is also a drop-in client — see *Sync SDK* below.

### Manifest

```
GET /api/sync/manifest
Authorization: Bearer sk_...
```

Returns everything you've flagged for sync, expanded (directories recurse to all
descendants), with each file's current id/hash/version so a client can diff.

```json
{
  "directories": [
    { "id": "DIR", "name": "project", "parentId": null, "ownerId": "U", "permission": "write",
      "encryptionEnabled": false, "encryptionKeyId": null, "encryptionPublicKey": null, "recipients": [] },
    { "id": "SECRETS", "name": "secrets", "parentId": "DIR", "ownerId": "U", "permission": "write",
      "encryptionEnabled": true, "encryptionKeyId": "k_dir", "encryptionPublicKey": "-----BEGIN PUBLIC KEY-----\n…",
      "recipients": [ { "keyId": "k_dir", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…" },
                      { "keyId": "k_alice", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…" } ] }
  ],
  "files": [
    { "versionGroupId": "G", "currentFileId": "F2", "version": 2, "fileHash": "sha256…",
      "fileSize": 2048, "mimeType": "text/plain", "originalName": "notes.txt",
      "directoryId": "DIR", "isEncrypted": false, "encryptionKeyId": null,
      "ownerId": "U", "permission": "write",
      "keyEnvelopes": [], "missingRecipients": [], "encryptionStatus": "ready" },
    { "versionGroupId": "G2", "currentFileId": "F9", "version": 1, "fileHash": "sha256-of-ciphertext…",
      "fileSize": 4112, "mimeType": "application/octet-stream", "originalName": "plan.md",
      "directoryId": "SECRETS", "isEncrypted": true, "encryptionKeyId": "k_dir",
      "ownerId": "U", "permission": "write",
      "keyEnvelopes": [ { "keyId": "k_dir", "wrappedKey": "base64-RSA-OAEP(AESkey)…" } ],
      "missingRecipients": [], "encryptionStatus": "ready" }
  ],
  "generatedAt": "2026-06-07T00:00:00.000Z"
}
```

Send your device id as the **`X-Device-Id`** header (see *Devices (machines)*
below). The manifest is then the **union** of: `all`-scoped items, `selected` items
targeting that device, the folders of the device's **server sync rules** (peer =
server, direction `to_device` or `both`) and items **sent to it** (queued or
delivered transfers). Without the header you get only `all`-scoped items.

**Manifest v2 (opt-in).** Add `X-Sync-Version: 2` (or `?v=2`) together with a known
`X-Device-Id` to also get the device's queued transfers, so it can ack them:

```json
{
  "directories": [ { "id": "DIR", …, "transferIds": ["tr_2"] } ],
  "files": [ { "versionGroupId": "G", …, "transferIds": ["tr_1"] } ],
  "transfers": [
    { "transferId": "tr_1", "fileId": "F2", "versionGroupId": "G", "directoryId": null,
      "fromDeviceId": null, "via": "server", "createdAt": "2026-10-19T08:00:00.000Z" }
  ],
  "generatedAt": "…"
}
```

Without the header the output is exactly the v1 shape above (no `transfers`, no
`transferIds`), so existing clients see no change.

#### Client-side encryption in the manifest

Everything a client needs to **encrypt on push and decrypt on pull** ships in the
manifest — no separate key fetch:

- **Directory `recipients`** — `[{ keyId, publicKeyPem }]`, the full set of public
  keys a file in that folder must be encrypted for. To write an encrypted file,
  encrypt the body once with a fresh AES-256-GCM key, wrap that key for **each**
  recipient, and upload the body with `clientEncrypted=true` + the `envelopes`
  (see *Upload a single file*). The `/sync/lib.js` SDK's `encryptFile(bytes,
  recipients)` does exactly this.
- **File `keyEnvelopes`** — `[{ keyId, wrappedKey }]`, the wrapped AES key(s) **you**
  (this API key's user) can unwrap with your RSA private key. Decrypt a downloaded
  body by trial-unwrapping these (`decryptFile(bytes, keyEnvelopes, privateKey)`).
  A **legacy** self-contained blob carries its wrapped key inside the body, so its
  `keyEnvelopes` is empty — decrypt it directly with the private key.
- **`missingRecipients`** / **`encryptionStatus`** — recipients that still lack an
  envelope, and `ready` vs `encrypting`. A client that can unwrap the AES key can
  backfill the missing envelopes (`POST /api/files/{id}/envelopes`) without
  re-uploading the body.

> **Change detection on encrypted files needs the plaintext.** `fileHash` is the
> hash of the **stored ciphertext**, and encryption is **non-deterministic** (a
> fresh AES key + IV each time), so re-encrypting the same plaintext yields a
> different `fileHash` — you can't tell "changed" from "re-encrypted" by comparing
> ciphertext hashes. The manifest deliberately exposes **no plaintext hash**: an
> `sha256(plaintext)` on the server would be a plaintext fingerprint that defeats
> the point of client-side encryption (it enables guess-and-confirm and cross-user
> dedup attacks). So to decide whether an encrypted file changed, a client
> **downloads and decrypts** the current version and compares plaintext (the
> `/sync/lib.js` SDK does this). Non-encrypted files compare on `fileHash` cheaply.

### Subscriptions (set the flag via API)

```
GET  /api/sync/subscriptions          → { "subscriptions": [ { resourceType, resourceId } ] }
POST /api/sync/subscriptions          → { "success": true, "enabled": true }
Authorization: Bearer sk_...
```

POST body: `{ "resourceType": "file"|"directory", "resourceId": "...", "enabled": true,
"scope": "all"|"selected", "deviceIds": ["..."] }`. `enabled`/`scope` default to
`true`/`all`; `deviceIds` apply only when `scope` is `selected`. For files,
`resourceId` is the file's `versionGroupId`.

## Devices (machines)

Native clients (desktop, mobile, CLI) register as **devices** — "machines" in the
UI — so folders can sync to all of them or a chosen subset, files can be sent to
them, and (machines v2) they can carry their own keys, folder sync rules and
offline transfer receipts. Everything below is additive: a client that only sends
`{ name, platform }` keeps getting exactly what it got before.

```
POST   /api/devices                 register            → { "id" }  (+ "machine" when machine fields were sent)
GET    /api/devices                 list                → { "devices": [ Machine ] }
GET    /api/devices/{id}            one machine         → { "device": Machine }
PATCH  /api/devices/{id}            update              → { "success": true }
DELETE /api/devices/{id}            revoke              → { "success": true }
Authorization: Bearer sk_...   (or a browser session)
```

### Registration and machine fields

```bash
curl -X POST https://sharefiles.eu/api/devices \
  -H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \
  -d '{ "name": "Pixel 9", "platform": "mobile",
        "model": "Pixel 9", "appVersion": "2.0.0",
        "capabilities": { "ble": true, "lan": true, "wifiDirect": false, "nearby": true, "multipeer": false, "usb": false },
        "signingPublicKey": "-----BEGIN PUBLIC KEY-----\n…P-256…\n-----END PUBLIC KEY-----\n",
        "encryptionPublicKey": "-----BEGIN PUBLIC KEY-----\n…RSA…\n-----END PUBLIC KEY-----\n" }'
# → { "id": "dev_1", "machine": { …Machine… } }
```

| Field | Notes |
| --- | --- |
| `name`, `platform` | as before; `platform` is `desktop` \| `mobile` \| `cli` \| `web` |
| `syncRoot` | the machine's local sync folder (default `~/Documents/sharefiles`), used for `clipboardPath` |
| `encryptionPublicKey` | RSA (≥ 2048 bit) **SPKI PEM**, the machine's RSA-OAEP key. Creates an encryption key named `Machine: <name>` and links it, so the machine can be an E2EE **recipient** with the existing envelope format |
| `encryptionKeyId` | …or link one of your existing encryption keys instead (`null` unlinks) |
| `signingPublicKey` | **ECDSA P-256 SPKI PEM**; verifies the offline receipts this machine signs (`null` clears) |
| `capabilities` | object of booleans: `ble`, `lan`, `wifiDirect`, `nearby`, `multipeer`, `usb` (unknown keys ignored) |
| `appVersion`, `model` | free text, max 200 chars |
| `requireEncryption` | boolean (PATCH): machine E2EE scope, see "Encryption scopes" |

All machine fields are optional on `POST` and `PATCH`. An invalid key answers `400`
and no device is created. `PATCH` still answers `{ "success": true }`.

`Machine` (as listed by `GET /api/devices`):

```json
{ "id": "dev_1", "name": "Pixel 9", "platform": "mobile", "lastSeenAt": "…", "createdAt": "…",
  "model": "Pixel 9", "appVersion": "2.0.0",
  "capabilities": { "ble": true, "lan": true, "nearby": true },
  "encryptionKeyId": "k_9", "encryptionPublicKey": "-----BEGIN PUBLIC KEY-----…",
  "encryptionKeyFingerprint": "3f2a:91c0:7b1e:…",
  "signingPublicKey": "-----BEGIN PUBLIC KEY-----…", "signingKeyFingerprint": "a0c4:…" }
```

A **fingerprint** is the first 16 bytes of `SHA-256(SPKI DER of the public key)`,
lowercase hex in 8 colon-separated groups of 4 — compare it on both phones when
pairing. Peers fetch each other's `signingPublicKey` here to verify receipts offline.

**Revoking** a machine (`DELETE`, or Settings → Machines) also deletes its sync
rules (as device or peer), cancels queued transfers to it, and removes its key as a
recipient so **future** versions are no longer encrypted for it. Envelopes already
written for past versions are kept: a key the machine may already have unwrapped
cannot be un-shared — re-upload (new AES key) anything that must become unreadable.

Manage machines in the dashboard under **Settings → Machines** (each has a detail
page at `/settings/machines/{id}`).

### Sync rules

A rule = "machine `deviceId` keeps folder `directoryId` in sync with `peerDeviceId`"
(`null` peer = the **server**). `direction` is from the machine's point of view:
`both` (default), `to_device` (peer → machine only), `from_device` (machine → peer
only). `encrypt: true` adds the machine's RSA key (and the peer's, for a machine
peer) as a recipient of the folder.

```
GET    /api/devices/{id}/rules                → { "rules": [ Rule ] }
POST   /api/devices/{id}/rules                { directoryId, peerDeviceId?, direction?, encrypt? } → 201 { rule, recipients? }
PATCH  /api/devices/{id}/rules/{ruleId}       { direction?, encrypt? }                             → { rule, recipients? }
DELETE /api/devices/{id}/rules/{ruleId}       → { "success": true }
```

`Rule`: `{ id, directoryId, directoryName, deviceId, peerDeviceId, direction, encrypt, createdAt }`.

- `GET` returns rules where the machine is the device **or** the peer, so both
  phones of a phone↔phone rule learn about it (for the P2P transports).
- The **server only relays null-peer rules**: `to_device`/`both` folders join the
  machine's manifest; `from_device` folders don't (the machine only uploads).
  Phone↔phone rules are stored and returned, never relayed.
- Posting the same device + peer + folder again updates the existing rule.
- Checks: the machine and peer must be your live machines (`404`); the folder must
  exist, not be the Inbox (`400`), be readable for `to_device` / writable for
  `both`/`from_device` (`404` / `403`), be inside a folder-scoped key's subtree, and
  belong to the same tenant as the machines (`403`).
- `recipients` (when `encrypt` is set): per machine
  `{ deviceId, result: { added: true, keyId } | { added: false, reason } }` with
  `reason` = `folder_not_encrypted` or `machine_has_no_key`.

`offline_syncs` (`POST /api/sync/subscriptions`, `all`/`selected`) keep working with
their old meaning; rules are an additional source for the same manifest.

### Transfers (send-to-machine outbox)

Every send to a machine is recorded as a **transfer** (`queued` → `delivered` |
`failed` | `cancelled`; `via` = `server` | `ble` | `lan` | `wifi_direct` | `nearby` |
`multipeer` | `usb`). `POST /api/send` with `target=machine` keeps its exact
response and also records one (`fromDeviceId` = the caller's `X-Device-Id` when it
is one of your machines, else `null` = web/API).

```
GET  /api/devices/{id}/transfers?status=queued&direction=in|out|all&limit=200  → { "transfers": [ Transfer ] }
POST /api/devices/{id}/transfers                { fileIds?, directoryIds?, fromDeviceId? } → 201 { transfers }
POST /api/devices/{id}/transfers/{tid}/ack      { status?: "delivered"|"failed", error?, via? } → { transfer }
```

`Transfer`: `{ id, fromDeviceId, toDeviceId, fileId, directoryId, versionGroupId, name,
status, via, error, createdAt, deliveredAt }`. While a transfer is queued or
delivered, its file/folder is part of the target machine's manifest (that is the
server relay). The **target machine** acks after the item has landed (manifest v2
lists the queued ones); acking is idempotent, other machines get `404`, a cancelled
transfer `409`. The web "Send to…" action uses the same outbox.

### Offline receipts

When one machine hands a file to another without the server (BLE, LAN, Wi-Fi
Direct, Nearby, Multipeer, USB), the **sender** signs a receipt; **either** machine
uploads it later:

```
POST /api/devices/{id}/receipts      [ Receipt, … ]   (or { "receipts": [...] }, max 500, max 1 MB)
GET  /api/devices/{id}/receipts?limit=100              → { "receipts": [...] }
```

```json
[{ "receiptId": "6f1c2a3b-4d5e-4f60-8a7b-9c0d1e2f3a4b",
   "fromDeviceId": "devPhoneA", "toDeviceId": "devPhoneB",
   "sha256": "b94d27b9…cde9", "size": 11, "name": "hello.txt", "relPath": "Notes/hello.txt",
   "ruleId": "rule123", "transferId": null, "via": "ble", "sentAt": "2026-10-19T08:30:00.000Z",
   "signature": "base64…" }]
```

- `{id}` (the uploader) must be the sender or the recipient (`not_a_party`).
- The signature is checked with the **sender's** `signingPublicKey` — the exact
  bytes are specified in [`synchelper.md` → Receipt signatures](./synchelper.md#receipt-signatures).
- Idempotent on `receiptId`: the second upload (e.g. by the other phone) answers
  `duplicate`; the same id with different content is `receipt_id_conflict`.
- A referenced `transferId` (a transfer to `toDeviceId`) becomes `delivered` with the
  receipt's `via`.
- `relPath` is relative to the rule's folder and includes the file name. When the
  rule folder already holds a current file with that `sha256` at that path the
  receipt is **linked** at once (dedupe by sha256 + path). Otherwise it stays
  `pending` until **either** machine uploads those bytes to that path (any upload
  route); then it is linked to that version. Without a `ruleId` only the file name
  is matched.
- **Never overwrites a newer server version**: if the server got a newer, different
  version at that path *after* `sentAt`, the newer version stays current and the
  uploaded bytes are stored next to it as `name (conflict from <machine> <date>).ext`
  — both kept; the receipt's `linkStatus` is `conflict`.
- "Any upload route" means every route that creates a file version, all through
  the same post-insert step: `PUT /api/files/by-path` (plain, `If-Match`,
  `If-None-Match`, versionless files), `POST /api/upload`, `POST /api/send`,
  `POST /api/cli/upload`, `POST /api/cli/send-code`, `POST /direct/{token}`
  (raw body and multipart, file and folder links), S3 `PutObject` and
  `CompleteMultipartUpload`, and copying a file in the web UI (a copy only ever
  links — it never replaces a version). With no pending receipt for the bytes it
  costs one indexed lookup.
- `If-Match: "<v>"` names the exact version being replaced, so it is treated as a
  deliberate overwrite: the receipt is linked, never turned into a conflict.
  `If-Match: *` and unconditional writes get the conflict check.

**Upload responses on a conflict.** The upload still succeeds (`200`), but the
bytes did NOT become the version at the requested path. The route answers with
its usual body plus `receiptConflict` (S3: response headers instead):

```json
{ "success": true, "id": "F9", "version": 1, "versionGroupId": "F9",
  "name": "notes (conflict from Pixel 2026-10-23).txt",
  "receiptConflict": { "fileId": "F9", "versionGroupId": "F9",
    "name": "notes (conflict from Pixel 2026-10-23).txt",
    "serverFileId": "F7", "serverVersion": 3, "receipts": ["r1"] } }
```

| Route | Where |
|---|---|
| `PUT /api/files/by-path` | body `receiptConflict` (+ `name`, `version: 1`); the `ETag` is the version still current at the path (`serverVersion`) |
| `POST /api/upload`, `POST /api/send`, `POST /api/cli/send-code` | body `receiptConflict` (`/api/upload` and `send-code` also return the stored `name`) |
| `POST /api/cli/upload` | `files[i].receiptConflict`, `files[i].name` = the stored name |
| `POST /direct/{token}` | file link: body `receiptConflict` (+ `name`), `ETag` = `serverVersion`; folder link: `uploaded[i].receiptConflict` |
| S3 `PutObject` / `CompleteMultipartUpload` | `X-Receipt-Conflict-File-Id`, `X-Receipt-Conflict-Name` (URL-encoded) |

A client that synced the path should keep the server version as the path's
content and treat `receiptConflict.fileId` as a new file (resolve via
`POST /api/receipts/{receiptId}/resolve`).

Response — always `200`, one result per receipt, in order:

```json
{ "results": [
  { "receiptId": "6f1c…", "status": "accepted", "linkStatus": "pending", "fileId": null },
  { "receiptId": "7a2d…", "status": "duplicate", "linkStatus": "linked", "fileId": "F12" },
  { "receiptId": "8b3e…", "status": "rejected", "error": "bad_signature" }
] }
```

Rejection codes: `invalid_receipt`, `invalid_receipt_id`, `invalid_device`,
`invalid_sha256`, `invalid_size`, `invalid_name`, `invalid_via`, `invalid_sent_at`,
`invalid_rule_id`, `invalid_transfer_id`, `missing_signature`, `receipt_id_conflict`,
`not_a_party`, `unknown_sender` (not one of your live machines — including other
users' machines), `unknown_recipient`, `sender_has_no_signing_key`, `bad_signature`,
`unknown_rule`, `unknown_transfer`.

`GET /api/devices/{id}/receipts` rows also carry `linkedAt`, `resolution`
(`kept_server` | `kept_phone` | `kept_both` | `null`) and `resolvedAt` (additive).

### Offline activity (all machines) and resolving conflicts

Every receipt across **all** the caller's machines — the web's Settings → Machines →
Offline activity and the app's reconcile view read this. User-scoped, and pinned to
the active tenant (session cookie) or the API key's tenant when multi-tenant is on.

```
GET  /api/receipts?cursor&limit&linkStatus&machineId&via&q&from&to&unresolved
POST /api/receipts/{receiptId}/resolve   { "action": "keep_server" | "keep_phone" | "keep_both" }
```

| Param | Meaning |
|---|---|
| `cursor` | Opaque; pass the previous page's `nextCursor` (`null` = last page) |
| `limit` | Page size, default **50**, max 200 |
| `linkStatus` | `pending` \| `linked` \| `conflict` |
| `machineId` | Receipts that machine sent **or** received |
| `via` | `ble` \| `lan` \| `wifi_direct` \| `nearby` \| `multipeer` \| `usb` (a hotspot link is `lan`) |
| `q` | Case-insensitive substring of the file name or `relPath` |
| `from`, `to` | `sentAt` range, ISO date/time (a bare `YYYY-MM-DD` `to` covers the whole day) |
| `unresolved` | `1` = only conflicts nobody resolved yet |

Newest `sentAt` first (ties by id). Bad values answer `400` with `invalid_link_status`,
`invalid_via`, `invalid_from`, `invalid_to`, `invalid_cursor` or `invalid_limit`.

```json
{ "receipts": [{
    "id": "r1", "receiptId": "6f1c…", "name": "doc.txt", "relPath": "Notes/doc.txt", "size": 11, "sha256": "…",
    "via": "ble",
    "from": { "id": "devPhoneA", "name": "Pixel", "platform": "android", "lastSeenAt": "…", "revoked": false },
    "to":   { "id": "devPhoneB", "name": "iPhone", "platform": "ios", "lastSeenAt": "…", "revoked": false },
    "sentAt": "…", "reportedAt": "…", "uploadedAt": "…",
    "linkStatus": "conflict", "fileId": "F9",
    "file":       { "id": "F9", "name": "doc (conflict from Pixel 2026-10-19).txt", "directoryId": "D1", "isCurrent": true, "deleted": false },
    "serverFile": { "id": "F7", "name": "doc.txt", "directoryId": "D1", "isCurrent": true, "deleted": false },
    "resolution": null, "resolvedAt": null, "needsAttention": true,
    "ruleId": "rule123", "transferId": null }],
  "nextCursor": "MTc5Mj…", "needsAttention": 1 }
```

- `pending`: no server bytes yet — they are still only on the machines and upload
  the next time the receiving machine syncs (`to.lastSeenAt` says when it last did).
- `linked`: `file` is the server file holding those bytes.
- `conflict`: `serverFile` is the newer server version that stayed current, `file` the
  phone's bytes kept beside it as `name (conflict from <machine> <date>).ext`.
  `needsAttention` = a conflict with no `resolution` yet (also the top-level count).
- `file` / `serverFile` are only returned for files the caller owns.

**Resolve** a conflict (`{receiptId}` = the client `receiptId`, or the row `id`):

| `action` | Effect | `resolution` |
|---|---|---|
| `keep_server` | Deletes the conflict copy (all its versions) | `kept_server` |
| `keep_phone` | The conflict copy becomes the **newest version** of the original file (renamed back to the original name); the server version stays in its version history | `kept_phone` |
| `keep_both` | Dismiss — both files stay | `kept_both` |

```json
{ "ok": true, "resolution": "kept_phone", "fileId": "F9", "receipts": 1, "alreadyResolved": false }
```

The receipt keeps `linkStatus: "conflict"` and records `resolution` + `resolvedAt`.
Every unresolved receipt pointing at the same conflict copy (e.g. one uploaded by
each phone) is resolved together. **Idempotent**: the same action again answers
`200` with `alreadyResolved: true` and changes nothing. Errors: `400 invalid_action`,
`404 not_found` (not yours / other tenant), `409 not_a_conflict`,
`409 already_resolved` (a different action was taken; body carries `resolution`),
`409 conflict_copy_missing` (`keep_phone` when the copy was deleted).

### Machine encryption recipients

A machine's RSA key can be a recipient of a **folder** (`directory_recipients`,
source `machine`) or of one **file** (all its future versions). Clients pick it up
through the existing manifest fields (`recipients`, `missingRecipients`) and backfill
envelopes with `POST /api/files/{id}/envelopes` — no new crypto format.

```
POST   /api/devices/{id}/recipients   { "directoryId": "…" } | { "fileId": "…" }  → { added: true, keyId } | 409 { added: false, reason }
DELETE /api/devices/{id}/recipients   { "directoryId": "…" } | { "fileId": "…" }  → { success: true }
```

You must own the folder/file. Rules with `encrypt: true` do the folder part
automatically. Removal (or revoking the machine) only affects **future** versions.

### Encryption scopes (per folder, per machine, per file)

End-to-end encryption is done by the **clients** (web, apps, CLI); the server only
decides **who** a file must be encrypted for and refuses plaintext where a scope
demands ciphertext. It never generates, unwraps or holds a content key. Format:
the existing PFB2 body + one `file_key_envelopes` row per recipient
(`encryptForRecipients` / `decryptAuto` in `src/lib/sync-core/crypto.ts`) — there is
no new crypto format, and legacy self-contained blobs still decrypt.

"Account keys" are your encryption keys that aren't a machine's own RSA key
(`devices.encryptionKeyId`).

| Scope | Switch | Recipients | Enforced |
| --- | --- | --- | --- |
| **Folder** | directory encryption (`encryptionEnabled`); "Sync with…" + Encrypt on a plaintext folder offers to turn it on (explicit confirm) | the folder's primary key + `directory_recipients`: your account key(s) + the machines of `encrypt: true` rules | every upload into the folder must be `clientEncrypted` (`403`), from any client |
| **Machine** | `requireEncryption` on a machine ("Require encryption for everything this machine syncs") | account keys + every live machine with a live rule on the folder (as device or peer) | plaintext uploads **from that machine** (`X-Device-Id`) into its rule folders and their subfolders → `409 encryption_required`. Other machines, the web and other folders are unaffected |
| **File** | "Encrypt this file for…" (web) — `file_recipients` for one logical file in a plaintext folder | the picked account keys / machine keys | the client re-uploads the file encrypted as a new version; earlier plaintext versions stay in the history |

**Folder scope from "Sync with…".** Turning encryption on only affects **new**
uploads. Existing plaintext files stay plaintext until they're re-uploaded. The
"Encrypt existing files" job runs **on a client**: the web (or app) downloads each
plaintext file, encrypts it for the folder's recipients and uploads it as a new
version. The server can already read plaintext files, but the encryption still has
to happen on the client so the server never holds the new content key. The REST
rule API (`POST /api/devices/{id}/rules` with `encrypt: true`) is unchanged and still
reports `folder_not_encrypted` per machine. Only the web dialog asks and enables.

**Machine scope.** `PATCH /api/devices/{id}` with `{ "requireEncryption": true }`
(boolean; `GET /api/devices` lists it on every machine). The 409 body is:

```json
{ "error": "encryption_required", "message": "This machine requires end-to-end encryption for this folder: …" }
```

It applies to `POST /api/upload`, `POST /api/send` (target directory/inbox) and
`PUT /api/files/by-path` (always plaintext). In that machine's sync manifest the
affected directories carry `"requiresEncryption": true` (the key is **absent**
otherwise, so older clients see no change) and their `recipients` already include the
machine-scope set. A client that sees `requiresEncryption` must encrypt new and
**changed** files there for `recipients`. Unchanged plaintext files stay as they
are.

**File scope.** An encrypted file with its own recipients carries
`"recipients": [ … ]` in the manifest (folder ∪ file recipients). Clients encrypt
new versions for that set instead of the folder's.

**Who to encrypt for — recipients API:**

```
GET /api/devices/{id|me}/recipients?directoryId=…   (or ?fileId=…)
→ { "directoryId": "…", "encrypted": false, "requiresEncryption": true,
    "recipients": [ { "keyId": "k1", "publicKeyPem": "…", "kind": "account", "name": "My key" },
                    { "keyId": "k2", "publicKeyPem": "…", "kind": "machine", "deviceId": "dev_1", "name": "Pixel" } ] }
```

`recipients: []` = plaintext is fine. You need read access to the folder/file
(`404` otherwise). `400` without `directoryId`/`fileId`. The mobile app caches
these answers for offline phone↔phone sync. `GET /api/devices` advertises
`encryption-scopes` in `features`.

**Receipts and dedupe with encryption.** A receipt's `sha256` is the hash of the
**stored body**. For an encrypted file that is the ciphertext, which is also what the
server stores as `fileHash`. Phones therefore move encrypted files as ciphertext +
envelopes (P2P protocol `OfferFile.enc`) and upload exactly those bytes. Encrypting
the same plaintext twice gives different ciphertext, so re-encrypting on upload
would break the receipt link. See `apps/mobile/docs/e2ee.md`.

**Revocation.** Revoking a machine tombstones its rules and its
`directory_recipients` / `file_recipients` rows, so **future** versions aren't
encrypted for it. On its next contact the machine's own
`GET /api/devices/{id}` answers `410 { "error": "machine_revoked" }` (tombstoned
row) or `404 { "error": "Device not found" }` (row deleted). `features` lists
`machine-revocation`. The app then wipes its machine private key and its decrypted
copies and shows "This phone was removed". Envelopes already written for past
versions can't be un-shared.

## Notifications & webhooks

Watch a **file** or a **directory** (optionally its whole subtree) and get notified
when it is **read**, **updated**, or **deleted**. Notifications go out over one or
more channels: **webhook** (an HTTP POST to your endpoint — the primary,
fully-API-driven channel), **email**, and **push** (web/mobile/desktop; queued to
an in-app feed today, see below). You may attach **0..n rules** to any one
resource — e.g. two webhooks plus an email on the same folder. Rules are managed
by the resource **owner** (a browser session or an API key that owns the resource;
a folder-scoped key may only attach rules inside its subtree).

A **rule** has these fields:

| Field | Meaning |
|-------|---------|
| `resourceType` | `"file"` or `"directory"` |
| `resourceId` | a directory id, or a file id / `versionGroupId` (normalized to the version group so the rule survives new versions) |
| `events` | non-empty subset of `["read","update","delete"]` (default `["update","delete"]`) |
| `includeSubdirs` | directories only: also fire for events on nested subfolders/files (default `true`) |
| `channel` | `"webhook"`, `"email"`, or `"push"` |
| `webhookUrl` | webhook channel: your `https://` (or `http://`) endpoint |
| `webhookSecret` | optional HMAC-SHA256 signing secret (see verification below) |
| `webhookHeaders` | optional JSON object of extra request headers (e.g. an `Authorization`) |
| `emailTo` | email channel: recipient (defaults to the owner's account email) |
| `pushDeviceId` | push channel: target one device (defaults to all the user's push targets) |
| `includeActor` | include **who** did it (user, IP, geo location, user-agent) in the payload — for read-auditing sensitive material. Default `false` |
| `label`, `active` | optional human label; toggle without deleting |

### Endpoints

```
GET    /api/notifications/rules                 ?resourceType=&resourceId=&channel=   → { rules: [...] }
POST   /api/notifications/rules                 (JSON body, see below)                → { rule }   (201)
GET    /api/notifications/rules/{ruleId}                                              → { rule }
PATCH  /api/notifications/rules/{ruleId}        (any mutable field; resource is fixed)→ { rule }
DELETE /api/notifications/rules/{ruleId}                                              → { success: true }
POST   /api/notifications/rules/{ruleId}/test   (fire a synthetic event now)          → { delivery: { status, responseStatus, error } }
GET    /api/notifications/deliveries            ?status=&channel=&ruleId=&unread=1    → { deliveries: [...] }   (log + in-app feed)
PATCH  /api/notifications/deliveries            { "ids": [...] } | { "all": true }    → mark feed items read
GET    /api/notifications/push                                                        → { subscriptions: [...] }
POST   /api/notifications/push                  { kind, endpoint, p256dh?, auth?, deviceId? } → { id }
DELETE /api/notifications/push                  ?id= | ?endpoint=                     → { success: true }
Authorization: Bearer sk_...     (or a logged-in browser session)
```

`GET /api/notifications/rules` never returns `webhookSecret`; it returns
`hasWebhookSecret: true|false` instead.

### Create a webhook (the common case)

```bash
# Notify a webhook whenever anything in a folder (and its subfolders) changes or is deleted:
curl -X POST "https://sharefiles.eu/api/notifications/rules" \
  -H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \
  -d '{
        "resourceType": "directory",
        "resourceId": "dir_abc123",
        "channel": "webhook",
        "events": ["update","delete"],
        "includeSubdirs": true,
        "webhookUrl": "https://example.com/hooks/sharefiles",
        "webhookSecret": "whsec_your_random_secret"
      }'
# → 201 { "rule": { "id": "rule_...", ... } }

# Verify it works before relying on it:
curl -X POST "https://sharefiles.eu/api/notifications/rules/rule_.../test" -H "Authorization: Bearer sk_..."
# → { "delivery": { "status": "sent", "responseStatus": 200, "error": null } }
```

### Webhook delivery

Each delivery is an HTTP `POST` with a JSON body and these headers:

```
Content-Type: application/json
User-Agent: sharefiles-webhooks/1.0
X-Sharefiles-Event: read|update|delete
X-Sharefiles-Delivery: <delivery id>
X-Sharefiles-Timestamp: <unix seconds>
X-Sharefiles-Signature: t=<ts>,v1=<hex hmac>        # only when a webhookSecret is set
```

Body:

```json
{
  "id": "del_...",
  "event": "update",
  "occurredAt": "2026-07-04T09:30:00.000Z",
  "resource": { "type": "file", "id": "<versionGroupId>", "fileId": "<file id>", "name": "report.pdf" },
  "rule": { "id": "rule_...", "label": null },
  "actor": {                                          // present only when includeActor = true
    "userId": "usr_... | null",
    "email": "someone@example.com | null",
    "ip": "203.0.113.7",
    "location": "Berlin, BE, DE",
    "userAgent": "..."
  }
}
```

**Verify the signature** by recomputing `HMAC_SHA256(secret, "<timestamp>.<raw body>")`
and comparing to the `v1=` value (constant-time). Reject stale timestamps to prevent
replay. A `2xx` response marks the delivery **sent**; any other status (or a
timeout — 10s) is retried with capped exponential backoff (~30s, 5m, 30m, 2h; 5
attempts) before it is marked **failed**. Inspect outcomes via
`GET /api/notifications/deliveries`.

### Events & recursion

- **update** fires on upload / new version (including writes through writable
  direct links). **delete** fires on file, whole-version-group, and directory
  deletes (a bulk directory delete emits one directory-level event, not one per
  file). **read** fires on downloads — it is opt-in and can be noisy; it is meant
  for **auditing access to sensitive files** (turn on `includeActor` to capture
  who/where).
- A rule on a file matches that file. A rule on a directory matches events on files
  **directly in it** always; events **deeper in the subtree** match only when
  `includeSubdirs: true`.

### Push (in-app feed for now)

The `push` channel is fully modelled (register web/native targets via
`POST /api/notifications/push`), but wire-level Web Push / FCM / APNs delivery is
not enabled yet. Until it is, each push notification is recorded as an **in-app
feed item** — poll `GET /api/notifications/deliveries?channel=push&unread=1` and
acknowledge with `PATCH /api/notifications/deliveries`. Webhooks and email deliver
for real today.

## Send a file to a target

Push a file into sharefiles, choosing where it lands. Powers the desktop drag-to-send and
the mobile "Share with…" flows.

```
POST /api/send
Authorization: Bearer sk_...
Content-Type: multipart/form-data
```

| Field             | Notes                                                                 |
| ----------------- | --------------------------------------------------------------------- |
| `file`            | the file (required)                                                    |
| `target`          | `machine` \| `inbox` \| `directory`                                    |
| `deviceId`        | target machine (when `target=machine`)                                |
| `directoryId`     | target directory (when `target=directory`)                            |
| `clientEncrypted` | `true` if the bytes are already client-encrypted (encrypted dirs)     |
| `encryptionKeyId` | the key used, when `clientEncrypted`                                   |

- **machine** — uploads and targets sync to **only that device**; the file lands in the
  device's sync root. Response includes `clipboardPath` (e.g. `~/sharefiles/report.pdf`).
  The send is also recorded as a queued **transfer** the machine can ack (see
  *Devices (machines) → Transfers*); the response is unchanged.
- **inbox** — drops into your **Inbox** (online-only, never synced).
- **directory** — a normal upload into that directory (follows its sync settings).

```bash
curl -X POST https://sharefiles.eu/api/send \
  -H "Authorization: Bearer sk_your_api_key_here" \
  -F "target=machine" -F "deviceId=DEVICE_ID" -F "file=@report.pdf"
# → { "ok": true, "fileId": "...", "versionGroupId": "...", "clipboardPath": "~/sharefiles/report.pdf" }
```

To list possible targets: `GET /api/devices` (machines) and `GET /api/directories`
(`{ directories: [{ id, name, parentId, isInbox }] }`). The **Inbox** is a per-user,
online-only directory shown in the dashboard under **Inbox**; files there can be moved
into directories. It cannot be flagged for offline sync.

### Pulling & pushing bytes

Download a synced file with `GET /api/files/{currentFileId}`. Push a local change
back with `POST /api/upload` (it accepts an **API key** too) — same as a normal
upload; a same-name upload becomes a new version. Encryption is client-side: send
`clientEncrypted=true` and `encryptionKeyId` when the folder is encrypted.

### Sync SDK

A dependency-free client is served at **`/sync/lib.js`** (works in browsers and
Node). It auto-detects available storage (Node FS, OPFS, or IndexedDB) and mirrors
everything you've flagged for sync:

```js
import { ShareFiles, hasFS } from 'https://sharefiles.eu/sync/lib.js';
const x = new ShareFiles({ key: 'sk_your_api_key' });
if (hasFS()) x.setLocation('./files');   // Node: a folder; browser: an OPFS subdir
await x.sync();                           // pulls flagged files down, pushes local changes up
```

Full SDK reference: [`/docs/llm/synchelper.md`](./synchelper.md).

---

## Account & stats

### Health check

```
GET /api/v1/health
Authorization: Bearer sk_...
```

```json
{
  "status": "healthy",
  "timestamp": "2026-06-07T00:00:00.000Z",
  "uptime": 3600,
  "version": "1.0.0",
  "message": "API is running successfully"
}
```

For an admin's key the answer also has `promoteQueue` — the background copies to
managed storage (Bunny), for monitoring (sharefiles-watch graphs it):

```json
"promoteQueue": {
  "waiting": 12, "fastWaiting": 10, "bulkWaiting": 2, "inFlight": 20,
  "oldestQueuedAgeSec": 3, "backlog": 32,
  "lastMinute": { "queued": 380, "overflowed": 0, "deduped": 4, "promoted": 381,
                  "promotedFast": 360, "promotedBulk": 20, "promotedSweep": 1, "failed": 0, "bytes": 52428800 },
  "total": { "...": "same counters since start" },
  "local": { "localFiles": 41200, "localBytes": 5100000000, "pendingFiles": 3, "pendingBytes": 90000,
             "oldestPendingAt": 1760190000000, "oldestPendingAgeSec": 840, "at": 1760190840000, "ageSec": 60 },
  "alarms": []
}
```

`local` is the local-blob sweeper's last census (null until its first run); `alarms`
lists the conditions currently logged as `[sharefiles] ERROR promote backlog`.

### Usage statistics

```
GET /api/v1/stats
Authorization: Bearer sk_...
```

Returns account info plus API usage counters.

```json
{
  "user": {
    "id": "USER_ID",
    "email": "you@example.com",
    "name": "Your Name",
    "role": "user",
    "createdAt": "2026-01-01T00:00:00.000Z"
  },
  "apiStats": {
    "totalApiKeys": 3,
    "requestsToday": 42,
    "requestsThisWeek": 310,
    "requestsThisMonth": 1280,
    "errorRate": "1.20%",
    "errorCount": 15
  },
  "meta": {
    "timestamp": "2026-06-07T00:00:00.000Z",
    "apiKey": "key name"
  }
}
```

### List users

```
GET /api/v1/users?limit=10&offset=0
Authorization: Bearer sk_...
```

Admins receive a paginated list of all users; regular users receive only their
own record.

| Param    | Default | Notes                |
| -------- | ------- | -------------------- |
| `limit`  | 10      | Max 100              |
| `offset` | 0       | Pagination offset    |

```json
{
  "users": [
    {
      "id": "USER_ID",
      "email": "you@example.com",
      "name": "Your Name",
      "role": "user",
      "emailVerified": "2026-01-01T00:00:00.000Z",
      "createdAt": "2026-01-01T00:00:00.000Z"
    }
  ],
  "meta": { "limit": 10, "offset": 0, "total": 1, "apiKey": "key name" }
}
```

---

## CLI authentication flow

These endpoints let an external tool obtain an API key through a browser
authorization step (used by the `sharefiles` CLI).

1. **Start** — `POST /api/cli/auth/init` → `{ token, url, expiresIn }`.
   Open `url` in a browser for the user to authorize (token valid 10 minutes).
2. **Poll** — `GET /api/cli/auth/poll?token=...` →
   `{ "status": "pending" }`, `{ "status": "expired" }`, or
   `{ "status": "complete", "apiKey": "sk_..." }` (returned once).
3. **Complete** — the browser page calls `POST /api/cli/auth/complete`
   (session-authenticated) with `{ token }`, which mints the key.

---

## Client logs (error reports from the app)

The mobile app reports its own failures here — boot steps that failed or timed
out and unhandled errors — so problems on phones nobody can attach a debugger to
still reach us. **No auth required** (sign-in itself may be what fails); if the
request carries an API key or a browser session, the report is linked to that
user.

```
POST /api/client-logs
Content-Type: application/json

[
  {
    "app": "mobile",
    "version": "1.2.0 (12345678)",
    "platform": "android",
    "osVersion": "14",
    "webview": "Chrome/129.0.6668.100",
    "step": "creds",
    "message": "Timed out after 8000 ms",
    "stack": "Error: Timed out…",
    "at": "2026-10-23T09:12:44.120Z"
  }
]
```

- The body is an array (or `{ "entries": [...] }`) of **1–50** entries, **max 64 KB**
  per request. Only `message` is required; long fields are truncated
  (message 4000, stack 16000 characters).
- `step` is the boot step that failed (`status`, `creds`, `removed`, `profile`, …)
  or empty for errors after start-up.
- Answers `202 { "accepted": 2 }`. `400` invalid body, `413` too large,
  `429` rate limited (`Retry-After` header): 30 requests/min per IP and 60/min
  per API key.
- Reports are kept **14 days**. Admins browse them at **/admin/client-logs**
  (filter by version and platform). With multi-tenant on, only superadmins see
  them (reports are not tied to a tenant).
- The app queues reports locally and retries with backoff when offline.

---

## QR key transfer (one-shot encrypted transfers)

Short-lived, one-shot blobs for moving keys between a user's own devices by QR
code ("qkt1"). ShareFiles hosts this for itself **and for ProjectCentral and
Chatter**. Clients encrypt before uploading (AES-256-GCM, key only in the QR);
the server only ever sees ciphertext. Protocol, kinds and security notes:
**[docs/QR-KEY-TRANSFER.md](../QR-KEY-TRANSFER.md)** — use that text in other apps.

```
POST /api/transfer
Content-Type: application/octet-stream
X-Transfer-TTL: 120              # seconds, 10–600 (default 120)
X-Transfer-Max-Downloads: 1      # 1–5 (default 1)
Authorization: Bearer sk_…       # optional — higher rate limits

<raw bytes, max 256 KB>

→ 201 { "id": "…",
        "downloadUrl": "https://sharefiles.eu/api/transfer/Xk3…(22 chars)",
        "statusUrl":   "https://sharefiles.eu/api/transfer/status/Q9a…(22 chars)",
        "expiresAt":   "2026-10-23T09:14:44.000Z" }
```

- **No auth required.** Anonymous: 10 uploads/min and 100/day per IP; with an
  API key or browser session: 60/min and 1000/day per user. `429` + `Retry-After`.
- `400` bad TTL / max-downloads / empty body, `401` a Bearer key that doesn't
  authenticate, `413` body over 256 KB, `503` store full (retry shortly).
- **CORS `*`** for POST/GET/OPTIONS — web apps on other origins can upload and
  download directly.

```
GET <downloadUrl>      → 200 bytes (Cache-Control: no-store), counted
                       → 410 { "error": "Transfer is gone (already downloaded or expired)" }
GET <statusUrl>        → 200 { "state": "waiting" | "downloaded" | "expired", "expiresAt": "…" }
GET <statusUrl>  (Accept: text/event-stream, or ?sse=1)
                       → event: state / data: {"state":"waiting",…} … closes after it leaves "waiting"
```

- The download URL works once (or `X-Transfer-Max-Downloads` times), then `410`;
  also `410` after expiry. `HEAD` answers 405 so link previews don't burn it.
- The status URL has its **own token** — knowing it doesn't let you download.
- Bytes are kept **in memory only** (never on disk, the mirror/CDN or in
  backups) and hard-deleted on download or expiry; a sweeper runs every 30 s.

Helpers for ShareFiles' own QR flows:

```
POST /api/qr-signin/mint       (browser session)  { "device": "mobile" | "desktop" | "browser", "label"? }
                               → { "apiKey": "sk_…", "baseUrl", "email", "name": "Mobile app via QR" }
POST /api/qr-signin/exchange   { "apiKey": "sk_…" }  → sets the session cookie, { "redirect": "/dashboard" }
                               (browser keys only, once, within 10 minutes; 410 otherwise)
GET  /api/cli/whoami           (API key or session) → { "userId", "email" }
```

---

## Machine-readable spec

A full OpenAPI 3.1 description is available (no auth required):

```
GET /api/openapi.json
```

---

## Notes for self-hosters

The following endpoints exist for operating a sharefiles.eu cluster and are not
part of the user-facing API: `/api/replication/*` (node replication, requires
`replicate` permission), `/api/sync` (database sync, shared-secret auth), and
`/api/webhooks/stripe` (billing). See `docs/REPLICATION.md` for details.
