defter 0.2.0

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
# defter API

defter serves a subset of the [Zotero Web API v3](https://www.zotero.org/support/dev/web_api/v3/start)
— what a stock Zotero client needs to sync, plus the read/query API a CLI
uses — and the parts of Zotero's local API that `commonmeta sync` relies on.

Every Zotero endpoint is served twice: at the root (the Zotero client's
`extensions.zotero.api.url`) and under `/api` (where commonmeta expects
Zotero's local API, `http://localhost:23119/api`). `<lib>` is `users/<id>`;
`users/0` is accepted as an alias for the configured user, as in the local API.

The sync contract follows zhost's `server/SPEC.md`, which records what live
testing against Zotero 9 showed; the differences are noted below.

## Authentication

Data endpoints need a key in `Zotero-API-Key` (or `Authorization: Bearer`);
an unknown key is `403`. Read-only keys get `403` on `POST`/`PUT`/`PATCH`/`DELETE`.
Keys are configured, never minted (see the README).

| Endpoint | Auth | Notes |
|---|---|---|
| `GET /api/` | — | `Zotero-Server-ID` (stable per installation) and `X-Defter-Version` headers; `/` carries them too |
| `GET /keys/current` | key | `{key, userID, username, access}`; `access.user.write` reflects the key |
| `DELETE /keys/current` | key | `204`; Zotero sends it when the account is unlinked. Nothing is revoked |
| `POST /keys/sessions` | — | `201 {sessionToken, loginURL}` |
| `GET /keys/sessions/<token>` | — | `{status: "pending"}` until approved, then once `{status: "completed", apiKey, userID, username}` |
| `DELETE /keys/sessions/<token>` | — | cancel |
| `GET /login?session=<token>` | — | server-rendered consent page with an Approve form, no JavaScript needed; never authorizes by itself. `400` without a session, `404` when unknown or expired. Not cacheable or frameable, strict CSP, no referrer |
| `POST /login` (form `session`) | — | approve; rejects a foreign `Origin`, and checks the SSO identity with `--login-authorized-user` (`403` page); `303` to `/login?approved=1` |

`/login`, `/ui/*` and the web UI belong behind SSO or a private network.

## Versions and preconditions

A single library version is bumped by every write and returned in
`Last-Modified-Version`; each object carries the version it last changed at.

- `?since=<v>` reads return objects with a greater version, always `200`
  (never `304`: the client's version reads loop on it). Only `GET /settings`
  answers `304` to `If-Modified-Since-Version`.
- Multi-object writes: `If-Unmodified-Since-Version` is the **library**
  version → `412` when stale. It is optional on `POST`/`PATCH` (as in the Web
  API; commonmeta sends none when creating); without it, an object whose
  `version` property differs from the stored one fails on its own with `412`
  in `failed`. Deletes require it (`428` without).
- Single-object writes (`PUT`/`PATCH`/`DELETE …/<key>`): the precondition is the
  **object's** version, from the header or the body's `version` (`428` when
  neither is given). `PUT` with version `0` creates.

Write bodies may be gzip-compressed (`Content-Encoding: gzip`).

## Library data

| Endpoint | Notes |
|---|---|
| `GET <lib>/{items,collections,searches}?format=versions&since=<v>` | `{key: version}` |
| `GET <lib>/{items,collections,searches}?{item,collection,search}Key=a,b` | `[{key, version, meta?, data}]` |
| `POST`/`PATCH <lib>/{items,collections,searches}` | ≤ 50 objects; merge semantics; `{successful, success, unchanged, failed}` keyed by batch index; `successful` holds full objects |
| `DELETE <lib>/{items,collections,searches}?<kind>Key=a,b` | `204` |
| `GET`/`PUT`/`PATCH`/`DELETE <lib>/{items,collections,searches}/<key>` | single object; `GET` carries the object's version in `Last-Modified-Version`; writes `204` |
| `GET <lib>/items/<key>/children` | attachments and notes (a listing, see below) |
| `GET <lib>/items/top`, `/items/trash` | listings; `items/top` also answers the `format=versions` and `itemKey` reads |
| `GET <lib>/collections/<key>/items[/top]` | listings |
| `GET <lib>/tags` | `[{tag, meta: {type, numItems}}]`, trash excluded |
| `DELETE <lib>/tags?tag=a \|\| b` | strips the tags from every item and logs them |
| `GET`/`POST`/`DELETE <lib>/settings` | `{key: {value, version}}`; delete by `settingKey=a,b` |
| `GET <lib>/deleted?since=<v>` | `{collections, searches, items, settings, tags}` |
| `GET <lib>/fulltext?since=<v>` | `{itemKey: version}` |
| `GET <lib>/items/<key>/fulltext` | `{content, indexedChars, …}` with the row's own version |
| `POST <lib>/fulltext` | batch of `{key, content, …}` |
| `GET <lib>/groups` | `{}`; groups are not supported |
| `GET /schema` | Zotero's item schema, fetched from `api.zotero.org/schema`, stored, revalidated daily |

Object keys are 8 characters from `23456789ABCDEFGHIJKLMNPQRSTUVWXYZ`;
others fail with `400`. A keyless object gets a fresh key. Item data is
stored verbatim; attachments and annotations come back with `linkMode` /
`annotationType` first. Top-level items carry `meta.numChildren`.

### Listings

Item listings accept:

| Parameter | Meaning |
|---|---|
| `q`, `qmode` | case-insensitive substring; `titleCreatorYear` (default) searches title, date and creators; `everything` searches every field and tag (DOI, `extra`, URL, …) and stored full text |
| `itemType` | `book \|\| journalArticle`, `-attachment` |
| `tag` | repeat for AND, `\|\|` for OR |
| `sort`, `direction` | `dateModified` (default), `dateAdded`, `title`, `creator`, `date`, `itemType`; `asc`/`desc` |
| `limit`, `start` | page of ≤ 100 (default 25); `Total-Results` and `Link: <…>; rel="next"` on the public URL |
| `includeTrashed` | include items in the trash |
| `format=keys` | every matching key, unpaged, as plain text |

## Files

Attachment bytes live in object storage (`files/<item key>`); the database
keeps their md5, size and mtime.

1. **Authorize** — `POST <lib>/items/<key>/file`, form `md5, filename,
   filesize, mtime` (ms), with `If-None-Match: *` (new) or `If-Match: <md5>`
   (replace); `428` without either. Answers `{exists: 1}` when the same file is
   stored, `412` when another is, `413` past `--max-upload-mb`, otherwise
   `{url, uploadKey, contentType, prefix: "", suffix: ""}` with an absolute `url`.
2. **Upload** — `POST <url>` with the raw bytes (no key: the token in the URL
   is the capability). Streamed to `pending/<token>` while hashing; a md5 or
   size mismatch is `400` and the bytes are dropped. `201`.
3. **Register** — `POST <lib>/items/<key>/file`, form `upload=<uploadKey>`:
   moves the file to `files/<key>`, records it and stamps `md5`/`mtime` into the
   item. `204`.

**Download** — `GET <lib>/items/<key>/file` answers `302` with
`Zotero-File-MD5`, `Zotero-File-Modification-Time` and `Location`: a pre-signed
bucket URL valid for `--presign-ttl` seconds, or, without signing (local
storage, `--proxy-downloads`), a `/downloads/<token>` link valid for five
minutes that streams the file through defter.

Deleting an attachment item deletes its file.

## Web UI

| Endpoint | Notes |
|---|---|
| `GET /` | dashboard; with `HX-Request: true` (htmx's 30-second poll) only its status block. `Vary: HX-Request` |
| `GET /assets/<name>` | the stylesheet and script (htmx), named by content hash and cached for a year |
| `GET /ui/status` | the dashboard's data as JSON, for monitoring |

Pages are rendered on the server from the askama templates in `templates/`
and work without JavaScript. They are sent with a Content-Security-Policy
that allows scripts and styles only from defter (plus a per-response nonce
for the inline theme script) and forbids framing; htmx is configured not to
use `eval` or inline styles, so it runs under that policy.

## Out of scope

Group libraries, the streaming (websocket) server, binary-diff uploads
(`PATCH …/file`), and styled bibliographies.