|
| 1 | +--- |
| 2 | +"@objectstack/runtime": minor |
| 3 | +"@objectstack/lint": minor |
| 4 | +"@objectstack/spec": patch |
| 5 | +--- |
| 6 | + |
| 7 | +fix(runtime,lint): `action.body` binds a handler only for `type: 'script'` (#4352) |
| 8 | + |
| 9 | +`ActionSchema.body` has always described itself as "Only used when type is |
| 10 | +`script`", and its JSDoc went further — "Only meaningful when |
| 11 | +`type === 'script'`. When set, the runtime invokes the body inside the sandbox |
| 12 | +… and ignores `target`." The runtime read none of it: |
| 13 | +`actionBodyRunnerFactory` bound a handler the moment `body` parsed, and |
| 14 | +`collectBundleActions` collected any named action. A `type: 'url'` action |
| 15 | +carrying a leftover `body` was therefore registered in the action registry and |
| 16 | +executed in the sandbox — reachable through |
| 17 | +`POST /api/v1/actions/:object/:action` and through |
| 18 | +`ql.object(o).execute(name)`, and counted by the governance inventory as a live |
| 19 | +handler. |
| 20 | + |
| 21 | +Declared ≠ enforced, in the shape that is hardest to debug: an author flips |
| 22 | +`type` from `script` to `url`, reasonably concludes the body is now dead code, |
| 23 | +and it keeps running with nothing anywhere saying so. |
| 24 | + |
| 25 | +**Behaviour change.** `body` now runs only under `type: 'script'`: |
| 26 | + |
| 27 | +| Action | Before | After | |
| 28 | +|:--|:--|:--| |
| 29 | +| `type: 'script'` + `body` | body runs | unchanged — body runs | |
| 30 | +| `type` omitted + `body` | body runs | unchanged — body runs (`ActionType.default('script')`) | |
| 31 | +| `type: 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'` + `body` | body ran | **no handler is bound**; the refusal is logged | |
| 32 | + |
| 33 | +Only an action that **explicitly** declares a non-`script` type *and* carries a |
| 34 | +`body` changes behaviour. An omitted `type` still means `script`, because the |
| 35 | +collectors walk raw bundle objects — a `strict: false` `defineStack` or a legacy |
| 36 | +`manifest.actions[]` never passes through `ActionSchema`, so the schema's own |
| 37 | +default has to be applied at the gate rather than assumed to have been applied |
| 38 | +already. |
| 39 | + |
| 40 | +**FROM → TO.** If you have an action whose body you want to keep running, set |
| 41 | +`type: 'script'` and move the navigation/dispatch target elsewhere; if you want |
| 42 | +the target behaviour, delete the now-inert `body`: |
| 43 | + |
| 44 | +```diff |
| 45 | + { |
| 46 | + name: 'open_portal', |
| 47 | +- type: 'url', |
| 48 | ++ type: 'script', |
| 49 | + target: '/portal', |
| 50 | + body: { language: 'js', source: "await ctx.api.object('lead').update(…)", capabilities: ['api.write'] }, |
| 51 | + } |
| 52 | +``` |
| 53 | + |
| 54 | +The refusal is **not** silent — silence would only relocate the invisibility the |
| 55 | +issue is about. `actionBodyRunnerFactory` logs a warning naming the action, its |
| 56 | +declared `type`, and both fixes. |
| 57 | + |
| 58 | +Authoring-time rejection of the same contradiction already shipped in #4438 |
| 59 | +(`ActionSchema` rejects `body` alongside a non-`script` `type`), so what remains |
| 60 | +reachable here is data at rest published before that gate existed, plus bundles |
| 61 | +that never parsed. This release closes that half. New tests also pin that the |
| 62 | +**publish gate resolves to the rejecting schema** — through |
| 63 | +`getMetadataTypeSchema('action')` and `ObjectSchema.actions` — so a re-point of |
| 64 | +either registration cannot silently reopen the hole while the schema's own unit |
| 65 | +tests stay green. |
| 66 | + |
| 67 | +`@objectstack/lint`'s `validate-action-body-writes` filters by `type` again. |
| 68 | +#4344 deliberately made that rule type-blind on the grounds that "the runtime |
| 69 | +binds a handler from `action.body` alone … checking what executes beats checking |
| 70 | +what the schema says should" — true then, and the comment predicted its own |
| 71 | +revision. Execution and declaration are the same set again, so a non-`script` |
| 72 | +body no longer produces write-set advice about writes that provably never |
| 73 | +happen; the publish gate names that metadata's real defect (`type`) with its own |
| 74 | +prescription. |
| 75 | + |
| 76 | +`collectBundleActions` stays deliberately type-blind: it feeds governance |
| 77 | +surfaces that must enumerate every declared action, bound or not, and the other |
| 78 | +bind path (`engine.setDefaultActionRunner`, for Studio-authored actions) never |
| 79 | +walks it. The gate lives at the single point where a `body` becomes an |
| 80 | +executable handler, so there is no second copy of the rule to drift. |
0 commit comments