Skip to content

fix(plugin-auth,platform-objects): an organization's require-MFA refusal reads in the admin's language, a turn-on names the grace period, and the help says two-factor must be on (#30411) - #30529

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-q3clql
Nov 5, 2026

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

维护者速读

  • 改了什么:两步验证关着时,组织表单勾上「强制多因素认证」被拒的那句话,改为按管理员的界面语言(中、英、日、西)返回,错误码、状态和字段位置不变;两步验证开着、保存成功把它打开时,保存应答多带一句提示(写入应答已有的 notice 字段):没绑定身份验证器的成员要在几天内绑定,天数就是当前生效的宽限期;require_mfa 的字段说明四种语言都补上「需要本部署开启双重验证(OS_AUTH_TWO_FACTOR=true),关闭时保存为开会被拒」。
  • 为什么改:中文管理员在表单上方看到中文提示,被拒后字段下却是一段英文;宽限期存在 auth 设置里,读它要 manage_platform_settings,组织管理员(organization_admin)没有这个权限,所以界面只能写「默认 7 天」;字段说明也没说要先开双重验证。
  • 风险与回滚:只改文案和保存成功时的一句提示:哪些写入被拒、哪些放行完全不变,拒绝的错误码、状态、字段条目不变,门禁和宽限期计时不变(宽限期的读取抽成一个函数,门禁和提示读同一个值),不新增任何读口、设置项或应答字段。回滚即撤本 PR。
  • 先看:packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts(四语拒绝文案、保存提示及何时发出);auth-manager-password-session-policy.ts 的 resolveMfaGracePeriodDays(门禁读数不变);packages/platform-objects/src/identity/sys-organization.object.ts 的 require_mfa 说明。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/organization-mfa-enrollment-guard.test.ts,预期全部通过,其中真实引擎用例断言 zh-CN 管理员被拒时读到中文、保存成功时 onNotice 收到写着生效天数的中文提示。
  • 需要你做的:无,席位落地。

Fixes #30411

Clause-②: no
Metadata-protocol: none

What changes

The three Wanted items of the card, all on the organization write path (sys_organization insert and update, every data door):

  1. The refusal is localized. organization-mfa-enrollment-guard.ts refuses a write that turns require_mfa on while two-factor enrollment is off, as before. The sentence now reads in the caller's language: the request's Accept-Language tag, which the engine hands every hook as ctx.session.locale, matched by core's resolveLocale against the four languages the copy carries (exact, then by language, so zh reaches zh-CN, else English). The copy lives beside the guard, the same home the global switch's refusal (mfa-required-enrollment-predicate.ts) uses. The field-level shape is unchanged in every language: 400 VALIDATION_FAILED, one require_mfa / invalid_value entry whose message is the top-level one. The English sentence is byte-identical to before.
  2. The grace period is answered by the save. Measured first: the auth settings namespace that holds mfa_grace_period_days is read through GET /api/settings/auth, whose readPermission is manage_platform_settings (service-settings/src/manifests/auth.manifest.ts, enforced by SettingsService.assertPermitted for every HTTP caller). The organization administrator who can edit the organization holds organization_admin (or its wall-less variant), whose system permissions are manage_org_users, setup.access, setup.write, manage_mailboxes and manage_connector_connections (plugin-security/src/objects/default-permission-sets.ts), so that read answers 403 for them. No read is added and /api/v1/auth/config is not touched. Instead, the card's own alternative: a save the guard admits that turns the requirement on (two-factor on, the stored value off, or an insert) calls ctx.notice(message), which the create and update answers already carry as their optional notice member (WRITE_ANSWER_NOTICE_DESCRIPTION, @objectstack/spec/api). The message says every member without an authenticator app must enroll, within the grace period in force in days (0 and 1 have their own sentences), in the caller's language. The number is read per write from AuthManager.getMfaGracePeriodDays(), which asks the same resolveMfaGracePeriodDays the MFA gate's clock now asks, so the saver is told the number the gate holds to. A write that turns nothing on (a clear, a re-save of a stored true, another column) answers no notice; a refused write answers its error.
  3. The field help. sys_organization.require_mfa's description says it needs two-factor authentication on for the deployment (OS_AUTH_TWO_FACTOR=true) and that saving it on is refused while it is off. en.objects.generated.ts is regenerated from the source with pnpm i18n:extract; the three translated values (zh-CN, ja-JP, es-ES) are re-translated by hand, which AGENTS.md's Documentation Guardrails allow for translated-locale values (keys unchanged, so no drift).

The read the ui half should use for the grace period: the notice member of the update answer of PATCH /api/v1/data/sys_organization/{id} (and of the create answer), which the console already receives as RecordSaveFeedback.notice through answerWarningClaim in ui/packages/app-shell/src/console/AppContent.tsx. It is present only on a save that turned the requirement on, and is already in the console's language.

Files: organization-mfa-enrollment-guard.ts and its test; auth-manager-password-session-policy.ts (the grace-period reading extracted, gate unchanged); auth-manager.ts (one @internal accessor); auth-plugin.ts (one option at the guard's registration, outside applySettings); sys-organization.object.ts and the four *.objects.generated.ts; one changeset.

Why

  • Item 1. On main the guard's organizationMfaWithoutEnrollmentRefusal() (fix(plugin-auth)!: an organization cannot require MFA while two-factor enrollment is off (#30201) #30293) took no locale and threw one English sentence whatever the caller's language, so a zh-CN administrator read a Chinese form and an English refusal under the field.
  • Item 2, measured on a real boot (pnpm dev -- --fresh, showcase, single posture; the settings read path is not touched by this diff): an organization administrator (organization_admin_no_bypass, made through the admin create-user and member-role doors) asking GET /api/settings/auth got 403 SETTINGS_FORBIDDEN ("requires the 'manage_platform_settings' capability"); the platform administrator got 200. No read the organization administrator holds carries mfa_grace_period_days, so the console can only write "7 days unless changed".
  • Item 3. require_mfa's description did not say that two-factor must be on.

Tests

All in organization-mfa-enrollment-guard.test.ts, as new top-level describe blocks beside the existing ones:

  • the refusal in zh-CN and en (first sentence, the env var and the setting key named, the field entry's message equal to the top-level one), the locale matching table (zh, zh-cn, zh-Hans-CN, ja, es-MX, fr-FR, empty), the same envelope in all four languages, and the hook answering in ctx.session.locale (English without one);
  • the notice: once per admitted turn-on (insert, update from stored off, update with no previous), its number, its zh-CN text, the 0-day and 1-day sentences in every language, silence for a clear, another column and a re-save of a stored true, and an admitted write with no notice member;
  • on a real ObjectQL engine over the real sys_organization behind the D2 guard: a zh-CN administrator's refused save reads Chinese and the row keeps its value (control: no locale reads English); an admitted save hands the write's onNotice listener the zh-CN notice with the configured days, a changed grace period is read on the next write, and a re-save answers none;
  • through AuthPlugin's real kernel:ready registration: the live manager's grace period reaches the notice, and a settings patch changes it on the next write;
  • the gate's reading unchanged: resolveMfaGracePeriodDays over undefined, 0, 14, 2.9, -3, 120, and, end to end on better-auth, a member whose clock started 30 days ago is held under a 20-day and the default period and not under a 40-day one, while getMfaGracePeriodDays() answers the same number.
Ablations (each through scripts/ablation-replace.mjs in wrap mode, restored, blob equal to HEAD after)

Each mutation landed (anchor 1 → 0, blob changed), the guard test file went red as below, and the file was restored to its HEAD blob (git diff HEAD empty). The file is 56/56 green before and after.

  • A1, the refusal ignores the caller's locale (organizationMfaWithoutEnrollmentRefusal() without callerLocale(ctx)): the real-engine zh-CN refusal and the hook-level locale case go red. 2 failed, 54 passed.
  • A2b, the notice is also sent on a re-save of a stored true: five cases go red, among them "says nothing for a write that turns nothing on", the real-engine notice case and the AuthPlugin registration case. 5 failed, 51 passed.
  • A3, the grace period is read once and cached: the real-engine "changed grace period is read on the next write" case and the AuthPlugin settings-patch case go red. 2 failed, 54 passed.
  • A4, the notice call removed: the four notice cases go red. 4 failed, 52 passed.
  • A5b, the gate's default moved from 7 to 8 days in resolveMfaGracePeriodDays: the undefined row, the end-to-end unset case and the registration case go red. 3 failed, 53 passed. This is the pin that the gate's reading did not move.

A first A2 attempt used an anchor that the replacement contained, so the anchor count did not drop and ablation-replace refused it before running anything (not a measurement). A2b used a different anchor. A1 to A4 ran before the commit that made the unset grace-period case configure none (f2c7a1fa, test file only); A5b and the green runs ran after it. The guard and policy blobs are unchanged since (862fa2ec, f7160fdb).

Acceptance notes

  • The console shows a save's notice and then adds its own after-save line for this field (organizationMfaSavedNotice, console.organizationMfa.nowRequired, which names "7 days unless changed"). Once this lands, an accepted turn-on shows both. The ui follow-up is to show the server's notice instead of that line when the answer carries one; it is outside this diff (ui/**) and handed to the seat.
  • @objectstack/client has no settings read either; not needed for this shape, and not touched.
  • The pins assert the first sentence and the named subjects (env var, setting key, day count), not whole sentences, per the repository's rule that copy is pinned only where its wording is the contract; the language is the contract here, so the first sentence carries it.

Verification

All readings are on head f3b41646: this branch merged with origin/main f506b79d, which carries #30449. Heavy runs were under scripts/pm/os-verify-lock.sh on a shared box.

Real boot (pnpm dev, showcase, fresh database)

Two-factor off, signed in as the platform administrator:

  • PATCH /api/v1/data/sys_organization/{id} {"require_mfa":true} with Accept-Language: zh-CN → 400 {"error":"本组织暂时无法强制多因素认证:本部署未开启双重验证的绑定,…","code":"VALIDATION_FAILED","fields":[{"field":"require_mfa","code":"invalid_value","message":"本组织暂时无法强制多因素认证:…"}],"object":"sys_organization"}.
  • The same with Accept-Language: en-US → 400 with the English sentence, byte-identical to before.
  • The require_mfa field help is served in zh-CN and en with the new sentence.

Restarted with OS_AUTH_TWO_FACTOR=true:

  • The same PATCH with Accept-Language: zh-CN → 200, the record with "require_mfa":true, and "notice":"本组织现已强制多因素认证。尚未绑定身份验证器应用的成员有 7 天时间完成绑定,…".
  • PUT /api/settings/auth {"values":{"mfa_grace_period_days":3}}, the column set back to false, then on again → the notice reads 有 3 天, with no restart.
  • A re-save of the stored true → 200 with no notice key.
Suites, typecheck, lint
  • pnpm build --concurrency=2 → VERDICT command-exit 0, 119/119 tasks.
  • pnpm --filter @objectstack/plugin-auth test → Test Files 224 passed (224), Tests 4001 passed, 10 skipped (4011).
  • pnpm --filter @objectstack/platform-objects test → Test Files 111 passed (111), Tests 1532 passed (1532).
  • pnpm --filter @objectstack/plugin-auth typecheck and pnpm --filter @objectstack/platform-objects typecheck → VERDICT command-exit 0.
  • src/organization-mfa-enrollment-guard.test.ts alone → 56/56.
  • node scripts/check-i18n-bundles.mjs → OK.
  • pnpm lint → VERDICT command-exit 0.
Full dogfood battery

pnpm --filter @objectstack/dogfood test (no shard, no path), after the whole-workspace build above, on f3b41646 → VERDICT command-exit 0, held the lock 29m31s. Test Files 419 passed, 3 skipped (422). Tests 3492 passed, 36 skipped (3528). No failure. The same battery on the pre-merge head e950155e gave the same counts.

Gates

node scripts/pm/dispatch-gates.mjs --commands derived 66 commands on f3b41646. Each was run with its exit code recorded before any pipe, and all 66 exited 0. --ran: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN.

On the pre-merge head e950155e, two commands were non-zero, neither a finding:

  • check-plugin-teardown-shape --self-test: exit 3, its pinned fixture commit missing from the shallow clone. It passed after git fetch --depth=1 of that commit.
  • check:type-check-debt: exit 3, NOT MEASURED, because its closure build never got a turn on the verify lock while this branch's own dogfood held it. It exited 0 on the final head.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is the maintainers' Chinese summary).
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6Cai3TgWyncLtR5EwF7

…aller'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 <[email protected]>
Brings #30449 (#30300) onto the branch before the PR, at the seat's request.

Claude-Session: https://claude.ai/code/session_01Utp6Cai3TgWyncLtR5EwF7
Co-authored-by: Claude <[email protected]>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/plugin-auth, touching 17 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via sys_organization (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/deployment/cli.mdx (via sys_organization (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/deployment/seed-tenancy-repair.mdx (via sys_organization (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/kernel/contracts/auth-service.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/administrator-guide.mdx (via sys_organization (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/protocol/kernel/config-resolution.mdx (via sys_organization (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/protocol/objectql/schema.mdx (via sys_organization (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_organization (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17/17-1.mdx (via sys_organization (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17/17-4.mdx (via sys_organization (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17/17-5.mdx (via sys_organization (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v18/18-2.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/releases/v18/18-3.mdx (via AuthManager (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fa9c0aa2a0a8c2752f83b57765f3aae978ae196a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d4f287be8aa2e231669e0cb999eb64a201b2c6d9 — the merge of head f3b41646a354713e2abc1159ab6b01284a3d2018 into base fa9c0aa2a0a8c2752f83b57765f3aae978ae196a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d4f287be8aa2e231669e0cb999eb64a201b2c6d9 && git checkout d4f287be8aa2e231669e0cb999eb64a201b2c6d9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fa9c0aa2a0a8c2752f83b57765f3aae978ae196a f3b41646a354713e2abc1159ab6b01284a3d2018 && git checkout -B drift-repro fa9c0aa2a0a8c2752f83b57765f3aae978ae196a && git merge --no-ff f3b41646a354713e2abc1159ab6b01284a3d2018

node scripts/docs-audit/affected-docs.mjs --json fa9c0aa2a0a8c2752f83b57765f3aae978ae196a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fa9c0aa2a0a8c2752f83b57765f3aae978ae196a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

维护者速读

  • 改了什么: 组织「强制多因素认证」(sys_organization.require_mfa)三处跟进:两步验证没开时的拒存提示改为按管理员的语言显示(四语,英文原句不变);开启成功的那次保存,应答里带一条提示,写明成员要在几天宽限期内绑定验证器;字段帮助写明需要 OS_AUTH_TWO_FACTOR=true。
  • 为什么改: 原来中文界面下拒存提示是英文;组织管理员读不到宽限期设置(没有 manage_platform_settings),控制台只能写死「7 天」;帮助里也没说要先开两步验证(Organization require MFA: the save refusal is English only, the grace period is unreadable by the console, and the field help does not say two-factor must be on #30411,fix(plugin-auth)!: an organization cannot require MFA while two-factor enrollment is off (#30201) #30293 的跟进项)。
  • 风险与回滚: 拒存的状态码、错误码和字段结构不变;提示走写入应答里已有的可选 notice 字段,不新增成员;宽限天数和 MFA 闸门读同一个函数。回滚即撤本 PR。
  • 先看: packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts,再看 auth-manager-password-session-policy.ts 抽出的 resolveMfaGracePeriodDays。
  • 两分钟验证: pnpm --filter @objectstack/plugin-auth exec vitest run src/organization-mfa-enrollment-guard.test.ts。
  • 需要您做的: 无,席位经合并队列落地(非受管面,484 行,非 BREAKING)。
  • 席位意见: ACCEPT。宽限期走已有的写入应答 notice,不加读口,也不碰公开的 /auth/config;保存提示里的天数和闸门同源,A5b 消融钉住闸门读数没动。

席位核对 — ACCEPT · domain:server#2 · head f3b41646a354713e2abc1159ab6b01284a3d2018

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 14:44
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 14:44
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit e8e924f Nov 5, 2026
49 checks passed
os-bill pushed a commit that referenced this pull request Nov 5, 2026
Conflicts: sys_organization.require_mfa help (source, en and zh-CN
bundles) against #30529. Kept this branch's administrator wording,
which already carries #30529's two-factor sentence; en confirmed by
extract (byte-identical).

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Utp6CbDNK83SuSb8zsRt2s
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant