You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 88e1b6f
Browse filesBrowse the repository at this point in the historyBrowse files
fix(service-automation,runtime): a flow whose data node is refused answers its caller with the refusal structured — `details.innerRefusal` and the producer-marked `userMessage` on every door's `400 FLOW_FAILED` — beside an unchanged `message`
7
+
8
+
Clause-②: yes (widening)
9
+
Metadata-protocol: none
10
+
11
+
Ruling B′ on #24914 (the maintainer's 「同意」, decision batch #379 item 5), the emission half of the contract `@objectstack/spec` declared in #24996. A record save the platform refuses reaches a record form as `code`, `message` and the producer-marked `userMessage`; the same refusal met by a flow's data node used to be flattened into one string (`Node 'ID' failed: create_record(OBJECT) failed: …`), so a screen flow could only show its user that developer text.
12
+
13
+
-**`service-automation`.** The catch arms of `get_record`, `create_record`, `update_record` and `delete_record` read the refusal off the thrown error: the producer-marked `userMessage` (through `declaredUserMessage`) and, for a throw that declared a 4xx answer, `{ code, message }` with `code` in the WIRE spelling the data door answers for the same throw — `UNIQUE_VIOLATION` for a unique refusal, `VALIDATION_FAILED`, `PERMISSION_DENIED`, or the member an app hook's declared status names. `NodeExecutionResult` gains the two optional members, and the engine carries them from the failing node to `AutomationResult.userMessage` / `.innerRefusal` on `execute()`'s failure exit, the `resumeInternal` stranded exit and the retry exits. A fault (no declared answer, or a declared 5xx) carries neither member; nothing is read from message text.
14
+
-**`runtime`.** The trigger, resume, `/actions` and declared-endpoint doors build their `400 FLOW_FAILED` details through one builder typed as `FlowFailureDetailsSchema` (`errorMessage?`, `summary?`, `innerRefusal?`) and put the run's `userMessage` at the envelope's top level (`ApiErrorSchema.userMessage`). The MCP `resume_run` bridge carries both as the REST resume door serves them. `DomainHandlerDeps.error` gains an optional fourth argument, `extra: { userMessage? }`, the declared slot the doors use; a call without it builds the envelope it always built.
15
+
-**Unchanged.** Every `error` / `message` text, byte for byte — the node's, the engine's prefix, the stored run, approvals and each door's wording. The in-flow `$error.code` keeps the engine's in-process `DUPLICATE_RECORD`. `error.code` stays `FLOW_FAILED`, and `AutomationResult.code` stays the closed union.
16
+
17
+
**For consumers.** A flow runner can render a refused run the way a record form renders a refused save: the top-level `userMessage` when present, then `details.innerRefusal`, then `message`. A client that does not read the new members keeps its current behaviour. A container that rewords a child's failure into its own (`try_catch` without a catch region, `subflow`) does not forward the members yet; the run then carries none.
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.
Copy file name to clipboardExpand all lines: content/docs/deployment/self-hosting.mdx
+42-36Lines changed: 42 additions & 36 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -609,11 +609,12 @@ this step.
609
609
**Two remedies that look like they work, measured:**
610
610
611
611
- **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
617
618
than `invite_only` forces email verification on (unless an `open` deployment
618
619
has itself declared it off — see
619
620
[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`:
641
642
|:---|:---|
642
643
| `invite_only` (default) | **Nothing.** A registered address and an unknown one both answer `403 SELF_REGISTRATION_CLOSED` — same body bytes, same headers. |
643
644
| `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.
0 commit comments