From 28251e51d076b16261ca266560400d417c3eaec5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:33:44 +0000 Subject: [PATCH 1/2] feat(spec)!: a page action row's url target is judged on the executor the runner runs, its name leg included The console's action runner dispatches on `type || actionType || name`, so a row with no executor key whose `name` is `url` or `navigation` runs the url navigator. `effectiveRowExecutor` states that resolution once, and the rows' url-target refinement judges `target` whenever it resolves to the url navigator, whichever key spelled it. `name` stays open for every other handler. Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- ...alidate-component-props-url-target.test.ts | 35 ++++ ...nent-action-row-effective-executor.test.ts | 167 ++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 123 +++++++++---- 3 files changed, 291 insertions(+), 34 deletions(-) create mode 100644 packages/spec/src/ui/component-action-row-effective-executor.test.ts diff --git a/packages/lint/src/validate-component-props-url-target.test.ts b/packages/lint/src/validate-component-props-url-target.test.ts index d084df8bdd9..ec0654efca3 100644 --- a/packages/lint/src/validate-component-props-url-target.test.ts +++ b/packages/lint/src/validate-component-props-url-target.test.ts @@ -55,3 +55,38 @@ describe('the props gate refuses a url `target` outside the shared set on the pa expect(validateComponentProps(stackWith(node('https://example.com/docs')))).toEqual([]); }); }); + +// The name leg. The runner dispatches on `type || actionType || name`, so a +// row with no executor key whose `name` is `url` or `navigation` runs the url +// navigator; the rows judge its `target` by the same set, and the gate reports +// it at the same path. A `name` that names any other handler is untouched. + +/** [row, the node for a given name and target, the finding's path, the refusal's subject for a `name`]. */ +const NAME_LEG_ROWS: ReadonlyArray AnyRec, string, (name: string) => string]> = [ + ['action:button', (name, target) => ({ type: 'action:button', properties: { label: 'Open', name, target } }), + `${BASE}.target`, (name) => `An \`action:button\`'s \`target\` (\`name: '${name}'\` and no \`actionType\`, so the runner runs the url navigator)`], + ['action:icon', (name, target) => ({ type: 'action:icon', properties: { label: 'Open', name, target } }), + `${BASE}.target`, (name) => `An \`action:icon\`'s \`target\` (\`name: '${name}'\` and no \`actionType\`, so the runner runs the url navigator)`], + ['action:group member', (name, target) => ({ type: 'action:group', properties: { actions: [{ label: 'Open', name, target }] } }), + `${BASE}.actions.0.target`, (name) => `An \`action:group\` member's \`target\` (\`name: '${name}'\` and no \`type\`, so the runner runs the url navigator)`], + ['action:menu member', (name, target) => ({ type: 'action:menu', properties: { actions: [{ label: 'Open', name, target }] } }), + `${BASE}.actions.0.target`, (name) => `An \`action:menu\` member's \`target\` (\`name: '${name}'\` and no \`type\`, so the runner runs the url navigator)`], +]; + +describe("the props gate judges a name-leg row's `target` on the executor the runner runs", () => { + it.each(NAME_LEG_ROWS)("REFUSAL: %s named `url` or `navigation`, with no executor key, is one finding at its `target`", (_row, node, path, subject) => { + for (const name of ['url', 'navigation']) { + const findings = validateComponentProps(stackWith(node(name, 'javascript:alert(1)'))); + expect(findings.map((f) => [f.rule, f.path]), name).toEqual([[COMPONENT_PROPS_INVALID, path]]); + expect(findings[0].message, name).toContain(`${subject(name)}${JAVASCRIPT_REST}`); + } + }); + + it.each(NAME_LEG_ROWS)('ACCEPTED CONTROL: %s named `url` with an admitted target reports nothing', (_row, node) => { + expect(validateComponentProps(stackWith(node('url', 'https://example.com/docs')))).toEqual([]); + }); + + it.each(NAME_LEG_ROWS)('CONTROL: %s named after another handler reports nothing at its `target`', (_row, node) => { + expect(validateComponentProps(stackWith(node('navigate_edit', 'convert_lead')))).toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/component-action-row-effective-executor.test.ts b/packages/spec/src/ui/component-action-row-effective-executor.test.ts new file mode 100644 index 00000000000..63bc4966713 --- /dev/null +++ b/packages/spec/src/ui/component-action-row-effective-executor.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The page action rows — `action:button`, `action:icon` and an `action:group` / + * `action:menu` member — judge their `target` against the executor the + * console's action runner will RUN, not only against the executor key. + * + * The runner dispatches on `action.type || action.actionType || action.name` + * (objectui `core/src/actions/ActionRunner.ts:1265` at the `.objectui-sha` + * pin), and every row forwards its executor key as `type` beside its `name`. + * So a row with no executor key whose `name` is `url` or `navigation` runs the + * url navigator — and its `target` is judged by the one url-target grammar + * (`navigation-target.ts`), whichever key spelled the executor. ⛔ `name` is + * not refused as an executor spelling: a row whose `name` names any other + * handler is untouched. ⛔ No second URL grammar. + * + * Each pin is one refusal (its issue path and first sentence) or one accepted + * control (a full parse). `os validate`'s props gate parses a page's + * `properties` against these same rows; `@objectstack/lint`'s + * `validate-component-props-url-target` test pins that door. + */ + +import { describe, it, expect } from 'vitest'; + +import { ActionButtonPropsSchema, ActionIconPropsSchema, ComponentPropsMap } from './component.zod'; + +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[]; + +type Parser = { safeParse: (value: unknown) => { success: boolean; error?: { issues: unknown[] } } }; +const GROUP = ComponentPropsMap['action:group'] as unknown as Parser; +const MENU = ComponentPropsMap['action:menu'] as unknown as Parser; + +/** One row shape: how to write it, where its keys sit in an issue path, and how a refusal names it. */ +interface Row { + readonly row: string; + readonly parse: (action: Record) => Issue[]; + readonly at: readonly PropertyKey[]; + readonly executorKey: 'actionType' | 'type'; + /** The refusal subject's opening, before `` `target` ``. */ + readonly owner: string; +} + +const ROWS: readonly Row[] = [ + { + row: 'action:button', + parse: (action) => issuesOf(ActionButtonPropsSchema.safeParse({ label: 'Open', ...action })), + at: [], + executorKey: 'actionType', + owner: "An `action:button`'s", + }, + { + row: 'action:icon', + parse: (action) => issuesOf(ActionIconPropsSchema.safeParse({ label: 'Open', ...action })), + at: [], + executorKey: 'actionType', + owner: "An `action:icon`'s", + }, + { + row: 'action:group member', + parse: (action) => issuesOf(GROUP.safeParse({ actions: [{ label: 'Open', ...action }] })), + at: ['actions', 0], + executorKey: 'type', + owner: "An `action:group` member's", + }, + { + row: 'action:menu member', + parse: (action) => issuesOf(MENU.safeParse({ actions: [{ label: 'Open', ...action }] })), + at: ['actions', 0], + executorKey: 'type', + owner: "An `action:menu` member's", + }, +]; + +/** Every row crossed with each name-leg spelling of the url navigator. */ +const NAME_LEG = ROWS.flatMap((r) => (['url', 'navigation'] as const).map((name) => ({ ...r, name }))); + +describe("a page action row's `target` is judged on the executor the runner runs — the name leg", () => { + it.each(NAME_LEG)( + "REFUSAL: $row with `name: '$name'` and no executor key, `javascript:alert(1)`, is refused at `target`", + ({ parse, at, executorKey, owner, name }) => { + const issues = parse({ name, target: 'javascript:alert(1)' }); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...at, 'target']]]); + expect(firstSentence(issues[0].message)).toBe( + `${owner} \`target\` (\`name: '${name}'\` and no \`${executorKey}\`, so the runner runs the url navigator)` + + JAVASCRIPT_REST, + ); + }, + ); + + it.each(ROWS)( + 'REFUSAL: $row with an EMPTY executor key falls through to `name`, as the runner\'s `||` does', + ({ parse, at, executorKey, owner }) => { + const issues = parse({ [executorKey]: '', name: 'url', target: 'javascript:alert(1)' }); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...at, 'target']]]); + expect(firstSentence(issues[0].message)).toBe( + `${owner} \`target\` (\`name: 'url'\` and an empty \`${executorKey}\`, so the runner runs the url navigator)` + + JAVASCRIPT_REST, + ); + }, + ); + + it.each(NAME_LEG)("ACCEPTED CONTROL: $row with `name: '$name'` and an admitted target parses", ({ parse, name }) => { + for (const target of ['https://example.com/docs', '/apps/crm', './tasks', 'mailto:sales@example.com', '${ctx.origin}/x']) { + expect(parse({ name, target }), `${name} ${target}`).toEqual([]); + } + }); + + it.each(NAME_LEG)("CONTROL: $row with `name: '$name'` and no `target` is not judged", ({ parse, name }) => { + expect(parse({ name })).toEqual([]); + }); +}); + +describe('the executor key outranks the name, as at the runner', () => { + it.each(ROWS)( + "CONTROL: $row with `'url'` at its executor key keeps the refusal it always had, whatever the name", + ({ parse, at, executorKey, owner }) => { + const issues = parse({ [executorKey]: 'url', name: 'convert_lead', target: 'javascript:alert(1)' }); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...at, 'target']]]); + expect(firstSentence(issues[0].message)).toBe(`${owner} \`target\` (\`${executorKey}: 'url'\`)${JAVASCRIPT_REST}`); + }, + ); + + it.each(ROWS)( + "CONTROL: $row with a non-url executor key and `name: 'url'` leaves `target` alone", + ({ parse, executorKey }) => { + for (const value of ['flow', 'script', 'api', 'modal', 'form', 'navigate_edit']) { + expect(parse({ [executorKey]: value, name: 'url', target: 'convert_lead' }), value).toEqual([]); + } + }, + ); + + it.each(ROWS)( + "REFUSAL: $row with `'navigation'` at its executor key is refused at the key AND judged at `target`", + ({ parse, at, executorKey, owner }) => { + const issues = parse({ [executorKey]: 'navigation', target: 'javascript:alert(1)' }); + const byPath = Object.fromEntries(issues.map((i) => [i.path.join('.'), i])); + expect(Object.keys(byPath).sort()).toEqual([[...at, executorKey].join('.'), [...at, 'target'].join('.')].sort()); + expect(firstSentence(byPath[[...at, 'target'].join('.')].message)) + .toBe(`${owner} \`target\` (\`${executorKey}: 'navigation'\`)${JAVASCRIPT_REST}`); + }, + ); +}); + +describe("a row whose `name` names another handler is that handler's business", () => { + it.each(ROWS)("CONTROL: $row with no executor key and a non-url `name` leaves `target` alone", ({ parse }) => { + for (const name of ['navigate_edit', 'navigate_create', 'convert_lead', 'flow', 'api', 'URL']) { + expect(parse({ name, target: 'convert_lead' }), name).toEqual([]); + } + expect(parse({ target: 'convert_lead' }), 'no executor key and no name').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 34c8b368411..a3d90ed3245 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -4024,34 +4024,89 @@ const ACTION_NODE_ALIASES = { ...ACTION_TARGET_ALIASES, } as const; +/** + * The executor a page action row RUNS — its effective executor: the value the + * console's action runner dispatches on, which is not always the executor key. + * + * `ActionRunner.execute` resolves `action.type || action.actionType || + * action.name` (`core/src/actions/ActionRunner.ts:1265` at the `.objectui-sha` + * pin `1fb1ced36243`, and unchanged at objectui `main` `866763bd`). Every row + * forwards its executor key as the runner's `type` — a node `type: + * schema.actionType` (`action-button.tsx:286`, `action-icon.tsx:167`), a + * member `type: action.type` (`action-group.tsx:350`, `action-menu.tsx:274`) + * — and its `name` beside it. So a row with no executor key runs the handler + * its `name` names: the runner's name leg, which `action-button.tsx:283-285` + * documents. The registrations' `defaultProps` (`actionType: 'script'`) are + * read by the designer palette only, never at render, so an omitted key stays + * omitted. + * + * `||`, as the runner writes it, not `??`: an EMPTY executor key falls through + * to `name` at the click, so it falls through here too. ⛔ `name` is not + * refused as an executor spelling: the runner declares that leg, and a row + * whose `name` names any other handler is that handler's business. This only + * states, once, which executor the contract judges a row's keys against. + */ +function effectiveRowExecutor( + row: { actionType?: string; type?: string; name?: string }, + executorKey: 'actionType' | 'type', +): string | undefined { + return row[executorKey] || row.name || undefined; +} + +/** + * The executors the runner sends to its url navigator, `navigateTo`: the + * built-in `url` (`executeUrl`, reading `target || redirect`) and its local + * alias `navigation` (`executeNavigation`, reading `to || target || redirect`), + * the two `builtinExecutors` rows that call it (`ActionRunner.ts:1145`, + * `:1150` at the pin). A host handler is dispatched before the built-ins, and + * the console registers none under either name (`ConsoleShell.tsx:97`, + * `useConsoleActionRuntime.tsx:803`, `AppContent.tsx`, `useObjectActions.ts` + * at the pin). + */ +const ROW_URL_NAVIGATOR_EXECUTORS: ReadonlySet = new Set(['url', 'navigation']); + /** * 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 + * `action:icon` and on an `action:group` / `action:menu` member, whenever the + * row's effective executor ({@link effectiveRowExecutor}) is the url navigator + * ({@link ROW_URL_NAVIGATOR_EXECUTORS}), whichever key spelled it: the + * executor key (`actionType: 'url'` on a node, `type: 'url'` on a member), or, + * with that key omitted or empty, the row's `name` (`name: 'url'` or + * `name: 'navigation'`). Every row 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; the url executor's alias spelling `'navigation'` is - * refused at the executor key ({@link refuseRowUrlExecutorAlias}), so no row - * whose EXECUTOR KEY names the url navigator reaches it with its `target` - * unjudged. A row with no executor key at all is outside both rules: the - * runner then dispatches on the row's `name`, so `name: 'url'` reaches the - * same navigator with `target` judged only at the click — that leg is #23168's - * question, not settled here. 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); + * navigator. ⛔ No second URL grammar here. + * + * On every other effective executor `target` names a script, flow, page, + * endpoint or form and is untouched. The alias spelling at the executor key + * (`actionType: 'navigation'`) is also refused there, with its rename + * ({@link refuseRowUrlExecutorAlias}); its `target` is judged here as well, so + * the author learns both at once. 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. + * + * `owner` opens the subject ("An `action:button`'s"); the parenthetical names + * the spelling that resolved the executor, so an `actionType: 'url'` row reads + * as it always has, and a name-leg row learns that declaring the executor key + * is how a `name` stops choosing the url navigator. + */ +function refuseUnopenableRowUrlTarget(executorKey: 'actionType' | 'type', owner: string) { + return ( + row: { actionType?: string; type?: string; name?: string; target?: string }, + ctx: z.RefinementCtx, + ): void => { + const executor = effectiveRowExecutor(row, executorKey); + if (executor === undefined || !ROW_URL_NAVIGATOR_EXECUTORS.has(executor) || typeof row.target !== 'string') return; + const spelled = row[executorKey] + ? `\`${executorKey}: '${executor}'\`` + : `\`name: '${executor}'\` and ${row[executorKey] === '' ? 'an empty' : 'no'} \`${executorKey}\`, ` + + 'so the runner runs the url navigator'; + const refusal = navigationTargetRefusal(row.target, `${owner} \`target\` (${spelled})`); if (refusal !== undefined) ctx.addIssue({ code: 'custom', path: ['target'], message: refusal }); }; } @@ -4215,7 +4270,7 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ guidance: ACTION_NODE_GUIDANCE, }, { name: z.string().optional() - .describe('Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone'), + .describe('Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target'), label: z.string().optional() .describe('Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map'), icon: z.string().optional() @@ -4235,7 +4290,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`. 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'), + .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: \'url\'`, or no `actionType` and a `name` of `url` or `navigation`): 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'), @@ -4257,7 +4312,7 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), 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'`)")) +}).superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:button`'s")) .superRefine(refuseRowUrlExecutorAlias('actionType', "An `action:button`'s `actionType: 'navigation'`"))); /** Author state (ADR-0122: the bare name is the author state). */ export type ActionButtonProps = z.input; @@ -4317,7 +4372,7 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ }, }, { name: z.string().optional() - .describe('Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`'), + .describe('Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target'), label: z.string().optional() .describe('Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id'), icon: z.string().optional() @@ -4335,7 +4390,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`. 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'), + .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: \'url\'`, or no `actionType` and a `name` of `url` or `navigation`): 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'), @@ -4355,7 +4410,7 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), 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'`)")) +}).superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:icon`'s")) .superRefine(refuseRowUrlExecutorAlias('actionType', "An `action:icon`'s `actionType: 'navigation'`"))); /** Author state (ADR-0122: the bare name is the author state). */ export type ActionIconProps = z.input; @@ -4504,7 +4559,7 @@ export type ActionIconPropsParsed = z.infer; function actionContainerMemberShape() { return { name: z.string().optional() - .describe('Action name, forwarded to the action runner, and the item\'s text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`'), + .describe('Action name, forwarded to the action runner, and the item\'s text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target'), label: z.string().optional() .describe('The item\'s text. A literal string, placed as-is — localize through the translation bundle entry for this component id'), icon: z.string().optional() @@ -4524,7 +4579,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`. 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'), + .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: \'url\'`, or no `type` and a `name` of `url` or `navigation`): 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'), @@ -4629,7 +4684,7 @@ function buildActionGroupMember() { 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(refuseUnopenableRowUrlTarget('type', "An `action:group` member's `target` (`type: 'url'`)")) + .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:group` member's")) .superRefine(refuseRowUrlExecutorAlias('type', "An `action:group` member's `type: 'navigation'`")); } let actionGroupMemberOnce: ReturnType | undefined; @@ -4650,7 +4705,7 @@ function buildActionMenuMember() { + 'read a member\'s `size`.', }, }, actionContainerMemberShape()).superRefine(actionContainerMemberParamsFitType('action:menu')) - .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:menu` member's `target` (`type: 'url'`)")) + .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:menu` member's")) .superRefine(refuseRowUrlExecutorAlias('type', "An `action:menu` member's `type: 'navigation'`")); } let actionMenuMemberOnce: ReturnType | undefined; From b76c26a9964c7c446409391ec8981e71e34d0941 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:51:32 +0000 Subject: [PATCH 2/2] feat(spec): the name-leg url target's D3 entry, changeset and regenerated component reference Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- ...168-page-action-row-name-leg-url-target.md | 31 +++++++++++++++ content/docs/references/ui/component.mdx | 16 ++++---- ...e-action-row-name-leg-url-target-judged.ts | 38 +++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 .changeset/23168-page-action-row-name-leg-url-target.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-name-leg-url-target-judged.ts diff --git a/.changeset/23168-page-action-row-name-leg-url-target.md b/.changeset/23168-page-action-row-name-leg-url-target.md new file mode 100644 index 00000000000..22d1ff44103 --- /dev/null +++ b/.changeset/23168-page-action-row-name-leg-url-target.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: a page action row's url `target` is judged on the executor the console's action runner actually runs, so a row with no executor key whose `name` is `url` or `navigation` has its `target` judged by the url scheme set + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on one key of four published page component rows, shipped as `minor` under the launch-window convention for accept-set narrowings. + +The page action rows are `action:button` and `action:icon` (`properties`), and each member of an `action:group` or `action:menu` (`properties.actions[]`). The console's action runner dispatches a row on `type || actionType || name`, and every row forwards its executor key (`actionType` on a node, `type` on a member) as `type` beside its `name`. So a row with no executor key runs the handler its `name` names, and `name: 'url'` or `name: 'navigation'` runs the url navigator. The rows judged `target` by the url scheme set only when the executor KEY read `'url'`, so such a row parsed with a target the click refuses. + +The rows now judge `target` against the row's effective executor: the executor key, or, when that key is omitted or empty, the `name`, exactly as the runner resolves it. When it resolves to the url navigator, `target` is judged by the same url scheme set as an `actionType: 'url'` row, with no second grammar. A declared executor key still outranks the `name`. `name` is not refused as an executor spelling, and a row whose `name` names any other handler (`navigate_edit`, `convert_lead`, …) is untouched. An `actionType: 'url'` row's refusal is unchanged. + +### FROM → TO + +| before | what to write instead | +| --- | --- | +| `{ name: 'url', target: 'javascript:…' }` with no `actionType` (or no `type` on a member), or `data:…`, `tel:…` or any other scheme | an `https://` page, a `mailto:` address, or an in-app path that starts with `/` or `./`; or, for a button meant to run something, the executor that does the work (`actionType: 'script'`, `'flow'`, `'api'`) | +| the same with `name: 'navigation'` | the same; the `name` is the runner's url alias, so the target is judged as a url | +| `{ name: 'url', target: 'forecast' }`, `'../forecast'`, `'?tab=2'` | the rooted in-app path, `'/forecast'`, or a `./` path; or declare the executor the row was meant to run | +| `{ name: 'url', target: '//cdn.example.com/x' }` | `'https://cdn.example.com/x'`, or `'/x'` for an in-app path | +| `{ name: 'url', target: '' }` | the address the button was meant to open | + +**The one-line fix: give every page action row that has no executor key and is named `url` or `navigation` an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` as its `target`, or declare the executor key it was meant to run.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-name-leg-url-target-judged`; the rewrite is by hand, because which address a refused target meant is the author's call. + +**What an author now sees.** `os validate`'s props gate reports the refusal at the row's `target` (`properties.target`, or `properties.actions.N.target` for a member). For example, `{ name: 'url', target: 'javascript:alert(1)' }` on an `action:button` gets: ``An `action:button`'s `target` (`name: 'url'` and no `actionType`, so the runner runs the url navigator) opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted target parses unchanged. + +**Measured producers: none.** No page action row in this repository, in objectui (at its pin `1fb1ced36243` and its main `866763bd`) or in hotcrm (at `0f84c9c`) is named `url` or `navigation`. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 4d5a39b5ec1..4db12657930 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -28,7 +28,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone | +| **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target | | **label** | `string` | optional | Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map | | **icon** | `string` | optional | Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type`: on a page component `type` is the component itself | @@ -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`. 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 | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no `actionType` and a `name` of `url` or `navigation`): 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 | @@ -86,7 +86,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` | +| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target | | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | @@ -96,7 +96,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`. 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 | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: 'url'`, or no `type` and a `name` of `url` or `navigation`): 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 | @@ -123,7 +123,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button` | +| **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target | | **label** | `string` | optional | Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`) | | **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type` | @@ -132,7 +132,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`. 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 | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no `actionType` and a `name` of `url` or `navigation`): 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 | @@ -176,7 +176,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` | +| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target | | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | @@ -186,7 +186,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`. 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 | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: 'url'`, or no `type` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | | **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | | **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-name-leg-url-target-judged.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-name-leg-url-target-judged.ts new file mode 100644 index 00000000000..fd6c1ae181d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-name-leg-url-target-judged.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-page-action-row-name-leg-url-target-judged', + surface: 'the `target` of a page action row with no executor key (or an empty one) whose `name` is `\'url\'` or ' + + '`\'navigation\'` — an `action:button` or `action:icon` with no `actionType`, or an `action:group` / ' + + '`action:menu` member with no `type` — 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) — the set a url row\'s `target` ' + + 'already takes (`ui-page-action-row-url-target-scheme-set-closed`). Or, for a row whose `name` merely happens ' + + 'to read `url` and that was meant to run something else, declare its executor (`actionType` on a node, ' + + '`type` on a member): a declared executor key outranks the `name` at the runner and here alike. ⛔ NOT ' + + 'mechanically convertible: which address a refused target meant, or which executor the row was meant to ' + + 'run, is the author\'s call.', + reason: + 'The runner dispatches on `type || actionType || name`, and every row forwards its executor key as `type` ' + + 'beside its `name`, so a row with no executor key runs the handler its `name` names; `url` and `navigation` ' + + 'reach the url navigator. The rows judged `target` only when the executor KEY read `\'url\'`, so such a row ' + + 'carried a target the click refuses past the parse. The rows now judge `target` against the executor the ' + + 'runner runs, whichever key spelled it, with the one url-target grammar. `name` is not refused as an ' + + 'executor spelling: a row whose `name` names any other handler is untouched. A page\'s `properties` is not ' + + 'parsed on the metadata save or load path; `os validate`\'s props gate parses it against these same rows ' + + 'and reports the refusal at the row\'s `target`. No measured producer writes such a row: no page action row ' + + 'in this repository, in objectui or in hotcrm is named `url` or `navigation` — so the entry is owed for rows ' + + 'authored outside them.', + acceptanceCriteria: + 'Every `action:button` / `action:icon` with no (or an empty) `actionType` and every `action:group` / ' + + '`action:menu` member with no (or an empty) `type` whose `name` is `\'url\'` or `\'navigation\'` 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 — or declares its executor key. `os validate`\'s props gate reports nothing at those rows\' `target`, ' + + 'and every row whose `name` names another handler parses as before.', +};