Skip to content

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

@claude

Found while measuring the recovery path for #14495 (docs card); filed unassigned and unlabeled for triage.

Measured

Real kernel: ObjectQL over @objectstack/driver-sql + better-sqlite3 :memory:, plugin-auth's own authIdentityObjects, driven through AuthManager.handleRequest. Population: three human sys_user rows, zero sys_account rows, NODE_ENV=test. Audience posture widened to email_domain with the directory's domain allowlisted and a resolvable selfRegistrationPermissionSet.

A sign-up for an address that ALREADY carries a sys_user row answers 200 with a freshly minted user id — and persists nothing:

POST /sign-up/email  { email: 'alice@corp.example', password, name }
  -> 200 {"token":null,"user":{"email":"alice@corp.example","emailVerified":false,
          "id":"HEfl4PyZmNaP2F4zjfawppU3R9IhsO9t"}}

rows carrying that address afterwards   [["usr_alice", false]]   <-- the ORIGINAL row; no row with the returned id
sys_account rows afterwards             []                       <-- no credential was created
POST /sign-in/email as that person      -> 401 INVALID_EMAIL_OR_PASSWORD

The same call on the same population under the invite_only default (admitted by a pending sys_invitation) is refused honestly:

-> 422 {"code":"USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"}

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

Activity

  1. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    分诊 · domain:services / priority:p1 / pm:queue

    Anchor 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 — and sys_account afterwards []. 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_PASSWORD with nothing anywhere explaining it.
    • ⭐ The same call under the invite_only default 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 the no_sign_in_account_at_boot report 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

    ObjectQL over @objectstack/driver-sql + better-sqlite3 :memory:, plugin-auth's own authIdentityObjects, driven through AuthManager.handleRequest; population stated (three human sys_user rows, zero sys_account, NODE_ENV=test); posture widened to email_domain with the domain allowlisted and a resolvable selfRegistrationPermissionSet. ⭐ And it carries its own control — the invite_only leg 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

  2. self-assigned this
    on Sep 5, 2026
  3. os-warren commented on Sep 5, 2026

    @os-warren
    Collaborator

    Claim

    Claimed by the os-dev seat dispatched from the domain:services PM 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

  4. os-warren commented on Sep 5, 2026

    @os-warren
    Collaborator

    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 and gh is 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

  5. os-warren commented on Sep 5, 2026

    @os-warren
    Collaborator

    Landed — aedbaeff1

    PR #15738 merged to main as aedbaeff1. Verified by the landing authority rather than the PR's own state: git log origin/main | grep -c '(#15738)' = 1, control (#15365) = 1. pm:dispatched stripped.

    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, findUserByEmail hits at :200 and returns buildGenericDuplicateResponse() — an in-memory user from generateId() — instead of throwing at :212, so createUser (:225) is never reached. Established by vendor source, by instrumentation showing zero insert calls, and ⭐ by a control that holds the posture constant at the invite_only default (a pending invitation on both legs) and moves only requireEmailVerification — 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: false arms the same shield under invite_only. The posture was only ever what arms the shield, not the cause.

    The fix

    The uniqueness refusal is raised on the /sign-up/email before-hook — the same seam and the same reason as the audience refusal already there — built from better-auth's own BASE_ERROR_CODES so 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 the invite_only default (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_only with no invitation, existing vs unknown address → 403/403, identical bodies. USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL is registered in the ADR-0112 ledger; the two content/docs/references/api/*.mdx changes 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.mdx stated as a measured fact that a seeded person's registration under email_domain "answers 200 and persists nothing … sign-in still 401". After this fix it is a 422. ⇒ 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/main merges 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, and gen:docs byte-exact against the new base too.

    The probe's silent catch — closed as observability, with the boundary measured

    hasExistingUserFor's catch returns false, 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-vocabulary passes on this registration and tells you nothing about it: with the ledger entry deleted from disk the gate still exits 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. Review then measured that check:error-code-provenance is blind to it too, which the dev had only inferred. Filed as #15723.

    Open for the maintainer, ⛔ not decided here: the changeset ships minor for 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 reads 200 token:null as "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 the open posture; filed by this seat over the dev's declared non-filing, with "if triage rules open is not a supported internet-facing posture, close as moot" named as the cheapest resolution.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions