From acadda8c869832f0b0f31ec5a15f2142d14f2b87 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:57:41 +0000 Subject: [PATCH 1/4] fix(console): a declared same-origin `type: 'api'` click goes through 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 --- .../src/hooks/useConsoleActionRuntime.tsx | 78 ++++++-- .../app-shell/src/utils/apiActionDoor.ts | 170 ++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 40 ++++- 3 files changed, 269 insertions(+), 19 deletions(-) create mode 100644 ui/packages/app-shell/src/utils/apiActionDoor.ts diff --git a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 4c62150104b..16b483f2b6d 100644 --- a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -40,7 +40,13 @@ import type { ServerActionHandler, ToastHandler, } from '@object-ui/core'; -import { actionErrorDetail, captureUpdateUndoData, resolveRecordIdParamSeed, resolveServerActionRecordId } from '@object-ui/core'; +import { + actionErrorDetail, + captureUpdateUndoData, + interpretActionResponse, + resolveRecordIdParamSeed, + resolveServerActionRecordId, +} from '@object-ui/core'; import { useActionModal } from './useActionModal.js'; import { ActionConfirmDialog, type ConfirmDialogState } from '../views/ActionConfirmDialog.js'; import { ActionParamDialog, type ParamDialogState } from '../views/ActionParamDialog.js'; @@ -53,6 +59,7 @@ import { entitlementDialogFromError, type EntitlementDialogSpec } from '../envir import { resolvePageVarTokens } from '../utils/resolvePageVarTokens.js'; import { launchConsoleFlow } from '../utils/flowLaunch.js'; import { apiActionFailureMessage } from '../utils/flowResponse.js'; +import { findDeclaredApiAction, isSameOriginApiTarget, sendApiActionThroughDoor } from '../utils/apiActionDoor.js'; import { createConsoleServerActionHandler } from '../utils/consoleServerAction.js'; import { withNewTabUrlFastPath } from '../utils/newTabUrlFastPath.js'; import { modalTargetRefusalMessage } from '../utils/modalTargetDiagnostics.js'; @@ -413,7 +420,10 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons const target = action.target || action.name; const params = action.params || {}; - // Absolute HTTP target — bypass dataSource and call the API directly + // Absolute HTTP target — bypass dataSource. A DECLARED action whose + // target is a path on this origin goes through the action door, so the + // gates its declaration carries hold for the click (objectstack#27611 — + // see utils/apiActionDoor); every other one calls the API directly // through the authenticated fetch wrapper. Same-origin targets get // Bearer + X-Tenant-ID + same-origin cookies; an off-origin target // (the metadata may name a third-party host) goes out through the bare @@ -435,6 +445,55 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons const pageVars = (context?.pageVariables ?? undefined) as Record | undefined; const resolvedParams = resolvePageVarTokens(rawParams, pageVars); + // A non-2xx answer, from the door or from the target. Plan/capacity + // gates (e.g. creating an environment the org's plan doesn't include) + // come back as coded 403s. Surface them as a friendly upgrade/limit + // DIALOG with a CTA — never a generic red error toast. Returning + // success:false WITHOUT an `error` suppresses the runner's error toast + // (ActionRunner.handlePostExecution); the dialog owns the messaging. + const refused = (res: { status: number }, body: unknown): ActionResult => { + const entitlementSpec = entitlementDialogFromError(body, t); + if (entitlementSpec) { + openEntitlementDialog(entitlementSpec); + return { success: false }; + } + // A flow-backed endpoint's refused run reads its inner refusal, not + // the node chain (objectstack#27487). + return { success: false, error: apiActionFailureMessage(res, body, `HTTP ${res.status}`, language) }; + }; + + // The action door (objectstack#27611). The server assembles the + // target's request from the declaration, so the click sends only the + // subject row's id and the params bag — the row stash and everything + // built below stay out of it. + const doorObject = action.objectName || objApiName; + if (isSameOriginApiTarget(targetStr) && doorObject && action.name) { + // Ask the store for the object type BEFORE the lookup, as the flow + // launch does: "this click names no declared action" is then an + // answer, not a race against a cold store. + await metadata.ensureType('object').catch(() => []); + if (findDeclaredApiAction(withKnownObjects(objects, metadata.objects), doorObject, action.name, targetStr)) { + const door = await sendApiActionThroughDoor({ + fetch: authFetch, + baseUrl, + action, + objectName: doorObject, + recordId: rowRecord?.id, + params: resolvedParams, + }); + if (!door.answered) return door.result; + if (!door.res.ok) return refused(door.res, door.json); + // The door answers the target's payload as its own `data`, already + // unwrapped from the target's `{ success, data }` — the level the + // direct path below hands back. A target's HTTP 200 rejection rides + // there, and the `/actions` envelope rule reads it. + const outcome = interpretActionResponse(door.res, door.json, `Action "${action.name}"`); + if (!outcome.ok) return { success: false, error: outcome.error }; + if (action.refreshAfter !== false) refresh(); + return { success: true, data: outcome.envelope, reload: action.refreshAfter !== false }; + } + } + // Interpolate `{field}` tokens in the target URL from the row record. let resolvedTarget = targetStr; if (rowRecord && /\{[a-z_][a-z0-9_]*\}/i.test(resolvedTarget)) { @@ -500,20 +559,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons if (!res.ok) { let body: any = null; try { body = await res.json(); } catch { /* response body not JSON */ } - // Plan/capacity gates (e.g. creating an environment the org's plan - // doesn't include) come back as coded 403s. Surface them as a friendly - // upgrade/limit DIALOG with a CTA — never a generic red error toast. - // Returning success:false WITHOUT an `error` suppresses the runner's - // error toast (ActionRunner.handlePostExecution); the dialog owns the - // messaging. - const entitlementSpec = entitlementDialogFromError(body, t); - if (entitlementSpec) { - openEntitlementDialog(entitlementSpec); - return { success: false }; - } - // A flow-backed endpoint's refused run reads its inner refusal, not - // the node chain (objectstack#27487). - return { success: false, error: apiActionFailureMessage(res, body, `HTTP ${res.status}`, language) }; + return refused(res, body); } const json = await res.json().catch(() => ({})); // A business rejection can arrive as HTTP 200 with `success: false` diff --git a/ui/packages/app-shell/src/utils/apiActionDoor.ts b/ui/packages/app-shell/src/utils/apiActionDoor.ts new file mode 100644 index 00000000000..fa8e5963f11 --- /dev/null +++ b/ui/packages/app-shell/src/utils/apiActionDoor.ts @@ -0,0 +1,170 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Which door a `type: 'api'` click goes through (objectstack#27611, step 3 of + * the ruling on objectstack#27506: people and agents use one system). + * Module-internal: the two api handlers — `useConsoleActionRuntime`'s and + * `RecordDetailView`'s — call it, and nothing outside this package can import + * it (it is not re-exported from `utils/index` or the package entry). + * + * ## Two doors, chosen by a lookup, not a guess + * + * - **the action door**, `POST /api/v1/actions/:object/:action` — for a click + * whose object and `name` resolve to a DECLARED `type: 'api'` action whose + * `target` is a path on this origin ({@link findDeclaredApiAction}). The + * server resolves the declaration by name and runs every gate it carries — + * the sign-in floor, its `requiredPermissions` (ADR-0066 D4), its activation + * switch (ADR-0126 §8: a switched-off action answers `409 ACTION_DISABLED` + * and its target is never called), its param contract and a caller-scoped + * load of the subject row — then forwards the request to `target` in-process + * with the caller's credentials. The server assembles the target's body from + * the declaration (`recordIdParam` / `recordIdField` from the subject row, + * `bodyShape.wrap`, the `outputs.*` fold, `organizationId` for an + * `/api/v1/auth/` target, `bodyExtra`, `method`, the target's + * placeholders), by the rules the direct fetch applies. So the click sends + * only what the browser alone holds: the subject row's id and the params bag, + * its `{{page.}}` tokens already resolved. + * - **the target itself**, fetched by the browser — for every other api click. + * An off-origin `target` is never governed by this server (its doors refuse + * it), and a click that names no declared action — an inline page + * `action:button` whose `name` is optional — has no gate to apply. That is + * not a fallback for a door that failed: for those clicks the target IS the + * door. + * + * ⚠️ **What the lookup cannot see, said rather than assumed.** It reads the + * object metadata the host already holds, as the flow launch's lookup does + * (`utils/flowLaunch`): an object's own `actions[]`, which includes every + * `defineAction({ objectName })` declared beside it. An object-LESS declared + * action (`/actions/global/:action`) and a standalone authored action row no + * object embeds are absent from it, so such a click keeps the direct fetch it + * always made. + * + * ## Who owns which half of the exchange + * + * The `/actions` REQUEST belongs to `@object-ui/core`'s dispatcher — + * `createServerActionHandler` owns name-only identity (ADR-0110 D1), the + * `{ recordId, params }` body and the `_rowRecord` strip, and app-shell names + * that route nowhere else (`actions-envelope.ratchet.test.ts`). Its ANSWER is + * the forwarded target's, so it is read by the host's api rules — the + * entitlement dialog, `apiActionFailureMessage`, the `{ success, data }` + * unwrap and `refreshAfter` — and the raw answer is handed back here for that. + */ + +import { createServerActionHandler } from '@object-ui/core'; +import type { ActionDef, ActionResult, ServerActionFetch } from '@object-ui/core'; + +/** + * `true` for a `target` the action door forwards to: a path on this origin. + * The server's own predicate, restated so the two answer alike: `//host` is + * protocol-relative (another origin), and `/\host` is the same thing spelled + * for browsers that read a backslash as a slash. + */ +export function isSameOriginApiTarget(target: unknown): target is string { + if (typeof target !== 'string' || target.length < 2 || !target.startsWith('/')) return false; + const second = target.charAt(1); + return second !== '/' && second !== '\\'; +} + +/** The keys of a declared action the lookup reads. */ +interface DeclaredActionShape { + name?: unknown; + type?: unknown; + target?: unknown; +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Is `clicked` the target `declared` produces? The runner substitutes every + * `${param.*}` / `${ctx.*}` token before a registered api handler sees the + * target (`ActionRunner.interpolateTarget`), each value percent-encoded, so a + * substituted token spans no `/`, `?` or `#`. `{field}` row placeholders reach + * the handler unsubstituted and compare as written. + */ +function declaredTargetMatches(declared: string, clicked: string): boolean { + if (declared === clicked) return true; + if (!declared.includes('${')) return false; + const pattern = declared.split(/\$\{[^}]*\}/).map(escapeRegExp).join('[^/?#]*'); + return new RegExp(`^${pattern}$`).test(clicked); +} + +/** + * The declared same-origin api action a click names, or `undefined` when it + * names none. + * + * A match needs all five: the object is in `objects`, it declares an action of + * this `name`, that action's `type` is `'api'`, its `target` is a path on this + * origin, and the click's target is the one that declaration produces. A + * same-named action of another type, or one aimed elsewhere, is not the + * click's declaration — sending the click to it would run something the click + * did not ask for. + */ +export function findDeclaredApiAction( + objects: readonly unknown[] | undefined, + objectName: string | undefined, + actionName: string | undefined, + clickedTarget: string, +): (DeclaredActionShape & { name: string; target: string }) | undefined { + if (!objectName || !actionName || !Array.isArray(objects)) return undefined; + const objectDef = objects.find( + (o) => (o as { name?: unknown } | null | undefined)?.name === objectName, + ) as { actions?: unknown } | undefined; + const actions: unknown[] = Array.isArray(objectDef?.actions) ? objectDef.actions : []; + const declared = actions.find( + (a) => (a as DeclaredActionShape | null | undefined)?.name === actionName, + ) as DeclaredActionShape | undefined; + if (!declared || declared.type !== 'api' || !isSameOriginApiTarget(declared.target)) return undefined; + if (!declaredTargetMatches(declared.target, clickedTarget)) return undefined; + return declared as DeclaredActionShape & { name: string; target: string }; +} + +export interface ApiActionDoorInput { + /** The host's authenticated transport. */ + fetch: ServerActionFetch; + baseUrl: string; + /** The clicked action; its `name` is the declaration's. */ + action: ActionDef; + /** The object the declaration belongs to. */ + objectName: string; + /** + * The SUBJECT ROW's id, resolved by the host. The door loads the row by it + * and reads `recordIdField` and the target's `{field}` placeholders off that + * row, so this is the row's `id`, never the `recordIdField` value. + */ + recordId: unknown; + /** The params bag, `_rowRecord` stripped and `{{page.}}` tokens resolved. */ + params: Record; +} + +/** The door's answer, raw, or the dispatcher's own result when none arrived. */ +export type ApiActionDoorExchange = + | { answered: true; res: { ok: boolean; status: number }; json: unknown } + | { answered: false; result: ActionResult }; + +/** Send a declared same-origin api click through the action door. */ +export async function sendApiActionThroughDoor(input: ApiActionDoorInput): Promise { + let answered: { res: { ok: boolean; status: number }; json: unknown } | undefined; + const keepAnswer: ServerActionFetch = async (url, init) => { + const res = await input.fetch(url, init); + const json = await res.json().catch(() => null); + answered = { res: { ok: res.ok, status: res.status }, json }; + return { ok: res.ok, status: res.status, json: async () => json }; + }; + const dispatch = createServerActionHandler({ + fetch: keepAnswer, + baseUrl: input.baseUrl, + // The host already resolved the subject row; the dispatcher must not + // re-derive it from a grid selection. + resolveRecordId: () => ({ recordId: input.recordId }), + }); + const result = await dispatch({ ...input.action, objectName: input.objectName, params: input.params }); + return answered ? { answered: true, ...answered } : { answered: false, result }; +} diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index e34a8fdbe8d..3b968e341e7 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -48,6 +48,7 @@ import { resolveHostAppSegment } from '../utils/appRoute.js'; import { hostInConsolePath } from '../utils/hostInConsolePath.js'; import { launchConsoleFlow } from '../utils/flowLaunch.js'; import { apiActionFailureMessage } from '../utils/flowResponse.js'; +import { findDeclaredApiAction, isSameOriginApiTarget, sendApiActionThroughDoor } from '../utils/apiActionDoor.js'; import { useNavigationContext, useRecordBreadcrumbTitle } from '../context/NavigationContext.js'; // Audit provenance renders as the one-line ; the other // framework-injected bookkeeping columns are hidden from the body outright. @@ -1426,8 +1427,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri delete params._rowRecord; // Absolute HTTP target — mirror of useConsoleActionRuntime.apiHandler's - // canonical branch (fetch + recordIdParam/bodyExtra/bodyShape + - // active-org injection for better-auth endpoints). The legacy + // canonical branch (the action door for a declared same-origin action; + // otherwise fetch + recordIdParam/bodyExtra/bodyShape + active-org + // injection for better-auth endpoints). The legacy // dataSource.update mapping below MUST NOT see these: routing a // better-auth row action (remove_member / update_member_role) through // it either silently no-opped (no collected params) or bypassed @@ -1435,6 +1437,38 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const targetStr = typeof target === 'string' ? target : ''; if (targetStr.startsWith('/') || /^https?:\/\//i.test(targetStr)) { const baseUrl = import.meta.env.VITE_SERVER_URL || ''; + + // The action door (objectstack#27611 — see utils/apiActionDoor): a + // DECLARED action whose target is a path on this origin, looked up in + // this page's `objects` as the flow launch looks it up. The server + // assembles the target's request from the declaration and loads the + // subject row itself, so the click sends that row's id — the stashed + // row's, else a related-list row's explicit `recordId`, else this + // page's own for an action on this page's object — and the params bag. + const doorObject = action.objectName ?? objectName; + if (isSameOriginApiTarget(targetStr) && action.name + && findDeclaredApiAction(objects, doorObject, action.name, targetStr)) { + const door = await sendApiActionThroughDoor({ + fetch: authFetch, + baseUrl, + action, + objectName: doorObject!, + recordId: rowRecord?.id + ?? (action as { recordId?: unknown }).recordId + ?? (doorObject === objectName ? pureRecordId : undefined), + params, + }); + if (!door.answered) return door.result; + if (!door.res.ok) { + return { success: false, error: apiActionFailureMessage(door.res, door.json, `HTTP ${door.res.status}`, language) }; + } + // The door's body, as this page has always handed back its target's: + // byte for byte the target's own answer when the target answers the + // platform `{ success, data }` envelope. + if (action.refreshAfter === true) notifyRecordChanged(); + return { success: true, data: door.json, reload: action.refreshAfter === true }; + } + // Interpolate `{field}` tokens in the target URL. Source: the stashed // row when the dispatch carries one (related-list rows; page:header // since objectui#3391), else THIS page's record — but only when the @@ -1644,7 +1678,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } catch (error) { return { success: false, error: (error as Error).message }; } - }, [dataSource, objectName, objectDef, pureRecordId, pageRecord, authFetch, activeOrganization, language]); + }, [dataSource, objectName, objectDef, objects, pureRecordId, pageRecord, authFetch, activeOrganization, language]); // Client-side modal transport: `type:'modal'` actions open here (Dialog / // Sheet / Drawer by `placement`) and render arbitrary SchemaNode content. From 96f75a3feacf3f034c8ba14fafd71ba372a904fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:16:21 +0000 Subject: [PATCH 2/4] test(console): pin api clicks through the action door; align D4 docblocks 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 --- .../27611-api-action-click-through-door.md | 12 + ...alRequestsDataSource.decisionPage.test.tsx | 19 +- ...ActionRuntime.apiActionDoor-27611.test.tsx | 319 ++++++++++++++++++ .../src/utils/__tests__/apiActionDoor.test.ts | 66 ++++ .../ObjectView.inviteFeedback-11817.test.tsx | 21 +- ...ew.accessEndedAfterDecision-26589.test.tsx | 9 +- ...ordDetailView.apiActionDoor-27611.test.tsx | 256 ++++++++++++++ ...cordDetailView.apiNewTabUrl-26747.test.tsx | 6 +- ...etailView.approvalDeclaredActions.test.tsx | 28 +- ...View.printPageHeaderActions-12906.test.tsx | 9 +- ...PageView.relatedListActions-12870.test.tsx | 8 +- .../src/__tests__/action-bar.test.tsx | 5 +- .../page-header-capability-gate.test.tsx | 5 +- .../src/renderers/layout/containers.tsx | 6 +- ui/packages/plugin-detail/src/RelatedList.tsx | 6 +- ...edList.toolbarCapabilityGate-9782.test.tsx | 6 +- .../bulkActionCapabilityGate.test.tsx | 5 +- 17 files changed, 739 insertions(+), 47 deletions(-) create mode 100644 .changeset/27611-api-action-click-through-door.md create mode 100644 ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx create mode 100644 ui/packages/app-shell/src/utils/__tests__/apiActionDoor.test.ts create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.apiActionDoor-27611.test.tsx diff --git a/.changeset/27611-api-action-click-through-door.md b/.changeset/27611-api-action-click-through-door.md new file mode 100644 index 00000000000..1e874ebb07c --- /dev/null +++ b/.changeset/27611-api-action-click-through-door.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(console): a click on a declared same-origin `type: 'api'` action goes through the platform action door + +Clause-②: no +Metadata-protocol: none + +The console's two api handlers (list, toolbar and page actions; the record page's header and related lists) fetched an api action's `target` directly, so the click met none of the platform's gates: switching a packaged api action off in Setup did not stop its button, and a human click took a different road from an agent's `run_action`. A click whose object and `name` resolve to a declared `type: 'api'` action whose `target` is a path on this origin now goes to `POST /api/v1/actions/:object/:action` with the subject row's id and the params bag (`{{page.}}` tokens resolved in the browser). The server runs the sign-in floor, `requiredPermissions`, the activation switch, the param contract and the subject-row load, then forwards to the declared target with the body it assembles from the declaration. A switched-off action answers `409 ACTION_DISABLED`, shown as its sentence, and its target is never called. + +An api action whose `target` is on another origin, and an inline page button that names no declared action, keep the direct fetch. The entitlement dialog, the flow-backed refusal message, the `{ success, data }` unwrap, the `newTabUrl` fast path and `refreshAfter` read the door's answer as they read the target's. diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx index 74116b69277..3c0f092bcc0 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx @@ -23,7 +23,8 @@ * - its timeline from `GET /approvals/requests/:id/actions`; * - the target record of the `record_id` pointer pair from the adapter, drawn * as A2's card; - * - the decision as the declared action's POST, through `ActionParamDialog`. + * - the decision as the declared action's POST through the action door + * (objectstack#27611), collected in `ActionParamDialog`. * * The page reads through `routeApprovalRecordReads`, the console's adapter * with a request's record reads routed, as every surface reads it. The last @@ -197,6 +198,8 @@ function json(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); } +const APPROVE_DOOR = `/api/v1/actions/${APPROVAL_REQUEST_OBJECT}/approval_approve`; + /** Every request the page sent over HTTP: method, path and query, body. */ let wire: Array<{ method: string; path: string; body?: unknown }> = []; @@ -209,8 +212,10 @@ const transport = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Pr wire.push({ method, path: url.pathname + url.search, body }); if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}`) return json(200, ROW); if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}/actions`) return json(200, { data: TIMELINE }); - if (method === 'POST' && url.pathname === `/api/v1/approvals/requests/${REQ}/approve`) { - return json(200, { request: ROW, finalized: false }); + // The decision goes through the action door (objectstack#27611), which + // forwards to the approve route and answers its payload as `data`. + if (method === 'POST' && url.pathname === APPROVE_DOOR) { + return json(200, { success: true, data: { request: ROW, finalized: false } }); } return json(200, { success: true, data: [] }); }); @@ -292,7 +297,7 @@ describe("an approval request's record page with record:approval_decision (objec expect(requestGets().length).toBeGreaterThan(0); }); - it('Approve collects a comment and several files in ActionParamDialog, POSTs the request route, then re-reads the request in place', async () => { + it('Approve collects a comment and several files in ActionParamDialog, POSTs it through the action door, then re-reads the request in place', async () => { openRequestPage(); const approve = await screen.findByTestId('declared-action-approval_approve', undefined, { timeout: 8000 }); const before = requestGets().length; @@ -308,11 +313,11 @@ describe("an approval request's record page with record:approval_decision (objec fireEvent.click(within(dialog).getByRole('button', { name: /Confirm/ })); await waitFor( - () => expect(wire.some((w) => w.method === 'POST' && w.path === `/api/v1/approvals/requests/${REQ}/approve`)).toBe(true), + () => expect(wire.some((w) => w.method === 'POST' && w.path === APPROVE_DOOR)).toBe(true), { timeout: 8000 }, ); - const post = wire.find((w) => w.method === 'POST' && w.path === `/api/v1/approvals/requests/${REQ}/approve`); - expect(post?.body).toMatchObject({ comment: 'Looks right' }); + const post = wire.find((w) => w.method === 'POST' && w.path === APPROVE_DOOR); + expect(post?.body).toMatchObject({ recordId: REQ, params: { comment: 'Looks right' } }); await waitFor(() => expect(requestGets().length).toBeGreaterThan(before), { timeout: 8000 }); }); diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx new file mode 100644 index 00000000000..ccef80a7f66 --- /dev/null +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx @@ -0,0 +1,319 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27611 — the shared console runtime sends a click on a DECLARED + * same-origin `type: 'api'` action through the action door, so the gates its + * declaration carries hold for a human click as they do for an agent (step 3 + * of the ruling on objectstack#27506, ADR-0066 D4 as amended). + * + * The door is the mocked authenticated fetch: what is pinned here is the + * request the console sends and how it reads each answer. That the door runs + * its gates, never calls a switched-off action's target, and assembles the + * target's body from the declaration is the server's own pin + * (`packages/runtime/src/domains/api-action-forward.test.ts`). + * + * Three answers below are written as the door gives them once objectstack#27738 + * relays a target's `error.details` / `userMessage` and admits decision-output + * params: a flow-backed endpoint's inner refusal, the entitlement dialog's call + * to action and usage, and an approval decision carrying its outputs. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, renderHook, act } from '@testing-library/react'; +import React from 'react'; +import { builtInLocales, registerBuiltInLocales } from '@object-ui/i18n/locales'; + +vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() })); + +const authFetchSpy = vi.fn(); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => authFetchSpy, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectLabel: () => ({ + fieldLabel: (_o: any, _n: any, l: any) => l, + fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l, + actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback, + actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback, + actionDescription: (_o: any, _a: any, fallback: any) => fallback, + }), + // The entitlement dialog localizes its own copy — stand in with the English + // defaults (+ `{{token}}` interpolation) the real `t` would resolve to. + useObjectTranslation: () => ({ + t: (key: string, options?: any) => + String(options?.defaultValue ?? key).replace( + /\{\{(\w+)\}\}/g, + (_m: string, name: string) => String(options?.[name] ?? ''), + ), + language: 'en', + i18n: undefined, + }), +})); + +vi.mock('../useActionModal', () => ({ + useActionModal: () => ({ + modalHandler: vi.fn(async () => ({ success: true })), + modalElement: null, + closeModal: () => {}, + resolveModalTarget: vi.fn(async () => null), + }), +})); + +vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('../../views/ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null })); + +vi.mock('sonner', () => { + const fn: any = vi.fn(); + fn.error = vi.fn(); + fn.success = vi.fn(); + return { toast: fn }; +}); + +import { useConsoleActionRuntime } from '../useConsoleActionRuntime'; + +registerBuiltInLocales(); + +const DOOR = '/api/v1/actions/'; + +const BAN = { + name: 'ban_user', + label: 'Ban User', + type: 'api', + target: '/api/v1/auth/admin/ban-user', + recordIdParam: 'userId', + params: [{ name: 'banReason', label: 'Reason', type: 'text' }], +}; +const PING = { name: 'ping_hook', label: 'Ping', type: 'api', target: 'https://hooks.example.test/ping' }; +const NOTE = { name: 'post_note', label: 'Post note', type: 'api', target: '/api/v1/notes' }; +const CREATE_ENV = { name: 'create_environment', label: 'Create', type: 'api', target: '/api/v1/cloud/environments' }; +const RECALC = { + name: 'showcase_recalc_estimate', + label: 'Recalculate Estimate', + type: 'api', + target: '/api/v1/apps/showcase/recalc', + recordIdParam: 'recordId', +}; +const APPROVE = { + name: 'approval_approve', + label: 'Approve', + type: 'api', + method: 'POST', + target: '/api/v1/approvals/requests/{id}/approve', + params: [{ name: 'comment', label: 'Comment', type: 'textarea' }], +}; + +const OBJECTS = [ + { name: 'sys_user', actions: [BAN, PING, NOTE] }, + { name: 'sys_environment', actions: [CREATE_ENV] }, + { name: 'showcase_task', actions: [RECALC] }, + { name: 'sys_approval_request', actions: [APPROVE] }, +]; + +const ROW = { id: 'u_target', email: 'target@example.test' }; +const DISABLED = "Action 'ban_user' is disabled — switched off in the packaged-metadata activation ledger " + + '(sys_metadata_activation, ADR-0126 §8).'; + +function answer(status: number, body: unknown) { + authFetchSpy.mockResolvedValue({ ok: status >= 200 && status < 300, status, json: async () => body }); +} + +function mount(objectName: string, objects: unknown[] = OBJECTS) { + const onRefresh = vi.fn(); + const hook = renderHook(() => useConsoleActionRuntime({ dataSource: {}, objects: objects as any[], objectName, onRefresh })); + return { ...hook, onRefresh }; +} + +async function click(result: { current: ReturnType }, action: any, context?: any) { + let out: any; + await act(async () => { + out = await result.current.apiHandler(action, context); + }); + return out; +} + +/** The one request the click sent: its URL and parsed body. */ +function sent(): { url: string; method: string; body: any } { + expect(authFetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = authFetchSpy.mock.calls[0] as [string, any]; + return { url: String(url), method: init.method, body: init.body === undefined ? undefined : JSON.parse(init.body) }; +} + +beforeEach(() => { + authFetchSpy.mockReset(); +}); + +describe('objectstack#27611 — a declared same-origin api click goes through the action door', () => { + it('sends the subject row\'s id and the params bag to the door, and nothing to the target', async () => { + answer(200, { success: true, data: { user: { id: 'u_target', banned: true } } }); + const { result } = mount('sys_user'); + + await click(result, { ...BAN, params: { banReason: 'spam', _rowRecord: ROW } }); + + const req = sent(); + expect(req.url.endsWith(`${DOOR}sys_user/ban_user`)).toBe(true); + expect(req.method).toBe('POST'); + expect(req.body).toEqual({ recordId: 'u_target', params: { banReason: 'spam' } }); + }); + + it('a switched-off action answers the door\'s 409 ACTION_DISABLED as its sentence, never the bare code', async () => { + answer(409, { success: false, error: { code: 'ACTION_DISABLED', message: DISABLED, httpStatus: 409 } }); + const { result, onRefresh } = mount('sys_user'); + + const out = await click(result, { ...BAN, params: { banReason: 'spam', _rowRecord: ROW } }); + + expect(sent().url.endsWith(`${DOOR}sys_user/ban_user`)).toBe(true); + expect(out).toEqual({ success: false, error: DISABLED }); + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it('a switched-on action answers the target\'s payload, unwrapped, and refreshes', async () => { + answer(200, { success: true, data: { user: { id: 'u_target', banned: true } } }); + const { result, onRefresh } = mount('sys_user'); + + const out = await click(result, { ...BAN, params: { banReason: 'spam', _rowRecord: ROW } }); + + expect(out).toEqual({ success: true, data: { user: { id: 'u_target', banned: true } }, reload: true }); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('a target\'s HTTP 200 rejection, relayed as the door\'s data, is a failure', async () => { + answer(200, { success: true, data: { success: false, error: { message: 'User is already banned' } } }); + const { result, onRefresh } = mount('sys_user'); + + const out = await click(result, { ...BAN, params: { _rowRecord: ROW } }); + + expect(out).toEqual({ success: false, error: 'User is already banned' }); + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it('resolves `{{page.}}` tokens in the browser and sends the values as params', async () => { + answer(200, { success: true, data: { id: 'n1' } }); + const { result } = mount('sys_user'); + + await click(result, { ...NOTE, params: { text: '{{page.draft}}' } }, { pageVariables: { draft: 'hello' } }); + + const req = sent(); + expect(req.url.endsWith(`${DOOR}sys_user/post_note`)).toBe(true); + expect(req.body).toEqual({ params: { text: 'hello' } }); + }); +}); + +describe('objectstack#27611 — every other api click keeps the direct fetch', () => { + it('an off-origin target is called directly', async () => { + answer(200, { ok: true }); + const { result } = mount('sys_user'); + + await click(result, { ...PING, params: { _rowRecord: ROW } }); + + expect(sent().url).toBe('https://hooks.example.test/ping'); + }); + + it('a click that names no declared action (an inline page button) is called directly', async () => { + answer(200, { success: true, data: {} }); + const { result } = mount('sys_user', []); + + await click(result, { ...BAN, params: { banReason: 'spam', _rowRecord: ROW } }); + + const req = sent(); + expect(req.url.endsWith(BAN.target)).toBe(true); + expect(req.body).toEqual({ banReason: 'spam', userId: 'u_target' }); + }); + + it('a same-named declaration aimed at another target is not the click\'s, so the click is called directly', async () => { + answer(200, { success: true, data: {} }); + const { result } = mount('sys_user'); + + await click(result, { ...BAN, target: '/api/v1/auth/admin/unban-user', params: { _rowRecord: ROW } }); + + expect(sent().url.endsWith('/api/v1/auth/admin/unban-user')).toBe(true); + }); +}); + +describe('objectstack#27611 — what the door answers once objectstack#27738 lands', () => { + it('a flow-backed endpoint\'s refused run reads its inner refusal, not the node chain', async () => { + const refusal = 'You do not have permission to perform this action. Contact your administrator if you need access.'; + answer(400, { + success: false, + error: { + code: 'FLOW_FAILED', + message: `Node 'write_estimate' failed: update_record(showcase_task) failed: ${refusal}`, + httpStatus: 400, + details: { innerRefusal: { code: 'PERMISSION_DENIED', message: refusal } }, + }, + }); + const { result } = mount('showcase_task'); + + const out = await click(result, { ...RECALC, params: { _rowRecord: { id: 'task-1' } } }); + + expect(sent().url.endsWith(`${DOOR}showcase_task/showcase_recalc_estimate`)).toBe(true); + expect(out).toEqual({ success: false, error: builtInLocales.en.form.noPermissionToSave }); + }); + + it('an entitlement 403 opens the upgrade dialog with its call to action, not a red toast', async () => { + answer(403, { + success: false, + error: { + code: 'DEV_ENV_PLAN_LOCKED', + message: 'Development environments are a paid feature. Upgrade to add them.', + httpStatus: 403, + details: { upgrade_url: '/settings/billing', plan: 'free' }, + }, + }); + const { result, onRefresh } = mount('sys_environment'); + + const out = await click(result, { ...CREATE_ENV, params: { displayName: 'x' } }); + + expect(sent().url.endsWith(`${DOOR}sys_environment/create_environment`)).toBe(true); + expect(out).toEqual({ success: false }); + expect(onRefresh).not.toHaveBeenCalled(); + render(<>{result.current.dialogs}); + expect(await screen.findByText('Development environments are a paid feature')).toBeTruthy(); + const cta = screen.getByTestId('entitlement-cta-primary'); + expect(cta.textContent).toBe('Upgrade plan'); + expect(cta.getAttribute('href')).toMatch(/\/settings\/billing$/); + }); + + it('an exhausted environment pool quotes the usage the door relays', async () => { + answer(403, { + success: false, + error: { + code: 'DEV_ENV_LIMIT', + message: 'Development environment limit reached.', + httpStatus: 403, + details: { current: 3, limit: 3, upgrade_url: '/settings/billing' }, + }, + }); + const { result } = mount('sys_environment'); + + expect(await click(result, { ...CREATE_ENV, params: { displayName: 'x' } })).toEqual({ success: false }); + render(<>{result.current.dialogs}); + expect(await screen.findByText(/You are using 3 of 3 development environments/)).toBeTruthy(); + }); + + it('an approval decision sends its outputs as `outputs.` params, for the door to fold', async () => { + answer(200, { success: true, data: { request: { id: 'req_1', record_title: 'Q4 budget' } } }); + const { result } = mount('sys_approval_request'); + + const out = await click(result, { + ...APPROVE, + params: { comment: 'ok', 'outputs.reason': 'within budget', _rowRecord: { id: 'req_1' } }, + }); + + const req = sent(); + expect(req.url.endsWith(`${DOOR}sys_approval_request/approval_approve`)).toBe(true); + expect(req.body).toEqual({ recordId: 'req_1', params: { comment: 'ok', 'outputs.reason': 'within budget' } }); + expect(out).toEqual({ success: true, data: { request: { id: 'req_1', record_title: 'Q4 budget' } }, reload: true }); + }); +}); diff --git a/ui/packages/app-shell/src/utils/__tests__/apiActionDoor.test.ts b/ui/packages/app-shell/src/utils/__tests__/apiActionDoor.test.ts new file mode 100644 index 00000000000..6d32d138f9f --- /dev/null +++ b/ui/packages/app-shell/src/utils/__tests__/apiActionDoor.test.ts @@ -0,0 +1,66 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27611 — which `type: 'api'` click goes through the action door. + * + * `isSameOriginApiTarget` restates the server's predicate (`@objectstack/runtime` + * `action-execution.ts#isSameOriginApiTarget`, ADR-0066 D4 as amended), so the + * two answer alike on every spelling below. `findDeclaredApiAction` is the + * lookup both api handlers ask before they send a click to the door. + */ + +import { describe, it, expect } from 'vitest'; +import { findDeclaredApiAction, isSameOriginApiTarget } from '../apiActionDoor'; + +describe('isSameOriginApiTarget — the server\'s predicate, restated', () => { + it('admits a path on this origin', () => { + expect(isSameOriginApiTarget('/api/v1/auth/admin/ban-user')).toBe(true); + expect(isSameOriginApiTarget('/x')).toBe(true); + }); + + it('refuses another origin, a bare slash and a non-string', () => { + expect(isSameOriginApiTarget('https://hooks.example.test/ping')).toBe(false); + expect(isSameOriginApiTarget('//hooks.example.test/ping')).toBe(false); + expect(isSameOriginApiTarget('/\\hooks.example.test/ping')).toBe(false); + expect(isSameOriginApiTarget('/')).toBe(false); + expect(isSameOriginApiTarget('api/v1/x')).toBe(false); + expect(isSameOriginApiTarget('')).toBe(false); + expect(isSameOriginApiTarget(undefined)).toBe(false); + }); +}); + +describe('findDeclaredApiAction — the click names a declared same-origin api action', () => { + const BAN = { name: 'ban_user', type: 'api', target: '/api/v1/auth/admin/ban-user' }; + const ARCHIVE = { name: 'archive', type: 'api', target: '/api/v1/things/${ctx.recordId}/archive' }; + const PING = { name: 'ping', type: 'api', target: 'https://hooks.example.test/ping' }; + const RUN = { name: 'run', type: 'script', target: '/api/v1/auth/admin/ban-user' }; + const OBJECTS = [{ name: 'sys_user', actions: [BAN, ARCHIVE, PING, RUN] }]; + + it('finds the declaration by object, name, type and target', () => { + expect(findDeclaredApiAction(OBJECTS, 'sys_user', 'ban_user', BAN.target)).toBe(BAN); + }); + + it('matches a target the runner already interpolated', () => { + expect(findDeclaredApiAction(OBJECTS, 'sys_user', 'archive', '/api/v1/things/rec%201/archive')).toBe(ARCHIVE); + expect(findDeclaredApiAction(OBJECTS, 'sys_user', 'archive', '/api/v1/things/a/b/archive')).toBeUndefined(); + }); + + it('names nothing for an off-origin declaration, another type or another target', () => { + expect(findDeclaredApiAction(OBJECTS, 'sys_user', 'ping', PING.target)).toBeUndefined(); + expect(findDeclaredApiAction(OBJECTS, 'sys_user', 'run', RUN.target)).toBeUndefined(); + expect(findDeclaredApiAction(OBJECTS, 'sys_user', 'ban_user', '/api/v1/auth/admin/unban-user')).toBeUndefined(); + }); + + it('names nothing without the object, the name or the object metadata', () => { + expect(findDeclaredApiAction(OBJECTS, 'crm_lead', 'ban_user', BAN.target)).toBeUndefined(); + expect(findDeclaredApiAction(OBJECTS, undefined, 'ban_user', BAN.target)).toBeUndefined(); + expect(findDeclaredApiAction(OBJECTS, 'sys_user', undefined, BAN.target)).toBeUndefined(); + expect(findDeclaredApiAction(undefined, 'sys_user', 'ban_user', BAN.target)).toBeUndefined(); + }); +}); diff --git a/ui/packages/app-shell/src/views/ObjectView.inviteFeedback-11817.test.tsx b/ui/packages/app-shell/src/views/ObjectView.inviteFeedback-11817.test.tsx index 6b166411e7f..49350510782 100644 --- a/ui/packages/app-shell/src/views/ObjectView.inviteFeedback-11817.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.inviteFeedback-11817.test.tsx @@ -186,16 +186,19 @@ const ADMIN = { id: 'u1', name: 'Dev Admin', positions: ['org_admin'] }; const GENERIC_SUBTITLE = 'Please provide the required information to continue.'; type InviteAnswer = { status: number; body: unknown }; +const INVITE_DOOR = '/api/v1/actions/sys_user/invite_user'; let inviteAnswer: InviteAnswer; let fetchSpy: ReturnType; beforeEach(() => { + // The click goes through the action door (objectstack#27611), which answers + // the target's payload as its `data` and a refusal as its own envelope. inviteAnswer = { status: 200, - body: { id: 'inv1', email: 'ada@example.com', role: 'member', status: 'pending' }, + body: { success: true, data: { id: 'inv1', email: 'ada@example.com', role: 'member', status: 'pending' } }, }; fetchSpy = vi.fn(async (url: unknown) => { - const answer = String(url).includes('/organization/invite-member') + const answer = String(url).includes(INVITE_DOOR) ? inviteAnswer : { status: 200, body: { data: [] } }; return new Response(JSON.stringify(answer.body), { @@ -258,7 +261,7 @@ async function inviteAda(): Promise { fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'ada@example.com' } }); await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'Confirm' })); }); await waitFor( - () => expect(fetchSpy.mock.calls.some(([u]) => String(u).includes('/organization/invite-member'))).toBe(true), + () => expect(fetchSpy.mock.calls.some(([u]) => String(u).includes(INVITE_DOOR))).toBe(true), { timeout: 5000 }, ); } @@ -271,12 +274,18 @@ describe('objectui#11817 — Invite User says it worked', () => { await inviteAda(); expect(await screen.findByText('Invitation sent', undefined, { timeout: 5000 })).toBeInTheDocument(); - const [, init] = fetchSpy.mock.calls.find(([u]) => String(u).includes('/organization/invite-member'))!; - expect(JSON.parse(String((init as RequestInit).body))).toMatchObject({ email: 'ada@example.com', role: 'member' }); + const [, init] = fetchSpy.mock.calls.find(([u]) => String(u).includes(INVITE_DOOR))!; + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ params: { email: 'ada@example.com', role: 'member' } }); }); it('control: a refused invite raises the server\'s message and no success toast', async () => { - inviteAnswer = { status: 400, body: { error: { message: 'User is already a member of this organization' } } }; + inviteAnswer = { + status: 400, + body: { + success: false, + error: { code: 'VALIDATION_ERROR', message: 'User is already a member of this organization', httpStatus: 400 }, + }, + }; mountList('sys_user', 'all_users', [sysUser(), ...MEMBERS]); await inviteAda(); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.accessEndedAfterDecision-26589.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.accessEndedAfterDecision-26589.test.tsx index daed5e3e9ca..7ba350586e6 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.accessEndedAfterDecision-26589.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.accessEndedAfterDecision-26589.test.tsx @@ -33,13 +33,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; -// The decision POST. It also flips `readEnded` the way the server's step end -// does (objectstack#26180): from then on the business record answers 404 to a -// viewer whose only read was the pending step's. +// The decision POST — through the action door, which forwards it to the +// declared decide route (objectstack#27611). It also flips `readEnded` the way +// the server's step end does (objectstack#26180): from then on the business +// record answers 404 to a viewer whose only read was the pending step's. let readEnded = false; let revokesOnDecision = true; const authFetchSpy = vi.fn(async (url: string) => { - if (revokesOnDecision && /\/approvals\/requests\/[^/]+\/(approve|reject)$/.test(String(url))) readEnded = true; + if (revokesOnDecision && /\/actions\/sys_approval_request\/approval_(approve|reject)$/.test(String(url))) readEnded = true; return new Response(JSON.stringify({ data: {} }), { status: 200, headers: { 'content-type': 'application/json' }, diff --git a/ui/packages/app-shell/src/views/RecordDetailView.apiActionDoor-27611.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.apiActionDoor-27611.test.tsx new file mode 100644 index 00000000000..bee321fee10 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.apiActionDoor-27611.test.tsx @@ -0,0 +1,256 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27611 — the record page sends a click on a DECLARED same-origin + * `type: 'api'` action through the action door, with the subject row's id: a + * related-list row's own, else this page's record (step 3 of the ruling on + * objectstack#27506, ADR-0066 D4 as amended). + * + * Driven through the handler set the view REALLY hands to its ActionProvider, + * captured the way `RecordDetailView.apiFlowRefusal-27487.test.tsx` does. The + * door is the mocked authenticated fetch: the request and the reading of each + * answer are pinned here, the door's gates and its forward on the server + * (`packages/runtime/src/domains/api-action-forward.test.ts`). + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, act, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { builtInLocales } from '@object-ui/i18n/locales'; + +const authFetchSpy = vi.fn(async (..._args: unknown[]) => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), +); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => authFetchSpy, +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +vi.mock('../hooks/useActionModal', () => ({ + useActionModal: () => ({ + modalHandler: vi.fn(async () => ({ success: true })), + modalElement: null, + closeModal: () => {}, + resolveModalTarget: vi.fn(async () => null), + }), +})); + +vi.mock('../utils/consoleServerAction', () => ({ + createConsoleServerActionHandler: () => vi.fn(async () => ({ success: true })), +})); + +const captured: Array<{ handlers: Record Promise>; context: any }> = []; +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ActionProvider: (props: any) => { + captured.push({ handlers: props.handlers, context: props.context }); + return React.createElement(actual.ActionProvider as any, props); + }, + SchemaRenderer: () => null, + }; +}); + +import { MetadataCtx } from '@object-ui/react'; +import { RecordDetailView } from './RecordDetailView'; + +const OBJECT_NAME = 'showcase_task'; +const RECORD_ID = 'task-1'; +const DOOR = '/api/v1/actions/'; + +const RECALC = { + name: 'showcase_recalc_estimate', + label: 'Recalculate Estimate', + type: 'api', + target: '/api/v1/apps/showcase/recalc', + recordIdParam: 'recordId', +}; +const PING = { name: 'ping_hook', label: 'Ping', type: 'api', target: 'https://hooks.example.test/ping' }; +const CLOSE_SUBTASK = { + name: 'close_subtask', + label: 'Close', + type: 'api', + target: '/api/v1/subtasks/{id}/close', +}; + +const OBJECTS = [ + { + name: OBJECT_NAME, + label: 'Task', + fields: { + id: { type: 'text', label: 'Id' }, + title: { type: 'text', label: 'Title' }, + }, + actions: [RECALC, PING], + }, + { name: 'showcase_subtask', label: 'Subtask', fields: { id: { type: 'text', label: 'Id' } }, actions: [CLOSE_SUBTASK] }, +]; + +const METADATA = { + objects: OBJECTS, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], +} as any; + +const DISABLED = "Action 'showcase_recalc_estimate' is disabled — switched off in the packaged-metadata " + + 'activation ledger (sys_metadata_activation, ADR-0126 §8).'; + +function recordPageCapture() { + return [...captured] + .reverse() + .find((c) => c.handlers && c.context?.record?.id === RECORD_ID); +} + +/** Mount the page, answer the next request with `status` / `body`, click `action`. */ +async function clickOnPage(action: any, status: number, body: unknown) { + render( + + + ({ data: [] })), + findOne: vi.fn(async () => ({ id: RECORD_ID, title: 'Audit current IA' })), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any} + objects={OBJECTS} + onEdit={() => {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={RECORD_ID} + embedded + /> + + , + ); + await waitFor(() => expect(recordPageCapture()).toBeTruthy()); + const handlers = recordPageCapture()!.handlers; + authFetchSpy.mockClear(); + authFetchSpy.mockImplementationOnce(async () => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }), + ); + let out: any; + await act(async () => { + out = await handlers.api({ ...action }); + }); + expect(authFetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = authFetchSpy.mock.calls[0] as unknown as [string, RequestInit]; + return { out, url: String(url), body: init.body === undefined ? undefined : JSON.parse(String(init.body)) }; +} + +beforeEach(() => { + cleanup(); + captured.length = 0; + authFetchSpy.mockClear(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('RecordDetailView api handler — a declared same-origin click goes through the action door (objectstack#27611)', () => { + it('a header click sends this page\'s record id to the door, and its answer back as the page always has', async () => { + const answer = { success: true, data: { recalculated: 1 } }; + const { out, url, body } = await clickOnPage(RECALC, 200, answer); + + expect(url.endsWith(`${DOOR}${OBJECT_NAME}/${RECALC.name}`)).toBe(true); + expect(body).toEqual({ recordId: RECORD_ID, params: {} }); + expect(out).toEqual({ success: true, data: answer, reload: false }); + }); + + it('a switched-off action answers the door\'s 409 ACTION_DISABLED as its sentence', async () => { + const { out, url } = await clickOnPage(RECALC, 409, { + success: false, + error: { code: 'ACTION_DISABLED', message: DISABLED, httpStatus: 409 }, + }); + + expect(url.endsWith(`${DOOR}${OBJECT_NAME}/${RECALC.name}`)).toBe(true); + expect(out).toEqual({ success: false, error: DISABLED }); + }); + + it('a related-list row action on a child object sends the child row\'s id', async () => { + const { url, body } = await clickOnPage( + { ...CLOSE_SUBTASK, objectName: 'showcase_subtask', recordId: 'sub-7', params: { _rowRecord: { id: 'sub-7' } } }, + 200, + { success: true, data: {} }, + ); + + expect(url.endsWith(`${DOOR}showcase_subtask/close_subtask`)).toBe(true); + expect(body).toEqual({ recordId: 'sub-7', params: {} }); + }); + + it('an off-origin target is called directly', async () => { + const { url } = await clickOnPage(PING, 200, { ok: true }); + + expect(url).toBe(PING.target); + }); + + it('once objectstack#27738 lands, a flow-backed endpoint\'s refused run reads its inner refusal through the door', async () => { + const refusal = 'You do not have permission to perform this action. Contact your administrator if you need access.'; + const { out, url } = await clickOnPage(RECALC, 400, { + success: false, + error: { + code: 'FLOW_FAILED', + message: `Node 'write_estimate' failed: update_record(showcase_task) failed: ${refusal}`, + httpStatus: 400, + details: { innerRefusal: { code: 'PERMISSION_DENIED', message: refusal } }, + }, + }); + + expect(url.endsWith(`${DOOR}${OBJECT_NAME}/${RECALC.name}`)).toBe(true); + expect(out).toEqual({ success: false, error: builtInLocales.en.form.noPermissionToSave }); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.apiNewTabUrl-26747.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.apiNewTabUrl-26747.test.tsx index 9eedd6d4613..eaed37fd078 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.apiNewTabUrl-26747.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.apiNewTabUrl-26747.test.tsx @@ -237,7 +237,7 @@ describe('objectstack#26747 — record page: an api action with opensInNewTab + expect(authFetchSpy).not.toHaveBeenCalled(); }); - it('CONTROL: the same api action without newTabUrl still sends its request to the target', async () => { + it('CONTROL: the same api action without newTabUrl still sends its request, through the action door', async () => { const { newTabUrl: _dropped, ...withoutNewTabUrl } = newTabAction('export_tab', 'Export'); await mountRecordPage([withoutNewTabUrl]); const { open } = stubWindowOpen(); @@ -248,7 +248,9 @@ describe('objectstack#26747 — record page: an api action with opensInNewTab + await waitFor(() => expect(authFetchSpy).toHaveBeenCalledTimes(1)); const [url, init] = authFetchSpy.mock.calls[0] as unknown as [string, RequestInit]; - expect(url.endsWith(TARGET)).toBe(true); + // A declared same-origin api action reaches its target through the door + // (objectstack#27611). + expect(url.endsWith(`/api/v1/actions/${OBJECT_NAME}/export_tab`)).toBe(true); expect(init.method).toBe('POST'); expect(open).not.toHaveBeenCalledWith('about:blank', '_blank'); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx index 8025f28ab7c..49dacb7bd2e 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx @@ -413,7 +413,7 @@ describe('record page decision actions — a GROUP approver (objectui#3055)', () expect(decisionButton('approval_resubmit')).toBeNull(); }); - it('POSTs to the REQUEST, not to the business record it is opened on', async () => { + it('dispatches the decision on the REQUEST, not on the business record it is opened on', async () => { stubApprovalsApi(pendingRequest({ can_act: true, is_submitter: false, can_override: false })); renderRecordPage(); await waitFor(() => expect(decisionButton('approval_send_back')).toBeTruthy()); @@ -423,16 +423,18 @@ describe('record page decision actions — a GROUP approver (objectui#3055)', () await waitFor(() => expect(authFetchSpy).toHaveBeenCalled()); const [url, init] = authFetchSpy.mock.calls[0] as unknown as [string, RequestInit]; - // `{id}` resolves from the dispatch record — the request row. Resolving it - // from the host page's record would have aimed the decision at + // A declared same-origin api action goes through the action door + // (objectstack#27611), which resolves `{id}` in the target from the + // subject row it loads by `recordId` — the request row. Naming the host + // page's record would have aimed the decision at // `/approvals/requests/QIF202607310002/revise`. - expect(url).toContain(`/api/v1/approvals/requests/${REQUEST_ID}/revise`); + expect(url).toContain('/api/v1/actions/sys_approval_request/approval_send_back'); expect(url).not.toContain(RECORD_ID); - expect(url).not.toContain('%7B'); expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body)).recordId).toBe(REQUEST_ID); }); - it('folds a declared decision output into the nested `outputs` body', async () => { + it('sends a declared decision output as its `outputs.` param, for the door to fold', async () => { stubApprovalsApi(pendingRequest({ can_act: true, is_submitter: false, can_override: false })); renderRecordPage(); await waitFor(() => expect(decisionButton('approval_send_back')).toBeTruthy()); @@ -443,11 +445,15 @@ describe('record page decision actions — a GROUP approver (objectui#3055)', () const [, init] = authFetchSpy.mock.calls[0] as unknown as [string, RequestInit]; const body = JSON.parse(String(init.body)); - // The dotted `outputs.` param the bar synthesizes per request must - // arrive nested — that is the shape the decide routes read back as - // `vars..` (objectui#2955 / framework#3447). - expect(body).toMatchObject({ comment: 'looks good', outputs: { next_reviewer: 'u_plant_mgr' } }); - expect(body['outputs.next_reviewer']).toBeUndefined(); + // The dotted `outputs.` param the bar synthesizes per request rides + // the params bag to the action door (objectstack#27611), which folds it + // into the nested `outputs` the decide routes read back as + // `vars..` (objectui#2955 / framework#3447) when it assembles + // the target's body from the declaration. + expect(body).toEqual({ + recordId: REQUEST_ID, + params: { comment: 'looks good', 'outputs.next_reviewer': 'u_plant_mgr' }, + }); }); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.printPageHeaderActions-12906.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.printPageHeaderActions-12906.test.tsx index e8d948dcce9..7b1d7e06577 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.printPageHeaderActions-12906.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.printPageHeaderActions-12906.test.tsx @@ -184,7 +184,7 @@ async function openOverflow(scope: HTMLElement): Promise { } /** Click the declared action and hand back the one request it sent. */ -async function clickSubmit(scope: HTMLElement): Promise<{ url: string; method: string | undefined }> { +async function clickSubmit(scope: HTMLElement): Promise<{ url: string; method: string | undefined; body: unknown }> { authFetchSpy.mockClear(); fireEvent.click(within(scope).getByRole('button', { name: 'Submit for Sign-off' })); await waitFor(() => @@ -194,7 +194,7 @@ async function clickSubmit(scope: HTMLElement): Promise<{ url: string; method: s string, RequestInit, ]; - return { url: call[0], method: call[1]?.method }; + return { url: call[0], method: call[1]?.method, body: JSON.parse(String(call[1]?.body)) }; } beforeEach(() => { @@ -262,8 +262,11 @@ describe('RecordDetailView — a print page draws the record’s header actions it('(c) clicking the declared action reaches the record page’s action runtime with the request a non-print record page sends', async () => { await mountRecordPage([PRINT_PAGE]); const onPrintPage = await clickSubmit(theToolbar()); - expect(onPrintPage.url.endsWith(`/api/v1/data/${OBJECT_NAME}/${RECORD_ID}/submit`)).toBe(true); + // A declared same-origin api action goes through the action door, which + // resolves the target's `{id}` from the record named here (objectstack#27611). + expect(onPrintPage.url.endsWith(`/api/v1/actions/${OBJECT_NAME}/submit_signoff`)).toBe(true); expect(onPrintPage.method).toBe('POST'); + expect(onPrintPage.body).toEqual({ recordId: RECORD_ID, params: {} }); cleanup(); await mountRecordPage([]); diff --git a/ui/packages/app-shell/src/views/__tests__/PageView.relatedListActions-12870.test.tsx b/ui/packages/app-shell/src/views/__tests__/PageView.relatedListActions-12870.test.tsx index 1eb31d439e4..c53d625ec72 100644 --- a/ui/packages/app-shell/src/views/__tests__/PageView.relatedListActions-12870.test.tsx +++ b/ui/packages/app-shell/src/views/__tests__/PageView.relatedListActions-12870.test.tsx @@ -224,11 +224,15 @@ describe('objectui#12870 — an app page serves the record page’s related-list expect(rowMenuNames(menu)).toEqual(['change_role', 'remove']); expect(unserved()).toBeNull(); + // Declared same-origin api actions run through the action door, which + // resolves `{id}` from the row named by `recordId` (objectstack#27611). fireEvent.click(within(menu).getByTestId('row-action-change_role')); - await waitFor(() => expect(calledUrls()).toContain('/api/v1/members/m-1/role')); + await waitFor(() => expect(calledUrls()).toContain(`/api/v1/actions/${CHILD}/change_role`)); + const [, roleInit] = apiFetch.mock.calls.find(([url]) => String(url).endsWith('/change_role'))!; + expect(JSON.parse(String((roleInit as RequestInit).body)).recordId).toBe('m-1'); fireEvent.click(screen.getByTestId('related-toolbar-action-invite_user')); - await waitFor(() => expect(calledUrls()).toContain('/api/v1/org/invite')); + await waitFor(() => expect(calledUrls()).toContain(`/api/v1/actions/${CHILD}/invite_user`)); }); it('a list with no parent in scope draws no authored action, and says so', async () => { diff --git a/ui/packages/components/src/__tests__/action-bar.test.tsx b/ui/packages/components/src/__tests__/action-bar.test.tsx index 43b631be9ac..e00772640e1 100644 --- a/ui/packages/components/src/__tests__/action-bar.test.tsx +++ b/ui/packages/components/src/__tests__/action-bar.test.tsx @@ -409,8 +409,9 @@ describe('ActionBar (action:bar)', () => { * of going through `ActionEngine.getActionsForLocation`, so the engine's * capability gate never reached `list_toolbar`: an action declaring a * capability nobody holds rendered as a live button, with nothing behind it - * for a `type: 'api'` action pointed at a custom endpoint (the platform's - * action route — the source of the 403 — never sees that request). + * for a `type: 'api'` action whose `target` is on another origin (the + * platform action door — the source of the 403 — never sees that request; + * ADR-0066 D4 as amended brings a same-origin api action under it). */ describe('requiredPermissions capability gate', () => { const Bar = ({ actions, systemActions }: { actions?: any[]; systemActions?: any[] }) => { diff --git a/ui/packages/components/src/__tests__/page-header-capability-gate.test.tsx b/ui/packages/components/src/__tests__/page-header-capability-gate.test.tsx index 7d822dcf1c4..c8c055719ac 100644 --- a/ui/packages/components/src/__tests__/page-header-capability-gate.test.tsx +++ b/ui/packages/components/src/__tests__/page-header-capability-gate.test.tsx @@ -14,8 +14,9 @@ * `ActionEngine.getActionsForLocation`. So the engine's capability gate never * ran on the one surface those locations exist on: an action declaring a * capability nobody holds rendered as a live button, and the only thing behind - * it was the server's 403 — which covers platform action routes only, never a - * `type: 'api'` action pointed at a custom endpoint. + * it was the server's 403 — which covers the calls the platform action door + * dispatches only, never a `type: 'api'` action whose `target` is on another + * origin (ADR-0066 D4 as amended). * * The gate reads the ActionProvider's runner user (the same object the engine * reads), falls back to the ambient predicate scope, and fails OPEN when diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index a52cde46797..7cab4bf642b 100644 --- a/ui/packages/components/src/renderers/layout/containers.tsx +++ b/ui/packages/components/src/renderers/layout/containers.tsx @@ -2017,8 +2017,10 @@ export const PageHeaderActions: React.FC = ({ // `ActionEngine.getActionsForLocation`, so the engine's gate never ran on // the ONE surface `record_header` / `record_more` actions live on: a // button declaring a capability nobody holds rendered, and only the - // server's 403 stopped it — and only for platform action routes, never - // for a `type: 'api'` action pointed at a custom endpoint. Same rule as + // server's 403 stopped it — and only for a call the platform action door + // dispatches, never for a `type: 'api'` action whose `target` is on + // another origin (ADR-0066 D4 as amended: the door dispatches a declared + // same-origin api action too). Same rule as // the engine, so the two surfaces can't disagree: hide unless the caller // holds ALL declared capabilities. // diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 716888d064f..a14507a54a0 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -811,8 +811,10 @@ export const RelatedList: React.FC = ({ * [ADR-0066 D4 / objectui#9782] The `list_toolbar` set this header may draw, * with `requiredPermissions` mirrored as a UI hide. * - * `@objectstack/spec` declares the key as "enforced with 403 on the platform - * action route (script/flow/modal + MCP) and **mirrored as a UI hide**". The + * `@objectstack/spec` declares the key as "enforced with 403 on every call the + * platform action door (`POST /actions/:object/:action` and MCP `run_action`) + * dispatches, and **mirrored as a UI hide**" — a declared same-origin + * `type: api` action included (ADR-0066 D4 as amended). The * same bridge (`RelatedRecordActionsBridge.deriveActions`) feeds the child * object's `list_item` actions and these header buttons, and it filters on * `locations` alone — so before this the toolbar honoured `visible` and diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.toolbarCapabilityGate-9782.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.toolbarCapabilityGate-9782.test.tsx index 733308e85e7..8564ae0185c 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedList.toolbarCapabilityGate-9782.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.toolbarCapabilityGate-9782.test.tsx @@ -10,8 +10,10 @@ * objectui#9782 — the related-list TOOLBAR had no ADR-0066 D4 capability gate. * * `@objectstack/spec` (`packages/spec/src/ui/action.zod.ts`) declares - * `requiredPermissions` as "enforced with 403 on the platform action route - * (script/flow/modal + MCP) and **mirrored as a UI hide**". The header of a + * `requiredPermissions` as "enforced with 403 on every call the platform action + * door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and + * **mirrored as a UI hide**" — a declared same-origin `type: api` action + * included (ADR-0066 D4 as amended). The header of a * related list evaluated only each action's `visible` CEL predicate, so a * `list_toolbar` action declaring a capability the caller does not hold * rendered anyway — one surface over from `EnvironmentListToolbar`, which diff --git a/ui/packages/plugin-grid/src/__tests__/bulkActionCapabilityGate.test.tsx b/ui/packages/plugin-grid/src/__tests__/bulkActionCapabilityGate.test.tsx index 32d0325a6f5..52b477adad3 100644 --- a/ui/packages/plugin-grid/src/__tests__/bulkActionCapabilityGate.test.tsx +++ b/ui/packages/plugin-grid/src/__tests__/bulkActionCapabilityGate.test.tsx @@ -16,8 +16,9 @@ * kebab); this one never did. The consequence was a contradiction on one * screen: the same action was hidden from an unentitled user in the row kebab * and offered to them in the bulk bar the moment they ticked a checkbox — and - * for a `type: 'api'` action pointed at a custom endpoint, nothing behind it - * was guaranteed to say no. + * for a `type: 'api'` action whose `target` is on another origin, which the + * platform action door never dispatches (ADR-0066 D4 as amended), nothing + * behind it was guaranteed to say no. * * The rule is the engine's, verbatim: an empty declaration always passes, * several are AND-ed, and unknown capabilities fail OPEN (the server is the From bd2818b4282c9160cc39c1df514f01b4f518b4a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:23:19 +0000 Subject: [PATCH 3/4] fix(console): read the door's 2xx once, at the direct fetch's payload 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 --- .../src/hooks/useConsoleActionRuntime.tsx | 25 ++++++-------- .../app-shell/src/utils/apiActionDoor.ts | 23 +++++++++++-- ...ordDetailView.apiActionDoor-27611.test.tsx | 34 ++++++++++++++++--- .../app-shell/src/views/RecordDetailView.tsx | 9 +++-- 4 files changed, 63 insertions(+), 28 deletions(-) diff --git a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 16b483f2b6d..a17e2680bbf 100644 --- a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -40,13 +40,7 @@ import type { ServerActionHandler, ToastHandler, } from '@object-ui/core'; -import { - actionErrorDetail, - captureUpdateUndoData, - interpretActionResponse, - resolveRecordIdParamSeed, - resolveServerActionRecordId, -} from '@object-ui/core'; +import { actionErrorDetail, captureUpdateUndoData, resolveRecordIdParamSeed, resolveServerActionRecordId } from '@object-ui/core'; import { useActionModal } from './useActionModal.js'; import { ActionConfirmDialog, type ConfirmDialogState } from '../views/ActionConfirmDialog.js'; import { ActionParamDialog, type ParamDialogState } from '../views/ActionParamDialog.js'; @@ -59,7 +53,12 @@ import { entitlementDialogFromError, type EntitlementDialogSpec } from '../envir import { resolvePageVarTokens } from '../utils/resolvePageVarTokens.js'; import { launchConsoleFlow } from '../utils/flowLaunch.js'; import { apiActionFailureMessage } from '../utils/flowResponse.js'; -import { findDeclaredApiAction, isSameOriginApiTarget, sendApiActionThroughDoor } from '../utils/apiActionDoor.js'; +import { + findDeclaredApiAction, + isSameOriginApiTarget, + readApiActionDoorSuccess, + sendApiActionThroughDoor, +} from '../utils/apiActionDoor.js'; import { createConsoleServerActionHandler } from '../utils/consoleServerAction.js'; import { withNewTabUrlFastPath } from '../utils/newTabUrlFastPath.js'; import { modalTargetRefusalMessage } from '../utils/modalTargetDiagnostics.js'; @@ -483,14 +482,10 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons }); if (!door.answered) return door.result; if (!door.res.ok) return refused(door.res, door.json); - // The door answers the target's payload as its own `data`, already - // unwrapped from the target's `{ success, data }` — the level the - // direct path below hands back. A target's HTTP 200 rejection rides - // there, and the `/actions` envelope rule reads it. - const outcome = interpretActionResponse(door.res, door.json, `Action "${action.name}"`); - if (!outcome.ok) return { success: false, error: outcome.error }; + const answer = readApiActionDoorSuccess(door, `Action "${action.name}"`); + if (!answer.ok) return { success: false, error: answer.error }; if (action.refreshAfter !== false) refresh(); - return { success: true, data: outcome.envelope, reload: action.refreshAfter !== false }; + return { success: true, data: answer.data, reload: action.refreshAfter !== false }; } } diff --git a/ui/packages/app-shell/src/utils/apiActionDoor.ts b/ui/packages/app-shell/src/utils/apiActionDoor.ts index fa8e5963f11..82d30ed8bd0 100644 --- a/ui/packages/app-shell/src/utils/apiActionDoor.ts +++ b/ui/packages/app-shell/src/utils/apiActionDoor.ts @@ -52,11 +52,12 @@ * `{ recordId, params }` body and the `_rowRecord` strip, and app-shell names * that route nowhere else (`actions-envelope.ratchet.test.ts`). Its ANSWER is * the forwarded target's, so it is read by the host's api rules — the - * entitlement dialog, `apiActionFailureMessage`, the `{ success, data }` - * unwrap and `refreshAfter` — and the raw answer is handed back here for that. + * entitlement dialog, `apiActionFailureMessage` and `refreshAfter` — and the + * raw answer is handed back here for that. A 2xx is read once, here, for both + * hosts ({@link readApiActionDoorSuccess}). */ -import { createServerActionHandler } from '@object-ui/core'; +import { createServerActionHandler, interpretActionResponse } from '@object-ui/core'; import type { ActionDef, ActionResult, ServerActionFetch } from '@object-ui/core'; /** @@ -168,3 +169,19 @@ export async function sendApiActionThroughDoor(input: ApiActionDoorInput): Promi const result = await dispatch({ ...input.action, objectName: input.objectName, params: input.params }); return answered ? { answered: true, ...answered } : { answered: false, result }; } + +/** + * Read the door's 2xx answer. The door answers the target's payload as its own + * `data`, already unwrapped from the target's `{ success, data }`, so `data` + * here is what the direct fetch hands back for a target that answers bare (a + * stock better-auth body): a `resultDialog` path such as `client_secret` reads + * alike on both roads. A target's HTTP 200 rejection rides inside that `data`, + * and the `/actions` envelope rule reads it. + */ +export function readApiActionDoorSuccess( + answer: { res: { ok: boolean; status: number }; json: unknown }, + label: string, +): { ok: true; data: unknown } | { ok: false; error: string } { + const outcome = interpretActionResponse(answer.res, answer.json, label); + return outcome.ok ? { ok: true, data: outcome.envelope } : { ok: false, error: outcome.error ?? `${label} failed` }; +} diff --git a/ui/packages/app-shell/src/views/RecordDetailView.apiActionDoor-27611.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.apiActionDoor-27611.test.tsx index bee321fee10..e7f524cd07c 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.apiActionDoor-27611.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.apiActionDoor-27611.test.tsx @@ -104,6 +104,14 @@ const RECALC = { recordIdParam: 'recordId', }; const PING = { name: 'ping_hook', label: 'Ping', type: 'api', target: 'https://hooks.example.test/ping' }; +/** A one-shot reveal on a stock better-auth route, which answers bare. */ +const ROTATE = { + name: 'rotate_secret', + label: 'Rotate Secret', + type: 'api', + target: '/api/v1/auth/oauth2/client/rotate-secret', + resultDialog: { fields: [{ path: 'client_secret', label: 'New Client Secret', format: 'secret' }] }, +}; const CLOSE_SUBTASK = { name: 'close_subtask', label: 'Close', @@ -119,7 +127,7 @@ const OBJECTS = [ id: { type: 'text', label: 'Id' }, title: { type: 'text', label: 'Title' }, }, - actions: [RECALC, PING], + actions: [RECALC, PING, ROTATE], }, { name: 'showcase_subtask', label: 'Subtask', fields: { id: { type: 'text', label: 'Id' } }, actions: [CLOSE_SUBTASK] }, ]; @@ -202,13 +210,29 @@ afterEach(() => { }); describe('RecordDetailView api handler — a declared same-origin click goes through the action door (objectstack#27611)', () => { - it('a header click sends this page\'s record id to the door, and its answer back as the page always has', async () => { - const answer = { success: true, data: { recalculated: 1 } }; - const { out, url, body } = await clickOnPage(RECALC, 200, answer); + it('a header click sends this page\'s record id to the door, and hands back the target\'s payload', async () => { + const { out, url, body } = await clickOnPage(RECALC, 200, { success: true, data: { recalculated: 1 } }); expect(url.endsWith(`${DOOR}${OBJECT_NAME}/${RECALC.name}`)).toBe(true); expect(body).toEqual({ recordId: RECORD_ID, params: {} }); - expect(out).toEqual({ success: true, data: answer, reload: false }); + expect(out).toEqual({ success: true, data: { recalculated: 1 }, reload: false }); + }); + + it('a bare target answer comes back at the level the direct fetch handed back, so a resultDialog path still reads', async () => { + // The door wraps a bare (stock better-auth) body as its `data`. + const { out, url } = await clickOnPage(ROTATE, 200, { success: true, data: { client_secret: 's3cr3t' } }); + + expect(url.endsWith(`${DOOR}${OBJECT_NAME}/${ROTATE.name}`)).toBe(true); + expect(out).toEqual({ success: true, data: { client_secret: 's3cr3t' }, reload: false }); + }); + + it('a target\'s HTTP 200 rejection, relayed in the door\'s data, is a failure', async () => { + const { out } = await clickOnPage(RECALC, 200, { + success: true, + data: { success: false, error: { code: 'VALIDATION_ERROR', message: 'Estimate is locked' } }, + }); + + expect(out).toEqual({ success: false, error: 'Estimate is locked' }); }); it('a switched-off action answers the door\'s 409 ACTION_DISABLED as its sentence', async () => { diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 3b968e341e7..194c6992992 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -48,7 +48,7 @@ import { resolveHostAppSegment } from '../utils/appRoute.js'; import { hostInConsolePath } from '../utils/hostInConsolePath.js'; import { launchConsoleFlow } from '../utils/flowLaunch.js'; import { apiActionFailureMessage } from '../utils/flowResponse.js'; -import { findDeclaredApiAction, isSameOriginApiTarget, sendApiActionThroughDoor } from '../utils/apiActionDoor.js'; +import { findDeclaredApiAction, isSameOriginApiTarget, readApiActionDoorSuccess, sendApiActionThroughDoor } from '../utils/apiActionDoor.js'; import { useNavigationContext, useRecordBreadcrumbTitle } from '../context/NavigationContext.js'; // Audit provenance renders as the one-line ; the other // framework-injected bookkeeping columns are hidden from the body outright. @@ -1462,11 +1462,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri if (!door.res.ok) { return { success: false, error: apiActionFailureMessage(door.res, door.json, `HTTP ${door.res.status}`, language) }; } - // The door's body, as this page has always handed back its target's: - // byte for byte the target's own answer when the target answers the - // platform `{ success, data }` envelope. + const answer = readApiActionDoorSuccess(door, `Action "${action.name}"`); + if (!answer.ok) return { success: false, error: answer.error }; if (action.refreshAfter === true) notifyRecordChanged(); - return { success: true, data: door.json, reload: action.refreshAfter === true }; + return { success: true, data: answer.data, reload: action.refreshAfter === true }; } // Interpolate `{field}` tokens in the target URL. Source: the stashed From 8f25dc13137ad7c959fd94aa5838f6e4c1898ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:55:55 +0000 Subject: [PATCH 4/4] test(console): two door pins assert the door road, not only the reading 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 --- .../useConsoleActionRuntime.apiActionDoor-27611.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx index ccef80a7f66..b7106fe1feb 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx @@ -184,6 +184,7 @@ describe('objectstack#27611 — a declared same-origin api click goes through th const out = await click(result, { ...BAN, params: { banReason: 'spam', _rowRecord: ROW } }); + expect(sent().url.endsWith(`${DOOR}sys_user/ban_user`)).toBe(true); expect(out).toEqual({ success: true, data: { user: { id: 'u_target', banned: true } }, reload: true }); expect(onRefresh).toHaveBeenCalledTimes(1); }); @@ -298,6 +299,7 @@ describe('objectstack#27611 — what the door answers once objectstack#27738 lan const { result } = mount('sys_environment'); expect(await click(result, { ...CREATE_ENV, params: { displayName: 'x' } })).toEqual({ success: false }); + expect(sent().url.endsWith(`${DOOR}sys_environment/create_environment`)).toBe(true); render(<>{result.current.dialogs}); expect(await screen.findByText(/You are using 3 of 3 development environments/)).toBeTruthy(); });