diff --git a/.changeset/23077-url-target-doors-scheme-set.md b/.changeset/23077-url-target-doors-scheme-set.md new file mode 100644 index 00000000000..6ce51d2f2b4 --- /dev/null +++ b/.changeset/23077-url-target-doors-scheme-set.md @@ -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) + + + +**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}`. diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index e90e4fbbc58..0a19bf0ff8e 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -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 ${ 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. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 22cb24e28cc..419714591b6 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -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 | @@ -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 | @@ -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 | @@ -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 | diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index dc1a11bf035..12ac9c76bca 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -139,7 +139,7 @@ Dashboard header action | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **label** | `string \| Record` | ✅ | 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 | @@ -155,7 +155,7 @@ Dashboard header action | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **label** | `string \| Record` | ✅ | 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 | diff --git a/packages/lint/src/validate-component-props-url-target.test.ts b/packages/lint/src/validate-component-props-url-target.test.ts new file mode 100644 index 00000000000..d084df8bdd9 --- /dev/null +++ b/packages/lint/src/validate-component-props-url-target.test.ts @@ -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; + +/** 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 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([]); + }); +}); diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 217d999915e..55687c29af4 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 228, - "droppedRefinementSites": 731, + "publishedSchemasWithDroppedRefinements": 232, + "droppedRefinementSites": 752, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -63,12 +63,14 @@ "sites": [ "manifest.actions.element.in", "manifest.actions.element.in.ai.outputSchema", + "manifest.actions.element.in.onSuccess", "manifest.actions.element.in.params.element.in", "manifest.agents.element.memory", "manifest.agents.element.structuredOutput.schema", "manifest.analyticsCubes.element.measures.valueType", "manifest.connectors.element.out", "manifest.dashboards.element.globalFilters.element", + "manifest.dashboards.element.header.actions.element", "manifest.dashboards.element.widgets.element", "manifest.dashboards.element.widgets.element.filter", "manifest.datasets.element", @@ -177,12 +179,14 @@ "data.options[0].manifest.navigationContributions.element.items.element.lazy.options[0]", "data.options[1].manifest.actions.element.in", "data.options[1].manifest.actions.element.in.ai.outputSchema", + "data.options[1].manifest.actions.element.in.onSuccess", "data.options[1].manifest.actions.element.in.params.element.in", "data.options[1].manifest.agents.element.memory", "data.options[1].manifest.agents.element.structuredOutput.schema", "data.options[1].manifest.analyticsCubes.element.measures.valueType", "data.options[1].manifest.connectors.element.out", "data.options[1].manifest.dashboards.element.globalFilters.element", + "data.options[1].manifest.dashboards.element.header.actions.element", "data.options[1].manifest.dashboards.element.widgets.element", "data.options[1].manifest.dashboards.element.widgets.element.filter", "data.options[1].manifest.datasets.element", @@ -281,12 +285,14 @@ "options[0].manifest.navigationContributions.element.items.element.lazy.options[0]", "options[1].manifest.actions.element.in", "options[1].manifest.actions.element.in.ai.outputSchema", + "options[1].manifest.actions.element.in.onSuccess", "options[1].manifest.actions.element.in.params.element.in", "options[1].manifest.agents.element.memory", "options[1].manifest.agents.element.structuredOutput.schema", "options[1].manifest.analyticsCubes.element.measures.valueType", "options[1].manifest.connectors.element.out", "options[1].manifest.dashboards.element.globalFilters.element", + "options[1].manifest.dashboards.element.header.actions.element", "options[1].manifest.dashboards.element.widgets.element", "options[1].manifest.dashboards.element.widgets.element.filter", "options[1].manifest.datasets.element", @@ -340,12 +346,14 @@ "data.packages.element.options[0].manifest.navigationContributions.element.items.element.lazy.options[0]", "data.packages.element.options[1].manifest.actions.element.in", "data.packages.element.options[1].manifest.actions.element.in.ai.outputSchema", + "data.packages.element.options[1].manifest.actions.element.in.onSuccess", "data.packages.element.options[1].manifest.actions.element.in.params.element.in", "data.packages.element.options[1].manifest.agents.element.memory", "data.packages.element.options[1].manifest.agents.element.structuredOutput.schema", "data.packages.element.options[1].manifest.analyticsCubes.element.measures.valueType", "data.packages.element.options[1].manifest.connectors.element.out", "data.packages.element.options[1].manifest.dashboards.element.globalFilters.element", + "data.packages.element.options[1].manifest.dashboards.element.header.actions.element", "data.packages.element.options[1].manifest.dashboards.element.widgets.element", "data.packages.element.options[1].manifest.dashboards.element.widgets.element.filter", "data.packages.element.options[1].manifest.datasets.element", @@ -397,6 +405,7 @@ "sites": [ "data.actions.element.in", "data.actions.element.in.ai.outputSchema", + "data.actions.element.in.onSuccess", "data.actions.element.in.params.element.in" ] }, @@ -405,6 +414,7 @@ "data", "data.actions.element.in", "data.actions.element.in.ai.outputSchema", + "data.actions.element.in.onSuccess", "data.actions.element.in.params.element.in", "data.fieldGroups", "data.fields.out.valueType", @@ -841,6 +851,7 @@ "", "actions.element.in", "actions.element.in.ai.outputSchema", + "actions.element.in.onSuccess", "actions.element.in.params.element.in", "fieldGroups", "fields.out.valueType", @@ -1021,6 +1032,7 @@ "", "actions.element.in", "actions.element.in.ai.outputSchema", + "actions.element.in.onSuccess", "actions.element.in.params.element.in" ] }, @@ -1120,6 +1132,7 @@ "operations.element.options[3].object", "operations.element.options[3].object.actions.element.in", "operations.element.options[3].object.actions.element.in.ai.outputSchema", + "operations.element.options[3].object.actions.element.in.onSuccess", "operations.element.options[3].object.actions.element.in.params.element.in", "operations.element.options[3].object.fieldGroups", "operations.element.options[3].object.fields.out.valueType", @@ -1139,6 +1152,7 @@ "object", "object.actions.element.in", "object.actions.element.in.ai.outputSchema", + "object.actions.element.in.onSuccess", "object.actions.element.in.params.element.in", "object.fieldGroups", "object.fields.out.valueType", @@ -1184,6 +1198,7 @@ "options[3].object", "options[3].object.actions.element.in", "options[3].object.actions.element.in.ai.outputSchema", + "options[3].object.actions.element.in.onSuccess", "options[3].object.actions.element.in.params.element.in", "options[3].object.fieldGroups", "options[3].object.fields.out.valueType", @@ -1282,6 +1297,7 @@ "sites": [ "in", "in.ai.outputSchema", + "in.onSuccess", "in.params.element.in" ] }, @@ -1290,11 +1306,21 @@ "outputSchema" ] }, + "ui/ActionButtonProps": { + "sites": [ + "" + ] + }, "ui/ActionGroupProps": { "sites": [ "actions.element" ] }, + "ui/ActionIconProps": { + "sites": [ + "" + ] + }, "ui/ActionMenuProps": { "sites": [ "actions.element" @@ -1323,11 +1349,22 @@ "ui/Dashboard": { "sites": [ "globalFilters.element", + "header.actions.element", "widgets.element", "widgets.element.filter", "widgets.element.filter.lazy" ] }, + "ui/DashboardHeader": { + "sites": [ + "actions.element" + ] + }, + "ui/DashboardHeaderAction": { + "sites": [ + "" + ] + }, "ui/DashboardWidget": { "sites": [ "", diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-action-on-success-navigate-scheme-set-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-action-on-success-navigate-scheme-set-closed.ts new file mode 100644 index 00000000000..b14ad338a19 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-action-on-success-navigate-scheme-set-closed.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-action-on-success-navigate-scheme-set-closed', + surface: "an action's `onSuccess.navigate` (the post-success hop of a `type: 'api'` or " + + "`type: 'script'` action) holding a target the console's action runner does not open: a " + + 'scheme other than `http:`, `https:` or `mailto:` (`javascript:`, `data:`, `tel:` and every ' + + 'other), `http:` or `https:` without the `//`, a protocol-relative `//host`, `/` followed by ' + + 'a backslash, a tab / line feed / carriage return anywhere, or a bare relative template with ' + + 'no leading `/` or `./` (`tasks/${result.id}`, the empty string included)', + replacement: 'an `http://` or `https://` URL, a `mailto:` address, an in-app route that starts ' + + 'with `/` or `./` (`/apps/crm/tasks/${result.id}`), or a template that opens with `${` ' + + '(`${result.redirectUrl}`), which the runner judges after interpolation. ⛔ NOT mechanically ' + + 'convertible: `tasks/${result.id}` may mean `/tasks/…` or `./tasks/…`, and a `javascript:` ' + + 'hop has no admitted equivalent — the author chooses, or drops `onSuccess`', + reason: + 'The key was a free string, so a post-success hop the runner refuses parsed clean, published, ' + + 'and failed silently after the action succeeded: the runner classifies the interpolated URL, ' + + 'warns, and navigates nowhere. The scheme set is stated once (`navigation-target.ts`) and ' + + 'every door that hands the runner a navigation target now applies it. No measured producer ' + + 'writes a refused value: the only `onSuccess.navigate` outside tests, in this repository and ' + + 'in objectui, is the documented in-app route template — so the entry is owed for hops ' + + 'authored outside them.', + acceptanceCriteria: + "Every action's `onSuccess.navigate` opens with `http://`, `https://` or `mailto:` " + + '(case-insensitive), starts with `/` (not `//`, not `/` followed by a backslash) or `./`, or ' + + 'opens with `${`; and none holds a tab, line feed or carriage return. Every admitted value ' + + 'parses byte-identically to before. `os validate` reports nothing at `onSuccess.navigate` for ' + + 'any action in the stack.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-header-action-url-scheme-set-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-header-action-url-scheme-set-closed.ts new file mode 100644 index 00000000000..80612a5cd2f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-header-action-url-scheme-set-closed.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-dashboard-header-action-url-scheme-set-closed', + surface: "a dashboard `header.actions[].actionUrl` whose `actionType` is `'url'` or omitted, " + + "holding a target the console's action runner does not open: a scheme other than `http:`, " + + '`https:` or `mailto:` (`javascript:`, `data:`, `tel:` and every other), `http:` or `https:` ' + + 'without the `//`, a protocol-relative `//host`, `/` followed by a backslash, a tab / line feed ' + + '/ carriage return anywhere, or a bare relative target with no leading `/` or `./` (the empty ' + + 'string included)', + replacement: 'an `http://` or `https://` URL, a `mailto:` address, an in-app path that starts ' + + 'with `/` or `./`, or a template that opens with `${` — or, when the action was meant to run ' + + "a flow, a script, a page, an endpoint or a form, the `actionType` that says so (`'flow'`, " + + "`'script'`, `'modal'`, `'api'`, `'form'`), which takes `actionUrl` out of the url route. " + + '⛔ NOT mechanically convertible: a bare `forecast` may mean `/forecast`, `./forecast` or a ' + + 'flow named `forecast` with its `actionType` left out, and a `javascript:` or `tel:` button has ' + + 'no admitted equivalent — the author chooses', + reason: + 'The key was a free string, so a url header action whose target the runner refuses parsed ' + + 'clean, published, and failed only at the click. The scheme set is stated once ' + + '(`navigation-target.ts`) and every door that hands the runner a navigation target now ' + + 'applies it. An omitted `actionType` is judged as the url route because both readers that ' + + "give it a meaning give it `'url'`. No measured producer writes a refused target: every " + + 'header action outside tests in this repository and in objectui names an admitted url or ' + + 'declares a non-url `actionType` — so the entry is owed for targets authored outside them.', + acceptanceCriteria: + "Every dashboard header action whose `actionType` is `'url'` or omitted carries an `actionUrl` " + + 'that opens with `http://`, `https://` or `mailto:` (case-insensitive), starts with `/` (not ' + + '`//`, not `/` followed by a backslash) or `./`, or opens with `${`; and none holds a tab, line ' + + 'feed or carriage return. Every admitted `actionUrl` parses byte-identically to before, and ' + + '`actionUrl` on every other `actionType` is untouched. `os validate` reports nothing at ' + + '`header.actions[].actionUrl` for any url header action in the stack.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-url-target-scheme-set-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-url-target-scheme-set-closed.ts new file mode 100644 index 00000000000..2d33bd0e07f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-url-target-scheme-set-closed.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-page-action-row-url-target-scheme-set-closed', + surface: "the `target` of a page action row on the url executor — an `action:button` or " + + "`action:icon` with `actionType: 'url'`, or an `action:group` / `action:menu` member with " + + "`type: 'url'` — holding a target the console's action runner does not open: a scheme other " + + 'than `http:`, `https:` or `mailto:` (`javascript:`, `data:`, `tel:` and every other), `http:` ' + + 'or `https:` without the `//`, a protocol-relative `//host`, `/` followed by a backslash, a tab ' + + '/ line feed / carriage return anywhere, or a bare relative target with no leading `/` or `./` ' + + '(the empty string included)', + replacement: 'an `http://` or `https://` URL, a `mailto:` address, an in-app path that starts ' + + 'with `/` or `./`, or a template that opens with `${` (judged after interpolation, at the ' + + 'click). ⛔ NOT mechanically convertible: a bare `thanks` may mean `/thanks` or `./thanks`, ' + + '`//cdn.example.com/x` may mean `https:` or an in-app path, and a `tel:` or `javascript:` ' + + 'button has no admitted equivalent at all — the author chooses the address they meant, or ' + + "moves the work to a different executor (`actionType: 'script'`, `'flow'`, `'api'`)", + reason: + 'The row forwards `target` to the runner as a free string, so a url row whose target the ' + + 'runner refuses was stored clean and failed only at the click. The scheme set is stated once ' + + '(`navigation-target.ts`) and every door that hands the runner a navigation target now ' + + "applies it; `os validate`'s props gate parses a page's `properties` against these same rows, " + + 'so it reports the refusal at the row with the same text. No measured producer writes a ' + + 'refused target: every url-executor row target outside tests in this repository and in ' + + 'objectui is admitted — so the entry is owed for targets authored outside them.', + acceptanceCriteria: + "Every `action:button` / `action:icon` with `actionType: 'url'` and every `action:group` / " + + "`action:menu` member with `type: 'url'` carries a `target` that opens with `http://`, " + + '`https://` or `mailto:` (case-insensitive), starts with `/` (not `//`, not `/` followed by a ' + + 'backslash) or `./`, or opens with `${`; and none holds a tab, line feed or carriage return. ' + + 'Every admitted target parses byte-identically to before, and `target` on every other ' + + "executor is untouched. `os validate`'s props gate reports nothing at those rows' `target`.", +}; diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 2ef003fa9e4..05f3a2c7386 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1725,6 +1725,17 @@ const actionObject = () => strictObject({ * hop (no full page load, immune to popup blocking); an absolute URL is a * document navigation. * + * **The scheme set** is a `type: 'url'` target's (`navigation-target.ts`, + * ruling A+ on objectstack-ai/objectui#12191): `navigate` is an `http://` or + * `https://` URL, a `mailto:` address, an in-app path that starts with `/` + * or `./`, or a template that opens with `${` — which the runner judges + * after interpolation, so `${result.redirectUrl}` is left alone here. Every + * other scheme, a protocol-relative `//host`, a `/` followed by a backslash, + * a tab / line feed / carriage return anywhere, and a bare relative template + * (`tasks/${result.id}`) are refused at parse: the runner + * (`navigateOnSuccess`) classifies the interpolated URL with the same set + * and performs no navigation for any of them. + * * **`openIn`** is a CLOSED two-member enum — `'self'` (default: navigate in * place) or `'newTab'` — deliberately not a general navigation DSL. * The default is MATERIALIZED (`.default('self')`, the file's convention — @@ -1782,7 +1793,7 @@ const actionObject = () => strictObject({ "the tab choice is the closed enum `openIn: 'self' | 'newTab'` — write `openIn: 'newTab'`.", }, }, { - navigate: z.string().describe("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: z.string().describe("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 ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions."), openIn: z.enum(['self', 'newTab'], { error: (issue) => (issue.input === 'new-tab' ? "`onSuccess.openIn` spells the new-tab member `'newTab'` (matching the handler-return " @@ -1790,7 +1801,7 @@ const actionObject = () => strictObject({ + "to the top-level `openIn` key for `type:'url'` actions. Write `openIn: 'newTab'`." : undefined), }).default('self').describe("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."), - }).optional().describe("Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response); `openIn` defaults 'self'. The handler-return convention ({ redirectUrl } without openIn) keeps its 17.0.0 new-tab behavior."), + }).superRefine(refuseUnopenableOnSuccessNavigate).optional().describe("Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response); `openIn` defaults 'self'. The handler-return convention ({ redirectUrl } without openIn) keeps its 17.0.0 new-tab behavior."), // `aria` REMOVED (ADR-0049 enforce-or-remove; the triage record on the card, // comment 5860351140 on #20323, follows the `ChartConfig.aria` retirement @@ -2046,6 +2057,24 @@ function refuseUnopenableUrlTarget(subject: string) { }; } +/** + * The scheme set of `onSuccess.navigate`, the post-success hop. The grammar is + * `navigation-target.ts`'s, unscoped: unlike `target`, `navigate` has no other + * meaning to protect — on both types that may declare it (`api`, `script`) it + * is only ever a URL the runner navigates to after interpolation. A template + * that opens with `${` keeps the predicate's own rule (judged at the hop, after + * interpolation); an empty value is judged like any other and refused, since + * the runner's shape guard (`readOnSuccessNavigation`) drops an empty + * `navigate` and no hop happens. + */ +function refuseUnopenableOnSuccessNavigate( + data: { navigate: string }, + ctx: z.core.$RefinementCtx, +): void { + const refusal = navigationTargetRefusal(data.navigate, "An action's `onSuccess.navigate`"); + if (refusal !== undefined) ctx.addIssue({ code: 'custom', path: ['navigate'], message: refusal }); +} + export const ActionSchema = lazySchema(() => actionObject().refine((data) => { // Require `target` for types that reference an external resource if (TARGET_REQUIRED_TYPES.has(data.type) && !data.target) { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e7b3348b686..58628db2dce 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -59,6 +59,7 @@ import { AggregationFunction } from '../data/query.zod'; import { ListViewExportOptionsSchema } from './list-view-export-options'; import { InlineActionSchema, ActionLocationSchema } from './action.zod'; import { ACTION_TARGET_ALIASES } from './action-target-aliases'; +import { navigationTargetRefusal } from './navigation-target'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; // [#21464] `object-metric.aggregate.groupBy` is the chart aggregate's own // `groupBy` union, by reference — the tile routes it exactly as the chart does. @@ -3943,6 +3944,31 @@ const ACTION_NODE_ALIASES = { ...ACTION_TARGET_ALIASES, } as const; +/** + * The scheme set of a page action row's url `target` — on `action:button` and + * `action:icon` (`actionType: 'url'`) and on an `action:group` / + * `action:menu` member (`type: 'url'`). Every one of them forwards `target` to + * the console's action runner, which navigates to it on a click and refuses a + * target outside the set (`classifyNavigationTarget`). The grammar is + * `navigation-target.ts`'s, the one the url-action doors apply (ruling A+ on + * objectstack-ai/objectui#12191: the scheme set is stated once in the contract + * and the runtime fulfils exactly that); this only scopes it to the url + * executor. ⛔ No second URL grammar here. + * + * On every other executor `target` names a script, flow, page, endpoint or + * form and is untouched. A row with no `target` is not judged here: nothing + * was written for the set to judge. Because `os validate`'s props gate parses + * `properties` against these same rows (`ComponentPropsMap`), it reports this + * refusal at the same path with the same text — one rule, two doors. + */ +function refuseUnopenableRowUrlTarget(executorKey: 'actionType' | 'type', subject: string) { + return (row: { actionType?: string; type?: string; target?: string }, ctx: z.RefinementCtx): void => { + if (row[executorKey] !== 'url' || typeof row.target !== 'string') return; + const refusal = navigationTargetRefusal(row.target, subject); + if (refusal !== undefined) ctx.addIssue({ code: 'custom', path: ['target'], message: refusal }); + }; +} + /** * Read-but-not-authorable keys shared by `action:button` and `action:icon`, * each read by the renderer and each refused here with what to write instead. @@ -4050,7 +4076,7 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ description: z.string().optional() .describe('Action description, forwarded to the runner — the parameter dialog shows it under its title'), target: z.string().optional() - .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`'), + .describe('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: z.enum(['self', 'new-tab']).optional() .describe('For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab'), method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), @@ -4071,7 +4097,7 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ onSuccess: z.unknown().optional().describe('Declared post-success navigation, forwarded to the runner'), objectName: z.string().optional() .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), -})); +}).superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:button`'s `target` (`actionType: 'url'`)"))); /** Author state (ADR-0122: the bare name is the author state). */ export type ActionButtonProps = z.input; /** @@ -4147,7 +4173,7 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ params: z.unknown().optional() .describe('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: z.string().optional() - .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`'), + .describe('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: z.enum(['self', 'new-tab']).optional() .describe('For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab'), method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), @@ -4166,7 +4192,7 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ onSuccess: z.unknown().optional().describe('Declared post-success navigation, forwarded to the runner'), objectName: z.string().optional() .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), -})); +}).superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:icon`'s `target` (`actionType: 'url'`)"))); /** Author state (ADR-0122: the bare name is the author state). */ export type ActionIconProps = z.input; /** @@ -4330,7 +4356,7 @@ function actionContainerMemberShape() { description: z.string().optional() .describe('Action description, forwarded to the runner — the parameter dialog shows it under its title'), target: z.string().optional() - .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`'), + .describe('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: z.enum(['self', 'new-tab']).optional() .describe('For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab'), method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), @@ -4433,7 +4459,8 @@ function buildActionGroupMember() { ...actionContainerMemberShape(), size: z.enum([...BUTTON_PRIMITIVE_SIZES, 'md']).optional() .describe('Inline button size — the Button primitive\'s vocabulary, plus `md` (drawn as `default`), falling back to the group\'s `size`. A dropdown item reads no size'), - }).superRefine(actionContainerMemberParamsFitType('action:group')); + }).superRefine(actionContainerMemberParamsFitType('action:group')) + .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:group` member's `target` (`type: 'url'`)")); } let actionGroupMemberOnce: ReturnType | undefined; /** The one {@link buildActionGroupMember} instance. */ @@ -4452,7 +4479,8 @@ function buildActionMenuMember() { + 'button (the menu\'s own `size`). Remove it, or put the action in an `action:group`, whose inline buttons ' + 'read a member\'s `size`.', }, - }, actionContainerMemberShape()).superRefine(actionContainerMemberParamsFitType('action:menu')); + }, actionContainerMemberShape()).superRefine(actionContainerMemberParamsFitType('action:menu')) + .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:menu` member's `target` (`type: 'url'`)")); } let actionMenuMemberOnce: ReturnType | undefined; /** The one {@link buildActionMenuMember} instance. */ diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 6e7bebcde6c..517bda8662e 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -12,6 +12,7 @@ import { DATE_MACRO_WRAPPED_RE, isDateMacroToken } from '../data/date-macros.zod import { DATE_RANGE_PRESETS } from '../data/date-range-presets'; import { ChartTypeSchema, ChartConfigSchema, type ChartType } from './chart.zod'; import { ActionType } from './action.zod'; +import { navigationTargetRefusal } from './navigation-target'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; // `AriaPropsSchema` is no longer imported here: `widgets[].aria` was retired // (#5010). The shape itself is NOT removed — it stays live on `page.aria` / @@ -79,6 +80,35 @@ const DASHBOARD_HISTORY = 'Until this shape was closed these were dropped silently — the dashboard still rendered, ' + 'without whatever the key was meant to configure.'; +/** + * The scheme set of a url header action's `actionUrl` — the grammar is + * `navigation-target.ts`'s, the one the console's action runner applies at the + * click (ruling A+ on objectstack-ai/objectui#12191); this only scopes it to + * the url route. ⛔ No second URL grammar here. + * + * The url route is `actionType: 'url'` OR an omitted `actionType`. The contract + * states no default for the key, and its two readers that assign one both + * assign `'url'`: objectui's `DashboardRenderer` builds the header action's + * `ActionDef` with `type: actionType || 'url'`, and `@objectstack/lint`'s + * `validateDashboardActionRefs` applies the same default. So an omitted type is + * judged, not waved through: a refused target never works on either reading. + * On every other type `actionUrl` names a script, flow, page, endpoint or form + * and is untouched. The refusal names both spellings of the url route, so an + * author who meant a flow or a script learns that declaring `actionType` is the + * way out. + */ +const HEADER_ACTION_URL_SUBJECT = + "A dashboard header action's `actionUrl`, a url target when `actionType` is `'url'` or omitted,"; + +function refuseUnopenableHeaderActionUrl( + data: { actionUrl: string; actionType?: string }, + ctx: z.core.$RefinementCtx, +): void { + if (data.actionType !== undefined && data.actionType !== 'url') return; + const refusal = navigationTargetRefusal(data.actionUrl, HEADER_ACTION_URL_SUBJECT); + if (refusal !== undefined) ctx.addIssue({ code: 'custom', path: ['actionUrl'], message: refusal }); +} + /** * Dashboard Header Action Schema * An action button displayed in the dashboard header area. @@ -105,15 +135,27 @@ export const DashboardHeaderActionSchema = lazySchema(() => strictObject({ /** Action label */ label: I18nLabelSchema.describe('Action button label').meta({ title: 'Label' }), - /** Action URL or target */ - actionUrl: z.string().describe('URL or target for the action').meta({ title: 'Action URL' }), + /** + * Action URL or target. + * + * **The url scheme set** (`actionType: 'url'`, or `actionType` omitted): + * `actionUrl` is an `http://` or `https://` URL, a `mailto:` address, an + * in-app path that starts with `/` or `./`, or a template that opens with + * `${` (judged after interpolation, at the click). Every other scheme, a + * protocol-relative `//host`, a `/` followed by a backslash, a tab / line + * feed / carriage return anywhere, and a bare relative target are refused at + * parse — the same set the console's action runner opens + * (`navigation-target.ts`). On every other `actionType` it names the script, + * flow, page, endpoint or form, and is untouched. + */ + actionUrl: z.string().describe("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.").meta({ title: 'Action URL' }), /** Action type */ actionType: WidgetActionTypeSchema.optional().describe('Type of action').meta({ title: 'Action Type' }), /** Icon identifier */ icon: z.string().optional().describe('Icon identifier for the action button').meta({ title: 'Icon' }), -}).describe('Dashboard header action')); +}).superRefine(refuseUnopenableHeaderActionUrl).describe('Dashboard header action')); /** * Dashboard Header Schema diff --git a/packages/spec/src/ui/url-target-doors.test.ts b/packages/spec/src/ui/url-target-doors.test.ts new file mode 100644 index 00000000000..7082ea78ba8 --- /dev/null +++ b/packages/spec/src/ui/url-target-doors.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The rest of the url-target family: the authoring doors outside the two + * url-action doors whose value the console's action runner navigates to, each + * judged by the ONE grammar in `navigation-target.ts` (ruling A+ on + * objectstack-ai/objectui#12191 — the scheme set is stated once in the + * contract and the runtime fulfils exactly that). The set itself is pinned + * case by case in `action-url-target-scheme-set.test.ts`; this file pins only + * that each door APPLIES it, with one refusal and one accepted control per + * door, plus the scope each door draws: + * + * - `DashboardHeaderActionSchema.actionUrl` — judged when `actionType` is + * `'url'` or omitted (both readers that give an omitted type a meaning give + * it `'url'`), untouched on every other type. + * - `ActionSchema.onSuccess.navigate` — judged on every value; a template that + * opens with `${` keeps the predicate's own rule and is left to the hop. + * - The page action rows — `action:button` / `action:icon` (`actionType: + * 'url'`) and an `action:group` / `action:menu` member (`type: 'url'`) — + * judged on the url executor only. A page's `properties` bag is outside the + * stack parse; `os validate` judges it through its props gate, whose reading + * of these rows `@objectstack/lint`'s `validate-component-props-url-target` + * test pins. + * + * Each refusal is pinned by its Zod issue path and its first sentence, through + * the schema AND through the stack parse `os validate` runs, so the reach is + * the public door's, not a unit's. + */ + +import { describe, it, expect } from 'vitest'; + +import { ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; +import { ActionSchema } from './action.zod'; +import { ActionButtonPropsSchema, ActionIconPropsSchema, ComponentPropsMap } from './component.zod'; +import { DashboardHeaderActionSchema } from './dashboard.zod'; + +const HEADER_SUBJECT = + "A dashboard header action's `actionUrl`, a url target when `actionType` is `'url'` or omitted,"; +const NAVIGATE_SUBJECT = "An action's `onSuccess.navigate`"; +const JAVASCRIPT_REST = ' opens with `javascript:`, a scheme a url target never opens.'; + +/** The message's first sentence: everything up to the first `. ` outside a code span. */ +function firstSentence(message: string): string { + let inCode = false; + for (let i = 0; i < message.length; i += 1) { + if (message[i] === '`') inCode = !inCode; + if (!inCode && message[i] === '.' && (message[i + 1] === ' ' || i === message.length - 1)) { + return message.slice(0, i + 1); + } + } + return message; +} + +type Issue = { code: string; path: PropertyKey[]; message: string }; +const issuesOf = (r: { success: boolean; error?: { issues: unknown[] } }): Issue[] => + (r.success ? [] : (r.error?.issues ?? [])) as Issue[]; + +const MANIFEST = { id: 'com.example.doors', name: 'doors', version: '1.0.0', type: 'app' }; + +// =========================================================================== +// 1. DashboardHeaderActionSchema.actionUrl +// =========================================================================== + +const headerAction = (extra: Record) => ({ label: 'Open', ...extra }); +const dashboardWith = (action: Record) => ({ + manifest: MANIFEST, + dashboards: [{ name: 'url_doors_dash', label: 'Doors', widgets: [], header: { actions: [headerAction(action)] } }], +}); + +describe('dashboard header action: `actionUrl` on the url route is judged by the shared set', () => { + it("REFUSAL: `actionType: 'url'` with `javascript:alert(1)` is refused at `actionUrl`", () => { + const issues = issuesOf(DashboardHeaderActionSchema.safeParse(headerAction({ actionType: 'url', actionUrl: 'javascript:alert(1)' }))); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['actionUrl']]]); + expect(firstSentence(issues[0].message)).toBe(`${HEADER_SUBJECT}${JAVASCRIPT_REST}`); + }); + + it('REFUSAL: an omitted `actionType` is the url route too — `javascript:alert(1)` is refused at `actionUrl`', () => { + const issues = issuesOf(DashboardHeaderActionSchema.safeParse(headerAction({ actionUrl: 'javascript:alert(1)' }))); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['actionUrl']]]); + expect(firstSentence(issues[0].message)).toBe(`${HEADER_SUBJECT}${JAVASCRIPT_REST}`); + }); + + it.each([ + ["`actionType: 'url'`", { actionType: 'url', actionUrl: 'https://example.com/report' }], + ['`actionType` omitted', { actionUrl: '/reports/forecast' }], + ])('ACCEPTED CONTROL: %s with an admitted target parses unchanged', (_label, action) => { + const r = DashboardHeaderActionSchema.safeParse(headerAction(action)); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect((r.data as { actionUrl: string }).actionUrl).toBe(action.actionUrl); + }); + + it('CONTROL: on a non-url `actionType` `actionUrl` names a flow, script or page and is not judged', () => { + for (const actionType of ['flow', 'script', 'modal', 'api', 'form']) { + const r = DashboardHeaderActionSchema.safeParse(headerAction({ actionType, actionUrl: 'export_dashboard_pdf' })); + expect(r.success, `${actionType}: ${JSON.stringify(issuesOf(r))}`).toBe(true); + } + }); + + it('REACH: the stack parse `os validate` runs refuses it at its full path, and accepts the control', () => { + const refused = issuesOf(ObjectStackDefinitionSchema.safeParse(dashboardWith({ actionType: 'url', actionUrl: 'javascript:alert(1)' }))); + expect(refused.map((i) => i.path.join('.'))).toEqual(['dashboards.0.header.actions.0.actionUrl']); + expect(firstSentence(refused[0].message)).toBe(`${HEADER_SUBJECT}${JAVASCRIPT_REST}`); + + const accepted = ObjectStackDefinitionSchema.safeParse(dashboardWith({ actionType: 'url', actionUrl: 'https://example.com/report' })); + expect(accepted.success, JSON.stringify(issuesOf(accepted))).toBe(true); + }); + + it('REACH: `defineStack` throws the refusal for the omitted-type spelling', () => { + expect(() => defineStack(dashboardWith({ actionUrl: 'javascript:alert(1)' }) as never)) + .toThrow(`${HEADER_SUBJECT}${JAVASCRIPT_REST}`); + }); +}); + +// =========================================================================== +// 2. ActionSchema.onSuccess.navigate +// =========================================================================== + +const apiAction = (navigate: string) => ({ + name: 'clone_task', label: 'Clone', type: 'api', target: '/api/v1/tasks/clone', onSuccess: { navigate }, +}); + +describe('`onSuccess.navigate` is judged by the shared set', () => { + it('REFUSAL: `javascript:alert(1)` is refused at `onSuccess.navigate`', () => { + const issues = issuesOf(ActionSchema.safeParse(apiAction('javascript:alert(1)'))); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['onSuccess', 'navigate']]]); + expect(firstSentence(issues[0].message)).toBe(`${NAVIGATE_SUBJECT}${JAVASCRIPT_REST}`); + }); + + it('REFUSAL: a bare relative template is refused — the runner refuses `tasks/42` after interpolation too', () => { + const issues = issuesOf(ActionSchema.safeParse(apiAction('tasks/${result.id}'))); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['onSuccess', 'navigate']]]); + expect(firstSentence(issues[0].message)) + .toBe(`${NAVIGATE_SUBJECT} opens with neither an admitted scheme nor \`/\` or \`./\`.`); + }); + + it.each([ + ['an in-app route template', '/apps/crm/tasks/${result.id}'], + ['an https:// URL', 'https://example.com/receipt'], + ['a template that opens with `${` — deferred to the hop, by the predicate\'s own rule', '${result.redirectUrl}'], + ])('ACCEPTED CONTROL: %s parses unchanged', (_label, navigate) => { + const r = ActionSchema.safeParse(apiAction(navigate)); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect((r.data as { onSuccess: { navigate: string } }).onSuccess.navigate).toBe(navigate); + }); + + it('REACH: the stack parse refuses it at its full path, `defineStack` throws it, and the control parses', () => { + const stack = (navigate: string) => ({ manifest: MANIFEST, actions: [apiAction(navigate)] }); + const refused = issuesOf(ObjectStackDefinitionSchema.safeParse(stack('javascript:alert(1)'))); + expect(refused.map((i) => i.path.join('.'))).toEqual(['actions.0.onSuccess.navigate']); + expect(() => defineStack(stack('javascript:alert(1)') as never)).toThrow(`${NAVIGATE_SUBJECT}${JAVASCRIPT_REST}`); + + const accepted = ObjectStackDefinitionSchema.safeParse(stack('/apps/crm/tasks/${result.id}')); + expect(accepted.success, JSON.stringify(issuesOf(accepted))).toBe(true); + }); +}); + +// =========================================================================== +// 3. The page action rows (`component.zod.ts`) +// =========================================================================== + +type Row = 'action:button' | 'action:icon' | 'action:group member' | 'action:menu member'; + +/** Each row: how to parse one, and the subject its refusal opens with. */ +const ROWS: ReadonlyArray) => ReturnType, PropertyKey[], string]> = [ + ['action:button', (f) => issuesOf(ActionButtonPropsSchema.safeParse({ label: 'Open', ...f })), ['target'], + "An `action:button`'s `target` (`actionType: 'url'`)"], + ['action:icon', (f) => issuesOf(ActionIconPropsSchema.safeParse({ label: 'Open', ...f })), ['target'], + "An `action:icon`'s `target` (`actionType: 'url'`)"], + ['action:group member', (f) => issuesOf(ComponentPropsMap['action:group'].safeParse({ actions: [{ label: 'Open', ...f }] })), + ['actions', 0, 'target'], "An `action:group` member's `target` (`type: 'url'`)"], + ['action:menu member', (f) => issuesOf(ComponentPropsMap['action:menu'].safeParse({ actions: [{ label: 'Open', ...f }] })), + ['actions', 0, 'target'], "An `action:menu` member's `target` (`type: 'url'`)"], +]; + +/** The executor key a row spells: a node's `actionType`, a member's `type`. */ +const executor = (row: Row, value: string) => (row.endsWith('member') ? { type: value } : { actionType: value }); + +describe('the page action rows judge a url `target` by the shared set', () => { + it.each(ROWS)('REFUSAL: %s with a url executor and `javascript:alert(1)` is refused at `target`', (row, parse, path, subject) => { + const issues = parse({ ...executor(row, 'url'), target: 'javascript:alert(1)' }); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', path]]); + expect(firstSentence(issues[0].message)).toBe(`${subject}${JAVASCRIPT_REST}`); + }); + + it.each(ROWS)('ACCEPTED CONTROL: %s with a url executor and an admitted target parses', (row, parse) => { + for (const target of ['https://example.com/docs', '/apps/crm', 'mailto:sales@example.com']) { + expect(parse({ ...executor(row, 'url'), target }), `${row} ${target}`).toEqual([]); + } + }); + + it.each(ROWS)('CONTROL: %s on a non-url executor, or with none, leaves `target` alone', (row, parse) => { + for (const value of ['flow', 'script', 'api', 'modal']) { + expect(parse({ ...executor(row, value), target: 'convert_lead' }), `${row} ${value}`).toEqual([]); + } + expect(parse({ target: 'convert_lead' }), `${row} with no executor`).toEqual([]); + }); +});