fix(auth): a taken workspace slug must not abort the signup transaction - #112
Merged
Merged
Conversation
Signup derives the personal workspace slug from the email's local part, which is unique across the deployment. When it was already taken, the INSERT failed with a duplicate key — and in Postgres a failed statement aborts the ENTIRE transaction until something rolls it back. So the retry never got its second shot at the slug: it came back with "current transaction is aborted, commands ignored until end of transaction block", which is not a duplicate-key error, so it escaped the retry and was thrown. The callback handler then put that text straight into the redirect, which is the fragment in yetone#102. The collision does not need one user's two accounts. Once ANY earlier user has taken `info`, every later info@anything signup failed, and short local parts (info, me, admin, a first name) make that ordinary. admin.ts hit this first on waitlist approval and was given a SAVEPOINT, with a comment describing exactly this failure. The signup path — the one every user takes — kept the copy without one, so the real root cause is the duplication. Both now call one insertPersonalWorkspace(); admin.ts loses 17 lines and none of its behaviour. Two things a real Postgres corrected while proving it out. companies.slug is not the only unique index on the table, so a companies_pkey collision was also a 23505: five attempts spent on something no new slug could fix, then reported as a slug problem. The exhaustion error now names the constraint that actually kept losing. And exhaustion used to fall through silently, leaving companyId pointing at no row so a later statement failed on something unrelated. The retry still fires on any unique violation rather than narrowing to companies_slug_key: narrowing would reintroduce this bug on a deployment that renamed the constraint, which is a worse trade than occasionally retrying something unretryable and saying so. Proof is an integration test against a real Postgres — remove the savepoints and three of its cases fail with the reporter's exact error string.
The callback handler put `e.message` into the redirect fragment, so whatever it caught was rendered on the sign-in screen and parked in the browser's address bar and history. That is how a Postgres transaction error became user-facing copy (yetone#102), and it also meant the provider's raw token-endpoint response body — interpolated into the "token exchange" error — could reach the address bar, which nobody had noticed. SignInError marks the messages written for a person to read; everything else collapses to `signin_failed`. Allow-by-construction rather than a deny-list: a deny-list needs extending every time a new failure mode appears, whereas this way an internal error added tomorrow is private on the day it is written rather than the day it turns up in a screenshot. The full text still goes to console.warn and the login_failed audit row, so no diagnostic detail is lost. The five marked messages are the account-state ones a user can act on ("github account has no verified email" and siblings). The native Apple route gets the same treatment — the app shows that string too. `signin_failed` is a code rather than a sentence to match the two the same handler already emits (bad_state, missing_code_or_state); mapping codes to friendly copy is a renderer change and is left alone. The classification lives in its own import-side-effect-free module so its unit test loads without booting env/pool/redis — the split cli-parse.ts documents. Importing it from oauth.ts hung the test on open DB handles.
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.
Closes #102.
The bug, confirmed
@bingqilinweimaotai's hypothesis on the issue was right, and the code says so out loud.
findOrCreateUserByProfileopens a transaction, then does this:In Postgres a failed statement aborts the whole transaction until something rolls it back. So the retry never gets a second shot at the slug:
INSERTfails,duplicate key. The transaction is now aborted.INSERTwith a fresh slug fails withcurrent transaction is aborted, commands ignored until end of transaction block.throw e→ the outer handler redirects with the raw text →#error=current+transaction+is+aborted…Which is exactly the fragment in the report.
One refinement to the hypothesis: it does not need the same user's two accounts.
companies.slugis unique across the deployment and seeded from the email's local part, so the collision is against any earlier workspace. Once one user has takeninfo, every laterinfo@anythingsignup fails. Short local parts —info,me,admin, a first name — make that ordinary rather than unlucky, and it gets worse monotonically as the user base grows.The fix already existed 150 lines away
admin.tshit this first, on waitlist approval, and was given aSAVEPOINT— with a comment explaining precisely this:The signup path — the one every single user takes — kept the copy without one. So the root cause here is really the duplication: two identical blocks, one fixed and one not.
Rather than paste the savepoint into the second copy and leave the same trap set for next time, both now call one
insertPersonalWorkspace()inpersonal-workspace.ts.admin.tsgets 17 lines shorter and nothing about its behaviour changes.Proof, against a real Postgres
server/src/__integration__/signup-slug-collision.test.ts. Removing the savepoints from the shared helper and re-running gives:— the reporter's error string, reproduced from the reported symptom. With the savepoints, 6/6. The test also asserts the transaction is still usable afterwards, since signup does several more inserts before it commits.
Three things the database corrected while writing this
Running against a real Postgres rather than reasoning about it changed three of my assumptions:
companies.owner_user_idhas no foreign key, so my first "non-collision failure" case didn't fail at all. Replaced with a NOT NULL violation, which does.companies_pkeyis also a 23505, so the retry burned all five attempts on something no new slug could ever fix and then reported it as a slug problem. The exhaustion error now names the constraint that actually kept losing.workspaceSlugSeed('...@example.com')is'-', not the'workspace'fallback —'-'is truthy, so|| 'workspace'never fires. Left exactly as-is (existing workspaces keep their slugs) but now pinned by a test so it is a known quirk rather than a surprise.The retry still fires on any unique violation rather than narrowing to
companies_slug_key. Narrowing would reintroduce this bug on any deployment that renamed the constraint, which is a worse trade than occasionally retrying something unretryable and then saying so clearly.Second half: the raw text should never have been user-visible
The issue asks that the user never see a raw Postgres message. The handler did
errorUrl(…, msg.slice(0, 120))— whatever was caught went into the URL fragment, onto the sign-in screen, and into browser history.Fixed allow-by-construction rather than by filtering:
SignInErrormarks the messages we wrote for a person to read, andpublicSignInError()passes only those through. Everything else becomessignin_failed. The full text still goes toconsole.warnand thelogin_failedaudit row, so nothing is lost for diagnosis.That also closes a leak nobody had reported:
throw new Error(\${p} token exchange ${r.status}: ${await r.text()}`)` interpolates the provider's raw response body, and that was reaching the address bar too.The five messages marked user-facing are the account-state ones a person can act on —
github account has no verified emailand its siblings. A deny-list would need extending every time a new failure mode appeared; this way an internal error added tomorrow is private the day it is written.signin_failedis a code rather than a sentence to match the two the same handler already emits (bad_state,missing_code_or_state). Mapping those codes to friendly copy is a renderer change and I've left it alone.Checks
--test-concurrency=1, as the runner requires).auth-public-error.test.ts. It imports from a newauth-errors.tsrather thanoauth.tsso the unit test doesn't boot the DB/redis graph and hang — the same splitcli-parse.tsdocuments.biome lint ., and both guards clean.