# 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://jluis-miniserver.swedencentral.cloudapp.azure.com` - **This guide:** `https://jluis-miniserver.swedencentral.cloudapp.azure.com/API.md` - **User web (sign up, devices, approve links):** `https://jluis-miniserver.swedencentral.cloudapp.azure.com/` - **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://jluis-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://jluis-miniserver.swedencentral.cloudapp.azure.com" export async function pair({ name, app, platform, onCode }) { const s = await (await fetch(`${BASE}/api/ms/link/start`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, app, platform }), })).json() onCode(s) // render QR of s.qr_payload + show s.code for (;;) { await new Promise((r) => setTimeout(r, s.poll_interval_seconds * 1000)) const res = await fetch(`${BASE}/api/ms/link/poll`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: s.code, secret: s.secret }), }) if (res.status === 200) return res.json() // { device_id, device_secret, token, record, device } if (res.status !== 202) throw new Error(`pairing failed: ${res.status}`) } } export function client({ deviceId, deviceSecret }) { let token = null async function refresh() { const r = await fetch(`${BASE}/api/ms/device/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_id: deviceId, device_secret: deviceSecret }), }) if (r.status === 403) throw new Error("device revoked") if (!r.ok) throw new Error(`auth failed: ${r.status}`) token = (await r.json()).token } async function api(method, path, body, retry = true) { if (!token) await refresh() const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json", Authorization: token }, body: body === undefined ? undefined : JSON.stringify(body), }) if (r.status === 401 && retry) { token = null; return api(method, path, body, false) } if (r.status === 404) return null if (!r.ok) throw new Error(`${method} ${path}: ${r.status} ${await r.text()}`) return r.status === 204 ? null : r.json() } const p = (a, c, k) => `/api/ms/items/${encodeURIComponent(a)}/${encodeURIComponent(c)}/${encodeURIComponent(k)}` return { put: (a, c, k, data) => api("PUT", p(a, c, k), { data, device: deviceId }), get: async (a, c, k) => (await api("GET", p(a, c, k)))?.data ?? null, del: (a, c, k) => api("DELETE", p(a, c, k)), sync: (a, cursor = "") => api("GET", `/api/ms/sync/${a}?cursor=${encodeURIComponent(cursor)}`), } } ``` ### C# (.NET 8) ```csharp using System.Net.Http.Json; using System.Text.Json.Nodes; public sealed class Miniserver(string baseUrl, string deviceId, string deviceSecret) { readonly HttpClient _http = new() { BaseAddress = new Uri(baseUrl) }; string? _token; async Task RefreshAsync() { var r = await _http.PostAsJsonAsync("/api/ms/device/token", new { device_id = deviceId, device_secret = deviceSecret }); r.EnsureSuccessStatusCode(); _token = (await r.Content.ReadFromJsonAsync())!["token"]!.GetValue(); } async Task SendAsync(HttpMethod m, string path, object? body = null, bool retry = true) { if (_token is null) await RefreshAsync(); var req = new HttpRequestMessage(m, path) { Content = body is null ? null : JsonContent.Create(body) }; req.Headers.TryAddWithoutValidation("Authorization", _token); var res = await _http.SendAsync(req); if (res.StatusCode == System.Net.HttpStatusCode.Unauthorized && retry) { _token = null; return await SendAsync(m, path, body, false); } return res; } static string P(string a, string c, string k) => $"/api/ms/items/{Uri.EscapeDataString(a)}/{Uri.EscapeDataString(c)}/{Uri.EscapeDataString(k)}"; public async Task PutAsync(string app, string col, string key, object data) => (await SendAsync(HttpMethod.Put, P(app, col, key), new { data, device = deviceId })).EnsureSuccessStatusCode(); public async Task GetAsync(string app, string col, string key) { var r = await SendAsync(HttpMethod.Get, P(app, col, key)); if (r.StatusCode == System.Net.HttpStatusCode.NotFound) return null; r.EnsureSuccessStatusCode(); return (await r.Content.ReadFromJsonAsync())!["data"]; } } ``` ### curl ```bash TOKEN=$(curl -s -X POST "$MINISERVER_URL/api/ms/device/token" -H 'Content-Type: application/json' \ -d "{\"device_id\":\"$MINISERVER_DEVICE_ID\",\"device_secret\":\"$MINISERVER_DEVICE_SECRET\"}" | jq -r .token) curl -s -X PUT "$MINISERVER_URL/api/ms/items/notes/todo/t1" -H "Authorization: $TOKEN" \ -H 'Content-Type: application/json' -d '{"data":{"text":"buy milk"}}' curl -s "$MINISERVER_URL/api/ms/sync/notes" -H "Authorization: $TOKEN" ``` --- ## 7. Endpoint reference | Method | Path | Auth | Purpose | |---|---|---|---| | GET | `/api/ms/info` | – | Server name, URLs, token TTL | | POST | `/api/ms/link/start` | – | Start QR pairing → code, secret, qr_payload | | POST | `/api/ms/link/poll` | – | `202` pending · `200` credentials + token · `410` expired/used | | GET | `/api/ms/link/info?code=` | user | Details of a pending pairing (used by /link page) | | POST | `/api/ms/link/approve` | user | Approve pairing `{code, name?}` | | POST | `/api/ms/devices/register` | user | Create device credentials directly | | POST | `/api/ms/device/token` | – | device_id + secret → 12 h user token | | GET/PUT/DELETE | `/api/ms/items/{app}/{collection}/{key}` | user | Get / upsert / soft-delete an item | | GET | `/api/ms/sync/{app}?cursor=&collection=&limit=` | user | Changes since cursor (incl. tombstones) | | * | `/api/collections/{items,files,devices}/records…` | user | Standard PocketBase CRUD (owner-only) | | POST | `/api/files/token` | user | Short-lived token for protected file downloads | | GET | `/api/realtime` | user | SSE realtime | 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. - Password-reset and verification emails are not enabled yet (no SMTP). ## 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. Show the user which account they're linked to (`record.email`), and give them an "Unlink" action that forgets the stored credentials.