Skip to content

fix(service-settings): settings 写入路径与 env 路径执行声明的 min / max / minLength / maxLength (#5932) - #6201

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-5932-settings-range-enforce
Aug 7, 2026
Merged

fix(service-settings): settings 写入路径与 env 路径执行声明的 min / max / minLength / maxLength (#5932)#6201
hotlong merged 1 commit into
mainfrom
claude/issue-5932-settings-range-enforce

Conversation

@hotlong

@hotlong hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #5932

前提核实(先于实现)

按 Prime Directive「issue 是线索不是规格」,先在 origin/main 上核实,而不是照抄正文:

  • 四个键在写入路径仍零读取点 —— grep 全包,min / max / minLength /
    maxLength 只出现在 manifests/ 字面量里,src/*.ts 无任何消费者(唯一命中是
    translations/es-ES.ts 里西班牙语单词 "minúscula" 的子串)。
  • 探针实测(worktree 内,SettingsService + 内存后端,跑通后删除):正文列的三个
    形状全部复现 —— number quota 声明 min:0 max:100-500 / 999999 均被接受;
    text code 声明 minLength:2 maxLength:4"X" / "ABCDEFGHIJ" 均被接受;
    slider ratio 声明 min:0 max:142 被接受。另加两条:auth.password_min_length
    1-3 均被接受;OS_AUTH_PASSWORD_MIN_LENGTH=1 被当作 source: 'env'
    locked: true 的生效值送出,且零日志。
  • service-settings: select 型 specifier 的 options 在保存期完全不校验 —— 声明的枚举不被强制 #5131 的分支形状现状:validatePatchrequired / 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 早已声明这五类约束,
FieldErrorCodemin_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/slidertext/textarea,schema 只查
min ≤ max 的次序)。在这里自拟一份类型清单,恰恰就是那份会漂移的第三清单,并且会
把本 issue 原样复制到下一层:窗口键声明在清单外的类型上,照样解析、照样渲染、照样不执行。

测试

新增 22 条,正文点名的形状全部钉住。

钉的东西 测试
number 越界两向 refuses a number below the declared min / above the declared max
text 长度两向 refuses a string shorter than minLength and longer than maxLength
slider 越界 refuses a slider outside its declared window
TOUCH 语义 checks the window only when the patch TOUCHES the key —— 在 max: 1000 下写 900、再以 max: 100 重注册:未触及的历史越界键不拦,只有重写该键才拒,reset 仍清得掉
env 同判定点 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.ts1min_value + constraint: { min: 6, max: 64 } + value: 1;history_count: -1expiry_days: -1 同拒;12 仍放行
六键覆盖 covers the rest of the six password-policy keys —— password_min_classes / password_max_length
边界含端点 accepts every value inside the window, bounds INCLUSIVE —— 否则 password_history_count: 0(声明 min: 0,且是关闭该检查的值)会被误拒
宽松面 未声明窗口不管;不可比较的形状不管;加密键不回显;每键只发一个 FieldError

反向验证 —— 方向先判后跑:预判是常规 Red(新钉的都是「只有新分支才产生的拒绝」,
既非计数型闸门也非 canonical-first ?? 链,不存在反转或「诊断变多」的情形)。把
settings-service.ts 单文件 checkout 回 origin/main 重跑:16 条转红,与预判一致
—— 全部是断言拒绝的那些;6 条宽松面/边界断言保持绿(它们断言的是接受,而接受也是
改动前的行为);既有 253 条全绿。恢复实现后 269/269 全绿。

# 还原实现前(origin/main 的 settings-service.ts)
 Test Files  1 failed | 14 passed (15)
      Tests  16 failed | 253 passed (269)

# 恢复实现后
 Test Files  15 passed (15)
      Tests  269 passed (269)

其余:

pnpm --filter @objectstack/service-settings build   → Build success (DTS 43.18 KB)
pnpm --filter @objectstack/service-sms      test    → 5 files / 70 tests passed
pnpm --filter @objectstack/cli              test    → 89 files / 902 tests passed
pnpm --filter @objectstack/verify           test    → 4 files / 17 tests passed
node scripts/check-type-check-coverage.mjs          → OK(service-settings 直测 tsc 仍是 13,与 DEBT 账本记录一致,未增)
check:nul-bytes / error-code-casing / empty-changeset / adr-anchors /
durability-log-level / route-envelope / engine-double-contract → 全 PASS

service-settingstypecheck script(在 check-type-check-coverage.mjs 的 DEBT
账本里,记 13),所以用直跑 tsc --noEmit -p tsconfig.json 计数核实:改动前后同为 13。

消费半径清扫(不只扫改动的包)

validatePatch 跑在每次 setMany 上,所以按规则的消费半径而不是按被改的包清扫:

  • 逐个 import 全部 10 份 manifest,扫「default 落在自己声明的窗口外」—— 42 个声明窗口的
    specifier,0 处违例,没有 manifest 会因此变得不可写。
  • 全仓 grep 落在有窗口的键上的 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 的结论。

另外,SpecifierSchemastep(ai.manifest.ts:189 声明 step: 0.1)属于同一族的
第五个键,同样在写入路径无读取点。它是否算值约束(值必须落在步进网格上)还是纯 UI
affordance,是产品判断而非实现细节 —— 按分诊定的同一条界线不自行决定,未在本 PR 内
处理,已另行按观察类记录为 #6199(finding,未入 pm:queue,未认领)。


Generated by Claude Code

…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
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 10:41am

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-settings, @objectstack/service-sms.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services/service-settings)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services/service-settings)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services/service-settings)
  • content/docs/permissions/authentication.mdx (via @objectstack/service-sms)
  • content/docs/plugins/packages.mdx (via @objectstack/service-settings, @objectstack/service-sms)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/service-settings)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-settings)
  • content/docs/releases/v14.mdx (via @objectstack/service-sms)
  • content/docs/releases/v9.mdx (via @objectstack/service-settings)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
@hotlong
hotlong marked this pull request as ready for review August 7, 2026 10:57
@hotlong
hotlong added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 9566c38 Aug 7, 2026
24 checks passed
@hotlong
hotlong deleted the claude/issue-5932-settings-range-enforce branch August 7, 2026 11:12
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/l tests tooling

Projects

None yet

2 participants