Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/30107-reset-without-email.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': minor
'@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.

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: added
2 changes: 2 additions & 0 deletions content/docs/references/api/auth-endpoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
6 changes: 4 additions & 2 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<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). |
| **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<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. |
Expand Down Expand Up @@ -95,6 +95,7 @@ const result = ActionSchema.parse(data);
* `phoneNumber`
* `cloudManaged`
* `phoneNumberOtp`
* `emailDelivery`

### Allowed Values: `Action.requiresMembershipReach`

Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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]`

Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2754,7 +2754,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
Expand Down Expand Up @@ -4665,6 +4667,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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown> | undefined) {
const d = await deployment(async () => ({ status: 'sent' }));
d.manager.setEmailService(service as never);
const config = d.manager.getPublicConfig() as { features: Record<string, unknown> };
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[] = [];
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/api/auth-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/src/api/auth-endpoints.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/kernel/public-auth-features.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
13 changes: 13 additions & 0 deletions packages/spec/src/kernel/public-auth-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, PublicAuthFeatureEntry>;

export type PublicAuthFeatureName = keyof typeof PUBLIC_AUTH_FEATURES;
Expand Down
Loading