diff --git a/.changeset/23745-record-json-block.md b/.changeset/23745-record-json-block.md new file mode 100644 index 00000000000..1134689be8f --- /dev/null +++ b/.changeset/23745-record-json-block.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +--- + +`record:json` is a declared page component type — one key of the bound record drawn as indented JSON behind a required, fail-closed `requiredPermissions` gate — with a strict `ComponentPropsMap` row + +Clause-②: yes + +- **New `PageComponentType` member:** `record:json`, in the reserved `record` namespace. One node draws the value under one key of the bound record as indented JSON, in a panel that starts collapsed, with a copy control. Declared spec first: objectui draws it and `@objectstack/plugin-approvals` places it on the approval request page after this release. +- **Its `ComponentPropsMap` row (`RecordJsonProps`) is strict and has three keys:** + - `field` (required) — the key to draw, in the snake_case machine-name grammar. It may be a declared field or a key the record's read route serves beside the declared fields (`payload` on a `sys_approval_request` row), so it is not checked against the object's fields: a misspelled `field` draws nothing, and no publish check reports it. + - `label` (optional) — the collapsed panel's summary line, a string or an inline locale map. + - `requiredPermissions` (required, at least one non-empty name) — capabilities the user must all hold. It fails CLOSED: a client that cannot resolve the user's capabilities draws nothing, and so does a user missing any of them. Presentation only — it authorises nothing, and the read route still serves the value. +- **The record blocks' own `requiredPermissions` is unchanged.** On `record:details`, `record:highlights`, `record:related_list` and `record:quick_actions` it stays optional and fail-open; the same key name on `record:json` is a separate declaration with its own describe. +- **Refusals.** Any other key in `properties` is refused by name (`component-props-unknown-key` at `os validate` / `os build` / `os lint`); a node with no `field` or no `requiredPermissions`, or with `requiredPermissions: []`, is refused at parse. Inside a page that declares `print`, the type is refused with its own reason (a collapsed panel behind a per-viewer gate). +- **Consumers.** A consumer that derives coverage from `PageComponentType.options` or `ComponentPropsMap` — a designer palette, a renderer registry — sees one more member to classify. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 70d4fa1983f..6fddd3beadd 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1536 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1537 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 168 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1536** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **197** | **1537** | 14 protocol modules | --- @@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 168 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 169 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -375,7 +375,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `ComponentNavItem`, `DashboardNavItem`, `DocNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` | | [`bulk-action.zod.ts`](/docs/references/ui/bulk-action) | `BulkActionDef`, `BulkActionExecution`, `BulkActionOperation`, `BulkActionParam` | | [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` | -| [`component.zod.ts`](/docs/references/ui/component) | `ActionButtonProps`, `ActionGroupProps`, `ActionIconProps`, `ActionMenuProps`, `ElementButtonProps`, `ElementDefinitionListProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementRepeaterProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTimelineProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordLineItemsProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | +| [`component.zod.ts`](/docs/references/ui/component) | `ActionButtonProps`, `ActionGroupProps`, `ActionIconProps`, `ActionMenuProps`, `ElementButtonProps`, `ElementDefinitionListProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementRepeaterProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTimelineProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordJsonProps`, `RecordLineItemsProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | | [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetChartConfig`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` | | [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` | | [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index ce78074e014..32d79c10e5d 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1,7 +1,7 @@ --- title: Component schema — UI Protocol reference navTitle: Component -description: "Component schemas of the ObjectStack UI Protocol: ActionButtonProps and 43 more — each property with its type, default and a TypeScript example." +description: "Component schemas of the ObjectStack UI Protocol: ActionButtonProps and 44 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Component schemas of the ObjectStack UI Protocol: ActionButtonProp ## TypeScript Usage ```typescript -import { ActionButtonPropsSchema, ActionGroupPropsSchema, ActionIconPropsSchema, ActionMenuPropsSchema, ElementButtonPropsSchema, ElementDefinitionListPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementRepeaterPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, ObjectCalendarPropsSchema, ObjectFormPropsSchema, ObjectGanttPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, ObjectMapPropsSchema, ObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema, ObjectTimelinePropsSchema, ObjectTreePropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordAlertActionSchema, RecordAlertProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordHistoryProps, RecordLineItemsProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, RecordRelatedListProps, ReferenceRailEntrySchema } from '@objectstack/spec/ui'; -import type { ActionButtonProps, ActionGroupProps, ActionIconProps, ActionMenuProps, ElementDefinitionListProps, ElementNumberProps, ElementRecordPickerProps, ElementRepeaterProps, ObjectCalendarProps, ObjectFormProps, ObjectGanttProps, ObjectGridProps, ObjectKanbanProps, ObjectMapProps, ObjectMasterDetailFormProps, ObjectMetricProps, ObjectTimelineProps, ObjectTreeProps, PageContainerProps, RecordAlertAction, RecordAlertProps, RecordHighlightsField, RecordHistoryProps, RecordLineItemsProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, ReferenceRailEntry } from '@objectstack/spec/ui'; +import { ActionButtonPropsSchema, ActionGroupPropsSchema, ActionIconPropsSchema, ActionMenuPropsSchema, ElementButtonPropsSchema, ElementDefinitionListPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementRepeaterPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, ObjectCalendarPropsSchema, ObjectFormPropsSchema, ObjectGanttPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, ObjectMapPropsSchema, ObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema, ObjectTimelinePropsSchema, ObjectTreePropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordAlertActionSchema, RecordAlertProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordHistoryProps, RecordJsonProps, RecordLineItemsProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, RecordRelatedListProps, ReferenceRailEntrySchema } from '@objectstack/spec/ui'; +import type { ActionButtonProps, ActionGroupProps, ActionIconProps, ActionMenuProps, ElementDefinitionListProps, ElementNumberProps, ElementRecordPickerProps, ElementRepeaterProps, ObjectCalendarProps, ObjectFormProps, ObjectGanttProps, ObjectGridProps, ObjectKanbanProps, ObjectMapProps, ObjectMasterDetailFormProps, ObjectMetricProps, ObjectTimelineProps, ObjectTreeProps, PageContainerProps, RecordAlertAction, RecordAlertProps, RecordHighlightsField, RecordHistoryProps, RecordJsonProps, RecordLineItemsProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, ReferenceRailEntry } from '@objectstack/spec/ui'; // Validate data const result = ActionButtonPropsSchema.parse(data); @@ -1790,6 +1790,19 @@ Type: `string` | **unknownUserText** | `string` | optional | Copy substituted when an entry has no resolvable actor. Literal string, every locale (renderer default: "Unknown user"). | +--- + +## RecordJsonProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | The key of the bound record whose value this block draws as indented JSON, in a panel that starts collapsed, with a copy control. A declared field, or a key the record's read route serves beside the declared fields (`payload` on a `sys_approval_request` row). It is NOT checked against the object's fields: a key with no value on the served record draws nothing, so a misspelled `field` draws nothing and no publish check reports it. | +| **label** | `string \| Record` | optional | The collapsed panel's summary line — a string or an inline locale map (renderer default: "Raw data (JSON)"). | +| **requiredPermissions** | `string[]` | ✅ | [ADR-0066] REQUIRED, at least one. Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions. It fails CLOSED, unlike the record blocks' gate of the same name: a client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) draws nothing, and so does a user missing any of them — no notice, no summary line, no empty panel. Presentation only: it authorises nothing, and the read route still serves the value to the same user. To keep a value from a user, gate the object, the field or the route. | + + --- ## RecordLineItemsProps diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 795849c5326..ccaad184a7e 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -293,7 +293,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'record:approval_decision' \| 'record:approvals' \| 'record:attachments' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'record:approval_decision' \| 'record:approvals' \| 'record:attachments' \| 'record:json' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | @@ -372,6 +372,7 @@ View filter rule * `record:approval_decision` * `record:approvals` * `record:attachments` +* `record:json` * `app:launcher` * `nav:menu` * `nav:breadcrumb` diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index a95f31caf2c..3a466662dad 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 213 | 202 | 4 | 0 | 7 | +| `ui/` | 214 | 203 | 4 | 0 | 7 | ## `ui/` — sites @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 19 | | `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 80 | +| `component.zod.ts` | 81 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -46,7 +46,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 59 | | `widget.zod.ts` | 1 | -| **total** | **213** | +| **total** | **214** | ## `ui/` — open @@ -54,7 +54,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 213**, in 4 file(s). +**7 strip of 214**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -62,7 +62,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 59 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **213** | +| **total** | **7** | **214** | | Bucket | Sites | |---|---| diff --git a/packages/lint/src/validate-record-json-23745.test.ts b/packages/lint/src/validate-record-json-23745.test.ts new file mode 100644 index 00000000000..f792ee6f447 --- /dev/null +++ b/packages/lint/src/validate-record-json-23745.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23745 — `record:json`, one key of the bound record drawn as indented JSON + * behind a REQUIRED, fail-closed `requiredPermissions` gate, through the + * authoring rules `os validate` / `os build` / `os lint` run. The spec-side + * pins (the row, the enum member, the print classification) live beside the + * row in `@objectstack/spec` (`component-record-json.test.ts`). + * + * Also pinned here: `field` is NOT judged against the object's fields — the + * key the block exists for (`payload` on a `sys_approval_request` row) is one + * the read route serves beside them, so `page-field-unknown` stays silent. + */ +import { describe, expect, it } from 'vitest'; + +import { COMPONENT_PROPS_INVALID, COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js'; +import { COMPONENT_TYPE_UNKNOWN, validateComponentTypes } from './validate-component-types.js'; +import { PAGE_FIELD_UNKNOWN, validatePageFieldBindings } from './validate-page-field-bindings.js'; + +type AnyRec = Record; + +const OBJECT = { + name: 'request', + label: 'Request', + fields: { + name: { type: 'text', label: 'Name' }, + payload_json: { type: 'textarea', label: 'Payload' }, + }, +}; + +const pageWith = (node: AnyRec): AnyRec => ({ + name: 'request_record', + label: 'Request', + type: 'record', + object: 'request', + regions: [{ name: 'main', components: [node] }], +}); + +const AT = 'pages[0].regions[0].components[0]'; +const VALID = { field: 'payload', requiredPermissions: ['studio.access'], label: 'Raw data (JSON)' }; + +describe('a `record:json` node passes the authoring doors', () => { + it('is a known type with an accepted bag', () => { + const page = pageWith({ type: 'record:json', properties: VALID }); + expect(validateComponentTypes({ pages: [page] })).toEqual([]); + expect(validateComponentProps({ pages: [page] })).toEqual([]); + }); + + it('`field` naming a route-served key is not a `page-field-unknown` finding', () => { + const page = pageWith({ type: 'record:json', properties: VALID }); + const findings = validatePageFieldBindings({ objects: [OBJECT], pages: [page] } as never); + expect(findings.filter((f) => f.rule === PAGE_FIELD_UNKNOWN)).toEqual([]); + // Control: the same rule fires on a sibling block naming the same key. + const control = pageWith({ type: 'record:highlights', properties: { fields: ['payload'] } }); + const fired = validatePageFieldBindings({ objects: [OBJECT], pages: [control] } as never); + expect(fired.map((f) => f.rule)).toContain(PAGE_FIELD_UNKNOWN); + }); + + it('control: a misspelled type in the reserved namespace offers the declared spelling', () => { + const findings = validateComponentTypes({ pages: [pageWith({ type: 'record:jsn', properties: VALID })] }); + expect(findings.map((f) => f.rule)).toEqual([COMPONENT_TYPE_UNKNOWN]); + expect(findings[0].message).toContain("'record:json'"); + }); +}); + +describe('the props gate refuses what the row does not declare', () => { + it('an undeclared key is `component-props-unknown-key`, naming it', () => { + const findings = validateComponentProps({ pages: [pageWith({ type: 'record:json', properties: { ...VALID, collapsed: false } })] }); + expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_UNKNOWN_KEY, `${AT}.properties.collapsed`]]); + expect(findings[0].message).toContain('`collapsed`'); + }); + + it.each([ + ['no `requiredPermissions`', { field: 'payload' }], + ['an empty `requiredPermissions`', { field: 'payload', requiredPermissions: [] }], + ['no `field`', { requiredPermissions: ['studio.access'] }], + ])('%s is a `component-props-invalid` error', (_label, properties) => { + const findings = validateComponentProps({ pages: [pageWith({ type: 'record:json', properties })] }); + expect(findings.length).toBeGreaterThan(0); + expect(findings.every((f) => f.rule === COMPONENT_PROPS_INVALID && f.severity === 'error')).toBe(true); + }); +}); diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index d1253cab225..f394c99c584 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -376,6 +376,8 @@ "RecordHighlightsProps (const)", "RecordHistoryProps (const)", "RecordHistoryProps (type)", + "RecordJsonProps (const)", + "RecordJsonProps (type)", "RecordLineItemsProps (const)", "RecordLineItemsProps (type)", "RecordLineItemsPropsParsed (type)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 02c5a4b580c..bd496eb1e7c 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1196,6 +1196,9 @@ "ui/RecordHistoryProps:emptyText", "ui/RecordHistoryProps:limit", "ui/RecordHistoryProps:unknownUserText", + "ui/RecordJsonProps:field", + "ui/RecordJsonProps:label", + "ui/RecordJsonProps:requiredPermissions", "ui/RecordLineItemsProps:amountField", "ui/RecordLineItemsProps:childObject", "ui/RecordLineItemsProps:columns", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index 6c451181ba4..78b9481f0c0 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -238,6 +238,7 @@ "RecordHighlightsField": "ui/RecordHighlightsField", "RecordHighlightsProps": "ui/RecordHighlightsProps", "RecordHistoryProps": "ui/RecordHistoryProps", + "RecordJsonProps": "ui/RecordJsonProps", "RecordLineItemsProps": "ui/RecordLineItemsProps", "RecordPathProps": "ui/RecordPathProps", "RecordQuickActionsProps": "ui/RecordQuickActionsProps", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 9854eac4674..c3ff99e88ea 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -367,6 +367,7 @@ "RecordHighlightsField": "src/ui/component.zod.ts#RecordHighlightsField (type)", "RecordHighlightsProps": "src/ui/component.zod.ts#RecordHighlightsProps (const)", "RecordHistoryProps": "src/ui/component.zod.ts#RecordHistoryProps (type)", + "RecordJsonProps": "src/ui/component.zod.ts#RecordJsonProps (type)", "RecordLineItemsProps": "src/ui/component.zod.ts#RecordLineItemsProps (type)", "RecordLineItemsPropsParsed": "src/ui/component.zod.ts#RecordLineItemsPropsParsed (type)", "RecordPathProps": "src/ui/component.zod.ts#RecordPathProps (type)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 3e53599717e..f1f4e2bd430 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -134,6 +134,7 @@ "ui/RecordHighlightsField", "ui/RecordHighlightsProps", "ui/RecordHistoryProps", + "ui/RecordJsonProps", "ui/RecordLineItemsProps", "ui/RecordPathProps", "ui/RecordQuickActionsProps", diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index d23691e1269..914a55a7216 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -277,7 +277,7 @@ import type * as M189 from './system/fate-supplement.zod.js'; import type * as M190 from './kernel/execution-context.zod.js'; // --------------------------------------------------------------------------- -// 787 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 788 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1520,11 +1520,14 @@ export type Iso_ui_chart__ChartTypeSchema = Assert, z.infer< typeof M170.ElementDefinitionListPropsSchema > >>; export type Iso_ui_component__PageContainerProps = Assert, z.infer< typeof M170.PageContainerProps > >>; export type Iso_ui_component__RecordAlertActionSchema = Assert, z.infer< typeof M170.RecordAlertActionSchema > >>; export type Iso_ui_component__RecordHighlightsField = Assert, z.infer< typeof M170.RecordHighlightsField > >>; export type Iso_ui_component__RecordHistoryProps = Assert, z.infer< typeof M170.RecordHistoryProps > >>; +export type Iso_ui_component__RecordJsonProps = Assert, z.infer< typeof M170.RecordJsonProps > >>; export type Iso_ui_component__RecordPathProps = Assert, z.infer< typeof M170.RecordPathProps > >>; export type Iso_ui_component__RecordQuickActionsProps = Assert, z.infer< typeof M170.RecordQuickActionsProps > >>; export type Iso_ui_component__RecordReferenceRailProps = Assert, z.infer< typeof M170.RecordReferenceRailProps > >>; @@ -1699,7 +1702,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 787 isomorphic pins', () => { + it('still declares all 788 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2526,7 +2529,13 @@ describe('ADR-0122 type-alias convention', () => { // `kernel/execution-context.zod.ts`) are plain string enums, so both are // pinned here (Iso_kernel_executionContext__*) rather than given // `…Parsed` synonyms. +2 added. - expect(pins).toHaveLength(787); + // + // 787 -> 788 is #23745: `RecordJsonProps` (`record:json`) — a snake_case + // string, an `I18nLabelSchema` union and a string array, with no default + // and no transform anywhere in its tree, so it is pinned here + // (Iso_ui_component__RecordJsonProps) rather than given a `…Parsed` + // synonym. +1 added. + expect(pins).toHaveLength(788); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/packages/spec/src/ui/component-record-block-field-security.test.ts b/packages/spec/src/ui/component-record-block-field-security.test.ts index 2be4c2fd5a3..cf36fcfb4be 100644 --- a/packages/spec/src/ui/component-record-block-field-security.test.ts +++ b/packages/spec/src/ui/component-record-block-field-security.test.ts @@ -192,8 +192,11 @@ describe('`requiredPermissions` is declared on the three blocks as a capability expect(keys).toContain('requiredPermissions'); }); - it('B · the census: exactly the three blocks and `record:quick_actions` declare it — lit controls `aria` and `fields` fire on the same instrument', () => { - expect(declaring('requiredPermissions')).toEqual([...BLOCKS, 'record:quick_actions'].sort()); + it('B · the census: exactly the three blocks, `record:quick_actions` and `record:json` declare it — lit controls `aria` and `fields` fire on the same instrument', () => { + // `record:json` (#23745) declares the same NAME with a different meaning on + // purpose — required and fail-closed — so it is outside the "one key, one + // text" pin below; its own pins live in `component-record-json.test.ts`. + expect(declaring('requiredPermissions')).toEqual([...BLOCKS, 'record:json', 'record:quick_actions'].sort()); // Controls: the census can find a key at all, and finds the blocks under // test when it should. `fields` is declared on two of the three (the related // list's column key is `columns`), which is itself a discriminating reading. diff --git a/packages/spec/src/ui/component-record-json.test.ts b/packages/spec/src/ui/component-record-json.test.ts new file mode 100644 index 00000000000..9845bf7cfbf --- /dev/null +++ b/packages/spec/src/ui/component-record-json.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23745 — `record:json`, one key of the bound record drawn as indented JSON + * behind a REQUIRED, fail-closed `requiredPermissions` gate (the spec half of + * face 4 of objectui#12438's ruling, letter C). + * + * What these pins hold: + * - the type is a `PageComponentType` member with its own strict + * `ComponentPropsMap` row, so a page naming it parses and its props bag is + * judged rather than skipped; + * - `field` and `requiredPermissions` are required, and the gate refuses an + * empty list — the fail-closed meaning is declared, not left to a default; + * - the record blocks' shared fail-OPEN gate is untouched: this row's + * describe is its own text, and the four sibling rows keep theirs; + * - a print page refuses the block, with a reason. + * The authoring rule ids are pinned where they live, in `@objectstack/lint`. + */ +import { describe, expect, it } from 'vitest'; + +import { ComponentPropsMap, RecordDetailsProps, RecordJsonProps } from './component.zod'; +import { + PageComponentSchema, + PageComponentType, + PageSchema, + PRINTABLE_PAGE_COMPONENT_TYPES, + PRINT_REFUSED_PAGE_COMPONENT_TYPES, +} from './page.zod'; +import { isKnownComponentType } from './component-type-vocabulary'; + +const GATE = ['studio.access']; +const VALID = { field: 'payload', requiredPermissions: GATE }; + +const issueCodes = (r: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[] }> } }) => + (r.error?.issues ?? []).map((i) => `${i.code}@${i.path.join('.')}`); + +describe('record:json is a declared page component type with its own strict row', () => { + it('is a `PageComponentType` member and a known vocabulary type', () => { + expect(PageComponentType.options).toContain('record:json'); + expect(isKnownComponentType('record:json')).toBe(true); + }); + + it('its `ComponentPropsMap` row is `RecordJsonProps`', () => { + expect(ComponentPropsMap['record:json']).toBe(RecordJsonProps); + }); + + it('accepts the whole contract: `field`, `label` (string or locale map) and the gate', () => { + expect(RecordJsonProps.safeParse(VALID).success).toBe(true); + expect(RecordJsonProps.safeParse({ ...VALID, label: 'Raw data (JSON)' }).success).toBe(true); + const r = RecordJsonProps.safeParse({ ...VALID, label: { en: 'Raw data', 'zh-CN': '原始数据' } }); + expect(r.success).toBe(true); + expect(r.data).toEqual({ ...VALID, label: { en: 'Raw data', 'zh-CN': '原始数据' } }); + }); + + it('a page node naming it parses, `properties` included', () => { + const node = { type: 'record:json', properties: { ...VALID, label: 'Raw data' } }; + expect(PageComponentSchema.safeParse(node).success).toBe(true); + const page = { + name: 'request_record', + label: 'Request', + type: 'record', + object: 'sys_approval_request', + regions: [{ name: 'main', components: [node] }], + }; + const r = PageSchema.safeParse(page); + expect(issueCodes(r)).toEqual([]); + expect(r.success).toBe(true); + }); + + it('refuses an undeclared key as `unrecognized_keys`, beside a valid bag (lit control)', () => { + expect(RecordJsonProps.safeParse(VALID).success).toBe(true); + const r = RecordJsonProps.safeParse({ ...VALID, collapsed: false }); + expect(r.success).toBe(false); + expect(r.error!.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); + }); + + it('names the declared key for a near spelling', () => { + const r = RecordJsonProps.safeParse({ requiredPermissions: GATE, key: 'payload' }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.message).join('\n')).toContain('field'); + }); +}); + +describe('`field` is a required machine name, not judged against declared fields', () => { + it('is required', () => { + const r = RecordJsonProps.safeParse({ requiredPermissions: GATE }); + expect(r.success).toBe(false); + expect(issueCodes(r)).toContain('invalid_type@field'); + }); + + it('takes the snake_case grammar and refuses anything else as a value error', () => { + for (const ok of ['payload', 'payload_json', '_meta', 'a1']) { + expect(RecordJsonProps.safeParse({ ...VALID, field: ok }).success).toBe(true); + } + for (const bad of ['', 'Payload', 'payload.amount', '1st', 'pay-load']) { + const r = RecordJsonProps.safeParse({ ...VALID, field: bad }); + expect(r.success).toBe(false); + expect(issueCodes(r)).toEqual(['invalid_format@field']); + } + }); +}); + +describe('`requiredPermissions` is REQUIRED and fail-closed — its own declaration, not the shared one', () => { + it('is required: a bag without it is refused at the key, not defaulted', () => { + const r = RecordJsonProps.safeParse({ field: 'payload' }); + expect(r.success).toBe(false); + expect(issueCodes(r)).toContain('invalid_type@requiredPermissions'); + }); + + it('refuses an empty list and an empty name — a gate nobody can pass is never authored by accident', () => { + expect(issueCodes(RecordJsonProps.safeParse({ ...VALID, requiredPermissions: [] }))) + .toEqual(['too_small@requiredPermissions']); + expect(issueCodes(RecordJsonProps.safeParse({ ...VALID, requiredPermissions: [''] }))) + .toEqual(['too_small@requiredPermissions.0']); + }); + + it('accepts any non-empty capability names, all of them carried through', () => { + const r = RecordJsonProps.safeParse({ ...VALID, requiredPermissions: ['studio.access', 'read'] }); + expect(r.success).toBe(true); + expect(r.data!.requiredPermissions).toEqual(['studio.access', 'read']); + }); + + it('its describe states the fail-closed meaning and that it authorises nothing', () => { + const text = RecordJsonProps.shape.requiredPermissions.description ?? ''; + expect(text).toContain('fails CLOSED'); + expect(text).toContain('authorises nothing'); + }); + + it('the shared record-block gate stays fail-OPEN and optional — the two declarations differ', () => { + const shared = RecordDetailsProps.shape.requiredPermissions; + expect(shared.safeParse(undefined).success).toBe(true); + expect(shared.safeParse([]).success).toBe(true); + expect(shared.description).toContain('fails open'); + expect(shared.description).not.toBe(RecordJsonProps.shape.requiredPermissions.description); + }); +}); + +describe('a print page refuses the block', () => { + it('is not printable, and the refusal carries a reason', () => { + expect(PRINTABLE_PAGE_COMPONENT_TYPES.has('record:json')).toBe(false); + expect(PRINT_REFUSED_PAGE_COMPONENT_TYPES.get('record:json')).toMatch(/\S/); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index edb65654b25..20f1c2dc30d 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2549,6 +2549,77 @@ const RecordApprovalsProps = strictObject({ }, }, {}); +/** + * `record:json` (#23745, the spec half of face 4 of objectui#12438's ruling, + * letter C): one key of the bound record, drawn as indented JSON in a panel + * that starts collapsed, with a copy control. The worked case is the approval + * request page's raw request snapshot (`payload` on a `sys_approval_request` + * row), which only a caller holding `studio.access` may see. + * + * A NEW block rather than a widened one, on the ruling's own test ("A NEW block + * is opened only where no existing block can carry the face"): no declared + * block draws a record value as JSON, `record:details` draws JSON only for a + * DECLARED `json` field, and the gate this face needs fails CLOSED, which the + * record blocks' gate of the same name does not + * ({@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION}, clause 5). That + * shared gate is left exactly as it is. + * + * Declared spec first, like `record:approval_decision`: no renderer draws it + * and no page authors it on the day it lands. objectui registers the renderer, + * and plugin-approvals places it on the request page, after this declaration. + * + * Three keys, all of them the renderer's whole contract: + * + * - `field` — the key to draw. It is NOT judged against the object's + * declared fields (it has no entry in `@objectstack/lint`'s + * `COMPONENT_FIELD_SPECS`), because the key this block exists for is one + * the read route serves beside the declared fields: the request's + * `payload` is parsed from the declared column `payload_json`, which is + * never served under its own name. `attachedOnRead` on the object does not + * describe such a key either — it declares per-caller blocks with a fixed + * set of scalar leaves, and a snapshot's leaves are the source record's + * fields. So a misspelled `field` draws nothing, and nothing at publish + * says so. + * - `label` — the collapsed panel's summary line. + * - `requiredPermissions` — REQUIRED, and fail-CLOSED: see the describe. + * Presentation only, like every block-level gate: it authorises nothing. + */ +export const RecordJsonProps = strictObject({ + surface: 'this `record:json`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, + aliases: { + key: 'field', path: 'field', fieldName: 'field', + title: 'label', summary: 'label', + permissions: 'requiredPermissions', requiredCapabilities: 'requiredPermissions', + }, +}, { + field: z.string() + .regex(/^[a-z_][a-z0-9_]*$/, { + message: 'record:json `field` must be a lowercase snake_case key of the bound record (e.g. "payload").', + }) + .describe( + 'The key of the bound record whose value this block draws as indented JSON, in a panel that starts ' + + 'collapsed, with a copy control. A declared field, or a key the record\'s read route serves beside ' + + 'the declared fields (`payload` on a `sys_approval_request` row). It is NOT checked against the ' + + 'object\'s fields: a key with no value on the served record draws nothing, so a misspelled `field` ' + + 'draws nothing and no publish check reports it.', + ), + label: I18nLabelSchema.optional().describe( + 'The collapsed panel\'s summary line — a string or an inline locale map (renderer default: "Raw data (JSON)").', + ), + requiredPermissions: z.array(z.string().min(1)).min(1).describe( + '[ADR-0066] REQUIRED, at least one. Capabilities the user must ALL hold — names that permission sets grant ' + + 'through `systemPermissions`, not object actions. It fails CLOSED, unlike the record blocks\' gate of the ' + + 'same name: a client that cannot resolve the user\'s capabilities (no permission provider, or one that ' + + 'does not report `systemPermissions`) draws nothing, and so does a user missing any of them — no notice, ' + + 'no summary line, no empty panel. ' + + 'Presentation only: it authorises nothing, and the read route still serves the value to the same user. ' + + 'To keep a value from a user, gate the object, the field or the route.', + ), +}); +export type RecordJsonProps = z.input; + export const PageAccordionProps = strictObject({ surface: 'this `page:accordion`', history: PROPS_HISTORY, @@ -8233,6 +8304,13 @@ export const ComponentPropsMap = { // attributes it reads are node-level, never `properties`. 'record:approvals': RecordApprovalsProps, 'record:attachments': emptyProps('record:attachments'), + // #23745 — one key of the bound record drawn as indented JSON, behind a + // REQUIRED, fail-closed `requiredPermissions` gate (objectui#12438 ruling, + // letter C, face 4). A `PageComponentType` member declared spec first, as + // `record:approval_decision` was: no renderer and no authored page yet, so + // the key set is the declared contract the renderer is built to, not a + // measurement of read points. See the schema's own header. + 'record:json': RecordJsonProps, // Navigation 'app:launcher': emptyProps('app:launcher'), diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index ca9f4721312..a9c6cb1adb2 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -204,6 +204,12 @@ export const PageComponentType = z.enum([ // every record page of an `enable.files` object. Each has a // `ComponentPropsMap` row measured from its renderer's read points. 'record:approvals', 'record:attachments', + // #23745 (the spec half of face 4 of the objectui#12438 ruling, letter C): + // one key of the bound record drawn as indented JSON behind a REQUIRED, + // fail-closed `requiredPermissions` gate. Declared spec first, as + // `record:approval_decision` above was: objectui registers the renderer and + // plugin-approvals places it after this declaration exists. + 'record:json', // Navigation 'app:launcher', 'nav:menu', 'nav:breadcrumb', // Utility — `user:profile` REMOVED (#14159): shell chrome, refused by name @@ -1383,6 +1389,7 @@ export const PRINT_REFUSED_PAGE_COMPONENT_TYPES: ReadonlyMap = n ['record:approval_decision', 'is the decision panel of an approval request: action controls that differ by viewer, beside a progress tally each decision changes, with nothing to print'], ['record:approvals', 'is the record\'s live approval timeline, with a remind control only the submitter sees, so what prints would depend on who prints it'], ['record:attachments', 'is a list of attached files with upload, download and delete controls, and the files themselves never reach paper'], + ['record:json', 'is a panel that starts collapsed behind a per-viewer capability gate, so what prints would depend on who prints it and whether they opened it'], ['element:button', 'is an action control, with nothing to print'], ['element:record_picker', 'is an input control, with nothing to print'], ['element:text_input', 'is an input control, with nothing to print'],