Skip to content

Commit f931e76

Browse files
feat(spec)!: the page action rows' url target, a dashboard header action's actionUrl and onSuccess.navigate apply the shared url-target refusal (#23101)
Fixes #23077 Clause-②: no (narrowing) The rest of ruling A+'s url-target family. Ruling A+ on objectstack-ai/objectui#12191 says the scheme set "is stated once in the contract and the runtime fulfils exactly that". PR #23076 (`9b37edbd7`) stated it once, as `navigationTargetRefusal` in `packages/spec/src/ui/navigation-target.ts`, and applied it at the two url-action doors. This PR applies the same function, with no second URL grammar, at the three doors the triage direction (comment 6129646171) names. A target that opens with `${` keeps the predicate's own rule: it is left alone at the door and judged by the runner after interpolation. ## What changed 1. **Page action rows** (`packages/spec/src/ui/component.zod.ts`). One refinement factory, `refuseUnopenableRowUrlTarget`, is applied to `ActionButtonPropsSchema` and `ActionIconPropsSchema` (when `actionType: 'url'`) and to the `action:group` and `action:menu` members (when `type: 'url'`). It judges `target` and refuses at `target`. On every other executor `target` names a script, flow, page, endpoint or form, so it is not judged, and a row with no `target` is not judged either. `os validate`'s props gate (`@objectstack/lint` `validateComponentProps`) parses `properties` against these same `ComponentPropsMap` rows, so it now reports the refusal at the row's `target` path, with the parse's own first sentence. The refusal is a `component-props-invalid` finding, a warning, because warning is that gate's only tier (see its module header). The card puts the gate's upgrade to error out of scope. The lint package changes no code; one test file pins the gate's reading. 2. **Dashboard header actions** (`packages/spec/src/ui/dashboard.zod.ts`). `DashboardHeaderActionSchema` judges `actionUrl` when `actionType` is `'url'` **or omitted**, and refuses at `actionUrl`. On `flow` / `script` / `modal` / `api` / `form`, `actionUrl` is untouched. The refusal's subject names both spellings of the url route ("a url target when `actionType` is `'url'` or omitted"), so an author who meant a flow learns that declaring `actionType` is the way out. 3. **Post-success navigation** (`packages/spec/src/ui/action.zod.ts`). The `onSuccess` block judges `navigate` on every value and refuses at `onSuccess.navigate`. `${result.redirectUrl}` stays accepted and `/apps/crm/tasks/${result.id}` stays accepted. `tasks/${result.id}` is refused, because the runner refuses `tasks/42` after interpolation too. The `describe` text and TSDoc on each key now state the set. Three D3 entries are added, one per door: `ui-page-action-row-url-target-scheme-set-closed`, `ui-dashboard-header-action-url-scheme-set-closed` and `ui-action-on-success-navigate-scheme-set-closed`. There is one changeset, `.changeset/23077-url-target-doors-scheme-set.md`: `@objectstack/spec` `minor`, BREAKING, with a FROM → TO table and the one-line fix, `Clause-②: no (narrowing)`, and ADR-0087 `registered` with the three ids. **Empty values.** At the three doors that have no target-required rule (dashboard `actionUrl`, `onSuccess.navigate`, a row's `target`), the predicate judges an empty string like any other value and refuses it. This keeps the predicate's own rule. The runtime does nothing with an empty value either: the dashboard renderer warns on an empty `actionUrl`, `readOnSuccessNavigation` drops an empty `navigate`, and `executeUrl` fails with "No URL provided". The changeset's FROM → TO table names this case. ### The dispatch's mechanism assumptions, as measured - **A1** (lines). Every door was a free string at `0aef78881`. `dashboard.zod.ts:109` `actionUrl: z.string()` and the three `component.zod.ts` rows at `:3834` / `:3931` / `:4114` matched. `onSuccess.navigate` was at `action.zod.ts:1785`, not `:941`. `:941` is the top-level `navigate` guidance key, a different thing. - **A2** (red at base). Reproduced through one-time ablations, recorded below. With each door's refinement removed, which is the base behaviour, these pins read red: - the schema-level refusals; - the `ObjectStackDefinitionSchema.safeParse` / `defineStack` reach pins for the dashboard and `onSuccess` doors; - the props-gate pins, where the expected finding is missing because the gate answered 0 findings. - **A3** (omitted `actionType`). The contract states no default for `actionType`. Its two readers that assign one both assign `'url'`: objectui `DashboardRenderer` builds the header `ActionDef` with `type: (a.actionType) || 'url'`, and this repo's `validateDashboardActionRefs` uses `strName(action.actionType) ?? 'url'`. **Pinned reading: an omitted `actionType` is the url route and is judged.** The renderer's click handler, read at objectui `origin/main` `7af0ab37` and at the pin `7578323`, returns early with a console warning on an omitted `actionType`. So a refused target never works on either reading, and nothing that works today is refused. The click handler's split is an Acceptance note below. - **A4** (deferred `${`). Held: `${result.redirectUrl}` is accepted at `onSuccess.navigate`, and that is pinned. ### File surface beyond the claim's literal list (declared) - `packages/spec/dropped-refinements.baseline.json`. `build-schemas.ts` refuses a published schema that gains a refinement it cannot project unless the new site is declared in this ledger. Its own prescription is "Declare it by adding…", and it says "⛔ do not delete or weaken" the rule. The ledger gains 21 sites: 17 in 13 existing entries, and 4 new entries (`ui/DashboardHeaderAction`, `ui/DashboardHeader`, `ui/ActionButtonProps`, `ui/ActionIconProps`). The `measured` header totals are updated to match its body (231 schemas / 734 sites), which `scripts/dropped-refinements.test.ts` pins. No site is removed. - The three D3 entry files. `check-adr-0087-registration` needs them: a changeset with a FROM → TO prescription cannot claim `no-migration-prescription`. - `content/docs/references/ui/{action,dashboard,component}.mdx`, regenerated by `gen:docs`. `check:generated` named only `check:docs`. ## Verification record Readings are at branch head `ed59283ff` unless stated. The branch merged `origin/main` `6662e3f59` (after #23080 landed as `a22e67b3c`) through `scripts/pm/os-regen-merge.sh`, and the merge commit is `f7f23a07d`. All heavy runs went through `scripts/pm/os-verify-lock.sh`. - `pnpm --filter @objectstack/spec build`: exit 0. `check:generated`: "All 14 generated artifacts are up to date", exit 0. - `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter @objectstack/lint typecheck`: exit 0. - `@objectstack/spec` full suite (`vitest run --project local`): **658 files, 19919 passed, 1 todo**, exit 0. The new `src/ui/url-target-doors.test.ts` holds 25 tests. - `@objectstack/lint` full suite at `beade18b6`, which differs from head only by the regenerated `component.mdx`: **138 files, 6590 passed, 5 skipped**. The new `src/validate-component-props-url-target.test.ts` holds 8 tests. - `@objectstack/platform-objects` full suite: 70 files, 1096 passed. - **Narrowed, declared.** These runs cover only the test files that author an affected key: - `@objectstack/runtime`, 8 files, 87 passed: `app-plugin-converted-collections`, `app-plugin-converted-readers`, `dispatcher-plugin.routes`, `http-dispatcher.actions-doubled-redirect` (which writes `onSuccess.navigate`), `notification-schema-conformance`, `packages-unscoped-environment-binding`, `standalone-stack-option-b-forward-conversion` and `standalone-stack-seeder-declaration-copy`; - `@objectstack/metadata-protocol` `protocol.meta-types-unauthorable-columns`, 8 passed. - **The population:** every test file outside spec and lint that names `actionUrl`, `onSuccess`, `navigate` or an `action:button` / `action:icon` / `action:group` / `action:menu` row. That is 22 files. Most of them are notify-node `actionUrl` or React `onSuccess` callbacks, which are different keys. - **The static census behind it.** Every `actionUrl:`, `navigate:` and url-adjacent `target:` literal in this repository, tests included, is either admitted or not on a url route. The exceptions are this PR's own pins and three raw-object fixtures in `validate-dashboard-action-refs.test.ts`, which the spec never parses (see the Acceptance notes). - **Blind spot:** a test that builds one of these values from a computed string. - `turbo ls --affected` names 76 packages. That set comes from the package dependency graph, so it is blind to tests that read another package's source from disk. CI runs the full farm, and the `@objectstack/cli` `integration` tier and the example suites are declared to CI. - **Ablations** (one-time proofs, not kept, each through `scripts/ablation-replace.mjs` with its restore trap): - Dashboard door at `2be8c3b15`: deleted `.superRefine(refuseUnopenableHeaderActionUrl)` (anchor 1 → 0, blob `517bda8662ed` → `1a0794811edb`). `url-target-doors.test.ts` read **4 failed / 9 passed**: both refusals and both reach pins. - `onSuccess` door at `2be8c3b15`: deleted `.superRefine(refuseUnopenableOnSuccessNavigate)` (anchor 1 → 0, blob `05f3a2c73867` → `984773e97456`). It read **3 failed / 10 passed**. - Page action rows at `ed59283ff`: replaced the factory's guard with an always-return carrying the marker `ablated-23077` (blob `3308f803bfee` → `3c5a701368c4`). - src leg: **4 failed / 21 passed**, the four row refusals. - dist leg: after `pnpm --filter @objectstack/spec build`, `ablation-dist-preflight.mjs` found the marker in 14 built files. The props-gate test then read **4 failed / 4 passed**. - restore leg: rebuilt; the marker is absent from all 232 built files; the tree is clean; the props-gate test read 8/8 passed. - Every restore put the blob back equal to HEAD with an empty `git diff HEAD`. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 112 commands at `ed59283ff`. **111 run, all exit 0. `pnpm check:type-check-debt` is NOT MEASURED by dispatch order.** Two of them first exited 3 on an unmet prerequisite: `check:skill-examples` (`@objectstack/client-react` unbuilt) and `check:dual-build-cjs-loads` (37 unbuilt packages). Both exit 0 after a workspace build through the lock, at the same head. `--ran` reconciles: "112 derived, 111 run, 1 NOT-MEASURED, 0 UNRUN", and every line carries its exit code. `check:nul-bytes` is green, and a raw control-byte scan of the 13 touched files finds 0 lines. - `check-adr-0087-registration`: "registered ui-page-action-row-url-target-scheme-set-closed, ui-dashboard-header-action-url-scheme-set-closed, ui-action-on-success-navigate-scheme-set-closed (new here: …)". - **Producers measured: none refused outside tests.** This repository was read at its head; objectui at its pin `7578323` and at `origin/main` `7af0ab37`. - The only refused literals are in tests that exercise the runner's own refusal: objectui `ActionRunner.test.ts`, `ActionRunner.onSuccessNavigation.test.ts` and `element-button-mailto-12191.test.tsx`. - The other refused-looking hits are `_blank` / `_top` nav attributes and non-url executor names. - No producer writes `actionType: 'navigation'` on a page action row. - Size: 13 files, **+559 / −21 = 580 changed lines** (generated files included). - `origin/main` has moved two commits past the merge (`1badd3183`, `2d21b9c89`). Their file set is disjoint from this branch's, and CI's merge-ref run covers the join. - Merge round (seat order, state note 6133884874): merged origin/main 4a87d09 (#23098 = 55828c5) at e6d8238; the one conflict, packages/spec/dropped-refinements.baseline.json, took main side whole, and build-schemas re-derived this branch's 21 sites (e9ff53c): 232 schemas / 752 sites, #23098's 18 sites kept. At e9ff53c: spec suite 660 files / 20010 passed / 1 todo; lint suite 138 files / 6617 passed / 5 skipped; dispatch-gates --ran 112 derived, 111 run, 1 NOT-MEASURED (check:type-check-debt, by order), 0 UNRUN. ## Acceptance notes - **objectui's dashboard header click handler bypasses the classifier** (read at objectui `7af0ab37`, `packages/plugin-dashboard/src/DashboardRenderer.tsx:1211-1223`). An `actionType: 'url'` header action is navigated by the renderer itself, not through `ActionRunner` / `classifyNavigationTarget`: - it calls `location.assign` for `^https?://` **or a `//`-prefixed** target; - it calls `history.pushState` for everything else, which throws on `mailto:` and admits a bare `thanks`; - an omitted `actionType` is skipped with a warning, while the same file's `headerActionDefs` defaults it to `'url'`. This is a second URL grammar on the runtime side of ruling A+. Reported to the seat with evidence in the dev report; it is not fixed here (objectui). - **A page action row's `onSuccess` is `z.unknown()`**, forwarded to the runner's `navigateOnSuccess`. Measured through the dist: `ActionButtonPropsSchema` and an `action:group` member accept `onSuccess: { navigate: 'javascript:alert(1)' }`, and the props gate answers 0 findings. As a control, `ActionSchema` refuses the same value at `onSuccess.navigate`. This is the same family, but declaring the rows' `onSuccess` shape is a contract choice and not a mechanical fix, so it is reported to the seat and not fixed here. - `actionType: 'navigation'` on a page action row reaches the runner's `executeNavigation`, an alias of `url`, and its `target` is not judged here. The row's `actionType` is a free string, and no producer writes it. This is a residual of the family; the card and the claim scope the rows to `'url'`. - Three fixtures in `packages/lint/src/validate-dashboard-action-refs.test.ts` (`//cdn.example.com/y` and `some-handler` with `actionType: 'url'`, and `do_something` with it omitted) feed the gate raw objects. They stay green, but they now pin inputs that the stack parse refuses before the gate sees them. - `action.form.ts`'s help text for `onSuccess.navigate` still says "A relative value is an in-app route". The `describe` text now states the set. - The props gate's generic `component-props-invalid` text ("nothing refuses it today, so the renderer receives the props as written") reads loosely for these rows, because the runner does refuse at the click. --- _Generated by [Claude Code](https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d43004 commit f931e76

13 files changed

Lines changed: 559 additions & 21 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: the page action rows' url `target`, a dashboard header action's `actionUrl` and an action's `onSuccess.navigate` declare the closed url scheme set — `http://`, `https://`, `mailto:` and in-app paths — and refuse every other target at parse
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: registered ui-page-action-row-url-target-scheme-set-closed, ui-dashboard-header-action-url-scheme-set-closed, ui-action-on-success-navigate-scheme-set-closed -->
10+
11+
**BREAKING** — an accept-set narrowing on three published authoring surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
All three were free strings, so a target the console's action runner refuses — `javascript:`, `data:`, `tel:`, a protocol-relative `//host`, a bare relative path — parsed clean, published, and failed only at the click or after the action succeeded. A `type: 'url'` action's `target` already closed this set; the scheme set is stated once in the contract and the runtime opens exactly that set, so these doors now apply the same grammar, with no second one:
14+
15+
- **The page action rows' `target`** on the url executor: an `action:button` or `action:icon` with `actionType: 'url'`, and an `action:group` / `action:menu` member with `type: 'url'`. On every other executor `target` names a script, flow, page, endpoint or form and is untouched, and a row with no `target` is not judged. A page's `properties` bag is outside the stack parse; `os validate` judges it through its props gate, which parses the bag against these same rows and so reports the refusal at the row's `target` with the same text, as a `component-props-invalid` finding (that gate's one tier, a warning).
16+
- **`dashboards[].header.actions[].actionUrl`**, when `actionType` is `'url'` or omitted. An omitted `actionType` is judged as the url route, because the two readers that give it a meaning (the console's dashboard renderer and `os validate`'s dashboard action check) both read it as `'url'`. On `'flow'`, `'script'`, `'modal'`, `'api'` and `'form'`, `actionUrl` names the flow, script, page, endpoint or form and is untouched.
17+
- **`actions[].onSuccess.navigate`**, on every value.
18+
19+
The admitted set, at both doors:
20+
21+
- an `http://` or `https://` URL (the scheme compared case-insensitively);
22+
- a `mailto:` address;
23+
- an in-app path that starts with `/` or `./` (`/apps/crm/tasks/${result.id}`);
24+
- a template that opens with `${` (`${result.redirectUrl}`), judged by the runner after interpolation.
25+
26+
Every other value is refused at the key, with a message that names the reason and the admitted set: `javascript:`, `data:`, `tel:` and every other scheme; `http:` or `https:` without the `//`; a protocol-relative `//host`; `/` followed by a backslash; a tab, line feed or carriage return anywhere; a bare relative value such as `forecast`, `../forecast` or `tasks/${result.id}`; and the empty string.
27+
28+
### FROM → TO
29+
30+
| before | what to write instead |
31+
| --- | --- |
32+
| `target: 'javascript:…'` / `actionUrl: 'javascript:…'` / `navigate: 'javascript:…'`, or `data:…` | no url target runs or renders content. For a page action row or a header action, declare the executor that does the work (`'script'`, `'flow'`, `'api'`), or link to an `https://` page; for `onSuccess`, drop it or navigate to an `https://` page. |
33+
| `'tel:+1…'`, `'ftp://…'` or any other scheme | an `https://` page that offers it, or a `mailto:` address. `tel:` is not admitted. |
34+
| `'https:example.com'` (no `//`) | `'https://example.com'` |
35+
| `'//cdn.example.com/x'` (protocol-relative) | `'https://cdn.example.com/x'` for an external link, or `'/x'` for an in-app path |
36+
| `'/\example.com'` (`/` then a backslash) | `'https://example.com'`, or an in-app path written with forward slashes |
37+
| a value holding a tab, line feed or carriage return | the same value without them; percent-encode one that belongs in the address (`%09`, `%0A`, `%0D`) |
38+
| `actionUrl: 'forecast'` with `actionType` omitted, meant as a flow or script | `actionType: 'flow'` (or `'script'`, …) beside it — the declared type takes `actionUrl` off the url route |
39+
| `'forecast'`, `'../forecast'`, `'?tab=2'`, `'#top'`, `navigate: 'tasks/${result.id}'` | the rooted in-app path, `'/forecast'` / `'/tasks/${result.id}'`, or a `./` path |
40+
| `target: ''` / `actionUrl: ''` / `navigate: ''` | the address the button or the hop was meant to open; drop an `onSuccess` that navigates nowhere |
41+
42+
**The one-line fix: give every url page action row, every url header action (`actionType: 'url'` or omitted) and every `onSuccess.navigate` an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — or, for a row or a header action meant to run something, the executor that names it.** `os migrate meta --from 17` lists the D3 entries `ui-page-action-row-url-target-scheme-set-closed`, `ui-dashboard-header-action-url-scheme-set-closed` and `ui-action-on-success-navigate-scheme-set-closed`; the rewrite is by hand, because which address a refused value meant is the author's call.
43+
44+
**What an author now sees.** `os validate`, `os build`, `defineStack` and `defineAction` refuse such a value at `header.actions[].actionUrl` or `onSuccess.navigate`; on a page action row, `os validate`'s props gate reports it at the row's `target` (`properties.target`, or `properties.actions.N.target` for a member). For example, `javascript:alert(1)` on a header action gets: ``A dashboard header action's `actionUrl`, a url target when `actionType` is `'url'` or omitted, opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted value parses unchanged.
45+
46+
**Measured producers: none outside tests.** Every url-executor page action row and every header action outside tests in this repository and in objectui (at its pin `7578323` and its main `7af0ab37`) names an admitted target or declares a non-url `actionType`, and the only `onSuccess.navigate` outside tests is the documented in-app route template `/apps/crm/tasks/${result.id}`.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
161161

162162
| Property | Type | Required | Description |
163163
| :--- | :--- | :--- | :--- |
164-
| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`). Relative = SPA route hop; renderers MUST encodeURIComponent values in query positions. |
164+
| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with $&#123; is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. |
165165
| **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. |
166166

167167

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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` |
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 |
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 |
@@ -89,7 +89,7 @@ const result = ActionButtonPropsSchema.parse(data);
8989
| **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 |
9090
| **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 |
9191
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
92-
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type` |
92+
| **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 |
9393
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
9494
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
9595
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
@@ -125,7 +125,7 @@ const result = ActionButtonPropsSchema.parse(data);
125125
| **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 |
126126
| **description** | `string` | optional | Action description — the tooltip text when there is no `label`, and forwarded to the runner for the parameter dialog |
127127
| **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 |
128-
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType` |
128+
| **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 |
129129
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
130130
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
131131
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
@@ -172,7 +172,7 @@ const result = ActionButtonPropsSchema.parse(data);
172172
| **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 |
173173
| **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 |
174174
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
175-
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type` |
175+
| **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 |
176176
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
177177
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
178178
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ Dashboard header action
139139
| Property | Type | Required | Description |
140140
| :--- | :--- | :--- | :--- |
141141
| **label** | `string \| Record<string, string>` | ✅ | Action button label |
142-
| **actionUrl** | `string` | ✅ | URL or target for the action |
142+
| **actionUrl** | `string` | ✅ | URL or target for the action. When actionType is 'url' or omitted: 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. |
143143
| **actionType** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Type of action |
144144
| **icon** | `string` | optional | Icon identifier for the action button |
145145

@@ -155,7 +155,7 @@ Dashboard header action
155155
| Property | Type | Required | Description |
156156
| :--- | :--- | :--- | :--- |
157157
| **label** | `string \| Record<string, string>` | ✅ | Action button label |
158-
| **actionUrl** | `string` | ✅ | URL or target for the action |
158+
| **actionUrl** | `string` | ✅ | URL or target for the action. When actionType is 'url' or omitted: 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. |
159159
| **actionType** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Type of action |
160160
| **icon** | `string` | optional | Icon identifier for the action button |
161161

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `os validate`'s props gate on the page action rows' url `target`.
4+
//
5+
// A page's `properties` bag is outside the stack parse, so the props gate is
6+
// the door `os validate` judges an `action:button` / `action:icon` /
7+
// `action:group` member / `action:menu` member through. It parses the bag
8+
// against the row's `ComponentPropsMap` schema, which now applies the shared
9+
// url-target grammar (`navigation-target.ts` in `@objectstack/spec`) to a url
10+
// executor's `target`. These pins hold that the gate reports that refusal at
11+
// the row's own `target` path, with the parse's own first sentence, and stays
12+
// silent on an admitted target. The finding is a `component-props-invalid`
13+
// warning, the gate's one tier (its module header says why).
14+
import { describe, expect, it } from 'vitest';
15+
import { validateComponentProps, COMPONENT_PROPS_INVALID } from './validate-component-props.js';
16+
17+
type AnyRec = Record<string, unknown>;
18+
19+
/** One page, one region, one component. */
20+
const stackWith = (component: AnyRec): AnyRec => ({
21+
pages: [
22+
{
23+
name: 'probe_page',
24+
label: 'Probe',
25+
type: 'home',
26+
object: 'task',
27+
regions: [{ name: 'main', components: [component] }],
28+
},
29+
],
30+
});
31+
32+
const BASE = 'pages[0].regions[0].components[0].properties';
33+
const JAVASCRIPT_REST = ' opens with `javascript:`, a scheme a url target never opens.';
34+
35+
/** [row, the node for a given target, the finding's path, the refusal's subject]. */
36+
const ROWS: ReadonlyArray<readonly [string, (target: string) => AnyRec, string, string]> = [
37+
['action:button', (target) => ({ type: 'action:button', properties: { label: 'Open', actionType: 'url', target } }),
38+
`${BASE}.target`, "An `action:button`'s `target` (`actionType: 'url'`)"],
39+
['action:icon', (target) => ({ type: 'action:icon', properties: { label: 'Open', actionType: 'url', target } }),
40+
`${BASE}.target`, "An `action:icon`'s `target` (`actionType: 'url'`)"],
41+
['action:group member', (target) => ({ type: 'action:group', properties: { actions: [{ label: 'Open', type: 'url', target }] } }),
42+
`${BASE}.actions.0.target`, "An `action:group` member's `target` (`type: 'url'`)"],
43+
['action:menu member', (target) => ({ type: 'action:menu', properties: { actions: [{ label: 'Open', type: 'url', target }] } }),
44+
`${BASE}.actions.0.target`, "An `action:menu` member's `target` (`type: 'url'`)"],
45+
];
46+
47+
describe('the props gate refuses a url `target` outside the shared set on the page action rows', () => {
48+
it.each(ROWS)('REFUSAL: %s with `javascript:alert(1)` is one finding at its `target`', (_row, node, path, subject) => {
49+
const findings = validateComponentProps(stackWith(node('javascript:alert(1)')));
50+
expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, path]]);
51+
expect(findings[0].message).toContain(`${subject}${JAVASCRIPT_REST}`);
52+
});
53+
54+
it.each(ROWS)('ACCEPTED CONTROL: %s with an admitted target reports nothing', (_row, node) => {
55+
expect(validateComponentProps(stackWith(node('https://example.com/docs')))).toEqual([]);
56+
});
57+
});

0 commit comments

Comments
 (0)