Skip to content

Commit 2aaee3b

Browse files
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)
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 (comment `6309679111`). Under the `open` and `email_domain` audience 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_only` is 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`** - **The decision stays in the `/sign-up/email` before-hook**, after the audience gate and after `hasExistingUserFor` finds the row. The existing-address refusal is raised unless all three of these hold (`existingAddressSignUpAnswersSuccess`): 1. The posture permits self-registration (`open`, `email_domain`). 2. The vendor's anti-enumeration shield is on. This is read from the options the vendor reads for it: verification required, or auto sign-in off. 3. Mail is delivered (`isEmailDeliveryLive`). - **One reader of `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. `hasEmailTransport` and `isEmailServiceAvailable` are untouched. - **The mail rides the vendor's `emailAndPassword.onExistingUserSignUp` hook** (`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. So `invite_only` never mails on that path either. The hook skips placeholder addresses, sends `auth.existing_account_sign_up` as identity mail, and never throws. A failed send is logged once at error level. - **Locale ladder:** the recipient's stored `sys_user.locale`, then the deployment default. There is no request rung, because the requester is not the recipient (Q2). - **Links:** the Console's `/login` and `/forgot-password` pages, built through `getConsolePageUrl`. No token is minted. - **One line in the sign-up after-hook, outside the claim's listed regions.** The vendor's success answer for an existing address carries a user id that belongs to no row. The after-hook stamped `password_changed_at` on 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 same `ctx.context` channel 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) - Adds `auth.existing_account_sign_up` in `en-US`, `zh-CN`, `ja-JP` and `es-ES`, and adds it to `BUILTIN_AUTH_TEMPLATES` (28 rows become 32). - Variables: `user.name` (optional), `user.email`, `signInUrl` (url), `resetPasswordUrl` (url), `appName` (optional). The two URL holes are triple-braced only inside `href` attributes, as the outlet pin requires. - The default footer stays: "if this wasn't you, you can safely ignore this message" is true here. **Tests** - New: `plugin-auth/src/signup-existing-address-success-shape.test.ts` (13 cases on a real ObjectQL and SQLite engine). - New block in `plugin-email/src/auth-templates-locales.test.ts`. - Count and digest updates: `builtin-auth-render-identity.test.ts` (32 rows; the four new rows are captured from the registry render, and a comment says so) and `email-plugin.template-runtime-write.test.ts` (32). - A header note in `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 claim `6329490109`; cross-lane declaration on #6023 `6329495818`). 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: - **The `open` row 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. - **The two disclosure-by-design paragraphs** ("On `open`, that disclosure is by design" and "That silence was never a real secret, either"). They are replaced by "What decides it on `open` and `email_domain`". It lists the three conditions: a self-registration posture, verification on or auto sign-in off, and a live transport per `IEmailService.isConfigured()`, asked at every sign-up, with plugin-email's `log` transport never live and an absent member counting as not live. It then describes the mail: the template, its language and its links. - **The paragraph saying `onExistingUserSignUp` is deliberately not wired** ("If you need `open` without 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 for `open` without 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. - **The `email_domain` remedy 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 is `patch` and not the card's `minor`. ## The partition-2 assumptions, measured on better-auth 1.7.12 1. **Vendor readings M4 and M5 were re-measured on 1.7.12.** - (a) Read from `dist/api/routes/sign-up.mjs` lines 155 and 192–204, and `context/create-context.mjs` lines 215–224. `onExistingUserSignUp` fires only when `findUserByEmail` returns a row AND the shield is on. It runs after the timing-equalizing hash, through `runInBackgroundOrAwait`. plugin-auth configures no `backgroundTasks` handler, so the hook is awaited inline, and its rejection is caught and logged by the vendor. This is unchanged from 1.7.3. - (b) Measured by pin ①, which does not write the shape down. On the same deployment, the existing-address answer equals a new sign-up's answer in status, header names, top-level members, user members and their types, a null token, and no session cookie. This holds under `email_domain`, under `open` with verification on, and under `open` with verification off and auto sign-in off. It is unchanged from the 1.7.3 reading. 2. **`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.ts` and `email-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 ②). 3. **The before-hook and vendor-hook split holds as assumed.** No change of route was needed. ## Verification at `629addbf9` - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` (full suite): lock `VERDICT 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: lock `VERDICT 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.ts` through `scripts/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 empty `git diff HEAD`. Each row ran the new pin file. The suites resolve the source directly through a relative import, so no build is needed. | Leg | Forbidden behaviour put back | Result | |:---|:---|:---| | A1 | a wired service reads as live (the option the seat refused) | red: 4 failed. The three not-live variants of pin ② and the runtime-swap case. | | A2 | the posture conjunct is dropped | red: 1 failed. Pin ③, the invited holder under `invite_only` with the shield on and a live transport. | | A3 | the shield conjunct is dropped | green, 13 passed. Pin ④ holds because the vendor itself refuses with the same code and message when the shield is off. Our conjunct keeps the refusal at our own seam (see the acceptance notes). | | A4 | the vendor hook is unwired | red: 7 failed. Pin ① in all three configurations, plus the swap, locale and failed-send cases. | | A5 | the decision always refuses (the old behaviour) | red: 7 failed, the same cases. | | A6 | the stored-locale rung is dropped | red: 1 failed. Pin ⑤, stored locale. | | A7 | the after-hook stamps the rowless id again | red: 2 failed. Pin ①, both postures (the warn-level lost-write report). | | A8 | the en-US subject is pasted under the zh-CN template row | red: 2 failed. Both the zh-CN render and the "no en-US text" negative pin. | Every leg restored cleanly (`restored=yes` on all eight). ### Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 70 commands at `629addbf9`. All 70 were run, with exit codes captured before any pipe. - 69 exited 0. `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it needs every package's `dist/`, so a whole-repo build). That one is NOT MEASURED locally and left to CI. - `--ran` reconciliation: "70 derived famil(ies) accounted for — 69 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". - Not run locally, and owned by CI: the repo-wide scans, `pnpm lint` first among them. - **Patch round 1, at `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. - On the first pass, nine exited non-zero for missing build prerequisites in the recreated worktree: eight exited 3, and `check:docs` exited 1 because its generated tree had not been built. - After a closure build of plugin-auth, plugin-email and lint with their dependencies (lock `VERDICT command-exit 0`), all nine exited 0. That includes `check:dual-build-cjs-loads`, which was NOT MEASURED in the first round. - `--ran` reconciliation: "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)". - The docs change touches no code, so the test runs above stand. ## Acceptance notes - **The changeset level is `patch`, not the card's `minor`.** The card was filed before maintainer ruling A on #25105 (2026-10-21). Ruling A makes the level follow the `Metadata-protocol:` declaration alone, and names REST and services as `none`, which takes `patch`. This diff adds no authorable key, value or metadata type. A built-in template name is a free string, not a new accepted value. With `none`, `check-changeset-no-major` refuses a `minor` (`none-minor`). Declaring `added` to reach `minor` would be a false declaration. The door change is described in the changeset either way. - **Two sentences of `self-hosting.mdx` were 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 under `invite_only`, the posture that recovery runs on, and the advice itself stays right. The `email_domain` table 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. - **The four new template constants are not re-exported from `plugin-email`'s `src/index.ts`**, which every sibling family is. `index.ts` is outside the claim's fence. The template is fully reachable through `BUILTIN_AUTH_TEMPLATES` and the registry. - **The shield conjunct is defence in depth, not the only guard (ablation A3).** Where the shield is off, the vendor refuses an existing address on its own, with the same code and message. Our conjunct keeps the refusal at the platform's seam, so it does not depend on that vendor branch. Pin ④ therefore reds only if both guards go. - **Accepted residual (Partition 3).** A send that fails after the decision leaves a success answer with no mail, the same gap a failed verification send leaves for a new address. It is logged once at error level, and pin ⑥ holds that. - **Probe-failure path.** When the uniqueness probe cannot be answered, the request falls through to the vendor exactly as before. With a live transport under `open` or `email_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. - **Pin ⑤'s second case is not ablated.** The second case checks that a requester's `Accept-Language` never 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. - **Timing.** Both answers now send one mail inline: verification for a new address, this notice for an existing one. The existing-address path no longer skips the send, which narrows the timing gap the vendor's hash only partly levels. - `origin/main` moved 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](https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9cfc575 commit 2aaee3b

9 files changed

Lines changed: 986 additions & 59 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/plugin-email': patch
4+
---
5+
6+
feat(plugin-auth, plugin-email): under the `open` and `email_domain` postures, a sign-up for an address that already has an account answers like a new sign-up and mails that address, while mail is really delivered
7+
8+
Clause-②: yes
9+
Metadata-protocol: none
10+
11+
The public email-and-password sign-up changes its answer in one case. Under the `open` and `email_domain` audience postures, a sign-up for an address that already has an account was refused explicitly. Now, when the deployment's mail transport really delivers, it answers exactly as a successful sign-up does on that deployment, and the address's holder receives a mail saying the address already has an account, with a link to sign in and a link to reset the password. Nothing is written: no new user, credential or session. The caller can no longer tell from the answer whether the address was registered.
12+
13+
- **When the explicit refusal stays, unchanged.**
14+
- The mail would not be delivered: the email service says its transport is not live (`IEmailService.isConfigured()` answers `false`, which plugin-email answers for its `log` transport in every environment), the email service does not implement `isConfigured()`, or no email service is wired. A success with no mail behind it is what the refusal replaced, so it is never answered.
15+
- The posture is `invite_only`. A stranger still learns nothing there, and an invited person whose address already has an account is still refused.
16+
- The posture is `open` with email verification declared off (and auto sign-in left on). A new sign-up there is signed in at once, and an existing address cannot get that answer without a session for someone else's account.
17+
- **Read at sign-up time.** The transport is asked on every sign-up, so a transport saved from the Mail settings applies to the next sign-up without a restart.
18+
- **The mail.** plugin-email ships a new built-in template, `auth.existing_account_sign_up`, in `en-US`, `zh-CN`, `ja-JP` and `es-ES`. It is registered like the other built-in auth templates, so an app declaration or a Studio save overrides it. Its variables are `user.name` (optional), `user.email`, `signInUrl`, `resetPasswordUrl` and `appName` (optional). The two links are the Console's sign-in and forgot-password pages and carry no token. `BUILTIN_AUTH_TEMPLATES` grows from 28 rows to 32.
19+
- **The mail's language.** The recipient's stored language (`sys_user.locale`), then the deployment default. The language of the browser that asked is never used, because the person signing up may not be the address's holder.
20+
- **For a client of the sign-up door.** In the configurations above, a screen that showed an "already registered" message on the refusal now receives the success answer instead, and should show its usual "check your email" step.
21+
- **A send that fails after the answer.** The answer is still the success, and the server logs one error-level line saying the mail was not sent. A verification mail that fails for a new address leaves the same gap.
22+
- **Unchanged.** A sign-up for a new address, every refusal the audience posture raises, and every other auth mail.

‎content/docs/deployment/self-hosting.mdx‎

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -609,11 +609,12 @@ this step.
609609
**Two remedies that look like they work, measured:**
610610

611611
- **Opening the audience posture is not enough on its own.** With the posture
612-
widened to `email_domain`, a seeded person's own registration is refused
613-
`422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` and nothing is written — the same
614-
answer the `invite_only` default already gives for an address the directory
615-
holds, so the widened posture buys that person no login and their sign-in
616-
stays `401`. A fresh address does get an account, but every posture other
612+
widened to `email_domain`, a seeded person's own registration writes nothing
613+
and buys them no login. It is refused explicitly, as the `invite_only` default
614+
already refuses an address the directory holds — or, while the mail transport
615+
is live, it gets the same answer a new registration gets and the address is
616+
mailed that it already has an account (see the next section). Either way their
617+
sign-in still fails. A fresh address does get an account, but every posture other
617618
than `invite_only` forces email verification on (unless an `open` deployment
618619
has itself declared it off — see
619620
[Email verification under each posture](#email-verification-under-each-posture)),
@@ -641,37 +642,42 @@ a populated deployment, posting to `/api/v1/auth/sign-up/email`:
641642
|:---|:---|
642643
| `invite_only` (default) | **Nothing.** A registered address and an unknown one both answer `403 SELF_REGISTRATION_CLOSED` — same body bytes, same headers. |
643644
| `email_domain` | **Only inside a domain you allowlisted.** Any address outside the list answers `403 EMAIL_DOMAIN_NOT_ALLOWED`, registered or not, so a caller must already hold an address on an allowlisted domain before they learn anything. |
644-
| `open` | **Whether any address is already registered.** A registered address answers `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`; an unregistered one answers `200`. |
645-
646-
**On `open`, that disclosure is by design.** Anyone who can reach the endpoint
647-
can test an address and learn whether it belongs to a user of this deployment,
648-
one address per request, holding no account of their own. On an
649-
internet-facing deployment that is an address-enumeration oracle. It is a cost
650-
of the posture rather than an oversight: the endpoint refuses **honestly**, and
651-
what that refusal replaced was worse for everyone except the enumerator. A duplicate
652-
sign-up used to answer `200` carrying a freshly minted user id for a row that
653-
was never written — no account, no verification mail, and the person's next
654-
sign-in `401` with nothing anywhere explaining it.
655-
656-
**That silence was never a real secret, either.** The auth engine hashed the
657-
password on both lanes to level the timing and stopped there: a genuine
658-
registration also writes rows and dispatches a verification mail, so the mail
659-
that never arrives — and the account that can never sign in — already told a
660-
patient caller what the `422` now states outright. The explicit refusal made an
661-
existing disclosure legible; it did not create it. `invite_only` and
662-
`email_domain` are untouched by it: the uniqueness refusal is reached only for
663-
a caller the posture has already admitted, so a stranger on the default still
664-
gets `403 SELF_REGISTRATION_CLOSED` and learns nothing.
665-
666-
**If you need `open` without the disclosure, ask for it.** better-auth, the
667-
auth engine underneath, ships the designed alternative: `onExistingUserSignUp`,
668-
a hook that pairs a generic sign-up response with a notification to the
669-
address's real holder — "someone tried to register with your email". It is
670-
deliberately **not wired** here, because turning it on trades the honest
671-
refusal above for a silent success, which is the shape that behaviour exists to
672-
remove. If you run a real internet-facing `open` deployment and want that
673-
trade, open an issue saying so: it is a per-deployment decision, waiting on a
674-
deployment that needs it.
645+
| `open` | **Nothing, while the mail transport is live.** A registered address gets the same answer as a new sign-up, nothing is written, and the address's holder is mailed that it already has an account. Without a live transport, or with email verification declared off and auto sign-in left on, a registered address is refused explicitly, so the answer tells a caller that the address is registered. |
646+
647+
**What decides it on `open` and `email_domain`.** A sign-up for an address that
648+
already has an account gets the same answer as a successful new sign-up, and
649+
writes nothing, when all three of these hold:
650+
651+
- The posture is `open` or `email_domain`. `invite_only` is unchanged: the
652+
uniqueness question is reached only for a caller the posture has already
653+
admitted, so a stranger on the default still learns nothing.
654+
- Email verification is on, or auto sign-in is off. Only then does a new
655+
sign-up's own answer carry no session, so an existing address can get the
656+
same answer without a session for someone else's account.
657+
- The email service's transport is live: `IEmailService.isConfigured()` answers
658+
true. It is asked at every sign-up, so a transport saved from the mail
659+
settings applies to the next one. plugin-email's `log` transport is never
660+
live, in any environment, and an email service without that member counts as
661+
not live.
662+
663+
The address's holder then receives a mail saying the address already has an
664+
account, with links to the sign-in and forgot-password pages and no token. It is
665+
the built-in `auth.existing_account_sign_up` template, overridable like the
666+
other auth mails, in the holder's stored language or else the deployment
667+
default. The platform wires better-auth's `onExistingUserSignUp` hook to send
668+
it, and decides first, before the auth engine answers, whether that mail can be
669+
promised.
670+
671+
**Otherwise the refusal stays explicit.** With no live transport, on
672+
`invite_only`, or on `open` with verification declared off and auto sign-in
673+
left on, a sign-up for a registered address is refused outright. On `open`
674+
that refusal tells anyone who can reach the endpoint whether an address belongs
675+
to a user of this deployment, one address per request — an address-enumeration
676+
oracle on an internet-facing deployment. It stays because a success answer is
677+
honest only when a mail follows it: what the refusal replaced was a duplicate
678+
sign-up answered as a success for a row that was never written, with no mail
679+
and a next sign-in that failed with nothing explaining it. To close the oracle
680+
on `open`, keep verification on and connect a mail transport.
675681

676682
### Email verification under each posture
677683

0 commit comments

Comments
 (0)