From 3ae32604bdc9c6bf3fbd65d6aa44197865f3dda4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:19:37 +0000 Subject: [PATCH 1/7] fix(plugin-auth): the organization require-MFA refusal reads in the caller's language, and an admitted turn-on answers the grace period in force (WIP) Claude-Session: https://claude.ai/code/session_01Utp6Cai3TgWyncLtR5EwF7 Co-authored-by: Claude --- .../auth-manager-password-session-policy.ts | 13 +- .../plugins/plugin-auth/src/auth-manager.ts | 15 ++ .../plugins/plugin-auth/src/auth-plugin.ts | 3 + .../organization-mfa-enrollment-guard.test.ts | 243 +++++++++++++++++- .../src/organization-mfa-enrollment-guard.ts | 154 ++++++++++- 5 files changed, 414 insertions(+), 14 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts index 6c5f9f92a82..f7160fdba31 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts @@ -71,6 +71,17 @@ export interface PasswordSessionPolicyHost { resolveActor: AuthManager['resolveActor']; } +/** + * The MFA grace period in force, in whole days (ADR-0069 D3): the configured + * `mfaGracePeriodDays` (the `auth.mfa_grace_period_days` setting), 7 when + * unset, never below 0. The one reading of it: the gate's clock below and the + * organization MFA guard's save notice (`AuthManager#getMfaGracePeriodDays`) + * both ask here, so the number a saver is told is the number the gate holds to. + */ +export function resolveMfaGracePeriodDays(config: { mfaGracePeriodDays?: number }): number { + return Math.max(0, Math.floor(Number(config.mfaGracePeriodDays ?? 7))); +} + /** The body of `AuthManager#assertPasswordComplexity`; the method's docblock there is its contract. */ export async function assertPasswordComplexity(this: Pick, password: string): Promise { if (!this.config.passwordRequireComplexity) return; @@ -222,7 +233,7 @@ export async function resolveUserAuthGates( if (mfaRequired && unenrolled && !this.isTwoFactorEnabled()) { this.noteMfaRequirementWithoutEnrollment(); } else if (mfaRequired && unenrolled) { - const graceDays = Math.max(0, Math.floor(Number(this.config.mfaGracePeriodDays ?? 7))); + const graceDays = resolveMfaGracePeriodDays(this.config); let requiredAt = u?.mfa_required_at; if (!requiredAt) { requiredAt = new Date(); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 439d9966bd8..bf3279ac505 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -124,6 +124,7 @@ import { recordPasswordHistory, refreshMustChangeCacheIfStale, refreshOrgMfaCacheIfStale, + resolveMfaGracePeriodDays, resolvePasswordChangeUserId, resolveUserAuthGates, stampPasswordChangedAt, @@ -5313,6 +5314,20 @@ export class AuthManager { return resolveTwoFactorEnabled(this.config.plugins); } + /** + * @internal Package-internal: read by the organization MFA enrollment guard + * `AuthPlugin` registers; not a supported entry point. + * + * [#30411] The MFA grace period in force, in whole days — the + * `auth.mfa_grace_period_days` setting as the settings pass applied it, + * {@link resolveMfaGracePeriodDays} over the live config, the same answer + * the MFA gate's clock reads. Read per call, so a settings change is seen at + * once. + */ + public getMfaGracePeriodDays(): number { + return resolveMfaGracePeriodDays(this.config); + } + /** * [#30201] Called by the MFA gate when a user is under an MFA requirement * (the global `mfaRequired` or their organization's `require_mfa`) that it diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 6ef278f3a58..56e036c6b55 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1914,6 +1914,9 @@ export class AuthPlugin implements Plugin { registerOrganizationMfaEnrollmentGuard(engine, { packageId: 'com.objectstack.plugin-auth.organization-mfa-enrollment-guard', isTwoFactorEnabled: () => this.authManager?.isTwoFactorEnabled() ?? false, + // [#30411] The grace period an admitted turn-on's save notice names. + // No manager means two-factor reads off above, so nothing is admitted. + mfaGracePeriodDays: () => this.authManager?.getMfaGracePeriodDays() ?? Number.NaN, }); // A reader's own named list views: the body is judged on every save as // a ListView for the row's own object. See personal-view-hooks.ts. diff --git a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts index 8b68b6de5be..b688301c0c6 100644 --- a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts +++ b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts @@ -32,10 +32,14 @@ import { AuthManager } from './auth-manager'; import { AuthPlugin } from './auth-plugin.js'; import { ORGANIZATION_MFA_ENROLLMENT_GUARD_PRIORITY, + ORGANIZATION_MFA_REFUSAL_COPY, + ORGANIZATION_MFA_REQUIRED_NOTICE_COPY, + organizationMfaRequiredNotice, organizationMfaWithoutEnrollmentRefusal, registerOrganizationMfaEnrollmentGuard, turnsMfaRequirementOn, } from './organization-mfa-enrollment-guard.js'; +import { resolveMfaGracePeriodDays } from './auth-manager-password-session-policy.js'; import { registerIdentityWriteGuard, registerManagedUpdateWhitelist } from './identity-write-guard.js'; import { managedExtensionEditableFields } from './managed-extension-fields.js'; // The SAME in-memory engine the sibling two-factor suites drive: a new fake @@ -82,6 +86,7 @@ async function judge( registerOrganizationMfaEnrollmentGuard(engine, { packageId: 'test.organization-mfa-enrollment-guard', isTwoFactorEnabled: () => enrollable, + mfaGracePeriodDays: () => 7, }); try { for (const r of engine.registrations.filter((x) => x.event === event)) { @@ -99,7 +104,11 @@ async function judge( describe('organization MFA enrollment guard — the rule', () => { it('registers insert and update hooks on sys_organization alone, after the D2 identity write guard', () => { const engine = captureEngine(); - registerOrganizationMfaEnrollmentGuard(engine, { packageId: 'p', isTwoFactorEnabled: () => false }); + registerOrganizationMfaEnrollmentGuard(engine, { + packageId: 'p', + isTwoFactorEnabled: () => false, + mfaGracePeriodDays: () => 7, + }); expect(engine.registrations.map((r) => r.event).sort()).toEqual(['beforeInsert', 'beforeUpdate']); for (const r of engine.registrations) { expect(r.options).toEqual({ object: 'sys_organization', priority: 15, packageId: 'p' }); @@ -218,6 +227,20 @@ describe('auth-plugin registers the guard on `kernel:ready` and asks the live ma // The settings pass turns the plugin on (as `auth.mfa_required` does): the next write is admitted. (plugin as any).authManager.applyConfigPatch({ plugins: { twoFactor: true } }); await expect(turnOn()).resolves.toBeUndefined(); + + // [#30411] …and its save notice names the grace period the live manager + // holds, read per write: the settings pass changes it, the next write says so. + const told: string[] = []; + const turnOnTold = () => update.handler({ + object: 'sys_organization', input: { id: 'org_1', data: { require_mfa: true } }, + session: { userId: 'usr_admin', locale: 'en-US' }, notice: (m: string) => told.push(m), + }); + await turnOnTold(); + expect(told).toEqual([organizationMfaRequiredNotice(7, 'en')]); + (plugin as any).authManager.applyConfigPatch({ mfaGracePeriodDays: 14 }); + await turnOnTold(); + expect(told[1]).toBe(organizationMfaRequiredNotice(14, 'en')); + expect(told[1]).toMatch(/has 14 days to enroll/); }); }); @@ -233,7 +256,10 @@ const SYSTEM = { context: { isSystem: true, positions: [] as string[], permissio const ADMIN = { context: { userId: 'usr_admin', positions: [] as string[], permissions: [] as string[] } }; /** A real sqlite engine with the real organization object and the production hook composition. */ -async function bootOrganizationEngine(enrollable: { value: boolean }): Promise { +async function bootOrganizationEngine( + enrollable: { value: boolean }, + graceDays: { value: number } = { value: 7 }, +): Promise { const engine = new ObjectQL(); engines.push(engine); engine.registerDriver( @@ -248,6 +274,7 @@ async function bootOrganizationEngine(enrollable: { value: boolean }): Promise enrollable.value, + mfaGracePeriodDays: () => graceDays.value, }); await engine.insert('sys_organization', { id: 'org_a', name: 'Acme', slug: 'acme' }, SYSTEM); await engine.insert('sys_organization', { id: 'org_b', name: 'Bolt', slug: 'bolt' }, SYSTEM); @@ -343,6 +370,194 @@ describe('organization MFA enrollment guard — on a real engine', () => { }); }); +// ── #30411. What the saver is told, in the saver's language ───────────────── + +/** The first sentence of each copy: the language is the contract, so the sentence that carries it is pinned. */ +const REFUSAL_FIRST = { + en: 'This organization cannot require multi-factor authentication yet:', + 'zh-CN': '本组织暂时无法强制多因素认证:', +} as const; +const NOTICE_FIRST = { + en: 'Multi-factor authentication is now required for this organization.', + 'zh-CN': '本组织现已强制多因素认证。', +} as const; + +/** Run the guard's hook for `event` with a session locale and a notice listener; the outcome and what it told. */ +async function judgeTold(opts: { + enrollable: boolean; + event: 'beforeInsert' | 'beforeUpdate'; + data: Record; + previous?: Record; + locale?: string; + graceDays?: number; + noNoticeMember?: boolean; +}): Promise<{ error: any; told: string[] }> { + const engine = captureEngine(); + registerOrganizationMfaEnrollmentGuard(engine, { + packageId: 'test.organization-mfa-enrollment-guard', + isTwoFactorEnabled: () => opts.enrollable, + mfaGracePeriodDays: () => opts.graceDays ?? 7, + }); + const told: string[] = []; + try { + for (const r of engine.registrations.filter((x) => x.event === opts.event)) { + await r.handler({ + object: 'sys_organization', event: opts.event, input: { id: 'org_1', data: opts.data }, + ...(opts.previous ? { previous: opts.previous } : {}), + ...(opts.locale ? { session: { userId: 'usr_admin', locale: opts.locale } } : {}), + ...(opts.noNoticeMember ? {} : { notice: (m: string) => told.push(m) }), + }); + } + return { error: null, told }; + } catch (e) { + return { error: e, told }; + } +} + +describe('#30411 the refusal is in the caller\'s language, with the same field-level shape', () => { + it('zh-CN: the refusal reads in Chinese and still names the env var and the setting key', () => { + const err: any = organizationMfaWithoutEnrollmentRefusal('zh-CN'); + expectRequireMfaRefusal(err); + expect(err.message.startsWith(REFUSAL_FIRST['zh-CN'])).toBe(true); + expect(err.message).toMatch(/OS_AUTH_TWO_FACTOR=true/); + expect(err.message).toMatch(/auth\.mfa_required/); + expect(err.fields[0].message).toBe(err.message); + }); + + it('en: the refusal is the English sentence it was, top level and on the field', () => { + for (const locale of ['en', 'en-US', undefined]) { + const err: any = organizationMfaWithoutEnrollmentRefusal(locale); + expectRequireMfaRefusal(err); + expect(err.message.startsWith(REFUSAL_FIRST.en)).toBe(true); + expect(err.message).toBe(ORGANIZATION_MFA_REFUSAL_COPY.en); + expect(err.fields[0].message).toBe(err.message); + } + }); + + it.each([ + ['zh', 'zh-CN'], ['zh-cn', 'zh-CN'], ['zh-Hans-CN', 'zh-CN'], ['ja', 'ja-JP'], ['es-MX', 'es-ES'], + ['fr-FR', 'en'], ['', 'en'], + ] as const)('the request locale %j reads the %s copy (exact, then by language, else English)', (asked, served) => { + expect((organizationMfaWithoutEnrollmentRefusal(asked) as any).message).toBe(ORGANIZATION_MFA_REFUSAL_COPY[served]); + }); + + it('every language names what an operator types, and carries no tracker number', () => { + for (const [locale, message] of Object.entries(ORGANIZATION_MFA_REFUSAL_COPY)) { + expect(message, locale).toMatch(/OS_AUTH_TWO_FACTOR=true/); + expect(message, locale).toMatch(/OS_AUTH_TWO_FACTOR .*false|false.*OS_AUTH_TWO_FACTOR/); + expect(message, locale).toMatch(/auth\.mfa_required/); + expect(message, locale).not.toMatch(/#\d/); + expectRequireMfaRefusal(organizationMfaWithoutEnrollmentRefusal(locale)); + } + }); + + it('the hook answers in the session\'s locale, and in English for a write with no caller locale', async () => { + for (const event of ['beforeInsert', 'beforeUpdate'] as const) { + const zh = await judgeTold({ enrollable: false, event, data: { require_mfa: true }, locale: 'zh-CN' }); + expectRequireMfaRefusal(zh.error); + expect(zh.error.message.startsWith(REFUSAL_FIRST['zh-CN'])).toBe(true); + expect(zh.told, 'a refused write tells no notice').toEqual([]); + const none = await judgeTold({ enrollable: false, event, data: { require_mfa: true } }); + expectRequireMfaRefusal(none.error); + expect(none.error.message.startsWith(REFUSAL_FIRST.en)).toBe(true); + } + }); +}); + +describe('#30411 an admitted turn-on answers the grace period in force as its save notice', () => { + it('turning it on (insert, or update of a row stored off) tells who must enroll and the grace period, once', async () => { + for (const [event, previous] of [ + ['beforeInsert', undefined], + ['beforeUpdate', { require_mfa: false }], + ['beforeUpdate', undefined], + ] as const) { + const out = await judgeTold({ enrollable: true, event, data: { require_mfa: true }, previous, graceDays: 30 }); + expect(out.error).toBeNull(); + expect(out.told).toEqual([organizationMfaRequiredNotice(30, 'en')]); + expect(out.told[0].startsWith(NOTICE_FIRST.en)).toBe(true); + expect(out.told[0]).toMatch(/has 30 days to enroll/); + } + }); + + it('zh-CN: the notice reads in Chinese and names the configured days', async () => { + const out = await judgeTold({ + enrollable: true, event: 'beforeUpdate', data: { require_mfa: true }, previous: { require_mfa: false }, + locale: 'zh-CN', graceDays: 14, + }); + expect(out.told).toHaveLength(1); + expect(out.told[0].startsWith(NOTICE_FIRST['zh-CN'])).toBe(true); + expect(out.told[0]).toMatch(/有 14 天时间完成绑定/); + }); + + it('a grace period of 0 and of 1 day get their own sentence in every language', () => { + for (const [locale, copy] of Object.entries(ORGANIZATION_MFA_REQUIRED_NOTICE_COPY)) { + expect(copy(0), locale).toMatch(/\b0\b/); + expect(copy(0), locale).not.toBe(copy(1)); + expect(copy(1), locale).toMatch(/\b1\b/); + expect(copy(45), locale).toMatch(/\b45\b/); + expect(copy(45), locale).not.toMatch(/#\d/); + } + expect(ORGANIZATION_MFA_REQUIRED_NOTICE_COPY.en(1)).toMatch(/has 1 day to enroll/); + expect(ORGANIZATION_MFA_REQUIRED_NOTICE_COPY.en(0)).toMatch(/blocked from data the next time they use the app/); + }); + + it('says nothing for a write that turns nothing on', async () => { + for (const event of ['beforeInsert', 'beforeUpdate'] as const) { + for (const data of [{ require_mfa: false }, { timezone: 'Asia/Shanghai' }, { name: 'Acme' }]) { + const out = await judgeTold({ enrollable: true, event, data }); + expect(out, `${event} ${JSON.stringify(data)}`).toEqual({ error: null, told: [] }); + } + } + // A re-save of a stored `true` (a whole-row door, an import re-run). + const resave = await judgeTold({ + enrollable: true, event: 'beforeUpdate', data: { require_mfa: true }, previous: { require_mfa: true }, + }); + expect(resave).toEqual({ error: null, told: [] }); + }); + + it('a dispatch with no notice member is admitted all the same', async () => { + const out = await judgeTold({ enrollable: true, event: 'beforeInsert', data: { require_mfa: true }, noNoticeMember: true }); + expect(out).toEqual({ error: null, told: [] }); + }); +}); + +describe('#30411 on a real engine: the caller\'s locale reaches the refusal, the notice reaches the write\'s answer', () => { + const zhAdmin = { context: { ...ADMIN.context, locale: 'zh-CN' } }; + + it('refuses the zh-CN administrator\'s save in Chinese, envelope unchanged, and the row keeps its value', async () => { + const engine = await bootOrganizationEngine({ value: false }); + const err = await caught(engine.update('sys_organization', { id: 'org_a', require_mfa: true }, zhAdmin)); + expectRequireMfaRefusal(err); + expect(err.message.startsWith(REFUSAL_FIRST['zh-CN'])).toBe(true); + expect(await storedRequireMfa(engine, 'org_a')).toBe(false); + // Control: the same write with no caller locale reads English. + const en = await caught(engine.update('sys_organization', { id: 'org_a', require_mfa: true }, ADMIN)); + expect(en.message.startsWith(REFUSAL_FIRST.en)).toBe(true); + }); + + it('the admitted save hands its listener the notice, with the grace period read per write', async () => { + const grace = { value: 21 }; + const engine = await bootOrganizationEngine({ value: true }, grace); + const told: string[] = []; + await engine.update('sys_organization', { id: 'org_a', require_mfa: true }, { + ...zhAdmin, onNotice: (m: string) => told.push(m), + } as any); + expect(await storedRequireMfa(engine, 'org_a')).toBe(true); + expect(told).toEqual([organizationMfaRequiredNotice(21, 'zh-CN')]); + // The setting changes; the next write that turns it on names the new number. + grace.value = 3; + await engine.insert('sys_organization', { id: 'org_c', name: 'Cove', slug: 'cove', require_mfa: true }, { + ...ADMIN, onNotice: (m: string) => told.push(m), + } as any); + expect(told[1]).toBe(organizationMfaRequiredNotice(3, 'en')); + // A re-save of the stored `true` answers no notice. + await engine.update('sys_organization', { id: 'org_a', require_mfa: true, timezone: 'UTC' }, { + ...ADMIN, onNotice: (m: string) => told.push(m), + } as any); + expect(told).toHaveLength(2); + }); +}); + // ── 3. End to end ─────────────────────────────────────────────────────────── const SECRET = 'test-secret-at-least-32-chars-long!!'; @@ -366,15 +581,16 @@ const post = (manager: AuthManager, path: string, body: unknown, headers: Record /** * A member of an organization that requires MFA, unenrolled, whose grace clock * started 30 days ago, in a session whose active organization is that one — - * the card's measured state, on a deployment with two-factor on or off. + * the card's measured state, on a deployment with two-factor on or off, and a + * grace period of `graceDays` (7 unless a case says otherwise). */ -async function arrangeHeldMember(twoFactor: boolean) { +async function arrangeHeldMember(twoFactor: boolean, graceDays: number | undefined = 7) { const engine: any = createMemoryEngine(); const manager = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, - mfaGracePeriodDays: 7, + ...(graceDays === undefined ? {} : { mfaGracePeriodDays: graceDays }), ...(twoFactor ? { plugins: { twoFactor: true } } : {}), } as any); const signedUp = await post(manager, '/sign-up/email', { email: EMAIL, password: PASSWORD, name: 'Member' }); @@ -427,3 +643,20 @@ describe('end to end — a member is never held for MFA while the enrollment end expect(((await enable.clone().json()) as { totpURI?: string }).totpURI).toMatch(/^otpauth:\/\/totp\//); }); }); + +describe('#30411 the grace period the notice names is the one the gate holds to', () => { + it.each([ + [undefined, 7], [0, 0], [14, 14], [2.9, 2], [-3, 0], [120, 120], + ] as const)('resolveMfaGracePeriodDays(%j) = %j, the reading the gate\'s clock always took', (configured, days) => { + expect(resolveMfaGracePeriodDays({ mfaGracePeriodDays: configured as number | undefined })).toBe(days); + }); + + it.each([ + [40, false], [20, true], [undefined, true], + ] as const)('grace %j days, clock started 30 days ago: held = %j, and the manager answers the same number', async (graceDays, held) => { + const { manager, userId } = await arrangeHeldMember(true, graceDays); + expect(manager.getMfaGracePeriodDays()).toBe(graceDays ?? 7); + const gates = await manager.resolveUserAuthGates(userId, { organizationId: ORG }); + expect(gates.some((g) => g.code === 'MFA_REQUIRED')).toBe(held); + }); +}); diff --git a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts index ebc1b7ce619..862fa2ec9e9 100644 --- a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts +++ b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts @@ -68,8 +68,34 @@ * enforce a requirement nobody can comply with (`resolveUserAuthGates`), so * such a row locks no one out; it starts holding members, with a fresh grace * window, once two-factor is turned on. + * + * ## What the saver is told (#30411) + * + * Both answers are in the caller's language: the locale the request asked for + * (`Accept-Language`, carried as `ExecutionContext.locale` and handed to every + * hook as `ctx.session.locale`), matched against the four the copy carries + * with core's `resolveLocale` — exact, then by language (`zh` reaches + * `zh-CN`), else English. A write with no caller locale (a system write, a + * seed) reads English. This is the same path the global switch's refusal takes + * (`mfa-required-enrollment-predicate.ts`). + * + * - The refusal above, with the field-level shape unchanged: the top-level + * `message` and the `require_mfa` entry's `message` are the same sentence. + * - A save the rule ADMITS that turns the requirement on (two-factor on, the + * stored value off, or an insert) answers a save notice + * (`ctx.notice(message)`, answered as the create / update answer's + * `notice`): who must now enroll, and the grace period in force, in days, + * read per write from + * {@link OrganizationMfaEnrollmentGuardOptions.mfaGracePeriodDays} — the + * number the gate's clock holds to. That is the one place an organization + * administrator reads the configured grace period: the `auth` settings it + * lives in are readable only with `manage_platform_settings`, which an + * organization administrator does not hold. A write that turns nothing on + * (a re-save of a stored `true`, a clear, another column) says nothing; a + * write no person waits on passes no listener, and the call sets nothing. */ +import { resolveLocale } from '@objectstack/core'; import { SystemObjectName } from '@objectstack/spec/system'; import { validationFailure } from '@objectstack/types'; @@ -87,6 +113,13 @@ export interface OrganizationMfaEnrollmentGuardOptions { * turns the plugin on admits the next write. */ isTwoFactorEnabled: () => boolean; + /** + * The MFA grace period in force, in whole days — read per admitted write + * that turns the requirement on, for its save notice + * (`AuthManager.getMfaGracePeriodDays`, the same number the gate's clock + * reads), so a settings change is told at once. + */ + mfaGracePeriodDays: () => number; } /** The engine surface this guard uses. */ @@ -112,24 +145,123 @@ export function turnsMfaRequirementOn(value: unknown): boolean { return !OFF_VALUES.has(value); } +/** The locales the copy below carries; English is the fallback. */ +type CopyLocale = 'en' | 'zh-CN' | 'ja-JP' | 'es-ES'; + /** - * The refusal: what was refused, why, who it would have locked out, and the - * ways forward. The second way is conditional because it is: the + * The refusal, per language: what was refused, why, who it would have locked + * out, and the ways forward. The second way is conditional because it is: the * `OS_AUTH_TWO_FACTOR` env var outranks the settings (`resolveTwoFactorEnabled`), * so under an `OS_AUTH_TWO_FACTOR=false` pin turning `auth.mfa_required` on - * does not turn enrollment on. + * does not turn enrollment on. The env var and the setting key are named + * verbatim in every language: they are what an operator types. */ -export function organizationMfaWithoutEnrollmentRefusal(): Error { - const message = +export const ORGANIZATION_MFA_REFUSAL_COPY: Readonly> = { + en: 'This organization cannot require multi-factor authentication yet: two-factor enrollment is off on this ' + 'deployment, so once the grace period ended every member, the owner included, would be blocked with no way ' + 'to enroll an authenticator app. Turn two-factor on first, then require it here: set OS_AUTH_TWO_FACTOR=true ' + 'on the deployment. Unless OS_AUTH_TWO_FACTOR is set to false there (it overrides the settings), turning on ' + 'the auth.mfa_required setting also works: it requires multi-factor authentication for every organization ' + - 'and turns enrollment on with it.'; + 'and turns enrollment on with it.', + 'zh-CN': + '本组织暂时无法强制多因素认证:本部署未开启双重验证的绑定,宽限期结束后所有成员(包括所有者)都会被拦截,' + + '且无法绑定身份验证器应用。请先开启双重验证,再在这里开启此项:在部署上设置 OS_AUTH_TWO_FACTOR=true。' + + '只要部署上没有把 OS_AUTH_TWO_FACTOR 设为 false(它的优先级高于设置),开启 auth.mfa_required 设置也可以:' + + '它会对所有组织强制多因素认证,并同时开启绑定。', + 'ja-JP': + 'この組織ではまだ多要素認証を必須にできません。このデプロイでは二要素認証の登録がオフになっているため、' + + '猶予期間が終わると所有者を含むすべてのメンバーがブロックされ、認証アプリを登録する手段もありません。' + + '先に二要素認証をオンにしてから、ここで必須にしてください:デプロイで OS_AUTH_TWO_FACTOR=true を設定します。' + + 'デプロイで OS_AUTH_TWO_FACTOR が false に設定されていない限り(これは設定より優先されます)、' + + 'auth.mfa_required 設定をオンにする方法もあります:すべての組織で多要素認証が必須になり、同時に登録もオンになります。', + 'es-ES': + 'Esta organización todavía no puede exigir la autenticación multifactor: el registro de dos factores está ' + + 'desactivado en esta implementación, así que al terminar el periodo de gracia todos los miembros, incluido el ' + + 'propietario, quedarían bloqueados sin forma de registrar una app de autenticación. Activa primero los dos ' + + 'factores y después exígelo aquí: define OS_AUTH_TWO_FACTOR=true en la implementación. Salvo que ' + + 'OS_AUTH_TWO_FACTOR esté definido como false allí (prevalece sobre los ajustes), también sirve activar el ' + + 'ajuste auth.mfa_required: exige la autenticación multifactor en todas las organizaciones y activa el ' + + 'registro con él.', +}; + +/** + * The save notice, per language, for a grace period of `days` whole days: + * who must now enroll, from when, and until when. `0` has its own sentence + * (the block starts at the member's next use), as do the plurals. + */ +export const ORGANIZATION_MFA_REQUIRED_NOTICE_COPY: Readonly string>> = { + en: (days) => + days === 0 + ? 'Multi-factor authentication is now required for this organization. Every member who has not set up an ' + + 'authenticator app is blocked from data the next time they use the app, until they enroll one: the MFA ' + + 'grace period is 0 days. Members who already use an authenticator app are not affected. The MFA grace ' + + 'period is set in Setup → Authentication.' + : 'Multi-factor authentication is now required for this organization. Every member who has not set up an ' + + `authenticator app has ${days === 1 ? '1 day' : `${days} days`} to enroll one, counted from the first ` + + 'time they use the app while it is required; after that they are blocked from data until they do. Members ' + + 'who already use an authenticator app are not affected. The MFA grace period is set in Setup → ' + + 'Authentication.', + 'zh-CN': (days) => + days === 0 + ? '本组织现已强制多因素认证。尚未绑定身份验证器应用的成员下次使用应用时即无法访问数据,直到完成绑定:' + + 'MFA 宽限期为 0 天。已使用身份验证器应用的成员不受影响。MFA 宽限期可在“设置 → 认证”中修改。' + : `本组织现已强制多因素认证。尚未绑定身份验证器应用的成员有 ${days} 天时间完成绑定,` + + '从要求生效后他们第一次使用应用时起算;期满后,未绑定的成员将无法访问数据,直到完成绑定。' + + '已使用身份验证器应用的成员不受影响。MFA 宽限期可在“设置 → 认证”中修改。', + 'ja-JP': (days) => + days === 0 + ? 'この組織では多要素認証が必須になりました。認証アプリをまだ登録していないメンバーは、次にアプリを' + + '利用した時点から登録するまでデータにアクセスできなくなります(MFA 猶予期間は 0 日です)。' + + 'すでに認証アプリを使っているメンバーには影響しません。MFA 猶予期間は「設定 → 認証」で変更できます。' + : 'この組織では多要素認証が必須になりました。認証アプリをまだ登録していないメンバーは、必須になってから' + + `初めてアプリを利用した時点から ${days} 日以内に登録する必要があり、期限を過ぎると登録するまで` + + 'データにアクセスできなくなります。すでに認証アプリを使っているメンバーには影響しません。' + + 'MFA 猶予期間は「設定 → 認証」で変更できます。', + 'es-ES': (days) => + days === 0 + ? 'La autenticación multifactor ahora es obligatoria en esta organización. A cada miembro que no haya ' + + 'configurado una app de autenticación se le bloquea el acceso a los datos la próxima vez que use la app, ' + + 'hasta que la registre: el periodo de gracia de MFA es de 0 días. Los miembros que ya usan un ' + + 'autenticador no se ven afectados. El periodo de gracia de MFA se configura en Configuración → ' + + 'Autenticación.' + : 'La autenticación multifactor ahora es obligatoria en esta organización. Cada miembro que no haya ' + + `configurado una app de autenticación tiene ${days === 1 ? '1 día' : `${days} días`} para registrarla, ` + + 'a contar desde la primera vez que usa la app mientras sea obligatoria; después se le bloquea el acceso ' + + 'a los datos hasta que lo haga. Los miembros que ya usan un autenticador no se ven afectados. El periodo ' + + 'de gracia de MFA se configura en Configuración → Autenticación.', +}; + +const COPY_LOCALES = Object.keys(ORGANIZATION_MFA_REFUSAL_COPY); + +/** The copy language for a caller's locale: exact, then by language, else English. */ +function copyLocaleFor(locale: string | undefined): CopyLocale { + const picked = typeof locale === 'string' && locale ? resolveLocale(locale, COPY_LOCALES) : undefined; + return (picked ?? 'en') as CopyLocale; +} + +/** + * The refusal in the caller's language (`locale`, a BCP-47 tag as the request + * asked for it; English when absent or unmatched). The field-level shape is + * the same in every language: `VALIDATION_FAILED`, one `require_mfa` / + * `invalid_value` entry whose `message` is the top-level one. + */ +export function organizationMfaWithoutEnrollmentRefusal(locale?: string): Error { + const message = ORGANIZATION_MFA_REFUSAL_COPY[copyLocaleFor(locale)]; return validationFailure(message, [{ field: REQUIRE_MFA_FIELD, code: 'invalid_value', message }]); } +/** The save notice for an admitted write that turned the requirement on, in the caller's language. */ +export function organizationMfaRequiredNotice(graceDays: number, locale?: string): string { + return ORGANIZATION_MFA_REQUIRED_NOTICE_COPY[copyLocaleFor(locale)](graceDays); +} + +/** The caller's locale as the engine hands it to a hook (`HookContext.session.locale`). */ +function callerLocale(ctx: any): string | undefined { + const locale = ctx?.session?.locale; + return typeof locale === 'string' && locale.length > 0 ? locale : undefined; +} + /** True when an update context proves the row already stores the requirement on. */ function alreadyRequiresMfa(ctx: any): boolean { const previous = ctx?.previous as Record | undefined; @@ -147,8 +279,14 @@ export function registerOrganizationMfaEnrollmentGuard( if (!turnsMfaRequirementOn(data[REQUIRE_MFA_FIELD])) return; // Unchanged: the row already stores it on, so this write turns nothing on. if (isUpdate && alreadyRequiresMfa(ctx)) return; - if (opts.isTwoFactorEnabled()) return; - throw organizationMfaWithoutEnrollmentRefusal(); + if (!opts.isTwoFactorEnabled()) throw organizationMfaWithoutEnrollmentRefusal(callerLocale(ctx)); + // Admitted, and it turns the requirement on: tell the saver who must + // enroll and the grace period in force. `ctx.notice` is bound on every + // insert / update dispatch and sets nothing when no one listens. + const graceDays = opts.mfaGracePeriodDays(); + if (typeof ctx?.notice === 'function' && Number.isFinite(graceDays)) { + ctx.notice(organizationMfaRequiredNotice(graceDays, callerLocale(ctx))); + } }; const options = { object: SystemObjectName.ORGANIZATION, From 050bc1c2a849f5b174e11802d2bb59696bd63884 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:20:26 +0000 Subject: [PATCH 2/7] fix(platform-objects): require_mfa field help says it needs two-factor on (WIP) Claude-Session: https://claude.ai/code/session_01Utp6Cai3TgWyncLtR5EwF7 Co-authored-by: Claude --- .../src/apps/translations/es-ES.objects.generated.ts | 2 +- .../src/apps/translations/ja-JP.objects.generated.ts | 2 +- .../src/apps/translations/zh-CN.objects.generated.ts | 2 +- .../src/identity/sys-organization.object.ts | 9 +++++++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 429a91c5ed0..41a9657a8b3 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -557,7 +557,7 @@ export const esESObjects: NonNullable = { }, require_mfa: { label: "Exigir autenticación multifactor", - help: "Si es true, todos los miembros de esta organización deben registrar una aplicación de autenticación para acceder a los datos." + help: "Si es true, todos los miembros de esta organización deben registrar una aplicación de autenticación para acceder a los datos. Requiere que la autenticación de dos factores esté activada en esta implementación (OS_AUTH_TWO_FACTOR=true); mientras esté desactivada, guardarlo como true se rechaza." }, timezone: { label: "Zona horaria", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index d63dbecd380..84b9cd2daf6 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -557,7 +557,7 @@ export const jaJPObjects: NonNullable = { }, require_mfa: { label: "多要素認証を必須化", - help: "true の場合、この組織のすべてのメンバーはデータにアクセスするために認証アプリの登録が必要になります。" + help: "true の場合、この組織のすべてのメンバーはデータにアクセスするために認証アプリの登録が必要になります。このデプロイで二要素認証がオンになっている必要があります(OS_AUTH_TWO_FACTOR=true)。オフの間は true で保存すると拒否されます。" }, timezone: { label: "タイムゾーン", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index ff6344f3a60..84aa65d7439 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -557,7 +557,7 @@ export const zhCNObjects: NonNullable = { }, require_mfa: { label: "强制多因素认证", - help: "为 true 时,该组织的每位成员都必须注册身份验证器 App 才能访问数据。" + help: "为 true 时,该组织的每位成员都必须注册身份验证器 App 才能访问数据。需要本部署开启双重验证(OS_AUTH_TWO_FACTOR=true);双重验证关闭时,保存为 true 会被拒绝。" }, timezone: { label: "时区", diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 243be28b896..93e63f1480c 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -395,13 +395,18 @@ export const SysOrganization = ObjectSchema.create({ // ADR-0069 D3 — per-org MFA tightening above the global floor. When true, // members of this org must enrol TOTP to access data (enforced at the // session-validation gate). An org can only tighten, never loosen, the - // global `mfa_required` setting. + // global `mfa_required` setting. Saving it on is refused while two-factor + // enrollment is off for the deployment (plugin-auth's + // `organization-mfa-enrollment-guard.ts`), which the help says (#30411). require_mfa: Field.boolean({ label: 'Require Multi-Factor Auth', required: false, defaultValue: false, group: 'Configuration', - description: 'When true, every member of this organization must enroll an authenticator app to access data.', + description: + 'When true, every member of this organization must enroll an authenticator app to access data. ' + + 'Needs two-factor authentication on for this deployment (OS_AUTH_TWO_FACTOR=true); while it is off, ' + + 'saving this as true is refused.', }), // #14238 — maintainer ruling 2026-09-02, option A (verbatim 「同意」): the From 6e5725ceef1d7a145b450dd597e4106f5d4ee8ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:21:26 +0000 Subject: [PATCH 3/7] chore(changeset): org require-MFA follow-ups (WIP) Claude-Session: https://claude.ai/code/session_01Utp6Cai3TgWyncLtR5EwF7 Co-authored-by: Claude --- .changeset/30411-org-require-mfa-follow-ups.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/30411-org-require-mfa-follow-ups.md diff --git a/.changeset/30411-org-require-mfa-follow-ups.md b/.changeset/30411-org-require-mfa-follow-ups.md new file mode 100644 index 00000000000..ff513d46c61 --- /dev/null +++ b/.changeset/30411-org-require-mfa-follow-ups.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/platform-objects': patch +--- + +fix(plugin-auth,platform-objects): an organization's require-MFA refusal reads in the caller's language, an accepted save that turns it on names the grace period in force, and the field help says two-factor must be on (#30411) + +Clause-②: no +Metadata-protocol: none + +- **Before.** Saving `sys_organization.require_mfa` on while two-factor enrollment was off was refused with an English sentence, whatever language the administrator's console used. An accepted save that turned it on answered nothing about when members would be blocked, and the configured grace period (`auth.mfa_grace_period_days`) was readable only through the `auth` settings, which need `manage_platform_settings`, a capability an organization administrator does not hold. The field help did not say the requirement needs two-factor on. +- **Now.** The refusal is in the request's language (English, Simplified Chinese, Japanese, Spanish; matched from `Accept-Language` exactly, then by language, else English), with the same shape as before: `400 VALIDATION_FAILED`, one `require_mfa` / `invalid_value` field entry carrying the same sentence as the top-level message. A save that is admitted and turns the requirement on (two-factor on, the stored value off, or an insert) answers a save `notice` on the create or update answer, in the same language: every member without an authenticator app must enroll, within the grace period in force, in days, read per write from the same setting the MFA gate's clock holds to. A save that turns nothing on (clearing it, re-saving a stored `true`, writing other columns) answers no notice. The field help, in all four languages, says the requirement needs two-factor authentication on for the deployment (`OS_AUTH_TWO_FACTOR=true`) and that saving it on is refused while it is off. +- **Unchanged.** Which writes are refused and which are admitted, the refusal's code, status and field entry, the MFA gate and its grace clock, and every `auth` setting and its read permission. No member is added to any answer: `notice` is the create and update answers' existing optional member. From f53f0704583c22dd1ed001bbc82f0d40015f27d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:36:23 +0000 Subject: [PATCH 4/7] fix(platform-objects): regenerate the en objects bundle for the require_mfa help (WIP) Claude-Session: https://claude.ai/code/session_01Utp6Cai3TgWyncLtR5EwF7 Co-authored-by: Claude --- .../src/apps/translations/en.objects.generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 9aac201e62c..307c4f4fa85 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -557,7 +557,7 @@ export const enObjects: NonNullable = { }, require_mfa: { label: "Require Multi-Factor Auth", - help: "When true, every member of this organization must enroll an authenticator app to access data." + help: "When true, every member of this organization must enroll an authenticator app to access data. Needs two-factor authentication on for this deployment (OS_AUTH_TWO_FACTOR=true); while it is off, saving this as true is refused." }, timezone: { label: "Timezone", From fe50ecf4659d74dac7a0860d76049568b6473a1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:37:26 +0000 Subject: [PATCH 5/7] test(plugin-auth): org require-MFA localized refusal, save notice and grace-period pins (WIP) Claude-Session: https://claude.ai/code/session_01Utp6Cai3TgWyncLtR5EwF7 Co-authored-by: Claude --- .../plugin-auth/src/organization-mfa-enrollment-guard.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts index b688301c0c6..210f4127453 100644 --- a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts +++ b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts @@ -545,9 +545,10 @@ describe('#30411 on a real engine: the caller\'s locale reaches the refusal, the expect(await storedRequireMfa(engine, 'org_a')).toBe(true); expect(told).toEqual([organizationMfaRequiredNotice(21, 'zh-CN')]); // The setting changes; the next write that turns it on names the new number. + // (An insert: user-context creates are the D2 guard's to refuse, so the system writes it.) grace.value = 3; await engine.insert('sys_organization', { id: 'org_c', name: 'Cove', slug: 'cove', require_mfa: true }, { - ...ADMIN, onNotice: (m: string) => told.push(m), + ...SYSTEM, onNotice: (m: string) => told.push(m), } as any); expect(told[1]).toBe(organizationMfaRequiredNotice(3, 'en')); // A re-save of the stored `true` answers no notice. From 859a0423e7d4a7eff25bdcc75877a87e4d22de69 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:37:46 +0000 Subject: [PATCH 6/7] test(platform-objects): require_mfa help carries the two-factor condition in every locale (WIP) Claude-Session: https://claude.ai/code/session_01Utp6Cai3TgWyncLtR5EwF7 Co-authored-by: Claude --- .../sys-organization-update-door.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/platform-objects/src/identity/sys-organization-update-door.test.ts b/packages/platform-objects/src/identity/sys-organization-update-door.test.ts index b7087d645b2..33a9ee0e027 100644 --- a/packages/platform-objects/src/identity/sys-organization-update-door.test.ts +++ b/packages/platform-objects/src/identity/sys-organization-update-door.test.ts @@ -43,6 +43,10 @@ import { describe, it, expect } from 'vitest'; import { checkManagedApiMethodAffordances } from '@objectstack/spec/data'; import { SysOrganization } from './sys-organization.object'; +import { enObjects } from '../apps/translations/en.objects.generated.js'; +import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; +import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; +import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; describe('#15873 — sys_organization.enable.apiMethods admits `update`, and only `update`', () => { it('declares exactly get / list / update — no create, no delete, no bulk', () => { @@ -89,3 +93,23 @@ describe('#15873 — better-auth keeps its own door for its own columns', () => expect(create.target).toBe('/api/v1/auth/organization/create'); }); }); + +describe('#30411 — require_mfa\'s help says it needs two-factor on, in the source and in every locale', () => { + const bundles = { en: enObjects, 'zh-CN': zhCNObjects, 'ja-JP': jaJPObjects, 'es-ES': esESObjects }; + + it('the source description names the deployment switch the save is refused without', () => { + const description = SysOrganization.fields.require_mfa?.description; + expect(description).toMatch(/two-factor authentication on for this deployment/); + expect(description).toMatch(/OS_AUTH_TWO_FACTOR=true/); + }); + + it.each(Object.entries(bundles))('%s: the generated help carries the condition', (_locale, bundle) => { + const help = (bundle as any).sys_organization?.fields?.require_mfa?.help; + expect(help).toEqual(expect.any(String)); + expect(help).toMatch(/OS_AUTH_TWO_FACTOR=true/); + }); + + it('the en bundle is the source, verbatim (the generator writes it)', () => { + expect((enObjects as any).sys_organization.fields.require_mfa.help).toBe(SysOrganization.fields.require_mfa?.description); + }); +}); From f2c7a1fa2299367f7c8ca17dd98f828e2071c0a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:47:02 +0000 Subject: [PATCH 7/7] test(plugin-auth): the unset grace-period case configures none (WIP) Claude-Session: https://claude.ai/code/session_01Utp6Cai3TgWyncLtR5EwF7 Co-authored-by: Claude --- .../src/organization-mfa-enrollment-guard.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts index 210f4127453..c0f4e0e1e93 100644 --- a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts +++ b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts @@ -583,15 +583,16 @@ const post = (manager: AuthManager, path: string, body: unknown, headers: Record * A member of an organization that requires MFA, unenrolled, whose grace clock * started 30 days ago, in a session whose active organization is that one — * the card's measured state, on a deployment with two-factor on or off, and a - * grace period of `graceDays` (7 unless a case says otherwise). + * grace period of `graceDays` (7 unless a case says otherwise; `null` configures + * none, so the manager's own default applies). */ -async function arrangeHeldMember(twoFactor: boolean, graceDays: number | undefined = 7) { +async function arrangeHeldMember(twoFactor: boolean, graceDays: number | null = 7) { const engine: any = createMemoryEngine(); const manager = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, - ...(graceDays === undefined ? {} : { mfaGracePeriodDays: graceDays }), + ...(graceDays === null ? {} : { mfaGracePeriodDays: graceDays }), ...(twoFactor ? { plugins: { twoFactor: true } } : {}), } as any); const signedUp = await post(manager, '/sign-up/email', { email: EMAIL, password: PASSWORD, name: 'Member' }); @@ -653,8 +654,8 @@ describe('#30411 the grace period the notice names is the one the gate holds to' }); it.each([ - [40, false], [20, true], [undefined, true], - ] as const)('grace %j days, clock started 30 days ago: held = %j, and the manager answers the same number', async (graceDays, held) => { + [40, false], [20, true], [null, true], + ] as const)('grace %j days (null: unset), clock started 30 days ago: held = %j, and the manager answers the same number', async (graceDays, held) => { const { manager, userId } = await arrangeHeldMember(true, graceDays); expect(manager.getMfaGracePeriodDays()).toBe(graceDays ?? 7); const gates = await manager.resolveUserAuthGates(userId, { organizationId: ORG });