fix(service-settings): settings 写入路径与 env 路径执行声明的 min / max / minLength / maxLength (#5932) - #6201
Merged
Merged
Conversation
…n the settings write and env paths (#5932) `SpecifierSchema` has declared five value constraints since it existed -- `pattern`, `min`, `max`, `minLength`, `maxLength` -- and `SettingsService.validatePatch` read exactly one of them. The other four had no reader anywhere on the write path: 42 specifiers across the shipped manifests declared a window, and every one of those windows was decoration. The load-bearing case is `auth.password_min_length`. It declares `min: 6`, the console renders a number input with that floor, and `PUT /api/settings/auth` accepted `1` (and negatives) and stored it, whereupon better-auth's password policy honoured the stored number. Same shape as #5131 (the `options` half of the same function): - `validatePatch` gains a value-window branch emitting the existing `FieldErrorCode` members (ADR-0114 D2) `min_value` / `max_value` / `min_length` / `max_length`, the same codes `record-validator.ts` emits for the same breach. `constraint` carries the whole declared window (`{ min, max }`, plus `actual` for the length codes). `packages/spec` is untouched -- the constraints were already declared and the code table already had the members. - The #5131 TOUCH gate is inherited: only keys the patch touches are checked, so a workspace whose stored value predates a tightened window is not locked out of its own settings page. - The env half goes through `effectiveEnvOverride`, the ONE decision point the option table is already judged at, reusing the same comparison helpers -- #5204 exists because that comparison once had two implementations. Deliberately not judged here: a value's SHAPE. `min`/`max` compare numbers (including numbers that arrived as strings through JSON or a form post), `minLength`/`maxLength` compare strings; booleans, arrays and objects are not coerced, because `invalid_type` is a different constraint with a different owner. Empty stays `required`'s business. Bounds are read from the DECLARATION rather than from the specifier `type`: the spec ties an option table to exactly three types but ties the four window keys to none, so a type list here would be a third list that drifts -- and would reproduce this issue one level down. Also corrects the `service-sms` daily-quota comments, which cited #5932 by number to explain why its clamp lived on the consumer side. The clamp stays (non-numeric values, fractional counts and pre-gate rows still reach it) but the producer half is no longer open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
hotlong
marked this pull request as ready for review
August 7, 2026 10:57
This was referenced Aug 7, 2026
Merged
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 free
to 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 #5932
前提核实(先于实现)
按 Prime Directive「issue 是线索不是规格」,先在
origin/main上核实,而不是照抄正文:grep全包,min/max/minLength/maxLength只出现在manifests/字面量里,src/*.ts无任何消费者(唯一命中是translations/es-ES.ts里西班牙语单词 "minúscula" 的子串)。SettingsService+ 内存后端,跑通后删除):正文列的三个形状全部复现 ——
number quota声明min:0 max:100写-500/999999均被接受;text code声明minLength:2 maxLength:4写"X"/"ABCDEFGHIJ"均被接受;slider ratio声明min:0 max:1写42被接受。另加两条:auth.password_min_length写
1和-3均被接受;OS_AUTH_PASSWORD_MIN_LENGTH=1被当作source: 'env'、locked: true的生效值送出,且零日志。validatePatch里required/options/pattern三分支俱在,TOUCH 闸门(
patchKeys.has(key) || deps.some(...))在原位可直接继承。前提成立。
改了什么
与 #5131(同一函数上
options的那一半)同形,是同一族的第三个成员。写入路径 ——
validatePatch补取值窗口分支发既有码表里的
FieldError(ADR-0114 D2):min_value/max_value/min_length/max_length—— 与record-validator.ts对同一类越界发出的码一致。constraint带完整窗口({ min, max };长度类另带actual),这正是errors.zod.ts自己的示例形状(max_value上带{ min: 0, max: 120 }):客户端渲染输入框需要整个窗口,而不只是被突破的那一侧。
⛔
packages/spec一行未改 ——SpecifierSchema早已声明这五类约束,FieldErrorCode里min_value/max_value/min_length/max_length四个成员现成可用,无需新增码。分诊定的 STOP 条件未触发。
TOUCH 闸门 —— 直接继承
新分支坐在同一个循环里、同一道闸门之后。除了 #5131 原有的理由,还多一条:取值窗口在
产品生命周期里会被收紧(口令下限从 6 提到 8),窗口下方的老工作区必须仍能编辑与之
无关的设置,只在重写该键时才被告知。
env 侧 —— 走
effectiveEnvOverride那一个判定点⛔ 没有开第二份实现。option 表与取值窗口在同一个函数里依次判定,共用同一组比较函数;
reportRejectedEnvOverride从「只会说 option」泛化成接三个句子片段,于是去重、加密键不回显、consequence/fix 三段话被两族复用,而不是旁边长出第二个 reporter。结论也就随之
一致:被拒的 override 不生效、不贡献 cascade 条目、不锁定该键(#5204 立的
locked一致性规则,第二族免费继承)、并且只报一次。刻意不判的东西
取值窗口只裁决可比较的值:
min/max只看数字(含经 JSON / 表单往返变成字符串的数字),
minLength/maxLength只看字符串。布尔、数组、对象不做强制转换 ——Number(true)是 1、Number([])是 0,把布尔按 "1" 拒掉等于就一个本检查从未被要求过问的形状发明裁决,那是
invalid_type,另一个约束、另一个负责人。空值仍归required管。一个判断请复核:按声明取,不按
type取约束从声明读,而不是从 specifier 的
type读 —— 与旁边按类型收口的 options 检查不同。这个差异是 spec 定的,不是偏好:
SpecifierSchema的 superRefine 把 options 表绑定到
select/radio/multiselect三型(缺表即拒),所以「必须声明表的类型」与「值要被表检查的类型」是同一个集合,不存在会漂移的第三份清单;而它没有把四个窗口键绑定
到任何类型(doc 注释写
number/slider与text/textarea,schema 只查min ≤ max的次序)。在这里自拟一份类型清单,恰恰就是那份会漂移的第三清单,并且会把本 issue 原样复制到下一层:窗口键声明在清单外的类型上,照样解析、照样渲染、照样不执行。
测试
新增 22 条,正文点名的形状全部钉住。
refuses a number below the declared min/above the declared maxrefuses a string shorter than minLength and longer than maxLengthrefuses a slider outside its declared windowchecks the window only when the patch TOUCHES the key—— 在max: 1000下写 900、再以max: 100重注册:未触及的历史越界键不拦,只有重写该键才拒,reset 仍清得掉ignores OS_AUTH_PASSWORD_MIN_LENGTH=1 …—— 落回 default、locked: false、无 env cascade 条目、一条日志closes the password-policy hole end-to-end on the real auth manifest—— 真auth.manifest.ts写1→min_value+constraint: { min: 6, max: 64 }+value: 1;history_count: -1、expiry_days: -1同拒;12仍放行covers the rest of the six password-policy keys——password_min_classes/password_max_lengthaccepts every value inside the window, bounds INCLUSIVE—— 否则password_history_count: 0(声明min: 0,且是关闭该检查的值)会被误拒反向验证 —— 方向先判后跑:预判是常规 Red(新钉的都是「只有新分支才产生的拒绝」,
既非计数型闸门也非 canonical-first
??链,不存在反转或「诊断变多」的情形)。把settings-service.ts单文件 checkout 回origin/main重跑:16 条转红,与预判一致—— 全部是断言拒绝的那些;6 条宽松面/边界断言保持绿(它们断言的是接受,而接受也是
改动前的行为);既有 253 条全绿。恢复实现后 269/269 全绿。
其余:
service-settings无typecheckscript(在check-type-check-coverage.mjs的 DEBT账本里,记 13),所以用直跑
tsc --noEmit -p tsconfig.json计数核实:改动前后同为 13。消费半径清扫(不只扫改动的包)
validatePatch跑在每次setMany上,所以按规则的消费半径而不是按被改的包清扫:default落在自己声明的窗口外」—— 42 个声明窗口的specifier,0 处违例,没有 manifest 会因此变得不可写。
OS_*值:OS_BRANDING_WORKSPACE_NAME在settings-routes.test.ts('X',1 字符)、envelope.conformance.test.ts(
'Locked Co',9 字符)、settings-service.test.ts('EnvCorp',7 字符),声明
[1, 60],全部在窗口内。OS_AUTH_PASSWORD_REJECT_BREACHED是 toggle,无窗口。packages/services/service-sms/src/sms-daily-quota.ts的注释按 issue 号点名了 settings 写入路径只执行 required / options / pattern —— 声明的 min / max / minLength / maxLength 一条都不校验(auth.password_min_length 可被写成 1) #5932,说「
SettingsService声明 min/max 但validatePatch今天不执行,所以
min: 0是惰性的,钳位放在消费侧」。这句话正是被本 PR 证伪的,所以一并更正(只改注释,断言一条未动)。钳位本身保留,而且不是出于沉没成本 ——
它的三类输入今天仍然可达:非数字形状(
true/'unlimited'/{n:5},本检查刻意不判形状)、小数(
100.5本就在min: 0内,向下取整是这个 reader 的规则)、以及闸门之前存下的历史行(TOUCH 语义使然)。
如实记录:未核实项(不扩范围)
正文「未核实各消费者对越界值的实际容忍度」本 PR 同样未核实,按分诊指示如实记录
而不扩范围:本 PR 关的是「声明了却没执行」这个产出侧的洞,没有去测 better-auth 口令策略
或 AI 服务拿到越界值时各自会怎样。这决定的是本条严重度算「配置面不一致」还是「可利用」,
仍是 triage 的判断,不是本 PR 的结论。
另外,
SpecifierSchema的step(ai.manifest.ts:189声明step: 0.1)属于同一族的第五个键,同样在写入路径无读取点。它是否算值约束(值必须落在步进网格上)还是纯 UI
affordance,是产品判断而非实现细节 —— 按分诊定的同一条界线不自行决定,未在本 PR 内
处理,已另行按观察类记录为 #6199(
finding,未入pm:queue,未认领)。Generated by Claude Code