Skip to content

Fix mutation reliability, close RLS holes, add e2e suite and CI gating#30

Merged
amyjko merged 17 commits into
mainfrom
fix/mutation-reliability-and-ci
Jul 18, 2026
Merged

Fix mutation reliability, close RLS holes, add e2e suite and CI gating#30
amyjko merged 17 commits into
mainfrom
fix/mutation-reliability-and-ci

Conversation

@amyjko

@amyjko amyjko commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Started from "I added a person by email and they didn't appear in the table." That turned out to be a dead realtime subscription with no fallback, and pulling on it surfaced several security problems underneath.

Six commits, each reviewable on its own. The first one is a security fix and deserves the closest look.

What was wrong

Any caller, including an unauthenticated one, could make themselves an admin of any organization. The profiles INSERT policy compared a profile's orgid to its own id inside a subquery, which is never true, so the whole policy collapsed to always-true. I confirmed the exploit against a live database before fixing it, then confirmed the same script is rejected afterward.

Separately, any member could promote themselves to admin, because the UPDATE policy gates the row rather than its columns.

Comments were being silently lost. Commenting required permission to UPDATE the thing being commented on, so a member commenting on someone else's change inserted the comment but linked it to nothing. PostgREST doesn't treat a zero-row update as an error, so the caller saw success.

Why mutations went stale

queryOrError only accepted PostgrestError | null, while the methods that create things returned {data, error} or {error, id}. The wrapper didn't fit them, so ~24 call sites did without it and depended entirely on realtime. Every mutation now returns one MutationResult shape and goes through mutate().

Tests

206 e2e tests run the real API against a real database — the layer where all of these bugs lived and where TypeScript can't help. Two guards keep it honest: one fails if any method is untested, another fails if any component calls a mutation without mutate().

Both security fixes were mutation-tested: I reverted the mechanism and confirmed the tests fail, so they're load-bearing rather than decorative.

CI

CI ran no tests — its only check regenerated types to a path that isn't the committed one, so git diff compared nothing and it could never fail. Meanwhile migrations pushed to production on every merge, and Vercel deployed independently, so the app could land before the migrations it depends on.

Now: test → migrate → deploy, in that order, with the same suite gating both PRs and main.

Before merging

  • Disable Vercel's production Git auto-deploy in the dashboard. vercel.json does this too, but it may not apply to the commit that introduces it, and this merge would otherwise race.
  • Merging pushes three migrations to production. They're backward compatible with the currently deployed app.

Not addressed

addComment can still be called on a change whose comments a member can't otherwise touch — worth deciding whether members should be able to comment on changes they didn't author, which is a product question rather than a bug.

🤖 Generated with Claude Code

amyjko and others added 9 commits July 18, 2026 09:47
Three problems found while testing permissions against a real database:

The profiles INSERT policy read `not exists(select * from profiles where
orgid = id)`, where both columns resolved to the subquery's own table.
Comparing a profile's org id to its own primary key is never true, so the
clause was always true and the policy collapsed to `isAdmin(orgid) or true`
-- any caller, including anon, could insert an admin profile into any
organization. The bootstrap case it was reaching for is unreachable anyway,
since create_org is security definer and creates the first profile itself.

The profiles UPDATE policy gates the row rather than its columns. Members
need it to edit their own name and bio, but it also let them set admin on
themselves. RLS cannot restrict columns, so a trigger now guards admin and
supervisor.

Commenting required permission to UPDATE the thing being commented on,
because addComment appended to the parent's comments array directly. A
member commenting on someone else's change inserted the comment but matched
zero rows on the link, which PostgREST does not report as an error, so the
comment was silently orphaned. Commenting and deleting now go through
security definer functions that check membership and act atomically.

Also fixes profile/person linking to be case insensitive, tightens role
visibility to match teams, and corrects `accountable = NULL` to `IS NULL` in
the process delete policy, which had made that clause dead code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bugs in the quote branch of the parser.

The loop parsed the line captured before it started rather than the current
line, so every line of a multi-line quote was a copy of the first: `"one"`
followed by `"two"` produced Quote[Text[one], Text[one]].

It also advanced the index once more after the loop had already advanced
past the last quoted line, which swallowed whatever block followed a quote.
The existing test for that case was failing; the multi-line case had no
coverage at all, so tests are added for both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mutations were fire-and-forget, relying on a realtime notification to make
the change visible. When realtime was delayed or disconnected, the write
succeeded but the UI silently kept showing stale data -- which is what
"added a person and they didn't appear" turned out to be.

Realtime itself was also broken. Organization.channels was never written to,
so the dedupe guard was dead and ignore() never removed a channel. Since
supabase.channel() returns the existing channel for a topic and .on() after
subscribe() throws, the second visit to an organization threw and realtime
stayed dead for the rest of the session. Subscribing without a status
callback hid it. The db instance was also rebuilt on every invalidation,
discarding its listeners.

The deeper reason mutations went unwrapped is that queryOrError only
accepted PostgrestError | null, while the methods that create things
returned {data, error} or {error, id} -- so the wrapper did not fit them and
roughly two dozen call sites did without. Every mutation now returns a
single MutationResult shape and goes through mutate(), which reports the
error and reloads on success. Reloads coalesce, so unchecking a task with
many subtasks reloads once rather than once per subtask.

Fixes found along the way: uncheckAll never returned its promises, so
Promise.all awaited nothing; updateProcessConcern, updateChangeStatus and
updateOrgName were missing awaits on addComment; updateTeamName recorded its
comment against 'orgs' instead of 'teams', so renames vanished from history;
and inserting a how focused the new node even when creation failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
206 tests running the real Organization API against a real local database,
so they cover PostgREST, row level security, and the profile/person linking
triggers. Every bug fixed in the preceding commits lived in that layer and
was invisible to TypeScript.

Each test file gets an organization built through the real create_org RPC
plus three actors -- an admin, a plain member, and an outsider -- so every
mutating operation can assert what each is actually permitted to do.
Assertions read state back through a service-role client rather than
trusting error === null, because a write filtered out by RLS returns no
error at all.

Two guards keep the suite honest as the code moves. coverage.test.ts fails
naming any Organization method no test exercises, and mutations.test.ts
fails naming any component that calls a mutating method without mutate(),
deriving the method list from the MutationResult return type so new
mutations are covered the moment they are written. Both keep an allowlist
for genuine exceptions plus a check that fails if an allowlist entry names
something that no longer exists.

test:unit ran vitest in watch mode, which is wrong for CI; it now uses
`vitest run`, with test:watch for the old behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
seed.sql was empty, so a fresh `supabase db reset` left nothing to click on
and every local check started with hand-building an organization.

Creates one bakery with enough shape to exercise every mutation: roles in
and out of teams, a role nobody holds, a nested how tree with mixed
completion and RCI assignments, processes in all three states, a change per
status, comments on each kind of parent, and a process with no accountable
role so that delete policy branch is reachable.

Sign in as test@test.com; the app uses magic links, so the message arrives
in Mailpit. sam@test.com is invited but has no account, so signing in as
them exercises the linking trigger. Ids are fixed rather than generated, so
a reset always produces the same data and links stay valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nothing gated a production deploy. ci.yaml ran on pull requests but executed
no tests at all -- its one step regenerated types into types.gen.ts and
diffed that path, while the committed file is src/database/database.types.ts,
so git diff compared nothing and the check could never fail.
production.yaml pushed migrations to production on every push to main with
no test gate, and the app deployed through Vercel's Git integration, which
no workflow here could block.

That left migrations and the app shipping through independent pipelines with
no ordering. Concretely: the app now calls add_comment and delete_comment,
which only exist after the migrations, so losing that race breaks
commenting.

The suite now lives in a reusable workflow that both pipelines call, so the
checks gating a deploy are the same ones that ran on the pull request. On
main it is test, then migrate, then deploy, with concurrency set so a
cancelled run cannot leave migrations applied with no matching app. The
Vercel CLI does the deploy, and vercel.json disables Git auto-deploy for
main so the two cannot race.

CI uses the Supabase CLI from devDependencies rather than setup-cli:
generated type output varies between CLI releases, so a floating version
would fail the drift check on unrelated changes.

.env.local was tracked in this public repository, and the Vercel CLI appends
a VERCEL_OIDC_TOKEN to it. gitignore listed it but has no effect on tracked
files, so it is untracked here and its local defaults move to .env.example.
The existing !.env.example negation sat before the broader .env* rule and
was silently overridden, since gitignore applies the last matching pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
adminima Ready Ready Preview, Comment Jul 18, 2026 11:11pm

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Amy J. Ko and others added 6 commits July 18, 2026 16:16
Supabase has removed pgsodium from their Postgres images, so replaying the
migration history on a current image failed with 'schema "pgsodium" does not
exist'. That broke CI, and would break any fresh local setup too -- existing
machines only worked because they had an older image cached.

The extension came from the original schema dump and is referenced nowhere
else in the project, so it is now created only where it is actually
available. Editing an applied migration is safe here: production already
records this version, so db push will not re-run it, and the change only
affects databases built from scratch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous attempt guarded on the extension being available, but that was
not the problem: pgsodium is still listed as available, and it is the schema
that current Supabase Postgres images no longer pre-create. CREATE EXTENSION
... WITH SCHEMA requires the schema to already exist, so the migration still
failed to replay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Current Supabase Postgres images no longer let the migration role create
objects in the auth schema, so replaying the history failed with 'permission
denied for schema auth'. Supabase provides auth.email() itself, and nothing
in this project ever referenced it -- including the migration that defined
it -- so the redefinition is removed rather than worked around.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
svelte-check failed on a fresh checkout two ways, both hidden locally by a
working copy that already had the generated files.

Without svelte-kit sync there is no .svelte-kit/tsconfig.json, so nothing
resolves. And sync generates the $env/static/public module from the
environment it can see, so without a .env the PUBLIC_SUPABASE_* exports are
missing and every file importing them fails to check.

.env is now written from the running stack rather than copied from
.env.example, so a port change cannot make CI disagree with reality, and the
build reads it instead of being handed the values separately. Verified in a
clean clone rather than the working copy, which is what let both of these
through in the first place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bad or expired token previously failed at `vercel pull` in the deploy job,
which runs after migrations have already been applied -- and `supabase db
push` cannot be rolled back, so that left the database ahead of an app that
never shipped. The credentials are now checked in their own job before
anything touches production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three of the five names in the exclude list were not services the CLI knows
about -- storage-api, logflare and supavisor, rather than storage, analytics
and nothing. Unrecognised names are ignored silently, so those containers
started and their images were pulled anyway. CI runs had already failed on
Docker Hub rate limits.

CI now excludes everything the tests do not touch, which skips those pulls:
nothing in the suite exercises realtime, email, storage or edge functions,
and supabase/functions is empty. Locally the list is narrower, since Studio,
Mailpit and realtime are all wanted during development -- magic link sign in
needs Mailpit, and realtime is what the app uses to stay current.

Verified by running the full suite against the reduced set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@amyjko
amyjko merged commit 06413c8 into main Jul 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant