Skip to content

fix(auth): a taken workspace slug must not abort the signup transaction - #112

Merged
WhichPaths merged 2 commits into
yetone:mainfrom
WhichPaths:fix/signup-slug-collision
Aug 30, 2026
Merged

fix(auth): a taken workspace slug must not abort the signup transaction#112
WhichPaths merged 2 commits into
yetone:mainfrom
WhichPaths:fix/signup-slug-collision

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

Closes #102.

The bug, confirmed

@bingqilinweimaotai's hypothesis on the issue was right, and the code says so out loud. findOrCreateUserByProfile opens a transaction, then does this:

for (let attempt = 0; attempt < 3; attempt++) {
  try {
    await client.query(`INSERT INTO companies (id, name, slug, owner_user_id) …`)
    break
  } catch (e) {
    if (!/duplicate key/.test(msg)) throw e     // ← attempt 2 lands HERE
    finalSlug = `${slugSeed}-${randomUUID().slice(0, 4)}`
  }
}

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:

  1. attempt 0 — INSERT fails, duplicate key. The transaction is now aborted.
  2. attempt 1 — INSERT with a fresh slug fails with current transaction is aborted, commands ignored until end of transaction block.
  3. that is not a duplicate-key error → 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.slug is unique across the deployment and seeded from the email's local part, so the collision is against any earlier workspace. Once one user has taken info, every later info@anything signup 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.ts hit this first, on waitlist approval, and was given a SAVEPOINT — with a comment explaining precisely this:

SAVEPOINT per attempt: a duplicate-key error aborts the whole transaction until rolled back, so without this the *retry* INSERT fails with "current transaction is aborted". Slug collisions are common in bulk approval (short local-parts like "info"/"me" recur), so this path is hot, not theoretical.

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() in personal-workspace.ts. admin.ts gets 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:

not ok 1 - a taken slug no longer aborts the signup transaction
  error: 'current transaction is aborted, commands ignored until end of transaction block'
not ok 3 - several signups on the same local part all succeed
  error: 'current transaction is aborted, commands ignored until end of transaction block'
not ok 5 - an exhausted retry names the constraint that actually kept losing
    error: current transaction is aborted, commands ignored until end of transaction block
# pass 3
# fail 3

— 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_id has no foreign key, so my first "non-collision failure" case didn't fail at all. Replaced with a NOT NULL violation, which does.
  • A duplicate companies_pkey is 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: SignInError marks the messages we wrote for a person to read, and publicSignInError() passes only those through. Everything else becomes signin_failed. The full text still goes to console.warn and the login_failed audit 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 email and 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_failed is 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

  • 6/6 in the new integration test, and the full integration suite green locally against Postgres 16 (--test-concurrency=1, as the runner requires).
  • 5/5 in auth-public-error.test.ts. It imports from a new auth-errors.ts rather than oauth.ts so the unit test doesn't boot the DB/redis graph and hang — the same split cli-parse.ts documents.
  • typecheck, biome lint ., and both guards clean.

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.
@WhichPaths
WhichPaths merged commit ecf2a25 into yetone:main Aug 30, 2026
6 checks 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.

Sign-in fails server-side: "current transaction is aborted, commands ignored until end of transaction block" on /auth/done callback

1 participant