A self-hosted application holding one household's financial history, reachable on a private origin, used by a small number of trusted people.
In scope: anything reachable over HTTP by an unauthenticated party or by a member acting outside their own data; anything that leaves the machine.
Out of scope: an attacker with the database file, the host, or root.
Encryption at rest is the storage layer's job: on the hosted service the
database, backups and attachments sit on a volume Fly.io encrypts at rest; on a
self-hosted install it is whatever the disk provides. The application does not
encrypt the file itself, because its key would have to live on the same machine
(the reasoning of 08 S9); stored credentials are sealed under SECRETS_KEY.
Every change is recorded in the event log with its actor; reads are not logged
(R37.7), except reads by an AI connection, which go to ai_access_log. Privacy between members separates people who trust each other;
it is not an adversarial boundary.
Google OAuth 2.0 or any OpenID Connect provider, both with PKCE; or a password, described under Passwords.
- The
statevalue is 16 random bytes, held in memory, single-use, expiring after ten minutes. It is also bound to the browser that started sign-in by a short-lived cookie, so a callback link opened in somebody else's browser cannot sign them into the sender's account. - A code the provider rejects (forged, replayed or expired), or an ID token
that fails its checks, is a 400 carrying the provider's reason, recorded in
auth_attemptsasrejected-code, not a server fault. - The email must already be a member with
allowed = 1. Others are refused and the attempt recorded inauth_attempts. - The provider must say the address is verified (
email_verified: truein the ID token), for Google and for any OpenID Connect provider alike. An unverified address (or a provider that does not send the claim) is refused with 403 before the allow-list is consulted, and recorded asunverified-email. Otherwise a provider that lets people register themselves would let anyone sign in as a member by typing their address. - Sign-in attempts are rate limited per source address; exceeding the limit
returns 429. Behind a proxy (
TRUST_PROXY) the source address is the right-mostX-Forwarded-Forentry, the one the proxy appended. Entries to its left are written by the client and would let it choose a fresh address per attempt. This assumes exactly one trusted hop (Fly's proxy, or one tunnel); with two, every request would appear to come from the first.
Sessions. 32 random bytes, base64url. Stored as a SHA-256 hash; the
plaintext exists only in the cookie. Cookie flags: HttpOnly, SameSite=Lax,
Path=/, and Secure when BASE_URL is HTTPS. A session lasts SESSION_DAYS
from sign-in, however often it is used. This is an absolute lifetime, not an idle
timeout, so a stolen cookie that is kept busy still expires on schedule. Signing
in again starts a new one.
Sessions can be revoked individually from Settings, but only your own. Another member's session id answers exactly like one that does not exist.
API tokens. Authorization: Bearer. Stored as a hash, compared in constant
time, checked for revocation and expiry after lookup. Scoped read or
read-write, with a deny-list of path prefixes a token may never reach,
including token management itself. Rate limited per token. A token belonging to
a member who has been removed from the allow-list stops working, as their
sessions do, and a token cannot be minted while viewing as another member.
AI app connections (OAuth). An MCP client (claude.ai, ChatGPT, Gemini,
Claude Code) connects to /mcp through a small OAuth 2.1 authorization server
in src/auth/oauth.ts. What it ends up holding is an
API token as above, with read scope unless changes were allowed (below) and a
one-hour lifetime, so nothing about tokens needed a second set of rules. The new surface, and what holds it:
- Registration is open, because these clients register themselves (RFC 7591)
with no account. So a registration proves nothing: the consent page leads
with the redirect URI's host, which the client cannot fake, not its
self-chosen name, and warns when it only answers on loopback. Redirect URIs
are
https, orhttpon loopback; neverjavascript:, a fragment oruser:pass@. Registrations are rate limited per address, capped at 200, and pruned after a day if nobody approved them. - Consent needs a signed-in session, is refused while viewing as another
member and on the demo, and its POST is an ordinary form post, held to the
Origin and Host rules below. A redirect URI the client did not register is
never followed: the error is shown on the page instead. The consent page's
CSP adds the one redirect origin to
form-action, so the browser can follow the answer there; no other page's policy changes. - Codes are SHA-256-hashed, ten minutes, single use, bound to the client,
the redirect URI and a PKCE
S256challenge (compared in constant time). A code used twice revokes what its first use issued. - Refresh tokens are hashed, rotate on every use and last 90 days. An old one presented again revokes the whole connection, on the assumption that either holder may be the thief. Removing a member, or revoking the connection or its access token in Settings, ends the refresh token with it.
- Scope is
readunless three things agree. The deployment setsAI_WRITEtostandardorfull; only then isbudget:writeadvertised and an "allow changes" box shown on the consent page, never pre-ticked whatever the app asked for. The member ticks it (stored on the grant,can_write). And the switch is read again at every token refresh, so turning it off makes every connection read-only within the hour, and the change tools vanish at once. The grant's ownscopecolumn staysreadunder its CHECK constraint. - An AI's token writes only through
/mcp. Any token made on Connect an AI, and every OAuth access token, is markedfor_ai; whatever its scope, a write to any other route is 403. Over/mcpa write is one of seven tools that call the same domain functions and visibility checks as the pages (no deletes, no loans, assets, month close, settings, members, tokens, imports, rules or schedules), capped at 100 a day and 50 suggestions waiting per connection, attributed with sourceai, and undoable as a day's group (ai.md). A script's ordinary read-write token is not offered the change tools: the member opted in for an AI, not for it. - The machine-facing endpoints (the two
/.well-knowndocuments,/oauth/register,/oauth/token,/oauth/revoke) are public, read at most 64 KB, and are exempt from the same-origin rule (the provider's servers post from their own origin). They take codes and refresh tokens, never a cookie, and sendAccess-Control-Allow-Origin: *so browser-based clients can read the answer; a page that calls them learns nothing it could not ask for directly./oauthis on the token deny-list, so a bearer can never approve more access. /mcpwithout a usable token is answered401withWWW-Authenticate: Bearer resource_metadata=…before the same-origin rule runs, which is what starts a client's sign-in.
One deliberate departure from RFC 8707's audience idea: the token is issued for
${BASE_URL}/mcp, but as an API token it is also good on the read routes
(/export.json and the like). Both are this server, under the same member's
visibility rules. The same holds for a token made on Connect an AI. Those
reads are not masked, whatever the connection chose (only
/export/analysis.json is, AI-3), and they are not in the AI access log, which
records /mcp. We chose to tell the member rather than close the routes: the
consent page and Connect an AI both say it where the key is made (AI-6).
Development login (DEV_LOGIN) is absent from the production image: the
module is compiled then deleted, and the build asserts its absence. The
application also refuses to start with it enabled in a production-shaped
environment: NODE_ENV=production, a BASE_URL that is not loopback, .local,
.test or a private LAN address, Google or OIDC credentials, or
LOCAL_LOGIN.
Demo mode (DEMO_MODE) bypasses authentication. It refuses to start
alongside DEV_LOGIN, or alongside any real way in (Google or OIDC
credentials, or LOCAL_LOGIN), and refuses a database with a connected
mailbox, a saved statement identity, a member's password or a linked Google
account. A demo is production-shaped by design, so these, not NODE_ENV or the
hostname, are what tell a household's instance apart from one.
Per-member visibility is described in privacy.md: visible-entity
guards on every parameterised route, viewerMemberId through every query that
can return data for a person, and 404 rather than 403.
Two independent controls, and a third that keeps the second honest:
- The session cookie is
SameSite=Lax, and noGEThandler in the router mutates state. This is verified by a test that walks every handler for write calls. - Every unsafe method must carry an
Originmatching the deployment, or aRefererfrom it whenOriginis absent.Referrer-Policy: same-originguarantees a same-origin request carries aRefererand a cross-origin one does not. Failure returns 403. - Before either is compared, the host the request was addressed to (
Host, or the right-mostX-Forwarded-Hostentry behindTRUST_PROXY) must beBASE_URL's hostname (withBASE_URL's port, or none) or a name no DNS answer is involved in: an IP literal orlocalhost. Anything else is 403 on an unsafe method. Without this, DNS rebinding defeated (2): a page onevil.examplere-points its own name at this machine and posts to it, so its Origin and the Host agree, and on a fresh LAN install withLOCAL_LOGINit could create the first member through/auth/first-run. An IP literal orlocalhostcannot be rebound, so the LAN address a household types keeps working; any other name it uses must be the one inBASE_URL.
Bearer-authenticated requests are exempt from (2) because they carry no cookie,
and only when they carry no session cookie: a request with one is held to (2)
whatever Authorization header it also sends.
The check is one middleware, applied before routing, and therefore covers every route.
Any redirect target taken from a request passes through safePath, which
requires a path beginning with a single /, rejects // and /\ (which
browsers resolve as another origin), and rejects control characters. Applied to
the sign-in return, the theme toggle, the review queue and the impersonation
banner.
The banner a redirect lands on is carried in the URL (?notice=), so a link
could put its own words there (escaped, never a script, but in the app's own
success banner). Every notice the server issues is signed (ns, an HMAC of the
text keyed by a random per-process secret) and an unsigned or mismatched one is
not shown. A restart between a redirect and its page drops that one notice.
The CAS import page's error is a fixed key (?error=expired), not text.
src/http/html.ts escapes & < > " ' on every interpolation, covering both
element and attribute contexts. raw() is the only way to emit unescaped
output, and when() accepts only SafeHtml. Server-generated SVG escapes every
interpolated label.
jsonScript() escapes < as < for values embedded in script blocks.
- SQL: every query is parameterised. The single dynamically-assembled
UPDATEbuilds itsSETclause from literal column names. - Uploads: MIME allow-list of
image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf, checked against both the declared type and the extension. 10 MB limit. Stored as blobs, so no path is constructed from user input. Served withnosniff; PDFs are servedContent-Disposition: attachment, images inline. The filename in the header is reduced to word characters, dots, spaces, parentheses and hyphens. - Untrusted parsers: the PDF and CSV readers are fuzzed with empty, truncated, malformed, all-null, absurd-length and self-referential inputs. A PDF may decompress to at most 32 MB in total, however its streams are nested or repeated, so a small hostile file cannot hold the server.
- CSV exports: imported text (payees, narrations) is chosen by whoever sent
the statement or alert, so every exported cell that starts with
=,+,-,@, tab or carriage return is prefixed with'and opens as text, never as a formula. Plain numbers are left alone so amounts still sum.
Applied to every response:
Content-Security-Policy: default-src 'self'; script-src 'self';
style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';
connect-src 'self'; frame-src 'none'; frame-ancestors 'none';
form-action 'self'; base-uri 'self'; object-src 'none'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: same-origin
Permissions-Policy: geolocation=(), camera=(), microphone=(), interest-cohort=()
Cache-Control: no-store
No third-party script, style, font or image is loaded.
Nothing the server sends is stored on the device (08 R35). The one exception
is the Add form's write-only queue (10 §3.7, R35.7): an entry that could
not reach the server is kept in localStorage under pathayam.outbox.v1
until the server confirms it. The entry holds the fields as typed, the
idempotency key, when it was kept, and the member id from the page's
data-member attribute.
- It is not encrypted. A key held by the same script on the same device
would stop nobody who can already read the browser's storage (the reasoning
of
08S9). Anyone with the unlocked device and browser profile can read an unsent entry; nothing else is there to read. - Every entry is rendered with
textContent, never as markup: it is what somebody typed, and a refusal message is what the server said. - A replay is an ordinary same-origin
POSTwith the session cookie and the originalIdempotency-Key, so it meets the same origin check, the same authorisation and the same R36.2 replay protection as the first attempt. A key is scoped to the member (R36.4), and an entry tagged with another member is discarded rather than sent. - Signing out purges it twice: the script clears it on submit, and the sign-out
response carries
Clear-Site-Data: "storage"for a page whose script never ran. "Sign out every device" cannot reach another device's storage; an entry left there is sent if the same member signs in again and discarded if anyone else does. client.test.tsasserts thatlocalStorageis reached in exactly one place, with that one key, and thatsessionStorage, IndexedDB, the Cache API, a service worker and script-written cookies appear nowhere.
statement_identity,gmail_connectionsandgmail_clientsare listed inNEVER_EXPORTEDand excluded from the JSON and CSV exports.- A Google client secret a household brings, and Gmail refresh tokens, are
sealed at rest with AES-256-GCM under
SECRETS_KEY(src/gmail/secrets.ts), each bound to its row, so a copied database file does not yield them. The key is set by the operator (a Fly secret, or.env), never stored beside the data; under 32 characters refuses to start. Without it, own Google keys cannot be saved. API tokens cannot reach/gmail/keys. - Statement passwords are used and discarded; only a description of which candidate worked is retained.
- The request log records method, path without query string, status and duration. No body, no headers, no query parameters, no financial values.
Every outbound host is fixed in source (Google's OAuth and Gmail endpoints,
the price providers, and NSE's and BSE's end-of-day files) or set by the operator: OIDC_ISSUER (and the
endpoints its discovery document names), BACKUP_WEBHOOK_URL and
HEARTBEAT_URL. User-supplied values reach only path segments and query
values, through encodeURIComponent.
OpenID Connect discovery ({OIDC_ISSUER}/.well-known/openid-configuration)
is bounded at ten seconds, like the price providers' fetches; a provider that
does not answer in time makes /auth/oidc answer 503, not hang.
None at runtime.
scrypt from node:crypto (N=32768, r=8, p=1), with the parameters stored
inside the encoded hash, so the cost can be raised later without invalidating
existing passwords. An old hash keeps verifying and is rewritten on the next
successful sign-in.
- Stored in
member_passwords, never on the member row. That row is read on nearly every request; a secret that is never loaded cannot be logged, serialised into a view model, or exported by accident. - In
NEVER_EXPORTED. A hash is not a password but it is an offline guessing target, and an export is the most portable thing this app produces. - Verification is constant-time, and a malformed or hostile stored row fails rather than throwing. This is the sign-in path, and an exception there is a 500 on a login page. Parameters read from the database are bounded before use, so a tampered row cannot ask scrypt for unbounded memory.
- Eight failures lock the credential for fifteen minutes. Per credential, not per address: IP rate limiting already exists and is the right tool against a flood from one place, and the wrong one against somebody patient with many.
- A wrong password and an unknown address are refused in identical words. The
difference would disclose who is in this household. For the same reason an
address with no usable password (not a member, removed, or no password set)
locks after the same eight guesses, replayed from
auth_attempts, and spends a scrypt verification on a decoy hash so it takes as long to refuse. - Changing a password requires the current one, and signs out every other session the member has; the device that made the change stays signed in.
- Removing a member deletes their password, as it revokes their sessions and API tokens. Adding the address back restores the member and everything they entered, not the credentials they held: they set a new password, and the person re-adding them is told so. Otherwise re-adding somebody silently reopened a password known to whoever learned it while they were away. Removal also deletes any password link made for them and not yet used.
A household with no Google or OIDC sign-in needs some way to give a newly
invited (or re-added) member their first password: /settings/password needs a
session, and /auth/first-run closes once anybody is in. A password link is
that way. Code: src/auth/setup-links.ts.
- The token is 32 random bytes, base64url. Only its SHA-256 is stored, in
password_setup_links, so a copy of the database, a backup or an export holds no working link. The raw token is shown once, either rendered on the page that made it (never in a redirect, a notice or a query string) or printed by the server command. It is kept out of the event log (which records that a link was made and used, by whom and for whom, never the token or its hash) and out of logged paths (redactedPathinsrc/http/server.ts, used for log lines andrequest_failures). - Expiry and single use. A link lasts 72 hours and sets a password once. Making another for the same member deletes their earlier unused ones, so at most one works. A link that is unknown, used, expired, or no longer suitable (below) answers in one set of words with 410, so a holder of a stale link learns nothing about which it is.
- The page.
/auth/setup/<token>works signed out. It is sent withCache-Control: no-storeandReferrer-Policy: strict-origin, so the path, which is the credential, is never sent as a Referer, not even to this app's own stylesheet. Notno-referrer: under that policy browsers sendOrigin: nullon the form's POST, which the same-origin write check refuses;strict-origingives the same guarantee for the token and leaves that check untouched. The POST is rate limited per source address like/auth/password, and a link that does not work counts as a failed attempt. A password is set in one transaction with the link being spent; one the rules refuse leaves the link usable.
Who may make one. Any signed-in member, for another member. All members are peers, and any of them can already add people to the household. Not on the demo, and not while viewing as somebody else. The target must be in the household, allowed in, and have no password. Four refusals carry the weight:
- Not for yourself. You have a session; set your own password in Settings.
- Not for somebody who already has a password. A link sets a password without asking for the old one, so it is a reset. A reset another member can perform is a takeover, so they change their own in Settings, and a forgotten one is reset by whoever runs the server.
- Not for somebody who has ever signed in. A member who signs in with
Google or OIDC has no password either, and a link made for them would add
a second way in that they never asked for: a takeover by another route.
Every sign-in sets
last_seen_at, so only somebody who has never been in counts as a newcomer; anyone else sets their own password in Settings or gets a link from whoever runs the server. - Not for somebody who holds records the issuer cannot see: a private
account or insurance policy, a personal budget with anything in it, saved
statement-password details (PAN, date of birth), a connected mailbox, or
tax figures. Whoever makes a link can use it instead of passing it on: set
the password, sign in as that member and read everything private to them.
Per-member privacy (
docs/privacy.md) would hold only until somebody pressed a button. The refusal says only that there is something private, never what, and names the server command.
The last three rules are checked again when the link is used, against the member who made it: a link made for a newcomer who has since opened a private account, signed in some other way, or set a password, does not work, and nor does one made by a member who has since been removed.
The server command (npm run password-link, in
operations) is how the refused cases are
handled. It is held only to the rules about the target (in the household,
and no password unless --reset is given), because whoever runs the server can
already read the database file (out of scope above), so a link from there
discloses nothing new. --reset clears an existing password and signs the
member out everywhere before the link is made.
There are three kinds:
| Thrown | Answers | Recorded as a fault? |
|---|---|---|
Refusal (src/core/refusal.ts) |
422, with its message | No |
Missing (extends Refusal) |
404, with its message | No |
HttpError (src/http/router.ts) |
its own status | No |
| anything else | 500, "Something went wrong" | Yes |
The last row is why this matters. An unexpected throw is recorded as a genuine defect, and a mistyped id in a URL once did exactly that: the failed check reached the health page, the health check reported the instance unhealthy, the platform stopped routing to it, and the public demo was down for twenty-four hours. Somebody guessing a URL must not be able to do that.
Missing exists so the domain can say "that is not here" without reaching for
the HTTP layer, which it must not depend on. It extends Refusal so both places
that already treat a refusal as deliberate (main.ts and the test harness)
needed no change at all.
src/web/no-crash.test.ts enforces this: every POST route, given four shapes of
bad body, and every id-addressed route given an id that names nothing. A 500
from any of them fails the suite.
src/pdf/decrypt.ts calls MD5 and SHA-256, and code scanning reports all three
as "password hash with insufficient computational effort". They are not password
hashes. Nothing in that file stores or verifies a credential: they are the
key-derivation steps ISO 32000 specifies for opening a PDF somebody else
encrypted, so the algorithm is a property of the file rather than a choice. Replacing
MD5 with scrypt there would not harden the decryptor; it would stop it opening
the statement. The SHA-256 in Algorithm 2.B is additionally only the seed
of a loop that runs at least sixty-four further rounds over sixty-four
repetitions of the password; the scanner sees only the first line of it.
src/auth/tokens.ts is reported the same way: it hashes API tokens and OAuth
codes and refresh tokens with SHA-256. Each of those is 32 random bytes the
server generated, so there is no password to guess and a slow hash would only
slow every request, which is authenticated by looking the hash up.
Each site carries a codeql[js/insufficient-password-hash] comment and the
reason. The alerts are dismissed as false positives rather than left open,
because three permanent known-good entries would make the security tab easy
to ignore.
Two more alerts flag regex .replace() calls shaped like an HTML or XML
sanitizer ("bad HTML filtering regexp" and "incomplete multi-character
sanitization") in test files, not in anything that touches a browser.
src/web/iso-dates.test.ts's visibleText() strips tags from a test's own
fetch of this app's server-rendered response, so it can search what a person
reads for a raw ISO date (WEBUX-1). The input is fixed templates this suite
controls, not markup an attacker supplies for display; a malformed match
confuses an assertion, at most, not a browser.
src/web/sitemap.test.ts strips XML comments from website/sitemap.xml
before checking its <loc> elements. That file is committed to this
repository, never attacker-supplied, and XML comments cannot nest (-- is
forbidden inside comment content by the spec), so there is no valid document
where the overlap the query warns about (a delimiter pair reassembling from
the pieces either side of a removed match) can arise.
Neither result feeds a decision this app makes, let alone one about
untrusted content, so neither is the thing these queries exist to catch. Each
site carries its codeql[...] comment and the reason, for the same reason as
above.