From 87ef9a91a197aabc48191f30a54934b0a3968572 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:49:17 +0000 Subject: [PATCH 1/3] fix(spec): newTabUrl is a backend path; dashboard read-refusal prose; north-star citations (WIP) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzhJoJYPHSvBpq8euSq --- packages/formula/README.md | 2 +- packages/formula/src/index.ts | 2 +- .../src/protocol.stored-conversions.test.ts | 50 ++++++++++ .../en.metadata-forms.generated.ts | 2 +- .../es-ES.metadata-forms.generated.ts | 2 +- .../ja-JP.metadata-forms.generated.ts | 2 +- .../zh-CN.metadata-forms.generated.ts | 2 +- packages/spec/liveness/action.json | 5 +- packages/spec/liveness/dashboard.json | 2 +- packages/spec/src/data/hook-body.zod.ts | 2 - packages/spec/src/shared/expression.zod.ts | 2 +- .../src/system/environment-artifact.zod.ts | 4 +- .../spec/src/ui/action-newtaburl-path.test.ts | 92 +++++++++++++++++++ packages/spec/src/ui/action.form.ts | 2 +- packages/spec/src/ui/action.zod.ts | 34 +++++-- packages/spec/src/ui/dashboard.zod.ts | 15 ++- skills/objectstack-ui/rules/actions.md | 8 +- 17 files changed, 202 insertions(+), 26 deletions(-) create mode 100644 packages/spec/src/ui/action-newtaburl-path.test.ts diff --git a/packages/formula/README.md b/packages/formula/README.md index d11c74b8f10..285877a47dd 100644 --- a/packages/formula/README.md +++ b/packages/formula/README.md @@ -6,4 +6,4 @@ Canonical expression engine for ObjectStack. - **Dialect: `js`** — sandboxed L2 hook bodies (delegated). - **Dialect: `cron`** — job schedules (delegated). -See `content/docs/concepts/north-star.mdx` §8 "No private expression DSL" and `ROADMAP.md` M9. +See ADR-0032 (`docs/adr/0032-unified-expression-layer.md`, "one CEL language") and `ROADMAP.md` M9. diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index 9a4645d8efa..8deea7522ca 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -5,7 +5,7 @@ * Language) is the default dialect; `js` and `cron` are dispatched to * dedicated plugin engines. * - * @see content/docs/concepts/north-star.mdx §8 "No private expression DSL" + * @see docs/adr/0032-unified-expression-layer.md — "one CEL language" * @see ROADMAP.md M9 "Expression Unification" */ diff --git a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts index 60b999ff699..c4a8355fd60 100644 --- a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts @@ -310,6 +310,56 @@ describe('a stored select with no option source — read and named, never droppe }); }); +// ── the newTabUrl path door's disposition for a STORED row ───────────────────── +// +// `ActionSchema.newTabUrl` refuses a value that does not start with `/`: it is +// a path on this backend, which the console prefixes with the backend origin. +// `saveMetaItem` stores the authored body verbatim, so a row saved before the +// refusal can hold an absolute URL. No conversion can derive a backend path +// from an external URL, so the row keeps its bytes and is READ AND NAMED: +// served with `_diagnostics` locating `newTabUrl`, counted `invalid` at boot +// and still registered (its next save is refused 422 by the same schema). +const absoluteNewTabUrlRow = { + type: 'action', + name: 'export_invoice', + metadata: { + name: 'export_invoice', label: 'Export', type: 'script', objectName: 'crm_invoice', + target: 'exportInvoice', opensInNewTab: true, + newTabUrl: 'https://example.com/export/{recordId}', + }, +}; + +describe('a stored action whose newTabUrl is an absolute URL — read and named, never rewritten', () => { + it('getMetaItem serves the row unchanged with _diagnostics naming newTabUrl', async () => { + const { engine } = makeStubEngine([absoluteNewTabUrlRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + const res: any = await protocol.getMetaItem({ type: 'action', name: 'export_invoice' }); + expect(res.item.newTabUrl).toBe('https://example.com/export/{recordId}'); + expect(res.item._diagnostics?.valid).toBe(false); + expect(res.item._diagnostics.errors.map((e: { path?: string }) => e.path)).toContain('newTabUrl'); + }); + + it('boot hydration counts it invalid and STILL registers it with the stored value', async () => { + const { engine, registered } = makeStubEngine([absoluteNewTabUrlRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + const res = await protocol.loadMetaFromDb(); + expect(res).toEqual({ loaded: 1, errors: 0, invalid: 1, storeUnavailable: false }); + const action = registered.find((r) => r.kind === 'item' && r.type === 'action')!; + expect(action.body.newTabUrl).toBe('https://example.com/export/{recordId}'); + }); + + it('CONTROL: the same row with a backend path loads valid', async () => { + const pathRow = { + ...absoluteNewTabUrlRow, + metadata: { ...absoluteNewTabUrlRow.metadata, newTabUrl: '/export/{recordId}' }, + }; + const { engine } = makeStubEngine([pathRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + const res = await protocol.loadMetaFromDb(); + expect(res).toEqual({ loaded: 1, errors: 0, invalid: 0, storeUnavailable: false }); + }); +}); + // ── [#20101] a declared DEFAULT is part of the canonical served shape ───────── // // `PageSchema` declares `type: PageTypeSchema.default('record')`: a page diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 1bf6bb40b84..ad4330920bb 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1837,7 +1837,7 @@ export const enMetadataForms: NonNullable = { }, newTabUrl: { label: "New-tab URL", - helpText: "Direct new-tab URL template, with a {recordId} placeholder. Set together with `opensInNewTab` the renderer navigates the pre-opened tab here immediately and posts nothing — so the endpoint must enforce auth itself." + helpText: "Path on this backend, starting with / (e.g. /sso-open/{recordId}), with a {recordId} placeholder. Set together with `opensInNewTab` the renderer navigates the pre-opened tab to the backend origin + this path immediately and posts nothing — so the endpoint must enforce auth itself. An absolute URL is refused; for an external page use a url action with Open In set to new-tab." }, locations: { label: "Locations", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 97f7a5ac903..f872d9ca91f 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1837,7 +1837,7 @@ export const esESMetadataForms: NonNullable = }, newTabUrl: { label: "URL de pestaña nueva", - helpText: "Plantilla de URL directa para la pestaña nueva, con un marcador {recordId}. Junto con `opensInNewTab`, el renderizador lleva la pestaña preabierta aquí de inmediato y no envía nada, así que el endpoint debe aplicar la autenticación por su cuenta." + helpText: "Ruta en este backend, que empieza por / (p. ej. /sso-open/{recordId}), con un marcador {recordId}. Junto con `opensInNewTab`, el renderizador lleva la pestaña preabierta de inmediato al origen del backend + esta ruta y no envía nada, así que el endpoint debe aplicar la autenticación por su cuenta. Una URL absoluta se rechaza; para abrir una página externa usa una acción de tipo url con «Abrir en» en new-tab." }, locations: { label: "Ubicaciones", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 0c1da605a44..6ab1c9b166e 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1837,7 +1837,7 @@ export const jaJPMetadataForms: NonNullable = }, newTabUrl: { label: "新しいタブの URL テンプレート", - helpText: "新しいタブで直接開く URL テンプレート。{recordId} プレースホルダーが使えます。`opensInNewTab` と併せて設定すると、レンダラーは先に開いたタブをただちにここへ遷移させ、アクションの POST は行いません。したがってこのエンドポイントは自前で認証を強制する必要があります。" + helpText: "このバックエンド上のパスで、/ で始めます(例: /sso-open/{recordId})。{recordId} プレースホルダーが使えます。`opensInNewTab` と併せて設定すると、レンダラーは先に開いたタブをただちに「バックエンドのオリジン + このパス」へ遷移させ、アクションの POST は行いません。したがってこのエンドポイントは自前で認証を強制する必要があります。絶対 URL は拒否されます。外部ページを開くには、url タイプのアクションで「リンクを開く場所」を new-tab にしてください。" }, locations: { label: "表示位置", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index eae72e313c1..f425db89ea9 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1837,7 +1837,7 @@ export const zhCNMetadataForms: NonNullable = }, newTabUrl: { label: "新标签页 URL 模板", - helpText: "直接使用的新标签页 URL 模板,可用 {recordId} 占位符。与 `opensInNewTab` 同时设置时,渲染端会立即把预开的标签页导航到这里,不再发起动作请求——因此该端点必须自行校验鉴权。" + helpText: "本后端上的路径,必须以 / 开头(如 /sso-open/{recordId}),可用 {recordId} 占位符。与 `opensInNewTab` 同时设置时,渲染端会立即把预开的标签页导航到“后端地址 + 该路径”,不再发起动作请求——因此该端点必须自行校验鉴权。绝对 URL 会被拒绝;要打开外部页面,请用 url 类型的动作,并把“链接打开位置”设为 new-tab。" }, locations: { label: "位置", diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index a76b4e010e8..d427062e37a 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -204,7 +204,10 @@ }, "newTabUrl": { "status": "live", - "note": "objectui." + "verifiedAt": "2026-10-28", + "evidenceScope": "in-repo", + "evidence": "ui/packages/app-shell/src/utils/consoleServerAction.ts#createConsoleServerActionHandler (behind `takesNewTabUrlFastPath`, drives the pre-opened tab to the backend origin + `newTabUrl` with `{recordId}` substituted, and posts nothing)", + "note": "A path on the same backend: the spec refuses at parse any value that does not start with / (an absolute URL opened the backend origin + the whole URL). An external page is a type:'url' action with openIn: 'new-tab'." }, "openIn": { "status": "live", diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index c7138a73c92..6df6109c7bb 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -158,7 +158,7 @@ "status": "live", "verifiedAt": "2026-08-03", "evidence": "ui/packages/app-shell/src/views/DashboardView.tsx:215-219 (prunes widgets whose requiresObject is not in the runtime SchemaRegistry)", - "note": "client-side capability gate. NOT gated server-side — packages/rest/src/rest-server.ts:1818 names `requiresObject` as deliberately not filtered there, unlike its `requiresService` sibling." + "note": "client-side capability gate. NOT gated server-side — packages/rest/src/rest-server.ts:1818 names `requiresObject` as deliberately not filtered there, unlike its `requiresService` sibling. An install gate only: a dataset-bound tile the server refuses as a read refusal (403 PERMISSION_DENIED) is left out for that viewer outside design mode by DashboardRenderer, with no key." }, "requiresService": { "status": "live", diff --git a/packages/spec/src/data/hook-body.zod.ts b/packages/spec/src/data/hook-body.zod.ts index 49692ce92e2..378228cc9c0 100644 --- a/packages/spec/src/data/hook-body.zod.ts +++ b/packages/spec/src/data/hook-body.zod.ts @@ -252,8 +252,6 @@ export type ScriptBodyParsed = z.infer; * The compiled-module path (`.mjs` envelope) is intentionally **not** part of * this union. All bodies are pure metadata and travel inside the project * artifact JSON — no separate runtime module is required. - * - * @see content/docs/concepts/north-star.mdx — "Metadata-only runtime" */ export const HookBodySchema = z.discriminatedUnion('language', [ ExpressionBodySchema, diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index 2fb7b4000ba..8abe29c6a12 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -56,7 +56,7 @@ import { cronExpressionInput, templateExpressionInput, type TypedExpressionRefus * routed through this schema — they stay driver-native because their security * posture and portability story differ. * - * @see content/docs/concepts/north-star.mdx §8 "No private expression DSL" + * @see docs/adr/0032-unified-expression-layer.md — "one CEL language" */ /** diff --git a/packages/spec/src/system/environment-artifact.zod.ts b/packages/spec/src/system/environment-artifact.zod.ts index 7dc1e59127a..372cc5ca399 100644 --- a/packages/spec/src/system/environment-artifact.zod.ts +++ b/packages/spec/src/system/environment-artifact.zod.ts @@ -43,8 +43,8 @@ import { PluginPermissionsSchema } from '../kernel/manifest.zod'; * `metadata`, outside the `checksum` digest. Absent ≠ `{}` — see the * key's docblock. * - * See {@link content/docs/concepts/north-star.mdx} §6.3 for the - * runtime-inputs boundary. + * See `content/docs/concepts/architecture-overview.mdx` § "Environment + * Artifact" for the runtime-inputs boundary. * * ## History (#4740) * diff --git a/packages/spec/src/ui/action-newtaburl-path.test.ts b/packages/spec/src/ui/action-newtaburl-path.test.ts new file mode 100644 index 00000000000..6883690d35f --- /dev/null +++ b/packages/spec/src/ui/action-newtaburl-path.test.ts @@ -0,0 +1,92 @@ +// `newTabUrl` is a path on the SAME backend, never a URL. +// +// The console's zero-roundtrip fast path (`consoleServerAction.ts`) drives the +// pre-opened tab to the backend origin + `newTabUrl`, so an absolute value such +// as `https://example.com/export/{recordId}` opened a tab at +// `https://backend.example/https://example.com/…`. The JSDoc already called the +// key a "path template"; the describe and the form help said "URL template", +// and nothing refused the absolute value at authoring. The key now refuses any +// value that does not start with `/`, and the refusal names the external-page +// remedy: a `type: 'url'` action with `openIn: 'new-tab'`. +import { describe, it, expect } from 'vitest'; +import { ActionSchema } from './action.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; + +const base = { + name: 'export_invoice', + label: 'Export invoice', + type: 'script' as const, + target: 'exportInvoice', + opensInNewTab: true, +}; + +function refusalOf(newTabUrl: string) { + const r = ActionSchema.safeParse({ ...base, newTabUrl }); + expect(r.success).toBe(false); + const issues = r.error!.issues.filter((i) => i.path.join('.') === 'newTabUrl'); + expect(issues).toHaveLength(1); + return issues[0]!; +} + +describe('ActionSchema — newTabUrl must be a backend path starting with /', () => { + describe('refusal pins', () => { + it('refuses an https:// URL, located on newTabUrl, with the prescription', () => { + const issue = refusalOf('https://example.com/export/{recordId}'); + expect(issue.code).toBe('invalid_format'); + // The prescription is the contract here: the path form, and the action + // type that opens an external page. + expect(issue.message).toMatch(/^`newTabUrl` is a path on this backend and must start with `\/`/); + expect(issue.message).toContain("type: 'url'"); + expect(issue.message).toContain("openIn: 'new-tab'"); + }); + + it('refuses an http:// absolute URL the same way', () => { + const issue = refusalOf('http://intranet.example/print/{recordId}'); + expect(issue.code).toBe('invalid_format'); + expect(issue.message).toContain("type: 'url'"); + }); + + it('refuses a bare relative path (no leading /), which has no base in the pre-opened tab', () => { + expect(refusalOf('sso-open/{recordId}').code).toBe('invalid_format'); + }); + + it('is refused through the registered `action` metadata schema too (the parsing door)', () => { + const schema = getMetadataTypeSchema('action'); + expect(schema).toBeDefined(); + const r = schema!.safeParse({ ...base, newTabUrl: 'https://example.com/export/{recordId}' }); + expect(r.success).toBe(false); + expect(r.error!.issues.some((i) => i.path.join('.') === 'newTabUrl')).toBe(true); + }); + }); + + describe('acceptance pins — a path template parses byte-identically', () => { + it('/path/{recordId}: accepted, output unchanged', () => { + const out = ActionSchema.parse({ ...base, newTabUrl: '/api/v1/invoice/{recordId}/pdf' }); + expect(out).toEqual({ + name: 'export_invoice', + label: 'Export invoice', + type: 'script', + target: 'exportInvoice', + refreshAfter: false, + opensInNewTab: true, + newTabUrl: '/api/v1/invoice/{recordId}/pdf', + }); + }); + + it('a path with a query string and the placeholder in it: accepted', () => { + const r = ActionSchema.safeParse({ ...base, newTabUrl: '/sso-open?recordId={recordId}' }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + }); + + it('the external-page remedy the refusal names parses: a type:url action with openIn new-tab', () => { + const r = ActionSchema.safeParse({ + name: 'open_portal', + label: 'Open portal', + type: 'url', + target: 'https://example.com/export/${ctx.recordId}', + openIn: 'new-tab', + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + }); + }); +}); diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index 2057710da5b..8bb9e3a70fb 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -150,7 +150,7 @@ export const actionForm = defineForm({ // `FormSelectOptionSchema.value` applies: the enum derives. { field: 'openIn', visibleWhen: "data.type == 'url'", helpText: "Where to open a static `target` URL — 'self' navigates in place, 'new-tab' opens a new browser tab. Omitted, an absolute or external URL opens in a new tab and a relative one navigates in place." }, { field: 'opensInNewTab', type: 'boolean', helpText: "Open the action RESULT in a new tab: the renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. Distinct from `openIn`, which routes a static URL target." }, - { field: 'newTabUrl', label: 'New-tab URL', type: 'text', visibleWhen: "data.opensInNewTab == true", helpText: 'Direct new-tab URL template, with a {recordId} placeholder. Set together with `opensInNewTab` the renderer navigates the pre-opened tab here immediately and posts nothing — so the endpoint must enforce auth itself.' }, + { field: 'newTabUrl', label: 'New-tab URL', type: 'text', visibleWhen: "data.opensInNewTab == true", helpText: 'Path on this backend, starting with / (e.g. /sso-open/{recordId}), with a {recordId} placeholder. Set together with `opensInNewTab` the renderer navigates the pre-opened tab to the backend origin + this path immediately and posts nothing — so the endpoint must enforce auth itself. An absolute URL is refused; for an external page use a url action with Open In set to new-tab.' }, ], }, { diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index efa6a69bd31..6528450f5d1 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1706,14 +1706,34 @@ const actionObject = () => strictObject({ opensInNewTab: z.boolean().optional().describe('Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler\'s redirectUrl.'), /** - * Zero-roundtrip new-tab target. A path template the renderer navigates - * the pre-opened tab to IMMEDIATELY on click, skipping the action POST - * entirely. Only valid together with `opensInNewTab`. The target endpoint - * MUST perform all auth/authz itself (e.g. the cloud `/sso-open` endpoint, - * which re-runs every check the POST half would have done). Supports the - * `{recordId}` placeholder, URL-encoded on substitution. + * Zero-roundtrip new-tab target. A path template on the SAME backend the + * renderer navigates the pre-opened tab to IMMEDIATELY on click, skipping + * the action POST entirely. Only valid together with `opensInNewTab`. The + * target endpoint MUST perform all auth/authz itself (e.g. the cloud + * `/sso-open` endpoint, which re-runs every check the POST half would have + * done). Supports the `{recordId}` placeholder, URL-encoded on substitution. + * + * **A path, never a URL.** The value must start with `/`: the console + * prefixes the backend origin to it (`consoleServerAction.ts`), so an + * absolute `https://…` value opened a tab at origin + the whole URL. Any + * other value is refused at parse. An external page is a `type: 'url'` + * action — `target: 'https://…'` (with `${ctx.recordId}` where the record + * id goes) and `openIn: 'new-tab'`. + * + * A stored row written before the refusal is converted by nothing (no + * backend path can be derived from an external URL): it loads unchanged, + * is counted `invalid` with a `[metadata_spec_invalid]` boot line and still + * registered, is served with `_diagnostics` locating `newTabUrl`, and its + * next save is refused until the value is a path or the action is rewritten + * as a `type: 'url'` action. */ - newTabUrl: z.string().optional().describe('Direct new-tab URL template ({recordId} placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself.'), + newTabUrl: z.string().regex(/^\//, { + message: + '`newTabUrl` is a path on this backend and must start with `/` (e.g. `/sso-open/{recordId}`): ' + + 'the console prefixes the backend origin to it, so an absolute or relative URL opens a broken tab. ' + + "To open an external page, use a `type: 'url'` action with `target: 'https://…'` " + + "(`${ctx.recordId}` where the record id goes) and `openIn: 'new-tab'`.", + }).optional().describe("New-tab path template on this backend, starting with / ({recordId} placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab to the backend origin + this path immediately — no action POST. The endpoint must enforce auth itself. An absolute URL is refused; an external page is a type:'url' action with openIn: 'new-tab'."), /** * Post-success navigation (#9566 / #9474, maintainer ruling 2026-08-18) — diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index c0105c45bd9..2260cb9ce70 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -1092,6 +1092,15 @@ export const DashboardWidgetChartConfigSchema = lazySchema(() => ChartConfigSche * Dashboard Widget Schema * A single component on the dashboard grid. * + * **A viewer who may not read a tile's data does not see the tile.** Outside + * design mode, a dataset-bound widget whose query the server refuses as a read + * refusal (403 `PERMISSION_DENIED`) is left out of the dashboard for that + * viewer; no key declares it, and the server's refusal is the only verdict + * (the renderer never guesses a permission). In design mode the widget stays + * and shows its no-access state, so the author can still select it. + * `requiresObject` / `requiresService` keep their install-only meaning: they + * gate a widget on what is INSTALLED, never on who is viewing. + * * Strict since the ADR-0021 cutover; since #6619 strict through the SHARED * `strictObject` template rather than a hand-written `$ZodErrorMap`. The three * prescriptions are unchanged in wording ({@link WIDGET_GUIDANCE_SETS}); what @@ -1145,8 +1154,12 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ * in single-environment runtimes instead of rendering a 404 error. * Set explicitly to the dataset's base object when the widget should be * gated on that object's availability. + * + * An install gate only, never a viewer gate: a viewer the server refuses a + * read of the widget's data is handled without this key (the widget is left + * out for that viewer outside design mode — see the schema's docblock). */ - requiresObject: z.string().optional().describe('Hide the widget unless the named object is registered').meta({ title: 'Requires Object' }), + requiresObject: z.string().optional().describe('Hide the widget unless the named object is registered — an install gate, not a viewer gate. Outside design mode, a dataset-bound widget whose query the server refuses as a read refusal (403 PERMISSION_DENIED) is left out of the dashboard for that viewer with no key.').meta({ title: 'Requires Object' }), /** * Runtime capability gate — widget is hidden when the named kernel diff --git a/skills/objectstack-ui/rules/actions.md b/skills/objectstack-ui/rules/actions.md index df0b0814460..efc9f2f6ce9 100644 --- a/skills/objectstack-ui/rules/actions.md +++ b/skills/objectstack-ui/rules/actions.md @@ -166,10 +166,10 @@ export const PrintA3Action = defineAction({ For actions whose redirect URL is **computed after a fetch** (SSO and SSO-like handlers), set `opensInNewTab: true`. The renderer pre-opens the tab **synchronously** on click so popup blockers don't fire, then navigates it to -the handler's returned `redirectUrl`. For external deep-links with no server -round-trip, add `newTabUrl` — a direct URL template (supports the `{recordId}` -placeholder). It is valid **only** alongside `opensInNewTab: true`, and the -target endpoint must enforce its own auth (the new tab carries no in-app session +the handler's returned `redirectUrl`. To skip the round-trip, add `newTabUrl` +— a backend path starting with `/` (`{recordId}` placeholder; an external URL +is refused, use `openIn` above). Valid **only** with `opensInNewTab: true`; the +endpoint must enforce its own auth (the new tab carries no in-app session context). ```typescript From c9724f005779470c4c50589e6417de321ed886c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:56:21 +0000 Subject: [PATCH 2/3] chore(spec): regenerate references Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzhJoJYPHSvBpq8euSq --- content/docs/references/data/object.mdx | 2 +- content/docs/references/kernel/metadata-plugin.mdx | 2 +- content/docs/references/shared/expression.mdx | 2 +- content/docs/references/system/environment-artifact.mdx | 4 ++-- content/docs/references/ui/action.mdx | 2 +- content/docs/references/ui/dashboard.mdx | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e0c72980991..eace9f5815f 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -509,7 +509,7 @@ const result = ApiMethod.parse(data); | **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | -| **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | +| **newTabUrl** | `string` | optional | New-tab path template on this backend, starting with / (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab to the backend origin + this path immediately — no action POST. The endpoint must enforce auth itself. An absolute URL is refused; an external page is a type:'url' action with openIn: 'new-tab'. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | | **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. 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. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 03e44741552..730931fea1f 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -356,7 +356,7 @@ const result = MetadataBulkResultSchema.parse(data); | **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | -| **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | +| **newTabUrl** | `string` | optional | New-tab path template on this backend, starting with / (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab to the backend origin + this path immediately — no action POST. The endpoint must enforce auth itself. An absolute URL is refused; an external page is a type:'url' action with openIn: 'new-tab'. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | | **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. 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. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/shared/expression.mdx b/content/docs/references/shared/expression.mdx index 77b63f30fff..62d65645433 100644 --- a/content/docs/references/shared/expression.mdx +++ b/content/docs/references/shared/expression.mdx @@ -52,7 +52,7 @@ SQL fragments (analytics joins, partial indexes) are intentionally **not** routed through this schema — they stay driver-native because their security posture and portability story differ. -See also: content/docs/concepts/north-star.mdx §8 "No private expression DSL" +See also: docs/adr/0032-unified-expression-layer.md — "one CEL language" **Source:** `packages/spec/src/shared/expression.zod.ts` diff --git a/content/docs/references/system/environment-artifact.mdx b/content/docs/references/system/environment-artifact.mdx index 6f3c681d191..f61b69763c1 100644 --- a/content/docs/references/system/environment-artifact.mdx +++ b/content/docs/references/system/environment-artifact.mdx @@ -42,8 +42,8 @@ identity (`commitId`, `checksum`). `metadata`, outside the `checksum` digest. Absent ≠ `{}` — see the key's docblock. -See `content/docs/concepts/north-star.mdx` §6.3 for the -runtime-inputs boundary. +See `content/docs/concepts/architecture-overview.mdx` § "Environment +Artifact" for the runtime-inputs boundary. ### History (#4740) diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index f7f888ed5a2..44d208afd02 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -66,7 +66,7 @@ const result = ActionSchema.parse(data); | **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | -| **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | +| **newTabUrl** | `string` | optional | New-tab path template on this backend, starting with / (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab to the backend origin + this path immediately — no action POST. The endpoint must enforce auth itself. An absolute URL is refused; an external page is a type:'url' action with openIn: 'new-tab'. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | | **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. 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. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index cccd1cf17d4..f0d7e167c9e 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -67,7 +67,7 @@ const result = DashboardSchema.parse(data); | **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>` | optional (default: `"metric"`) | Visualization type | | **chartConfig** | `{ title?: string \| Record; subtitle?: string \| Record; description?: string \| Record; colors?: string[] \| Record; … }` | optional | Chart appearance: title, subtitle, description, colors, height, legend, data labels, annotations, interaction. The bound dataset decides which series exist and which column each one reads (ADR-0021), so `type` (the widget's own `type` wins), `xAxis`, `yAxis` and `series` are refused here — select `dimensions` / `values` instead. | | **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Widget color variant for theming | -| **requiresObject** | `string` | optional | Hide the widget unless the named object is registered | +| **requiresObject** | `string` | optional | Hide the widget unless the named object is registered — an install gate, not a viewer gate. Outside design mode, a dataset-bound widget whose query the server refuses as a read refusal (403 PERMISSION_DENIED) is left out of the dashboard for that viewer with no key. | | **requiresService** | `string` | optional | Hide the widget unless the named kernel service is registered | | **filter** | `any` | optional | Presentation-scope filter (runtimeFilter) | | **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) | @@ -167,7 +167,7 @@ Dashboard header action | **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| … +6 more>` | optional (default: `"metric"`) | Visualization type | | **chartConfig** | `{ title?: string \| Record; subtitle?: string \| Record; description?: string \| Record; colors?: string[] \| Record; … }` | optional | Chart appearance: title, subtitle, description, colors, height, legend, data labels, annotations, interaction. The bound dataset decides which series exist and which column each one reads (ADR-0021), so `type` (the widget's own `type` wins), `xAxis`, `yAxis` and `series` are refused here — select `dimensions` / `values` instead. | | **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Widget color variant for theming | -| **requiresObject** | `string` | optional | Hide the widget unless the named object is registered | +| **requiresObject** | `string` | optional | Hide the widget unless the named object is registered — an install gate, not a viewer gate. Outside design mode, a dataset-bound widget whose query the server refuses as a read refusal (403 PERMISSION_DENIED) is left out of the dashboard for that viewer with no key. | | **requiresService** | `string` | optional | Hide the widget unless the named kernel service is registered | | **filter** | `any` | optional | Presentation-scope filter (runtimeFilter) | | **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) | From c6d355804cebc9222b0925aeb2097a0ce7541969 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:57:42 +0000 Subject: [PATCH 3/3] chore: changesets for the newTabUrl narrowing, dashboard prose and citations Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzhJoJYPHSvBpq8euSq --- .changeset/26924-new-tab-url-backend-path.md | 28 +++++++++++++++++++ ...26924-platform-objects-new-tab-url-help.md | 8 ++++++ .../26973-formula-north-star-citation.md | 8 ++++++ 3 files changed, 44 insertions(+) create mode 100644 .changeset/26924-new-tab-url-backend-path.md create mode 100644 .changeset/26924-platform-objects-new-tab-url-help.md create mode 100644 .changeset/26973-formula-north-star-citation.md diff --git a/.changeset/26924-new-tab-url-backend-path.md b/.changeset/26924-new-tab-url-backend-path.md new file mode 100644 index 00000000000..0e24c34bf81 --- /dev/null +++ b/.changeset/26924-new-tab-url-backend-path.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: an action's `newTabUrl` is a path on this backend and must start with `/`; an absolute URL is refused at parse + +`newTabUrl` is the zero-roundtrip target of the pre-opened-tab flow (`opensInNewTab: true`): on click the console drives the pre-opened tab to the backend origin + `newTabUrl`, with `{recordId}` substituted, and posts nothing. Its JSDoc called it a path template, while its `.describe()` and the action form's help text called it a URL template, and nothing refused an absolute value. So `newTabUrl: 'https://example.com/export/{recordId}'` parsed clean and opened a tab at `https://BACKEND/https://example.com/export/…`. `ActionSchema.newTabUrl` now refuses any value that does not start with `/`, and the JSDoc, `.describe()` and the form help (all four form-translation locales) say the same thing: a path on this backend. + +Clause-②: no (narrowing) — `ActionSchema.newTabUrl` (every action door: `defineAction`, the `action` metadata type, an object's `actions[]`) refuses a value that does not start with `/`. Nothing is widened. + +Metadata-protocol: narrowed (an action's `newTabUrl` may no longer be an absolute URL or a relative path without a leading `/`) + + + +**BREAKING** accept-set narrowing on `ActionSchema.newTabUrl` (`@objectstack/spec`). + +- **Before.** `newTabUrl: 'https://example.com/export/{recordId}'` (or `sso-open/{recordId}`) parsed, and the console opened the backend origin + that text: a broken tab. +- **Now.** The parse refuses it at `newTabUrl` with: `` `newTabUrl` is a path on this backend and must start with `/` `` and names the external-page remedy. +- **Unchanged.** A path template such as `/sso-open/{recordId}` or `/api/v1/invoice/{recordId}/pdf` parses byte-identically. No renderer changes: the console already prefixes the backend origin, so the declared meaning and the behaviour now agree. +- **A row stored before this release.** Nothing converts it. It loads unchanged and is still registered, counted `invalid` with a `[metadata_spec_invalid]` boot line; the metadata read serves it with `_diagnostics` locating `newTabUrl`; it keeps opening the same broken tab; and its next save is refused until the value is a path. +- **Who sees it.** An author who wrote an absolute URL in `newTabUrl`. No example, template, fixture or platform object in this repository does. + +**The remedy.** For an endpoint on this backend, write its path: `newTabUrl: '/sso-open/{recordId}'`. For an external page, drop `opensInNewTab` and `newTabUrl` and declare a `type: 'url'` action instead: `target: 'https://example.com/export/${ctx.recordId}'`, `openIn: 'new-tab'`. + +Also in `@objectstack/spec`, prose only, no accept-set change: + +- The `DashboardWidget` docblock and `requiresObject`'s `.describe()` say that, outside design mode, a dataset-bound widget whose query the server refuses as a read refusal (403 `PERMISSION_DENIED`) is left out of the dashboard for that viewer, and that `requiresObject` stays an install gate, never a viewer gate. +- Three shipped citations of `content/docs/concepts/north-star.mdx` "§8" / "§6.3" (sections that page never had; the page is now `concepts/architecture-overview`) are corrected: `expression.zod.ts` cites ADR-0032 ("one CEL language"), `environment-artifact.zod.ts` cites the architecture overview's "Environment Artifact" section, and `hook-body.zod.ts` drops a pointer to a heading that does not exist. The generated references follow. diff --git a/.changeset/26924-platform-objects-new-tab-url-help.md b/.changeset/26924-platform-objects-new-tab-url-help.md new file mode 100644 index 00000000000..c0c78e5ea66 --- /dev/null +++ b/.changeset/26924-platform-objects-new-tab-url-help.md @@ -0,0 +1,8 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): the action form's `newTabUrl` help text says it is a path on this backend starting with `/`, in all four locales (en, zh-CN, ja-JP, es-ES), and names the `type: 'url'` action for an external page — matching the spec, which now refuses an absolute `newTabUrl` + +Clause-②: no +Metadata-protocol: none diff --git a/.changeset/26973-formula-north-star-citation.md b/.changeset/26973-formula-north-star-citation.md new file mode 100644 index 00000000000..566490e23a5 --- /dev/null +++ b/.changeset/26973-formula-north-star-citation.md @@ -0,0 +1,8 @@ +--- +'@objectstack/formula': patch +--- + +docs(formula): the README and the package docblock cite ADR-0032 ("one CEL language") instead of `content/docs/concepts/north-star.mdx` "§8 No private expression DSL", a section that page never had (the page is now `concepts/architecture-overview`, which carries no such heading) + +Clause-②: no +Metadata-protocol: none