diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-17 19:55:36 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-17 19:55:36 +0200 |
| commit | 380195f7cd5e57acf2c1cf2bc41069e6b0b979ed (patch) | |
| tree | 32a88fb22a7fbe8f1fd5c105156d1f928c93950d /docs/PROTOCOL.md | |
| download | bokf-a154c5d9585d54ebdaae7234e65bbf120121698d.tar.gz bokf-a154c5d9585d54ebdaae7234e65bbf120121698d.zip | |
Initial commit: daemon, clients, docs, Docker deploy pipelinev0.1.0
Diffstat (limited to 'docs/PROTOCOL.md')
| -rw-r--r-- | docs/PROTOCOL.md | 469 |
1 files changed, 469 insertions, 0 deletions
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..6dc8c7b --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,469 @@ +# bokf protocol v1 + +Status: Draft 0.1 · 2026-09-17 · License: GPL-3.0-or-later + +`bokf` is a self-hosted bookkeeping system for Swedish organizations (aktiebolag, +enskild firma, handelsbolag, föreningar). One daemon owns the ledger. Every +client — the CLI (`bokfctl`), the ncurses UI (`bokftui`) and any LLM agent — +speaks exactly this protocol. There is no privileged client and no second API. + +## 1. Design principles + +1. **One API.** TUI, CLI and agent are peers. Anything one can do, all can do. +2. **Self-describing.** A client can discover every command, argument and + permission at runtime (`describe`), plus prose workflow rules + (`agent.instructions`). +3. **Dry run everywhere.** Every mutating command accepts `dry_run: true` and + must have no side effects when it is set. +4. **The server owns the invariants.** Number series, balance, period locks, + append-only vouchers, hash chains and tenant isolation are enforced inside + the daemon, never trusted to clients. +5. **Machine-first errors.** Stable error codes and machine-readable `details`. + +## 2. Components + +| Component | Role | +|---|---| +| `bokfd` | Daemon. Sole owner of the SQLite database. Serializes writes. | +| `bokfctl` | Scriptable CLI client. First client built; used for tests and automation. | +| `bokftui` | ncurses client for interactive reviewing and entry. | +| agent | Any process using a token; a token is its own audit actor. | + +No client reads the database file or the filesystem directly. Backups are made +by the daemon (`backup.snapshot`), not by clients. + +## 3. Transport + +### 3.1 Unix domain socket (default) + +- Default path: `/run/bokfd/bokfd.sock` (configurable). +- File mode `0660`, owned by a dedicated group. Access to the socket is the + first line of defense. +- Intended for `bokftui`, `bokfctl` and agents running on the same host. + +### 3.2 TCP (optional) + +- Disabled by default. When enabled it binds `127.0.0.1` unless explicitly + configured otherwise. +- TLS is **not implemented in v1**. The intended deployments are: + - loopback + SSH tunnel (`ssh -L`), or + - a private overlay network (Tailscale/WireGuard), or + - a reverse proxy that terminates TLS in front of `bokfd`. +- Every command on TCP requires authentication, including read commands. + `meta` and `health` are the only unauthenticated commands. + +### 3.3 Framing + +- UTF-8, one JSON object per line, `\n` terminated (JSON Lines / NDJSON). +- One response line per request line, in order. Requests may be pipelined; + responses preserve order. Match on `id`. +- Default maximum request line: 1 MiB. Because attachments travel base64 in + one line, the daemon accepts lines large enough for + `max_attachment_bytes` (default 10 MiB decoded, ~13.4 MiB encoded plus + envelope). +- Idle connections are closed after 10 minutes. Sessions have their own TTL + (default 8 h, sliding). + +## 4. Authentication and authorization + +### 4.1 Sessions + +`session.open` authenticates with either a username/password or an API token +and returns an opaque, high-entropy session id: + +```json +{"v":1,"id":"1","cmd":"session.open","args":{"method":"password","username":"anders","password":"..."}} +{"id":"1","ok":true,"result":{"session":"s_9f3...","user":{"id":1,"username":"anders","display_name":"Anders"},"orgs":[{"id":1,"name":"AB Ett","role":"owner"}],"active_org":1}} +``` + +- Sessions live in memory only. Restarting `bokfd` logs everyone out; that is + intentional. API tokens survive restarts. +- Sliding TTL, `session_ttl` default 8 h. `session.close` ends one explicitly. +- Passwords are stored as Argon2id hashes. Failed logins are rate limited per + peer (default: 5 failures per 15 minutes, then `RATE_LIMITED`). +- Token lookups compare SHA-256 hashes in constant time. Token values are + shown exactly once at creation and are never logged. + +### 4.2 Tokens + +- Format: `bokf_` + 32 random bytes, base64url. Stored as SHA-256 hash. +- A token is bound to one user and one org, has scopes (`read`, `write`, + `admin`) and is an independent audit actor (label shown in history). +- Tokens are the intended mechanism for agents and for accountant/viewer + access. They can be revoked immediately (`token.revoke`). + +### 4.3 Roles and permissions + +Roles are per membership (org, user): `owner`, `bookkeeper`, `viewer`. +Scopes on a token can narrow but never widen the user's role. + +| Capability | viewer | bookkeeper | owner | system admin | +|---|---|---|---|---| +| Read: vouchers, reports, audit, accounts | ● | ● | ● | ● | +| `voucher.post`, `voucher.correct`, `attachment.put` | | ● | ● | | +| `sie.import`, `account.create`, `account.update` | | ● | ● | | +| `period.lock`, `fiscal_year.open/close`, `org.update` | | | ● | | +| `org.member_*`, `token.create` for others | | | ● | | +| `user.create`, any org | | | | ● | +| `backup.snapshot` | | | ● | ● | + +Any authenticated user may create a new org (config `allow_org_create`, +default true) and becomes its owner. + +### 4.4 Org scoping + +Every command executes in exactly one org context. The `org` field in the +request selects it; without it the session's `active_org` is used. The server +checks membership and role before touching data. Isolation is additionally +enforced at the database level via composite keys (see `SCHEMA.md`). + +## 5. Message format + +### 5.1 Request + +| Field | Type | Notes | +|---|---|---| +| `v` | int | Required. Protocol version, currently `1`. | +| `id` | string | Required. Client-generated; echoed in the response. | +| `cmd` | string | Required. Command name, e.g. `voucher.post`. | +| `session` | string | Session id from `session.open`. | +| `org` | int | Optional org id. Overrides `active_org`. | +| `dry_run` | bool | Optional, default false. No side effects when true. | +| `args` | object | Command arguments. Required for commands that take any. | + +### 5.2 Response + +```json +{"id":"42","ok":true,"result":{...},"warnings":[{"code":"FY_ENDING","message":"..."}]} +{"id":"43","ok":false,"error":{"code":"UNBALANCED","message":"Debit and credit differ","details":{"difference_ore":1250}}} +``` + +`ok:true` always has `result` (may be `{}`). `ok:false` always has `error`. +`warnings` is optional and only appears on success. + +### 5.3 Error codes + +`PARSE_ERROR`, `UNSUPPORTED_VERSION`, `UNKNOWN_COMMAND`, `INVALID_ARGS`, +`AUTH_REQUIRED`, `AUTH_FAILED`, `SESSION_EXPIRED`, `RATE_LIMITED`, +`ORG_REQUIRED`, `ORG_FORBIDDEN`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, +`UNBALANCED`, `ACCOUNT_NOT_FOUND`, `ACCOUNT_INACTIVE`, `FISCAL_YEAR_NOT_FOUND`, +`FISCAL_YEAR_CLOSED`, `PERIOD_LOCKED`, `DATE_OUT_OF_RANGE`, `IMMUTABLE`, +`SEQUENCE_GAP`, `TOO_LARGE`, `UNSUPPORTED`, `DB_BUSY`, `INTERNAL`. + +Codes are stable; `message` is human-readable and may change. `details` is +machine-readable where offered. + +### 5.4 Value conventions + +- **Money** is always integer **öre** (1/100 SEK) in JSON. Never floats. +- **Dates** are `YYYY-MM-DD`. **Timestamps** are RFC 3339 UTC with `Z`. +- **Account numbers** are strings (`"1930"`) to preserve leading zeros. +- **Org numbers** are strings (`"5560123456"`), not integers. + +### 5.5 Idempotency + +Any mutating command may carry `args.client_ref`, a client-generated string +(≤ 64 chars, unique per org). If the same `client_ref` is seen again, the +server returns the original result with `"replayed":true` instead of posting +twice. Agents should always set it on writes. + +### 5.6 Dry run + +`dry_run: true` performs the full validation path — role check, period lock, +balance, account existence, VAT computation, number assignment preview — and +returns exactly what would be written, with `"dry_run":true` in the result. +Nothing is persisted, including audit entries. + +### 5.7 Batching + +```json +{"v":1,"id":"7","cmd":"batch","session":"s_...","args":{"atomic":true,"requests":[ + {"cmd":"voucher.post","args":{...}}, + {"cmd":"attachment.put","args":{...}} +]}} +``` + +Executes requests in order. `atomic:true` wraps them in one transaction. +Each element's response is returned in `result.results`; execution stops at the +first error unless `"continue_on_error":true`. + +### 5.8 Pagination + +List results have the shape `{"items":[...],"next_cursor":"..."}`. +Pass `args.cursor` back to continue. `limit` defaults to 100, maximum 1000. +Cursors are opaque. + +## 6. Discovery + +### 6.1 `meta` / `health` (unauthenticated) + +`meta` returns server version, protocol version `1`, enabled features +(`checks`, `sie`, `vat_report`), configured limits and whether TCP is enabled. +`health` returns `{"status":"ok"}`. + +### 6.2 `describe` + +Returns the full command catalogue. `describe {"cmd":"voucher.post"}` returns +one entry. Each entry: + +```json +{ + "name":"voucher.post","summary":"Post an immutable voucher", + "permission":{"role":"bookkeeper","require_org":true}, + "mutating":true,"dry_run":true, + "args":{"date":{"type":"date","required":true}, + "rows":{"type":"array","min":2,"of":{...}}}, + "result":{...}, + "examples":[{"args":{...},"result":{...}}] +} +``` + +This is the primary integration surface for agents: call `describe`, then act. + +### 6.3 `agent.instructions` + +Returns Markdown workflow rules served by the daemon itself, so operational +policy versions with the software. It covers, at minimum: + +- authenticate with a token, pick org, never store passwords +- always `dry_run` first, then post with the same `client_ref` +- amounts are öre; accounts are strings; dates `YYYY-MM-DD` +- never attempt to edit or delete: corrections are new vouchers + (`voucher.correct`) +- receipts: `attachment.put` before or together with posting +- locked periods and closed years are hard stops — ask the human +- `fiscal_year.close`, `period.lock` and `sie.import` are irreversible + operations: confirm with the human first +- how to read `report.vat` boxes and `report.balance_sheet` +- on `CONFLICT`/`replayed`, fetch the existing object instead of retrying + +## 7. Commands + +Arguments are shown abbreviated; `describe` is authoritative. + +### 7.1 Session, orgs, users, tokens + +| Command | Args | Result | +|---|---|---| +| `session.open` | `method`, `username`, `password`, `token` | `session`, `user`, `orgs[]`, `active_org` | +| `session.close` | — | `{}` | +| `session.whoami` | — | `user`, `active_org`, `role`, `scopes` | +| `session.use_org` | `org` | `active_org`, `role` | +| `org.create` | `name`, `org_nr?`, `fiscal_year_start_month?`, `moms_period?`, `framework?` | `org` | +| `org.get` / `org.list` | `org?` | `org` / `items[]` | +| `org.update` | `org`, fields | `org` (owner) | +| `org.member_list` | `org` | `items[{user,role}]` | +| `org.member_add` | `org`, `username`, `role` | `{}` | +| `org.member_set_role` | `org`, `username`, `role` | `{}` | +| `org.member_remove` | `org`, `username` | `{}` | +| `user.create` | `username`, `password`, `display_name`, `is_admin?` | `user` (system admin) | +| `user.list` | — | `items[]` (system admin) | +| `token.create` | `label`, `scopes[]`, `org`, `expires_at?` | `token` (shown once), `id` | +| `token.list` / `token.revoke` | — / `id` | `items[]` / `{}` | + +### 7.2 Kontoplan + +| Command | Args | Notes | +|---|---|---| +| `account.list` | `active_only?` | BAS 2026 seeded at org creation | +| `account.get` | `id` or `number` | | +| `account.create` | `number`, `name`, `type`, `sru_code?`, `vat_code?` | bookkeeper | +| `account.update` | `id`, `name?`, `sru_code?`, `vat_code?`, `active?` | bookkeeper, audited | + +Account `type` is one of `asset`, `liability`, `equity`, `revenue`, `expense`. + +### 7.3 Fiscal years and locks + +| Command | Args | Notes | +|---|---|---| +| `fiscal_year.list` / `fiscal_year.get` | `org?` / `id` | | +| `fiscal_year.open` | `label`, `start_date`, `end_date` | owner | +| `fiscal_year.close` | `id`, `confirm:true` | owner; irreversible | +| `period.lock` | `fiscal_year`, `until`, `reason?` | owner; `until` inclusive | +| `period.unlock` | `fiscal_year`, `reason` | owner; audited with reason | + +Postings dated on or before `locked_until` are rejected with `PERIOD_LOCKED`. +Years with status `closed` reject all postings. + +### 7.4 Vouchers + +| Command | Args | Notes | +|---|---|---| +| `voucher.post` | `date`, `description?`, `rows[]` **or** `template`+`x`, `series?`, `corrects_voucher?`, `attachment_ids?`, `client_ref?` | dry-run supported | +| `voucher.get` | `id` | | +| `voucher.list` | `fiscal_year?`, `from?`, `to?`, `series?`, `account?`, `text?`, `limit`, `cursor` | | +| `voucher.correct` | `voucher`, `description`, `date?`, `client_ref?` | creates ändringsverifikat | + +A row is `{"account":"1930","debit_ore":125000,"credit_ore":0,"description":"..."}`. +Exactly one of `debit_ore`/`credit_ore` may be non-zero; at least two rows; +sum debit = sum credit. `voucher.correct` mirrors the original rows, links the +new voucher to the original via `corrects_voucher`, and posts it as a normal +immutable voucher. The original is never touched. + +### 7.4.1 Templates (konteringsmallar) + +Instead of `rows`, a post may name a template and supply its variable: +`{"template":"Försäljning 25%","x":"1250","date":"2026-01-15"}`. + +| Command | Args | Notes | +|---|---|---| +| `template.list` | `active_only?` | id, name, series, row_count | +| `template.get` | `id` or `name` | includes rows with account, formula | +| `template.create` | `name`, `series?`, `description?`, `rows[]{account,formula,description?}` | bookkeeper | +| `template.update` | `id` or `name`, plus fields/`rows[]` to replace | bookkeeper | +| `template.archive` | `id` or `name` | soft delete; bookkeeper | + +Formulas use `x`, decimal numbers and `+ - * /` with parentheses, evaluated +in kronor. A positive result is a debit, a negative result a credit, a zero +result drops the row. Rows are rounded to whole öre and the rounding +remainder is assigned to the largest row so the voucher balances. `{x}` in +the template description (used when the request carries none) is replaced by +the amount. Unknown accounts and invalid formulas are rejected when the +template is saved; `voucher.post` re-validates at apply time and reports the +resolved rows in a dry run. + +### 7.4.2 Settings + +| Command | Args | Notes | +|---|---|---| +| `settings.get` | — | effective org settings (defaults included) | +| `settings.set` | `key`, `value` | known keys: `default_series`, `attachment_dir` | + +`default_series` (1–8 characters, e.g. `A`, `V-`, `A `) is used when +`voucher.post` carries no `series` and as the default series for new +templates. `attachment_dir` (a path, up to 255 characters) is the folder the +TUI file browser opens in when attaching underlag. Verification ids are the concatenation of series and number +(`V-8`), and series are free-form: only an unbroken numbering per series is +required. + +### 7.5 Attachments (underlag) + +| Command | Args | Notes | +|---|---|---| +| `attachment.put` | `filename`, `mime`, `content_base64`, `voucher_id?` | ≤ max_attachment_bytes | +| `attachment.get` | `id` | returns base64 + sha256 | +| `attachment.list` | `voucher_id?`, `unlinked?`, `limit`, `cursor` | inbox = `unlinked:true` | + +Attachments are immutable and content-addressed by SHA-256. They are linked to +vouchers at posting time (`attachment_ids`) or afterwards via +`attachment.put` with `voucher_id`; links are separate insert-only rows. + +### 7.6 Reports + +| Command | Args | Result | +|---|---|---| +| `report.trial_balance` | `fiscal_year`, `from?`, `to?`, `include_zero?` | saldo per account, IB/UB | +| `report.income_statement` | `fiscal_year`, `from?`, `to?` | resultaträkning, K2-ish grouping | +| `report.balance_sheet` | `fiscal_year`, `to?` | balansräkning | +| `report.general_ledger` | `fiscal_year`, `accounts?`, `from?`, `to?` | huvudbok | +| `report.voucher_list` | `fiscal_year`, `series?` | grundbok/verifikationslista | +| `report.vat` | `from`, `to`, `period_type?` | momsdeklaration ruta för ruta | + +All reports are pure reads, respect locks, and return JSON rows. Amounts are +öre. `report.vat` returns `{"boxes":[{"box":"05","label":"...","amount_ore":...}],"period":{...}}`. + +### 7.7 SIE 4 + +| Command | Args | Result | +|---|---|---| +| `sie.export` | `fiscal_year`, `inline?` | by default writes `<export_dir>/<org>_<fy>.se` and returns `path`, `sha256`, `size`; `inline:true` also returns `content_base64` | +| `sie.import` | `content_base64` or `path`, `dry_run?` | creates missing accounts and posts #VER as `source:"sie_import"`; only into an empty org fiscal year | + +SIE 4 files are written in CP437 with PC8 format, `#SIETYP 4`, `#FNR`, `#ORGNR`, +`#KONTO`, `#IB`, `#UB`, `#RES`, `#VER`, `#TRANS`. Import is the migration path +from Fortnox/Visma/BL and must be dry-run first; it reports exactly what would +be created. + +### 7.8 Backup and audit + +| Command | Args | Result | +|---|---|---| +| `backup.snapshot` | `dest?` | `path`, `sha256`, `size`, `at` — uses SQLite `VACUUM INTO`, no downtime | +| `audit.list` | `from?`, `to?`, `action?`, `actor?`, `limit`, `cursor` | behandlingshistorik | +| `audit.verify` | `full?` | recomputes voucher and audit hash chains; `ok`, `checked`, first/last mismatch if any | + +`audit.verify` is cheap enough to run after every import and before every +backup; `full:true` includes attachment hashes. + +## 8. The TUI is just a client + +`bokftui` logs in over the same socket, picks an org and issues the same +commands. Implemented screens (0.1.0-dev): + +- **Inloggning** — server, user, password; org picker when several exist. +- **Dashboard** — status line with org, fiscal year, role and user. +- **Verifikat** — list and detail view (rows, attachments, hash, link to + corrected voucher); `c` posts an ändringsverifikat. +- **Nytt verifikat** — row editor with live balance display, F5 dry-run + validation and F9 posting; one `client_ref` per form makes retries safe. + F4 applies a konteringsmall (prompts for template and `x`). +- **Ingående balans** — the series `IB` voucher for the selected fiscal year; + enter accounts with signed amounts (positive debit, negative credit), the + editor posts deltas so existing entries are never edited. +- **Välj organisation att representera** — login step two: pick which org the + session works in. `--org ID` skips the picker for scripts. +- **Byt räkenskapsår** (dashboard) — pick from the org's fiscal years; shown + as `YYYY-MM-DD - YYYY-MM-DD` (plus label and open/closed) so broken fiscal + years are visible. All screens then work in the selected year. +- **Mallar** — list, create and edit templates in the same form style as + vouchers (Tab, dynamic rows, F7 clear row, F5 validate, F9 save); archive + keeps the template but hides it from the list. +- **Underlag** — inbox of unlinked attachments; `a` uploads a file. +- **Rapporter** — saldobalans, resultaträkning, balansräkning, moms and + kontolista (all accounts with type, moms treatment, SRU and status). +- **Revision** — chain verification and behandlingshistorik. + +**Ctrl+N is the universal "add" key**: it starts a new verifikat from the +dashboard, the voucher list and the voucher detail view; a new mall from the +Mallar menu; a new fiscal year from the year picker; and maps to the editor +in Ingående balans and to file upload in Underlag. F5 is the universal +refresh. Hints show the keys per screen. + +Still missing from the UI (API already supports): SIE export/import, +period locks, member/token administration. The TUI holds no local state +beyond the session and calls nothing but public commands. + +## 9. Versioning + +- `v` is the protocol major version. The server rejects unknown majors with + `UNSUPPORTED_VERSION`. +- v1 evolves additively only: new commands, new optional fields, new error + codes. Removals or semantic changes require v2. +- `meta.capabilities` lets clients feature-detect without version sniffing. + +## 10. Security notes + +- Bind nothing publicly by default. Loopback or Unix socket unless the operator + opts in. +- Passwords: Argon2id (vendored reference implementation). Tokens: 256-bit + random, stored hashed, revocable, never logged. Sessions: memory only. +- Audit and logs redact secrets: `session.open` records username and outcome, + never the password or token value. +- Socket and database files are `0600`/`0660`; backups inherit the same + discipline. +- For data at rest, prefer LUKS on the host. SQLCipher support is a possible + later option; not in v1. +- The server answers `DB_BUSY` rather than blocking indefinitely when a writer + holds the database; clients should retry with backoff. + +## 11. Server configuration + +`/etc/bokfd/bokfd.conf`, overridable by `BOKFD_*` environment variables: + +| Key | Default | Meaning | +|---|---|---| +| `socket` | `/run/bokfd/bokfd.sock` | Unix socket path | +| `tcp` | off | `host:port` to enable TCP | +| `db` | `/var/lib/bokfd/bokfd.db` | SQLite database | +| `backup_dir` | `/var/lib/bokfd/backup` | destination for `backup.snapshot` | +| `export_dir` | `/var/lib/bokfd/export` | SIE exports | +| `session_ttl` | `8h` | sliding session lifetime | +| `max_line_bytes` | `1048576` | NDJSON line limit | +| `max_attachment_bytes` | `10485760` (10 MiB) | decoded attachment limit | +| `auth_fail_limit` | `5/15m` | login rate limit per peer | +| `synchronous` | `FULL` | SQLite durability (`FULL`/`NORMAL`) | +| `audit_reads` | `false` | log read commands too | +| `allow_org_create` | `true` | any user may create an org | + +Container deployments mount the socket directory, database directory, backup +and export directories as volumes; the daemon is otherwise stateless. |
