Skip to content

Commit 09720e2

Browse files
fix(auth): say on /auth/config whether email leaves the deployment, so forgot-password can stop claiming a link was sent (#30365)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg6s33jXCmdvuMRxu2PeJhbi)_ domain:server#2 · os-bill · session_01Utp6CbSX6BdpVNjS79LB5A Fixes #30107 Clause-②: yes (widening) — `GET /api/v1/auth/config` gains an optional `features.emailDelivery` boolean (`AuthFeaturesConfigSchema` in `@objectstack/spec/api`), and the flag joins `PUBLIC_AUTH_FEATURES`, so `requiresFeature` on an action accepts one more value, `'emailDelivery'`. Metadata-protocol: added — that `requiresFeature` value is a new accepted value an author can write; the Claim's `none` was re-measured: the PR moves `packages/spec/authorable-surface/api.json` (a `none` declaration is refused for that by the level axis) and widens the `requiresFeature` enum. Skills-docs: none — no skill or hand-written doc enumerates the `requiresFeature` values or the `/auth/config` feature flags; the generated reference pages (`content/docs/references/api/auth-endpoints.mdx`, `content/docs/references/ui/action.mdx`) carry the new member and value in this PR. ## 维护者速读 - 改了什么:登录配置接口 `GET /api/v1/auth/config` 新增一个开关 `features.emailDelivery`,说明「这台服务器能不能真的发出邮件」。只有接了邮件服务且该服务自报已配置时为 true;没有邮件服务、或用默认的 log 传输(只把邮件打印到服务器控制台)时为 false。忘记密码页据此就能不再谎称「重置链接已发送」。 - 为什么改:新装的自托管部署默认不发邮件,成员点忘记密码却看到「已将重置链接发送至你的邮箱」,等一封永远不会到的信;服务端其实知道发不出去,只写进日志,页面无从得知。 - 风险与回滚:只加一个可选字段,现有字段与行为不变。它说的是部署、不是某个邮箱:已注册和未注册的邮箱拿到的重置请求应答仍完全相同,不会泄露账号是否存在。判定复用邀请成员已在用的同一条规则(`isEmailDeliveryLive`),每次请求现读,邮件服务后配上也不用重启。回滚即撤本 PR。 - 先看:`packages/plugins/plugin-auth/src/auth-manager.ts` 的 `getPublicConfig()` 里 `emailDelivery` 一行;`packages/spec/src/kernel/public-auth-features.ts` 的 `emailDelivery` 条目;`packages/plugins/plugin-auth/src/reset-request-mail-after-response.test.ts` 新增的一组用例。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/reset-request-mail-after-response.test.ts`,预期 1 个文件、12 条全绿。 - 需要你做的:无,席位落地;页面文案是 `domain:ui` 的另一半,由席位立卡。 ## What changes **The rule: the public auth config says whether mail leaves this deployment, and says it about the deployment, never about an address.** - **The served flag (`@objectstack/plugin-auth`).** `getPublicConfig()` adds `features.emailDelivery: this.isEmailDeliveryLive()`. That is the one deliverability rule this package already has: the wired email service's own `isConfigured()`, read per call, the rule sign-up and invite-member's `outcome: 'sent' | 'link_only'` already use. It is not `hasEmailTransport()` / `isEmailServiceAvailable()`, which answer "a service object is wired" and are true on every default boot, whose `log` transport writes mail to the console. So: no email service, `false`; the `log` transport (or any service reporting itself unconfigured, or one without the member), `false`; a configured transport, `true`. Read at request time, so a transport configured later from the mail settings is reported without a restart. The hono adapter's `/auth/config` arm serves `getPublicConfig()` unchanged, so it serves the flag too. - **The contract (`@objectstack/spec`).** `AuthFeaturesConfigSchema` gains `emailDelivery: z.boolean().optional()`, and `PUBLIC_AUTH_FEATURES` classifies it (`surface: 'login'`, `semantics: 'opt-in'`, exempt: no spec input is gated), which the drift guard in plugin-auth requires for every boolean the config serves. Being classified also makes it a `requiresFeature` value, lowered to `features.emailDelivery == true`. - **Why this carrier and not an `outcome` on the reset answer.** Measured both. The `/auth/config` flag has the exact precedent in `features.phoneNumberOtp` ("only advertised when an SMS service can actually deliver"), which already drives the phone branch of the same forgot-password form; the page reads the config before submit, so it can say "a reset link cannot be emailed here, ask an administrator" on the form itself instead of after a pointless submit; and the console's `forgotPassword(email)` resolves to `void` and discards the reset answer, so an answer member would need more console plumbing. The reset answer itself (`POST /api/v1/auth/request-password-reset`, `{ status: true, message }`) is not changed. - **No account-existence oracle.** The flag is a property of the deployment and is the same for every caller. The reset request still answers a known and an unknown address with the same status and body, with and without mail; the new pins assert that in all three transport states. ## Tests `packages/plugins/plugin-auth/src/reset-request-mail-after-response.test.ts`, a new top-level `describe` in the reset door's existing harness (real `AuthManager.handleRequest` over a real `ObjectQL` engine on better-sqlite3), 3 cases: - no email service: `features.emailDelivery` is `false`, and the reset request for an address with an account and one without answers the same `200` body; - a transport reporting itself unconfigured (the `log` shape): `false`, same answer for both addresses; - a configured transport: `true`, both addresses answer the same, and that answer equals the one a deployment without mail gives. Updated: the exact `features` fixture in `auth-manager.test.ts` (`emailDelivery: false`, no email service there), the served fixture in `packages/spec/src/api/auth-endpoints.test.ts`, and the registry roster in `packages/spec/src/kernel/public-auth-features.test.ts` (12 → 13 flags). <details><summary>Ablations (forbidden behaviour put back with `scripts/ablation-replace.mjs`, red seen, restored; the tests import `./auth-manager.js` from source)</summary> - A. `emailDelivery: this.isEmailServiceAvailable()` (the "service wired" reader): `Tests 1 failed | 11 passed (12)` — the log-transport case, `AssertionError: expected true to be false`. Restore: blob `5bc1cba772b0…` equals the HEAD blob. - B. the `emailDelivery` line deleted: `Tests 5 failed | 12 passed (17)` over the reset harness and `public-feature-registry.test.ts` — all three new cases, plus the registry drift guard ("every boolean flag served … is classified") and its default-semantics case. Restore: blob equals the HEAD blob. </details> ## Acceptance notes - **The console half (`domain:ui`), not in this PR.** `ui/packages/auth/src/ForgotPasswordForm.tsx` and the `successDescription` strings in `ui/packages/i18n` still say the link was sent; the form should read `features.emailDelivery` from `/auth/config` (as it reads `phoneNumberOtp`) and, when it is not `true`, say a reset link cannot be emailed on this deployment and to ask an administrator (who can set a password from Setup → Users), the same for every address. Named for the seat to file; the UI lane is off duty. - **Siblings measured.** The visitor verification-email resend already refuses with `400 EMAIL_SERVICE_REQUIRED` when the transport is not live (`visitor-verification-resend.ts`), so it does not say "sent". Invite-member already answers `outcome: 'link_only'`. `POST /magic-link/send` still answers success with no mail, but magic link is not advertised on `/auth/config` and no console UI calls it; a caller can now read `features.emailDelivery` first. Noted, not filed. ## Verification Gates, suites and lint on head `f78457d8` (`origin/main` merged in at that commit). <details><summary>Suites and typecheck</summary> - `pnpm --filter @objectstack/spec test`: Test Files 813 passed (813), Tests 25579 passed | 1 todo (25580). - `pnpm --filter @objectstack/plugin-auth test`: Test Files 220 passed (220), Tests 3879 passed | 10 skipped (3889). - `pnpm --filter @objectstack/platform-objects exec vitest run src/feature-gate-guard.test.ts` (the registry's other consumer): 112 passed. - `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. - `pnpm --filter @objectstack/spec check:generated`: `content/docs/references/**` stale after the `.describe()` change; `--fix` regenerated exactly that one (the `auth-endpoints` and `action` reference pages, the latter because `requiresFeature`'s enum grew), committed. </details> <details><summary>Derived gates: 106 derived, 105 exit 0, 1 NOT MEASURED</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 106 commands; each ran with its exit code captured before any pipe. - `--ran` reconciliation: "106 derived famil(ies) accounted for — 105 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". - NOT MEASURED: `pnpm check:dual-build-cjs-loads` exits 3 (PREREQUISITE NOT MET, 35 packages without `dist/`; it needs a whole-workspace build). CI runs it. - Re-run after their prerequisite: `node scripts/check-plugin-teardown-shape.mjs --self-test` exit 0 ("48 cases pass", after `git fetch origin 621a487…` on the shallow checkout); `check:skill-examples` exit 0 after building `@objectstack/client` and `client-react` ("283 prose examples type-check"). - `pnpm lint` (`eslint . --no-inline-config`): exit 0. - No full dogfood: the title carries no `!` and the Clause-② arm is `(widening)`. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed (`authorable-surface/api.json`, and the two auto-generated reference pages from `check:generated --fix`). - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/` (the two reference pages are generator output). - [x] 8. Title, body and comments are in English (the 维护者速读 block is Chinese by the Communication rule). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CbSX6BdpVNjS79LB5A <!-- signed: domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d3f23d2 commit 09720e2

11 files changed

Lines changed: 101 additions & 6 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
`GET /api/v1/auth/config` now says whether email leaves the deployment, in a new optional `features.emailDelivery` flag. It is `true` only when the wired email service reports itself configured, and `false` with no email service or with the stock `log` transport, which writes mail to the server console instead of sending it. The forgot-password page can read it to stop saying a reset link was sent on a deployment that sends no mail.
7+
8+
The flag describes the deployment, not an address: `POST /api/v1/auth/request-password-reset` still answers a known and an unknown address the same way, with or without mail.
9+
10+
Like every public auth flag, it is classified in `PUBLIC_AUTH_FEATURES` (login surface, opt-in), so an action may now also name it: `requiresFeature: 'emailDelivery'` is a new accepted value, lowered to `features.emailDelivery == true`.
11+
12+
Clause-②: yes (widening)
13+
Metadata-protocol: added

‎content/docs/references/api/auth-endpoints.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const result = AuthEndpointSchema.parse(data);
6363
| **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) |
6464
| **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) |
6565
| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service |
66+
| **emailDelivery** | `boolean` | optional | Email leaves this deployment: the wired email service reports itself configured with a real transport, so password-reset, verification and invitation mail is delivered. False with no email service and with the stock log transport, which writes mail to the server console instead of sending it; the forgot-password page can then say a reset link cannot be emailed here and to ask an administrator. A statement about the deployment, never about an address: the password-reset request answers known and unknown addresses alike either way. Absent means not live |
6667
| **cloudManaged** | `boolean` | optional | Managed (cloud-hosted, SSO-only) mode: people arrive through the identity provider, so the UI hides inviting, creating users and setting their passwords; absent means not managed |
6768
| **turnstileSiteKey** | `string` | optional | Public Cloudflare Turnstile site key, present only while the opt-in sign-up challenge is on: the sign-up form renders the widget and sends its token in the x-captcha-response header; absent means no challenge |
6869
| **multiOrgEnabled** | `boolean` | optional | Multi-organization capability: true when the effective tenancy posture enforces an organization wall (group or isolated), the same answer the create-organization door gives; false on a single posture, and on a degraded deployment that asked for isolation but has none. The console offers creating, switching and leaving organizations only when it is on; the flag is default-on, so absent reads as on |
@@ -200,6 +201,7 @@ This schema accepts one of the following structures:
200201
| **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) |
201202
| **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) |
202203
| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service |
204+
| **emailDelivery** | `boolean` | optional | Email leaves this deployment: the wired email service reports itself configured with a real transport, so password-reset, verification and invitation mail is delivered. False with no email service and with the stock log transport, which writes mail to the server console instead of sending it; the forgot-password page can then say a reset link cannot be emailed here and to ask an administrator. A statement about the deployment, never about an address: the password-reset request answers known and unknown addresses alike either way. Absent means not live |
203205
| **cloudManaged** | `boolean` | optional | Managed (cloud-hosted, SSO-only) mode: people arrive through the identity provider, so the UI hides inviting, creating users and setting their passwords; absent means not managed |
204206
| **turnstileSiteKey** | `string` | optional | Public Cloudflare Turnstile site key, present only while the opt-in sign-up challenge is on: the sign-up form renders the widget and sends its token in the x-captcha-response header; absent means no challenge |
205207
| **multiOrgEnabled** | `boolean` | optional | Multi-organization capability: true when the effective tenancy posture enforces an organization wall (group or isolated), the same answer the create-organization door gives; false on a single posture, and on a degraded deployment that asked for isolation but has none. The console offers creating, switching and leaving organizations only when it is on; the flag is default-on, so absent reads as on |

‎content/docs/references/ui/action.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const result = ActionSchema.parse(data);
5555
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. |
5656
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
5757
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
58-
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| … +2 more>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
58+
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| … +3 more>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
5959
| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +4 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. |
6060
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
6161
| **disabledReason** | `string \| Record<string, string>` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. |
@@ -95,6 +95,7 @@ const result = ActionSchema.parse(data);
9595
* `phoneNumber`
9696
* `cloudManaged`
9797
* `phoneNumberOtp`
98+
* `emailDelivery`
9899

99100
### Allowed Values: `Action.requiresMembershipReach`
100101

@@ -248,7 +249,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
248249
| **defaultFromRow** | `boolean` | optional | |
249250
| **carryOver** | `boolean` | optional | Carry-over param: seed the value from the current row (requires defaultFromRow: true), render it as a non-editable summary in the dialog, and submit it verbatim in the request body. Unlike `visible: false` (which omits the param from the submission entirely), a carry-over param is always sent. |
250251
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Param visibility predicate (CEL); omits the param when false. |
251-
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| … +2 more>` | optional | Public auth feature flag gating this param; lowered into `visible` at parse time. |
252+
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| … +3 more>` | optional | Public auth feature flag gating this param; lowered into `visible` at parse time. |
252253

253254
### Allowed Values: `ActionParam.type`
254255

@@ -316,6 +317,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
316317
* `phoneNumber`
317318
* `cloudManaged`
318319
* `phoneNumberOtp`
320+
* `emailDelivery`
319321

320322
### Nested Shape: `ActionParam.options[number]`
321323

‎packages/plugins/plugin-auth/src/auth-manager.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3208,6 +3208,8 @@ describe('AuthManager', () => {
32083208
admin: false,
32093209
phoneNumber: false,
32103210
phoneNumberOtp: false,
3211+
// [#30107] No email service is wired here, so no mail leaves.
3212+
emailDelivery: false,
32113213
multiOrgEnabled: false,
32123214
// [ADR-0105 D1] Which of `single` | `group` | `isolated` is in force.
32133215
// A vanilla deployment (no tenancy service wired, no env override) is

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2790,7 +2790,9 @@ export class AuthManager {
27902790
* transport swapped from the mail settings at runtime). Read at call time,
27912791
* never cached at boot. [#27843] invite-member's `outcome` asks it too
27922792
* (`invitation-delivery-outcome.ts`), so an invite answers `link_only` on
2793-
* exactly the deployments where sign-up would not mail either.
2793+
* exactly the deployments where sign-up would not mail either. [#30107]
2794+
* `getPublicConfig()`'s `features.emailDelivery` serves the same answer, so
2795+
* the forgot-password page stops saying a reset link was sent where none is.
27942796
*
27952797
* ⛔ An implementation WITHOUT the member is NOT live: the contract says an
27962798
* absent member is unknown, and the flow asking — a success answer that only
@@ -4701,6 +4703,14 @@ export class AuthManager {
47014703
// SMS path can actually deliver (plugin on + deliverable SMS service),
47024704
// so the login UI never shows a dead "sign in with code" option.
47034705
phoneNumberOtp: ((pluginConfig as any).phoneNumber ?? false) && this.isPhoneOtpDeliverable(),
4706+
// [#30107] Does mail leave this deployment? The one deliverability rule
4707+
// (`isEmailDeliveryLive()`: the wired service's own `isConfigured()`),
4708+
// read per request — so no email service and the stock `log` transport
4709+
// both answer false, and a transport configured later answers true
4710+
// without a restart. ⛔ Not `isEmailServiceAvailable()`: that is true on
4711+
// every default boot. The reset request answers known and unknown
4712+
// addresses alike either way; this describes the deployment, not one.
4713+
emailDelivery: this.isEmailDeliveryLive(),
47044714
// The managed (cloud-hosted, SSO-only) mode, from the one accessor every
47054715
// judgement site reads (ruling 6341690661 on #25083, Q2 D / Q3 A). A
47064716
// hide-when-on flag: the console hides the doors that add people.

‎packages/plugins/plugin-auth/src/reset-request-mail-after-response.test.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@
2828
* gets; the server-side phone reset call (no `Request`) still awaits its
2929
* send;
3030
* - `runAfterResponse` hands a task's synchronous throw to `onFailure` and
31-
* survives an `onFailure` that throws.
31+
* survives an `onFailure` that throws;
32+
* - the public config says whether mail leaves the deployment
33+
* (`features.emailDelivery`): false with no email service and with a
34+
* transport that reports itself unconfigured (the stock `log` one), true
35+
* with a configured one. It describes the deployment, not an address: in
36+
* all three a known and an unknown address get the same reset answer, and
37+
* that answer is the same one whether mail leaves or not.
3238
*/
3339

3440
import { describe, it, expect, afterAll, afterEach, vi } from 'vitest';
@@ -297,6 +303,45 @@ describe('the phone-number reset request answers without waiting on the reset te
297303
}, 60_000);
298304
});
299305

306+
describe('the public config says whether a reset link can be emailed from this deployment', () => {
307+
/** The deployment's one account, then the email service under test swapped in. */
308+
async function withEmailService(service: Record<string, unknown> | undefined) {
309+
const d = await deployment(async () => ({ status: 'sent' }));
310+
d.manager.setEmailService(service as never);
311+
const config = d.manager.getPublicConfig() as { features: Record<string, unknown> };
312+
const forAccount = await requestReset(d.manager, EMAIL);
313+
const forNobody = await requestReset(d.manager, NOBODY);
314+
return { emailDelivery: config.features.emailDelivery, forAccount, forNobody };
315+
}
316+
317+
const sendTemplate = async () => ({ status: 'sent' });
318+
319+
it('no email service: no delivery, and a known and an unknown address get the same answer', async () => {
320+
const r = await withEmailService(undefined);
321+
322+
expect(r.emailDelivery).toBe(false);
323+
expect(r.forAccount.status).toBe(200);
324+
expect(r.forAccount).toEqual(r.forNobody);
325+
}, 60_000);
326+
327+
it('a transport that reports itself unconfigured (the log one): no delivery, same answer for both addresses', async () => {
328+
const r = await withEmailService({ sendTemplate, isConfigured: () => false });
329+
330+
expect(r.emailDelivery).toBe(false);
331+
expect(r.forAccount.status).toBe(200);
332+
expect(r.forAccount).toEqual(r.forNobody);
333+
}, 60_000);
334+
335+
it('a configured transport: delivery, and the reset answer is the one a deployment without mail gives', async () => {
336+
const live = await withEmailService({ sendTemplate, isConfigured: () => true });
337+
const none = await withEmailService(undefined);
338+
339+
expect(live.emailDelivery).toBe(true);
340+
expect(live.forAccount).toEqual(live.forNobody);
341+
expect(live.forAccount).toEqual(none.forAccount);
342+
}, 60_000);
343+
});
344+
300345
describe('runAfterResponse', () => {
301346
it("hands a task's synchronous throw to onFailure", async () => {
302347
const failures: unknown[] = [];

‎packages/spec/authorable-surface/api.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@
214214
"api/AuthFeaturesConfig:cloudManaged",
215215
"api/AuthFeaturesConfig:degradedTenancy",
216216
"api/AuthFeaturesConfig:deviceAuthorization",
217+
"api/AuthFeaturesConfig:emailDelivery",
217218
"api/AuthFeaturesConfig:magicLink [RETIRED]",
218219
"api/AuthFeaturesConfig:multiOrgEnabled",
219220
"api/AuthFeaturesConfig:oidcProvider",

‎packages/spec/src/api/auth-endpoints.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ describe('AuthFeaturesConfig declares every classified served key', () => {
234234
admin: true,
235235
phoneNumber: false,
236236
phoneNumberOtp: false,
237+
emailDelivery: false,
237238
cloudManaged: false,
238239
termsUrl: 'https://objectstack.ai/terms',
239240
privacyUrl: 'https://objectstack.ai/privacy',

‎packages/spec/src/api/auth-endpoints.zod.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,11 @@ export const AuthFeaturesConfigSchema = lazySchema(() => z.object({
262262
phoneNumberOtp: z.boolean().optional().describe(
263263
'Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service',
264264
),
265+
emailDelivery: z.boolean().optional().describe(
266+
'Email leaves this deployment: the wired email service reports itself configured with a real transport, so password-reset, verification and invitation mail is delivered. '
267+
+ 'False with no email service and with the stock log transport, which writes mail to the server console instead of sending it; the forgot-password page can then say a reset link cannot be emailed here and to ask an administrator. '
268+
+ 'A statement about the deployment, never about an address: the password-reset request answers known and unknown addresses alike either way. Absent means not live',
269+
),
265270
cloudManaged: z.boolean().optional().describe(
266271
'Managed (cloud-hosted, SSO-only) mode: people arrive through the identity provider, so the UI hides inviting, creating users and setting their passwords; absent means not managed',
267272
),

‎packages/spec/src/kernel/public-auth-features.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ import {
1616
describe('PUBLIC_AUTH_FEATURES registry', () => {
1717
const entries = Object.entries(PUBLIC_AUTH_FEATURES);
1818

19-
it('classifies all 12 public flags', () => {
20-
expect(PUBLIC_AUTH_FEATURE_NAMES).toHaveLength(12);
19+
it('classifies all 13 public flags', () => {
20+
expect(PUBLIC_AUTH_FEATURE_NAMES).toHaveLength(13);
2121
expect([...PUBLIC_AUTH_FEATURE_NAMES].sort()).toEqual(
2222
[
2323
'admin',
2424
'cloudManaged',
2525
'degradedTenancy',
2626
'deviceAuthorization',
27+
'emailDelivery',
2728
'multiOrgEnabled',
2829
'oidcProvider',
2930
'organization',

0 commit comments

Comments
 (0)