← andrewbolaji.com

Four defects, four ways of finding them

The bugs are not the interesting part. What changed over eight months was the method that caught them, and the only number that improved was how early.

Junta · live marketplace · Postgres, Supabase, Stripe Connect

Junta is a two-sided marketplace where homeowners post a job, contractors bid on it blind, and the money moves through escrow. I built it with a co-founder handling the business side, which means the database, the authorization model, the payment rails and every test around them are mine. It is live and it takes real money: 48 migrations, 17 serverless functions, and 42,354 zip centroids doing service-area matching, with every write that changes state or moves money routed through a security-definer function rather than a table grant.

Over eight months it produced four defects worth writing down. Each one was found by a different method, and if you sort them by how long they survived, the list stops being a list of bugs and becomes an argument about testing.

The defect How it was found Survived
A blanket UPDATE grant exposed an admin flag, so any signed-in user could promote themselves and move other people's money Walking the real flow as a real user Months
Lint and typecheck both reported passes while checking zero files Doubting a green result and breaking it on purpose 11 blocks
The test suite was writing users and jobs into the production database Counting rows after a wipe that should have left one 3 days
A rate limiter's check-then-log was two statements, so simultaneous calls both passed Adversarial review before merge Never shipped
A revoke that revoked nothing, leaving a locked-down function publicly callable Testing the assumption against a throwaway real database Never shipped

One

The grant that quietly covered a column that did not exist yet

The foundation migration did the ordinary thing and granted UPDATE on the accounts table to signed-in users. Months later a different migration added an is_admin boolean to that same table. Nobody re-read the grant, because nothing about adding a column suggests you should.

A blanket grant covers every column the table will ever have. So from that moment, any signed-in user could set is_admin = true on their own row with one API call, and an admin on this platform resolves disputes, which means deciding whether escrowed money goes back to the homeowner or on to the contractor. The path from ordinary account to moving somebody else's five-figure payment was a single PATCH request.

It was not found by a test. It was found by sitting down and walking the admin flow the way an actual admin would, and noticing that the column was writable from the client. The fix was to revoke the blanket grant and re-grant only the two columns a user has any business writing.

A blanket grant is not a decision you make once. It is a decision you keep making, silently, every time anyone adds a column.

The rule that came out of it is now permanent: adding a privileged column to an existing table is the trigger to re-audit that table's grants, not just to write the column.

Two

Eleven blocks of a lint gate that was linting nothing

Every block of work closed with a recap, and every recap reported a clean lint pass from running npx eslint src. This went on for eleven blocks.

ESLint was not in the project's dependencies. There was no config file. There was no lint script. The command was quietly resolving to a transient global install with zero rules loaded, walking the source tree, finding nothing to complain about because it had been told nothing to complain about, and exiting zero. Eleven green checks in a row, all of them true and all of them meaningless.

When a real config finally went in, the first honest run returned 89 errors. Sixty one were misused promises and twenty seven were floating promises, which is to say most of them were unawaited async calls in a codebase that moves money.

Three days later the same shape turned up in the type checker, which is the part I find harder to forgive myself for. The root tsconfig.json was a project-references file with "files": [], so tsc --noEmit against it checks zero files and always exits clean. Meanwhile the deploy pipeline was running the real config, failing, and shipping nothing to staging.

# what the block recap ran, and what it actually checked
npx tsc --noEmit           # root config, "files": [] -> 0 files -> always exits 0

# what the deploy ran
tsc --noEmit -p tsconfig.app.json   # all of src/, strict, noUncheckedIndexedAccess

20 type errors had accumulated behind that gap while every recap in the sequence recorded "0 type errors."

Both were confirmed the same way, and it is the only way I now trust a check: write a line that must fail, and watch. const x: number = 'string' passes the root config and fails the real one. Once you have seen a checker refuse to notice a deliberate error, you stop believing green results on principle.

Three

The test suite was a user

Two suites tested row-level security and service-area matching, which are exactly the things you cannot meaningfully test against a mock, because the behavior under test is the database's own enforcement. So they signed up real users with random emails and asserted on what those users could and could not see. Correct instinct.

They were gated on an environment variable being present. The variable was the database URL, and it was present in .env, and the URL in .env was production. The gate was always open and pointed at the live system. Neither suite cleaned up after itself.

The scale only became visible because of an unrelated event. The database was wiped down to a single administrator account, which gave a known starting point of one. Three days later there were 35 contractor profiles, 68 auth users, 80 orphaned jobs and 34 audit rows. Every time anyone ran the tests, four or more rows appeared in production.

The fix was an explicit opt-in flag plus cleanup hooks. The part that mattered more was proving the new gate: a plain test run now skips both files and says so, and the flagged run passes all 52 tests. A gate you have not watched refuse something is not a gate.

Four

Caught before it shipped, twice, by two different kinds of doubt

Adversarial review

The rate limiter counts events in a trailing window and logs the current call. Two statements. Between the count and the insert there is a gap, and two truly simultaneous calls for the same key can both read a passing count before either has written a row, so both are allowed through a limit of one.

This surfaced in review, before merge, from someone reading the function specifically looking for how to defeat it. The fix serializes only callers sharing the exact same action and key, using an advisory lock scoped to that pair, so unrelated traffic is untouched. It is proven by a test that fires the limit plus two calls at once and asserts that exactly the limit succeed.

Testing the assumption itself

Hardening the same migration, I wanted a helper function to be unreachable from the browser. The obvious line is revoke all on function ... from public, and it had already been committed.

It does nothing useful on this platform. Supabase's default privileges grant execute on every new function in the public schema directly to the anon and authenticated roles by name. Those are separate grants from the Postgres "everyone" pseudo-role, so revoking from public removes a grant that was never the one doing the work, and leaves the function callable by exactly the two roles a browser client holds.

-- what was committed, and does not do what it reads like
revoke all on function check_rate_limit(...) from public;

-- what actually closes it
revoke all on function check_rate_limit(...) from public, anon, authenticated;

I found this by starting a throwaway Postgres container, applying the migration, and calling the function as each role instead of assuming the revoke had worked. It never reached production, for an unglamorous reason: an unrelated failure earlier in the same paste meant none of it had applied yet.

The argument

Sorted by how long each defect survived, the four discovery methods land in a clear order. A user walking the real flow found the oldest one. Doubting a passing check found the next two. Adversarial review and a disposable real database found the last two before anything shipped.

The skill was never finding bugs. It was moving the discovery earlier, from the person using the product, to the person reviewing the diff, to a container that lives for ninety seconds.

Three of the five were a check that reported success while doing nothing: a linter with no rules, a compiler with no files, a revoke against the wrong role. That is one failure mode wearing three costumes, and it is the reason I now hold a rule that sounds paranoid and is merely arithmetic. A passing check is evidence of nothing until you have watched it fail on purpose. It costs about a minute. Eleven blocks of false confidence cost considerably more.

The other two are a different lesson and a narrower one. A grant, an environment gate, a revoke: each was a security boundary that read correctly in the source and did not hold in the running system. Reading the code told me what I intended. Only the live thing told me what was true.