# ChronoScan Server — API guide for ChronoScan What a station (ChronoScan Desktop, a worker, a tool) does against a ChronoScan Server. The server keeps names, counters, locks and sequence numbers in clear and **everything else encrypted with the space key, which it never receives**. Base URL: the server the user connected to (several servers per PC are fine). Engine: PocketBase 0.40, so the standard records API and realtime (SSE) work for reads, with the token below. - `GET /api/v1/info` (no auth) → `{name, product, version, url, registration, license_proof, limits, time}`. - Every error is JSON: `{"status": 4xx, "message": "..."}`; some carry more (`lock`, `seq`, `quota_bytes`). ## 1. Connect: registration and tokens | Step | Call | |---|---| | The user opens `/register/`, types the license and the station name, gets a **connection code** `css1:///` (also shown as `XXXX-XXXX` and as a QR). 10 minutes, once. | (the page calls `POST /api/v1/enrol {license, name}`) | | ChronoScan redeems it | `POST /api/v1/enrol/redeem {code, version?}` → `{station_id, station_secret, account: {id, email, name, license}, server: {name, url, version}}`. `code` may be the whole `css1://…` string or just the 8 characters. `404` unknown, `410` expired or used. | | Store `station_id` + `station_secret` (DPAPI). Never the token. | | | Token (12 h) | `POST /api/v1/station/token {station_id, station_secret, version?}` → `{token, expires_in, account, station}`. `401` wrong secret, `403` revoked (forget the credentials, register again). | | Every call | headers `Authorization: ` and `X-Station: ` (the second says which station holds a lock or wrote an operation; writes without it get `428`). On `401`, get a new token once and retry. | | Who am I | `GET /api/v1/me` → `{account, station, stations, spaces: [{id, name, role, …}], server}`. | | Unlink | `POST /api/v1/stations/{id}/revoke` (own account's stations). | A station that ships the license secret may skip the page: `POST /api/v1/station/register {license, name, proof, kind?, version?}` with `proof = MD5(secret + license + name)` upper-case hex, exactly as ChronoScan Mobile does; `503` when the server has no `LICENSE_SECRET`. All stations of one license share one **account**. ## 2. Keys (client side) A **space key** is 32 random bytes made by the client when a space is created; shown once as `csk1-` + lower-case base32 without padding (57 characters) and as a QR, handed to the other stations by the administrator. Derive with **HKDF-SHA256** (no salt, 32 bytes each): | Derived key | `info` | Use | |---|---|---| | access key | `chronoscan-server/access` | Hex, lower case. Sent as `access` when joining. `access_hash = SHA-256(that 64-character hex string)` (hex) is what the server stores and what `POST /spaces` takes. | | data key | `chronoscan-server/data` | AES-256-GCM for every payload and file. | | id key | `chronoscan-server/id` | `blob_id = hex(HMAC-SHA256(id_key, plaintext))`. | **Envelope** (bytes): `0x01`, 12-byte random nonce, ciphertext, 16-byte GCM tag. Sent as base64 in JSON (`payload`, `ops`) or raw as a file (`blobs`). Additional authenticated data (UTF-8): | What | AAD | |---|---| | operations of a batch | `/ops/` | | snapshot of a batch | `/snap/` | | a file (page, attachment) | `/blob/` | | a job package | `/job/` | | an item | `/item//` | Keep the space key in DPAPI with the connection. Offer to print or export it when a space is created: the server cannot recover it. ## 3. Spaces ``` POST /api/v1/spaces {name, access_hash, note?} -> space (you are admin) 409 key already used POST /api/v1/spaces/join {access} -> space (member; same account as an admin -> admin) 404 wrong key GET /api/v1/spaces -> {spaces: [...]} GET /api/v1/spaces/{id} -> space + members [{account, name, email, role}] + usage PATCH /api/v1/spaces/{id} admin: {name?, note?, role?: {account, role: admin|member}, remove?: account} POST /api/v1/spaces/{id}/leave ``` A space: `{id, name, role, enabled, quota_bytes, bytes_used, key_epoch, members, note, created, updated}`. ## 4. Jobs (shared configurations) The package (config.xml, document types, scripts, AI config) is a zip the station makes, encrypted and uploaded as a blob (§6, AAD `/job/`), then published: ``` GET /api/v1/spaces/{id}/jobs -> {jobs: [{name, revision, blob, size, by, enabled, note, updated}]} POST /api/v1/spaces/{id}/jobs admin: {name, blob, note?} -> job (revision + 1) GET /api/v1/spaces/{id}/jobs/{name}/revisions -> {revisions: [{revision, blob, size, by, note, created}]} POST /api/v1/spaces/{id}/jobs/{name}/rollback admin: {revision} -> job (a new revision with that blob) PATCH /api/v1/spaces/{id}/jobs/{name} admin: {enabled?, note?} ``` On opening a job, compare `revision` with the local copy and download the blob when it changed. ## 5. Batches, locks, operations, snapshots ``` POST /api/v1/spaces/{id}/batches {name, job?, note?} -> batch, locked by you for 60 minutes GET /api/v1/spaces/{id}/batches ?status=open|exported&job=&since=&q=&page=&per= -> {items, has_more, page, now} GET /api/v1/batches/{id} PATCH /api/v1/batches/{id} lock holder or admin: {name?, job?, note?, docs?, pages?, bytes?, exported?: true|false} DELETE /api/v1/batches/{id} lock holder, or the creator while unlocked, or admin -> status deleted POST /api/v1/batches/{id}/lock {minutes?: 1..10080, force?} -> batch 409 {lock} held by another station POST /api/v1/batches/{id}/unlock POST /api/v1/batches/{id}/ops lock holder: {ops: [base64 envelope, …] (1..500), expect_seq?} -> {first_seq, last_seq, seq, count} 409 {seq} when expect_seq is stale, 409 {lock} without the lock, 413 over quota GET /api/v1/batches/{id}/ops ?since=&limit= -> {ops: [{seq, station, size, payload, created}], seq, snapshot_seq, snapshot_blob, has_more} POST /api/v1/batches/{id}/snapshot lock holder: {blob, seq, refs?: [blob ids], docs?, pages?, bytes?} -> batch ``` A batch: `{id, space, name, job, status, seq, snapshot_blob, snapshot_seq, refs, docs, pages, bytes, lock: {station, account, name, until} | null, by, exported_at, note, created, updated}`. How ChronoScan uses it: 1. **Open**: `lock`, then download `snapshot_blob` (if any) and `GET ops?since=snapshot_seq`; decrypt and apply the operations to the local copy; `has_more` → repeat with the last `seq`. 2. **Work**: every `ChronoSqliteStore` operation (UpsertDocument, UpsertPage, SetField, SetValState, Move, Trash, annotations…) goes into a local journal; a background sender groups them (a bulk = one JSON array = one envelope, up to 1 MB of ciphertext, up to 500 envelopes per call) and appends them with `expect_seq` = the last seq it knows. Pages are uploaded first as blobs (§6) with `batch=`, and the operation carries the `blob_id`. 3. **Snapshot** every N operations and on close: encrypt the batch database, upload it as a blob, `POST snapshot` with its id, the `seq` it covers and `refs` = every blob id the batch still uses (the server deletes unreferenced blobs after 24 hours; blobs uploaded with `batch=` are referenced automatically until the next snapshot replaces the list). 4. **Close**: `unlock`. Renew a long lock by calling `lock` again. A lock that expires lets another station take the batch; `force: true` (an admin, or the same account) takes it at once. 5. **Export**: `PATCH {exported: true}` when the batch left ChronoScan. The server never merges: with the lock gone, a stale `expect_seq` answers 409 and the station must fetch and apply the new operations before resending. ## 6. Blobs (encrypted files) ``` POST /api/v1/spaces/{id}/blobs/check {ids: [blob id, …] (≤ 2000)} -> {have, missing} POST /api/v1/spaces/{id}/blobs multipart/form-data: blob_id, file (the envelope), size?, batch? -> blob 409 already stored (fine), 413 over quota or over MAX_BLOB_MB GET /api/v1/spaces/{id}/blobs/{blob_id} 302 to the protected file (short-lived file token for your account) GET /api/v1/spaces/{id}/blobs/{blob_id}?direct=1 -> {url, size, blob_id} ``` Before uploading, `check` the ids; a page uploaded once is never sent again, in any batch of the space. Downloads support HTTP Range (resume) on the file URL. ## 7. Items (shared lists, IntelliTags) ``` PUT /api/v1/spaces/{id}/items/{collection}/{key} {payload, if_version?} -> item 409 when older than if_version GET /api/v1/spaces/{id}/items/{collection}/{key} DELETE /api/v1/spaces/{id}/items/{collection}/{key} -> tombstone GET /api/v1/spaces/{id}/sync ?cursor=&collection=&limit= -> {items: [{collection, key, payload, version, deleted, updated}], cursor, has_more} ``` `collection` is a slug (`lists`, `intellitags`, …), `key` any string up to 255 characters (e.g. `Invoices__Supplier`). Keep the cursor; `deleted: true` means remove it locally. Last write wins. ## 8. Realtime PocketBase SSE with the station token: `GET /api/realtime` → event `PB_CONNECT {clientId}`; then `POST /api/realtime {clientId, subscriptions: ["batches", "ops", "jobs", "items"]}` with `Authorization`. Members receive create / update / delete events of their spaces' records (`e.record.space`), so the batch list, locks, new operations (`ops` with `payload`), job revisions and items refresh without polling. Filters: `batches?filter=space='…'`, `ops?filter=batch='…'`. The standard records API (`GET /api/collections/batches/records?filter=…`) is read-only for members with the same visibility. ## 9. Limits and rules - A payload or an envelope in `ops`: ≤ 1 MB of ciphertext (1,500,000 base64 characters). A file: ≤ `MAX_BLOB_MB`. - Quota per space (`quota_bytes`, default `SPACE_QUOTA_GB`): files plus operations. `413` beyond it; the administrator raises it in the console. - Operations under a snapshot are deleted after `OPS_KEEP_DAYS` (7); unreferenced blobs after 24 hours; a deleted batch loses its operations at once and its row after 30 days. - Tokens last 12 h. Revoking a station stops new tokens; a token already issued works until it expires. - Rate limits: PocketBase defaults (about 300 requests per 10 s per IP); group operations instead of sending one by one.