Skip to content

Commit 88e1b6f

Browse files
committed
Merge origin/main into claude/issue-25151-objectui-pin-13316
Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude <noreply@anthropic.com>
2 parents 7ab800f + 2aaee3b commit 88e1b6f

26 files changed

Lines changed: 2206 additions & 107 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
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.
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)