# Miniserver — Integration Guide Personal backend for small apps. One **user account** owns data from many **apps** and many **devices**. Any app, script, service or AI agent can use it by following this guide. - **Base URL:** `https://cs-miniserver.chronoscan.org` (the same server is also `https://cs-miniserver.swedencentral.cloudapp.azure.com`, which keeps working) - Clients must treat it as a *default*. Read `MINISERVER_URL` from the environment or app settings first, so the same app can run against another Miniserver. - **This guide:** `https://cs-miniserver.chronoscan.org/API.md` - **User web (sign up, devices, approve links):** `https://cs-miniserver.chronoscan.org/` - **Engine:** PocketBase 0.40 behind Caddy (HTTPS). All standard PocketBase REST/realtime APIs work, plus the custom `/api/ms/*` endpoints below. - **Server info (no auth):** `GET /api/ms/info` --- ## 1. Concepts | Concept | What it is | |---|---| | **User** | A person's account (`users` collection). Every record belongs to exactly one user. Users sign up on the web page or through the API. | | **Device** | Any client acting for a user: phone, desktop app, CLI, server, agent. It holds a `device_id` + `device_secret` pair, and the user can revoke it from the web. | | **App id** | A short slug that you choose for your app, for example `claudemm`, `notes` or `home.sensors`. It must match `^[a-z0-9][a-z0-9._-]{0,63}$`. Every item and file carries one, so data from different apps stays separate. | | **Item** | A JSON document addressed by `(app, collection, key)`. `collection` is a slug you choose inside your app (for example `sessions` or `settings`), and `key` is any string up to 255 chars. The JSON can be up to 5 MB. | | **File** | A binary up to 100 MB addressed by `(app, path)`. Files are protected: downloading one needs a file token. | > **Isolation:** users are fully isolated from each other. Apps are **not** isolated from each other inside one user account: a device linked to a user can read that user's data from every app. The `app` field is for organising data, not for enforcing access. --- ## 2. Choose how your client authenticates | Situation | Use | |---|---| | Desktop, mobile or TV app, or any client without a keyboard-friendly login | **A. QR / code pairing** (recommended) | | App with its own email + password login screen | **B. Password login** | | Script, cron job, server, CI or AI agent | **C. API credentials** created by the user on the web dashboard | All three end with a **token**. Send it on every request: ``` Authorization: (a "Bearer " value also works) ``` ### A. QR / code pairing (device flow) 1. **Start** (no auth): ```http POST /api/ms/link/start Content-Type: application/json {"name": "José's laptop", "platform": "windows", "app": "claudemm"} ``` Response: ```json { "code": "ABCD-EFGH", "secret": "…48 chars… (keep private, never display)", "link_url": "https://…/link/?code=ABCD-EFGH", "qr_payload": "https://…/link/?code=ABCD-EFGH", "qr_page": "https://…/qr/?code=ABCD-EFGH", "expires_at": "2026-09-25T12:51:40.712Z", "poll_interval_seconds": 2 } ``` 2. **Show** the user a QR code of `qr_payload` together with the human-readable `code`. - If your app can't draw QR codes, open `qr_page` in a browser. It renders the QR for you. - The user can also go to `/link` on any signed-in browser and type the code by hand. 3. **The user approves.** They scan the QR with their phone, sign in or create an account on the page, and press **Approve**. 4. **Poll** every ~2 s until approved (no auth): ```http POST /api/ms/link/poll {"code": "ABCD-EFGH", "secret": ""} ``` | Status | Meaning | What to do | |---|---|---| | `202` `{"status":"pending"}` | Not approved yet | Keep polling | | `200` `{"status":"approved", …}` | Done | Read the body below | | `410` `expired` / `consumed` | Code is dead | Start again | | `404` | Wrong code or secret | Start again | The approved body: ```json { "status": "approved", "device_id": "abc123…", "device_secret": "", "token": "", "expires_in": 43200, "record": { "id": "…", "email": "…", "name": "…" }, "device": { "id": "…", "name": "…", "app": "claudemm", "revoked": false } } ``` 5. **Store** `device_id` and `device_secret` securely (OS keychain, DPAPI, Keystore). They are the device's long-term credentials. Then continue with **Token refresh** below. ### B. Password login ```http POST /api/collections/users/records (sign up; skip if the account exists) {"email": "a@b.com", "password": "min 8 chars", "passwordConfirm": "…", "name": "Optional"} POST /api/collections/users/auth-with-password {"identity": "a@b.com", "password": "…"} → {"token": "…", "record": {…}} ``` - Tokens from password login last 7 days. - You can renew one with `POST /api/collections/users/auth-refresh` (send the current token). - **Recommended:** right after login, call `POST /api/ms/devices/register` (see C). Then store device credentials instead of the password. ### C. API credentials (scripts / services / agents) The user opens the web dashboard, goes to **Create API credentials**, and receives: ``` MINISERVER_URL=https://cs-miniserver.swedencentral.cloudapp.azure.com MINISERVER_DEVICE_ID=… MINISERVER_DEVICE_SECRET=… ``` The same thing through the API, with a user token: ```http POST /api/ms/devices/register Authorization: {"name": "Backup script", "app": "backup", "platform": "linux"} → {"device_id": "…", "device_secret": "…", "device": {…}} ``` The secret is only ever returned once. ### Token refresh (device credentials → token) ```http POST /api/ms/device/token {"device_id": "…", "device_secret": "…"} → {"token": "…", "expires_in": 43200, "record": {user}, "device": {…}} ``` - Call it at startup, and again whenever a request returns **401**. - The token lasts 12 h and **cannot** be renewed with auth-refresh. That is deliberate: it keeps revocation effective. - If you get **403** `This device has been revoked`, the user revoked this device. Wipe the stored credentials and ask the user to pair again. --- ## 3. Storing data — items ### Convenience endpoints (recommended) All of these need a user token. `{app}`, `{collection}` and `{key}` go in the URL path, URL-encoded. | Method & path | Does | |---|---| | `PUT /api/ms/items/{app}/{collection}/{key}` body `{"data": , "device": ""?}` | Creates or replaces the item and returns the full record. | | `GET /api/ms/items/{app}/{collection}/{key}` | Returns the record, or `404` if it doesn't exist or was deleted. | | `DELETE /api/ms/items/{app}/{collection}/{key}` | Soft delete: sets `deleted=true` and `data=null`. Returns `204`. | | `GET /api/ms/sync/{app}?cursor=&collection=&limit=` | Incremental changes. See **Sync**. | An item record looks like this: ```json { "id": "…", "user": "…", "app": "claudemm", "collection": "sessions", "key": "2026-09-25-abc", "data": { "anything": "you want" }, "deleted": false, "device": "…", "created": "2026-09-25 12:00:00.000Z", "updated": "2026-09-25 12:05:00.000Z" } ``` - Pass `device` if you want to know later which device wrote an item. - **Conflict policy:** last write wins. If you need stronger rules, keep a version counter or timestamp inside `data` and resolve on the client. ### Sync (multi-device) 1. On the first run, call `GET /api/ms/sync/{app}` with no cursor. 2. Apply every item you receive: - `deleted=true` → remove it locally. - otherwise → upsert it locally. 3. Save the returned `cursor`. 4. While `has_more` is `true`, call again with `?cursor=`. 5. Later syncs start from the saved cursor, so you only receive what changed since then, including tombstones. 6. `limit` defaults to 200, max 1000. Add `collection=` to sync one collection only. ```json { "items": [ {…}, {…} ], "cursor": "2026-09-25 12:05:00.000Z|abc123", "has_more": false } ``` For push instead of polling, subscribe to realtime (section 5) and run a sync whenever an event arrives. ### Standard PocketBase records API (for queries) The `items` collection is also available through the normal API. Owner-only rules are enforced on the server. ```http GET /api/collections/items/records?filter=(app='notes' && collection='todo' && deleted=false)&sort=-updated&perPage=100 GET /api/collections/items/records/{id} POST /api/collections/items/records {"app":"notes","collection":"todo","key":"t1","data":{…}} (user is set automatically) PATCH /api/collections/items/records/{id} {"data": {…}} DELETE /api/collections/items/records/{id} (hard delete: other devices won't see it in /sync) ``` - Filters can reach into JSON: `data.status = 'open'`. - `(user, app, collection, key)` is unique. If you POST a key that already exists, you get `400`. Use the `PUT` convenience endpoint to upsert. --- ## 4. Files Upload with multipart/form-data. `user` is filled in automatically. ```http POST /api/collections/files/records Authorization: Content-Type: multipart/form-data app = claudemm path = exports/2026-09-25.zip (unique per user+app) file = meta = {"size": 1234} (optional JSON) device = (optional) ``` Other operations: - **List:** `GET /api/collections/files/records?filter=(app='claudemm')` - **Replace:** `PATCH /api/collections/files/records/{id}` (multipart) - **Delete:** `DELETE …/{id}` **Download** (files are protected): 1. `POST /api/files/token` with `Authorization` → `{"token": "…"}`. This file token is valid for about 2 minutes. 2. `GET /api/files/files/{recordId}/{record.file}?token=` --- ## 5. Realtime (optional) With the JS SDK (`npm i pocketbase`, or `https://cdn.jsdelivr.net/npm/pocketbase@0.28.1/dist/pocketbase.umd.js`): ```js pb.authStore.save(token, userRecord) await pb.collection("items").subscribe("*", (e) => { // e.action: "create" | "update" | "delete"; e.record: the item }, { filter: "app = 'claudemm'" }) ``` Raw protocol (any language): 1. Open SSE `GET /api/realtime`. The first event is `PB_CONNECT` with `{clientId}`. 2. `POST /api/realtime` with `{"clientId": "…", "subscriptions": ["items", "files"]}` and your `Authorization` header. 3. You only receive events for your own records. --- ## 6. Code samples ### Python (stdlib only) ```python import json, os, time, urllib.request, urllib.error, urllib.parse class Miniserver: def __init__(self, base, device_id=None, device_secret=None): self.base, self.device_id, self.device_secret, self.token = base.rstrip("/"), device_id, device_secret, None def _req(self, method, path, body=None, auth=True, retry=True): h = {"Content-Type": "application/json"} if auth: if not self.token: self.refresh() h["Authorization"] = self.token req = urllib.request.Request(self.base + path, method=method, headers=h, data=json.dumps(body).encode() if body is not None else None) try: with urllib.request.urlopen(req, timeout=30) as r: raw = r.read() return r.status, (json.loads(raw) if raw else None) except urllib.error.HTTPError as e: if e.code == 401 and auth and retry: self.refresh() return self._req(method, path, body, auth, retry=False) raise def refresh(self): _, r = self._req("POST", "/api/ms/device/token", {"device_id": self.device_id, "device_secret": self.device_secret}, auth=False) self.token = r["token"] def pair(self, name, app, platform="python", show=print): _, s = self._req("POST", "/api/ms/link/start", {"name": name, "app": app, "platform": platform}, auth=False) show(f"Open {s['qr_page']} or scan the QR / enter code {s['code']} at {self.base}/link") while True: time.sleep(s["poll_interval_seconds"]) try: st, r = self._req("POST", "/api/ms/link/poll", {"code": s["code"], "secret": s["secret"]}, auth=False) except urllib.error.HTTPError as e: raise RuntimeError(f"pairing failed ({e.code})") if st == 200: self.device_id, self.device_secret, self.token = r["device_id"], r["device_secret"], r["token"] return r def put(self, app, col, key, data): q = lambda s: urllib.parse.quote(s, safe="") return self._req("PUT", f"/api/ms/items/{q(app)}/{q(col)}/{q(key)}", {"data": data, "device": self.device_id})[1] def get(self, app, col, key): q = lambda s: urllib.parse.quote(s, safe="") try: return self._req("GET", f"/api/ms/items/{q(app)}/{q(col)}/{q(key)}")[1]["data"] except urllib.error.HTTPError as e: if e.code == 404: return None raise def sync(self, app, cursor=""): while True: _, r = self._req("GET", f"/api/ms/sync/{app}?cursor={urllib.parse.quote(cursor)}") yield from r["items"] cursor = r["cursor"] if not r["has_more"]: self.last_cursor = cursor return # ms = Miniserver(os.environ["MINISERVER_URL"], os.environ["MINISERVER_DEVICE_ID"], os.environ["MINISERVER_DEVICE_SECRET"]) # ms.put("notes", "todo", "t1", {"text": "buy milk"}); print(ms.get("notes", "todo", "t1")) ``` ### JavaScript / TypeScript (fetch) ```js const BASE = "https://cs-miniserver.swedencentral.cloudapp.azure.com" export async function pair({ name, app, platform, onCode }) { const s = await (await fetch(`${BASE}/api/ms/link/start`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, app, platform }), })).json() onCode(s) // render QR of s.qr_payload + show s.code for (;;) { await new Promise((r) => setTimeout(r, s.poll_interval_seconds * 1000)) const res = await fetch(`${BASE}/api/ms/link/poll`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: s.code, secret: s.secret }), }) if (res.status === 200) return res.json() // { device_id, device_secret, token, record, device } if (res.status !== 202) throw new Error(`pairing failed: ${res.status}`) } } export function client({ deviceId, deviceSecret }) { let token = null async function refresh() { const r = await fetch(`${BASE}/api/ms/device/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_id: deviceId, device_secret: deviceSecret }), }) if (r.status === 403) throw new Error("device revoked") if (!r.ok) throw new Error(`auth failed: ${r.status}`) token = (await r.json()).token } async function api(method, path, body, retry = true) { if (!token) await refresh() const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json", Authorization: token }, body: body === undefined ? undefined : JSON.stringify(body), }) if (r.status === 401 && retry) { token = null; return api(method, path, body, false) } if (r.status === 404) return null if (!r.ok) throw new Error(`${method} ${path}: ${r.status} ${await r.text()}`) return r.status === 204 ? null : r.json() } const p = (a, c, k) => `/api/ms/items/${encodeURIComponent(a)}/${encodeURIComponent(c)}/${encodeURIComponent(k)}` return { put: (a, c, k, data) => api("PUT", p(a, c, k), { data, device: deviceId }), get: async (a, c, k) => (await api("GET", p(a, c, k)))?.data ?? null, del: (a, c, k) => api("DELETE", p(a, c, k)), sync: (a, cursor = "") => api("GET", `/api/ms/sync/${a}?cursor=${encodeURIComponent(cursor)}`), } } ``` ### C# (.NET 8) ```csharp using System.Net.Http.Json; using System.Text.Json.Nodes; public sealed class Miniserver(string baseUrl, string deviceId, string deviceSecret) { readonly HttpClient _http = new() { BaseAddress = new Uri(baseUrl) }; string? _token; async Task RefreshAsync() { var r = await _http.PostAsJsonAsync("/api/ms/device/token", new { device_id = deviceId, device_secret = deviceSecret }); r.EnsureSuccessStatusCode(); _token = (await r.Content.ReadFromJsonAsync())!["token"]!.GetValue(); } async Task SendAsync(HttpMethod m, string path, object? body = null, bool retry = true) { if (_token is null) await RefreshAsync(); var req = new HttpRequestMessage(m, path) { Content = body is null ? null : JsonContent.Create(body) }; req.Headers.TryAddWithoutValidation("Authorization", _token); var res = await _http.SendAsync(req); if (res.StatusCode == System.Net.HttpStatusCode.Unauthorized && retry) { _token = null; return await SendAsync(m, path, body, false); } return res; } static string P(string a, string c, string k) => $"/api/ms/items/{Uri.EscapeDataString(a)}/{Uri.EscapeDataString(c)}/{Uri.EscapeDataString(k)}"; public async Task PutAsync(string app, string col, string key, object data) => (await SendAsync(HttpMethod.Put, P(app, col, key), new { data, device = deviceId })).EnsureSuccessStatusCode(); public async Task GetAsync(string app, string col, string key) { var r = await SendAsync(HttpMethod.Get, P(app, col, key)); if (r.StatusCode == System.Net.HttpStatusCode.NotFound) return null; r.EnsureSuccessStatusCode(); return (await r.Content.ReadFromJsonAsync())!["data"]; } } ``` ### curl ```bash TOKEN=$(curl -s -X POST "$MINISERVER_URL/api/ms/device/token" -H 'Content-Type: application/json' \ -d "{\"device_id\":\"$MINISERVER_DEVICE_ID\",\"device_secret\":\"$MINISERVER_DEVICE_SECRET\"}" | jq -r .token) curl -s -X PUT "$MINISERVER_URL/api/ms/items/notes/todo/t1" -H "Authorization: $TOKEN" \ -H 'Content-Type: application/json' -d '{"data":{"text":"buy milk"}}' curl -s "$MINISERVER_URL/api/ms/sync/notes" -H "Authorization: $TOKEN" ``` --- ## 7. Endpoint reference | Method | Path | Auth | Purpose | |---|---|---|---| | GET | `/api/ms/info` | – | Server name, URLs, token TTL, `role` and `standby` URL (section 11) | | POST | `/api/ms/link/start` | – | Start QR pairing → code, secret, qr_payload | | POST | `/api/ms/link/poll` | – | `202` pending · `200` credentials + token · `410` expired/used | | GET | `/api/ms/link/info?code=` | user | Details of a pending pairing (used by /link page) | | POST | `/api/ms/link/approve` | user | Approve pairing `{code, name?}` | | POST | `/api/ms/devices/register` | user | Create device credentials directly | | POST | `/api/ms/device/token` | – | device_id + secret → 12 h user token | | GET/PUT/DELETE | `/api/ms/items/{app}/{collection}/{key}` | user | Get / upsert / soft-delete an item | | GET | `/api/ms/sync/{app}?cursor=&collection=&limit=` | user | Changes since cursor (incl. tombstones) | | * | `/api/collections/{items,files,devices}/records…` | user | Standard PocketBase CRUD (owner-only) | | POST | `/api/files/token` | user | Short-lived token for protected file downloads | | GET | `/api/realtime` | user | SSE realtime | | * | `/v1/*`, `/api/ce/*` | key | ChronoEye trial (OCR/PDF service), documented separately in [/chronoeye/API.md](/chronoeye/API.md) | | * | `/api/cs/*` | user / inbox token | ChronoScan mobile inbox (phones → ChronoScan jobs), see section 10 | Collections your client can use: - `items`: see above. - `files`: see above. - `devices`: you can list your own devices, rename them, set `revoked`, or delete them. You cannot create devices through this collection. Error bodies look like `{"status": 400, "message": "…", "data": {field: {code, message}}}`. ## 8. Limits & rules - Item `data` ≤ 5 MB. Files ≤ 100 MB. Request body ≤ 100 MB. - Pairing codes expire after 10 minutes and can only be used once. - There are per-IP rate limits (PocketBase defaults), so don't poll faster than 1–2 s. - Device tokens last 12 h. Revoking or deleting a device stops new tokens right away; tokens that were already issued keep working until they expire. - The server backs up its data every day at 04:00 (Europe/Madrid) and keeps the last 7 backups. - New accounts get a verification email from "MiniServer Admin" that names the app the sign-up form was on (the page's path: `/chronoeye/`, `/cslink/`, `/cs/`, `/burnapp/`). `POST /api/collections/users/request-password-reset` `{email}` sends a reset link; both links open `/auth/` on this server. Apps can send users to `/auth/` for "forgot password". - The user is emailed whenever a device is linked (QR) or registered (`/api/ms/devices/register`) on their account: device name, platform, app and IP, with a link to the dashboard to revoke it. Give devices a recognisable `name` (for example the computer name) so that email makes sense. ## 9. Checklist for a new app / agent 1. Pick an **app id** slug (for example `myapp`) and your **collections** (for example `settings` and `notes`). 2. Pick an auth flow (section 2): QR pairing for user devices, API credentials for services. 3. Store `device_id` and `device_secret` securely. Never store passwords, and never ship the superuser account. 4. Wrap every call so that a 401 triggers one `POST /api/ms/device/token` and a retry. A 403 on token exchange means you must pair again. 5. Write with `PUT /api/ms/items/{app}/{collection}/{key}` and read with `GET` or `/sync`. 6. For multi-device apps, keep the sync cursor locally and treat `deleted=true` as a delete. 7. If the app has a public page, add it to `pb_public/apps.json` so it appears in the catalog on the home page. 8. Show the user which account they're linked to (`record.email`), and give them an "Unlink" action that forgets the stored credentials. 9. Try the standby server when the primary does not answer (section 11): it is a two-line change in the HTTP wrapper. ## 10. ChronoScan mobile inbox (`/api/cs/*`, app id `chronoscan`) Phones photograph documents and drop them into a **mobile inbox** of a ChronoScan job. The ChronoScan PC runs the *ChronoScan Mobile Inbox* service, which polls its queue, imports the pages into a batch, processes them and posts the result back. Full design: `ChronoScanAndroid/PROTOCOL.md` in the ChronoScan tree. Three credentials, three audiences: | Who | Credential | Gets | |---|---|---| | PC service, ChronoScan desktop | user token (API credentials, section 2C) | stations, inboxes (token included), queue, results | | Phone | **inbox token** (in the invite link `…/cs/join/#.`) | inbox info, submit, list its own uploads | | Anyone with a result link | **result token** (`…/cs/r/#.`) | one result + its previews, may post a review | Phones never sign in: the invite is the credential, so the ChronoScan owner can hand inboxes to people who have no Miniserver account. Rotating the token (`POST /api/cs/inboxes/{id}/token`) invalidates every phone at once. | Method | Path | Auth | Purpose | |---|---|---|---| | POST | `/api/cs/station/register` | – | `{license, proof, name, version}`: a PC with a valid ChronoScan license (format + check characters, `proof = MD5(CS_LICENSE_SECRET + license + name)`) gets device credentials under the account `lic-@chronoscan.app`, created on first use. No sign-up, no QR. | | POST | `/api/cs/station/heartbeat` | user | `{station?, device, name, version, info}` every 30 s → `{station, pending, reviewed, plan}`. A station is *online* for 90 s after a beat. `plan` = the account's plan (see *Plans* below). | | GET | `/api/cs/station/pending?station=` | user | Queue: `uploaded`, `reviewed`, and `processing` claims older than 30 min. Pages are downloaded with a file token: `/api/files/cs_submissions/{id}/{file}?token=`. | | POST | `/api/cs/submission/{id}/claim` | user | → `processing` | | POST | `/api/cs/submission/{id}/result` | user | multipart `status=done|error`, `error`, `result=`, `previews=` | | POST | `/api/cs/submission/{id}/applied` | user | review applied to the batch → `applied` | | POST | `/api/cs/submission/{id}/purge` | user | the station holds the pages: `pages` and `previews` are deleted from the server, the record (status, result summary) stays; `purged` = when. The TWAndroid bridge calls it right after downloading. | | POST | `/api/cs/inboxes` | user | `{station, name, description, accepts, config, job_id, job_name, mode: append|per_document, batch_name, batch_seed, max_pages, kind: job|twain}` → inbox with `token` and `links` (`join_url`, `qr_payload`, `whatsapp_url`, `share_text`). `kind: "twain"` (TWAndroid bridge, `ChronoScan\Android\TWAndroid`): no ChronoScan job, `job_id` defaults to `twain`; the pages wait in the PC's scanner tray for any TWAIN application. `want_location: true` makes the app attach the phone's GPS position to each document. | | GET | `/api/cs/inboxes?station=`, `/api/cs/inboxes/{id}` | user | owner view (token + links) | | POST | `/api/cs/inboxes/{id}/token` | user | rotate the invite token | | GET | `/api/cs/inbox?id=&t=` | inbox token | name, description, accepts, job, mode, station `{name, online}`, `want_location`, `e2e_key` (see *End to end* below) | | POST | `/api/cs/submit` | inbox token (`X-Cs-Inbox`, `X-Cs-Token`) | multipart `pages=` (JPEG/PNG/WebP/PDF, ≤ `max_pages`, 20 MB each), `sender`, `device`, `phone` (model), `note`, `location` (`lat,lon,accuracy_m`, when the inbox has `want_location`) → `201` submission with `result_url` | | GET | `/api/cs/submissions?id=&t=&device=` | inbox token | that phone's uploads, newest first (`status`, `summary`, `result_url`) | | GET | `/api/cs/submission/{id}?rt=` or `?t=&device=` | result token, or inbox token + the sender's install id | full record: `result`, `review`, `pages`, `previews`, `inbox_info`. With the inbox token, `device` must be the `sender_device` of the submission: phones of the same inbox cannot read each other's uploads. | | GET | `/api/cs/submission/{id}/file/{name}?rt=|t=&device=` | same | streams a page or preview | | POST | `/api/cs/submission/{id}/review?rt=` | result or inbox token | `{reviewer, fields:{name:value}, lines:[{table,row,accepted}], note}` → `reviewed` | | GET | `/api/cs/status` | – | `{online, stations_online}` for the catalog dot | Submission states: `uploaded → processing → done | error`, then `done → reviewed → applied`. Uploads are deleted after `CS_KEEP_DAYS` (default 30); the batch on the PC is the record. **Not collected:** a submission still `uploaded` (no station picked it up) expires after its owner's plan `keep_days` — `CS_FREE_KEEP_DAYS` (3) on Free, `CS_PRO_KEEP_DAYS` (15) on the paid plans. An hourly job deletes its pages and previews and sets `status: error`, `error: "Not collected: the receiving PC did not pick this up within N days, so it was deleted. Please send it again."`, `purged`, and `result.summary.twain: "expired"`; the phone shows that message. (`POST /api/cs/admin/expire {user, now}`, superuser only, runs it for one account as if it were `now`: tests.) ### Plans The ChronoTwain editions (2026-10-07). Limits apply per account (the inbox's owner) to documents from phones, the only thing that goes through the server. Every byte a phone sends is downloaded once by the PC (Azure egress), so no plan is unlimited: | Plan | Period | Documents | Volume | Pages per document | How | |---|---|---|---|---|---| | `free` (ChronoTwain Free) | calendar month | `CS_FREE_DOCS` (100) | `CS_FREE_MB` (250 MB) | `CS_FREE_PAGES` (10) | default | | `maintenance` (ChronoTwain Professional) | maintenance year | – | `CS_PRO_GB` (10 GB) | the inbox's `max_pages` (≤ 100) | included with ChronoScan Professional while its maintenance is active: accounts `lic-…@chronoscan.app` (unless `CS_LICENSE_INCLUDED=0`); `cs_plan_until` = the end of maintenance; `cs_plan = "free"` takes it away | | `pro` | maintenance year | – | same | same | set by hand: `users.cs_plan = "pro"`, until `users.cs_plan_until` (empty = no end) | | `credits` (ChronoScan Hub) | none | – | the license's credits | the inbox's `max_pages` (≤ 100) | with `CS_HUB_URL` + `CS_HUB_APIKEY` set: an account with a ChronoScan license (`lic-…` account or a station registered by license) whose license the Hub reports `Active` or `Expired` (maintenance). `Invalid` / `Disabled` = free. It takes precedence over `maintenance` / `pro`; a Hub that does not answer leaves those as they are | **Credits (2026-10-09, `pb_hooks/cs_hub.js`).** The order: a host without a license has the month's free allowance (`CS_FREE_DOCS` documents, `CS_FREE_MB`); a licensed host pays with its credits from the first byte; once the credits run out, the free allowance still applies, counted over the documents that were not charged (`users.cs_month_free_docs` / `cs_month_free_bytes`), and past it the upload is refused (`CS_CREDITS_FREE_TIER=0`: no allowance for licensed hosts). The host's ChronoScan license pays at the Hub's rate (`transfer.creditsPerUnit` per `bytesPerUnit`, 0.13 credits per 100 KB at launch; read from `GetLicenseStatus`, never fixed here). Senders never pay. Before accepting an upload the server asks `CheckCredits` (cached 2 minutes per license, 1 when there are none) and answers **402** "…run out of ChronoScan credits…" when there are none. The charge is made when the PC has collected the document (`POST /api/cs/submission/{id}/purge`): a row in `cs_charges` (`reference` = the submission id, `bytes` = `cs_submissions.bytes`, `client` = mobile | chronolite, `device` = the station, `account` = the email), sent to `ConsumeCreditsForTransfer` in batches of up to 200 by the `cs_hub_flush` cron every 2 minutes; the Hub is idempotent by (CA, Reference), so retries never charge twice. Rows end `charged` (with `charged` credits and the Hub's `result`), `failed` (LICENSE, INVALID_INPUT, NO_CREDITS: an `hub.charge_failed` event) or stay `pending` with a later `next_try` (Hub down: 2, 4, 8… up to 60 minutes; RATE_LIMIT: an hour). `noCredits: true` in the answer drops the license's caches, so its next upload is refused (`hub.no_credits` event). Documents that expire uncollected are never charged. Superuser: `POST /api/cs/admin/hub/flush` → `{configured, pending, sent, charged, failed, retry, credits}`; `GET /api/cs/admin/hub/license?ca=…` → the Hub's license status. A submission accepted on credits is flagged `cs_submissions.charge`; only those are charged at purge. The heartbeat's `plan` for this plan: `plan: "credits"`, `ca`, `free_docs`, `free_bytes` (the allowance), `free_docs_this_month`, `free_bytes_this_month` (the uncharged use), `until` (maintenance end), `credits: {available, total, spent, bytes_per_unit, credits_per_unit, credits_per_mb, mb_available, price_configured, maintenance_active, product, usage_url, buy_url}`. - The **maintenance year** is the 12 months that end on an anniversary of `cs_plan_until` (a maintenance that ends in three years counts this year's window); without an end date, the calendar year. - `POST /api/cs/submit` answers **413** past the plan's pages ("Up to 10 pages per document on this inbox's free plan… ChronoTwain Professional allows up to 100.") and **402** once the period's documents or volume are used ("…its 100 free documents of this month…", "…its 250 MB of this month on the free plan…", "…its 10 GB of this maintenance year. It starts again on 2027-03-15."). The phone app shows that message on the document. Volume = the sum of the uploaded page files. - `GET /api/cs/inbox` (and the owner views) report `max_pages` already capped by the plan, so the app stops the camera at 10 pages on a free account. - The heartbeat's `plan`: `{plan: free|pro|maintenance, period: month|year, period_start, period_end (year only), docs_limit, pages_limit, bytes_limit (0 = none), keep_days, docs_this_period, bytes_this_period, until, upgrade_url}`, plus `docs_this_month`, `bytes_this_month`, `month` for older apps. `upgrade_url` = `CS_UPGRADE_URL` (default the editions on www.chronoscan.org/chronotwain). ChronoTwain shows it on the Account page. - Counters on the user, not a count of `cs_submissions` rows (PCs delete the rows they are done with): `cs_month` + `cs_month_docs` + `cs_month_bytes` (UTC month, always), and for the paid plans `cs_year` (the first day of the counted period) + `cs_year_docs` + `cs_year_bytes`. Accounts cannot edit the `cs_*` fields (`users.updateRule`). Web pages: `/cs/` (download), `/cs/join/#…` (invite landing, opens the Android app), `/cs/r/#…` (result + review). **The sender's language (2026-10-14).** Everything a *sender* reads comes in English, Spanish, French or German: the 4xx `message` of the inbox-token and result-token routes (`/api/cs/inbox`, `/api/cs/submit`, `/api/cs/submissions`, `/api/cs/submission/{id}…`), the `error` / `result.summary.title` of an expired submission, and the two pages `/cs/join/` and `/cs/r/`. The server picks the language from, in this order, the `lang` query parameter (`?lang=es`), the `X-Lang` header, then `Accept-Language` (first two letters); anything else is English, so a request with no header gets the English text. The pages use `?lang=` (before the `#`) or else `navigator.language`, and pass `lang=` on to the API so its messages match. Hooks: `cs.t(e, "English text with {n}", {n: 10})` in `cs_lib.js`, tables keyed by the English text (the key is what the code shows), numbers kept; the expiry text is stored in English and translated when the sender reads it. Owner / station routes (user token) stay in English. ### End to end ("CSE1") An inbox with `e2e_key` is end-to-end encrypted: the server relays and stores ciphertext only. TWAndroid gives every TWAIN inbox one; job inboxes of the Mobile Inbox service have none yet. - **Key.** The PC that owns the inbox makes a P-256 key pair, keeps the private half (DPAPI) and stores the public half as `e2e_key` (base64 of the 65-byte uncompressed point) on create (`POST /api/cs/inboxes`) or with `PATCH /api/collections/cs_inboxes/records/{id}`. Phones read it from `GET /api/cs/inbox`. - **Invite.** The PC appends `..` to the invite fragment (`#id.token.fp.secret`, both base64url, 16 bytes): `fp` = first 16 bytes of SHA-256(public key), which pins the key so the server cannot swap it, and `secret` = the inbox secret. The fragment never reaches the server. The links the server builds lack that part; the PC adds it before showing the QR. - **Sealing.** `blob = "CSE1" | ephemeral public key (65) | nonce (12) | AES-256-GCM(ciphertext + tag)`, key = HKDF-SHA256(ECDH(ephemeral, inbox key), salt = inbox secret, info = `"CSE1" | eph pub | inbox pub`), AAD = `"CSE1"`. Without the inbox secret the tag does not verify, so the server cannot forge documents. - **Submit.** Pages are sealed files named `NNN.cse`; the form carries `device`, `e2e=1` and `sealed` = base64 of the sealed metadata `{"v":1, sender, phone, note, location, pages:[{name, sha256}]}` (the SHA-256 of each sealed page, in order, so pages cannot be swapped or reordered). The server answers `426` to a plain upload to such an inbox and does not keep `sender`, `phone`, `note` or `location`. Submissions carry `e2e: true`; the station gets `sealed` in its queue and `purge` deletes it with the pages. - Reference implementations: `TWAndroid/common/Seal.cpp` (PC, CNG), `ChronoScanAndroid/.../crypto/Seal.kt` (Android), `TWAndroid/tools/tests/cse1.py` (Python, tests). ## 11. Failover: the standby server There are two servers with the same data: | Role | URL | What it is | |---|---|---| | primary | `https://cs-miniserver.chronoscan.org` | The normal server. Everything is written here. | | standby | `https://cs-miniserver-r.chronoscan.org` | A copy, restored from a fresh backup of the primary every hour. Same users, devices, credentials and tokens, same API. | Both are listed in `GET /api/ms/info` (`url`, `standby`, and `role` = `primary` or `standby` for the server that answered). **What a client does.** Keep the primary URL as the base URL. When a request fails at the transport level (DNS, connect, TLS, timeout, or HTTP 502/503/504 from the proxy), repeat the same request on the standby URL. If the standby answers, keep using it for the rest of the session and go back to trying the primary first after a few minutes (or at the next start). Nothing else changes: `device_id` + `device_secret` work on both servers, a device token issued by one is valid on the other, and all `/api/ms/*`, `/api/cs/*` and PocketBase endpoints are the same. Rules of thumb: - Do not fail over on 4xx: a 401 or 403 is an auth problem, not an outage. - Do not spread writes across both servers on purpose. Use the standby only while the primary is down. - The standby is at most one hour behind the primary. Data written to the primary in the last hour may be missing there, and data written to the standby during an outage is merged back by José (the standby's backup is restored on the primary before switching back). A client should therefore keep its own local copy of what it wrote and re-sync (`/api/ms/sync`) after an outage. - Config: make the standby URL a setting like the primary one (`MINISERVER_STANDBY_URL`, app setting, then the default above), or read it from `/api/ms/info` at start and cache it. ```js // JavaScript: one wrapper, two servers const SERVERS = [primaryUrl, standbyUrl]; let active = 0; async function msFetch(path, init) { for (let i = 0; i < SERVERS.length; i++) { const idx = (active + i) % SERVERS.length; try { const r = await fetch(SERVERS[idx] + path, { ...init, signal: AbortSignal.timeout(15000) }); if (r.status >= 502 && r.status <= 504) continue; // proxy up, server down: try the next one active = idx; return r; } catch (e) { /* network error: try the next one */ } } throw new Error("miniserver: no server answered"); } ``` --- ## 12. csLink shared desktop (`/api/cslink/*`, app id `cslink`) A PC runs the **csLink agent** (the one being shown: a customer's ChronoScan PC, or one of ours), which opens a session and shows its code. Support people open the **csLink viewer**, type the code, and once the host accepts them they see the desktop as H.264 video and, when the host grants it, drive its mouse and keyboard. Everything goes through the **relay** (`cslink_relay/`, container `cslink`): both sides connect *out* to `wss:///cslink/ws`, no PC opens a port. Sources: `M:\ChronoScan\CSLink`. Who is who: | Who | Credential | Gets | |---|---|---| | Agent (host) | user token. A ChronoScan PC registers with its license like ChronoTwain (`POST /api/cs/station/register`, section 10) and lands on the `lic-@chronoscan.app` account; ours pair by QR | opens and closes sessions, connects as `role=host` | | Viewer | user token (any Miniserver account; QR pairing) | joins with the code, connects as `role=view`; the host still accepts or declines each one | | Method | Path | Auth | Purpose | |---|---|---|---| | POST | `/api/cslink/sessions` | user | `{name, device?, agent?, key?, ttl_minutes?, reset?}` → `{id, code, pretty_code, name, status, has_key, viewers, expires, ws_url}`. The code is 8 characters of the pairing alphabet (shown as `XXXX-XXXX`) and **belongs to the PC**: session after session it stays the same (address books keep working) until the owner passes `reset: true`. Opening a session closes any open one of the same PC name, except a **keyed** one, which is reused. Life 24 h (7 days at most), 30 days with a key. | | GET | `/api/cslink/sessions/{id}` | owner | the session plus `live` from the relay (`host_connected`, `viewers[]` with `state` and `control`, `frames`, `bytes`, `uptime_s`), or `live: null` when nobody is connected | | POST | `/api/cslink/sessions/{id}/close` | owner | closes it: the code stops working and the relay drops everyone | | POST | `/api/cslink/sessions/{id}/key` | owner | `{key}` (4+ characters) sets the key, `{key: ""}` clears it. Stored hashed. Viewers approved by an old key lose the approval. | | POST | `/api/cslink/sessions/{id}/peer` | owner | `{peer, action: accept|reject|control|release}`: what the host's consent dialog does, from the web page (`/cslink/`) when nobody sits at the PC. 404 when that viewer is not on the relay. | | POST | `/api/cslink/join` | anyone | `{code, key?, name?}` (case and dashes do not matter) → `{id, name, agent, expires, approved, you, ws_url, ticket?}`. The caller is added to the session's viewers (12 at most). A signed-in user joins as itself; **a guest needs no account**: it sends a `name` and gets a `ticket` (40 characters, shown once) which is its credential for the WebSocket (`&ticket=`); its identity is `guest:`. With the session's key, `approved: true`: the relay admits them without the host's consent and gives them control on request when nobody has it. 404 when no open session has that code, 403 wrong key, 429 after 20 wrong codes or keys in 10 minutes (per user, or per IP for guests). | | GET | `/api/cslink/auth` | Caddy | `forward_auth` for `/cslink/ws`: reads `?session=&role=host|view` from `X-Forwarded-Uri` and the user token from `Authorization` or `?token=`, or a guest's `?ticket=` (viewers only); answers 200 with `X-Cslink-User`, `X-Cslink-Name`, `X-Cslink-Role`, `X-Cslink-Session`, `X-Cslink-Approved` (1 = joined with the key), which Caddy copies to the relay. 401 no token, 403 closed/expired session, not the owner, or a viewer who did not join. | | POST | `/api/cslink/sessions/{id}/log` | owner or a joined viewer | `{kind, message?, bytes?, data?}`: an activity row written by the agent or a viewer (`kind` is a slug: `file.sent`, `file.received`, `program.installed`, `note`...; `data` any JSON). | | GET | `/api/cslink/sessions/{id}/log` | owner | the activity log of a session, newest first (`?limit=`, 200 by default): `{log: [{kind, actor, name, peer, ip, bytes, message, data, created}]}`. | | POST | `/api/cslink/relay/report` | the relay (`X-Cslink-Relay` = `CSLINK_RELAY_SECRET`) | the relay's accounting: `session.start`, `host.reconnect`, `viewer.wait`, `viewer.join` (`data.by`: host, web or key), `viewer.leave` (`bytes` delivered to that viewer, `data.seconds`, `data.control_seconds`), `control.start`/`control.end`, `point`, `session.end` (`data`: bytes_in, bytes_out, frames, inputs, viewers, seconds, reason). Each becomes a `cslink_log` row; `session.end` and `viewer.leave` add up into the session's `totals`. | Every session carries `totals` (`bytes_in` from the host, `bytes_out` to the viewers, `frames`, `inputs`, `seconds`, `viewers`, `host_ip`), and `GET /api/collections/cslink_log/records` (owner-only) is the raw activity log for reports: who connected from where, who drove, what was transferred. The owner can also list its sessions with the records API (`/api/collections/cslink_sessions/records`, owner-only rules; no create or update through it). **The WebSocket** (`ws_url` as returned, with the token in `Authorization` or `&token=`): - Text frames are JSON control messages (`{"t": ...}`), binary frames are video from the host, forwarded as they are to every accepted viewer: `[0]` kind (1 = H.264 access unit of the whole screen, Annex B, SPS/PPS repeated on each keyframe; 2 = **zoom region**: the part a viewer looks at through its loupe, at the host's native resolution, every frame intra, payload = u16 LE x, y, w, h in host pixels + the access unit; sent first, the whole screen goes on in the background), `[1]` flags (bit 0 = keyframe), `[2..5]` u32 LE timestamp in ms, then the payload. - Host → relay: `hello{name,screen:{w,h},agent}`, `accept{peer}`, `reject{peer}`, `kick{peer}`, `control{peer|null}` (who drives; one at a time), `cursor{x,y}`, `screen{w,h}`. - Relay → host: `peer{id,user,name,state:pending|joined|left,approved}`, `keyframe{}` (send an IDR now: somebody joined or fell behind), `input{from,k,...}` only from the peer that has control, `control_request{peer,name}`, `control_release{peer}`, `control{peer|null}` when the owner's web page (or an approved viewer's request) decided who drives: the agent follows. - Viewer → relay: `hello{name}`, `input{k:move|down|up|wheel|key, x, y, b, d, vk, down}` (screen pixels of the host), `request_control{}`, `release_control{}`, `keyframe{}`, `point{x,y}` ("look here": a marker the host and every viewer see for a few seconds; no control needed), `note{x,y,text}` (a comic bubble with a text, 20 s, same audience), `install_offer{id, items:[{name,file,size,sha256,args,kind,signer}]}` (installer packages: the host's user approves or declines the whole request; then a file transfer tagged `install:`; routed like `file_*`), `zoom{x,y,w,h}` / `zoom{}` (send that region as kind 2 frames, crisp and first; `{}` = stop; forwarded to the host with `from`). - Relay → viewer: `session{you, state:waiting|pending|joined|rejected, host:{name,agent,connected,screen}, control, peers:[{id,name,control}]}` on every change, `cursor{x,y}`, `point{from,name,x,y}` and `note{from,name,x,y,text}` (from another viewer, or `from: "host"`: the host has Point and Note too), `ended{reason}`, `error{message}`. - A viewer whose connection falls behind (3 MB unsent) stops getting delta frames until the next keyframe, so one slow viewer never slows the host or the others. - **File transfer**, either direction, with consent: `file_offer{to?, xfer, files:[{rel,size}], total}` → `file_accept{xfer}` / `file_reject{xfer, reason}` → chunks as binary frames of kind 3 (after the 6-byte header, `u8 n` + `n` bytes = the viewer it goes to, empty from a viewer to the host; then the sealed body: u32 xfer, u32 file, u64 offset, u32 len, data; 256 KB chunks, at most 4 MB before an ack) → `file_ack{xfer, received}` every megabyte → `file_done{xfer, ok, error?}`; `file_cancel{xfer}` from either side. The relay only routes (`to` in clear) and logs `file.offer` / `file.done`; the agent logs `file.received` / `file.sent` with the names and bytes. - **End to end** (the same CSE1 sealing as ChronoTwain's inboxes): the agent makes a random AES-256 key per connection; a viewer's `hello` carries an ephemeral P-256 public key (`e2e`, base64), the relay repeats it in `peer{}`, and once the viewer is accepted the agent answers `key{peer, blob}` with the session key sealed to that key (`CSE1 | eph pub | nonce | AES-GCM`, salt = SHA-256 of the session id), which the relay forwards as `key{blob}`. From then on video payloads carry flags bit 1 and are `nonce | AES-256-GCM ciphertext | tag` (aad = the 6-byte header), and `input`, `point`, `zoom` (viewer → host) and `cursor`, `point` (host → viewers) keep only the routing fields in clear and the rest in `e` (base64 of the same construction, aad = the `t` value). The relay forwards bytes it cannot read; the viewer refuses clear video outside its dev mode. Sessions are recorded as `ms_events` of app `cslink`: `session.opened`, `session.resumed`, `viewer.joined`, `key.set`, `key.cleared`, `peer.accept|reject|control|release` (from the web), `session.closed`, `join.failed` (wrong code or key, level warn).