System overview
The moving parts, the rules that shape them, and how one request flows from the browser or a phone to the database.
In one line: each instance is one msaving container (API, web app, daily jobs) and a Postgres database,
started with one docker-compose.yml on the operator’s machine. Clients sign in through the API itself and
send its session token; Postgres does the access control with row-level security.
phone (mobile app, later) browser
│ │ opens https://money.example.com
│ no Origin header │ web app files + API on the same origin
▼ ▼
[ optional: Caddy / Tailscale / Cloudflare Tunnel for HTTPS ]
│
▼
msaving container ─────────────────────────────────────────────
app/launch.py: secrets → wait for db → backup + migrate → setup code
uvicorn (FastAPI) scheduler (daily)
1. own web app only (Origin check) account_deletion (purge_login)
2. session token → session_user_id() backup (owner, pg_dump to /backups)
3. begin transaction fx_update (fx_login)
4. set_config('app.user_id', <user>, true)
5. route, then commit
│ api_login (app_user): no BYPASSRLS, no owner rights
▼
Postgres 17 (db container, or the operator's own Postgres / Supabase)
row-level security on every user table
The rules that shape everything
- Operators run it, not us.
git cloneanddocker compose up -d --buildare the whole install; anything else is a bug in the setup. See Running an instance. - The browser never talks to the database. Clients call the API with a session token.
- Access control lives in the database. Every user table has row-level security. If the API forgets a
WHERE book_id = ..., Postgres still returns only the books the user belongs to. With noapp.user_idset, those queries return nothing. - One login per job. The API logs in as
api_login(roleapp_user), which can’t bypass RLS or change the schema, and never gets the owner’s password. See Database roles. - Balances are derived. No balance column anywhere. See The ledger.
- Money is integers in minor units, everywhere: Python
int, Postgresbigint, JSnumber. - No URL in code. A server is whatever address it’s reached at; the web app uses its own origin.
One request, step by step
Saving an expense from the web app:
- The web app generates the transaction’s id when the user clicks Save (so a retry can’t save it twice)
and sends
POST /v1/books/{book_id}/transactionswithAuthorization: Bearer <session token>. app/api/main.pychecks the browser’sOriginis this server’s own address (a phone sends none).app/core/auth.pyhashes the token and askssession_user_id()whose it is: unknown, expired (30 days unused), signed out or disabled gives401 not_signed_in.app/core/db.pyopens one transaction and runsset_config('app.user_id', <user id>, true). Thetruemeans “this transaction only”, so a pooled connection never carries one user into the next request.- The route (
app/api/routes/v1/transactions.py) callsapp/domain/transactions.py, which writes the header and its entries. RLS checks the user is an editor or owner of that book. - The route calls
db.commit()itself, before returning. The ledger’s shape checks run at commit; a commit after the response would be too late to report an error. - Errors come back as
{"detail": "<code>"}and the clients translate the code. See Error codes.
Where the code is
All in the msaving monorepo.
| Concern | File |
|---|---|
| Container start: secrets, backup, migrations, setup code, API + jobs | backend/app/launch.py |
| Migrations runner, logins | backend/app/jobs/migrate.py |
| Schema, RLS, roles, sign-in tables | backend/migrations/ |
| Passwords, codes, tokens, rate limits | backend/app/core/sign_in.py |
| Session token check | backend/app/core/auth.py |
| Per-request database session | backend/app/core/db.py |
App, web app files, CSP, Origin check, /v1/instance |
backend/app/api/main.py |
| Database errors to codes | backend/app/api/errors.py |
| Routes | backend/app/api/routes/v1/ |
| Business logic | backend/app/domain/ |
| Daily jobs and scheduler | backend/app/jobs/ |
| Operator commands | backend/app/admin.py |
| Web: session, API client and types | web/src/auth/, web/src/lib/api.ts |
| Web: queries and cache | web/src/lib/queries.ts |
| Web: money formatting and parsing | web/src/lib/money.ts |
| The image and the stack | Dockerfile, docker-compose.yml |
Details
- Backend: FastAPI, SQLAlchemy 2 and psycopg. SQLAlchemy is for queries; schema changes are SQL migrations (see Database changes).
- Per-request user: FastAPI checks the token, then opens one database transaction with
set_config('app.user_id', <user>, true). The setting ends with the transaction, so a pooled connection never carries one user’s identity into the next request, even after a crash. - Writes commit inside the route. FastAPI runs a dependency’s cleanup after the response is sent, and the
ledger’s shape checks run at commit, so a commit in cleanup could fail after the client got 200
(
request_sessioninapp/core/db.py). - Errors reach the clients as
{"detail": "<code>"}; full list in Error codes. - Routes: every
/v1route is listed in API routes. - Sign-in, invites and the admin: Sign-in and accounts.