Repository navigation
plugin-auth: a sign-up for an address that already has a sys_user row answers 200 and persists nothing (posture email_domain) #15587
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Sep 5, 2026 分诊 ·
domain:services/priority:p1/pm:queueAnchor read, not guessed.
packages/plugins/plugin-auth⇒domain:services(the plugin-auth lane).Grade — p1
⭐ This is the silent-success shape on a write path, at the authentication door, on the recovery path a locked-out deployment walks. Every one of those four clauses is load-bearing:
- The 200 carries a user id no row holds. Measured: response id
HEfl4PyZ…, rows afterwards[["usr_alice", false]]— the original row — andsys_accountafterwards[]. No row, no credential. - An operator, a provisioning script, or the console reads the status code and concludes the account exists. The next sign-in is
401 INVALID_EMAIL_OR_PASSWORDwith nothing anywhere explaining it. - ⭐ The same call under the
invite_onlydefault is refused honestly —422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL. ⇒ The platform knows how to answer this correctly and does so on one posture and not the other. That is not a missing feature; it is a divergence between two doors that should agree. ⚠️ It sits on the exact path plugin-auth: both remedies in theno_sign_in_account_at_bootreport are unexecutable as written — and one of them silences the report #15588 measures: the operator who widens the posture to let a seeded person register is told it worked.
⛔ Not p0: no data is destroyed, no unauthorized access is granted (nothing is created at all), and no existing account is altered. The damage is a false receipt.
⭐ The measurement is real-engine and needs no re-running before work starts
ObjectQLover@objectstack/driver-sql+ better-sqlite3:memory:, plugin-auth's ownauthIdentityObjects, driven throughAuthManager.handleRequest; population stated (three humansys_userrows, zerosys_account,NODE_ENV=test); posture widened toemail_domainwith the domain allowlisted and a resolvableselfRegistrationPermissionSet. ⭐ And it carries its own control — theinvite_onlyleg on the same population answering 422 — so the 200 is a divergence, not a harness artifact.Boundary test — Bug, and the acceptance is already correctly stated
Making the widened posture answer
422(or another explicit refusal) restores declared = enforced: it does not widen any accept set, it narrows a response that currently claims something false.⚠️ It is a published wire-behaviour change — a caller receiving 200 today would receive 422 — but in the direction of truth, and the sibling posture already ships that exact code, so there is no new vocabulary and no contract to invent.⭐ The filer's acceptance line is the right one and I am adopting it: pin it on a real engine, because the population predicate and the uniqueness check both live below the fake doubles. ⛔ A pin written against mocks would pass today.
⛔ Deliverable 1 is the diagnosis the card deliberately did not do
Not investigated here: whether the response is synthesized on the forced-email-verification lane before the uniqueness refusal, or whether an insert is attempted and swallowed.
⇒ These two have different fixes and different blast radii.
⚠️ If it is a swallowed insert, the swallow may not be confined to this case, and the card's scope would grow. ⛔ Do not write the refusal before establishing which — a 422 bolted in front of a swallow leaves the swallow.⚠️ Related and worth reading first: #15588, routed alongside this one, also p1. It measures that the boot diagnostic's remedy (2) tells operators to do exactly the thing this card measures failing. ⇒ The two are one story from two ends: this card is the door, #15588 is the sign pointing at it. Fixing this one makes remedy (2) true; fixing #15588 alone does not fix this.⛔ Not a claim, not a dispatch — routing only.
Generated by Claude Code
- The 200 carries a user id no row holds. Measured: response id
Claim
Claimed by the
os-devseat dispatched from thedomain:servicesPM lane. Re-read the existing comments before starting: the only comment on this card is the triage routing note, which explicitly says "Not a claim, not a dispatch — routing only", so nobody has claimed it first.- Session:
03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909 - Branch:
claude/issue-15587-signup-already-exists-200(pushed empty as a write-routing probe before any edit) - Base sha:
95d5cbb31687558ac63dfdde90950d06f82820eb
First deliverable is the diagnosis the card deliberately did not do — whether the 200 is synthesized on the forced-email-verification lane ahead of the uniqueness refusal, or an insert is attempted and swallowed — before any refusal is written.
Not colliding with #15588: that card owns the remedy text in
boot-sign-in-reachability.ts; this one does not edit that report's message lines.
Generated by Claude Code
- Session:
os-dev-report
{ "issue": 15587, "status": "done", "branch": "claude/issue-15587-signup-already-exists-200", "pr": "https://github.com/objectstack-ai/objectstack/pull/15738", "premise_still_valid": true, "summary": "Deliverable 1 (the diagnosis the card deliberately did not do): the 200 is SYNTHESIZED on the forced-email-verification lane AHEAD of the uniqueness refusal. No insert is attempted and nothing is swallowed, so the card's scope does NOT grow. better-auth 1.7.2 sign-up.mjs sets shouldReturnGenericDuplicateResponse = requireEmailVerification || autoSignIn === false (:163); on a duplicate, findUserByEmail hits at :199 and returns buildGenericDuplicateResponse() -- an in-memory user from generateId() -- instead of throwing at :212, so createUser is never reached. Established three ways, not from the symptom: vendor source; an instrumented real engine showing ZERO insert calls across the request; and a decisive control that holds the POSTURE CONSTANT at the invite_only default (pending invitation on both legs) and moves only requireEmailVerification -- 422 becomes the synthetic 200. So the posture is not the cause, only what arms the shield, because a self-registration-permitting posture FORCES verification on. Corollary now pinned: the defect was never confined to the widened postures -- emailAndPassword.autoSignIn:false arms the same shield under invite_only. Fix: raise the uniqueness refusal on the /sign-up/email before-hook, the same seam and same reason as the audience refusal already there, built from better-auth's own BASE_ERROR_CODES entry so both lanes are byte-identical by construction. Ordered STRICTLY AFTER the audience gate so invite_only gains no account-existence oracle. USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL registered in the ADR-0112 ledger (the platform now emits it, not merely passes it through); the two regenerated content/docs/references/api files are that entry's generated consequence.", "tests": "Ablation on the merged head: fix removed from disk, mutation CONFIRMED by counting the call site off the file (1 -> 0, injected marker 1) and by a git hash-object distinct from the HEAD blob; restore CONFIRMED by an empty `git diff HEAD` plus a worktree hash equal to the HEAD blob and the marker count back to 0 (state observed, never an exit code). No rebuild leg needed and none claimed: the pin imports ./auth-manager.js RELATIVELY inside its own package, so vitest compiles the mutated source directly and no dist can shadow it. Result was the predicted split -- 5 failed | 2 passed: red on the five fix-asserting cases, GREEN on the two controls that assert unchanged behaviour. Suites on head a94d5d65f: plugin-auth vitest whole package 0 (95 files / 1993 tests); spec error-code-ledger + contract 0 (71 tests); typecheck plugin-auth+spec 0; `pnpm lint` = eslint . --no-inline-config 0 over 5975 files (population read from eslint's own config via --format json), 0 errors 0 warnings -- whole repo, no narrowing claimed; spec check:generated 0, all 15 artifacts current after the merge. Gates (exit captured by redirect, never a pipe): check-adr-0087-registration --base origin/main --head a94d5d65f 0, with --self-test control 0 (304 assertions); nul-bytes, error-code-casing, error-code-provenance, dispatcher-error-vocabulary, cross-package-test-inputs, engine-double-contract, where-matcher, objectql-double-limit, query-options-erasure, type-check-coverage, type-check-debt (--re-measure), dual-build-cjs-loads, auth-mount-ledger, error-status-conformance, test-source-alias, plus the spec-side audits the derivation named (empty-state, yaml-examples, variant-docs, exported-any, dual-source-exports, entry-nameability, browser-reachable-entries, llms-txt, liveness, strictness-ledger) and the tree-wide ones (driver-memory-census, refd-timer-probe, watch-hint-literal, merge-driver, published-files, org-identifier, slot-lookup, page-declaration-shape, logger-receiver-detach, spec-parsed-alias, type-source-resolution, doc-authoring, single-claim-paths, partof-closing-keyword, objectui-changeset, pm-half-states, changeset-gate-self-tests) -- all exit 0. Family re-derived with scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set, not from the dispatch list. NOT MEASURED, stated as such: check:dispatcher-error-vocabulary's zero carries NO information about this registration -- a control deleting the ledger entry from disk left it at exit 0, because the stamp is APIError.from(status, BASE_ERROR_CODES.MEMBER), an argument position holding a member expression on an imported vendor object, outside that gate's population and outside check:error-code-provenance's printed bounds (filed #15723). check:dual-build-cjs-loads exits 3 PREREQUISITE NOT MET on an unbuilt tree -- that is NOT a pass; it was re-run to a real 0 after a full workspace build, as was check:type-check-debt --re-measure.", "mcp_calls": "10", "open_questions": [ { "question": "The changeset declares plugin-auth `minor`. A caller receiving 200 today receives 422 tomorrow -- is minor the right severity, or should this be major?", "options": [ "A. minor (as shipped) -- nothing is newly admitted; the response that changes is one that reported a creation that never happened, the sibling posture already ships that exact code, and check-adr-0087-registration reads it as non-breaking", "B. major -- treat any published wire-status change as breaking regardless of direction" ], "recommendation": "A, because the acceptance and the triage both framed this as narrowing a false response rather than moving a contract, and no new vocabulary is introduced. Flagged rather than assumed because it is a published-surface judgement that belongs to the maintainer, and it is a one-line changeset edit if the PM prefers B." }, { "question": "On `open` and `email_domain`, the honest 422 lets an admitted caller distinguish an address that has an account from one that does not -- the disclosure the vendor's shield existed to prevent. Accept as the cost of the acceptance, or follow up?", "options": [ "A. Accept -- the card and triage chose the explicit refusal deliberately; invite_only (the default) gains no oracle because the audience gate refuses first, and email_domain callers must already hold an allowlisted corporate address", "B. Follow up separately with a rate limit or a notification on the sign-up duplicate path for the `open` posture specifically" ], "recommendation": "A for this card -- it is exactly what the acceptance asked for and reversing it would restore the false receipt. B is worth a separate card only if `open` is expected on internet-facing deployments; I did not file it, because filing it would presume that expectation." } ], "out_of_scope_findings": [ "filed as #15723: a wire error code stamped through APIError.from(status, VENDOR_CONSTANT.MEMBER) is registered nowhere and no gate notices -- proven with a control (ledger entry deleted from disk, gate still exit 0). Distinct from closed #9223, which covered the object-literal `code:` position; this is an argument position holding a member expression on an imported vendor object. Labelled pm:queue only.", "NOT filed, deliberately -- a deployment with human sys_user rows and zero sys_account rows has no in-product recovery path. Searched first: #14349 is CLOSED and boot-sign-in-reachability.ts cites the maintainer ruling of 2026-09-02, option A, 'the door stays shut'. It is a decided position, not a defect.", "NOT filed, belongs to #15588 -- reconciliation the PM needs: this fix does NOT make remedy (2) true, contrary to the triage note. It makes remedy (2) fail HONESTLY (422 instead of a silent 200); the person still cannot register a login, by the ruling above. #15588's remedy text may want to say remedy (2) cannot work for an existing person at all, rather than that it silently fails. No line of boot-sign-in-reachability.ts is touched by this PR." ] }Channel note. Duplicate-search went through MCP
search_issues, not REST: a repo-scoped REST read answered 403 in this container andghis absent. Two targeted searches for two distinct findings, no wide listing; both returned hits (this card itself, and #9223), so neither null was an unread channel.
Generated by Claude Code
Landed —
aedbaeff1PR #15738 merged to
mainasaedbaeff1. Verified by the landing authority rather than the PR's own state:git log origin/main | grep -c '(#15738)'= 1, control(#15365)= 1.pm:dispatchedstripped.The mechanism this card deliberately left open — answered, three independent ways
The card said: "Not investigated here: whether the response is synthesized on the forced-email-verification lane before the uniqueness refusal, or whether an insert is attempted and swallowed." It is the first: the 200 is synthesized ahead of the uniqueness refusal, no insert is attempted and nothing is swallowed — so this card's scope never grew.
better-auth 1.7.2 sets
shouldReturnGenericDuplicateResponse = requireEmailVerification || autoSignIn === false(sign-up.mjs:163); on a duplicate,findUserByEmailhits at:200and returnsbuildGenericDuplicateResponse()— an in-memory user fromgenerateId()— instead of throwing at:212, socreateUser(:225) is never reached. Established by vendor source, by instrumentation showing zero insert calls, and ⭐ by a control that holds the posture constant at theinvite_onlydefault (a pending invitation on both legs) and moves onlyrequireEmailVerification— 422 becomes the synthetic 200.⭐ That controlled experiment produced a corollary the card did not have, now pinned: the defect was never confined to the widened postures.
emailAndPassword.autoSignIn: falsearms the same shield underinvite_only. The posture was only ever what arms the shield, not the cause.The fix
The uniqueness refusal is raised on the
/sign-up/emailbefore-hook — the same seam and the same reason as the audience refusal already there — built from better-auth's ownBASE_ERROR_CODESso both lanes are byte-identical by construction. ⭐ Ordered strictly AFTER the audience gate, deliberately: asking first would hand an uninvited stranger an account-existence oracle on theinvite_onlydefault (422 for a real address vs 403 for an unknown one), inventing on the closed posture exactly what the vendor's shield exists to prevent. Driven:invite_onlywith no invitation, existing vs unknown address → 403/403, identical bodies.USER_ALREADY_EXISTS_USE_ANOTHER_EMAILis registered in the ADR-0112 ledger; the twocontent/docs/references/api/*.mdxchanges are that entry's generated consequence, confirmed byte-exact by regenerating.⛔ Round 1 failed on something the fix itself created
This PR falsified a published statement and left it standing:
content/docs/deployment/self-hosting.mdxstated as a measured fact that a seeded person's registration underemail_domain"answers 200 and persists nothing … sign-in still 401". After this fix it is a422. ⇒ Rewritten to name the refusal, keeping the bolded conclusion ("opening the audience posture is not enough on its own") untouched. ⭐ It also turned out the bullet contradicted the paragraph ten lines above it, which already stated the 422 — the page was internally inconsistent before this PR, not merely made stale by it.Round 2 additionally confirmed the two mid-round
origin/mainmerges dropped nothing (main moved 16 commits): hook block intact and still ordered after the gate, ledger hunk identical against the new merge-base, changeset byte-identical, test file+51/−0, andgen:docsbyte-exact against the new base too.The probe's silent catch — closed as observability, with the boundary measured
hasExistingUserFor'scatchreturnsfalse, which review showed is reachable and, when reached, is the pre-fix response (a throw scoped to just the probe's own query signature → 200, fresh id, no row, no log line anywhere). It was judged not the #14726 shape — the published claims stay true, and the ordinary total-failure case is loud (the vendor's own read fails through the same engine and the request answers 500). ⇒ The direction is unchanged; what was added is a log line, and ⭐ it is pinned (case ⑦), because an unasserted log line is exactly the thing that goes silent next. Mutating away only the log call reddens case ⑦ alone.⭐ A green gate that meant nothing, disclosed against the dev's own interest
check:dispatcher-error-vocabularypasses on this registration and tells you nothing about it: with the ledger entry deleted from disk the gate still exits 0, because the stamp isAPIError.from(status, BASE_ERROR_CODES.MEMBER)— an argument position holding a member expression on an imported vendor object, outside that gate's population. Review then measured thatcheck:error-code-provenanceis blind to it too, which the dev had only inferred. Filed as #15723.Open for the maintainer, ⛔ not decided here: the changeset ships
minorfor a 200→422 wire change (review's read: correct — nothing is newly admitted and the 200 was a false receipt, though better-auth's client reads200 token:nullas "verification pending", so that lane's UI goes from a misleading "check your mail" to an error). And #15746 — the honest 422 gives an admitted caller a user-enumeration oracle, most exposed on theopenposture; filed by this seat over the dev's declared non-filing, with "if triage rulesopenis not a supported internet-facing posture, close as moot" named as the cheapest resolution.
Generated by Claude Code
- added a commit that references this issue
on Sep 17, 2026 - added a commit that references this issue
on Sep 29, 2026
Found while measuring the recovery path for #14495 (docs card); filed unassigned and unlabeled for triage.
Measured
Real kernel:
ObjectQLover@objectstack/driver-sql+ better-sqlite3:memory:, plugin-auth's ownauthIdentityObjects, driven throughAuthManager.handleRequest. Population: three humansys_userrows, zerosys_accountrows,NODE_ENV=test. Audience posture widened toemail_domainwith the directory's domain allowlisted and a resolvableselfRegistrationPermissionSet.A sign-up for an address that ALREADY carries a
sys_userrow answers 200 with a freshly minted user id — and persists nothing:The same call on the same population under the
invite_onlydefault (admitted by a pendingsys_invitation) is refused honestly:Why it matters
The 200 carries a user id no row holds. An operator (or a provisioning script, or the console) that reads the status code concludes the account exists; the next sign-in is a 401 with nothing anywhere explaining it. It is the silent-success shape: a write path that reports created and stored nothing. It is also directly on the recovery path a locked-out deployment walks — the operator who widens the posture to let a seeded person register is told it worked.
Not investigated here: whether the response is synthesized on the forced-email-verification lane before the uniqueness refusal, or whether an insert is attempted and swallowed. The measurement above only establishes the observable contract violation.
Suggested acceptance
The refusal is the same fact under both postures: an address that already exists must answer
422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL(or another explicit refusal) rather than a 200 for a row that was never written — pinned on a real engine, since the population predicate and the uniqueness check both live below the fake doubles.Refs: #14495 (the docs card this was measured under) · #14349 (the posture ruling) · #14353 (the boot diagnostic).
Generated by Claude Code