Skip to content

Commit b529a5c

Browse files
feat(spec)!: a page action row's url target is judged on the executor the runner runs, its name leg included (#23289)
Part of #23168 Clause-②: no (narrowing) The fifth door of ruling A+'s url-target family (objectstack-ai/objectui#12191: the scheme set "is stated once in the contract and the runtime fulfils exactly that"), on the card's **name leg**, the half triage `6139317450` ruled. A page action row's `target` is now judged against the executor the console's action runner actually runs. One grammar, `navigation-target.ts`'s `navigationTargetRefusal`, through the url-target refinement PR #23101 added; no second URL grammar. **This PR leaves #23168 open.** The card's other half, carrying `ActionSchema`'s "`onSuccess` only beside `api` / `script`" to the rows' effective executor (position 2, `6138882596`), is not in this diff. The dispatch's assumption 4 has a fork clause, and measurement tripped it: two objectui producers write a row with `onSuccess` beside a non-`api` / non-`script` executor. That half goes back to the PM for a decision. See the section below. ## What changed - **`effectiveRowExecutor(row, executorKey)`** (`packages/spec/src/ui/component.zod.ts`) states the runner's resolution once: the row's executor key, or its `name` when that key is omitted or empty. It uses `||`, as the runner writes it, not the `??` the triage text transcribes. An empty `actionType` falls through to `name` at the click (`'' || 'url'` is `'url'`), so it falls through here too. The resolution-order pins hold this. - **`ROW_URL_NAVIGATOR_EXECUTORS`** is `{ 'url', 'navigation' }`, the two `builtinExecutors` rows that reach `navigateTo`. - **`refuseUnopenableRowUrlTarget`** judges `target` whenever the effective executor is one of those two, whichever key spelled it. This applies on `action:button`, on `action:icon`, and on every `action:group` / `action:menu` member. The refusal is `navigationTargetRefusal`'s own text. Only the subject's parenthetical names the spelling that resolved the executor: - `actionType: 'url'`, unchanged byte for byte (the control); - `` `name: 'url'` and no `actionType`, so the runner runs the url navigator `` on the name leg, which tells the author that declaring the executor key is how a `name` stops choosing the navigator (the same teaching #23101's dashboard subject uses for an omitted `actionType`); - `` `actionType: 'navigation'` ``: that row is still refused at the key by #23164's alias rule, and its `target` is now judged too, so the author learns both at once. - ⛔ **`name` is not refused** as an executor spelling. A row whose `name` names any other handler (`navigate_edit`, `convert_lead`, `flow`, …) is untouched, and a declared executor key still outranks the `name`. - The `name` and `target` describes on the three shapes state the name leg. `component.mdx` is regenerated. - Added the D3 entry `ui-page-action-row-name-leg-url-target-judged` and the changeset `.changeset/23168-page-action-row-name-leg-url-target.md`: `@objectstack/spec` `minor`, BREAKING (accept-set narrowing, launch-window convention), `Clause-②: no (narrowing)`, ADR-0087 `registered`, with a FROM → TO table and the one-line fix. - `os validate`'s props gate (`@objectstack/lint` `validateComponentProps`) parses `properties` against these same rows, so it reports the refusal at `properties.target` / `properties.actions.N.target` with no lint code change. `validate-component-props-url-target.test.ts` gains the name-leg readings. `validate-component-props.ts` is untouched, which keeps clear of PR #23244's severity rework there. ## The `onSuccess` half: the fork the dispatch's assumption 4 names **Position 2's premise holds.** objectui's runner navigates twice for a navigating executor that also declares `onSuccess.navigate`. Read at the `.objectui-sha` pin `1fb1ced36243`, byte-identical at objectui `main` `866763bd`: - the first navigation: a `url` row runs `executeUrl` → `navigateTo` (`ActionRunner.ts:1934`; `navigation` reaches the same navigator through `executeNavigation`, `:2227`); - the second: `execute` then calls `handlePostExecution` (`:1418`) → `readOnSuccessNavigation` (`:1640`) → `navigateOnSuccess` (`:1642`). A registered host handler takes the same post-hop (`:1384-1387`), so a host handler that navigates (`navigate`, `navigate_create`, `navigate_edit`) also navigates twice. **The fork.** Applied verbatim, the rule refuses `onSuccess` beside every effective executor other than `api` / `script`. The census found two producers that write exactly that: 1. **objectui `packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx:474-490`** writes `actionType: 'run'`, the recording host handler `node()` sets at `:176-183` with `handlers: { run }`. Beside it sits `onSuccess: { navigate: '/app/records/${result.id}' }`, with and without `openIn: 'newTab'`, on both `action:button` and `action:icon`. - **At the runner:** `run` answers `{ success: true, data: { id: '42' } }` (`:113`). The hop then navigates **once**, to `/app/records/42`, and the test pins that. This is a host handler whose post-success hop works and reads `${result.*}` from the handler's return. - This test mounts through the renderer and never parses the node through the spec. 2. **objectui `packages/types/src/__tests__/held-public-block-arms-10872.test.ts:224`** asserts that the installed spec's `action:button` / `action:icon` row **accepts** `properties: { onSuccess: { navigate: '/tasks' } }`, a row with no executor key and no `name`. It is the remedy target of that file's flat-`onSuccess` refusal. - **At the runner:** the executor resolves to `''`, no handler or built-in matches, and `executeActionSchema` answers "not executable" (`:2118-2123`). The result is unsuccessful, so the hop never runs. - The verbatim rule would refuse it, and this objectui test would go red at objectui's next spec bump. It would not red `main` here: the Console Pin Gate builds the console and runs no objectui tests. The rest of the census found zero rows with `onSuccess` beside a non-`api` / non-`script` executor: - this repository's `examples/**`, `packages/cli` and `packages/qa` (dogfood); its only row `onSuccess` producers are spec and lint tests, all beside `api`; - objectui's app metadata at the pin; - hotcrm at `0f84c9c`. The dispatch says: ⛔ do not silently narrow to the navigating subset, and do not refuse the producer. So no `onSuccess` rule is in this diff. The options and the four-axis analysis are in the dev report on the card. The ruled half is independent of the fork, so it ships here. ## Mechanism readings (the dispatch's assumptions) - **A1 held**, with one correction. `ActionRunner.execute` reads `action.type || action.actionType || action.name || ''` at `:1265`, at the pin `1fb1ced36243` and unchanged at objectui `main` `866763bd`. - The renderers forward the executor key as the runner's `type`: `action-button.tsx:286` `type: schema.actionType`, `action-icon.tsx:167`, `action-group.tsx:350` `type: action.type`, `action-menu.tsx:274`. - The operator is `||`, not `??`, so the helper uses `||`. - The registrations' `defaultProps` (`actionType: 'script'`) are read only by the designer palette (`PageDesigner.tsx:216`), never at render, so an omitted key stays omitted. - The url navigator `navigateTo` is reached by exactly `url` and `navigation`. No host registers a handler under either name: `ConsoleShell.tsx:97` and `useConsoleActionRuntime.tsx:803` register `api` / `flow` / `script` / `modal`, and `AppContent.tsx` / `useObjectActions.ts` register `crud_success`, `dialog_cancel`, `navigate_create`, `navigate_edit`, `create`, `delete`, `navigate` and `refresh`. - Handlers win before built-ins. The legacy `action.navigate` fallback is a key the strict rows refuse. - **A2 held.** At `4f5679c26`, `actionType: 'navigation'` was refused at `actionType` (#23164), and `name: 'navigation'` / `name: 'url'` with no executor key parsed clean with a `javascript:` target, on all four rows. `actionType: ''` with `name: 'url'` parsed clean too. The pins assert what the runner does for each spelling. - **A3 held.** `validateComponentProps` parses `properties` against `ComponentPropsMap` (`validate-component-props.ts:372`) and files every non-`unrecognized_keys` issue as `component-props-invalid` at its path. The lint pins read the new refusal there, from the rebuilt `dist/`. - **A4 tripped its fork clause** (above). - **A5 held** (above): two navigations, cited at the pin. - **A6:** the name leg's refusal is `navigationTargetRefusal`'s, with #23101's subject form. No new phrasing. ## FROM → TO | row | before | after | | --- | --- | --- | | `action:button` / `action:icon` with no (or an empty) `actionType` and `name: 'url'` or `'navigation'` | any `target` | `target` judged by the url scheme set, refused at `properties.target` | | an `action:group` / `action:menu` member with no (or an empty) `type` and `name: 'url'` or `'navigation'` | any `target` | `target` judged by the url scheme set, refused at `properties.actions.N.target` | | `actionType: 'navigation'` / member `type: 'navigation'` with a refused `target` | refused at the executor key only | refused at the executor key (unchanged) and at `target` | | `actionType: 'url'` / member `type: 'url'` | judged | unchanged, same message | **Producers:** none. No page action row in this repository, in objectui (pin `1fb1ced36243`, `main` `866763bd`) or in hotcrm (`0f84c9c`) is named `url` or `navigation`. ## Verification record Readings are at `f1a31cee7` unless stated. That head is this branch merged with `origin/main` `6d7f4df3c` through `scripts/pm/os-regen-merge.sh`, with no conflicts. Every heavy run went through `scripts/pm/os-verify-lock.sh`, and each result is its `VERDICT command-exit` line. - **Builds:** - `@objectstack/spec` build: exit 0. - `check:generated`: "All 14 generated artifacts are up to date", exit 0. Before `gen:docs` it named only `content/docs/references/**`. - The dropped-refinements ledger did not move (759 sites / 234 schemas): this diff changes an existing refinement and adds no site. - Lint's dependency closure (`'@objectstack/lint^...'`) and platform-objects' closure: exit 0. - The workspace build (`turbo run build --filter=!@objectstack/docs --concurrency=2`): 74 / 74. - **Typecheck:** `@objectstack/spec typecheck` exit 0 (at `b76c26a99`), and `@objectstack/lint typecheck` exit 0. - **Tests:** - `@objectstack/spec`, full (`vitest run --project local`), at `b76c26a99`: **668 files, 20254 passed, 1 todo**, exit 0. The merge since brought nothing into `packages/spec`. - The new `component-action-row-effective-executor.test.ts` holds 44 tests. With `url-target-doors.test.ts`, `component-action-row-on-success-executor.test.ts` and `component-props-unknown-members.pin.test.ts`: 4 files, 163 passed. - `@objectstack/lint`, full: **138 files, 6686 passed, 5 skipped**, exit 0. `validate-component-props-url-target.test.ts` holds 20 tests (8 before). - **The importers, narrowed and declared.** The run covered every test file outside spec and lint that names a page action row or `ComponentPropsMap` (6 files in 4 packages), plus `platform-objects` in full: - `platform-objects`: 70 files, 1096 passed; - `mcp`: 1 / 11; - `cloud-connection`: 1 / 16; - `example-showcase`: 3 files / 14. The blind spot is a test that builds such a row from computed values. CI runs the full farm. - **Ablation**, one-time, at `b76c26a99`, through `scripts/ablation-replace.mjs` with its restore trap. `effectiveRowExecutor` lost its name leg (the base behaviour), marked `ablated-23168-name-leg`. - **Spec, src leg:** 12 failed / 57 passed. These are exactly the 8 name-leg refusals and the 4 empty-key refusals; every control and all of `url-target-doors.test.ts` stayed green. - **Lint, dist leg:** after a spec rebuild, `ablation-dist-preflight` found the marker in 14 dist files. The lint file then read 4 failed / 16 passed: the four name-leg refusals at the gate. - **Restore:** blob == HEAD (`a3d90ed32459`) with an empty `git diff HEAD`. A spec rebuild followed, and `--absent` found the marker in none of the 232 dist files, with the tree clean. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 111 at `f1a31cee7`. Every exit was recorded before any pipe. - 110 ran, and every final exit is 0. - Eleven first exited 3 or 1 with PREREQUISITE NOT MET. Eight of them read `packages/spec/dist` while a closure build of mine was rewriting it ("there is none to read"). Three needed the workspace build: spec `check:skill-examples`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. All eleven exit 0 on the rerun at the same head. - `check:type-check-debt` is NOT MEASURED (Acceptance notes). - The `--ran` reconcile reads "111 derived, 110 run, 1 NOT-MEASURED, 0 UNRUN", exit 0. - `check-adr-0087-registration` registers `ui-page-action-row-name-leg-url-target-judged` (new here). - `check:spec-changes` reads 126 converted / 440 migrated. - `check:objectui-pin-citations`: 55 asserting citations match `1fb1ced36`. - `check:nul-bytes` is green, and a raw control-byte scan of the touched files finds 0 lines. - **eslint, narrowed and proven.** The population is the 4 TypeScript files of this diff, read from eslint's own config: the changeset `.md` and `component.mdx` resolve to no config (`--print-config` answers `undefined`). `--format json` reads 4 files, 0 errors and 0 warnings. Invariance: this config enables no type-aware linting (no `parserOptions.project`; the config says so itself in its comment at `eslint.config.mjs:327-329`), so this diff cannot move the verdict on any file it does not touch. - **Size:** 6 files, +368 / -42 = 410 changed lines. ## Acceptance notes - **#23168 stays open for the `onSuccess` half.** The merge of this PR does not close it (`Part of`). - **`check:type-check-debt` is NOT MEASURED here.** Its `--re-measure` mode re-runs tsc over every DEBT / TEST_DEBT ledger entry repo-wide. This diff changes no tsconfig, no `typecheck` script and no ledger entry, and CI runs it. - **The spec full suite** was read at `b76c26a99`, before the merge of `main`. The merge brought `6d7f4df3c` and two earlier commits, which touch service-mailbox, create-objectstack, `scripts/pm/ci-failure.mjs`, a workflow, two changesets and the lockfile, and nothing in `packages/spec`. The row pins and the lint suite were re-read at the merged head. --- _Generated by [Claude Code](https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 01d5034 commit b529a5c

6 files changed

Lines changed: 368 additions & 42 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a page action row's url `target` is judged on the executor the console's action runner actually runs, so a row with no executor key whose `name` is `url` or `navigation` has its `target` judged by the url scheme set
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: registered ui-page-action-row-name-leg-url-target-judged -->
10+
11+
**BREAKING**: an accept-set narrowing on one key of four published page component rows, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
The page action rows are `action:button` and `action:icon` (`properties`), and each member of an `action:group` or `action:menu` (`properties.actions[]`). The console's action runner dispatches a row on `type || actionType || name`, and every row forwards its executor key (`actionType` on a node, `type` on a member) as `type` beside its `name`. So a row with no executor key runs the handler its `name` names, and `name: 'url'` or `name: 'navigation'` runs the url navigator. The rows judged `target` by the url scheme set only when the executor KEY read `'url'`, so such a row parsed with a target the click refuses.
14+
15+
The rows now judge `target` against the row's effective executor: the executor key, or, when that key is omitted or empty, the `name`, exactly as the runner resolves it. When it resolves to the url navigator, `target` is judged by the same url scheme set as an `actionType: 'url'` row, with no second grammar. A declared executor key still outranks the `name`. `name` is not refused as an executor spelling, and a row whose `name` names any other handler (`navigate_edit`, `convert_lead`, …) is untouched. An `actionType: 'url'` row's refusal is unchanged.
16+
17+
### FROM → TO
18+
19+
| before | what to write instead |
20+
| --- | --- |
21+
| `{ name: 'url', target: 'javascript:…' }` with no `actionType` (or no `type` on a member), or `data:…`, `tel:…` or any other scheme | an `https://` page, a `mailto:` address, or an in-app path that starts with `/` or `./`; or, for a button meant to run something, the executor that does the work (`actionType: 'script'`, `'flow'`, `'api'`) |
22+
| the same with `name: 'navigation'` | the same; the `name` is the runner's url alias, so the target is judged as a url |
23+
| `{ name: 'url', target: 'forecast' }`, `'../forecast'`, `'?tab=2'` | the rooted in-app path, `'/forecast'`, or a `./` path; or declare the executor the row was meant to run |
24+
| `{ name: 'url', target: '//cdn.example.com/x' }` | `'https://cdn.example.com/x'`, or `'/x'` for an in-app path |
25+
| `{ name: 'url', target: '' }` | the address the button was meant to open |
26+
27+
**The one-line fix: give every page action row that has no executor key and is named `url` or `navigation` an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` as its `target`, or declare the executor key it was meant to run.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-name-leg-url-target-judged`; the rewrite is by hand, because which address a refused target meant is the author's call.
28+
29+
**What an author now sees.** `os validate`'s props gate reports the refusal at the row's `target` (`properties.target`, or `properties.actions.N.target` for a member). For example, `{ name: 'url', target: 'javascript:alert(1)' }` on an `action:button` gets: ``An `action:button`'s `target` (`name: 'url'` and no `actionType`, so the runner runs the url navigator) opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted target parses unchanged.
30+
31+
**Measured producers: none.** No page action row in this repository, in objectui (at its pin `1fb1ced36243` and its main `866763bd`) or in hotcrm (at `0f84c9c`) is named `url` or `navigation`.

‎content/docs/references/ui/component.mdx‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ActionButtonPropsSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone |
31+
| **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target |
3232
| **label** | `string` | optional | Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map |
3333
| **icon** | `string` | optional | Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon) |
3434
| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type`: on a page component `type` is the component itself |
@@ -38,7 +38,7 @@ const result = ActionButtonPropsSchema.parse(data);
3838
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the button is shown but cannot be pressed while it is TRUE. Omit for never-disabled |
3939
| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the static parameter values |
4040
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
41-
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For `actionType: 'url'`: an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
41+
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
4242
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
4343
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
4444
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
@@ -86,7 +86,7 @@ const result = ActionButtonPropsSchema.parse(data);
8686

8787
| Property | Type | Required | Description |
8888
| :--- | :--- | :--- | :--- |
89-
| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` |
89+
| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target |
9090
| **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id |
9191
| **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) |
9292
| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) |
@@ -96,7 +96,7 @@ const result = ActionButtonPropsSchema.parse(data);
9696
| **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn |
9797
| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs. Only a `type: 'api'` member takes any other value, forwarded as its request payload (use `bodyExtra` for that); on every other member a non-array `params` is refused — author an action with static parameter values as its own `action:button` node |
9898
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
99-
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For `type: 'url'`: an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
99+
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: 'url'`, or no `type` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
100100
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
101101
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
102102
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
@@ -123,7 +123,7 @@ const result = ActionButtonPropsSchema.parse(data);
123123

124124
| Property | Type | Required | Description |
125125
| :--- | :--- | :--- | :--- |
126-
| **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button` |
126+
| **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target |
127127
| **label** | `string` | optional | Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id |
128128
| **icon** | `string` | optional | Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`) |
129129
| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type` |
@@ -132,7 +132,7 @@ const result = ActionButtonPropsSchema.parse(data);
132132
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the icon is shown but cannot be pressed while it is TRUE. Omit for never-disabled |
133133
| **description** | `string` | optional | Action description — the tooltip text when there is no `label`, and forwarded to the runner for the parameter dialog |
134134
| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values |
135-
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For `actionType: 'url'`: an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
135+
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
136136
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
137137
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
138138
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
@@ -176,7 +176,7 @@ const result = ActionButtonPropsSchema.parse(data);
176176

177177
| Property | Type | Required | Description |
178178
| :--- | :--- | :--- | :--- |
179-
| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` |
179+
| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target |
180180
| **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id |
181181
| **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) |
182182
| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) |
@@ -186,7 +186,7 @@ const result = ActionButtonPropsSchema.parse(data);
186186
| **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn |
187187
| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs. Only a `type: 'api'` member takes any other value, forwarded as its request payload (use `bodyExtra` for that); on every other member a non-array `params` is refused — author an action with static parameter values as its own `action:button` node |
188188
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
189-
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For `type: 'url'`: an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
189+
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: 'url'`, or no `type` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
190190
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
191191
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
192192
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |

‎packages/lint/src/validate-component-props-url-target.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,38 @@ describe('the props gate refuses a url `target` outside the shared set on the pa
5555
expect(validateComponentProps(stackWith(node('https://example.com/docs')))).toEqual([]);
5656
});
5757
});
58+
59+
// The name leg. The runner dispatches on `type || actionType || name`, so a
60+
// row with no executor key whose `name` is `url` or `navigation` runs the url
61+
// navigator; the rows judge its `target` by the same set, and the gate reports
62+
// it at the same path. A `name` that names any other handler is untouched.
63+
64+
/** [row, the node for a given name and target, the finding's path, the refusal's subject for a `name`]. */
65+
const NAME_LEG_ROWS: ReadonlyArray<readonly [string, (name: string, target: string) => AnyRec, string, (name: string) => string]> = [
66+
['action:button', (name, target) => ({ type: 'action:button', properties: { label: 'Open', name, target } }),
67+
`${BASE}.target`, (name) => `An \`action:button\`'s \`target\` (\`name: '${name}'\` and no \`actionType\`, so the runner runs the url navigator)`],
68+
['action:icon', (name, target) => ({ type: 'action:icon', properties: { label: 'Open', name, target } }),
69+
`${BASE}.target`, (name) => `An \`action:icon\`'s \`target\` (\`name: '${name}'\` and no \`actionType\`, so the runner runs the url navigator)`],
70+
['action:group member', (name, target) => ({ type: 'action:group', properties: { actions: [{ label: 'Open', name, target }] } }),
71+
`${BASE}.actions.0.target`, (name) => `An \`action:group\` member's \`target\` (\`name: '${name}'\` and no \`type\`, so the runner runs the url navigator)`],
72+
['action:menu member', (name, target) => ({ type: 'action:menu', properties: { actions: [{ label: 'Open', name, target }] } }),
73+
`${BASE}.actions.0.target`, (name) => `An \`action:menu\` member's \`target\` (\`name: '${name}'\` and no \`type\`, so the runner runs the url navigator)`],
74+
];
75+
76+
describe("the props gate judges a name-leg row's `target` on the executor the runner runs", () => {
77+
it.each(NAME_LEG_ROWS)("REFUSAL: %s named `url` or `navigation`, with no executor key, is one finding at its `target`", (_row, node, path, subject) => {
78+
for (const name of ['url', 'navigation']) {
79+
const findings = validateComponentProps(stackWith(node(name, 'javascript:alert(1)')));
80+
expect(findings.map((f) => [f.rule, f.path]), name).toEqual([[COMPONENT_PROPS_INVALID, path]]);
81+
expect(findings[0].message, name).toContain(`${subject(name)}${JAVASCRIPT_REST}`);
82+
}
83+
});
84+
85+
it.each(NAME_LEG_ROWS)('ACCEPTED CONTROL: %s named `url` with an admitted target reports nothing', (_row, node) => {
86+
expect(validateComponentProps(stackWith(node('url', 'https://example.com/docs')))).toEqual([]);
87+
});
88+
89+
it.each(NAME_LEG_ROWS)('CONTROL: %s named after another handler reports nothing at its `target`', (_row, node) => {
90+
expect(validateComponentProps(stackWith(node('navigate_edit', 'convert_lead')))).toEqual([]);
91+
});
92+
});

0 commit comments

Comments
 (0)