Skip to content

Commit fcfad1e

Browse files
committed
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 <noreply@anthropic.com>
1 parent cfb602c commit fcfad1e

8 files changed

Lines changed: 92 additions & 4 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': patch
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+
Clause-②: yes (widening)
11+
Metadata-protocol: none

‎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
@@ -2733,7 +2733,9 @@ export class AuthManager {
27332733
* transport swapped from the mail settings at runtime). Read at call time,
27342734
* never cached at boot. [#27843] invite-member's `outcome` asks it too
27352735
* (`invitation-delivery-outcome.ts`), so an invite answers `link_only` on
2736-
* exactly the deployments where sign-up would not mail either.
2736+
* exactly the deployments where sign-up would not mail either. [#30107]
2737+
* `getPublicConfig()`'s `features.emailDelivery` serves the same answer, so
2738+
* the forgot-password page stops saying a reset link was sent where none is.
27372739
*
27382740
* ⛔ An implementation WITHOUT the member is NOT live: the contract says an
27392741
* absent member is unknown, and the flow asking — a success answer that only
@@ -4645,6 +4647,14 @@ export class AuthManager {
46454647
// SMS path can actually deliver (plugin on + deliverable SMS service),
46464648
// so the login UI never shows a dead "sign in with code" option.
46474649
phoneNumberOtp: ((pluginConfig as any).phoneNumber ?? false) && this.isPhoneOtpDeliverable(),
4650+
// [#30107] Does mail leave this deployment? The one deliverability rule
4651+
// (`isEmailDeliveryLive()`: the wired service's own `isConfigured()`),
4652+
// read per request — so no email service and the stock `log` transport
4653+
// both answer false, and a transport configured later answers true
4654+
// without a restart. ⛔ Not `isEmailServiceAvailable()`: that is true on
4655+
// every default boot. The reset request answers known and unknown
4656+
// addresses alike either way; this describes the deployment, not one.
4657+
emailDelivery: this.isEmailDeliveryLive(),
46484658
// The managed (cloud-hosted, SSO-only) mode, from the one accessor every
46494659
// judgement site reads (ruling 6341690661 on #25083, Q2 D / Q3 A). A
46504660
// 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/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',

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,19 @@ export const PUBLIC_AUTH_FEATURES = {
283283
'transport in production keeps it off.',
284284
},
285285
},
286+
emailDelivery: {
287+
surface: 'login',
288+
semantics: 'opt-in',
289+
exempt: {
290+
reason:
291+
'Login-surface only: tells the forgot-password page whether a reset ' +
292+
'link can be emailed on this deployment, so its success screen does ' +
293+
'not say a link was sent when no mail leaves. True only when the wired ' +
294+
'email service reports itself configured; no email service and the ' +
295+
'stock log transport keep it off. Describes the deployment, never an ' +
296+
'address: the reset request answers known and unknown addresses alike.',
297+
},
298+
},
286299
} as const satisfies Record<string, PublicAuthFeatureEntry>;
287300

288301
export type PublicAuthFeatureName = keyof typeof PUBLIC_AUTH_FEATURES;

0 commit comments

Comments
 (0)