Skip to content

Expenses with functions

The same shared-expenses app as "expenses without functions", with its four fixable holes closed by server-side functions — compared side by side.

The same group-expenses tracker as expenses without functions, with the same layout, the same data model’s spirit, and the same rules where nothing needed to change — but holes 1 through 4 are closed, by five small functions instead of client-trusted copies. Hole 5 (signing up with someone else’s email) is closed too, but by email verification, not by functions: the realm requires a verified address, and rules and functions check user.email_verified.

Read expenses without functions first if you haven’t: this page assumes you know the five holes it documents, and spends its time on what’s different here and why.

The configuration is the expenses-with-functions realm in examples/config/expenses-with-functions. The application is in clients/js/examples/expenses-with-functions.

Try it

make example

Open https://localhost:8443/example/expenses-with-functions/. It looks and works exactly like expenses without functions — sign up, create a group, invite a second email, add expenses, watch balances — with one visible difference: “I paid this” now says the other person needs to confirm it, and a pending settlement shows a “Confirm received” button to its receiver.

make hack-expenses-functions

Runs the same kind of attacks as make hack-expenses does against the other example, against this one — and where that script reports holes 1-4 as open, this one reports them closed (and hole 5 too), with real calls, not just by reading the functions’ source.

What changed

Expenses without functionsExpenses with functions
LayoutGroups sidebar · group panel · balances · new expense · historyIdentical
groups collectionRules enforce everything neededUnchanged — no holes there, so nothing to close
expenses collectionmembers: a client-written copy of the group, checked by rulesNo members field. A direct read only ever returns your own entries (read: user != nil && document._meta.owner == user.id); everyone else’s go through list_expenses
Creating an expenseexpenses.create({...}), a direct writedb.fn('add_expense', {...}), which validates group_id and split_between against the real, current group first
Reading a group’s expensesexpenses.iterate({ where: { group_id } }), a direct read using the stale members copydb.fn('list_expenses', { group_id }), which checks the caller’s current group membership every time
Settling upexpenses.create({ kind: 'settlement', ... }) — final the moment it’s writtendb.fn('request_settlement', {...}) records it as status: 'pending'; only db.fn('confirm_settlement', { id }), called by the receiver, makes it count
BalancesComputed in the browser, from whatever entries the client happens to have (ledger.js)db.fn('balances', { group_id }), computed once on the server from data the holes above no longer let anyone fake
ledger.js~75 lines: balances, the settlement plan, toCents~10 lines: only toCents — parsing what someone typed before sending it to the server, the one piece of money math still in the browser

The functions

Five functions in examples/config/expenses-with-functions/main/_functions/, one per hole (roughly) plus a shared helper:

Functionadmin: true?ClosesWhat it does
add_expenseno1Reads the group as the caller (ctx.db, so a non-member’s attempt fails not found before anything is written), checks split_between against its real, current members, then creates the expense as the caller — same as a direct write, just validated first
list_expensesyes1, 2Same group-membership check, then ctx.admin.db lists every expense for that group — bypassing the direct read rule’s owner-only limit, but only after membership passed
request_settlementno3 (half)Same group check on the receiver (to), then creates a settlement with status: 'pending'
confirm_settlementyes3 (half)ctx.admin.db reads the entry (the receiver isn’t its owner, so a direct read wouldn’t see it), checks the caller is really split_between[0], and flips status to 'confirmed' — the one write a direct PATCH could never make, since expenses/collection.yaml’s update rule refuses anyone from changing status, even the entry’s own writer
balancesyes4Same group check, then ctx.admin.db lists the group’s expenses, counts only confirmed settlements (a pending one doesn’t move anyone’s balance yet), and returns the same balances/settlement-plan shape ledger.js used to compute alone

add_expense and request_settlement need only ctx.db, acting as the caller — everything they check is already something the rules would let that caller do themselves; the function just does the group lookup in the same call instead of trusting a copy. list_expenses, confirm_settlement and balances need admin: true because their whole job is showing or changing something that belongs to someone else (another member’s expenses, another person’s pending settlement) — the function itself, via its own membership or receiver check, is what makes that safe, not the rules.

Every function that reads a group re-throws ctx.db’s own error (see lib/relay.ts) instead of letting it become a generic 500 function_failed: only what a function throws via ctx.error(...) reaches its caller with a real status, so an uncaught error from ctx.db needs to be turned into one on purpose. A non-member calling any of these five sees the same 404 not_found reading the group directly would give them.

Why the read rule looks the way it does

A first draft of the rules of expenses/collection.yaml set read: false, meaning to force every read through list_expenses. It broke updating and deleting your own entries: backd fetches the current document through the collection’s read rule before checking update/delete against it (the same way changed() needs the current value to compare against) — so a read rule that refuses everyone refuses those too, not just direct GET/list. read: user != nil && document._meta.owner == user.id is the fix: it’s exactly narrow enough to let update and delete keep working normally for your own entries (which never goes stale — ownership doesn’t change), while still refusing a direct read of anyone else’s. Seeing the group collectively — including entries you don’t own — is list_expenses’s job alone.

One visible consequence: editing or deleting someone else’s entry now answers 404, not 403 as it does in expenses without functions — there, the reader’s copy of members let them see the entry (read succeeds, then the write is refused); here, a direct read of someone else’s entry was never allowed in the first place, so the document doesn’t exist as far as they’re concerned, same as a stranger’s private draft in the blog example.

Known holes

No holes are left. Hole 5, unchanged from expenses without functions, is closed by email verification rather than functions: the realm sets account.require_verified_email, so whoever signs up with an invited email without access to its mailbox never gets a session, and the rules and every function’s invoke also check user.email_verified.

Best practices

What this version adds to expenses without functions’ list:

  • When a check needs data from another document, a function reading it as the caller (ctx.db) both performs the check and proves the caller may see what it read — no separate permission check needed on top.
  • Reserve ctx.admin.db for exactly the part of a function’s job the caller themselves couldn’t do directly — here, seeing or changing someone else’s document — and keep every other check (ownership, “you only write what’s yours”) in the rules, where it still applies as a backstop.
  • A field only a function should ever set (status, here) needs the rules to say so explicitly (!('status' in changed())) — admin: true bypasses rules entirely, but the direct document API is still there for anyone to try.
  • Computing something once on the server, from data the rules and functions together keep honest, beats computing it on every client from data some of them might not be able to trust.
Edit this page · Last updated 08 Oct 2026