From 509f9c1432cc4fb6d0f83eb0ff5148a2977aca6e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:50:42 +0000 Subject: [PATCH 1/9] feat(spec)!: dashboard header actionUrl and onSuccess.navigate apply the shared url-target refusal The two doors judge their value with navigation-target.ts's navigationTargetRefusal, the one grammar the console's action runner applies at the click. A header action's actionUrl is judged when actionType is 'url' or omitted; onSuccess.navigate on every value, a template that opens with ${ left to the hop. Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- packages/spec/src/ui/action.zod.ts | 33 +++- packages/spec/src/ui/dashboard.zod.ts | 48 +++++- packages/spec/src/ui/url-target-doors.test.ts | 148 ++++++++++++++++++ 3 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 packages/spec/src/ui/url-target-doors.test.ts 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/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..6f59ce92197 --- /dev/null +++ b/packages/spec/src/ui/url-target-doors.test.ts @@ -0,0 +1,148 @@ +// 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. + * + * 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 { 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.url_doors', name: 'url_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); + }); +}); From 0673f4eb56fd5742eeedc405ccb9bbece7b5b5d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:56:22 +0000 Subject: [PATCH 2/9] feat(spec): D3 entries and changeset for the two url-target doors Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .../23077-url-target-doors-scheme-set.md | 45 +++++++++++++++++++ ...n-on-success-navigate-scheme-set-closed.ts | 32 +++++++++++++ ...ard-header-action-url-scheme-set-closed.ts | 35 +++++++++++++++ packages/spec/src/ui/url-target-doors.test.ts | 2 +- 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 .changeset/23077-url-target-doors-scheme-set.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-action-on-success-navigate-scheme-set-closed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-dashboard-header-action-url-scheme-set-closed.ts 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..ef6bedcc0a8 --- /dev/null +++ b/.changeset/23077-url-target-doors-scheme-set.md @@ -0,0 +1,45 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: 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 two published authoring surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. + +Both keys 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 two doors now apply the same grammar, with no second one: + +- **`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 | +| --- | --- | +| `actionUrl: 'javascript:…'` / `navigate: 'javascript:…'`, or `data:…` | no url target runs or renders content. For a header action, declare the `actionType` 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 | +| `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 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 header action meant to run something, the `actionType` that names it.** `os migrate meta --from 17` lists the D3 entries `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`. 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 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/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/ui/url-target-doors.test.ts b/packages/spec/src/ui/url-target-doors.test.ts index 6f59ce92197..fc399800fe9 100644 --- a/packages/spec/src/ui/url-target-doors.test.ts +++ b/packages/spec/src/ui/url-target-doors.test.ts @@ -48,7 +48,7 @@ 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.url_doors', name: 'url_doors', version: '1.0.0', type: 'app' }; +const MANIFEST = { id: 'com.example.doors', name: 'doors', version: '1.0.0', type: 'app' }; // =========================================================================== // 1. DashboardHeaderActionSchema.actionUrl From f41da4ed2343e6958a2196f9eb90db1ad9388301 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:58:59 +0000 Subject: [PATCH 3/9] chore(spec): declare the two doors' refinement sites in the dropped-refinements ledger Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .../spec/dropped-refinements.baseline.json | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index f297643c161..c5179150d94 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -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", @@ -176,12 +178,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", @@ -278,12 +282,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", @@ -336,12 +342,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", @@ -393,6 +401,7 @@ "sites": [ "data.actions.element.in", "data.actions.element.in.ai.outputSchema", + "data.actions.element.in.onSuccess", "data.actions.element.in.params.element.in" ] }, @@ -401,6 +410,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", @@ -830,6 +840,7 @@ "", "actions.element.in", "actions.element.in.ai.outputSchema", + "actions.element.in.onSuccess", "actions.element.in.params.element.in", "fieldGroups", "fields.out.valueType", @@ -1009,6 +1020,7 @@ "", "actions.element.in", "actions.element.in.ai.outputSchema", + "actions.element.in.onSuccess", "actions.element.in.params.element.in" ] }, @@ -1108,6 +1120,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", @@ -1126,6 +1139,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", @@ -1170,6 +1184,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", @@ -1267,6 +1282,7 @@ "sites": [ "in", "in.ai.outputSchema", + "in.onSuccess", "in.params.element.in" ] }, @@ -1308,11 +1324,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": [ "", From 2be8c3b15f228968ec8b1e11cda1921e227a3f8d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:09:47 +0000 Subject: [PATCH 4/9] docs(references): regenerate for the two doors' describe text Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- content/docs/references/ui/action.mdx | 2 +- content/docs/references/ui/dashboard.mdx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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 | From 21f470970771eda2c45cc6a1a3ac788bcdca3154 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:22:23 +0000 Subject: [PATCH 5/9] feat(spec)!: the page action rows' url target applies the shared url-target refusal action:button / action:icon (actionType 'url') and an action:group / action:menu member (type 'url') judge target with navigation-target.ts's navigationTargetRefusal; os validate's props gate reports it at the row, pinned in @objectstack/lint. Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .../23077-url-target-doors-scheme-set.md | 19 ++++--- ...alidate-component-props-url-target.test.ts | 57 +++++++++++++++++++ ...action-row-url-target-scheme-set-closed.ts | 35 ++++++++++++ packages/spec/src/ui/component.zod.ts | 42 +++++++++++--- packages/spec/src/ui/url-target-doors.test.ts | 49 ++++++++++++++++ 5 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 packages/lint/src/validate-component-props-url-target.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-url-target-scheme-set-closed.ts diff --git a/.changeset/23077-url-target-doors-scheme-set.md b/.changeset/23077-url-target-doors-scheme-set.md index ef6bedcc0a8..6ce51d2f2b4 100644 --- a/.changeset/23077-url-target-doors-scheme-set.md +++ b/.changeset/23077-url-target-doors-scheme-set.md @@ -2,16 +2,17 @@ '@objectstack/spec': minor --- -feat(spec)!: 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 +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 two published authoring surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. +**BREAKING** — an accept-set narrowing on three published authoring surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. -Both keys 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 two doors now apply the same grammar, with no second one: +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. @@ -28,7 +29,7 @@ Every other value is refused at the key, with a message that names the reason an | before | what to write instead | | --- | --- | -| `actionUrl: 'javascript:…'` / `navigate: 'javascript:…'`, or `data:…` | no url target runs or renders content. For a header action, declare the `actionType` that does the work (`'script'`, `'flow'`, `'api'`), or link to an `https://` page; for `onSuccess`, drop it or navigate to an `https://` page. | +| `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 | @@ -36,10 +37,10 @@ Every other value is refused at the key, with a message that names the reason an | 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 | -| `actionUrl: ''` / `navigate: ''` | the address the button or the hop was meant to open; drop an `onSuccess` that navigates nowhere | +| `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 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 header action meant to run something, the `actionType` that names it.** `os migrate meta --from 17` lists the D3 entries `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. +**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`. 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. +**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 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}`. +**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/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/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/component.zod.ts b/packages/spec/src/ui/component.zod.ts index f085be38210..3308f803bfe 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. @@ -3918,6 +3919,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. @@ -4025,7 +4051,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'), @@ -4046,7 +4072,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; /** @@ -4122,7 +4148,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'), @@ -4141,7 +4167,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; /** @@ -4305,7 +4331,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'), @@ -4408,7 +4434,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. */ @@ -4427,7 +4454,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/url-target-doors.test.ts b/packages/spec/src/ui/url-target-doors.test.ts index fc399800fe9..7082ea78ba8 100644 --- a/packages/spec/src/ui/url-target-doors.test.ts +++ b/packages/spec/src/ui/url-target-doors.test.ts @@ -15,6 +15,12 @@ * 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 @@ -25,6 +31,7 @@ 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 = @@ -146,3 +153,45 @@ describe('`onSuccess.navigate` is judged by the shared set', () => { 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([]); + }); +}); From 239a0fbdcac284470d0c988e18f9e04fbb0e038b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:39:38 +0000 Subject: [PATCH 6/9] chore(spec): declare the action:button / action:icon refinement sites in the dropped-refinements ledger Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 5bcc0ec574c..9351c8d737c 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -1297,11 +1297,21 @@ "outputSchema" ] }, + "ui/ActionButtonProps": { + "sites": [ + "" + ] + }, "ui/ActionGroupProps": { "sites": [ "actions.element" ] }, + "ui/ActionIconProps": { + "sites": [ + "" + ] + }, "ui/ActionMenuProps": { "sites": [ "actions.element" From beade18b610ec669724373b360e962a0297e4d60 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 21:02:10 +0000 Subject: [PATCH 7/9] chore(spec): dropped-refinements ledger header totals match its body Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 9351c8d737c..b8181d501d4 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": 227, - "droppedRefinementSites": 713, + "publishedSchemasWithDroppedRefinements": 231, + "droppedRefinementSites": 734, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, From ed59283ff50ef870d35db074c121c699bf21cf41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 21:07:26 +0000 Subject: [PATCH 8/9] docs(references): regenerate for the page action rows' target describe Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 3d801d8c274..e111c5fa098 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 | From e9ff53c82db9703a18425a4fb2e36fb630ee4a7b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:03:44 +0000 Subject: [PATCH 9/9] chore(spec): re-derive the merged dropped-refinements ledger and component reference The merge took main's ledger side whole (228 schemas / 731 sites, #23098's 18 emptyState.link.url sites among them). build-schemas on the merged tree then printed this branch's 21 sites as undeclared or moved: 4 new entries and 17 sites in 13 existing entries. They are applied exactly as printed, and the header totals now match the body: 232 schemas / 752 sites. gen:docs regenerates ui/component.mdx from the merged tree. Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 5 ++- .../spec/dropped-refinements.baseline.json | 41 ++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index e111c5fa098..419714591b6 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -793,7 +793,7 @@ Sort field and direction pair | **label** | `string \| Record` | optional | Grid label — used as the table caption and export file title | | **title** | `string \| Record` | optional | Fallback for `label` (the renderer reads `label \|\| title`) | | **description** | `string \| Record` | optional | One line of help text drawn above the grid's rows — a string, or an inline locale map resolved against the display locale | -| **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | What the grid draws instead of an empty table: `{ title, message, icon }` — the list view's own empty-state shape | +| **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | What the grid draws instead of an empty table: `{ title, message, icon }` — the list view's own empty-state shape | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | @@ -837,6 +837,7 @@ Sort field and direction pair | **title** | `string \| Record` | optional | Heading of the empty state | | **message** | `string \| Record` | optional | Line of text below the heading | | **icon** | `string` | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph | +| **link** | `{ label: string \| Record; url: string }` | optional | [PLANNED — not yet rendered] The one next step an empty list names: a link the user follows, `{ label, url }` — for example to the how-to page that explains what belongs here when nobody can add a row. It opens a page or an address and runs no action. The console's empty-state renderers are its planned readers; until they ship, the link parses and is not drawn. | ### Nested Shape: `ObjectGridProps.columns[number]` @@ -1522,7 +1523,7 @@ View filter rule | **subtitle** | `string \| Record` | optional | Page subtitle | | **icon** | `never` | optional | [REMOVED] `page:header` property `icon` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the header's own props bag, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key. The header's own identity is drawn by the record chrome (`recordChrome`, on by default) and each action carries its own `icon`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **breadcrumb** | `never` | optional | [REMOVED] `page:header` property `breadcrumb` was removed in @objectstack/spec 17 (ADR-0087 D2) — no renderer ever drew a trail for it: objectui drew an empty slot and nothing filled it, and the navigation trail is drawn once, by the app shell's header. Delete the key, whether it was `true` or `false`; the shell's trail is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **actions** | `string[]` | optional | Action IDs to show in header | +| **actions** | `string[]` | optional | Action IDs the header draws, resolved against the actions declared on the page's object. An authored list is the whole set: an action that declares `record_header` (or `record_more`) in its `locations` but is not named here is not drawn, and omitting the key draws none of the object's actions. A named action that declares neither location renders nowhere. The host's own Edit / Share / Delete chrome is separate from this list. | | **recordChrome** | `boolean` | optional (default: `true`) | Render the record chrome — the title as a record chip with its follow star and copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. | | **showStar** | `boolean` | optional (default: `true`) | Show the follow (favourite) star beside the record title. Part of the record chrome — no effect when `recordChrome` is false. | | **showCopyId** | `boolean` | optional (default: `true`) | Show the copy-record-id button beside the record title. Part of the record chrome — no effect when `recordChrome` is false. | 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": [ "",