Uh oh!
There was an error while loading. Please reload this page.
fix(service-settings): 保存期强制 select 声明的 options —— 声明即强制 (#5131) - #5151
Merged
os-zhuang merged 1 commit intoAug 4, 2026
Merged
Conversation
`SettingsService.validatePatch` 只校验 `required` 与 `pattern` 两项, manifest 声明的 `options` 表从头到尾不参与校验。走控制台碰不到——下拉框 只会发出合法值;但 `PUT /api/settings/:ns` 是公开的可授权面,脚本、迁移 工具、AI 写的初始化代码可以直接写入枚举外的值,而且一路静默:存下来了, 读回来了,消费端各自随机应对。这不是 mail 专有的,所有 `type: 'select'` 的键都一样。 这正是 #5094 缺失的 API 侧闸门:那一单把 `sendgrid` / `ses` 从 mail 的 选项表里退役(本服务器无法通过它们投递),而没有写入期强制,刚退役的值 当天就能被重新写回去。 现在 `select` / `radio` / `multiselect` 的越界值以 `invalid_option` 拒绝, `constraint` 带上允许值集合(ADR-0114:constraint kind 在失败点打戳, 不让路由层从文案反推)。强制的类型集合取自 spec 自身——`SpecifierSchema` 的 superRefine 恰好要求这三类声明非空 `options`,所以「声明」与「强制」 指的是同一份清单,不存在第三份会漂移的列表。 两条刻意的边界: - **按 touch 语义校验**,与既有 required/pattern 一致。存量越界值只让 写该键的那次 patch 失败,只改 `from_name` 不会因为库里躺着一个老的 `provider` 而被拒。相反做法会把带历史脏值的工作区锁死在设置页里改不动 任何东西,比现状更糟。全 null 的重置永不阻塞。 - **没有声明 options 的 specifier 放行**:它说不出什么是合法的,保持宽容 而不是拒绝所有写入。 值按字符串形态比较,声明为 `value: 30` 的选项经 JSON 或表单往返后仍然匹配。 不设逃生舱:确需自定义值的 manifest 应在 spec 侧显式声明,而不是靠消费端宽容。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 4, 2026 06:40
Uh oh!
There was an error while loading. Please reload this page.
os-zhuang
deleted the
claude/issue-5131-settings-select-options-enforced
branch
August 4, 2026 06:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#5131
问题
SettingsService.validatePatch的 docstring 写明它「fulfilling the spec promise thatrequiredis enforced server-side」,实际只做两项校验:required+ visible + 空,以及pattern不匹配。manifest 声明的options表从头到尾不参与校验。走控制台碰不到——下拉框只会发出合法值;但
PUT /api/settings/:ns是公开的可授权面,脚本、迁移工具、AI 写的初始化代码可以直接写入枚举外的值,而且一路静默:值存下来了,读回来了,消费端各自随机应对。这不是mail专有的,storage.adapter、sms.provider、ai.provider、localization.date_format等所有type: 'select'的键都一样。这正是 #5094 缺失的另一半。那一单把
sendgrid/ses从mail的选项表里退役(本服务器无法通过它们投递),而没有写入期强制,刚退役的值当天就能被重新写回去——manifest 侧收紧的契约在 API 侧没有对应的闸门。改动
validatePatch增加第三条:select/radio/multiselect的非空值不在声明的options表内 →FieldError,code为invalid_option,constraint带上允许值集合。按 ADR-0114,constraint kind 在失败点打戳,不让路由层从文案反推;
constraint的键名与逗号连接形态取自 spec 自己文档化的例子(errors.zod.ts里{ allowed: 'draft, sent' }),也是 record validator 对同一个 code 已经发出的形态——同一个 code 不应该有两种线上形状。三个判断,请复核
1.
invalid_option不是新 code,不动packages/spec。 issue 与裁定都说「用新的invalid_option」,实际它已经在FieldErrorCode这个闭合枚举里(packages/spec/src/api/errors.zod.ts:257,注释// not a member of the field's declared options)。所以本单完全不需要碰 spec——四单在飞的车道没有被占用,这是好消息。2.
multiselect存在,radio也一并覆盖。 裁定要求先确认multiselect是否真实存在:它在SpecifierType闭合枚举里(settings-manifest.zod.ts:34),radio同理。更关键的是SpecifierSchema的 superRefine 恰好要求这三类声明非空options:所以强制的类型集合不是我在这里做的判断,而是抄 spec 自己的那一份——「必须声明选项表的类型」与「值要被拿去比对选项表的类型」指同一份清单,不存在第三份会漂移的列表。
radio/multiselect今天确实没有生产者 manifest(15 处select,0 处另两种)。我仍然覆盖了它们,理由是这里不是声明一个没人生产的属性(那才是本仓反复在修的形态),而是让校验器的类型覆盖面等于 spec 已经闭合的枚举;只做select的话,第一个写radio的 manifest 会无声地重新打开这个洞。如果认为这仍算越界,删掉常量里的两个成员即可,测试会指出对应用例。3. 不留逃生舱,按裁定执行。 没有
allowCustom之类的预留。兼容性:两条刻意的边界
required/pattern完全一致。存量越界值只让写该键的那次 patch 失败;只改from_name的 patch 不会因为库里躺着一个老的provider被整体拒绝。相反做法会把带历史脏值的工作区锁死在设置页里改不动任何东西,比现状更糟。全 null 的重置(resetNamespace)永不阻塞——脏值始终清得掉。options的 specifier 放行。 spec 在 parse 期就拒绝这种形状,但registerManifest是照单全收的(不走 Zod),所以手搓 manifest 会到达校验器。它说不出什么是合法的,于是保持宽容——与既有「visible解析不了」「pattern非法」两个分支同样的退让。另有两处细节:值按字符串形态比较,声明为
value: 30的选项经 JSON 或表单往返读回'30'仍然匹配(否则强制的是传输层而不是枚举,与 record validator 对 objectui#2729 的处理一致);encryptedspecifier 的越界值不回显(encrypted在任何 specifier 上都可声明,而这条 message 会进日志)。测试
packages/services/service-settings新增 7 个用例 + envelope conformance 1 个:sendgrid今天仍能写进去 → 修复后以invalid_option被拒,且批次原子(合法的from_email也没落库);smtp/resend/postmark/log)逐个仍可保存;sendgrid,再把收紧后的真 manifest 覆盖注册上去(plugin-email: SendGrid / Amazon SES 设置项同样后端无实现 —— #5087 的同形缺口 #5094 的真实过程),此时只改from_name仍然成功、重写provider被拒、resetNamespace仍能清掉;options的 select 放行;radio/multiselect逐元素校验;数字/布尔选项的字符串形态匹配;encrypted 不回显;details.fields[0]能通过FieldErrorSchema.safeParse,code === 'invalid_option',constraint如实到达客户端。反向验证过用例不是摆设:把强制类型集合临时置空后,恰好这 6 个断言拒绝的用例失败,两个断言放行的用例仍然通过。
现有测试没有一条在钉「越界值可以保存」的现状,因此没有需要翻面的用例。
aimanifest 那批写provider: 'cloudflare'的用例一度看着可疑,核对后cloudflare确实在选项表内(ai.manifest.ts:50),不受影响。类型检查:该包没有
typecheckscript,npx tsc --noEmit -p tsconfig.json报 13 处错误——改动前后逐字相同,且没有一处落在本 PR 触碰的文件里(已用 stash 对比基线确认)。这 13 处是 #4311 那个缺口的实例,已作为数据点评论在该 issue 下,未在本 PR 内修。影响面
写入路径的行为收紧,可能影响绕过控制台直接调 API 的脚本——但它们写入的正是本单要拦的越界值。读取路径、控制台交互、既有合法写入均不变。
🤖 Generated with Claude Code
https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
Generated by Claude Code