Skip to content

fix(console): a declared same-origin type: 'api' click goes through the action door - #27793

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-d3yu0i
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-d3yu0i

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

维护者速读

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:
  • 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 action door: forwarding a type:api action drops the target's error.details and userMessage, and the ADR-0104 param check refuses approval decision outputs (outputs.KEY), so console step 3 (#27611) would regress #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

Verification

Local runs (head b6ee680)
  • 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.

Generated by Claude Code

… the action door

Both api handlers (the shared console runtime and the record page) look the
click up in the object metadata they hold. A declared `type: 'api'` action
whose target is a path on this origin is sent to the action door with the
subject row's id and the params bag, so its gates (the sign-in floor,
requiredPermissions, the packaged-action switch, the param contract, the
subject load) hold for a human click as they do for an agent. An off-origin
target and an undeclared click keep the direct fetch.

Claude-Session: https://claude.ai/code/session_01Utp6CXnX1eGkqKFsNRxR8H
Co-authored-by: Claude <noreply@anthropic.com>
…ocks

New pins for objectstack#27611: the door lookup and same-origin predicate,
the list runtime and record page handlers sending a declared same-origin
api click to POST /api/v1/actions/:object/:action with { recordId, params },
the 409 ACTION_DISABLED sentence, off-origin and undeclared clicks staying
direct, and three regressions written against what the door answers once
objectstack#27738 lands (flow inner refusal, entitlement dialog, decision
outputs as params).

Existing pins that asserted the direct target URL for a declared action now
assert the door route and body. Six docblock sites read ADR-0066 D4 as
amended. Changeset: @object-ui/app-shell patch.

Claude-Session: https://claude.ai/code/session_01Utp6CXnX1eGkqKFsNRxR8H
Co-authored-by: Claude <noreply@anthropic.com>
… level

Both api handlers now read a 2xx from the action door through one helper
that applies the /actions envelope rule. The record page used to hand back
the door's body whole, so a bare target answer (a stock better-auth body
behind a one-shot resultDialog reveal) came back wrapped and the dialog's
field paths read nothing; it now hands back the target's payload, as the
list runtime does, and a relayed HTTP 200 rejection is a failure there too.

Claude-Session: https://claude.ai/code/session_01Utp6CXnX1eGkqKFsNRxR8H
Co-authored-by: Claude <noreply@anthropic.com>
The switched-on payload pin and the exhausted-pool pin read the same answer
on the direct road, so an ablated lookup left them green. Both now assert
the request went to the action door.

Claude-Session: https://claude.ai/code/session_01Utp6CXnX1eGkqKFsNRxR8H
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
This was referenced Oct 29, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh

席位核对

ACCEPT · head 689a9b8c44b3a576569b3e57bb7391855ba445a8 · 2026-10-30T09:02Z

  • Shape: draft → main, Fixes #27611. Clause-②: no and Metadata-protocol: none. apiActionDoor.ts is module-internal: it is not re-exported from utils/index or the package entry. The changeset is patch on @object-ui/app-shell. The components and plugin-detail edits are comment-only and outside components/src/ui/**.
  • Serial: the blocker action door: forwarding a type:api action drops the target's error.details and userMessage, and the ADR-0104 param check refuses approval decision outputs (outputs.KEY), so console step 3 (#27611) would regress #27738 landed as PR fix(runtime): a forwarded api action keeps the target's whole refusal, and decision outputs pass the param check #27915 (e8cdf4eb, 07:52Z), and the card is closed. The head merges origin/main ab759966 with no conflicts and no code change. The PR body's Blocked-by: #27738 line and its "shown only against a mocked door" acceptance note now read as history. The relay cannot patch the body.
  • Change, read (20 files, +1044/−65; source is apiActionDoor.ts, useConsoleActionRuntime.tsx and RecordDetailView.tsx):
    • A click is sent to POST /api/v1/actions/:object/:action with { recordId, params } through @object-ui/core's createServerActionHandler only when all of these hold:
      • its object declares an action of that name;
      • that action's type is 'api';
      • its target is a path on this origin (//host and /\host are refused, as the server's own predicate does);
      • the clicked target is the one the declaration produces, with ${…} tokens matched per segment.
    • Off-origin targets, undeclared inline buttons and object-less declared actions keep the direct fetch. The module docblock says so.
    • The list runtime awaits metadata.ensureType('object') before the lookup, as the flow launch does, so a cold store does not decide the road.
    • On both hosts the door's answer goes through each host's existing reading:
      • the non-2xx refusal: the entitlement dialog plus apiActionFailureMessage on the list runtime, and apiActionFailureMessage on the record page, the same as each host's direct road;
      • the 2xx success through interpretActionResponse;
      • refreshAfter, with each host's own default kept (!== false on the list runtime, === true on the record page).
    • The record page now hands back the door's data level, which keeps one-shot resultDialog reveals readable. objects was added to the handler's dependency list.
  • Checked against the landed server:
  • Evidence (report 6510947543, re-run on the merged head per the thread's hand-back):
    • On 689a9b8c: 10 card files, 71 tests pass. app-shell type-check (src and tests) exits 0. Derived gates: 34/34 exit 0, --ran reconciled with 0 NOT-MEASURED and 0 UNRUN. check:new-line-citations: 0 new.
    • Ablation lesson kept: every door pin asserts the request URL, not only the answer.
  • Gates (check-runs on the head): 44 success, 11 skipped, 0 failure. mergeable_state is clean. NOT governed: 0 of 20 paths hit the register, 1109 changed lines, under the 3000-line threshold.
  • Landing: non-governed and all green, so the seat lands it with pr_ready and automerge_enable.

Generated by Claude Code

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/xl tests tooling

Projects

None yet

1 participant