Uh oh!
There was an error while loading. Please reload this page.
fix(console): FormPage 的 redirect 按 os#7496 裁定当 in-shell 相对路由,越契约目标响亮拒绝 (#4190) - #4992
Conversation
objectstack#7496(2026-08-11 裁定,objectstack#7657 落 spec,本仓 17.0.0 GA pin
已覆盖)把 `submitBehavior.url` 裁为:仅相对路径、仅 `{{record.field}}` 插值、
构建重定向时对每个插值做 URL 转义。此前 redirect 臂把authored 字符串原样交给
浏览器级整页跳转,带来两个后果:
- 裁定的「应用内路径」会离开应用。整页跳转看不到 React Router 的 basename,
而 framework CLI 为每个嵌入式部署都配挂载点,于是 `/objects/lead` 相对
origin 根解析,把提交者甩出 SPA —— 即 objectui#4181 在 auth 页修掉的同一类。
本渲染器两个挂载(`/f/:slug` 与 `/forms/:name`)都在 console 路由内,故改为
router 导航,由路由自己施加挂载前缀。不用 `withConsoleBase()`:它会给非
绝对 SPA 挂载的值加前缀,对绝对目标是弄坏而非修好。
- `{{record.field}}` 从未被替换。裁定接受该 token,并把替换与转义划给「构建
重定向」的时刻,也就是这里。作用域是本次提交刚写入的记录。
形状裁决不在本 app 重写一遍:`resolveSubmitRedirect` 在使用时刻直接 parse
`@objectstack/spec` 自己的 `FormViewSchema`,因此绝对 URL、协议相对 `//host`、
反斜杠、控制字符走私、畸形 token、文档相对路径都以 spec 自带的面向作者的
处方被拒;裁定日后放宽由 pin 跟随,而非在此再改一次。拒绝时确认提交(写入
成功,只是目标越契约)并显示原因,而不是让提交者盯着一个不该发生的跳转。
`delayMs` 语义不变,但等待移入绑定组件的 effect:延迟期间离开页面的提交者
不再被一个活过页面的定时器拽回来。
Co-authored-by: Claude <noreply@anthropic.com>三次变异均先预判后跑,commit 后变异、git checkout 还原:
1. 回退到浏览器级整页跳转 → rendered 7 红(5 个导航 + 2 个拒绝),
`submitRedirect.test.ts` 全绿 —— 该变异杀的是调用点,模块本身仍正确
但无人使用。这正是机制必须钉在 rendered 页而非单测的原因。
2. 撤掉拒绝分支、照跑 authored 值 → 恰好 2 个拒绝红,6 个契约内绿。
3. 删掉转义、原样插值 → 两文件共 10 红。
第 3 项推翻了我原先写下的理由,按实测改正:重新 parse 发射值【不足以】
抓住未转义的值。原样插值下 `/t/{{record.slug}}`(slug 为地址)得到
`/t/https://evil.example/steal` —— 以 `/` 开头、无前导 scheme,是一个
完全合法的相对路径,却指向作者没写的地方。relative-only 管的是路径从哪
开始,对更靠后注入的结构无话可说;真正抓住它的是「转义后的值仍在其中」
这条断言。故该块两条断言覆盖的是「转义够不够」的两个不相交半边,不是
双保险。
Co-authored-by: Claude <noreply@anthropic.com>✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
一个事实一个读者:`error` 是本页的加载/提交失败通道,而「目标越契约」不属于 那一类 —— 提交是成功的。原先渲染分支从 `error` 取文案、由 redirect 状态取 标志,是把一个事实拆到两处。改为 refusal 与标志同行。 Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
两条都是「衡量该设计代价」时会先问的问题,直接写在模块文档里: - `@object-ui/app-shell` 的 `views/metadata-admin/clientValidation.ts` 早已在 客户端用同一批 spec schema 校验元数据草稿并展示其 issue 文案 —— 「在消费侧 问 spec」是既有模式,不是本 PR 新开的路。 - 打包成本实测为零:`@objectstack/spec` 本来就在 console 的 `vendor-objectstack` 块里(vite.config.ts 里就是一个 manualChunks 组), 因为 app-shell 这个核心依赖在运行时多处 import `@objectstack/spec/ui`。 实测 console 主入口 25.3 KB gzip,预算 350 KB。这也是为什么这里用静态 import 而非 clientValidation 那种 lazy:没有块可延迟,改成 lazy 只会把这个 函数在提交路径上变成 async。 Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
commented
Aug 17, 2026
PM 验收 ✅ ACCEPT(#4190,批次 20) 实物核验(merge-base CI 亲读:20/20 check runs completed,零失败(两项 path-filter skipped 计绿)。 契约消费的三件事都到位:①redirect 臂改走 router 「verbatim」读法的否决窗(dev 自己在 PR 末节点名,验收记录之):spec 的 describe 写「a verbatim redirect on the resolved relative path」,dev 读作约束值(不改写、不白名单、不包壳)而非规定导航 API —— 依据是同一裁定通篇称「the in-app path」,且整页导航消费 in-app 路径正是 #4190 的缺陷本身。合理。维护者若按导航 API 读,只有机制半边反转,拒绝与插值半边不动。 反向验证读数:变异 1 的诚实细节有价值 —— 单测文件在「杀调用点」变异下保持 34/34 绿(模块正确只是没人用),这正是机制必须钉在渲染文件的实测理由;变异 4 是 dev 自加且证伪了自己已写下的理由,两处 docblock 按实测改写(schema oracle 与 toContain 断言覆盖「转义充分」的两个不相交半边)。写控制字符测试用例时物化出真实控制字节(#4890 形态)被越门自扫抓住并按字节修复,记为纪律加分。 findings 已定级:#4989(plugin-form 同键按裁定前形状消费,静默丢弃 + 挂载盲)、#4990(授权门比 schema 宽)均入队;#4535 的范围复核提示(item 2 或已部分落地)记入 PM 案头,派发前先核。undraft + auto-merge(squash)。 Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#4190
前提测量(先行,三项)
① GA spec 内
FormView.submitBehavior的裁定后形状 —— os#7496(2026-08-11 裁定)经 PR os#7657 落地,本仓 pin 已由 PR #4953 升到@objectstack/spec17.0.0 GA,^17.0.0覆盖,实测安装版本17.0.0。形状(实测 parse,非读文档推断):submitBehavior是z.discriminatedUnion('kind', …)四成员;redirect成员为{ kind: 'redirect', url: z.string().superRefine(…), delayMs?: int >= 0 }。url的 refine 按「最具体优先」顺序拒绝七族:空串 / 前导 scheme(含javascript:、data:)/ 前导//协议相对 / 任意反斜杠 / 空白与 C0-DEL 控制字符 / 非{{record.field_name}}形状的花括号 / 不以/开头。{{record.field_name}}(字段段为扁平 snake_case,与object.fields键同一文法),且裁定把「每个插值值 URL 转义」划给构建重定向的时刻(消费侧)。delayMs仍在契约内 → 延迟语义保留。即卡正文那句「undecided whether it is a console route or an arbitrary destination」已被裁定解决:绝对目标出契约。
②
FormPage.tsxredirect 臂现状 —— 行号漂移已重定位(:574 → :1018 → :1102),代码同一行仍在:setTimeout(() => window.location.assign(behavior.url), delay)。前提成立。③
withConsoleBase现行为 ——path.startsWith('/_')直通,否则拼BASE_URL前缀。卡里「不能简单套withConsoleBase」的理由仍然成立:它会把绝对值拼坏而不是修好。改动
redirect 臂按裁定当 in-shell 相对路由走 router 导航。 本渲染器两个挂载(
/f/:slug、/forms/:name)都是 console 路由内的兄弟路由,故navigate()由路由自己施加挂载前缀 —— 而整页跳转看不到 basename,把 authored 的/objects/lead相对 origin 根解析,直接把提交者甩出 SPA(#4181 在 auth 页修掉的同一类)。绝对值既已在门口被拒,withConsoleBase也就不需要了:挂载只有一个来源(注入的 base href 元素,路由与该 helper 读的是同一个),不再有前缀算术。越契约目标响亮拒绝。 写入成功、仅目标越契约时:确认提交(「Your submission has been received.」)+ 屏幕上显示拒绝原因 + toast。既不静默丢弃,也不照跑,更不会让提交者盯着一个不该发生的「Redirecting…」。
插值 + 转义(裁定 point 2 的消费侧半边)。 此前 token 从未被替换 —— 契约接受
{{record.id}},而消费侧原样跳转会把花括号送进 URL。作用域是「本次提交刚写入的记录」:提交值 + 服务端回显的 record 覆盖其上 + id;id 用的是created-record臂同一条规则(editingId ?? readCreatedRecordId(result)),所以一个{{record.id}}不会在两条臂里解析出两个答案。形状裁决不在本 app 重写一遍。
resolveSubmitRedirect在使用时刻直接 parse spec 自己的FormViewSchema(最小 view,只提交这一个值受审,只回读该 path 上的 issue —— 整份存量 view 会因任何无关键被 strict schema 拒掉,那样一次元数据漂移就能连带毁掉重定向)。两条轴上都是它更优:
{ type: 'url', url }),与发布门说的是同一句话,不可能各说各话。反面就是「本地重写七族 + 手写 parity 表」:
scripts/check-spec-symbol-derivation.mjs存在的理由正是本仓为这个形状付过四次代价(os#4115、#4074/#4588/#4592)—— 手抄件在原件挪动的那个 release 之前,能通过一切值比较。delayMs语义保留,但等待移入绑定组件的 effect:延迟期间离开页面的提交者不再被活过页面的定时器拽回来。反向验证(先预判后跑,commit 后变异,
git checkout还原)① 有一处值得写下来:该变异杀的是调用点,
submitRedirect.test.ts34 条全绿 —— 模块仍正确、只是无人使用。所以机制必须钉在 rendered 页,单测钉不住。唯一在 ① 下仍绿的 rendered 用例是那条「整页跳转会解析到哪」的说明性断言,它陈述的是 URL 解析事实、与组件无关。④ 额外一跑推翻了我自己写下的理由,已按实测改正。 删掉转义、原样插值 → 两文件共 10 红,但重新 parse 发射值不足以抓住未转义的值:
/t/{{record.slug}}(slug 为地址)原样插值得到/t/https://evil.example/steal,以/开头、无前导 scheme,是完全合法的相对路径,却指向作者没写的地方。relative-only 管的是路径从哪开始,对更靠后注入的结构无话可说 —— 真正抓住它的是「转义后的值仍在其中」那条断言。schema oracle 只抓那些原样形式在任意位置都破坏形状的值(反斜杠、空白、控制字符)。故该块两条断言覆盖「转义够不够」的两个不相交半边,不是双保险;文档与 commit message 已改成这个测得的说法。测试
以发射值解析钉契约:两个测试文件都用真的
FormViewSchema当 oracle(specAccepts()就是授权门做的那次 parse),既钉「拒绝族与契约逐一同判」,也钉「本模块发射的字符串本身仍是契约接受的值」。所以它们是契约的漂移探测器,而不是我这份实现的意见快照:spec 日后收窄或放宽,它们跟着走。rendered 测试全部挂在
/_console挂载下 —— 裸/部署上前缀与不带前缀的拼写重合,那正是这个 bug(和 #4181 的兄弟缺陷)藏身之处。顺带发现(⛔ 未并入本 PR,已另立卡)
plugin-form的ObjectForm.tsx:803/WizardForm.tsx:490在已发布的渲染器包里消费同一个 key。措辞需要精确,免得被高估:那两处有isSameOriginUrl守卫,开放重定向面已经关上。剩下的分歧是另外几条 —— 守卫为假时静默丢弃(写入已成功,却无 toast、无错误、无确认面板,提交者面对一张仍填满的表单,下一步很自然就是再提交一次 → 第二条记录);同源绝对 URL 照跑,而契约是 relative-only;整页跳转同样看不到 basename;token 从未替换。其中「机制」那半在那边不能照抄本 PR:plugin-form是组件库,可能被渲染在 router 之外,不能直接useNavigate()—— 我没有度量去在几个选项间挑,所以没有猜。PublicFormsPage.saveEdit是本 console 自己的授权门,对 redirect URL 只查非空(七族里只挡了「空」这一族),https://…甚至javascript:都存得进去。消费侧已被本 PR 收紧,所以导航是安全的;留下的是「授权门比它所服务的 schema 更宽松」这个反过来的形状 —— 而门口才是错值该被拒的地方。按 Prime Directive #10 立卡不并修;两张都未指派。两张都先做了关键词搜索(open issues 无同类,仅命中 #4190 自身)。
一处需要读者知情的读法
spec 的
describe文本把 point 3 写成「a verbatim redirect on the resolved relative path is the intended consumption」,并在注释里提到window.location.assign。我把「verbatim」读作约束值(不得改写、不得白名单、不得套信封),而非指定导航 API:同一份裁定通篇称它为「the in-app path」「the rooted in-app path」,而挂载下用整页跳转消费一个 in-app 路径恰恰落不进 app —— 那就是 #4190 报的缺陷本身。router 导航是让「in-app」为真的那个机制。spec 侧那句window.location.assign是照当时 objectui 的既有消费点描述的(#4190 正是它的来源卡),不构成对机制的裁定。若维护者读法不同,请在 review 里点出:那会把本 PR 的机制部分反过来,而拒绝与插值两半不受影响。Generated by Claude Code