Skip to content

Commit 9aa7b63

Browse files
baozhoutaoclaude
andcommitted
Merge origin/main into fix/2955-decision-outputs
Only conflict: the import block in RecordDetailView — main added `interpretActionResponse` (#2967) on the line this branch added `decisionOutputParams`. Both kept; the two changes touch disjoint parts of the file (the action-envelope reader vs the approval decision actions). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 parents 3a6ec4f + 52ec79d commit 9aa7b63

48 files changed

Lines changed: 2203 additions & 125 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(actions): a failed server action no longer reports as success (green toast) — objectstack#3913
6+
7+
`useConsoleActionRuntime.serverActionHandler` — the console's **main** action
8+
path (list toolbars, row actions, page actions) — decided success from
9+
`res.ok` and the OUTER envelope only:
10+
11+
```ts
12+
if (!res.ok || (json && json.success === false)) { /* failure */ }
13+
```
14+
15+
A server older than objectstack#3913 reports a handler failure as HTTP **200**
16+
with the failure nested one level down:
17+
18+
```json
19+
{"success":true,"data":{"success":false,"error":"Action 'log_call' on object '*' not found"}}
20+
```
21+
22+
Both guards pass, so the action was reported as completed: the ActionRunner
23+
fired its green "completed" toast, the list refreshed, and the real error was
24+
swallowed. `RecordDetailView`'s copy of the same handler already inspected the
25+
inner envelope; the shared runtime now does too, and the marketplace install
26+
call (`marketplaceApi.installPackage`), which had the identical hole and could
27+
report a package as installed when it was not.
28+
29+
Current servers answer a failed action with a real HTTP status, which `!res.ok`
30+
catches first — the inner-envelope check is what keeps the console honest
31+
against a runtime that has not been upgraded yet.
32+
33+
**Also fixed:** with objectstack#3913 the failure body is
34+
`{success: false, error: {message, code}}`. `RecordDetailView` read `json?.error`
35+
raw and would have handed that **object** to `toast.error()` as a React child,
36+
crashing the page (React #31) — the exact failure the console runtime's
37+
`errorDetail` helper existed to prevent. That helper is now a shared util
38+
(`utils/actionErrorDetail`) and both call sites go through it, so a nested
39+
`{message}` always resolves to a string.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
"@object-ui/components": patch
4+
"@object-ui/plugin-detail": patch
5+
"@object-ui/plugin-grid": patch
6+
"@object-ui/react": patch
7+
---
8+
9+
fix(actions): apply the ADR-0066 D4 capability gate on every action surface (framework#3923)
10+
11+
An action declaring `requiredPermissions` is supposed to be one declaration with
12+
two enforcement surfaces: 403 on the server, hidden button in the UI. The UI half
13+
only ever ran inside `ActionEngine.getActionsForLocation` — and the surfaces
14+
`record_header`, `record_more`, `list_item` and `list_toolbar` actually render on
15+
do not go through the engine. They filter their own action lists. So a button
16+
declaring a capability nobody holds rendered, live and clickable, on the record
17+
header, in every grid row menu, and on the list toolbar. For a `type: 'api'`
18+
action pointed at a self-authored endpoint, nothing else was checking either: the
19+
platform's action route (which is where the 403 comes from) never sees that
20+
request.
21+
22+
`page:header`, `action:bar` (business *and* `systemActions`) and the grid's
23+
`RowActionMenu` now apply the same gate, via a shared `useCapabilityGate()` so
24+
the surfaces cannot drift apart. The rule is the engine's, unchanged: hide unless
25+
the caller holds **all** declared capabilities; an empty held set is "holds
26+
nothing" and gates; **unknown** — no action runtime, no resolved capabilities —
27+
fails OPEN, because the server is the authority and hiding a permitted user's
28+
button on missing client data is the worse failure.
29+
30+
The record surface was also feeding the gate nothing to work with.
31+
`RecordDetailView` mounts its own `<ActionProvider>`, which shadows the shell's
32+
for every action on that page, and seeded it with identity only — no
33+
`systemPermissions`. Since unknown fails open, that alone un-gated every
34+
`record_header` / `record_more` / `record_section` action on the one page those
35+
locations exist on. It now forwards the caller's resolved capabilities (and only
36+
once they have actually resolved, so a standalone embed without a
37+
`PermissionProvider` keeps failing open rather than hiding everything).
38+
39+
`useRecordEditable`'s record-level explain probe went out on a bare
40+
`fetch(..., { credentials: 'include' })`. A bearer-token session carries its
41+
credential in the `Authorization` header, not a cookie, so the probe came back
42+
401 on a perfectly valid admin session and the verdict silently failed open —
43+
the hook was inert in exactly the deployments it was written for. It now rides
44+
the host's authenticated fetch (`SchemaRendererProvider`'s `apiFetch`), falling
45+
back to the global one for standalone embeds.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(actions): one source for the `/actions` envelope rule, and `redirectUrl` finally works (objectstack#3913 follow-up)
6+
7+
The `/actions` response wraps **twice** — the route's own `{success, data}`
8+
inside the dispatcher's — and a failure has three shapes, only one of which
9+
`res.ok` catches. That rule was hand-rolled in two places
10+
(`useConsoleActionRuntime.serverActionHandler` and `RecordDetailView`'s copy of
11+
the same handler), and the two drifted. Four hand-rolled copies produced three
12+
distinct bugs:
13+
14+
1. **A failed action reported as success** — the copy that didn't inspect the
15+
inner envelope was the console's *main* action path, so a failure fired the
16+
green "completed" toast on every list and page surface (fixed in #2963).
17+
2. **React #31 crash** — the nested `{message, code}` object handed to
18+
`toast.error()` as a React child (fixed in #2963).
19+
3. **`redirectUrl` never fired***fixed here.*
20+
21+
Both handlers now call `interpretActionResponse` from `utils/actionResponse`,
22+
and a ratchet test (`actions-envelope.ratchet.test.ts`) fails if a third
23+
hand-rolled copy appears.
24+
25+
## `redirectUrl` was unreachable
26+
27+
A script action can return `{ redirectUrl: 'https://…' }` to ask the console to
28+
open a URL. Both handlers read it off `body.data` — the **action** envelope,
29+
one level too shallow:
30+
31+
```
32+
{ success: true, data: { success: true, data: { redirectUrl: '…' } } }
33+
^^^^ read here ^^^^ actually lives here
34+
```
35+
36+
`body.data` is constructed by the server and only ever holds `success` / `data`,
37+
so `body.data.redirectUrl` was **always** undefined — the convention could never
38+
fire, and no handler could work around it. An `opensInNewTab` action was worse
39+
than a no-op: it pre-opens a tab on a spinner page for popup-blocker safety, and
40+
with no redirect to drive it to, that tab sat on the spinner forever.
41+
42+
`ActionResult.data` still carries the **action envelope**, unchanged — some
43+
`resultDialog` field paths in the wild may have adapted to that depth, so it is
44+
not silently re-pointed here.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@object-ui/plugin-report": minor
3+
---
4+
5+
feat(report): carry a report's `order` into the dataset selection (framework#3916)
6+
7+
`@objectstack/spec` 17 gave reports an ordering declaration — `ReportSchema.order`
8+
(and `blocks[].order` for a joined report): a list of `{ by, direction }` keys,
9+
most significant first. The framework executor applies it. `DatasetReportRenderer`
10+
built the selection it posts and never carried the declaration into it, so an
11+
authored `order` reached no query and did nothing.
12+
13+
`useDatasetRows` — the single fetch choke point behind every report path — now
14+
takes the lowered ordering, and all four call sites supply it: the grouped table,
15+
the embedded chart, the matrix cross-tab, and each joined block.
16+
17+
- **Lowering.** `readOrder()` turns the authored list into
18+
`DatasetSelection.order`, the array's element order becoming the object's key
19+
insertion order (which is how sort significance is expressed on the wire). It
20+
is permissive about its input, like the neighbouring `readNames()` — stored
21+
report JSON crosses the repo boundary and may lag the schema, so an entry with
22+
no usable `by` is dropped rather than thrown. An absent or entirely-unusable
23+
list yields `undefined`, so the field is OMITTED and the server's own defaults
24+
still apply: a selected time dimension comes back chronological with nothing
25+
declared.
26+
27+
Kept local rather than importing spec's `reportSelectionOrder` — the pinned
28+
`^17.0.0-rc.0` predates that export. Swap it for the import on the next bump.
29+
30+
- **Scoped per sub-selection.** A report's `order` is validated against its
31+
WHOLE selection, but this renderer issues narrower queries from it: the chart
32+
plots one dimension × one measure, and the flat-table path drops the matrix
33+
across-dimensions. The server rejects an order key naming nothing the
34+
selection projects (a deliberate 400), so forwarding the full list would turn
35+
a valid report into a failed chart. Keys outside a sub-selection are dropped
36+
at the choke point instead. Nothing is masked: the schema already validated
37+
every key against `rows``columns``values`, so the only keys that can be
38+
lost are ones the narrower query genuinely has no column for.
39+
40+
- **Part of the refetch key.** The ordering changes the ROWS the server returns,
41+
not just their presentation, so it joins the `useDatasetRows` signature — an
42+
ordering edited from asc to desc refetches instead of re-rendering the stale
43+
grid.
44+
45+
- **Matrix across-axis.** `colHeaders` are collected in row-arrival order, so
46+
ordering the rows by the across dimension is what makes the columns read
47+
left-to-right in that order. Ordering rides on the primary query only; the
48+
server drops it for the totals sub-queries by design.
49+
50+
Ordering stays server-side throughout — never a client-side re-sort, which would
51+
order the page rather than the query and could not sort by a derived measure at
52+
all.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@object-ui/fields": minor
3+
"@object-ui/i18n": minor
4+
---
5+
6+
fix(fields): the sharing-criteria builder stops calling an empty criteria "All records" (objectstack#3896)
7+
8+
`FilterConditionField` renders `sys_sharing_rule.criteria_json`. With no
9+
criteria it displayed **"All records"**, and `filterGroupToMongo` carried a
10+
matching `// empty = match all` comment. That was describing a bug as a
11+
feature: a sharing rule with no predicate was stored as `criteria_json: null`
12+
and evaluated as `find(object, { filter: {} })` under the system context —
13+
every record of the object, granted to the recipient. `SharingRuleSchema` had
14+
always forbidden the shape ("never seeded as a permissive match-all",
15+
ADR-0049); the REST and data-API entries just never checked.
16+
17+
objectstack#3896 closes those entries: the server now refuses to save a rule
18+
whose criteria would match everything, and one already stored shares nothing.
19+
This is the renderer catching up.
20+
21+
- **The empty read-only state now says the rule shares nothing**, in
22+
`destructive` styling — key renamed `fields.filterCondition.allRecords`
23+
`fields.filterCondition.noCriteria`, retranslated across all ten locales.
24+
Nothing else read the old key.
25+
- **A new `fields.filterCondition.criteriaRequired` hint** renders under the
26+
builder (and the JSON editor) while the criteria is empty. The server's
27+
rejection is precise but only arrives as a toast *after* Save; this says it
28+
while the admin is still looking at the empty builder.
29+
- **`isMatchAllCriteria` is exported** — a client-side mirror of the server
30+
predicate covering `{}`, `[]`, and the vacuous combinators (`{ $and: [] }`,
31+
`{ $or: [{}] }`), conservative in the same direction. The server stays
32+
authoritative; this only decides whether to show the hint.
33+
34+
Unparsable JSON keeps its own `invalidJson` message and does **not** also
35+
collect the empty-criteria hint.
36+
37+
Note for anyone wiring this end-to-end: the Criteria field is not marked
38+
`required` in the object metadata, deliberately — `sys_sharing_rule.criteria_json`
39+
is nullable in deployed tenants, so `required: true` would only produce a
40+
destructive `NOT NULL` migration that those nulls block. The invariant lives in
41+
the server's write guards; this change makes the UI stop contradicting it.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* objectstack#3913 ratchet — every `/api/v1/actions` caller goes through
6+
* `interpretActionResponse`.
7+
*
8+
* The bug this guards: the `/actions` response wraps TWICE (the route's own
9+
* `{success, data}` inside the dispatcher's), and a failure has three shapes
10+
* only one of which `res.ok` catches. Two copies of that rule existed — the
11+
* shared console runtime and RecordDetailView's — and they drifted: one learned
12+
* to inspect the inner envelope, the other did not. The one that did not was
13+
* the console's MAIN action path, so a failed action fired a green "completed"
14+
* toast on every list and page surface while the real error was swallowed.
15+
* `marketplaceApi.installPackage` had the same hole and could report a package
16+
* as installed when it was not.
17+
*
18+
* Reading the envelope by hand is the anti-pattern, not any particular way of
19+
* reading it wrong: four hand-rolled copies produced three different bugs
20+
* (missed inner failure, a `{message}` object handed to `toast.error()` as a
21+
* React child → React #31, and `redirectUrl` read one level too shallow so it
22+
* never fired). One helper, one rule.
23+
*
24+
* If this fails: don't hand-roll the check. Import `interpretActionResponse`
25+
* from `utils/actionResponse` — or, better, call the action through
26+
* `@objectstack/client`, which folds every shape into `{ success, data?, error? }`.
27+
*/
28+
29+
import { describe, it, expect } from 'vitest';
30+
import { readdirSync, readFileSync, statSync } from 'node:fs';
31+
import path from 'node:path';
32+
import { fileURLToPath } from 'node:url';
33+
34+
const here = path.dirname(fileURLToPath(import.meta.url));
35+
const appShellSrc = here;
36+
37+
/** The helper that owns the rule — exempt from its own guard. */
38+
const OWNER = path.join(appShellSrc, 'utils', 'actionResponse.ts');
39+
40+
/** Files allowed to name the route without going through the helper. */
41+
const EXEMPT = new Set<string>([
42+
OWNER,
43+
// The cloud marketplace install posts to a *cloud* origin's action route
44+
// and consumes `InstallResponse`, not `ActionResult`. It still has to
45+
// honour the envelope, and does — covered by its own tests — but it is not
46+
// an ActionRunner handler, so it does not use this helper.
47+
path.join(appShellSrc, 'console', 'marketplace', 'marketplaceApi.ts'),
48+
]);
49+
50+
function walk(dir: string, out: string[] = []): string[] {
51+
for (const entry of readdirSync(dir)) {
52+
if (entry === 'node_modules' || entry === 'dist' || entry === '__tests__') continue;
53+
const full = path.join(dir, entry);
54+
if (statSync(full).isDirectory()) walk(full, out);
55+
else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full);
56+
}
57+
return out;
58+
}
59+
60+
/** A source file that POSTs to the action route. */
61+
const ROUTE = /\/api\/v1\/actions\//;
62+
63+
/**
64+
* Comments name the route freely while explaining the routing rules (e.g.
65+
* ConsoleShell's note on why `modal` stays client-side). Only CODE counts.
66+
*/
67+
function stripComments(src: string): string {
68+
return src
69+
.replace(/\/\*[\s\S]*?\*\//g, '')
70+
.replace(/(^|[^:])\/\/.*$/gm, '$1');
71+
}
72+
73+
describe('objectstack#3913 ratchet — /actions callers use interpretActionResponse', () => {
74+
const callers = walk(appShellSrc)
75+
.filter((f) => !EXEMPT.has(f) && ROUTE.test(stripComments(readFileSync(f, 'utf8'))));
76+
77+
const offenders = callers
78+
.filter((f) => !readFileSync(f, 'utf8').includes('interpretActionResponse'))
79+
.map((f) => path.relative(appShellSrc, f));
80+
81+
it('no app-shell file interprets an /actions response by hand', () => {
82+
expect(offenders).toEqual([]);
83+
});
84+
85+
it('still guards something — the known callers are present', () => {
86+
// A ratchet that matches nothing passes vacuously forever. Pin that the
87+
// two handlers it exists for are actually being scanned.
88+
expect(callers.map((f) => path.relative(appShellSrc, f))).toEqual(
89+
expect.arrayContaining([
90+
path.join('hooks', 'useConsoleActionRuntime.tsx'),
91+
path.join('views', 'RecordDetailView.tsx'),
92+
]),
93+
);
94+
});
95+
});

packages/app-shell/src/console/marketplace/marketplaceApi.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,20 @@ export async function installPackage(input: {
248248
});
249249
let payload: any = null;
250250
try { payload = await res.json(); } catch { /* empty */ }
251-
if (!res.ok) {
251+
// A server older than objectstack#3913 reports a failed install action as
252+
// HTTP 200 `{success: true, data: {success: false, error}}` — checking only
253+
// `res.ok` reported a package as installed when it was not. Current servers
254+
// answer with a real status, which `!res.ok` catches first.
255+
const inner = payload?.data;
256+
const innerFailed = !!inner && typeof inner === 'object' && inner.success === false;
257+
if (!res.ok || innerFailed) {
252258
const code = payload?.code ?? payload?.error?.code ?? `HTTP_${res.status}`;
253-
const message = payload?.error ?? payload?.message ?? res.statusText;
259+
// `error` is a nested `{code, message}` object on the current wire and a
260+
// plain string on the older one — read the message out of both before
261+
// falling back, or a real explanation degrades into a bare status code.
262+
const message = innerFailed
263+
? (inner.error ?? res.statusText)
264+
: (payload?.error?.message ?? payload?.error ?? payload?.message ?? res.statusText);
254265
const err = new Error(typeof message === 'string' ? message : `${code}`);
255266
(err as any).code = code;
256267
(err as any).status = res.status;

0 commit comments

Comments
 (0)