# 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 | | * | `/api/board/*` | – / user / editor key | Office Board wall display and its config page, see section 13 | | * | `/api/bellota/*` | user / Bellota device token | Bellota maths tutor: family model, invitations, server-paid screen time, see section 15 | | POST / GET | `/api/ms/backup/repo` | user | ChronoBackup: provision / read the user's restic rest-server account, see section 16 | 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. `GET /apps.json` leaves out the apps the admin parked (/admin/ → Apps); `GET /apps.json?parked=1` returns them too, with `"parked": true`. 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 **read-only** copy of the primary, refreshed every 5 minutes. Same users, devices, credentials and tokens, same API. | Both are listed in `GET /api/ms/info`: `url` (the primary), `standby`, `servers` (every server, primary first; more than two some day) and `role` = `primary` or `standby` for the server that answered. Primary and standby give the same `url`, `standby` and `servers`. **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 **read only** and at most about 5 minutes behind the primary. Reads, downloads, sign-in (password, Google, refresh), `POST /api/ms/device/token`, file tokens, realtime and presence pings work there; every other write answers **503** with `{"data": {"standby_read_only": true}}` and `Retry-After: 300`, and nothing is saved. A client keeps what it could not send (or tells the user "the server is in read-only mode, try again in a few minutes") and sends it to the primary once it is back, then re-syncs (`/api/ms/sync`). Pairing a new device (`/api/ms/link/*`) waits for the primary too. - A 503 with `standby_read_only` is not an outage of the standby: do not loop between the two servers on it. - **Discover the servers, do not hard-code them.** The seed is the configured URL (`MINISERVER_URL` > app setting > default). At start and every 24 h, in the background with a short timeout, `GET /api/ms/info` (if it does not answer, the cached or default standby's `/api/ms/info`) and cache `servers` (or `url` + `standby` from servers older than 2026-10-23) per seed host. Failover goes through that list: `MINISERVER_STANDBY_URL` > the cached list > the default standby above (only for the two primary names). The base stays the seed. The Office Board wall (`pb_public/board/index.html`) does exactly this. ```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/presence` | - | `{codes:[...]}` (at most 60) → `{codes:{CODE:"online"\|"offline"}}`: online = an open session with that code whose agent is connected to the relay right now. No account needed; 600 calls per IP in 10 minutes. | | 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 **10 digits** (shown `123 456 7890`, never a leading 0; 2026-10-22, before that 8 characters `XXXX-XXXX`) and **belongs to the PC**: session after session it stays the same (address books keep working) until the owner passes `reset: true`. A PC that still had an older code gets a 10-digit one at its next session, and the old one stays accepted by join and presence (`old_code`, carried from session to session) until the owner resets the code. 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, spaces and dashes do not matter; the PC's older code works too) → `{id, name, agent, expires, approved, you, ws_url, ticket?, code, pretty_code}` (`code` = the PC's current one: an address book holding an older code updates it). **Official ChronoScan support** (an approved station: its `station_id` + `station_secret` or this PC's `pc_device_*`; or an approved support account, signed in or the one this PC's agent is registered with) always appears with its registered name + ` (ChronoScan support)`, whatever `name` says, on every session; anybody else's name loses anything that reads as ChronoScan support (`you.official` says which). 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,caps?}` (`caps: ["drop"]` = this agent takes files without a session, `"media"` = voice and webcam, below), `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,files,media,mic,cam}` (`files: true` = a drop, see below), `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, files?, caps?}` (`caps: ["media"]`: voice and webcam, below; relay 1.9.0: `files: true` = a **drop**, files to a PC without a session: that viewer never gets the screen, control, cursor, notes or chat and may only send `file_*`; an agent without `caps` "drop" makes the relay answer `error{message}` + `ended` and close; the agent admits a drop at once and asks about the files on a card, with the sender's note from the sealed `file_offer{..., note}`), `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`), `video{on}` (relay 1.8.0, in clear: `false` = this viewer left the screen and stays only to finish its file transfers: no video, cursor, points, notes, chat or monitors for it, control taken away; `true` = back to the screen; forwarded to the host as `video{from,on}`; logged `viewer.files_only` / `viewer.screen`). - Host → relay: `video{peer, on:false}` stops the screen for that viewer while its file transfers finish (the viewer gets `video{on:false, by:"host"}` and cannot turn it back on; the agent removes it when the transfers are done). - Relay → viewer: `session{you, state:waiting|pending|joined|rejected, host:{name,agent,connected,screen,media,mic,cam}, control, peers:[{id,name,control,video,mic,cam}]}` 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. - **Voice and webcam** (relay 1.10.0, clients 0.15.3+ and `ChronoRemoteHost.dll`; José, 2026-10-22: talk, and a webcam that stays off until the person turns it on): both sides say `caps: ["media"]` in `hello`. `media{mic,cam}` (in clear: only on or off) is that side's microphone and camera; the relay keeps it for `session{}` / `peer{}`, forwards it as `media{from,name,mic,cam}` (`from` = `"host"` or the viewer's id) to the host and to every joined viewer that takes media and sees the screen, and logs `audio.on|off` / `camera.on|off` on the session. Frames: binary kind 4 = voice (Opus, 16 kHz mono, 20 ms each, 24 kbit/s with FEC), kind 5 = webcam (H.264, 15 fps, a keyframe every 2 s), the same 6-byte header and the payload sealed with the session key (aad = the header); the relay inserts `u8 n` + `n` bytes = the sender after the header and forwards to that audience. A receiver 3 MB behind loses voice frames and gets the camera back from its next keyframe. A viewer that leaves the screen (`video{on:false}`) has its microphone and camera turned off; drops never take media. - **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). ### ChronoRemote support (`/api/remote/*`) csLink is now **ChronoRemote** (the API keeps the `cslink` names). ChronoScan's ribbon button "Solicitar soporte (control remoto)" loads `ChronoRemoteHost.dll`: the person accepts the terms, gets the PC's **support number** (10 digits) and reads it to ChronoScan support; only **official ChronoScan support** can use it, and the person still approves every connection by hand. The request, the terms, the knock, the approval, control and the end all land in the session's log (`cslink_log`, kinds `support.*`). The session is a `cslink_sessions` row with `support = true`, owned by the system account `support@chronoscan.app` (the customer's PC has no account). The number belongs to the PC (since 2026-10-23; one use and 30 minutes before): every request of the same license, PC name and Windows user gets the same code, like a PC's own ChronoRemote code, and closes that PC's earlier request if it is still open. It works while the support window is open: the window renews its request every `renew_minutes` with `/alive` (a request that is not renewed lapses `ttl_minutes` after the last renewal), and a station that was cut joins again with the same code (the person approves it again). Official support is either: - a **support station** (`remote_stations`), only ever added by an administrator at a distributor's explicit request: a PC by its ChronoRemote code (`kind: "pc"`: the station is that PC's agent device; the viewer on that PC joins with the agent's own device credentials as `pc_device_id` + `pc_device_secret`, nothing to register; stations that registered from an older viewer join with `station_id` + `station_secret`), or - a **support account** (`remote_supporters`): a Miniserver account asks on its page (`/`, card "ChronoScan support") or an administrator adds it by email, user id or ChronoScan license number; once approved, a ChronoRemote viewer linked to that account joins with its user token, and the viewer of any PC whose agent is registered with that account joins with the PC's proof (`pc_device_*`): a **license account** made support covers every PC registered with that license. | Method | Path | Auth | Purpose | |---|---|---|---| | POST | `/api/remote/support/request` | - (license proof) | ChronoScan: `{license, proof, pc, user, product, version, reason, language, terms, accepted: true}` → `{id, code, pretty_code, expires, ttl_minutes, renew_minutes, ws_url, ticket}`. `code` is the PC's number (the same on every request of that license, PC and Windows user; the PC's earlier request still open closes). `proof` = MD5(secret + license + pc), as `/api/cs/station/register`. The `ticket` (48 characters) is the host's credential: `&ticket=` on the WebSocket (`role=host`) and `X-Remote-Ticket` below. 400 terms not accepted / invalid license, 403 wrong proof, 429 after 10 requests per hour per license or per IP. | | POST | `/api/remote/support/{id}/alive` | `X-Remote-Ticket` | The support window is still open (every `renew_minutes`) → `{ok, expires}` (`ttl_minutes` more). **410** `{status: "replaced"}` when a newer request of the same PC took over (that window ends), `{status: "closed"}` when it lapsed (the PC slept): ask again, the number is the same. | | POST | `/api/remote/support/{id}/log` | `X-Remote-Ticket` | `{kind: "support.", message, data}`: the host's steps (`support.approved`, `support.declined`, `support.control_given`...). | | POST | `/api/remote/support/{id}/end` | `X-Remote-Ticket` | `{reason}`: logs `support.ended` and closes the session (the window closed); the number works again with the PC's next request. | | POST | `/api/cslink/join` | station or support account | on a support code: `{code, station_id, station_secret}` (a registered station), `{code, pc_device_id, pc_device_secret}` (a PC added by its code, or a PC of an approved support account) or the user token of an approved support account → `{..., support: true, approved: false, you, ticket}` (the viewer connects with `&ticket=`; identity `station:` or `supporter:`, name " (ChronoScan support)"). Again as often as needed while the request is open (logged `support.joined`, then `support.rejoined`; the station's earlier ticket stops working; `used_by` / `used_at` = the first use); 403 for anyone else (logged `support.refused`), 400 "not asking for support right now" when that PC's support window is closed or its request lapsed. | | POST | `/api/remote/stations/register` | - | **closed** (403, 2026-10-22): stations are added by an administrator at a distributor's explicit request (`/api/remote/admin/add` with the PC's ChronoRemote code). | | POST | `/api/remote/stations/status` | station | `{station_id, secret}` or `{pc_device_id, pc_device_secret}` → `{status, name, kind: viewer\|pc, ...}` (403 unknown, wrong secret, or not a station). | | GET | `/api/remote/me` | user | `{supporter: null \| {status: pending\|approved\|revoked, name, shown_as, message, created, approved_at, last_seen}}`: this account's support role. | | POST | `/api/remote/me` | user | `{message, name?}`: ask to be ChronoScan support (`message` required: who you are, why) → pending; José gets a Telegram. Pending: the request can be changed; approved: only `name` (what the customer sees) changes; revoked: asking again makes it pending. | | DELETE | `/api/remote/me` | user | withdraw the request, or stop being support. | | GET | `/api/remote/admin/stations` | superuser | every station (`id`, `note`, `ip`, `version` too). | | POST | `/api/remote/admin/stations/{id}` | superuser | `{status: pending\|approved\|revoked, note, name}`. | | DELETE | `/api/remote/admin/stations/{id}` | superuser | the station has to register again. | | GET | `/api/remote/admin/supporters` | superuser | every support account (`id`, `email`, `user_name`, `note` too). | | POST | `/api/remote/admin/supporters` | superuser | `{email \| user, name?, note?}`: that account becomes support at once (approved). A license account is also found without its `lic-`. 404 when no account matches. | | POST | `/api/remote/admin/add` | superuser | `{who, name?, note?}`, the one box of /admin/: `who` = an email, a user id or a ChronoScan license number (→ that account is support: `{added: "account", supporter}`), a station id (→ approved), or a PC's ChronoRemote code (→ that PC is a station: `{added: "station", station}`; 400 when its agent's sessions carry no device). 404 when nothing matches. | | POST | `/api/remote/admin/supporters/{id}` | superuser | `{status, note, name}`. | | DELETE | `/api/remote/admin/supporters/{id}` | superuser | removes the role. | | GET | `/api/remote/admin/requests` | superuser | the support requests, newest first (`?limit=`, 100 by default): license, PC, Windows user, reason, code, who used it (`used_by`, `station_name`), seconds, bytes. | | GET | `/api/remote/admin/requests/{id}/log` | superuser | one request's whole log. | The administrator's page is `/admin/` → **Remote support**: one box adds support by email, user id, license number, station id or a PC's ChronoRemote code; stations (approve, revoke), support accounts (approve, revoke, the name the customer sees) and every request with its log. Events (`ms_events`, app `cslink`): `station.registered|approved|revoked|deleted`, `supporter.requested|approved| revoked|withdrawn|deleted`, `support.requested`, `support.joined`, `join.failed` (level warn). ## 13. Office Board (`/api/board/*`, app id `officeboard`) A Trello-like wall for an always-on office screen: columns, cards with task, owner and deadline, and warning effects. - Pages: `/board/?b=` is the **wall display** (public, read-only, no account). `/board/?b=,,…&every=` rotates through several boards, each shown `every` minutes (default 5, from 0.25 to 120), with a fade between them; a deleted board drops out of the rotation, the arrow keys switch by hand, and the footer shows "BOARD 2/3 · NEXT 4:12". The owner's board list in the editor builds that link ("Wall rotation"). `/board/edit/` is the **config page**: the owner signs in with a Miniserver account; team members can edit with an **editor link** `/board/edit/#b=&key=`, no account needed. - Storage: a board is a normal item of its owner (`items`, app `officeboard`, collection `boards`, key = share id, 12 chars). The owner also sees it through `/api/ms/items` and `/sync`, but writes should go through `PUT /api/board/{share}`, which checks the revision and cleans the data. - The share id is the secret of the wall: anyone with it can read the board. The editor key is kept only as a SHA-256 (`data.editor_key_hash`, never returned by the board endpoints) and travels in the URL fragment and the JSON body, so it never reaches access logs. | Method | Path | Auth | Purpose | |---|---|---|---| | GET | `/api/board/mine` | user | `{boards: [{share, title, cards, editor_link, metrics, metrics_key, updated}]}` | | POST | `/api/board` | user | Create `{title}` or `{board}` → `{share, board}`. 50 boards per user. | | GET | `/api/board/{share}?rev=` | – | The board. With `rev` = the current revision: `{unchanged: true, rev}` (cheap polling). Adds `you: "owner"` and `metrics_key: bool` for the owner's token. | | POST | `/api/board/{share}/access` | user or `{key}` | `{role: "owner" \| "editor"}`, `403` for a wrong or revoked key | | PUT | `/api/board/{share}` | owner token or `{key}` | Body `{board, rev, key?}`. Saves the whole board → new board with `rev + 1`. `409 {message, board}` when `rev` is stale (someone saved in between): reload and reapply. | | POST | `/api/board/{share}/editor-key` | owner | New editor key `{key}`; the previous one stops working. Shown once. | | DELETE | `/api/board/{share}/editor-key` | owner | Turn the editor link off | | GET | `/api/board/{share}/metrics?mrev=&today=&full=1` | – | Metric values for the wall (see *Live metrics* below) | | POST | `/api/board/{share}/metric/{id}` | metrics key, editor key or owner | Push one value: `{key, value}` sets, `{key, inc}` adds → `{id, value, mrev}` | | POST | `/api/board/{share}/metrics` | metrics key, editor key or owner | Push several: `{key, values: {"sales": 1250, "downloads": {"inc": 3}}}` → `{mrev, values}`. All or nothing. | | POST | `/api/board/{share}/metrics-key` | owner | New metrics key `{key}` (`obm_…`); the previous one stops working. Shown once. | | DELETE | `/api/board/{share}/metrics-key` | owner | Turn the metrics key off | | DELETE | `/api/board/{share}` | owner | Delete the board (soft delete of the item) | Board JSON (unknown fields are dropped, strings are cut to their limits): ```json { "title": "OFFICE OPS", "subtitle": "", "ticker": "scrolling text", "theme": "matrix|amber|cyber|ice|paper", "settings": {"warn_hours": 48, "auto_effects": true, "show_clock": true, "show_done": true, "scanlines": true, "sound": false, "fx_every": 10, "fx_off": [], "tz": "Europe/Madrid", "metrics_pos": "top|bottom|off", "show_score": true, "score_tasks": 50, "score_metrics": 50}, "columns": [{"id": "todo", "name": "TODO", "color": "#00ff66", "done": false}], "cards": [{"id": "c1", "col": "todo", "title": "Deliver 216, review!!", "owner": "Ana", "due": "2026-10-20T17:00", "priority": "low|normal|high|critical", "effect": "none|pulse|blink|shake|glitch|siren|neon|rainbow", "color": "#ff2e4d", "emoji": "🚨", "note": ""}], "metrics": [{"id": "sales-today", "label": "Sales today", "unit": "€", "emoji": "💶", "kind": "counter|gauge", "period": "day|week|month|none", "goal": 10000, "higher_better": true, "warn": null, "bad": null, "min": null, "max": null, "decimals": 0, "style": "number|progress|gauge|spark", "color": "", "fx": "", "show_rate": true, "show_delta": true}], "rev": 7, "share": "…", "updated": "…" } ``` - `due` is `YYYY-MM-DD` (end of that day) or `YYYY-MM-DDTHH:MM`, read as the wall-clock time where the screen is. Max 12 columns, 300 cards. - The wall computes the states every second: overdue cards blink red, cards due within `warn_hours` pulse amber (when `auto_effects`), a critical overdue card raises the red alert strip, and `done` columns dim their cards and silence them. `effect` adds a card's own effect on top. - Attention effects: every `fx_every` minutes (0 = off, max 240) the wall plays one effect for 4 to 10 seconds. It goes through all the enabled ones in random order before repeating any. `fx_off` lists the ones switched off (names `^[a-z0-9-]{1,24}$`, max 400), so effects added later are on by default. There are 113, in 8 categories (`OBFX.CATS`), registered with `OBFX.register(name, label, category, fn)` in `pb_public/board/fx.js` (core, toolkit, the rules an effect must follow) and one pack file per category: - Your board (`fx_board.js`, they read the cards, owners and deadlines): `zoom`, `fire`, `radar`, `kitt`, `terminal`, `countdown`, `flapboard`, `leaderboard`, `wheel`, `slots`, `newspaper`, `forecast`, `wanted`, `launch`, `breaking`, `award`. - Retro games (`fx_retro.js`): `invaders`, `pacman`, `dvd`, `snake`, `tetris`, `pong`, `breakout`, `asteroids`, `dino`, `flappy`, `missile`, `tron`, `lemmings`, `platformer`. - Demoscene (`fx_demo.js`): `matrix`, `tunnel`, `plasma`, `life`, `demo`, `starfield`, `rotozoom`, `metaballs`, `cube`, `twister`, `mandel`, `voxel`, `ttunnel`, `kaleido`, `dotsphere`. - Wall tricks (`fx_wall.js`, they move the real wall): `wave`, `glitch`, `crt`, `barrel`, `quake`, `flip`, `starwars`, `cubeturn`, `mirror`, `upside`, `jelly`, `polaroid`, `dizzy`, `swap`, `vhs`, `heartbeat`. - Physics (`fx_physics.js`): `gravity`, `magnet`, `tornado`, `blackhole`, `explode`, `bounce`, `pendulum`, `domino`, `newton`, `cloth`, `ballpit`. - Nature & weather (`fx_nature.js`): `storm`, `bubbles`, `raindrops`, `snow`, `aurora`, `sunrise`, `aquarium`, `vines`, `fireflies`, `flood`, `boids`, `nightsky`, `tumbleweed`, `eclipse`. - Party (`fx_party.js`): `fireworks`, `confetti`, `disco`, `rainbow`, `balloons`, `cake`, `champagne`, `emojirain`, `dancefloor`, `lasers`, `karaoke`, `parrot`, `conga`, `stadium`. - Pranks (`fx_pranks.js`): `bsod`, `update`, `cracked`, `battery`, `spider`, `cat`, `cursors`, `popups`, `comicsans`, `coffee`, `fly`, `clippy`, `captcha`. The first 29 live in `fx.js` (`matrix`, `tunnel`, `wave`, `zoom`) and `fx_pack.js`. Key `E` on the wall plays one now, `&fx=` on the wall URL plays that one once after loading, and the editor lists them by category (search, per-category on/off, a play button each, "Surprise me"). `/board/fx/` is the **effects gallery**: every effect with a thumbnail, played on a demo wall (`/board/?demo=1`, a built-in board, no server data), with stars for favourites kept in the browser; the editor's "Use gallery ★" switches a board to only the starred ones. Checks: `tools/fx_check.mjs` (real-time headless Chrome: ends on time, leaves nothing behind, clean console, fps), `tools/fx_sheet.py` (contact sheet, gallery thumbnails). - The wall polls every 5 s with `?rev=`, keeps the last board in the browser while offline, and reads from the standby (section 11) when the primary does not answer. - Events in `/admin/`: `board.created`, `board.deleted`, `board.editor_link`, `board.metrics_key`, `monitor.down`, `monitor.up`. ### Alerts (watched URLs) `board.monitors` = `[{id, label, url}]`, at most 8 per board. This server checks every watched URL once a minute (cron `board_monitors`, `pb_hooks/board_monitor.js`, GET with an 8 s timeout, at most 40 distinct URLs per run). A URL is **down** after 2 failed checks in a row (no answer, or status ≥ 400) and **up** again after one good check; the state lives in memory, so a restart re-checks within a minute. Only public `http(s)` addresses are accepted: no private, loopback, link-local or metadata IPs, no single-label names (container names), no `.local`/`.internal`/`.lan`-style names; `www.example.com` gets `https://` added. Invalid entries are dropped on save. | Method | Path | Auth | Purpose | |---|---|---|---| | GET | `/api/board/{share}/alerts` | – | `{monitors: [{id, label, url, state: up\|down\|unknown, fails, code, ms, error, since, checked}], every_seconds, fails_to_down}`. A monitor never checked yet is checked on the spot (3 per call). | | POST | `/api/board/{share}/alerts/test` | owner token or editor `{key}` | Body `{url}`: check it now → `{url, ok, code, ms, error}`; `400` for addresses that are not allowed. | On the wall (`pb_public/board/alerts.js`): anything down raises a full-width flashing banner ("🚨 MAIN SERVER DOWN!! 🚨", the monitor's label) with the reason and since when, a red frame round the screen, footer chips with each URL's state, and a siren every 10 s. The wall also alarms by itself ("MINISERVER UNREACHABLE!!") after 90 s without reaching this server. **SILENCE** (button or key `M`) quiets that screen until the set of alerts changes; the editor's Alerts tab silences every screen for 1 h / 4 h / until tomorrow (`settings.alert_silence_until`, at most 24 h ahead) and switches the siren off (`settings.alert_sound`). Back up → a green "IS BACK UP" banner and a chime. ### Scheduled cards (reminders) `board.reminders` = `[{id, title, days, date, time, minutes, owner, emoji, effect, color, sound, confirm, done}]`, at most 30: at `time` (`HH:MM`, board `tz`) on `days` (0 = Sunday), or once on `date` (`YYYY-MM-DD`), a card appears at the top of the first column for `minutes` (1–720, default 60) with its `effect` (default `blink`) and, unless `sound` is false, a buzz and its title said aloud once; with `confirm: true` both repeat every minute, up to 10 times, until someone marks it done for the day (`done` = that date: editor "Done today", MCP `reminder_done`) (`pb_public/board/reminders.js`; nothing is written back, the wall adds the card while it is due). A reminder already running when the wall loads shows without buzzing. In a rotation the wall reads the other boards every minute and jumps to the one whose reminder just started, holding it 5 minutes. Edited in the editor's Reminders tab or with the MCP (`add_reminder`). ### Screens (one wall link per account) `/board/?s=` rotates the owner's boards — all of them (default, new boards join by themselves) or a picked list — re-reading the list every 3 minutes. Stored as an item of the owner (collection `screens`, `pb_hooks/board_screen.js`). | Method | Path | Auth | Purpose | |---|---|---|---| | GET | `/api/board-screen/mine` | user | The account's screen (created on first use): `{id, mode: all\|pick, boards, every, volume, hours, hours_from: screen\|board, showing: [{share, title}], all: [...], url}` | | PUT | `/api/board-screen/mine` | user | Body `{mode, boards: [share ids in order], every: minutes (0.25–120), volume: 1–10, hours}`; fields left out keep their value; picked ids that are not the owner's live boards are ignored | | GET | `/api/board-screen/{id}` | – | `{every, volume, hours, boards: [share ids]}` — what the wall rotates now, and when the screen is on | The editor's board list has the "Office screen" card (mode, boards, minutes, volume, office hours, link); the MCP's `office_screen` changes the same (e.g. `hours="mon-thu 08:00-17:30, fri 08:00-15:00, sat-sun off"`). The office mini PC shows the screen link. Screen office hours `hours` = `{enabled, tz, week: [7 × ({on: "HH:MM", off: "HH:MM"} | null)]}`, index 0 = Sunday: `null` is off all day, `on` = `off` is on all day, `off` earlier than `on` runs past midnight. Until the owner sets them (`hours_from: "board"`), the screen uses its first board's office-hours settings below, and `tz` falls back to that board's time zone. ### Voice Settings `voice` (default on), `voice_cards` (default off) and `voice_lang` (`auto` | `es` | `en`; `auto` guesses Spanish or English from the text). The wall speaks when a watched URL goes down or comes back; only with `voice_cards` does it also read cards aloud: when a reminder starts ("Atención: …"; the repeats of one that needs confirmation add "Pendiente de confirmar"), when a new critical card appears (in a rotation it first jumps to that board) and when a watched URL goes down or comes back. `pb_public/board/voice.js` queues phrases in `window.__obSpeak`; on the office mini PC the `office-wall-voice` agent says them with Piper, elsewhere the browser's own voices are used if it has any. Silent in the editor preview and outside office hours. ### Office hours (the screen's schedule) Settings `screen_sched` (bool, default off), `screen_on` / `screen_off` (`HH:MM`, defaults 08:00 / 20:00; `screen_off` earlier than `screen_on` is an overnight window, equal means the whole day), `screen_days` (0 = Sunday … 6, default Mon–Fri), all in the board's `tz`. They apply to the board's own wall link (`?b=`); the screen link (`?s=`) uses the screen's per-weekday `hours` above. Outside those hours the wall turns black and quiet: no attention effects, no siren, a dim drifting "back on at 08:00 MON" note (`pb_public/board/screen.js`). On the office mini PC, `office-wall-power` (`tools/kiosk/`, a user service) reads the same schedule for the wall link every 30 s and switches the HDMI output off and on (`wlr-randr`), so the monitor sleeps; `office-wall screen on|off|auto` overrides it. Both implement the same rule; keep them in step. ### Live metrics Tiles on the wall with numbers that roll when they change (sales, deliveries, downloads, sign-ups, line speed…), plus a global score ring in the header. Set them up in the editor's **Metrics** tab, which also makes the push key and shows ready-to-copy curl, PowerShell and Python lines. - **Definitions** live in the board (`metrics`, max 24, saved with the board and its `rev`). `id` is a slug `^[a-z0-9][a-z0-9_-]{0,31}$` (made from the label when missing) and is what scripts push to, so keep it once scripts use it. A `counter` adds up and starts again from 0 each `period` (day, ISO week or month in the board's `settings.tz`; `none` never resets); a `gauge` is a reading and has no period. `goal`, `warn` and `bad` are optional numbers; `higher_better: false` turns them around (open bugs, waiting time). `style` picks the tile: big number, progress bar to the goal with a pace mark, gauge dial (`min`..`max`, default 0..goal×1.25) or a 24 h sparkline. `fx` is the effect played when the goal is reached (`""` = default celebration, `none` = only the tile glows). - **Values** are not in the board: they live in a second item of the owner (`items`, app `officeboard`, collection `metrics`, key = share id) as `{mrev, m: {id: {v, t, ps, pv, pts}}}`, so pushes every few seconds never touch the board's `rev`. `pts` keeps one point per 6 minutes for the last 24 h; `pv` is the total of the previous period (for "vs yesterday"). - **Push** with the board's **metrics key** (`obm_…`, only its SHA-256 is stored, as `metrics_key_hash`). It can push values and nothing else. Send it in the JSON body as `key` or in an `X-Board-Key` header, **never in the URL**. The editor link and the signed-in owner can push too (that is how the editor's Set / +1 buttons work). ```bash curl -X POST https://cs-miniserver.chronoscan.org/api/board//metric/sales-today \ -H "Content-Type: application/json" -d '{"key": "obm_…", "inc": 120}' ``` Values are numbers (or numeric strings) up to ±1e15; anything else is `400`, an unknown metric id `404` (the message lists the ids), a wrong key `403`. Increments from parallel pushers add up (one transaction per request). Push to the primary only: the standby is a read-only copy (writes answer 503). - **Reading**: `GET /api/board/{share}/metrics` → `{mrev, today, now, metrics: {id: {v, t, pv}}}`; `full=1` adds `pts`. With `mrev` and `today` from the last answer it returns `{unchanged: true, mrev, today}` until a value moves or the day rolls over. Counters whose period ended read as 0 (with `pv` = the last total) even before the next push. The wall polls it every 5 s next to `?rev=` and asks for `full=1` every 5 minutes. - **On the wall** each tile shows the value (odometer), the period, and when enabled the rate over the last hour (`+12/h`), the comparison with the previous period at the same point of it (`▲ 8% vs yday`) or, for gauges, with 24 h / 1 h ago, and the projection to the end of the period (`→ 362 today ✓`). Amber at `warn`, red with alarm and a beep (when `sound`) at `bad`; reaching the goal makes the tile glow and plays the celebration effect. - **Global score** (0–100, ring in the header, `show_score`): `(score_tasks × TASKS + score_metrics × GOALS) / (score_tasks + score_metrics)`, where `TASKS = 1 − weight of overdue cards / weight of open cards with a due date` (weights low 0.5, normal 1, high 2, critical 4; cards in `done` columns do not count) and `GOALS` = the average over metrics with a goal of `min(1, value / goal so far)`. For counters "goal so far" is `goal × the part of the period already gone` (the pace needed), so 9:00 is not judged like 18:00; when lower is better it is `min(1, goal / value)`. A part with nothing to measure is left out. The editor shows this formula with the current numbers. - **Fed by the server**: a metric with `source: "ms."` gets its value from this Miniserver every minute (cron `board_ms_feed`, `pb_hooks/board_ms.js`), only on boards whose owner's email is in `ADMIN_MONITOR_EMAIL` (the numbers are server-wide). Sources: `ms.requests_today`, `ms.requests_per_min` (last 5 min), `ms.errors_today` (5xx), `ms.latency_ms` (average API time, last 15 min), `ms.realtime_clients` (open realtime connections), `ms.active_devices` and `ms.active_users` (last 15 min), `ms.users_total`, `ms.signups_today`, `ms.events_today`, `ms.docs_today` and `ms.pages_today` (ChronoTwain uploads), `ms.cslink_open`, `ms.web_hits_hour` and `ms.visitors_hour` (Caddy access log, last hour). Host resources of the VM (the container sees the host's /proc): `ms.cpu_pct` (busy share since the previous minute), `ms.mem_pct`, `ms.mem_free_gb`, `ms.disk_pct`, `ms.disk_free_gb` (the disk holding pb_data) and `ms.load1`. **Alerts** (`pb_hooks/board_alerts.js`), checked by the same cron every minute whether or not a board shows them, sent by Telegram (lab bot) and logged as events `alert.resources` / `alert.resolved`. A rule fires when its condition holds for the minutes shown, at most once an hour, and sends a second note when the value is back to normal. cs-miniserver: CPU ≥ 90 % (3 min), memory ≥ 90 % (2 min), disk ≥ 90 % or under 2 GB free (1 min), load ≥ 2 × cores (5 min), ≥ 10 server errors (5xx) in 5 minutes, API average ≥ 1000 ms over 5 minutes (5 min). ChronoScanHUB: web does not answer (2 min) or takes ≥ 5 s (3 min), always; with Azure Monitor configured also CPU ≥ 90 % (3 min), memory ≥ 90 % (2 min; under 0.5 GB free when `HUB_AZ_MEM_GB` is not set), OS disk ≥ 95 % of its IOPS (10 min) and VM not running (2 min). `MS_RESOURCE_ALERT_PCT` changes the 90 % of CPU, memory and disk. `tools/vm/healthcheck.sh` still warns at 85 % disk on its own. **ChronoScanHUB** (another Azure account): sources `hub.cpu_pct`, `hub.mem_free_gb`, `hub.mem_pct` (needs `HUB_AZ_MEM_GB`), `hub.net_in_mbps`, `hub.net_out_mbps`, `hub.disk_iops_pct` and `hub.vm_running` come from Azure Monitor (`pb_hooks/board_hub.js`) with a service principal that has **Monitoring Reader** on the Hub VM: `HUB_AZ_TENANT`, `HUB_AZ_CLIENT_ID`, `HUB_AZ_CLIENT_SECRET`, `HUB_AZ_RESOURCE_ID` (the VM's full resource id) in the server's `.env`, passed by `docker-compose.yml` (`docker compose up -d` after editing). `hub.web_ms` and `hub.web_up` time a plain GET of `HUB_WEB_URL` (default `https://chronoscan.org/`) and need no credentials. The Hub's CPU and memory get the same Telegram alert, named "ChronoScanHUB". Azure Monitor has no free-disk metric without an agent on the VM. Request counts leave out the walls' own `GET /api/board/…` polling and the e2e accounts (`@example.com`) are not counted. Unknown sources are dropped when the board is saved. The editor offers them under "Where the value comes from". - **Miniserver map** (`settings.topology`, boards of a Miniserver admin only): the wall shows, instead of the columns, the map of /admin/ → Storage: the nodes with their health and disks, what lives on each disk (standby database, ChronoBackup repositories, mirror copies, storage volumes), where the data comes from (the primary, the PCs that back up, the build machine) and each operation as an animated arrow coloured by its last run (green on time, amber late, red failed, blue running, grey no data). Data: `GET /api/board/{share}/topology` (public like the wall, `404` unless the owner is in `ADMIN_MONITOR_EMAIL` and the setting is on), a cut of the admin endpoints with no emails, IPs, keys or URLs. Wall code `pb_public/board/topology.js`, route `pb_hooks/board_topology.pb.js`; refreshed every 30 s. - **Dashboard mode**: a board with metrics and no cards hides the empty columns and the task counters; its tiles fill the screen in balanced rows. - Time zones: the server has no time zone database; it knows the EU and US summer-time rules and a list of fixed-offset zones (Asia, Latin America…). Other zones count as UTC. ## 14. Review sites (`/review//`) A static folder per site, behind one shared key, for showing a customer or a colleague the outcome of a run (images, decisions, what goes on to an LLM) without creating accounts. Not an app API: there are no items, devices or tokens involved. | Method | Path | Purpose | |---|---|---| | GET | `/review/{site}/` | The site's `index.html`; without the key, the key form (200) | | GET | `/review/{site}/{file}` | Any file of the site; `401` without the key, `404` outside the site | | POST | `/review/{site}/login` | Form field `key`; right → `303` to the site with the cookie `rv_` (30 days, HttpOnly); wrong → `403` with the form again | | GET | `/review/{site}/logout` | Forgets the cookie | - The key may also travel as `?key=` (a link for a first visit; Caddy redacts `key` in the access log) or in an `X-Review-Key` header (scripts). Both set the cookie too. - A site is `pb_review//` next to `pb_hooks` on the server (`/opt/miniserver/pb_review`, a volume, not in the repo): `key.txt` (the key, one line) and `www/` (the files; `index.html` at the root). `` matches `^[a-z0-9][a-z0-9_-]{0,31}$`. Pages are served with `Cache-Control: private` and `X-Robots-Tag: noindex`; `/review/` is disallowed in `robots.txt`. - Publishing: build the folder in your project, `scp -r` it to `jose@…:/opt/miniserver/pb_review//` (no restart needed). Example: `M:\ChronoScan\Pruebas y Tests\PageSplitDetect\publish_review.py --upload`. --- ## 15. Bellota maths tutor (`/api/bellota/*`, app id `bellota`) A family model on top of items. **The parent owns the account**; each child is a set of items of that account keyed by a child id (`cid`). A child's phone and BellotaDesktop on the family PC never get a user token: they join with an invitation code, become a *restricted device* (`devices.info.restricted = "bellota"`, `info.bellota = {role, child}`) and hold a device token that only the endpoints below accept. `POST /api/ms/device/token` refuses restricted devices (403). Hooks: `pb_hooks/bellota.pb.js`, `bellota_lib.js`. App and agent: `I:\LLMLab\Games\Bellota` (its `RULES.md` is the rules contract). Page: `/bellota/`. Items of the parent (app `bellota`): | Collection / key | Written by | What | |---|---|---| | `children/` | server | `{name, age, email, created}` | | `profile/` | the child's phone (`PUT /profile`) | `{name, age, points, results, wisdom, lang, updated}`; the newer `updated` wins | | `rules/` | the parent (generic items API) | parental rules: `{v, pinHash, freeMinutesPerDay, pc: {enforce, blockNow, dailyMax, windows, blockedApps}, phone: {…}, updated}` | | `awards/` | server | `{node: stars}`: what has been paid already | | `wallet/.` | server (`earn`), devices (`spend`, daily `grant`), parent (`grant`) | `{kind: earn\|spend\|grant, s, note, t, by: server\|phone\|pc\|parent}`; balance = Σ earn + Σ grant − Σ spend | | `invites/` | server | `{child, kind, expires, used, device}` | **Parent** (user token, normal account; the welcome email applies): | Method | Path | Body → result | |---|---|---| | GET | `/api/bellota/children` | `{children: [{id, name, age, email, points, solved, updated, balance_s}]}` | | POST | `/api/bellota/children` | `{name, age, email?}` → child. Creates `profile`, `rules` (defaults). Max 12 | | DELETE | `/api/bellota/children/{cid}` | removes the child's items and revokes its devices | | POST | `/api/bellota/children/{cid}/invite` | `{kind: phone\|pc, email?}` → `{code: "ABCD-EFGH", kind, expires, emailed, to, page}`. 48 h, once. Emailed to `email` or (phone) the child's email | | GET | `/api/bellota/children/{cid}/wallet` | `{balance_s, entries}` newest first | | POST | `/api/bellota/children/{cid}/grant` | `{minutes (1–600), note?}` → `{balance_s}` | | GET/PUT | `/api/ms/items/bellota/rules/{cid}`, `…/profile/{cid}` | the generic items API | **Devices** (`phone` invite → role `child`; `pc` invite → role `pc`). Token in the header `X-Bellota-Token: ` (or `Authorization: Bellota `), 12 h; on 401 ask for a new one; 403 = revoked or the child was removed: forget the credentials. | Method | Path | Role | Body → result | |---|---|---|---| | POST | `/api/bellota/join` | – | `{code, name?, platform?}` → `{device_id, device_secret, token, expires_in, role, child}` · `404` unknown · `410 {status: used\|expired}` | | POST | `/api/bellota/token` | – | `{device_id, device_secret}` → `{token, expires_in, role, child}` | | GET | `/api/bellota/me` | any | `{child, role, profile, rules, wallet}` (one call to sync) | | GET | `/api/bellota/rules` | any | the rules, defaults merged | | PUT | `/api/bellota/profile` | child | `{data}` → `{profile, kept: phone\|server}` | | POST | `/api/bellota/solved` | child | `{node, stars (1–3), pts?, title?}` → `{minutes, stars, balance_s}`. **The server pays**: 3★ 10 min, 2★ 6, 1★ 3, once per star level reached per node (`awards`) | | POST | `/api/bellota/spend` | child, pc | `{s (1–3600), note?}` → `{balance_s}`; `by` = phone or pc from the role | | POST | `/api/bellota/daily` | child, pc | `{day: "yyyymmdd"}` (local date) → `{granted_s, balance_s}`: `freeMinutesPerDay` once per day (`wallet/.g--free`), idempotent across devices | **Per-device rules, extra time, requests, tasks, events, location** (`pb_hooks/bellota_family.pb.js`): - `rules/` may carry `devices: { "": { enforce?, blockNow?, dailyMax?, windows?, blockedApps?, free?, extraUntil? } }`. `free: true` (on a kind block or a device) = that device needs no time: nothing is counted or spent, no balance / hours / cap checks; `blockNow` and `blockedApps` still apply. `/me` and `/rules` add `effective`: the device's kind block (`pc` | `phone`) with those overrides, plus `extraUntil` (ISO instant until which the device is allowed whatever the hours and the cap), `kind` and `device`. `/me` also returns `device` (the caller's id) and `tasks` (open or done ones). - Items: `requests/.` `{device, deviceName, minutes, reason, status: pending|granted|denied, created, resolved, granted, until}`; `tasks/.` `{title, minutes, status: open|done|approved|rejected, created, doneAt, resolved}`; `events/.` `{kind, text, device, data, t}` (kinds: joined, solved, extra, request, request_done, task_done, task_approved, task_rejected, location; purged after 30 days). A device's last position is in `devices.info.bellota.loc`. | Method | Path | Who | Body → result | |---|---|---|---| | GET | `/api/bellota/children/{cid}/devices` | parent | `{devices: [{id, role, name, platform, revoked, last_seen, last_ip, loc, rules (effective)}]}` | | POST | `/api/bellota/children/{cid}/devices/{id}` | parent | `{name?, revoked?}` → the device | | POST | `/api/bellota/children/{cid}/devices/{id}/extra` | parent | `{minutes (1–600), note?}` → `{extraUntil, balance_s}`: extra time on that device now, and the balance grows | | GET | `/api/bellota/children/{cid}/requests` | parent | the child's requests, newest first | | POST | `/api/bellota/children/{cid}/requests/{id}` | parent | `{action: grant\|deny, minutes?}` → the request; grant = extra time on the request's device + balance | | GET / POST | `/api/bellota/children/{cid}/tasks` | parent | list · `{title, minutes}` → task | | POST | `/api/bellota/children/{cid}/tasks/{id}` | parent | `{action: approve\|reject\|delete}`; approve pays the minutes (`earn`, by parent) | | GET | `/api/bellota/events?since=&limit=` | parent | `{events: [{id, child, childName, kind, text, device, data, t}], now}` newest first, every child | | POST | `/api/bellota/request` | child, pc | `{minutes (5–180), reason?}` → request · `409` while one is pending | | GET | `/api/bellota/requests` | child, pc | this child's requests | | POST | `/api/bellota/tasks/{id}/done` | child | the child marks a task done | | POST | `/api/bellota/location` | child, pc | `{lat, lon, acc?}` → `{loc, event}`; an event only when moved > 200 m or 30 min passed | Rules of thumb: the phone writes its profile, claims enigmas, asks for time and marks tasks; the PC only spends, asks for the daily grant and reports; only the parent (user token) grants minutes, answers requests, approves tasks or changes rules. Invitation codes use the pairing alphabet (no 0/O/1/I), 8 characters shown as `XXXX-XXXX`, and expired ones are purged after a week. ## 16. ChronoBackup (`/api/ms/backup/*`, app id `chronobackup`) ChronoBackup is the Windows backup client (a service + a ChronoUI app + `cbk.exe`, driven from Claude Code) built on restic. The Miniserver gives it three things: a **cloud target** (an account on the office restic rest-server, append-only, one private space per user), a **catalog** of jobs and runs, and **alerts**. - The PC pairs like any app (QR pairing, app id `chronobackup`) and gets a device token. - `POST /api/ms/backup/repo` `{rotate?: true}` → `{rest_user, repo_url, rest_password?, node, created, quota_gb, mirrors}`. `rest_password` comes only when the account is created or rotated: the client stores it (DPAPI) and never asks again. `repo_url` is the restic repository (`https://cs-miniserver-r.chronoscan.org/restic//`, used as `rest:` + url with `RESTIC_REST_USERNAME/PASSWORD`). Private beta: admin accounts, or users the administrator approved in `/admin/` → Backups; everyone else gets `403`, and so does an account the administrator disabled (its rest-server access is removed too: expect `401` from the repository). `503` while the server has no `MS_BACKUP_KEY`. If the administrator rotated the password from `/admin/`, the repository answers `401`: call this endpoint with `{rotate: true}` to get a fresh one. - `GET /api/ms/backup/repo` → the account without the password, `404` if none. Same `quota_gb` and `mirrors`. - `quota_gb`: the account's space on the cloud target (GB = 1024³ bytes, measured as the repository's size on disk). Over it, the user gets one email a month and the administrator is told; backups are not stopped. Show it in the client next to the repository's size (`restic stats --mode raw-data`, or the server's figure). - `mirrors`: rest URLs (`https://///`) of read-only copies of the repository on other Miniserver nodes, same credentials, for **reading** (snapshots, restore) when `repo_url` does not answer; never back up to one. Empty today: the first mirror is a plain disk on the office node (docs/chronobackup.md). - `maintenance`: `{state: none|pending|active, keep, keep_set, window, prune_now, last_prune}`. The cloud target is append-only, so the repository never shrinks unless the office node prunes it with a **maintenance key** the user lets it have (opt-in from the client, e.g. *Targets & account* → *Let the server prune old snapshots*): 1. `POST /api/ms/backup/maintenance` `{rotate?}` → `{key_password, state: "pending", keep, window, …}`. `key_password` comes once (empty when a key is already active and `rotate` was not asked). 2. `restic -r rest:… key add --new-password-file ` with the repository password. 3. `POST /api/ms/backup/maintenance/confirm` → `state: "active"`. From then on the node runs `restic forget --prune` once a day inside `window` (node local time) with `keep`. - `PATCH /api/ms/backup/maintenance` `{keep?: {last, hourly, daily, weekly, monthly, yearly}, window?: "HH:MM-HH:MM"}`: the retention (restic `--keep-*`; it must keep something) and the window. `keep_set = false` means the server default is in force (shown in `keep`). - `DELETE /api/ms/backup/maintenance`: forget the key on the server (then `restic key remove ` on the PC). - A prune holds the repository's exclusive lock: run backups with `--retry-lock 1h` so a backup that meets a prune waits instead of failing. `last_prune` = `{at, ok, error, seconds, snapshots, removed, freed}`. - Trust: with the key the server can read and delete in the repository. Without it, it only stores encrypted files. - Catalog items, written by the client with the plain items API (section 3): - `jobs/__`: `{job, name, device, enabled, paths, profile, schedule, repo, last_start, last_end, last_status, last_error, last_snapshot, next_run, missed_hours, alert_on_failure, total_files, total_bytes, version}` (times in UTC). - `runs/____`: `{job, name, device, status: ok|warnings|failed|cancelled, error, started, ended, seconds, snapshot, repo, profile, paths, files_new, files_changed, files_unmodified, total_files, total_bytes, data_added, data_added_packed}`. - Alerts (primary only): a `failed` run → email to the user (and Telegram to José for admin accounts), once per run; hourly, a job whose `last_start` is older than its `missed_hours` → "Backup overdue", once per silence. Both leave an `ms_events` row (`backup.run.failed`, `backup.job.missed`). - The office node agent receives the enabled accounts (`backup_users`) with its rules and keeps the rest-server's `.htpasswd` in step; mirrors on other nodes (planned) will receive the same list. - `GET /api/cb/status` and `GET /api/cb/download` serve the Windows setup like ChronoDuplicates (account required).