Repository navigation
Commit 2aaee3b
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
File tree
- .changeset
- content/docs/deployment
- packages/plugins
- plugin-auth/src
- plugin-email/src
- templates
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
609 | 609 | | |
610 | 610 | | |
611 | 611 | | |
612 | | - | |
613 | | - | |
614 | | - | |
615 | | - | |
616 | | - | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
617 | 618 | | |
618 | 619 | | |
619 | 620 | | |
| |||
641 | 642 | | |
642 | 643 | | |
643 | 644 | | |
644 | | - | |
645 | | - | |
646 | | - | |
647 | | - | |
648 | | - | |
649 | | - | |
650 | | - | |
651 | | - | |
652 | | - | |
653 | | - | |
654 | | - | |
655 | | - | |
656 | | - | |
657 | | - | |
658 | | - | |
659 | | - | |
660 | | - | |
661 | | - | |
662 | | - | |
663 | | - | |
664 | | - | |
665 | | - | |
666 | | - | |
667 | | - | |
668 | | - | |
669 | | - | |
670 | | - | |
671 | | - | |
672 | | - | |
673 | | - | |
674 | | - | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
675 | 681 | | |
676 | 682 | | |
677 | 683 | | |
| |||
0 commit comments