From fcfad1efd335352f34059550dc717b9db7a84bb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 06:56:07 +0000 Subject: [PATCH 1/3] feat(auth): say on /auth/config whether email leaves the deployment GET /api/v1/auth/config gains features.emailDelivery, served from the one deliverability rule (isEmailDeliveryLive: the wired email service's own isConfigured()). False with no email service and with the stock log transport; true with a configured one. The forgot-password page can read it instead of saying a reset link was sent where no mail leaves. The flag describes the deployment, not an address: the reset request still answers known and unknown addresses the same way. Claude-Session: https://claude.ai/code/session_01Utp6CbSX6BdpVNjS79LB5A Co-authored-by: Claude --- .changeset/30107-reset-without-email.md | 11 +++++ .../plugin-auth/src/auth-manager.test.ts | 2 + .../plugins/plugin-auth/src/auth-manager.ts | 12 ++++- .../reset-request-mail-after-response.test.ts | 47 ++++++++++++++++++- packages/spec/src/api/auth-endpoints.test.ts | 1 + packages/spec/src/api/auth-endpoints.zod.ts | 5 ++ .../src/kernel/public-auth-features.test.ts | 5 +- .../spec/src/kernel/public-auth-features.ts | 13 +++++ 8 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 .changeset/30107-reset-without-email.md diff --git a/.changeset/30107-reset-without-email.md b/.changeset/30107-reset-without-email.md new file mode 100644 index 00000000000..c4f2ddd2f4a --- /dev/null +++ b/.changeset/30107-reset-without-email.md @@ -0,0 +1,11 @@ +--- +'@objectstack/spec': patch +'@objectstack/plugin-auth': patch +--- + +`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. + +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. + +Clause-②: yes (widening) +Metadata-protocol: none diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 430002245a6..654cd17b006 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -3208,6 +3208,8 @@ describe('AuthManager', () => { admin: false, phoneNumber: false, phoneNumberOtp: false, + // [#30107] No email service is wired here, so no mail leaves. + emailDelivery: false, multiOrgEnabled: false, // [ADR-0105 D1] Which of `single` | `group` | `isolated` is in force. // A vanilla deployment (no tenancy service wired, no env override) is diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 7c46b7cb160..5bc1cba772b 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2733,7 +2733,9 @@ export class AuthManager { * transport swapped from the mail settings at runtime). Read at call time, * never cached at boot. [#27843] invite-member's `outcome` asks it too * (`invitation-delivery-outcome.ts`), so an invite answers `link_only` on - * exactly the deployments where sign-up would not mail either. + * exactly the deployments where sign-up would not mail either. [#30107] + * `getPublicConfig()`'s `features.emailDelivery` serves the same answer, so + * the forgot-password page stops saying a reset link was sent where none is. * * ⛔ An implementation WITHOUT the member is NOT live: the contract says an * absent member is unknown, and the flow asking — a success answer that only @@ -4645,6 +4647,14 @@ export class AuthManager { // SMS path can actually deliver (plugin on + deliverable SMS service), // so the login UI never shows a dead "sign in with code" option. phoneNumberOtp: ((pluginConfig as any).phoneNumber ?? false) && this.isPhoneOtpDeliverable(), + // [#30107] Does mail leave this deployment? The one deliverability rule + // (`isEmailDeliveryLive()`: the wired service's own `isConfigured()`), + // read per request — so no email service and the stock `log` transport + // both answer false, and a transport configured later answers true + // without a restart. ⛔ Not `isEmailServiceAvailable()`: that is true on + // every default boot. The reset request answers known and unknown + // addresses alike either way; this describes the deployment, not one. + emailDelivery: this.isEmailDeliveryLive(), // The managed (cloud-hosted, SSO-only) mode, from the one accessor every // judgement site reads (ruling 6341690661 on #25083, Q2 D / Q3 A). A // hide-when-on flag: the console hides the doors that add people. diff --git a/packages/plugins/plugin-auth/src/reset-request-mail-after-response.test.ts b/packages/plugins/plugin-auth/src/reset-request-mail-after-response.test.ts index 4d91df00615..09a51c10eb3 100644 --- a/packages/plugins/plugin-auth/src/reset-request-mail-after-response.test.ts +++ b/packages/plugins/plugin-auth/src/reset-request-mail-after-response.test.ts @@ -28,7 +28,13 @@ * gets; the server-side phone reset call (no `Request`) still awaits its * send; * - `runAfterResponse` hands a task's synchronous throw to `onFailure` and - * survives an `onFailure` that throws. + * survives an `onFailure` that throws; + * - the public config says whether mail leaves the deployment + * (`features.emailDelivery`): false with no email service and with a + * transport that reports itself unconfigured (the stock `log` one), true + * with a configured one. It describes the deployment, not an address: in + * all three a known and an unknown address get the same reset answer, and + * that answer is the same one whether mail leaves or not. */ 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 }, 60_000); }); +describe('the public config says whether a reset link can be emailed from this deployment', () => { + /** The deployment's one account, then the email service under test swapped in. */ + async function withEmailService(service: Record | undefined) { + const d = await deployment(async () => ({ status: 'sent' })); + d.manager.setEmailService(service as never); + const config = d.manager.getPublicConfig() as { features: Record }; + const forAccount = await requestReset(d.manager, EMAIL); + const forNobody = await requestReset(d.manager, NOBODY); + return { emailDelivery: config.features.emailDelivery, forAccount, forNobody }; + } + + const sendTemplate = async () => ({ status: 'sent' }); + + it('no email service: no delivery, and a known and an unknown address get the same answer', async () => { + const r = await withEmailService(undefined); + + expect(r.emailDelivery).toBe(false); + expect(r.forAccount.status).toBe(200); + expect(r.forAccount).toEqual(r.forNobody); + }, 60_000); + + it('a transport that reports itself unconfigured (the log one): no delivery, same answer for both addresses', async () => { + const r = await withEmailService({ sendTemplate, isConfigured: () => false }); + + expect(r.emailDelivery).toBe(false); + expect(r.forAccount.status).toBe(200); + expect(r.forAccount).toEqual(r.forNobody); + }, 60_000); + + it('a configured transport: delivery, and the reset answer is the one a deployment without mail gives', async () => { + const live = await withEmailService({ sendTemplate, isConfigured: () => true }); + const none = await withEmailService(undefined); + + expect(live.emailDelivery).toBe(true); + expect(live.forAccount).toEqual(live.forNobody); + expect(live.forAccount).toEqual(none.forAccount); + }, 60_000); +}); + describe('runAfterResponse', () => { it("hands a task's synchronous throw to onFailure", async () => { const failures: unknown[] = []; diff --git a/packages/spec/src/api/auth-endpoints.test.ts b/packages/spec/src/api/auth-endpoints.test.ts index 02748193780..ac1e870881e 100644 --- a/packages/spec/src/api/auth-endpoints.test.ts +++ b/packages/spec/src/api/auth-endpoints.test.ts @@ -234,6 +234,7 @@ describe('AuthFeaturesConfig declares every classified served key', () => { admin: true, phoneNumber: false, phoneNumberOtp: false, + emailDelivery: false, cloudManaged: false, termsUrl: 'https://objectstack.ai/terms', privacyUrl: 'https://objectstack.ai/privacy', diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index f64a881c5db..e0d06daccce 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -262,6 +262,11 @@ export const AuthFeaturesConfigSchema = lazySchema(() => z.object({ phoneNumberOtp: z.boolean().optional().describe( 'Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service', ), + emailDelivery: z.boolean().optional().describe( + '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', + ), cloudManaged: z.boolean().optional().describe( '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', ), diff --git a/packages/spec/src/kernel/public-auth-features.test.ts b/packages/spec/src/kernel/public-auth-features.test.ts index ff1f2672490..65e25ae1b6f 100644 --- a/packages/spec/src/kernel/public-auth-features.test.ts +++ b/packages/spec/src/kernel/public-auth-features.test.ts @@ -16,14 +16,15 @@ import { describe('PUBLIC_AUTH_FEATURES registry', () => { const entries = Object.entries(PUBLIC_AUTH_FEATURES); - it('classifies all 12 public flags', () => { - expect(PUBLIC_AUTH_FEATURE_NAMES).toHaveLength(12); + it('classifies all 13 public flags', () => { + expect(PUBLIC_AUTH_FEATURE_NAMES).toHaveLength(13); expect([...PUBLIC_AUTH_FEATURE_NAMES].sort()).toEqual( [ 'admin', 'cloudManaged', 'degradedTenancy', 'deviceAuthorization', + 'emailDelivery', 'multiOrgEnabled', 'oidcProvider', 'organization', diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index 8866819ba54..529df9b2e29 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -283,6 +283,19 @@ export const PUBLIC_AUTH_FEATURES = { 'transport in production keeps it off.', }, }, + emailDelivery: { + surface: 'login', + semantics: 'opt-in', + exempt: { + reason: + 'Login-surface only: tells the forgot-password page whether a reset ' + + 'link can be emailed on this deployment, so its success screen does ' + + 'not say a link was sent when no mail leaves. True only when the wired ' + + 'email service reports itself configured; no email service and the ' + + 'stock log transport keep it off. Describes the deployment, never an ' + + 'address: the reset request answers known and unknown addresses alike.', + }, + }, } as const satisfies Record; export type PublicAuthFeatureName = keyof typeof PUBLIC_AUTH_FEATURES; From 5cec946fea112eb4cc6b0d78284b6ea43ca8e671 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:03:26 +0000 Subject: [PATCH 2/3] chore(spec): record features.emailDelivery in the authorable-surface snapshot Claude-Session: https://claude.ai/code/session_01Utp6CbSX6BdpVNjS79LB5A Co-authored-by: Claude --- packages/spec/authorable-surface/api.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 0170378fc39..96111bf259a 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -214,6 +214,7 @@ "api/AuthFeaturesConfig:cloudManaged", "api/AuthFeaturesConfig:degradedTenancy", "api/AuthFeaturesConfig:deviceAuthorization", + "api/AuthFeaturesConfig:emailDelivery", "api/AuthFeaturesConfig:magicLink [RETIRED]", "api/AuthFeaturesConfig:multiOrgEnabled", "api/AuthFeaturesConfig:oidcProvider", From d305bbf5e11ab60aaac5165f7ae39e4aa89380eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:08:30 +0000 Subject: [PATCH 3/3] chore(spec): regenerate the reference pages for features.emailDelivery The auto-generated auth-endpoints and action references, from check:generated --fix. The changeset declares the requiresFeature value the registry entry adds. Claude-Session: https://claude.ai/code/session_01Utp6CbSX6BdpVNjS79LB5A Co-authored-by: Claude --- .changeset/30107-reset-without-email.md | 6 ++++-- content/docs/references/api/auth-endpoints.mdx | 2 ++ content/docs/references/ui/action.mdx | 6 ++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.changeset/30107-reset-without-email.md b/.changeset/30107-reset-without-email.md index c4f2ddd2f4a..8f2350b90bc 100644 --- a/.changeset/30107-reset-without-email.md +++ b/.changeset/30107-reset-without-email.md @@ -1,5 +1,5 @@ --- -'@objectstack/spec': patch +'@objectstack/spec': minor '@objectstack/plugin-auth': patch --- @@ -7,5 +7,7 @@ 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. +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`. + Clause-②: yes (widening) -Metadata-protocol: none +Metadata-protocol: added diff --git a/content/docs/references/api/auth-endpoints.mdx b/content/docs/references/api/auth-endpoints.mdx index 9778aa73122..c11acd0433e 100644 --- a/content/docs/references/api/auth-endpoints.mdx +++ b/content/docs/references/api/auth-endpoints.mdx @@ -63,6 +63,7 @@ const result = AuthEndpointSchema.parse(data); | **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) | | **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) | | **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service | +| **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 | | **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 | | **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 | | **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: | **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) | | **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) | | **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service | +| **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 | | **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 | | **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 | | **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 | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 9da9d6ecb15..1c2209e003e 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -55,7 +55,7 @@ const result = ActionSchema.parse(data); | **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. | | **resultDialog** | `{ title?: string \| Record; description?: string \| Record; acknowledge?: string \| Record; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). | | **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. | -| **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. | +| **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. | | **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. | | **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. | | **disabledReason** | `string \| Record` | 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); * `phoneNumber` * `cloudManaged` * `phoneNumberOtp` +* `emailDelivery` ### Allowed Values: `Action.requiresMembershipReach` @@ -246,7 +247,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **defaultFromRow** | `boolean` | optional | | | **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. | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Param visibility predicate (CEL); omits the param when false. | -| **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. | +| **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. | ### Allowed Values: `ActionParam.type` @@ -314,6 +315,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities * `phoneNumber` * `cloudManaged` * `phoneNumberOtp` +* `emailDelivery` ### Nested Shape: `ActionParam.options[number]`