Repository navigation
feat(plugin-auth, plugin-email): under open and email_domain, a sign-up for an existing address answers like a new one and mails that address, while mail is delivered - #25145
Conversation
…uccess and mails its holder under open and email_domain, while mail is delivered Ruling 1B on #25040. The /sign-up/email before-hook keeps the explicit refusal unless the posture permits self-registration, the vendor's anti-enumeration shield is on and IEmailService.isConfigured() answers true; the vendor's onExistingUserSignUp hook sends the new auth.existing_account_sign_up template (plugin-email, four locales) in the recipient's stored locale, then the deployment default. Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…after-hook stamps nothing on the answer's rowless user id Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…; changeset Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
… 28 seeded-row renders Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7d8df8e6fe4e442cfe5def08f41efec4e8069eb8 && git checkout 7d8df8e6fe4e442cfe5def08f41efec4e8069eb8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 459afea1f69ff69422de2a7a2c7674b6f7ea1aca c8b1dce6365cf61c7d8c95bb44dc08a0edc3fed0 && git checkout -B drift-repro 459afea1f69ff69422de2a7a2c7674b6f7ea1aca && git merge --no-ff c8b1dce6365cf61c7d8c95bb44dc08a0edc3fed0
node scripts/docs-audit/affected-docs.mjs --json 459afea1f69ff69422de2a7a2c7674b6f7ea1aca
|
Contract reviewServed-tier: The review is owed by the ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…ing address, now that open and email_domain answer like a new sign-up while mail is delivered Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
席位核对
Regen-provenance: 6329519413 ·
Prose faces, checked sentence by sentence against the diff:
Generated by Claude Code |
Fixes #25060
Clause-②: yes
Metadata-protocol: none
This carries out ruling 1B on #25040 (comment
6308462370), with the seat's calls Q1–Q3 on this card (comment6309679111). Under theopenandemail_domainaudience postures, a sign-up for an address that already has an account now answers the same shape as a successful sign-up, and that address's holder gets a mail saying it already has an account, pointing to sign-in and password reset. This happens only while the mail transport really delivers. Otherwise the explicit refusal stays, so a success with no mail behind it cannot come back.invite_onlyis unchanged.Disclosure: the #9387 ruling (
5323310017) applies. This body describes behaviour only. It carries no request shape, response body, status or code sample, and no probe.What changed
packages/plugins/plugin-auth/src/auth-manager.ts/sign-up/emailbefore-hook, after the audience gate and afterhasExistingUserForfinds the row. The existing-address refusal is raised unless all three of these hold (existingAddressSignUpAnswersSuccess):open,email_domain).isEmailDeliveryLive).IEmailService.isConfigured()(isEmailDeliveryLive). It is read at sign-up time and never cached, so a transport swapped from the mail settings applies to the next request. A service without the member, or a member that throws, reads as not live, so the request falls back to the refusal.hasEmailTransportandisEmailServiceAvailableare untouched.emailAndPassword.onExistingUserSignUphook (sendExistingAccountSignUpNotice). The vendor hands it the existing row. The hook checks posture and liveness again, because it also fires on the one path that never reached the decision: an unanswerable uniqueness probe falls through to the vendor. Soinvite_onlynever mails on that path either. The hook skips placeholder addresses, sendsauth.existing_account_sign_upas identity mail, and never throws. A failed send is logged once at error level.sys_user.locale, then the deployment default. There is no request rung, because the requester is not the recipient (Q2)./loginand/forgot-passwordpages, built throughgetConsolePageUrl. No token is minted.password_changed_aton that id, and every such sign-up logged the "password_changed_at / must_change_password were NOT written" durability warning about a user that was never created. Pin ① measured this: it records warn-level lines, and it went red before the fix. The before-hook now marks the request (__osExistingAddressSignUp, the samectx.contextchannel as the neighbouring__osDisableSignUpOrig), and the after-hook skips the stamp for it. Without this line, the landed behaviour produces a false lost-write report on every existing-address sign-up, so it stays in this PR.packages/plugins/plugin-email/src/templates/auth-templates.ts(Q2)auth.existing_account_sign_upinen-US,zh-CN,ja-JPandes-ES, and adds it toBUILTIN_AUTH_TEMPLATES(28 rows become 32).user.name(optional),user.email,signInUrl(url),resetPasswordUrl(url),appName(optional). The two URL holes are triple-braced only insidehrefattributes, as the outlet pin requires.Tests
plugin-auth/src/signup-existing-address-success-shape.test.ts(13 cases on a real ObjectQL and SQLite engine).plugin-email/src/auth-templates-locales.test.ts.builtin-auth-render-identity.test.ts(32 rows; the four new rows are captured from the registry render, and a comment says so) andemail-plugin.template-runtime-write.test.ts(32).signup-existing-address-refusal.test.ts. Its cases wire no email service, which is exactly the deployment that keeps the refusal, so they stand unchanged.content/docs/deployment/self-hosting.mdx(patch round 1, under the amended claim6329490109; cross-lane declaration on #60236329495818). The section "Who may sign up — and what each posture tells a stranger" is corrected to the behaviour as shipped, written abstractly under the #9387 discipline. Every other sentence of the page is byte-identical. Four parts changed:openrow of the table. It said a caller learns whether an address is registered. It now says a caller learns nothing while the mail transport is live: a registered address gets the same answer as a new sign-up, nothing is written, and the holder is mailed. Without a live transport, or with verification declared off and auto sign-in left on, the explicit refusal stays and does tell the caller.open, that disclosure is by design" and "That silence was never a real secret, either"). They are replaced by "What decides it onopenandemail_domain". It lists the three conditions: a self-registration posture, verification on or auto sign-in off, and a live transport perIEmailService.isConfigured(), asked at every sign-up, with plugin-email'slogtransport never live and an absent member counting as not live. It then describes the mail: the template, its language and its links.onExistingUserSignUpis deliberately not wired ("If you needopenwithout the disclosure, ask for it"). It is replaced by "Otherwise the refusal stays explicit". That paragraph names the three refusal cases, keeps the enumeration-oracle warning foropenwithout a live transport, and keeps the black-hole reason the refusal exists. It ends with how to close the oracle: keep verification on and connect a mail transport.email_domainremedy bullet ("Opening the audience posture is not enough on its own", under "Two remedies that look like they work"). A seeded person's registration writes nothing and buys no login either way: refused explicitly, or, with a live transport, answered like a new registration and mailed. The bullet's sentences on fresh addresses and verification are unchanged..changeset/25060-signup-existing-address.md: plugin-auth and plugin-email,patch. See the first acceptance note for why this ispatchand not the card'sminor.The partition-2 assumptions, measured on better-auth 1.7.12
dist/api/routes/sign-up.mjslines 155 and 192–204, andcontext/create-context.mjslines 215–224.onExistingUserSignUpfires only whenfindUserByEmailreturns a row AND the shield is on. It runs after the timing-equalizing hash, throughrunInBackgroundOrAwait. plugin-auth configures nobackgroundTaskshandler, so the hook is awaited inline, and its rejection is caught and logged by the vendor. This is unchanged from 1.7.3.email_domain, underopenwith verification on, and underopenwith verification off and auto sign-in off. It is unchanged from the 1.7.3 reading.isConfigured()as landed.EmailService.isConfigured()(plugin-email/src/email-service.ts) answers!(transport instanceof LogTransport), read from the live transport on every call. Its tests (email-service.test.tsandemail-plugin.mail-settings.test.ts) cover the log transport, a live transport, and swaps in both directions through the mail settings. An absent member reads as not live here (pin ②).Verification at
629addbf9pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2(full suite): lockVERDICT command-exit 0. Test Files 182 passed (182); Tests 3451 passed, 10 skipped (3461).pnpm --filter @objectstack/plugin-email exec vitest run --maxWorkers=2(full suite): Test Files 36 passed (36); Tests 668 passed (668).pnpm --filter @objectstack/plugin-email typecheck: exit 0, test layer 0 ledgered errors.pnpm --filter @objectstack/plugin-auth typecheck, after building both packages: lockVERDICT command-exit 0. The test layer holds its ledger unchanged (10 files, 88 errors, 23 signatures), and the new test file compiles clean.Ablations
Each row is one mutation of
auth-manager.tsthroughscripts/ablation-replace.mjs: an anchor that must hit, the landing proven by anchor count and blob change, a restore proven against the HEAD blob with an emptygit diff HEAD. Each row ran the new pin file. The suites resolve the source directly through a relative import, so no build is needed.invite_onlywith the shield on and a live transport.Every leg restored cleanly (
restored=yeson all eight).Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 70 commands at629addbf9. All 70 were run, with exit codes captured before any pipe.pnpm check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: it needs every package'sdist/, so a whole-repo build). That one is NOT MEASURED locally and left to CI.--ranreconciliation: "70 derived famil(ies) accounted for — 69 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)".pnpm lintfirst among them.c8b1dce63(adds the docs page). The derivation grew to 100 commands, adding the docs families. All 100 were run, with exit codes captured before any pipe.check:docsexited 1 because its generated tree had not been built.VERDICT command-exit 0), all nine exited 0. That includescheck:dual-build-cjs-loads, which was NOT MEASURED in the first round.--ranreconciliation: "100 derived famil(ies) accounted for — 100 run, 0 NOT-MEASURED (a DERIVED zero — all 100 recorded an exit code and none of them is 3)".Acceptance notes
patch, not the card'sminor. The card was filed before maintainer ruling A on changeset levels: minor only when the metadata protocol changes (added / narrowed / semantic), patch otherwise; release notes grouped by what the reader must do (maintainer ruling A, 2026-10-21) #25105 (2026-10-21). Ruling A makes the level follow theMetadata-protocol:declaration alone, and names REST and services asnone, which takespatch. This diff adds no authorable key, value or metadata type. A built-in template name is a free string, not a new accepted value. Withnone,check-changeset-no-majorrefuses aminor(none-minor). Declaringaddedto reachminorwould be a false declaration. The door change is described in the changeset either way.self-hosting.mdxwere left byte-identical, as the patch round ordered. Both still describe the refusal without the live-transport case. The recovery section's "Invite an address the directory does not already hold" says a sign-up for an address the directory holds is refused "invitation or not". That holds underinvite_only, the posture that recovery runs on, and the advice itself stays right. Theemail_domaintable row says a caller learns something only inside an allowlisted domain. That is still true, but it is now conditional on there being no live transport.plugin-email'ssrc/index.ts, which every sibling family is.index.tsis outside the claim's fence. The template is fully reachable throughBUILTIN_AUTH_TEMPLATESand the registry.openoremail_domain, that path now also mails the holder. The after-hook marker is not set on that path, so the pre-existing stamp warning remains there, next to the probe's own error line.Accept-Languagenever chooses the mail's language. The vendor hook is given the request, but this code does not read it, so there is no request rung to put back. The pin holds the line against a future change.origin/mainmoved by two commits since the base (459afea1f,4c7dcb451). Neither touches plugin-auth, plugin-email or the email contract. No merge was taken; CI and the queue validate the merge.Generated by Claude Code