Skip to content

Commit 9b37edb

Browse files
feat(spec)!: a type:'url' action's target declares its closed scheme set — http(s)://, mailto: and in-app paths — at both url-action doors (#23076)
Fixes #23010 Clause-②: no (narrowing: the published accept set of a `type: 'url'` target narrows; the lane charter reads a narrowing as `no`, where the card's own text says `yes`, and `yes` would not be wrong either) The spec half of ruling A+ on objectstack-ai/objectui#12191 (maintainer's word, carried verbatim in the card: 「22861 objectui#12191 同意。」). The runner half landed as objectstack-ai/objectui#12230 (`334f0cff98`). ## What changed On both url-action doors, a `type: 'url'` action's `target` now has a closed scheme set, the same set the console's action runner opens at the click: - an `http://` or `https://` URL (the scheme compared case-insensitively); - a `mailto:` address; - an in-app path that starts with `/` or `./`; - a template that opens with `${`. The runner judges it after interpolation, at the click; the door leaves it alone. Every other target is refused at `target`, 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; and a bare relative target such as `thanks`, `../thanks`, `?tab=2` or `#top`. `target` on `script` / `flow` / `modal` / `api` / `form` is untouched, and so are interpolation and `openIn`. - `packages/spec/src/ui/navigation-target.ts` (new): the one grammar, `navigationTargetRefusal(raw, subject)`. It is reached by relative import only, like `action-target-aliases.ts`, because `action.zod.ts` rides the `ui` barrel and an export there would grow the published API. #23004 (the empty-state link, `Blocked-by` this card) can import it from `ui/view.zod.ts` with no copy. The admitted schemes are one table; the refusal text is built from it, so admitting a scheme is one row. - `packages/spec/src/ui/action.zod.ts`: one `superRefine` per door (`ActionSchema`, and `InlineActionSchema` after the legacy `type: 'navigation'` / `to` fold), scoped to `type: 'url'`. An empty target is left to the target-required rule, so it reports once. The `target` describe and TSDoc state the set. - D3 entry `ui-action-url-target-scheme-set-closed` and `.changeset/23010-url-action-target-scheme-set.md` (`minor`, `Clause-②: no (narrowing)`, ADR-0087 `registered`, FROM → TO table). - Fixture triage: one fixture changed. `src/ui/action.test.ts`'s all-types loop gave its url arm the bare `test_handler`. That was never a navigation target; the fixture now writes `/test_handler`, and the other arms keep their names. - `content/docs/references/**`: regenerated for the describe (4 files, `check:generated` named only `check:docs`). - **File surface, beyond the claim's literal list (declared):** `navigation-target.ts` is a new sibling module because the dispatch delegated the predicate's placement and asked for it non-public, and `action.zod.ts` is barrel-exported whole (`ui/index.ts`: `export * from './action.zod'`). The D3 entry file is what `check:adr-0087-registration` requires: a changeset that carries a FROM → TO prescription cannot claim `no-migration-prescription`. ## The lane decision, measured against the runner The door admits, for a literal target, exactly what objectui's `classifyNavigationTarget` admits (`packages/core/src/actions/navigationTarget.ts`, read at objectui `origin/main` `7578323`): tab / LF / CR anywhere refused; `^https?://` (case-insensitive) admitted; `^mailto:` (case-insensitive) admitted; `./` admitted; `/` admitted unless its second character is `/` or a backslash; everything else refused. **No difference was found between the classifier and the lane paragraph.** The test file's section 3 replays the runner's own pinned cases from `navigationTarget.mailto-12191.test.ts` at that sha and answers each the same way at the door. ## Fork clause: no producer writes a refused target The instrument looked at every literal within 8 lines of a `type` / `actionType` of `'url'` or `'navigation'`, in TS, JS, JSON, MD and MDX, plus YAML `type: url`. Positive control: the showcase's `OpenDocsAction` (`https://objectstack.ai`) is found. - objectstack at `df7a137923`: 9 url-action target sites outside tests, all admitted. The hits it refuses are not url-action targets. They are `target: '_blank'` link attributes on app navigation items (`content/docs/ui/apps.mdx` ×3) and a neighbouring `type: 'form'` target (`content/docs/ui/forms.mdx:415`). - objectui at the pin `9646e2ed2bf3` and at `origin/main` `7578323`: 8 sites each, all admitted. The refused hits are `_blank` nav attributes (`apps/console/src/preview-samples.ts`, `content/docs/guide/designing-app-navigation.md`, `packages/types/src/app.ts`). - A grep for a non-admitted scheme literal in any `target` / `to` / `actionUrl` / `navigate` / `newTabUrl` value finds tests only: objectui `ActionRunner.test.ts`, `ActionRunner.onSuccessNavigation.test.ts` and `element-button-mailto-12191.test.tsx` (main only). These tests exercise the runner's refusal. ## Every schema path where a url-action target is authored Reach was measured through `ObjectStackDefinitionSchema.safeParse`, the parse `os validate` runs, and `defineStack`, with `javascript:alert(1)` as the target. Where the props bag is judged by `@objectstack/lint`'s `validateComponentProps`, that gate was measured too. | path | status | measured | | --- | --- | --- | | `ActionSchema.target`, `type: 'url'` (registered actions; also reached by `record:alert`, page toolbars and `action:bar`, which name declared actions) | **changed** | refused at `actions.0.target` by `defineStack` and the stack parse | | `InlineActionSchema.target` / `to`, `type: 'url'` or `'navigation'` (`element:button`'s `action`) | **changed** | `ElementButtonPropsSchema` refuses at `action.target`. A page's `properties` bag is outside the stack parse, so `os validate` reports it through the props gate as a `component-props-invalid` **warning**. That gate is advisory by its own header, and its posture is not this card's. | | `action:button` / `action:icon` rows, `actionType: 'url'` + `target` (`component.zod.ts`) | out of scope: outside this card's two named doors and outside the claim's file surface | accepted, 0 findings | | `action:group` / `action:menu` members, `type: 'url'` + `target` (`component.zod.ts`) | out of scope (same reason) | accepted, 0 findings | | `DashboardHeaderActionSchema.actionUrl` with `actionType: 'url'` or omitted, which objectui's `DashboardRenderer` defaults to `'url'` (`dashboard.zod.ts`) | out of scope (same reason) | accepted | | `ActionSchema.onSuccess.navigate` (`api` / `script` actions; the runner classifies it too) | out of scope: a different key, with a `${result.*}` scope; no ruling covers it | accepted | | `ActionSchema.newTabUrl` | out of scope: the runner does not classify it (only `ActionDef` declares it) | not measured | | App navigation `{ type: 'url', url }`, `FormView.submitBehavior.url` | not url actions. The first is app-shell nav; the second has its own stricter relative-only rule. | n/a | The four out-of-scope rows are one family: the doors would reuse `navigationTargetRefusal` with no second grammar. They are reported to the seat, not filed here. ## Tests and gates All spec and gate readings are at `64b6470c9`, the branch head after merging `origin/main` `c5b43b606`. - `pnpm --filter @objectstack/spec build`: exit 0. `check:generated` first named one stale artifact, `check:docs`. `gen:docs` rewrote the 4 reference pages. At `64b6470c9`, `check:generated` exits 0. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `@objectstack/spec` suite (`vitest run --project local`): 655 files, 19790 passed, 1 todo, exit 0. The new `action-url-target-scheme-set.test.ts` holds 148 tests. - **Ablation** (one-time proof, not kept). `scripts/ablation-replace.mjs` deleted the registered door's `.superRefine(refuseUnopenableUrlTarget(…))`: anchor 1 → 0, blob `2ef003fa9e4f` → `e0687bd797b0`. The new file then read **48 failed / 100 passed**: the 19 registered-door refusals, the 19 admitted-set checks and the 10 runner-parity refusals. The inline-door and acceptance cases stayed green, as expected. After the restore the blob equals HEAD (`2ef003fa9e4f`) and `git diff HEAD` is empty. - Consumers ran at `f22d8b80f`, the pre-merge head. The merge's added lines carry no url-action producer; the only hit is one changeset prose line. - `@objectstack/lint`, full suite: 137 files, 6569 passed. - `@objectstack/platform-objects`, full suite: 70 files, 1096 passed. - `@objectstack/example-showcase`, full suite: 33 files, 409 passed. - `@objectstack/runtime`, **narrowed**: 7 files, 142 passed. - `@objectstack/cli --project unit`, **narrowed**: 4 files, 76 passed. Two of the files the narrowing named, `generate-string-family-width.pin.test.ts` and `verify-author-time-stage.test.ts`, are in the `integration` tier. That tier is declared to CI. - The narrowing, declared. The population is every test file that writes a `type` / `actionType` of `'url'` or `'navigation'`, or that imports `ActionSchema`, `InlineActionSchema` or `defineAction`: 7 of 360 runtime test files and 8 of 479 cli test files. Every other test reads only in-tree producers, and those are all measured admitted (above), so their verdict cannot move. Blind spot: a test that builds a url action with a computed `type`. CI runs both full suites. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 111 commands at `64b6470c9`. **All 111 exit 0.** `--ran` reconciles 111 run, 0 NOT-MEASURED, 0 UNRUN. The first pass, at `f22d8b80f`, had 3 exit-3 prerequisite refusals, all green at `64b6470c9`: - the teardown self-test's pinned control commit was missing from the shallow clone (fetched, then green); - `check:skill-examples` needed `@objectstack/client-react` built; - `check:dual-build-cjs-loads` needed 8 unbuilt packages. - `check-adr-0087-registration`: `registered ui-action-url-target-scheme-set-closed (new here)`. `check:nul-bytes` and the raw control-byte self-scan of the touched files are both clean. - Not run locally, declared to CI: the full `@objectstack/cli` and `@objectstack/runtime` suites, and the cli `integration` tier. ## Acceptance notes - `content/docs/protocol/objectui/actions.mdx` § URL Actions teaches two admitted targets (`/_console/dashboards/…`, `https://www.google.com/maps/…`) and does not state the set. The reference page now carries it through the describe. A one-sentence statement there is a docs nicety outside the claim's surface. - The props gate's warning posture means an `element:button` with a refused inline target still publishes with a warning, until that gate's own error upgrade. --- _Generated by [Claude Code](https://claude.ai/code/session_01GZSPSQ5ArPjSJdxxuqwGoZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 038d4c9 commit 9b37edb

10 files changed

Lines changed: 513 additions & 12 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a `type: 'url'` action's `target` declares its closed scheme set — `http://`, `https://`, `mailto:` and in-app paths — and refuses every other target at parse
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: registered ui-action-url-target-scheme-set-closed -->
10+
11+
**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
`target` was a free string on every action type, so a url action whose target the console's action runner refuses — `javascript:`, `data:`, `tel:`, a protocol-relative `//host` — parsed clean, published, and failed only at the click. The scheme set is now stated once in the contract and the runtime opens exactly that set. On both url-action doors, `ActionSchema` (a registered action) and `InlineActionSchema` (an inline action, such as `element:button`'s `action`, including the legacy `type: 'navigation'` + `to` spelling), a `type: 'url'` target must be one of:
14+
15+
- an `http://` or `https://` URL (the scheme compared case-insensitively);
16+
- a `mailto:` address;
17+
- an in-app path that starts with `/` or `./`;
18+
- a template that opens with `${` (`${ctx.origin}/x`, `${param.url}`), judged by the runner after interpolation, at the click.
19+
20+
Every other target is refused at `target`, with a message that names the reason and the admitted set. `target` on every other action type (`script`, `flow`, `modal`, `api`, `form`) names a handler, flow, page, endpoint or form and is untouched, and so are interpolation and `openIn`.
21+
22+
### FROM → TO
23+
24+
| before | what to write instead |
25+
| --- | --- |
26+
| `target: 'javascript:…'` or `target: 'data:…'` | no url target runs or renders content. Move the work to a `type: 'script'` action (an inline `body`), or link to an `https://` page. |
27+
| `target: 'tel:+1…'`, `target: 'ftp://…'` or any other scheme | an `https://` page that offers it, or a `mailto:` address. `tel:` is not admitted. |
28+
| `target: 'https:example.com'` (no `//`) | `target: 'https://example.com'` |
29+
| `target: '//cdn.example.com/x'` (protocol-relative) | `target: 'https://cdn.example.com/x'` for an external link, or `target: '/x'` for an in-app path |
30+
| `target: '/\example.com'` (`/` then a backslash) | `target: 'https://example.com'`, or an in-app path written with forward slashes |
31+
| a target holding a tab, line feed or carriage return | the same target without them; percent-encode one that belongs in the address (`%09`, `%0A`, `%0D`) |
32+
| `target: 'thanks'`, `'../thanks'`, `'?tab=2'`, `'#top'` | the rooted in-app path, `target: '/thanks'`, or a `./` path |
33+
34+
**The one-line fix: give every `type: 'url'` action an `http(s)://` URL, a `mailto:` address, or an in-app path that starts with `/` or `./`.** `os migrate meta --from 17` lists the D3 entry `ui-action-url-target-scheme-set-closed`; the rewrite is by hand, because which address a refused target meant is the author's call.
35+
36+
**What an author now sees.** `os validate`, `os build`, `defineAction` and `defineStack` refuse such a target at `target`. For example, `javascript:alert(1)` gets: ``A `type: 'url'` action's `target` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted target parses unchanged.
37+
38+
**Measured producers: none outside tests.** Every url-action target written outside tests is admitted: 9 sites in this repository at `df7a137923` (the showcase's `https://objectstack.ai` among them), and 8 in objectui at both its pin `9646e2ed2bf3` and its main `7578323`. One test fixture in `@objectstack/spec` gave the url arm of an all-types loop a bare `test_handler`; it now writes `/test_handler`.

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ const result = ApiMethod.parse(data);
456456
| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Locations where this action is visible |
457457
| **component** | `Enum<'action:button' \| 'action:icon' \| 'action:menu' \| 'action:group'>` | optional | Visual component override |
458458
| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional (default: `"script"`) | Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is `operation: 'update'` + `patch` on the default `script` route. |
459-
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. |
459+
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. 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. |
460460
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). |
461461
| **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. |
462462
| **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. |

‎content/docs/references/kernel/metadata-plugin.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ const result = MetadataBulkResultSchema.parse(data);
320320
| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Locations where this action is visible |
321321
| **component** | `Enum<'action:button' \| 'action:icon' \| 'action:menu' \| 'action:group'>` | optional | Visual component override |
322322
| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional (default: `"script"`) | Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is `operation: 'update'` + `patch` on the default `script` route. |
323-
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. |
323+
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. 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. |
324324
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). |
325325
| **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. |
326326
| **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. |

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const result = ActionSchema.parse(data);
3636
| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Locations where this action is visible |
3737
| **component** | `Enum<'action:button' \| 'action:icon' \| 'action:menu' \| 'action:group'>` | optional | Visual component override |
3838
| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional (default: `"script"`) | Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is `operation: 'update'` + `patch` on the default `script` route. |
39-
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. |
39+
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. 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. |
4040
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). |
4141
| **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. |
4242
| **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. |
@@ -308,7 +308,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
308308
| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional (default: `"script"`) | Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is `operation: 'update'` + `patch` on the default `script` route. |
309309
| **name** | `string` | optional | Machine name (lowercase snake_case) |
310310
| **label** | `string \| Record<string, string>` | optional | Display label |
311-
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. |
311+
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. 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. |
312312
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). |
313313
| **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. |
314314
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). |

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ const result = ActionButtonPropsSchema.parse(data);
214214
| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional (default: `"script"`) | Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is `operation: 'update'` + `patch` on the default `script` route. |
215215
| **name** | `string` | optional | Machine name (lowercase snake_case) |
216216
| **label** | `string \| Record<string, string>` | optional | Display label |
217-
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. |
217+
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. 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. |
218218
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). |
219219
| **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. |
220220
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). |
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'ui-action-url-target-scheme-set-closed',
7+
surface: "the `target` of a `type: 'url'` action — on a registered `action` document and on an "
8+
+ 'inline action (`element:button`\'s `action`, including the legacy `type: \'navigation\'` + `to` '
9+
+ 'spelling) — that the console\'s action runner does not open: a scheme other than `http:`, '
10+
+ '`https:` or `mailto:` (`javascript:`, `data:`, `tel:` and every other), `http:` or `https:` '
11+
+ 'without the `//`, a protocol-relative `//host`, `/` followed by a backslash, a tab / line feed '
12+
+ '/ carriage return anywhere, or a bare relative target with no leading `/` or `./`',
13+
replacement: 'an `http://` or `https://` URL, a `mailto:` address, an in-app path that starts '
14+
+ 'with `/` or `./`, or a template that opens with `${` (judged after interpolation, at the '
15+
+ 'click). ⛔ NOT mechanically convertible: a bare `thanks` may mean `/thanks` or `./thanks`, '
16+
+ '`//cdn.example.com/x` may mean `https:` or an in-app path, and a `tel:` or `javascript:` '
17+
+ 'button has no admitted equivalent at all — the author chooses the address they meant, or '
18+
+ "moves the work to a different action type (a `type: 'script'` handler, a `type: 'api'` call)",
19+
reason:
20+
'The key was a free string on every action type, so a url action whose target the runner '
21+
+ 'refuses parsed clean, published, and failed only at the click — the declared-but-not-'
22+
+ 'delivered shape. The runner opens a closed set of targets, and the door now admits exactly '
23+
+ 'that set and refuses the rest at parse, with a per-reason message that names the admitted '
24+
+ 'set. No measured producer writes a refused target: the instrument read every url-action '
25+
+ 'target in this repository and in objectui at its pin and at its main, and found only '
26+
+ 'admitted ones outside tests — so the entry is owed for targets authored outside them.',
27+
acceptanceCriteria:
28+
"Every `type: 'url'` action — registered or inline — carries a target that opens with "
29+
+ '`http://`, `https://` or `mailto:` (case-insensitive), starts with `/` (not `//`, not `/` '
30+
+ 'followed by a backslash) or `./`, or opens with `${`; and none holds a tab, line feed or '
31+
+ 'carriage return. Every admitted target parses byte-identically to before, and `target` on '
32+
+ "every other action type is untouched. `os validate` reports nothing at `target` for any "
33+
+ 'url action in the stack.',
34+
};

0 commit comments

Comments
 (0)