From b8a54c9362c0d0cd56833442b01d1f38c92e9592 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 02:51:45 +0000 Subject: [PATCH] =?UTF-8?q?fix(example-showcase):=20my-work=20=E7=9A=84=20?= =?UTF-8?q?admin=20=E5=8D=A1=E7=89=87=E6=94=B9=E7=94=A8=20ADR-0089=20?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=BA=A7=20visibleWhen=20(#6274)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `page:card` 的可见性谓词原先写在 `properties.visible` 里,靠 objectui SchemaRenderer 把 `properties` hoist 到节点上才生效 —— 是渲染器的巧合, 不是契约。`PageCardProps` 不声明任何可见性键,#5068 的 `component-props-unknown-key` 闸门正是这样报的它。 改为组件节点的兄弟键 `visibleWhen`(ADR-0089 唯一规范拼法),绑定根用 `current_user` —— ADR-0089 为 page 组件谓词声明的身份根(`record` / `current_user` / `page.`)。绑定名是浏览器实测确定的,不是读 ADR 推断: 对 pin 住的 console(objectui 7dfbeb70)以 admin / 非 admin 双身份各看一次 My Work 页。 新增 test/my-work-visibility.test.ts 同时钉住形状与语义:谓词在组件级、 任何 page:card 的 properties 里不再出现可见性键、绑定根是 `current_user`、 以及谓词按页面自己声称的方式门控。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .../app-showcase/src/ui/pages/my-work.page.ts | 17 ++- .../test/my-work-visibility.test.ts | 108 ++++++++++++++++++ 2 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 examples/app-showcase/test/my-work-visibility.test.ts diff --git a/examples/app-showcase/src/ui/pages/my-work.page.ts b/examples/app-showcase/src/ui/pages/my-work.page.ts index df317aa692..ce5453a7a8 100644 --- a/examples/app-showcase/src/ui/pages/my-work.page.ts +++ b/examples/app-showcase/src/ui/pages/my-work.page.ts @@ -8,7 +8,7 @@ import { definePage } from '@objectstack/spec/ui'; * • a KPI hero row of live `object-metric` tiles in an equal-width `grid`; * • a personal work queue — `object-grid` filtered to the signed-in user * via the `{current_user_id}` token (records I own); - * • sidebar shortcuts + a per-user `visible`-gated note on `user.email`. + * • sidebar shortcuts + a card gated by component-level `visibleWhen` (ADR-0089). */ export const MyWorkPage = definePage({ name: 'showcase_my_work', @@ -71,15 +71,22 @@ export const MyWorkPage = definePage({ ], }, }, - // Admin-only card — per-user rendering via `visible` on the signed-in - // user (the renderer now feeds `user` into the expression context). + // Admin-only card — gated by the ADR-0089 canonical, COMPONENT-LEVEL + // `visibleWhen`: a sibling of `properties`, never a key inside it. + // `properties` is the widget's own prop bag (`PageCardProps`), which + // declares no visibility key; a predicate written there only worked + // because objectui's SchemaRenderer hoists `properties` onto the node + // before evaluating, i.e. by accident of the renderer rather than by + // contract. The predicate binds `current_user` — the identity root + // ADR-0089 declares for page-component predicates (`record`, + // `current_user`, `page.`). { type: 'page:card', + visibleWhen: "current_user.email == 'admin@objectos.ai'", properties: { title: 'Leadership View', - visible: "user.email == 'admin@objectos.ai'", children: [ - { type: 'element:text', properties: { content: 'Admin-only — shown because user.email matches the card’s visible expression.' } }, + { type: 'element:text', properties: { content: 'Admin-only — shown because current_user.email matches the card’s visibleWhen predicate.' } }, ], }, }, diff --git a/examples/app-showcase/test/my-work-visibility.test.ts b/examples/app-showcase/test/my-work-visibility.test.ts new file mode 100644 index 0000000000..5f7e268bfb --- /dev/null +++ b/examples/app-showcase/test/my-work-visibility.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { MyWorkPage } from '../src/ui/pages/index.js'; + +/** + * Dogfood gate for the My Work page's admin-only card (objectstack#6274). + * + * ADR-0089 makes `visibleWhen` the one conditional-visibility predicate, and on + * a page it is a **component-level** key — a sibling of `properties`, not a key + * inside it. `properties` is the widget's own prop bag (`PageCardProps` in + * `ComponentPropsMap`), which declares no visibility key at all; a predicate + * written there rendered correctly only because objectui's `SchemaRenderer` + * hoists every `properties` entry onto the node before evaluating visibility. + * That is a renderer accident, not a contract — and #5068's + * `component-props-unknown-key` gate reported it as exactly that. + * + * These assertions pin the shape AND the semantics, so neither half can regress + * silently: + * - the predicate lives at the component level and NO `page:card` carries a + * visibility key inside `properties` again; + * - the binding root is `current_user` — the identity root ADR-0089 declares + * for page-component predicates (`record`, `current_user`, `page.`). + * Measured in a browser against the pinned console (objectui `7dfbeb70`) on + * 2026-08-08: as `admin@objectos.ai` the card renders, as a non-admin it does + * not. A `data.`-rooted spelling (the metadata-form root) would never match; + * - the predicate actually gates the way the page's own comment claims. + */ + +type AnyComponent = { + type: string; + visibleWhen?: unknown; + properties?: Record; + [k: string]: unknown; +}; + +/** Flatten every component across the page's regions. */ +function allComponents(): AnyComponent[] { + const out: AnyComponent[] = []; + for (const region of MyWorkPage.regions ?? []) { + for (const c of region.components ?? []) out.push(c as AnyComponent); + } + return out; +} + +/** CEL source, whether stored bare or as the normalized `{ dialect, source }`. */ +function predicateSource(v: unknown): string | undefined { + if (typeof v === 'string') return v; + if (v && typeof v === 'object' && typeof (v as { source?: unknown }).source === 'string') { + return (v as { source: string }).source; + } + return undefined; +} + +/** Evaluate the card's comparison-only predicate against a bound scope. */ +function evalPredicate(source: string, scope: Record): boolean { + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + const fn = new Function('current_user', `"use strict"; return (${source});`) as ( + u: unknown, + ) => boolean; + return Boolean(fn(scope.current_user)); +} + +const leadershipCard = () => + allComponents().find( + (c) => c.type === 'page:card' && c.properties?.title === 'Leadership View', + ); + +describe('My Work — admin-only card gating (ADR-0089 component-level visibleWhen)', () => { + it('carries the predicate at the COMPONENT level, not inside `properties`', () => { + const card = leadershipCard(); + expect(card, 'the "Leadership View" page:card must exist').toBeTruthy(); + expect(card!.visibleWhen, 'predicate must be a sibling of `properties`').toBeDefined(); + expect( + card!.properties, + '`PageCardProps` declares no visibility key — a predicate here is an unknown prop (#5068)', + ).not.toHaveProperty('visibleWhen'); + }); + + it('leaves no visibility key inside any page:card `properties` bag', () => { + // The failure this pins is a rewrite that moves one card and forgets the + // other, or a later card re-introducing the hoisted spelling. + for (const card of allComponents().filter((c) => c.type === 'page:card')) { + for (const key of ['visible', 'visibleWhen', 'visibleOn', 'visibility', 'hidden'] as const) { + expect( + card.properties, + `page:card "${String(card.properties?.title)}" must not carry \`${key}\` in properties`, + ).not.toHaveProperty(key); + } + } + }); + + it('binds `current_user` — the ADR-0089 identity root for a page predicate', () => { + const source = predicateSource(leadershipCard()!.visibleWhen); + expect(source).toBeDefined(); + expect(source).toContain('current_user.email'); + // `data.` is the metadata-editing-form root; on a runtime page surface it + // never matches (validate-visibility-predicates, `visibility-root-mislayered`). + expect(source).not.toMatch(/(^|[^.\w$])data\.\w/); + }); + + it('gates the card the way the page claims: admin sees it, others do not', () => { + const source = predicateSource(leadershipCard()!.visibleWhen)!; + expect(evalPredicate(source, { current_user: { email: 'admin@objectos.ai' } })).toBe(true); + expect(evalPredicate(source, { current_user: { email: 'analyst@objectos.ai' } })).toBe(false); + }); +});