Repository navigation
Commit b00e3f4
fix(console): a declared same-origin
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_
## 维护者速读
- 改了什么:控制台里点击「对象上声明过、目标是本站路径的 `type: 'api'`
动作」(列表行、工具栏、页面按钮、记录页抬头和相关列表)时,不再由浏览器直接请求动作的 target,而是发到平台动作门 `POST
/api/v1/actions/:object/:action`,只带记录 id 和参数,由服务端按声明组装请求再转发。目标在其他域名的 api
动作、没有对应声明的页面内联按钮、不挂在对象上的全局动作,仍然直连。
- 为什么改:这是 #27506 裁定「agent 和人类使用同一套系统」的第 3
步。之前人点按钮绕过了服务端的登录底线、requiredPermissions、启用开关和参数契约:在 Setup 关掉一个打包的 api
动作,按钮照样能执行。现在关掉的动作点下去返回 409 ACTION_DISABLED,界面显示那句说明,目标不会被调用。
- 风险与回滚:所有声明式同源 api
动作(邀请成员、封禁用户、审批同意/驳回、轮换密钥等)的请求路径都变了。服务端动作门目前有两个缺口(#27738):丢掉目标错误里的
details/userMessage,以及拒收审批决策的 `outputs.<key>` 参数。在 #27738 合入之前合本
PR,会让「带决策输出的审批」被拒、套餐升级弹窗缺链接、流程端点的拒绝提示退回节点链。回滚即还原本 PR。
- 先看:`ui/packages/app-shell/src/utils/apiActionDoor.ts`(选门规则、发送与 2xx
读取);`hooks/useConsoleActionRuntime.tsx` 的 apiHandler
动作门分支;`views/RecordDetailView.tsx` 的 apiHandler 动作门分支。
- 两分钟验证:`pnpm --dir ui exec vitest run
packages/app-shell/src/utils/__tests__/apiActionDoor.test.ts
packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx
packages/app-shell/src/views/RecordDetailView.apiActionDoor-27611.test.tsx`,预期
3 个文件 25 条全绿。
- 需要你做的:无,席位落地。合并顺序是 #27738 在前、本 PR 在后,本 PR 已标 Blocked-by。
Fixes #27611
Blocked-by: #27738
Clause-②: no
Metadata-protocol: none
## What changes
Step 3 of the ruling on #27506: a person's click and an agent's
`run_action` take the same road. The server half is on `main` (#27649:
the action door accepts a declared `type: 'api'` action whose `target`
is a path on this origin, runs its gates and forwards to the target
in-process). This PR moves the console's two api handlers onto it.
- **New `ui/packages/app-shell/src/utils/apiActionDoor.ts`**
(module-internal, not re-exported):
- `isSameOriginApiTarget` restates the server's predicate
(`action-execution.ts#isSameOriginApiTarget`): a path that starts with
one `/`, not `//` or `/\`.
- `findDeclaredApiAction(objects, objectName, actionName,
clickedTarget)` decides the door by lookup, as `flowLaunch` looks up a
flow action: the click's object and `name` must resolve to a declared
`type: 'api'` action whose declared `target` is same-origin and is the
clicked target. A `${…}` token the runner already interpolated into the
target matches one path segment.
- `sendApiActionThroughDoor` sends through `@object-ui/core`'s
`createServerActionHandler`, so the `/actions` request (name-only
identity, `{ recordId, params }`, the `_rowRecord` strip) stays core's
and app-shell still names that route nowhere
(`actions-envelope.ratchet.test.ts`). It keeps the raw answer for the
host to read.
- `readApiActionDoorSuccess` reads a 2xx through the `/actions` envelope
rule (`interpretActionResponse`). The door answers the target's payload
as its `data`, already unwrapped from the target's `{ success, data }`,
so the payload is what the direct fetch handed back for a target that
answers bare (a stock better-auth body), and a target's HTTP 200
rejection relayed inside `data` reads as a failure.
- **`useConsoleActionRuntime` `apiHandler`** (list rows, toolbar, page
actions): for a same-origin target with an object and a name it waits
for the object type (as the flow launch does, so "no declaration" is an
answer and not a race against a cold store), looks the action up, and on
a hit sends `{ recordId: <stashed row's id>, params: <params,
{{page.<var>}} resolved> }` to the door. A non-2xx goes through the same
reader as the direct fetch (`refused`: the entitlement dialog, then
`apiActionFailureMessage`). A 2xx returns the payload and honours
`refreshAfter`.
- **`RecordDetailView` `apiHandler`** (record header, related lists):
the same, looked up in the page's `objects`. The subject id is the
stashed row's id, else a related-list row's explicit `recordId`, else
this page's id when the action is on this page's object. A non-2xx reads
`apiActionFailureMessage` as the direct fetch does, and `refreshAfter
=== true` keeps this page's meaning. The edit stays inside `apiHandler`,
plus its import line and `objects` in its dependency list.
- The direct fetch is unchanged for an off-origin target, for a click
that names no declared action (an inline page `action:button`), and for
a declared action the lookup cannot see (below). `withNewTabUrlFastPath`
still wraps both handlers, so a `newTabUrl` click sends nothing on
either road.
- The six docblock and pin sites that quoted the superseded ADR-0066 D4
text now read the amended D4 (the action door dispatches a declared
same-origin api action and runs its `requiredPermissions`; an off-origin
target never reaches it): `containers.tsx`, `action-bar.test.tsx`,
`page-header-capability-gate.test.tsx`,
`bulkActionCapabilityGate.test.tsx`,
`plugin-detail/src/RelatedList.tsx`,
`RelatedList.toolbarCapabilityGate-9782.test.tsx`.
- Changeset: `@object-ui/app-shell` patch.
## Tests
New:
- `utils/__tests__/apiActionDoor.test.ts` (6): the predicate on every
spelling the server's predicate answers; the lookup by object, name,
type and target, an interpolated `${ctx.recordId}` target, and no hit
for an off-origin declaration, another type, another target, or a
missing object, name or metadata.
- `hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx`
(12), on the hook's real `apiHandler`:
- door road: the request is `POST /api/v1/actions/sys_user/ban_user`
with `{ recordId, params }` and nothing reaches the target; `409
ACTION_DISABLED` answers its sentence, never the bare code; a 2xx
answers the unwrapped payload and refreshes; a relayed 200 rejection is
a failure and does not refresh; `{{page.<var>}}` tokens arrive resolved
in `params`.
- direct road: an off-origin target; a click that names no declared
action; a same-named declaration aimed at another target.
- written against what the door answers once #27738 lands: a flow-backed
endpoint's `FLOW_FAILED` reads its inner refusal (the form's permission
line); `DEV_ENV_PLAN_LOCKED` opens the upgrade dialog with its call to
action and no error toast; `DEV_ENV_LIMIT` quotes the usage; an approval
decision sends `outputs.<key>` as params.
- `views/RecordDetailView.apiActionDoor-27611.test.tsx` (7), on the
handler set the record page hands its `ActionProvider`: a header click
sends this page's id and hands back the target's payload; a bare target
answer comes back at the level the direct fetch handed back, so a
`resultDialog` path (`client_secret`) still reads; a relayed 200
rejection is a failure; `409 ACTION_DISABLED` answers its sentence; a
related-list child row sends the child row's id to the child object's
door; an off-origin target goes direct; the `FLOW_FAILED` inner refusal
once #27738 lands.
Updated, because they pinned the direct target URL of a DECLARED
same-origin action, which now goes through the door (each now pins the
door route and its body):
`RecordDetailView.approvalDeclaredActions.test.tsx` (the decision goes
to `/actions/sys_approval_request/approval_send_back` with the request's
id; the output rides as `outputs.next_reviewer` in `params`),
`RecordDetailView.accessEndedAfterDecision-26589.test.tsx`,
`RecordDetailView.apiNewTabUrl-26747.test.tsx` (CONTROL),
`RecordDetailView.printPageHeaderActions-12906.test.tsx` (c),
`PageView.relatedListActions-12870.test.tsx`,
`ObjectView.inviteFeedback-11817.test.tsx`, and
`apps/console/.../approvalRequestsDataSource.decisionPage.test.tsx`.
## Acceptance notes
- **Blocked by #27738.** On the door road, three answers lose what the
direct fetch kept until #27738 lands: the door drops the target's
`error.details` and `userMessage` (a flow-backed endpoint's refusal
shows the node chain again; an entitlement 403 loses its upgrade/contact
links, plan and usage), and the door's param contract refuses an
approval decision's `outputs.<key>` params (`approval_approve` /
`approval_reject` declare `comment` and `attachments`), so a decision
with declared outputs is refused 400. The four pins in the "once #27738
lands" group are written against the door's answer after that change and
are shown here only against a mocked door.
- **The `outputs.*` fold moved to the server.** The console now sends
`outputs.<key>` as params and the door's `buildApiActionRequest` folds
them into `outputs`. No server test pins that fold today; #27738 is the
place to add one.
- **"Same body as before" is pinned in two halves.** The console side
pins the same inputs (the subject row's id and the params bag) against a
mocked door. The body the door assembles from them (`recordIdParam` /
`recordIdField`, `bodyShape.wrap`, `bodyExtra`, `method`, the target's
placeholders, `organizationId`) is pinned server-side in
`packages/runtime/src/domains/api-action-forward.test.ts`.
- **What the lookup cannot see keeps the direct fetch.** The lookup
reads the object metadata the host holds, as the flow launch does. An
object-less declared action (`/actions/global/:action`) and a standalone
authored action row no object embeds are absent from it, so their click
stays direct and their switch does not hold on a click. Noted, not
filed.
- **Edges that differ on the door road, by reading the code (not
reproduced):**
- `organizationId` for an `/api/v1/auth/` target now comes from the
server's execution context rather than the browser's active
organization.
- A target that answers HTTP 200 `{ success: false, data: … }` loses its
rejection at the door (`apiActionResultOf` hands back `data`), and a 200
`{ success: false, message }` with keys outside the legacy envelope
reads as success under the narrow `/actions` rule. The direct fetch
caught both through `success === false`. No producer of either shape was
measured.
- A `{{page.<var>}}` token with no page-variable snapshot is sent as-is;
the door refuses it 400, where the direct fetch sent it to the target
literally.
- On the record page, a 2xx now hands back the payload unwrapped from
the target's `{ success, data }` (the list runtime's level), where the
direct fetch handed back the envelope; a bare answer is unchanged, and a
relayed 200 rejection is now a failure there too.
- `content/docs/ui/actions.mdx` already describes the door for declared
same-origin api actions; no docs change.
- Sibling #27504 (the switched-off button's visibility) is not in this
PR.
## Verification
<details><summary>Local runs (head b6ee680)</summary>
- Final head `b6ee6801` (after merging `origin/main` 6d9419b), under
`scripts/pm/os-verify-lock.sh`: vitest on the 3 new files and the 7
updated ones, Test Files 10 passed, Tests 71 passed; then `pnpm --filter
@object-ui/app-shell run type-check` (both `tsc --noEmit` and
`tsconfig.test.json`), VERDICT command-exit 0.
- Affected surface on `bd2818b4` (every ui test file naming an api
action, an api handler, the `/actions` route, `useConsoleActionRuntime`,
`RecordDetailView`, `ObjectView` or `PageView`, across app-shell, core,
components, the plugins and the console app): Test Files 714 passed,
Tests 8944 passed, VERDICT command-exit 0.
- Ablation (`scripts/ablation-replace.mjs`, marker `'ABLATED_27611'`
making `findDeclaredApiAction` answer no declaration, so every click
takes the direct fetch): on the 3 new files, 17 red (every door-road
case in both hosts and the two positive lookup cases), 8 green (the four
direct-road controls and the predicate and negative lookup cases); on
the 10-file set, 27 red including every updated pin. Restore: blob ==
HEAD, `git diff HEAD` empty. Two door pins that stayed green on the
first ablation (they read the same answer on both roads) now also assert
the door road.
- Derived gates (`dispatch-gates --commands`) on `b6ee6801`: 34 derived,
34 run, all exit 0 (including `check-sdui-manifest.mjs` and its
`--self-test`, and `check:nul-bytes`); `--ran` reconciliation: 0
NOT-MEASURED, 0 UNRUN.
- `pnpm --dir ui run check:new-line-citations`: 0 new citations.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Utp6CXnX1eGkqKFsNRxR8H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>type: 'api' click goes through the action door (#27793)1 parent f934a41 commit b00e3f4
20 files changed
Lines changed: 1044 additions & 65 deletions
File tree
- .changeset
- ui
- apps/console/src/services
- packages
- app-shell/src
- hooks
- __tests__
- utils
- __tests__
- views
- __tests__
- components/src
- __tests__
- renderers/layout
- plugin-detail/src
- __tests__
- plugin-grid/src/__tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
26 | | - | |
| 26 | + | |
| 27 | + | |
27 | 28 | | |
28 | 29 | | |
29 | 30 | | |
| |||
197 | 198 | | |
198 | 199 | | |
199 | 200 | | |
| 201 | + | |
| 202 | + | |
200 | 203 | | |
201 | 204 | | |
202 | 205 | | |
| |||
209 | 212 | | |
210 | 213 | | |
211 | 214 | | |
212 | | - | |
213 | | - | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
214 | 219 | | |
215 | 220 | | |
216 | 221 | | |
| |||
292 | 297 | | |
293 | 298 | | |
294 | 299 | | |
295 | | - | |
| 300 | + | |
296 | 301 | | |
297 | 302 | | |
298 | 303 | | |
| |||
308 | 313 | | |
309 | 314 | | |
310 | 315 | | |
311 | | - | |
| 316 | + | |
312 | 317 | | |
313 | 318 | | |
314 | | - | |
315 | | - | |
| 319 | + | |
| 320 | + | |
316 | 321 | | |
317 | 322 | | |
318 | 323 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
0 commit comments