# 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 | | 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. `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". ## 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. ## 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) | - 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). ### 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).