No description
  • Rust 91%
  • Shell 8.1%
  • PLpgSQL 0.5%
  • Dockerfile 0.4%
Find a file
Kerry Hatcher 0590b48864
All checks were successful
build / image (push) Successful in 47s
fix(auth): prefix the session cookie with __Host-
A browser honours the prefix only for a cookie that is Secure, is Path=/, and
sets no Domain; all three were already true, so the rename itself is one
constant. In exchange the prefix forbids Domain scoping, which shuts out cookie
injection from a sibling subdomain — the shape per-PR preview environments are
about to create under hatchtech.dev.

The tests needed more than the rename. tests/auth.rs and tests/oauth.rs
hardcoded the literal "ob_session" in 48 places, so the rename turned ~20 of
them red; they now build their Cookie headers from session::COOKIE_NAME, and
cookie_from() strips the same constant. Exactly one place still names the
literal — the new assertion that pins it — so the next rename costs nothing.
One assertion was checking `header.contains("ob_session=abc")`, which survives
the rename by substring accident; it uses the constant now too.

Verified in a browser: Chromium accepts __Host-ob_session on plain-HTTP
localhost (host-only, Secure, Path=/), sends it cross-port to the API, and the
server reads it back — logout answers 204 and clears it, while the ledger
answers 401 for a half-session.

Existing sessions are invalidated: browsers hold ob_session and the server now
looks for __Host-ob_session. The rows expire on their own schedule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dzGnUu13aw9sWokr7j1fV
2026-08-07 17:33:09 -04:00
.forgejo/workflows ci: build and publish the api image with podman 2026-08-07 02:24:11 -04:00
migrations feat(oauth): device authorization and the device_code grant 2026-08-05 13:08:19 -04:00
src fix(auth): prefix the session cookie with __Host- 2026-08-07 17:33:09 -04:00
tests fix(auth): prefix the session cookie with __Host- 2026-08-07 17:33:09 -04:00
.dockerignore ci: build and publish the api image with podman 2026-08-07 02:24:11 -04:00
.gitignore feat: double-entry ledger API with postgres and meeting reports 2026-08-03 13:33:56 -04:00
Cargo.lock feat: build a webauthn relying party at boot 2026-08-03 22:54:09 -04:00
Cargo.toml feat(oauth): reap expired codes, tokens, sessions, and challenges 2026-08-05 23:12:47 -04:00
CLAUDE.md docs(claude-md): point the superproject link at Forgejo, not the stale GitHub mirror 2026-08-07 13:39:27 -04:00
compose.yaml refactor: publish postgres on 38083 2026-08-03 15:53:21 -04:00
Dockerfile fix(ci): fully qualify Dockerfile base images 2026-08-07 02:29:34 -04:00
LICENSE Initial commit 2026-08-03 12:58:06 -04:00
README.md refactor: publish postgres on 38083 2026-08-03 15:53:21 -04:00
script-auth.sh feat(scripts): let script-auth.sh use a pre-built binary via OPENBOOKS_BIN 2026-08-07 17:19:14 -04:00
script-auth.test.sh docs(scripts): record why the dev-path test stages no fake binary 2026-08-07 17:21:08 -04:00
seed.sh feat(seed): two users and both token states 2026-08-06 00:09:00 -04:00
smoke.sh feat(oauth): add RFC 7009 token revocation 2026-08-05 21:52:58 -04:00

openbooks-api

Rust API for a family or club to track a bank account, cash, and expenses on a real double-entry ledger — without anyone needing to know what double-entry means.

Very early POC: no auth, no RBAC, CORS wide open. Don't put real money data in it.

Run it

docker compose up -d          # postgres on host port 38083
cargo run                     # migrations run on startup; listens on :38081
./smoke.sh                    # end-to-end check (needs jq)

Config via env: DATABASE_URL, BIND_ADDR.

How the ledger works

One table of entries, each with a signed amount_cents: debit is positive, credit is negative. A transaction is balanced when its entries sum to zero, and a deferred constraint trigger in Postgres enforces that — so no API bug can write crooked books.

Money is stored as integer cents. No floats anywhere.

Reports flip the sign on income, liability, and equity accounts so every statement line reads as a positive number.

Endpoints

Method Path Notes
GET /health
GET /accounts
POST /accounts {name, kind}, kind is asset/liability/equity/income/expense
GET /transactions?from=&to= entries nested per transaction
POST /transactions see below
DELETE /transactions/{id} removes both legs
GET /reports/balances?from=&to= balance per account
GET /reports/income-statement?from=&to= the meeting report: money in, money out, net
GET /reports/balance-sheet?as_of= what we own vs. owe
POST /mcp MCP endpoint, see below

Recording $45 of pizza paid from checking:

curl -X POST localhost:38081/transactions -H 'content-type: application/json' -d '{
  "occurred_on": "2026-03-05",
  "description": "Meeting pizza",
  "entries": [
    {"account_id": 7, "amount_cents":  4500},
    {"account_id": 1, "amount_cents": -4500}
  ]
}'

Unbalanced or single-legged transactions come back 400.

MCP

POST /mcp speaks MCP over Streamable HTTP, so an assistant can read the books and record transactions. Six tools: list_accounts, account_balances, income_statement, balance_sheet, list_transactions, and record_transaction.

Point a client at http://localhost:38081/mcp — the repo's .mcp.json already does. Request/response only; there's no SSE stream, so GET /mcp returns 405.

Each tool calls the same function as the matching HTTP handler, so the two interfaces can't disagree about the ledger — smoke.sh asserts they return identical numbers. record_transaction goes through the same validation, so unbalanced entries come back as JSON-RPC -32602 rather than being written.

curl -s localhost:38081/mcp -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq -r '.result.tools[].name'

Same no-auth caveat as the rest of the API: anything that can reach the port can write to the books.

Chart of accounts

Migration 0002 seeds Checking Account, Cash Box, Dues, Donations, Fundraising, Supplies, Food, Rent, Fees, and Opening Balance. Add your own via POST /accounts.