Skip to content

fix(platform): type RLS errors as ConvexError and name a deleted org - #3024

Closed
larryro wants to merge 2 commits into
mainfrom
fix/rls-typed-convex-errors
Closed

fix(platform): type RLS errors as ConvexError and name a deleted org#3024
larryro wants to merge 2 commits into
mainfrom
fix/rls-typed-convex-errors

Conversation

@larryro

@larryro larryro commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Org-scoped queries and mutations guarded by the RLS helper crashed with Uncaught RLSError: Not a member of organization jh7csd… — 16 events in GlitchTip 7489510 from the deleted-org incident. Two defects in one throw site: the error was uncaught (a plain Error, redacted to "Server Error" for clients), and it misdiagnosed a deleted organization as a membership refusal, which is exactly what sent triage down the permissions path for a month.

Fixes #3021.

What changed

  1. RLSError now extends ConvexError<{ code, message }> (convex/lib/rls/errors.ts). The Convex runtime detects application errors via the Symbol.for('ConvexError') marker field, which subclasses inherit — so every RLS refusal that escapes a public function now reaches the client as structured data with a stable code, exactly like requireOrgMembershipById's errors, instead of a redacted "Server Error" plus an uncaught-exception record. message is kept as the human sentence (ConvexError would stringify the data into it), so server logs and GlitchTip titles keep reading naturally.
  2. getOrganizationMember distinguishes the two failure states (convex/lib/rls/organization/get_organization_member.ts). On an empty member lookup — and only then, so the happy path costs nothing — it re-checks the organization row and throws UnauthorizedError with code: 'ORG_NOT_FOUND' ("Organization … not found") when the row is gone, vs code: 'ORG_FORBIDDEN' ("Not a member of organization …") when the org exists. The disabled-member throw also carries ORG_FORBIDDEN, matching requireOrgMembershipById. The re-check is guarded by looksLikeConvexDocumentId (a sentinel/slug would throw inside the betterAuth component) and its own failure falls back to ORG_NOT_FOUND with a warn, mirroring getCurrentMemberContext's catch.

Why the blast radius is contained

getOrganizationMember is referenced across ~88 non-test files, so the design constraint was: don't change what existing catches see.

  • All swallow sites catch by instanceof UnauthorizedError (approvals ×6, sandbox ×7, members ×3, team_members, tasks, governance/erasure) — the class hierarchy is unchanged, both new codes are still UnauthorizedError, so "non-member → return empty/null" behavior is byte-identical, including for deleted orgs (which already took this path).
  • getCurrentMemberContext (members/queries.ts) keeps working unchanged — it catches UnauthorizedError and does its own org re-check, so the dashboard's not_found bounce is unaffected.
  • http.ts boundaries catch broadly and map to 403 without echoing the error — unchanged.
  • Frontend: isStructuredConvexError is a structural data check (no instanceof), so useActionQuery now correctly treats RLS refusals as terminal (no 3×-retry delay); the layout error boundary's transient matcher already deliberately matches the structured {"code":"UNAUTHENTICATED"} payload shape (see the Bug: workflow execution mutations/actions throw raw Error — execution management rejections are opaque Server Errors #2013 comment in layout-error-boundary.tsx) — this PR makes prod behave like that comment always assumed.
  • Callers constructing these errors are source-compatible: the (message, code?) signatures are supersets of the old ones.

One deliberate information tradeoff: a client can now distinguish "org gone" from "org exists, not yours" — the same distinction requireOrgMembershipById (and thus the whole agents/credentials surface) already exposes.

Tests

get_organization_member.test.ts rewritten against the real error classes (it previously mocked the errors module): existing hot-path/mirror/email-fallback cases kept; new cases pin ORG_FORBIDDEN (org exists / disabled member, incl. the wire data payload), ORG_NOT_FOUND (row gone, with the findOne call asserted), the shape-guard short-circuit (no component read for a non-id-shaped org id), and the re-check-failure fallback.

Targeted suites over the catch sites: convex/lib/rls (84), plus members, team_members, sandbox, governance, tasks error-codes/stats, auth, http_jwks_cache — 584 tests / 50 files, green.

A full --project server sweep (8,057 tests / 657 files, run twice with identical results) then surfaced the two public-boundary tests that assert the refusal message end-to-end: connector_credentials/queries.test.ts and provider_credentials/queries.test.ts — their fixture org strings never existed as organization rows, so under the new distinction they correctly read "Organization … not found" where they expected /Not a member/. Adapted in the second commit: the membership-refusal case now seeds a real betterAuth organization row (the org must exist for "not a member" to be the true state) and keeps asserting /Not a member/; the unknown-org flavor is pinned as its own case (/not found/). Both credential suites green after: 109 tests / 9 files. The sweep's only other failure is lib/mocks/contract/openai-compat.test.ts (Prism-mock embeddings length) — pre-existing, fails identically on untouched main in this environment.

Verifying after deploy

GlitchTip: RLSError: Not a member of organization jh7csd… (7489510) stops recurring for the deleted org; new occurrences of the same visit pattern surface as RLSError: Organization … not found application errors instead. See the cleanup checklist in #3019.

Related: #3019 (incident), #3020 (browser event grouping), #2018 (same problem class for lifecycle mutations, closed).

Gate: tsc --noEmit clean, oxlint --type-aware clean, oxfmt, targeted vitest 584 + full server sweep (sole remaining failure pre-exists on main).

RLSError and its subclasses extended plain Error, so every RLS refusal
that escaped a public function was an uncaught server exception, redacted
to 'Server Error' for clients. And an empty member lookup always claimed
'Not a member of organization …' — even when the true state was that the
organization row had been deleted, which is what a stale bookmark session
produced daily in GlitchTip 7489510.

RLSError now extends ConvexError (the Symbol.for('ConvexError') marker is
inherited, so the runtime treats it as an application error), keeping the
class hierarchy — and therefore every instanceof catch — unchanged.
getOrganizationMember re-checks the org row on the failure path only and
throws ORG_NOT_FOUND vs ORG_FORBIDDEN, mirroring requireOrgMembershipById
and getCurrentMemberContext.
The full-suite run surfaced the two public-boundary tests that assert
the refusal message end-to-end: their fixture org strings never existed
as organization rows, so under the org-existence distinction they now
correctly read 'Organization … not found' instead of 'Not a member'.
Seed a real betterAuth organization row for the membership-refusal case
(the org must exist for that to be the true state), and pin the unknown
org flavor as its own case.
@larryro

larryro commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as obsolete. The Convex tree this patched (services/platform/convex/lib/rls/**) was retired in #3125; the 0.5 backend already carries the behaviour this PR added — backend/auth/membership.ts requireOrganizationMember distinguishes ORG_NOT_FOUND (org row gone) from ORG_FORBIDDEN (exists, not a member / disabled), and refusals reach clients as coded HTTP errors, not redacted server errors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug service: platform Platform service

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: getOrganizationMember throws a bare RLSError — uncaught by Convex, and it says "Not a member" even when the organization was deleted

1 participant