Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/23077-url-target-doors-scheme-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@objectstack/spec': minor
---

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

Clause-②: no (narrowing)

<!-- 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 -->

**BREAKING** — an accept-set narrowing on three published authoring surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings.

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:

- **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).
- **`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.
- **`actions[].onSuccess.navigate`**, on every value.

The admitted set, at both doors:

- an `http://` or `https://` URL (the scheme compared case-insensitively);
- a `mailto:` address;
- an in-app path that starts with `/` or `./` (`/apps/crm/tasks/${result.id}`);
- a template that opens with `${` (`${result.redirectUrl}`), judged by the runner after interpolation.

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.

### FROM → TO

| before | what to write instead |
| --- | --- |
| `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. |
| `'tel:+1…'`, `'ftp://…'` or any other scheme | an `https://` page that offers it, or a `mailto:` address. `tel:` is not admitted. |
| `'https:example.com'` (no `//`) | `'https://example.com'` |
| `'//cdn.example.com/x'` (protocol-relative) | `'https://cdn.example.com/x'` for an external link, or `'/x'` for an in-app path |
| `'/\example.com'` (`/` then a backslash) | `'https://example.com'`, or an in-app path written with forward slashes |
| 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`) |
| `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 |
| `'forecast'`, `'../forecast'`, `'?tab=2'`, `'#top'`, `navigate: 'tasks/${result.id}'` | the rooted in-app path, `'/forecast'` / `'/tasks/${result.id}'`, or a `./` path |
| `target: ''` / `actionUrl: ''` / `navigate: ''` | the address the button or the hop was meant to open; drop an `onSuccess` that navigates nowhere |

**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.

**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.

**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}`.
2 changes: 1 addition & 1 deletion content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |
| **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. |
| **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. |


Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const result = ActionButtonPropsSchema.parse(data);
| **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 |
| **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 |
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType` |
| **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 |
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
Expand Down Expand Up @@ -89,7 +89,7 @@ const result = ActionButtonPropsSchema.parse(data);
| **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 |
| **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 |
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type` |
| **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 |
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
Expand Down Expand Up @@ -125,7 +125,7 @@ const result = ActionButtonPropsSchema.parse(data);
| **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 |
| **description** | `string` | optional | Action description — the tooltip text when there is no `label`, and forwarded to the runner for the parameter dialog |
| **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 |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType` |
| **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 |
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
Expand Down Expand Up @@ -172,7 +172,7 @@ const result = ActionButtonPropsSchema.parse(data);
| **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 |
| **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 |
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type` |
| **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 |
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Dashboard header action
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string \| Record<string, string>` | ✅ | Action button label |
| **actionUrl** | `string` | ✅ | URL or target for the action |
| **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. |
| **actionType** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Type of action |
| **icon** | `string` | optional | Icon identifier for the action button |

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

Expand Down
57 changes: 57 additions & 0 deletions packages/lint/src/validate-component-props-url-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `os validate`'s props gate on the page action rows' url `target`.
//
// A page's `properties` bag is outside the stack parse, so the props gate is
// the door `os validate` judges an `action:button` / `action:icon` /
// `action:group` member / `action:menu` member through. It parses the bag
// against the row's `ComponentPropsMap` schema, which now applies the shared
// url-target grammar (`navigation-target.ts` in `@objectstack/spec`) to a url
// executor's `target`. These pins hold that the gate reports that refusal at
// the row's own `target` path, with the parse's own first sentence, and stays
// silent on an admitted target. The finding is a `component-props-invalid`
// warning, the gate's one tier (its module header says why).
import { describe, expect, it } from 'vitest';
import { validateComponentProps, COMPONENT_PROPS_INVALID } from './validate-component-props.js';

type AnyRec = Record<string, unknown>;

/** One page, one region, one component. */
const stackWith = (component: AnyRec): AnyRec => ({
pages: [
{
name: 'probe_page',
label: 'Probe',
type: 'home',
object: 'task',
regions: [{ name: 'main', components: [component] }],
},
],
});

const BASE = 'pages[0].regions[0].components[0].properties';
const JAVASCRIPT_REST = ' opens with `javascript:`, a scheme a url target never opens.';

/** [row, the node for a given target, the finding's path, the refusal's subject]. */
const ROWS: ReadonlyArray<readonly [string, (target: string) => AnyRec, string, string]> = [
['action:button', (target) => ({ type: 'action:button', properties: { label: 'Open', actionType: 'url', target } }),
`${BASE}.target`, "An `action:button`'s `target` (`actionType: 'url'`)"],
['action:icon', (target) => ({ type: 'action:icon', properties: { label: 'Open', actionType: 'url', target } }),
`${BASE}.target`, "An `action:icon`'s `target` (`actionType: 'url'`)"],
['action:group member', (target) => ({ type: 'action:group', properties: { actions: [{ label: 'Open', type: 'url', target }] } }),
`${BASE}.actions.0.target`, "An `action:group` member's `target` (`type: 'url'`)"],
['action:menu member', (target) => ({ type: 'action:menu', properties: { actions: [{ label: 'Open', type: 'url', target }] } }),
`${BASE}.actions.0.target`, "An `action:menu` member's `target` (`type: 'url'`)"],
];

describe('the props gate refuses a url `target` outside the shared set on the page action rows', () => {
it.each(ROWS)('REFUSAL: %s with `javascript:alert(1)` is one finding at its `target`', (_row, node, path, subject) => {
const findings = validateComponentProps(stackWith(node('javascript:alert(1)')));
expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, path]]);
expect(findings[0].message).toContain(`${subject}${JAVASCRIPT_REST}`);
});

it.each(ROWS)('ACCEPTED CONTROL: %s with an admitted target reports nothing', (_row, node) => {
expect(validateComponentProps(stackWith(node('https://example.com/docs')))).toEqual([]);
});
});
Loading
Loading