fix(platform): type RLS errors as ConvexError and name a deleted org - #3024
Closed
larryro wants to merge 2 commits into
Closed
fix(platform): type RLS errors as ConvexError and name a deleted org#3024larryro wants to merge 2 commits into
larryro wants to merge 2 commits into
Conversation
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.
Collaborator
Author
|
Closing as obsolete. The Convex tree this patched ( |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 plainError, 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
RLSErrornow extendsConvexError<{ code, message }>(convex/lib/rls/errors.ts). The Convex runtime detects application errors via theSymbol.for('ConvexError')marker field, which subclasses inherit — so every RLS refusal that escapes a public function now reaches the client as structureddatawith a stablecode, exactly likerequireOrgMembershipById's errors, instead of a redacted "Server Error" plus an uncaught-exception record.messageis kept as the human sentence (ConvexError would stringify the data into it), so server logs and GlitchTip titles keep reading naturally.getOrganizationMemberdistinguishes 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 throwsUnauthorizedErrorwithcode: 'ORG_NOT_FOUND'("Organization … not found") when the row is gone, vscode: 'ORG_FORBIDDEN'("Not a member of organization …") when the org exists. The disabled-member throw also carriesORG_FORBIDDEN, matchingrequireOrgMembershipById. The re-check is guarded bylooksLikeConvexDocumentId(a sentinel/slug would throw inside the betterAuth component) and its own failure falls back toORG_NOT_FOUNDwith a warn, mirroringgetCurrentMemberContext's catch.Why the blast radius is contained
getOrganizationMemberis referenced across ~88 non-test files, so the design constraint was: don't change what existing catches see.instanceof UnauthorizedError(approvals ×6, sandbox ×7, members ×3, team_members, tasks, governance/erasure) — the class hierarchy is unchanged, both new codes are stillUnauthorizedError, 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 catchesUnauthorizedErrorand does its own org re-check, so the dashboard'snot_foundbounce is unaffected.http.tsboundaries catch broadly and map to 403 without echoing the error — unchanged.isStructuredConvexErroris a structuraldatacheck (noinstanceof), souseActionQuerynow 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 inlayout-error-boundary.tsx) — this PR makes prod behave like that comment always assumed.(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.tsrewritten against the real error classes (it previously mocked the errors module): existing hot-path/mirror/email-fallback cases kept; new cases pinORG_FORBIDDEN(org exists / disabled member, incl. the wiredatapayload),ORG_NOT_FOUND(row gone, with thefindOnecall 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 serversweep (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.tsandprovider_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 islib/mocks/contract/openai-compat.test.ts(Prism-mock embeddings length) — pre-existing, fails identically on untouchedmainin 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 asRLSError: Organization … not foundapplication 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 --noEmitclean,oxlint --type-awareclean, oxfmt, targeted vitest 584 + full server sweep (sole remaining failure pre-exists onmain).