MSaving

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

  1. Operators run it, not us. git clone and docker compose up -d --build are the whole install; anything else is a bug in the setup. See Running an instance.
  2. The browser never talks to the database. Clients call the API with a session token.
  3. 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 no app.user_id set, those queries return nothing.
  4. One login per job. The API logs in as api_login (role app_user), which can’t bypass RLS or change the schema, and never gets the owner’s password. See Database roles.
  5. Balances are derived. No balance column anywhere. See The ledger.
  6. Money is integers in minor units, everywhere: Python int, Postgres bigint, JS number.
  7. 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:

  1. 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}/transactions with Authorization: Bearer <session token>.
  2. app/api/main.py checks the browser’s Origin is this server’s own address (a phone sends none).
  3. app/core/auth.py hashes the token and asks session_user_id() whose it is: unknown, expired (30 days unused), signed out or disabled gives 401 not_signed_in.
  4. app/core/db.py opens one transaction and runs set_config('app.user_id', <user id>, true). The true means “this transaction only”, so a pooled connection never carries one user into the next request.
  5. The route (app/api/routes/v1/transactions.py) calls app/domain/transactions.py, which writes the header and its entries. RLS checks the user is an editor or owner of that book.
  6. 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.
  7. 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_session in app/core/db.py).
  • Errors reach the clients as {"detail": "<code>"}; full list in Error codes.
  • Routes: every /v1 route is listed in API routes.
  • Sign-in, invites and the admin: Sign-in and accounts.

Edit this page · Report a problem