Skip to content

plugin-form: ObjectForm/WizardForm consume submitBehavior.url against the pre-ruling shape — out-of-contract values silently dropped, same-origin absolutes followed, mount-blind #4989

Description

@yinlianghui

Found while implementing objectui#4190 (the console FormPage half of the same ruling). Filed unassigned, NOT fixed there — #4190's scope is apps/console, and this surface is a published renderer package with a different routing situation, so it needs its own decision about mechanism.

What

packages/plugin-form consumes submitBehavior: { kind: 'redirect' } at two sites, both with the same shape:

  • src/ObjectForm.tsx:803-805
  • src/WizardForm.tsx:490-492
if (isSameOriginUrl(behavior.url)) {
  setTimeout(() => window.location.assign(behavior.url), behavior.delayMs ?? 0);
}

os#7496 (ruled 2026-08-11, landed by os#7657, live on this repo's @objectstack/spec 17.0.0 GA pin) settled what this value means: relative paths only, interpolation only from declared record fields spelled {{record.field_name}}, every interpolated value URL-escaped when the redirect is built. Measured against that, these two sites diverge four ways.

1. It is explicitly NOT an open redirect — stating this first so the issue is not over-graded. isSameOriginUrl (src/successBehavior.ts:19-27) resolves the value against window.location.href and compares origins, so a cross-origin destination never reaches the navigation. The open-redirect face is already closed here.

2. Out-of-contract values are SILENTLY DROPPED. When the guard answers false, the if simply does not fire: no toast, no error, no confirmation panel, no navigation. The write already succeeded, so the submitter is left looking at a still-filled form with zero feedback about what happened — and the obvious next move is to submit again, which writes a second record. Silence is the one outcome the consumer side of the ruling rules out ("neither silently dropped nor followed").

3. A same-origin ABSOLUTE url is followed, where the contract says relative-only. isSameOriginUrl('https://own-host/thanks') is true, so this consumer accepts a spelling the spec now refuses at the authoring door. More permissive than the contract, i.e. metadata that cannot be published still works here — which is how a rejected spelling stays alive in a corpus.

4. Mount-blindness, the same class as objectui#4190/#4181. A full-page navigation does not see React Router's basename, so a ruled in-app path such as /thanks resolves against the ORIGIN root. Under a mounted host (the framework CLI configures one for every embedded deployment) that leaves the app. This is the half that needs a decision rather than a patch: plugin-form is a component library that can be rendered outside a router, so it cannot simply call useNavigate() the way apps/console's FormPage now does. Options include an injected navigate callback, a router-presence check, or declaring the router a peer requirement of these two components — I have no measurement to pick between them and did not guess.

5. Tokens are never substituted, so an authored /thanks?ref={{record.id}} — a value the contract accepts — navigates with the literal braces in the query.

Note for whoever picks this up: navigateOnSuccess (ObjectForm.tsx:826-828, WizardForm.tsx:520-523, via resolveSuccessNavigate) is a different declared key with its own {id} / {recordId} interpolation dialect. It is same-origin-guarded the same way and shares defects 2 and 4, but it is a separate contract question and is only mentioned here so the next reader does not conflate the two.

Why this was not folded into objectui#4190's PR

Prime Directive #10 — #4190 is scoped to apps/console/src/components/FormPage.tsx by its card, its triage chain and its dispatch. Fixing a published package's navigation behaviour in that PR would widen it past the ruling's console half and past the mechanism decision noted in point 4.

Reachability, honestly: needs a form authoring a redirect behaviour, and for defect 4 a mounted host as well. Defects 2 and 3 need only the authored redirect, and defect 2 has user-visible consequences (duplicate submissions) with no mount involved.


Generated by Claude Code

Activity

  1. self-assigned this
    on Aug 17, 2026
  2. yinlianghui commented on Aug 17, 2026

    @yinlianghui
    CollaboratorAuthor

    认领(objectui 分片 PM 座席,批次 21)。Session session_01GTRjn8xBqp75dk7kFupVRt,分支 claude/issue-4989-plugin-form-redirect-contract,os-dev 子代理实施。

    PM 裁定卡面第 4 点的机制方向(契约机械 + 实现选型,公开留否决窗):缺陷 2/3/5 按已落地裁定机械修(响亮拒绝 / relative-only / 插值+转义,契约判据用 spec FormViewSchema 同一份解析 —— plugin-form 依赖面可及,⛔ 不 import console 的模块、⛔ 不写第二份规则);缺陷 4 采用可选读 router context:组件在 React Router context 内则经 router navigate(吃 basename),context 缺席(useContext 返回 null 合法)则保留今日 window.location.assign 语义 —— 无 router 的宿主没有 basename,origin 解析恰是正确行为,不新增已发布 prop。若实测发现该中间路线不可行(context 读不到 / basename 拿不到),按 premise 变化停下报告,⛔ 不改走注入回调(那是新发布面,归决策箱)。navigateOnSuccess 是另一契约键,⛔ 不并入(卡面已划界)。

    消费侧先例:PR4992(console FormPage 同裁定实施件)已落 main。文件面:packages/plugin-form(ObjectForm/WizardForm/successBehavior + 测试),与在飞席位不相交。


    Generated by Claude Code

  3. yinlianghui commented on Aug 17, 2026

    @yinlianghui
    CollaboratorAuthor

    实施报告(os-dev,session session_01GTRjn8xBqp75dk7kFupVRt,分支 claude/issue-4989-plugin-form-redirect-contract)。

    Draft PR #5032 —— 卡面缺陷 2、3、5 已修,CI 20 个 check 全部 completed、0 failure(2 个 path-filter skipped)。缺陷 4(挂载盲)未修,升级为决策题,理由是实测,不是畏难。

    前提复核:卡面五点全部成立

    按 origin/main 逐条验过,无一条过期:两个消费点确实是 isSameOriginUrl(behavior.url) + setTimeout(assign);isSameOriginUrl('https://own-host/thanks') 实测为真(缺陷 3);guard 答否时确实什么都不做(缺陷 2);token 确实原样带 {{}} 进 URL(缺陷 5)。唯一需要更正的是卡面末尾关于 navigateOnSuccess 的一句 —— 它说该键"shares defects 2 and 4"。缺陷 4 成立,缺陷 2 不完全成立:resolveSuccessNavigate 答 null 时调用点会落到 toast.success(...),所以不是完全静默,而是"只报成功、不报声明的跳转被拒"。已按精确措辞另开 #5034,没有把这个偏差混进本 PR。

    缺陷 4:PM 裁定的中间路线,React 那一半成立、依赖那一半不成立

    按派发要求先实测两点再动手,实测结论分裂:

    ① React 语义 —— 成立。 provider 外 useInRouterContext() 返回 false、useContext(UNSAFE_NavigationContext) 返回 null 且不抛(裁定里"useContext 在 provider 外返回 null 合法"完全正确);挂 basename="/_console" 时 context 在场、basename 读得到。② basename 确实被吃到 —— useNavigate()('/thanks') 在 basename 路由下落在 /thanks 路由上;对照组:同一个字符串交 window.location.assign,在注入了 mount 的文档里解析成 origin 根 /thanks(而 app-relative 解析会得到 /_console/thanks)。

    依赖面 —— 不成立,这是停下的原因。 React context 是模块实例级的对象,要读 React Router 的 context 就必须 import react-router,没有第二条路。而:

    • 本包 dependencies / peerDependencies / optionalDependencies 里都没有 react-router;本仓能解析到它纯粹是仓根 devDependency 抬上来的幻影依赖 —— scripts/check-phantom-dependencies.mjs(Phantom dependency: react resolves from packages/core although core declares none — a resolution-based layering check returns the wrong answer #4394)正是为这件事存在,它的文件头还记着 plugin-detail 同类真缺陷。
    • 本包 vite.config.ts external 掉所有裸 specifier,静态 import 会在发布的 dist/index.js 里留下裸 react-router-dom,每个消费者都必须解析。而确实有没有 react-router 的消费者:apps/site(把本包列为真依赖,app/components/PluginLoader.tsx 里 import)与 packages/plugin-view,两者任何字段都没有 react-router。
    • 退而求其次的写法都自败:放 dependencies/optionalDependencies 会给本包自己一份 react-router 实例,context 对象和宿主的不是同一个,永远答 null;唯一能保单实例的是 peer,而 peer 恰恰就是强制安装。惰性 import() 仍要在消费者构建期解析,且用打包器分析不到的 specifier 绕过是 AGENTS.md 准则 Fix documentation deployment for www.objectui.org #6 明令禁止的。

    按派发纪律 ⛔ 未改走注入回调/新 prop,也 ⛔ 未擅自换第三种机制(注入的 base href 读法是可行的第三条路,已在实测里量到,但它同样是"已发布渲染器如何发现自己的挂载点"这一架构决定,归决策箱)。三个选项的两轴分析在给 PM 的结构化报告里。

    今天行为保持不变,submitRedirect.mountBlind.test.tsx 把这个开放缺陷的实测钉住(含 app-relative 对照),免得"衡量过、故意留着"退化成口头传说。

    ⚠️ PR 正文按派发要求逐字写了 Fixes #4989,但合并即关闭会连带关掉尚未裁定的第 4 点 —— 合并前请按裁定改成 Refs,或先把第 4 点拆成后续 issue。

    顺带发现(均已另开,未混入本 PR)


    Generated by Claude Code

  4. yinlianghui commented on Aug 17, 2026

    @yinlianghui
    CollaboratorAuthor

    PM 处置(objectui 分片 PM,session session_01GTRjn8xBqp75dk7kFupVRt):reopen 转 needs-user-decision,按 PR #5032 验收评论预告执行。

    缺陷 2/3/5 已随 PR #5032 落 main(ae804eca2):契约外可见拒绝、同源绝对拒绝、插值+转义+fail-closed。本卡现在只持有缺陷 4(挂载盲)的机制决策 —— PM 此前裁定的中间路线(可选读 router context)被实施席两点实测拆解:React 层可行、依赖层不可行(读 context 必须 import react-router;本包四个依赖字段均未声明;apps/site 与 plugin-view 两个真实消费者未安装,发布产物裸 external 会打断其构建;dependency/optionalDependencies 因 pnpm 实例隔离拿到自己的 context 实例恒 null;惰性 import 被诫令 #6 禁止且救不了同步渲染读)。

    待维护者裁定,选项与测量全文在楼上实施席评论 5318710832,概要:

    • B(推荐):读注入的 base href 作挂载信号 —— 仓内已正典化的信号(withConsoleBase/resolveHomeUrl 同源),实测 new URL('./thanks', document.baseURI) 正确吃挂载;零依赖变化、零新发布面、AI 作者的相对路径处处同义。代价:仍是整页导航(非 SPA 转场);对「router 有 basename 但没发 base href」的宿主是部分修复。
    • A:react-router 声明为(可选)peer + 静态 import —— 换来真 SPA 转场,但发布产物裸 external 会打断 apps/site 与一切无 router 嵌入方的构建,花掉本包「任意 React 应用可嵌入」的属性;peerDependenciesMeta 只静音安装警告拦不住打包期。
    • C:定为 by-design(挂载宿主自己包导航) —— 元数据在 dev 可用、embedded 破裂的最坏失败分布,不建议。
    • 不可代裁的一点:若「无整页刷新的 SPA 转场」是本面需求,只有 A 能给 —— 那实质是「plugin-form 是否允许要求 react-router」的包受众定位,产品判断。

    关联:#5034(navigateOnSuccess 同类,Blocked-by 本卡)、#5033(delayMs 竞态,已入队)、#5035(消费侧力学两份的漂移记录,池)。


    Generated by Claude Code

  5. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded (defect 4 — the remaining open half) — maintainer, 2026-08-17, batch acceptance of the PM decision-inbox recommendations, verbatim: 「同意」. Four-dimension analysis delivered in-session; provenance: PM session session_01NYgmGheCzM6NrHZN436Cxf.

    Ruled: optional injected navigation. plugin-form gains an optional navigation seam (context or prop — implementer's call, stated with reasoning) that a hosting shell (console/app-shell) supplies with its router-aware, basename-aware navigate; when absent, current window.location.assign behavior stands. Router-presence sniffing is rejected (implicit magic); a react-router peer requirement is rejected (imposes resolution on router-less consumers). Explicit injection keeps the published package dependency-free and makes mount-awareness visible at the type face.

    The console/app-shell wiring (providing the callback) is part of this card's scope — the seam without a supplier changes nothing. This ruling is also the mechanism for #5034 (navigateOnSuccess), which unblocks when this lands.

    Label needs-user-decision → pm:queue in the same stroke.


    Generated by Claude Code

  6. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    Contributor

    Claim: PM seat dispatch (post-ruling; defect 4, the reopened remainder)
    Session: session_01NYgmGheCzM6NrHZN436Cxf
    Branch: claude/issue-4989-injected-navigation
    Scope: the injected-navigation ruling recorded above — optional navigation seam on plugin-form (context or prop, implementer's choice stated with reasoning), app-shell/console supplies its router-aware, basename-aware navigate, absent-seam behavior unchanged. ⛔ No router sniffing, no react-router peer requirement, no navigateOnSuccess changes (that key is #5034, blocked on this landing).
    Routing authority: maintainer ruling, 2026-08-17 「同意」 batch (comment above).

    Label pm:queue → pm:dispatched in the same stroke.


    Generated by Claude Code

  7. self-assigned this
    on Aug 18, 2026
  8. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    Contributor
    {
      "issue": 4989,
      "status": "done",
      "branch": "claude/issue-4989-injected-navigation",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5111",
      "premise_still_valid": true,
      "summary": "Defect 4 (mount-blindness) implemented as the maintainer ruled it on 2026-08-17: an optional injected navigation seam. `HostNavigationContext` in `@object-ui/react` (a host offers its router-aware navigate; absent answers `{navigate: undefined}` and never throws); `submitRedirectNavigation.ts` in plugin-form owns the delayed leg for BOTH redirect call sites and travels through the host's navigate when present, `window.location.assign` when not; `HostNavigationBridge` mounted in app-shell's `ConsoleShell` supplies React Router's navigate, so the deployment basename is applied. Context over prop because several of these forms arrive through ComponentRegistry/SchemaRenderer where the shell never constructs the element (a prop-only seam would silently skip schema-rendered forms), and because `DrillNavigationContext` is the same package's existing precedent for host-provided navigation. One judgement call flagged for review in the PR body: the bridge supplies nothing rather than throwing when no router is above it, because `ConsoleShell` is rendered router-less by an existing test and making it crash would be an unasked-for tightening.",
      "tests": "All at f7aa041 (branch head; union run AFTER the final commit). `pnpm exec vitest run packages/plugin-form/src/ packages/app-shell/src/console/ packages/react/src/` -> `Test Files 151 passed (151) | Tests 1525 passed (1525)`. `pnpm --workspace-concurrency=2 --filter @object-ui/plugin-form --filter @object-ui/react --filter @object-ui/app-shell type-check` -> all three `Done` (each runs `tsc --noEmit && tsc -p tsconfig.test.json`, so the new tests are type-checked); same filter `lint` -> `0 errors` in all three (308/597/2386 warnings = main's baseline). Gates: check:phantom-deps, check:self-import, check:control-bytes, check-changeset-presence (`14 source file(s) of 3 released package(s) changed, 1 changeset`), check-changeset-no-major -> all green. Dependency closure built first (`pnpm --filter '@object-ui/app-shell^...' build`); without it type-check reports TS6305 against unbuilt dist/*.d.ts and reads like a real failure. New coverage: submitRedirect.injectedNavigation.test.tsx (12, replaces submitRedirect.mountBlind.test.tsx as that file's own docblock required), HostNavigationBridge.test.tsx (5, against a REAL router: `/thanks` under basename `/_console` lands at `/_console/thanks` in window.location), ConsoleShell.hostNavigation.test.tsx (2). REVERSE VERIFICATION, measured: (1) seam ablated (hook always calls window.location.assign) -> 5 of 12 red, exactly the cases asserting a host navigate received the destination; I had PREDICTED 8 and the docblock now carries the measured number plus which survivors are not change detectors (the delay/unmount cases assert nothing was called, true under both bodies); both pre-existing component redirect files stay entirely green, since they mount no provider. (2) supplier ablated (the HostNavigationBridge line deleted from ConsoleShell) -> 2 red, both in ConsoleShell.hostNavigation.test.tsx, 0 anywhere else. That second ablation was run BEFORE the file existed and found a real gap: deleting one line restored the whole mount-blind behaviour with every test in the repo still green, which is why the supplier pin was added.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5112: EmbeddableForm's thank-you redirect (`thankYouPage.redirectUrl`, EmbeddableForm.tsx:262-268 via `isRedirectUrlSafe`) is mount-blind the same way — a same-origin RELATIVE destination is admitted and then travelled to with `window.location.href`, leaving a sub-path-mounted host. Not folded in: different key, deliberately more permissive contract (same-origin or an allowedHosts allowlist, because a public form's thank-you page is often genuinely external), so the arm needs splitting relative-vs-external rather than the seam applied wholesale. Filed unassigned, unlabeled for PM triage.",
        "no issue filed, fixed in-PR as part of my own wiring: nothing pinned that `ConsoleShell` MOUNTS the bridge — see reverse verification (2). A seam with no supplier changes nothing, and the supplier was unwatched until `ConsoleShell.hostNavigation.test.tsx` was added."
      ]
    }

    Draft PR #5111 is open and NOT marked ready; auto-merge not enabled. CI gate conclusions are the PM's to read — reporting at draft-PR time per the dispatch contract.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions