Accounts and entitlements
Accounts are optional#
"Continue without an account" is a first-class path, not a dark-pattern afterthought. A user who never signs in gets the entire gambling block, the PIN, the cooldown, delete protection, the widget and the on-device buddy.
One hard rule runs through this whole area: authentication never gates protection. Signing out, an expired session, a deleted account, or a backend that is unreachable all leave the gambling filter exactly as it was.
What an account does add: the server-owned streak, entitlement portability across devices, and password recovery.
Two implementations behind one interface#
AuthRepository has two implementations, selected at build configuration time:
| Local | Server-backed | |
|---|---|---|
| Selected when | No accounts backend configured | Accounts backend configured |
| Email accounts | Verified on-device against a salted PBKDF2 hash | Handled by the hosted auth service |
| Sessions | Minted locally, short-lived access token, silent refresh | Issued by the auth service, refreshed on expiry |
| Password reset | No-op that always "succeeds" | A real recovery email to the app's reset page |
| Account deletion | Erasing the local store is the deletion | A server function erases the row, cascading to all related data |
Both are chosen lazily, so building the object graph does not force the decision before the configuration is known.
Security properties shared by both#
- Passwords are never stored raw — only hashed.
- Sign-in failures are uniform, whether the address is unknown or the password wrong. There is no account enumeration.
- Password reset always reports success, for the same reason.
- Brute force is throttled per email address with a lockout.
- A password change re-authenticates first and rotates the session afterwards.
That last one deserves its own note. The hosted auth service will set a new password from an access token alone, without asking for the old one. Relying on that would mean anyone holding an unlocked, signed-in phone could take the account over and lock its owner out. So Haven produces the proof itself: it re-authenticates with the password the user just typed and only continues on a genuine grant. Every other outcome stops the change — including a failed network call, because "we could not check" must never read as "it was right".
Google sign-in#
Uses Credential Manager (not the deprecated sign-in client). The system account sheet returns a signed ID token, which Haven verifies on-device — signature against Google's published keys, plus audience, issuer and expiry — before it is trusted at all. Only then is it exchanged for a session.
The two responsibilities are deliberately split: one class runs the picker, another verifies the token, so neither can quietly skip the other's job.
The entitlement#
The backend is the source of truth. Pro comes from server-side records, written by the payment processor's webhook and by administrative operations. The billing SDK's local customer information is only a fallback for when no accounts backend is configured, and a prompt to re-ask the server after a purchase.
The local cache is availability, not authority. It holds the last answer the server gave, so a Pro-gated feature works while offline.
Three rules govern how that cache is written:
- A failed check writes nothing. The last known-good answer stands. This matters because financial protection gates on this value: if a failed request could flip Pro to false, the pause in front of someone's banking app would disappear the moment they lost signal.
- Deliberately no fallback to the device on failure. When the backend is configured and unreachable, Haven does not fall through to the billing SDK's local cache. "The backend is the source of truth" would mean little if every offline moment quietly handed authority back to the device.
- Signing out clears it. The entitlement belongs to the account, and a session ending is a local, certain signal — unlike a missing answer, which is indistinguishable from no signal. A user who was never signed in keeps what they bought.
Identity has to be linked#
The billing SDK's user id must be the backend's user id, because that is the only thing the webhook can map a purchase onto. Left anonymous, every payment event arrives unmappable, the webhook drops it, and a paying subscriber silently never receives Pro.
Haven therefore ties the two together whenever the session changes, and does so unconditionally — the sign-out cleanup has to work even when billing is not configured.
The billing SDK's update listener is treated as a trigger, not an answer: a renewal or expiry means the webhook has moved (or is about to move) the grant, so Haven re-asks the server rather than believing the device. Only when there is no backend at all does the device's verdict stand on its own.
What a modified app cannot do#
- Grant itself an entitlement — the client role has read access to its own entitlement row and no write path.
- Reach the administrative grant and revoke functions — they are not in the app.
- Read another user's data — every call is scoped by the identity inside the token, server-side.
Account deletion#
Reachable in the app, and also from a page on the public site for anyone who no longer has the app installed.
Server-side deletion runs as a privileged function because erasing an authentication record needs authority the app must never hold. The user id comes from the token, so the call can only ever delete the caller.
The ordering is deliberate: the session is not cleared until the server confirms. Signing out first would throw away the token the call needs to prove who is asking, leaving the row behind with no way to reach it. And on failure nothing local is cleared either — a user whose data is still on the server should stay signed in so they can see that and try again, rather than be shown a farewell that is not true.
Deleting the account does not turn protection off.