diff --git a/.changeset/26924-new-tab-url-d3-entry.md b/.changeset/26924-new-tab-url-d3-entry.md new file mode 100644 index 00000000000..6244b5c64bd --- /dev/null +++ b/.changeset/26924-new-tab-url-d3-entry.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +'@objectstack/platform-objects': patch +--- + +fix(spec): the `newTabUrl` path refusal is registered as an ADR-0087 D3 semantic entry, and the action form labels the field "New-tab path" + +Clause-②: no +Metadata-protocol: none + + + +Supersedes, in the same release, the ADR-0087 disposition of the `newTabUrl` narrowing note (`fix(spec)!: an action's newTabUrl is a path on this backend and must start with /`), which declared `not-required (no-migration-prescription)` while carrying a remedy. That narrowing is now registered: D3 semantic entry `ui-action-new-tab-url-path-refused` (protocol 18) names the surface (an `action` whose `newTabUrl` does not start with `/`), the two remedies (write the backend path, or drop `opensInNewTab` / `newTabUrl` and declare a `type: 'url'` action with `target: 'https://…'` and `openIn: 'new-tab'`), and why neither is applied mechanically: an external URL names no path on this backend, and the url-action rewrite changes the action's type, drops its handler and replaces an auth-enforcing endpoint with a page the browser opens directly. So `os migrate meta` and the upgrade guide carry it as a structured TODO, and a stored row holding such a value behaves exactly as that note says (loads unchanged and registered, counted `invalid` with a `[metadata_spec_invalid]` boot line, served with `_diagnostics`, next save refused until rewritten). No accept set changes here. + +The action form's `newTabUrl` field label now says path in every locale: "New-tab path" (en), "新标签页路径" (zh-CN), "新しいタブのパス" (ja-JP), "Ruta de pestaña nueva" (es-ES). diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 1ac1b3db471..43222d3d850 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -107,7 +107,7 @@ params: required: true ``` -For a **static** url, `openIn: 'new-tab'` opens `target` in a new tab and `openIn: 'self'` navigates in place; omitted, absolute URLs open in a new tab and relative ones navigate in place. For an **async handler that redirects**, set `opensInNewTab: true` instead (the renderer pre-opens the tab synchronously so popup blockers don't fire); `newTabUrl` provides a zero-roundtrip new-tab target template supporting the `{recordId}` placeholder. +For a **static** url, `openIn: 'new-tab'` opens `target` in a new tab and `openIn: 'self'` navigates in place; omitted, absolute URLs open in a new tab and relative ones navigate in place. For an **async handler that redirects**, set `opensInNewTab: true` instead (the renderer pre-opens the tab synchronously so popup blockers don't fire); `newTabUrl` skips the round-trip with a path on this backend, starting with `/` (`{recordId}` placeholder); an absolute URL is refused, so an external page is a `type: 'url'` action with `openIn: 'new-tab'`. **The interpolation scope is not authorable, and `params` is not it.** `params` diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 3f5a8fe0bdd..1e1bed20368 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1844,7 +1844,7 @@ export const enMetadataForms: NonNullable = { helpText: "Open the action RESULT in a new tab: the renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. Distinct from `openIn`, which routes a static URL target." }, newTabUrl: { - label: "New-tab URL", + label: "New-tab path", helpText: "Path on this backend, starting with / (e.g. /sso-open/{recordId}), with a {recordId} placeholder. Set together with `opensInNewTab` the renderer navigates the pre-opened tab to the backend origin + this path immediately and posts nothing — so the endpoint must enforce auth itself. An absolute URL is refused; for an external page use a url action with Open In set to new-tab." }, locations: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 82cceae79a0..049c59c4c8d 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1844,7 +1844,7 @@ export const esESMetadataForms: NonNullable = helpText: "Abre el RESULTADO de la acción en una pestaña nueva: el renderizador la abre de forma síncrona al hacer clic (a prueba de bloqueadores de ventanas emergentes) y luego la lleva a la redirectUrl del manejador. Distinto de `openIn`, que enruta un destino de URL estática." }, newTabUrl: { - label: "URL de pestaña nueva", + label: "Ruta de pestaña nueva", helpText: "Ruta en este backend, que empieza por / (p. ej. /sso-open/{recordId}), con un marcador {recordId}. Junto con `opensInNewTab`, el renderizador lleva la pestaña preabierta de inmediato al origen del backend + esta ruta y no envía nada, así que el endpoint debe aplicar la autenticación por su cuenta. Una URL absoluta se rechaza; para abrir una página externa usa una acción de tipo url con «Abrir en» en new-tab." }, locations: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index a9dbbc0c6ad..935f0462eba 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1844,7 +1844,7 @@ export const jaJPMetadataForms: NonNullable = helpText: "アクションの実行結果を新しいタブで開きます。レンダラーはクリック時に同期的にタブを先に開き(ポップアップブロックを回避します)、ハンドラーが返す redirectUrl へ遷移させます。静的な URL の行き先を決める `openIn` とは別物です。" }, newTabUrl: { - label: "新しいタブの URL テンプレート", + label: "新しいタブのパス", helpText: "このバックエンド上のパスで、/ で始めます(例: /sso-open/{recordId})。{recordId} プレースホルダーが使えます。`opensInNewTab` と併せて設定すると、レンダラーは先に開いたタブをただちに「バックエンドのオリジン + このパス」へ遷移させ、アクションの POST は行いません。したがってこのエンドポイントは自前で認証を強制する必要があります。絶対 URL は拒否されます。外部ページを開くには、url タイプのアクションで「リンクを開く場所」を new-tab にしてください。" }, locations: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 8e99cc7c52c..ba38648a865 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1844,7 +1844,7 @@ export const zhCNMetadataForms: NonNullable = helpText: "在新标签页打开动作的执行结果:渲染端在点击时同步预开一个标签页(不会被弹窗拦截),随后把它导航到处理程序返回的 redirectUrl。与 `openIn` 不同,后者决定静态 URL 目标的去向。" }, newTabUrl: { - label: "新标签页 URL 模板", + label: "新标签页路径", helpText: "本后端上的路径,必须以 / 开头(如 /sso-open/{recordId}),可用 {recordId} 占位符。与 `opensInNewTab` 同时设置时,渲染端会立即把预开的标签页导航到“后端地址 + 该路径”,不再发起动作请求——因此该端点必须自行校验鉴权。绝对 URL 会被拒绝;要打开外部页面,请用 url 类型的动作,并把“链接打开位置”设为 new-tab。" }, locations: { diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-action-new-tab-url-path-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-action-new-tab-url-path-refused.ts new file mode 100644 index 00000000000..7debb944440 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-action-new-tab-url-path-refused.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-action-new-tab-url-path-refused', + surface: '`action` documents (standalone, an object\'s `actions[]`, or a type-level action) ' + + 'whose `newTabUrl` does not start with `/` — an absolute `http(s)://` URL, or a relative ' + + 'path such as `sso-open/{recordId}`', + replacement: 'for an endpoint on this backend, its path: `newTabUrl: \'/sso-open/{recordId}\'`; ' + + 'for an external page, drop `opensInNewTab` and `newTabUrl` and declare a `type: \'url\'` ' + + 'action instead — `target: \'https://example.com/export/${ctx.recordId}\'`, ' + + '`openIn: \'new-tab\'`. ⛔ NOT mechanically convertible, so this entry is a structured TODO ' + + 'and no conversion rewrites a stored row: an external URL names no path on this backend, ' + + 'and the url-action rewrite is not one spelling of the same action — it changes `type`, ' + + 'drops the action\'s own `target` / `body` (the handler the pre-opened-tab flow falls back ' + + 'to without `newTabUrl`) and replaces the backend endpoint that enforces auth with a page ' + + 'the browser opens directly. Which of the two the author meant is an intent no artifact ' + + 'records; the refusal names both and the author chooses', + reason: + 'The console\'s zero-roundtrip fast path (`consoleServerAction.ts`) drives the pre-opened ' + + 'tab to the backend origin + `newTabUrl`, so an absolute value opened a tab at ' + + '`https://BACKEND/https://example.com/…`. The JSDoc called the key a path template while ' + + 'its describe and the form help called it a URL template, and nothing refused the absolute ' + + 'value, so AI-written metadata could carry it silently. The key exists for a same-backend ' + + 'endpoint that enforces auth itself (the `/sso-open` shape), so the accepted set is closed ' + + 'to values that start with `/` rather than widened to navigate URLs verbatim — a widening ' + + 'would let metadata open any origin from an action.', + acceptanceCriteria: + 'Every `action` document\'s `newTabUrl`, when present, starts with `/`. A path template ' + + '(`/sso-open/{recordId}`, `/api/v1/invoice/{recordId}/pdf`) parses byte-identically to ' + + 'before. Any other value is refused at `newTabUrl` with a message naming the backend-path ' + + 'form and the `type: \'url\'` + `openIn: \'new-tab\'` action for an external page. A stored ' + + 'row holding such a value is converted by nothing: it loads unchanged and is still ' + + 'registered, counted `invalid` with a `[metadata_spec_invalid]` boot line, served with ' + + '`_diagnostics` locating `newTabUrl`, and its next save is refused 422 `INVALID_METADATA` ' + + 'until the author writes a path or rewrites it as a url action.', +}; diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index 8bb9e3a70fb..31444239aa9 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -150,7 +150,7 @@ export const actionForm = defineForm({ // `FormSelectOptionSchema.value` applies: the enum derives. { field: 'openIn', visibleWhen: "data.type == 'url'", helpText: "Where to open a static `target` URL — 'self' navigates in place, 'new-tab' opens a new browser tab. Omitted, an absolute or external URL opens in a new tab and a relative one navigates in place." }, { field: 'opensInNewTab', type: 'boolean', helpText: "Open the action RESULT in a new tab: the renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. Distinct from `openIn`, which routes a static URL target." }, - { field: 'newTabUrl', label: 'New-tab URL', type: 'text', visibleWhen: "data.opensInNewTab == true", helpText: 'Path on this backend, starting with / (e.g. /sso-open/{recordId}), with a {recordId} placeholder. Set together with `opensInNewTab` the renderer navigates the pre-opened tab to the backend origin + this path immediately and posts nothing — so the endpoint must enforce auth itself. An absolute URL is refused; for an external page use a url action with Open In set to new-tab.' }, + { field: 'newTabUrl', label: 'New-tab path', type: 'text', visibleWhen: "data.opensInNewTab == true", helpText: 'Path on this backend, starting with / (e.g. /sso-open/{recordId}), with a {recordId} placeholder. Set together with `opensInNewTab` the renderer navigates the pre-opened tab to the backend origin + this path immediately and posts nothing — so the endpoint must enforce auth itself. An absolute URL is refused; for an external page use a url action with Open In set to new-tab.' }, ], }, { diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 6528450f5d1..4da86ee5311 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1720,8 +1720,9 @@ const actionObject = () => strictObject({ * action — `target: 'https://…'` (with `${ctx.recordId}` where the record * id goes) and `openIn: 'new-tab'`. * - * A stored row written before the refusal is converted by nothing (no - * backend path can be derived from an external URL): it loads unchanged, + * A stored row written before the refusal is converted by nothing (the + * rewrite is the author's choice; ADR-0087 D3 entry + * `ui-action-new-tab-url-path-refused` is its structured TODO): it loads unchanged, * is counted `invalid` with a `[metadata_spec_invalid]` boot line and still * registered, is served with `_diagnostics` locating `newTabUrl`, and its * next save is refused until the value is a path or the action is rewritten