Security
Every table in this database refuses to return another owner's rows, and a test that fails the build proves it on every commit. Not a policy someone remembers to apply — a suite that reads the database catalogue and fails when a migration adds a table without protection. This page describes the model and names the mechanism behind each claim. It deliberately names no host, no bucket and no function: describing how authorisation works earns trust, and publishing an inventory just hands out a target list.
The model
One rule: you get your own rows, and nothing else. Every table carries row-level security with owner-scoped policies. There is no application-level permission layer that could be bypassed by reaching the database another way, because the database itself is where the rule lives.
Three suites hold it, and they run against a real Postgres — not a mock — on every commit.
A migration that forgets is caught by the meta-ratchet. Its own docblock states the design:
RLS meta-ratchet — the row-level-security equivalent of the file-size ratchet in scripts/doctrine.mjs.
CLAUDE.md: "Every table has RLS enabled with owner-scoped policies. No exceptions." A future migration that adds an unprotected table fails here, so the promise is mechanical rather than a thing we remember to check.
packages/db/src/rls/meta-ratchet.integration.test.ts. The tables permitted to
have no policies at all are listed in the test with the reason each is
service-only; an exception has to be written down and argued in the file that
would otherwise fail.
A second real user cannot reach the first one's rows. The isolation suite signs in two actual users and has the second try every operation against the first's data, across every owner-scoped table. What makes it worth reading is that it asserts what the policies actually do rather than what we would like them to do:
The assertions match what the policies ACTUALLY promise, which is not uniformly "an error" — each was verified against the local stack before it was written:
usingfilters rows, so B's SELECT/UPDATE/DELETE on A's rows succeed with ZERO rows affected. PostgREST returns 200 and an empty array, not 403. Asserting an error here would be asserting a wish.with checkblocks writes, so B inserting a row that claims owner_id = A is a real error: postgres 42501.- feedback has RLS and no policies: writes are 42501, but SELECT is simply empty — denial by emptiness, not by error.
packages/db/src/rls/isolation.integration.test.ts. A test that asserted "this
returns 403" would pass for the wrong reason or fail for no reason; this one
encodes the behaviour that was measured.
Photos are private per account, held to the same standard by
packages/db/src/rls/storage.integration.test.ts.
The suites cannot be pointed anywhere but a local database. Every client
they build passes through a guard that throws unless the host is local
(packages/db/src/rls/local-stack.ts). They create and delete real users and
real rows; running them anywhere else must fail loudly rather than quietly work.
Server functions
Two sentences cover the whole of it.
Every server function a client can reach verifies the caller before it does anything. The privileged database key is used only by a function that has already verified who is asking, and only on data scoped to the identity that token proved.
That order is enforced, not remembered. Every server function must declare how it verifies its caller, and a function that leaves that undeclared fails the build. The rule found a real gap the day it was written: the most privileged function in the project had never declared anything and was relying on a platform default. It was almost certainly behaving correctly — and "almost certainly, by default" is not a decision, which is the entire reason the check exists.
What leaves the phone
Six analytics events, and nothing else that describes your birds.
They are a single constant in packages/core/src/analytics-events.ts, from
which both the app's event type and the published privacy policy are generated.
That the list is one constant is the point: the policy previously named four of
them for two sprints, because the policy and the code were separate lists and
nothing could notice.
The rule that constant states, and that the policy publishes: an event carries a name, and at most one property that is a size. Two events do — how many seasons a loft has, and how many analysis cards had something to say. No ring number, bird, race, season, user identifier or device identifier leaves the phone as analytics.
Separately, crash reports: when the app crashes we receive the error and the device model, with no personal data.
Region
All of it — database, photos, authentication and the analytics — is in the European Union. That is a commitment in the published privacy policy, which is the thing that binds us rather than this page.
Deletion
Deleting the account empties the live database immediately and destroys the photos at once, with no copy in any backup. Copies of the rows remain in the provider's routine daily backups for up to 8 days, then expire.
Both halves are stated because the first alone would be true and misleading. The long form, with what to do and what you will see, is on Your data.
Check it yourself
Nothing on this page asks to be believed. The reader-verifiable claims here — that the export is free and complete, and that deletion empties your loft — are steps 5, 6, 7 and 8 of the walkthrough on Your data. It takes five minutes on your own phone and needs no account of ours.
The claims about row-level security are not on that list, because a reader cannot check another user's rows without being another user. What stands behind those is the code quoted above and the fact that it fails the build.
Reporting a security issue
Email contact@thatpigeon.app — the same address published, machine-readably,
at /.well-known/security.txt. Tell us what you found and how to reproduce it;
you will get a human reply. If it is sensitive, say so in the first line and we
will agree a channel before you send details.
There is no bug-bounty programme and no formal disclosure timetable — this is one developer, and pretending otherwise would be the sort of claim the rest of this page exists to avoid.