From 7dbc812e79547debf3c35b654852a51b9e798d30 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:54:21 +0000 Subject: [PATCH 1/3] feat(app-shell,types): cloud:plan-status marks the organization's current plan card The Cloud pricing page is static metadata and cannot see the organization's plan. `cloud:plan-status` reads it from the org-scoped entitlements summary (through `useEnvironmentEntitlements`) and renders a "Current plan" badge on the card whose `properties.plan` equals it, and nothing otherwise. - app-shell: the widget, registered as `cloud:plan-status` only (`namespace: 'cloud'`, `skipFallback: true`), wired into the barrel and the `sideEffects` array. - types: a local `CloudPlanStatusSchema` arm on `AnyComponentSchema` - a required strict `properties: { plan }` bag, both content channels refused. - cli: `known-schema-types.ts` regenerated; the namespaced ratchet stays 397. - i18n: `cloudPlanStatus.current` in all ten locale packs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1 --- .changeset/10919-cloud-plan-status.md | 24 +++ packages/app-shell/package.json | 2 + .../src/console/home/CloudPlanStatus.tsx | 96 ++++++++++ .../home/__tests__/CloudPlanStatus.test.tsx | 169 ++++++++++++++++++ packages/app-shell/src/index.ts | 2 + ...tered-types-validate-ratchet-10859.test.ts | 8 + packages/cli/src/utils/known-schema-types.ts | 1 + packages/i18n/src/locales/ar.ts | 3 + packages/i18n/src/locales/de.ts | 3 + packages/i18n/src/locales/en.ts | 4 + packages/i18n/src/locales/es.ts | 3 + packages/i18n/src/locales/fr.ts | 3 + packages/i18n/src/locales/ja.ts | 3 + packages/i18n/src/locales/ko.ts | 3 + packages/i18n/src/locales/pt.ts | 3 + packages/i18n/src/locales/ru.ts | 3 + packages/i18n/src/locales/zh.ts | 3 + .../cloud-plan-status-arm-10919.test.ts | 137 ++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 6 + packages/types/src/zod/README.md | 4 + packages/types/src/zod/cloud.zod.ts | 80 +++++++++ packages/types/src/zod/index.zod.ts | 7 + 22 files changed, 567 insertions(+) create mode 100644 .changeset/10919-cloud-plan-status.md create mode 100644 packages/app-shell/src/console/home/CloudPlanStatus.tsx create mode 100644 packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx create mode 100644 packages/types/src/__tests__/cloud-plan-status-arm-10919.test.ts create mode 100644 packages/types/src/zod/cloud.zod.ts diff --git a/.changeset/10919-cloud-plan-status.md b/.changeset/10919-cloud-plan-status.md new file mode 100644 index 0000000000..7c35e41098 --- /dev/null +++ b/.changeset/10919-cloud-plan-status.md @@ -0,0 +1,24 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/types': minor +'@object-ui/i18n': patch +'@object-ui/cli': patch +--- + +New SDUI widget `cloud:plan-status`: a "Current plan" badge for one plan card on the Cloud pricing page, shown when that card's plan is the organization's plan (objectui#10919). + +**Clause-②: yes** — the accept set of `AnyComponentSchema`, and so of `safeValidateSchema` and `objectui validate`, widens by one `type` literal, `cloud:plan-status`, and `@object-ui/types/zod` exports one new schema, `CloudPlanStatusSchema`. Nothing that parsed before is refused now. + +**Why.** The pricing page is static metadata, and nothing in a page's expression scope carries the organization's plan, so the page could not tell which of its cards the organization is already on. The plan is available only from the org-scoped `GET /cloud/environment-entitlements` summary. + +**What changed, in observable terms.** + +- `@object-ui/app-shell` registers `cloud:plan-status`. A page places one node on each plan card and names that card's plan code in `properties.plan`: `{ "type": "cloud:plan-status", "properties": { "plan": "free" } }`. The widget reads the summary through the hook the environment list already uses, and renders the badge when the summary's `plan` equals `properties.plan`. The comparison is exact, so `Free` does not match `free`. +- The widget renders nothing on every other card, while the summary loads, when the request fails, and when the body is not the `{ success, data }` envelope. It never guesses a plan. +- The node's `className` and `responsiveStyles` reach the badge. Each node reads the summary itself, so a page with three cards makes three requests. +- The widget is registered under one key, `cloud:plan-status`. There is no bare `plan-status` fallback and no `app-shell:`-prefixed twin. +- `@object-ui/types/zod` exports `CloudPlanStatusSchema`, a member of `AnyComponentSchema`. `properties` is required and must be exactly `{ plan }`, with `plan` a non-empty string. A missing bag, a missing or empty `plan`, and any other key in the bag are each refused at that path. +- `body` and `children` are refused by name on this node, because the widget reads neither. +- `plan` is not an enum: the plan catalog belongs to the control plane, and ObjectUI does not list its codes. +- `@object-ui/i18n` adds `cloudPlanStatus.current` ("Current plan") to all ten locale packs. +- `@object-ui/cli`: `objectui check` knows `cloud:plan-status` as a registered type. diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 7feb9698f7..8ab08ef694 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -9,6 +9,7 @@ "./dist/console/connect/ConnectAgentWidget.js", "./dist/console/diagnostics/CloudAiModelStatus.js", "./dist/console/home/CloudOnboardingNext.js", + "./dist/console/home/CloudPlanStatus.js", "./dist/console/marketplace/InstalledListWidget.js", "./dist/services/builtinComponents.js", "./dist/views/global-notifications-renderer.js", @@ -24,6 +25,7 @@ "./src/console/connect/ConnectAgentWidget.tsx", "./src/console/diagnostics/CloudAiModelStatus.tsx", "./src/console/home/CloudOnboardingNext.tsx", + "./src/console/home/CloudPlanStatus.tsx", "./src/console/marketplace/InstalledListWidget.tsx", "./src/services/builtinComponents.tsx", "./src/views/global-notifications-renderer.tsx", diff --git a/packages/app-shell/src/console/home/CloudPlanStatus.tsx b/packages/app-shell/src/console/home/CloudPlanStatus.tsx new file mode 100644 index 0000000000..a7b946157f --- /dev/null +++ b/packages/app-shell/src/console/home/CloudPlanStatus.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * CloudPlanStatus — the "current plan" marker for one plan card on the Cloud + * control plane's pricing page, registered as the SDUI widget + * `cloud:plan-status` (objectui#10919). + * + * The pricing page is static metadata: nothing in its expression scope carries + * the organization's plan, so a page cannot tell which of its cards is the one + * the organization is already on. The plan is only available from the + * org-scoped `GET /cloud/environment-entitlements` summary (`data.plan`). This + * widget reads that summary and answers one question for the card it is placed + * on: is this card's plan the organization's plan? + * + * • the summary names this card's plan → a localized "Current plan" badge; + * • the summary names another plan → nothing; + * • loading, failed, or no summary → nothing. The plan is never guessed: + * only the authoritative summary (`source: 'summary'`) can mark a card, and + * the row-derived fallback carries no plan at all. + * + * ## One node per card, and the page names the card + * + * The plan code a card stands for is the page's, not this widget's: the plan + * catalog belongs to the control plane, so the page passes its own code in + * `properties.plan` (the `properties` bag is the spelling every page component + * is authored in) and the widget compares it VERBATIM with `data.plan`. No + * code is listed, normalized or defaulted here. + * + * ## The summary is read through `useEnvironmentEntitlements` + * + * The same hook the environment list uses — one reading of the endpoint and its + * strict `{ success, data }` envelope, not a third copy of the fetch. It is + * enabled unconditionally here because this widget exists only to read it. + */ + +import { useState } from 'react'; +import { Badge, cn } from '@object-ui/components'; +import { createAuthenticatedFetch } from '@object-ui/auth'; +import { ComponentRegistry } from '@object-ui/core'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { useAdapter } from '../../providers/AdapterProvider.js'; +import { useEnvironmentEntitlements } from '../../environment/useEnvironmentEntitlements.js'; + +export interface CloudPlanStatusProps { + properties?: { + /** + * The plan code of the card this node sits on, exactly as the control + * plane's entitlements summary spells it (for example `free`). + */ + plan?: string; + }; + /** Carries the scope class of the node's `responsiveStyles`. */ + className?: string; +} + +export function CloudPlanStatus({ properties, className }: CloudPlanStatusProps) { + const { t } = useObjectTranslation(); + const dataSource = useAdapter(); + // `useState`, not `useMemo`: the hook keys its fetch effect on this function, + // so its identity has to be one React guarantees to keep (AGENTS.md #10). + const [authFetch] = useState(() => createAuthenticatedFetch()); + const entitlements = useEnvironmentEntitlements({ + enabled: true, + dataSource, + authFetch, + apiBase: (import.meta as any).env?.VITE_SERVER_URL || '', + }); + + const plan = properties?.plan; + const isCurrent = + typeof plan === 'string' + && plan !== '' + && entitlements?.source === 'summary' + && entitlements.plan === plan; + if (!isCurrent) return null; + + return ( + + {t('cloudPlanStatus.current')} + + ); +} + +// SDUI registration — the Cloud pricing page places one node per plan card. +// Registered under the `cloud` namespace with no bare fallback, so the one key +// is `cloud:plan-status`: the spelling the page authors, and the one literal +// its `@object-ui/types` arm claims (`CloudPlanStatusSchema`). +ComponentRegistry.register('plan-status', (props: CloudPlanStatusProps) => ( + +), { + namespace: 'cloud', + skipFallback: true, + label: 'Cloud Plan Status', + category: 'plugin', + inputs: [], +}); diff --git a/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx b/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx new file mode 100644 index 0000000000..ac88cea256 --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * CloudPlanStatus (`cloud:plan-status`, objectui#10919) marks ONE plan card on + * the Cloud pricing page: a "Current plan" badge when the organization's plan, + * read from the authoritative entitlements summary, equals the card's + * `properties.plan` — and nothing in every other state, so a plan is never + * guessed. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import React from 'react'; + +let fetchImpl: (url: string, init?: any) => Promise; +const fetchedUrls: string[] = []; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ activeOrganization: { id: 'org_1' } }), + createAuthenticatedFetch: () => (url: string, init?: any) => { + fetchedUrls.push(url); + return fetchImpl(url, init); + }, +})); + +import { I18nProvider } from '@object-ui/i18n'; +import { ComponentRegistry } from '@object-ui/core'; +import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; +import { CloudPlanStatus } from '../CloudPlanStatus'; + +/** + * The badge text resolves from the locale packs, so a real i18n context is + * needed — without one `t()` returns the raw key and a text assertion would + * pass against nothing. The adapter is the row-derived fallback's data source; + * its rows name a production env, so the fallback resolves `derived` for real + * rather than failing. + */ +const adapter = { find: vi.fn().mockResolvedValue([{ id: 'env_1', environment_type: 'production' }]) }; + +const renderInShell = (ui: React.ReactElement) => + render( + + {ui} + , + ); + +/** The control plane's `{ success, data }` envelope around a summary naming `plan`. */ +function summary(plan: string) { + return { + ok: true, + status: 200, + json: async () => ({ success: true, data: { plan, hasProductionEnv: true } }), + }; +} + +afterEach(() => { + cleanup(); + fetchedUrls.length = 0; + adapter.find.mockClear(); +}); + +describe('CloudPlanStatus (objectui#10919)', () => { + it('marks the card whose plan is the organization\'s plan', async () => { + fetchImpl = async () => summary('free'); + const { container } = renderInShell(); + + expect(await screen.findByText('Current plan')).toBeTruthy(); + expect(container.querySelector('[data-plan-status="current"]')).toBeTruthy(); + }); + + it('reads the org-scoped summary endpoint', async () => { + fetchImpl = async () => summary('free'); + renderInShell(); + + await screen.findByText('Current plan'); + expect(fetchedUrls).toEqual(['/api/v1/cloud/environment-entitlements?organizationId=org_1']); + }); + + it('renders nothing on a card whose plan is not the organization\'s', async () => { + fetchImpl = async () => summary('team'); + const { container } = renderInShell(); + + // Wait until the summary was actually read, so the empty render is a + // verdict on it and not the loading state. + await waitFor(() => expect(fetchedUrls).toHaveLength(1)); + await new Promise((r) => setTimeout(r, 0)); + expect(container.innerHTML).toBe(''); + }); + + it('compares the plan code verbatim — no case folding', async () => { + fetchImpl = async () => summary('free'); + const { container } = renderInShell(); + + await waitFor(() => expect(fetchedUrls).toHaveLength(1)); + await new Promise((r) => setTimeout(r, 0)); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing while the summary is loading', () => { + fetchImpl = () => new Promise(() => {}); + const { container } = renderInShell(); + + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing when the summary fails — the row-derived fallback carries no plan', async () => { + fetchImpl = async () => ({ ok: false, status: 500, json: async () => null }); + const { container } = renderInShell(); + + // The hook fell through to the rows, which resolve (source `derived`) but + // name no plan — so the card is left unmarked instead of guessed. + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + await new Promise((r) => setTimeout(r, 0)); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing for a bare (un-enveloped) body that names the plan', async () => { + // A bare body is a producer contract violation (cloud#1046): it yields no + // summary, so even a `plan` that matches must not mark the card. + fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ plan: 'free' }) }); + const { container } = renderInShell(); + + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + await new Promise((r) => setTimeout(r, 0)); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing when the node names no plan', async () => { + fetchImpl = async () => summary('free'); + const { container } = renderInShell(); + + await waitFor(() => expect(fetchedUrls).toHaveLength(1)); + await new Promise((r) => setTimeout(r, 0)); + expect(container.innerHTML).toBe(''); + }); +}); + +describe('`cloud:plan-status` registration (objectui#10919)', () => { + it('is registered under the one key the page authors, with no second spelling', () => { + expect(ComponentRegistry.has('cloud:plan-status')).toBe(true); + // `skipFallback: true` — no bare `plan-status` fallback … + expect(ComponentRegistry.has('plan-status')).toBe(false); + // … and no `app-shell:`-prefixed twin. + expect(ComponentRegistry.has('app-shell:cloud:plan-status')).toBe(false); + }); + + it('renders from a page node through SchemaRenderer, reading `properties.plan`', async () => { + fetchImpl = async () => summary('team'); + const { container } = renderInShell( + , + ); + + expect(await screen.findByText('Current plan')).toBeTruthy(); + // The node's `responsiveStyles` scope class reaches the badge through + // `className`, so the page can place it inside its card. + const scope = container.querySelector('style[data-os-scope]')?.getAttribute('data-os-scope'); + expect(scope).toBeTruthy(); + const badge = container.querySelector('[data-plan-status="current"]'); + expect(badge?.classList.contains(scope as string)).toBe(true); + }); +}); diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 083225e376..21dff54e3d 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -312,6 +312,8 @@ import './console/marketplace/InstalledListWidget.js'; import './console/connect/ConnectAgentWidget.js'; // SDUI widget for the Cloud Welcome page's state-aware onboarding next-step. import './console/home/CloudOnboardingNext.js'; +// SDUI widget for the Cloud pricing page's "current plan" marker (objectui#10919). +import './console/home/CloudPlanStatus.js'; // SDUI widget: read-only admin diagnostic for the env's effective AI model // (cloud#797) — fetches GET /api/v1/ai/effective-model. import './console/diagnostics/CloudAiModelStatus.js'; diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index ca131cb9f8..e3eef9eddd 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -196,6 +196,14 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet expect(refusedAtType(key), key).toBe(false); } }); + + it('counts `cloud:plan-status` armed — it registered WITH its arm (objectui#10919)', () => { + // One registry key (`skipFallback: true`, so no bare `plan-status`), armed in + // `@object-ui/types/zod` in the same change, so the pin above did not move. + expect(NAMESPACED_KEYS).toContain('cloud:plan-status'); + expect(BARE_KEYS).not.toContain('plan-status'); + expect(refusedAtType('cloud:plan-status')).toBe(false); + }); }); /* ── End to end: the README document through `objectui validate` ─────────── */ diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts index 3447617c5d..d55df71610 100644 --- a/packages/cli/src/utils/known-schema-types.ts +++ b/packages/cli/src/utils/known-schema-types.ts @@ -86,6 +86,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'cloud-connection:panel', 'cloud:ai-model-status', 'cloud:onboarding-next', + 'cloud:plan-status', 'code', 'code-editor', 'collapsible', diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index d91a43d17d..33e411c5a5 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2951,6 +2951,9 @@ const ar = { openProduction: "فتح بيئة الإنتاج", manageEnvironments: "إدارة البيئات", }, + cloudPlanStatus: { + current: "الخطة الحالية", + }, ai: { nlQuery: { placeholder: "اطرح سؤالًا حول بياناتك…", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 5703e328c3..ec1ab7081c 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2921,6 +2921,9 @@ const de = { openProduction: "Produktion öffnen", manageEnvironments: "Umgebungen verwalten", }, + cloudPlanStatus: { + current: "Aktueller Tarif", + }, ai: { nlQuery: { placeholder: "Stellen Sie eine Frage zu Ihren Daten…", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 801225657d..11c6a9e542 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3396,6 +3396,10 @@ const en = { openProduction: 'Open Production', manageEnvironments: 'Manage environments', }, + // `cloud:plan-status` — the Cloud pricing page's current-plan marker (objectui#10919). + cloudPlanStatus: { + current: 'Current plan', + }, // `@object-ui/plugin-ai` — the `nl-query`, `ai-form-assist` and // `ai-recommendations` components (objectui#10232). The `*One` rows are this // repo's two-key plural convention (see `search.itemsAvailableOne`): the diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 2099d03620..8969d5b5d6 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2925,6 +2925,9 @@ const es = { openProduction: "Abrir producción", manageEnvironments: "Gestionar entornos", }, + cloudPlanStatus: { + current: "Plan actual", + }, ai: { nlQuery: { placeholder: "Haga una pregunta sobre sus datos…", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 755037abfc..d1d3483527 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2923,6 +2923,9 @@ const fr = { openProduction: "Ouvrir la production", manageEnvironments: "Gérer les environnements", }, + cloudPlanStatus: { + current: "Offre actuelle", + }, ai: { nlQuery: { placeholder: "Posez une question sur vos données…", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 12a82967ee..aa0d10f463 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2923,6 +2923,9 @@ const ja = { openProduction: "本番環境を開く", manageEnvironments: "環境を管理", }, + cloudPlanStatus: { + current: "現在のプラン", + }, ai: { nlQuery: { placeholder: "データについて質問してください…", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index e95aef406e..1d9e55e589 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2920,6 +2920,9 @@ const ko = { openProduction: "프로덕션 열기", manageEnvironments: "환경 관리", }, + cloudPlanStatus: { + current: "현재 요금제", + }, ai: { nlQuery: { placeholder: "데이터에 대해 질문하세요…", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 833b9084d0..92dfc1f4d3 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2920,6 +2920,9 @@ const pt = { openProduction: "Abrir produção", manageEnvironments: "Gerenciar ambientes", }, + cloudPlanStatus: { + current: "Plano atual", + }, ai: { nlQuery: { placeholder: "Faça uma pergunta sobre seus dados…", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index a0f2a4eccd..0edbb7d1dc 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2951,6 +2951,9 @@ const ru = { openProduction: "Открыть продакшн", manageEnvironments: "Управление окружениями", }, + cloudPlanStatus: { + current: "Текущий тариф", + }, ai: { nlQuery: { placeholder: "Задайте вопрос о своих данных…", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index ca79498e18..07bf1fa10b 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3058,6 +3058,9 @@ const zh = { openProduction: '打开生产环境', manageEnvironments: '管理环境', }, + cloudPlanStatus: { + current: '当前套餐', + }, ai: { nlQuery: { placeholder: '就您的数据提问…', diff --git a/packages/types/src/__tests__/cloud-plan-status-arm-10919.test.ts b/packages/types/src/__tests__/cloud-plan-status-arm-10919.test.ts new file mode 100644 index 0000000000..eda09738eb --- /dev/null +++ b/packages/types/src/__tests__/cloud-plan-status-arm-10919.test.ts @@ -0,0 +1,137 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10919 — the `cloud:plan-status` arm (`../zod/cloud.zod.ts`). + * + * `@object-ui/app-shell` registers `cloud:plan-status` for the Cloud pricing + * page, and this arm lands with it so `AnyComponentSchema` claims the type + * instead of refusing it at `type`. The arm is declared from the widget's read + * points: a required `properties` bag holding exactly `plan`, a non-empty + * string, and both content channels refused by name. + * + * Refusals are asserted by issue `code` and `path` — the envelope a consumer + * reads — and not by message wording. + */ + +import { describe, expect, it } from 'vitest'; + +import { + AnyComponentSchema, + CloudPlanStatusSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; + +const TYPE = 'cloud:plan-status'; + +/** The issues of a refused parse, as `code` + dotted `path`. */ +function issuesOf(document: unknown): { code: string; path: string; message: string }[] { + const result = safeValidateSchema(document); + expect(result.success, 'expected a refusal').toBe(false); + if (result.success) return []; + return result.error.issues.map((issue) => ({ + code: issue.code, + path: issue.path.join('.'), + message: issue.message, + })); +} + +describe('`cloud:plan-status` — the node the pricing page authors (objectui#10919)', () => { + it('accepts one plan-card marker on every face', () => { + const node = { type: TYPE, properties: { plan: 'free' } }; + expect(CloudPlanStatusSchema.safeParse(node).success).toBe(true); + expect(safeValidateSchema(node).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(node).success).toBe(true); + }); + + it('accepts the node inside a page, with the spec page-component keys cloud writes beside it', () => { + const page = { + type: 'page', + children: [ + { + type: TYPE, + id: 'plan_free_current', + className: 'self-start', + properties: { plan: 'free' }, + }, + ], + }; + expect(safeValidateSchema(page).success).toBe(true); + }); + + it('is claimed at `type` — the union no longer refuses the literal', () => { + // The ratchet's reading (`registered-types-validate-ratchet-10859.test.ts`): + // a bare `{ type }` fails on the missing bag, NOT with `invalid_union` at `type`. + const issues = issuesOf({ type: TYPE }); + expect(issues.some((i) => i.code === 'invalid_union' && i.path === 'type')).toBe(false); + // Control: an unclaimed `cloud:` literal IS refused there. + const unclaimed = issuesOf({ type: 'cloud:no-such-widget-10919', properties: { plan: 'free' } }); + expect(unclaimed.some((i) => i.code === 'invalid_union' && i.path === 'type')).toBe(true); + }); +}); + +describe('`cloud:plan-status` — what the arm refuses (objectui#10919)', () => { + it('refuses a node with no `properties` bag', () => { + expect(issuesOf({ type: TYPE })).toEqual([ + expect.objectContaining({ code: 'invalid_type', path: 'properties' }), + ]); + }); + + it('refuses a bag that names no plan', () => { + expect(issuesOf({ type: TYPE, properties: {} })).toEqual([ + expect.objectContaining({ code: 'invalid_type', path: 'properties.plan' }), + ]); + }); + + it('refuses an empty plan', () => { + expect(issuesOf({ type: TYPE, properties: { plan: '' } })).toEqual([ + expect.objectContaining({ code: 'too_small', path: 'properties.plan' }), + ]); + }); + + it('refuses a plan that is not a string', () => { + expect(issuesOf({ type: TYPE, properties: { plan: 1 } })).toEqual([ + expect.objectContaining({ code: 'invalid_type', path: 'properties.plan' }), + ]); + }); + + it('refuses a key the widget does not read, inside the bag', () => { + expect(issuesOf({ type: TYPE, properties: { plan: 'free', label: 'Current' } })).toEqual([ + expect.objectContaining({ code: 'unrecognized_keys', path: 'properties' }), + ]); + }); + + it('refuses a flat `plan` — the widget reads the bag only', () => { + // Tolerant face: the bag is missing, so the node is refused there. + expect(issuesOf({ type: TYPE, plan: 'free' })).toEqual([ + expect.objectContaining({ code: 'invalid_type', path: 'properties' }), + ]); + // Strict face: a flat `plan` beside a valid bag is an unknown node key. + const strict = StrictAnyComponentSchema.safeParse({ type: TYPE, plan: 'free', properties: { plan: 'free' } }); + expect(strict.success).toBe(false); + expect(strict.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); + }); + + it.each(['children', 'body'])('refuses `%s` by name — the widget reads neither content channel', (key) => { + const issues = issuesOf({ type: TYPE, properties: { plan: 'free' }, [key]: [{ type: 'text', content: 'x' }] }); + expect(issues).toEqual([expect.objectContaining({ code: 'invalid_type', path: key })]); + expect(issues[0].message).toContain(TYPE); + }); + + it('reaches the arm through the union, not a second schema', () => { + // The union dispatches on the literal to this arm: the same refusal either way. + const bare = CloudPlanStatusSchema.safeParse({ type: TYPE, properties: { plan: '' } }); + const viaUnion = AnyComponentSchema.safeParse({ type: TYPE, properties: { plan: '' } }); + expect(bare.success).toBe(false); + expect(viaUnion.success).toBe(false); + expect(viaUnion.error?.issues.map((i) => [i.code, i.path.join('.')])).toEqual( + bare.error?.issues.map((i) => [i.code, i.path.join('.')]), + ); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 73c04e052c..89defffc2d 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4042,6 +4042,12 @@ const EXCLUSIONS: Readonly> = { "spec-owned, RESTATED — `BaseSchema` + the `element:divider` literal + a member-less strict `properties`, because `@objectstack/spec/ui` exports `ComponentPropsMap['element:divider']` under no name to read by reference; its key set and accept set are pinned to the row's in `./public-block-arms-10872.test.ts`, and no TS declaration in this package restates the node", 'public-blocks.zod.ts#PublicBlockComponentSchema': "a union OVER the public-block arms, not an object of its own — its members are accounted for individually above", + // objectui#10919 — the `cloud:plan-status` widget's arm, declared locally from + // the widget's read points: `@objectstack/spec` has no row for a `cloud:` + // widget, and the widget's props type lives beside it in `@object-ui/app-shell`, + // not in this package. Pinned by `./cloud-plan-status-arm-10919.test.ts`. + 'cloud.zod.ts#CloudPlanStatusSchema': + "no TS declaration in this package restates it — `BaseSchema` + the `cloud:plan-status` literal + a strict `properties` bag holding the one key the widget reads (`plan`) + the two content-channel refusals; the widget's own props type is in `@object-ui/app-shell`", // Renamed from `StylePropsSchema` by objectui#5928. Under the old name the // like-named `StyleProps` (../base.ts) — the Tailwind-scale vocabulary, sharing // ZERO keys with this `{ className, style }` object — read as its declaration, and diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index a3d1de0fd0..5213bdf5a6 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -247,6 +247,10 @@ Each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` r - `RecordHistoryBlockSchema`, `RecordQuickActionsBlockSchema`, `RecordReferenceRailBlockSchema` - `RecordAlertBlockSchema`, `ElementTextBlockSchema`, `ElementNumberBlockSchema`, `ElementButtonBlockSchema`, `ElementDividerBlockSchema` +### Cloud Widgets (1) +`@object-ui/app-shell`'s `cloud:` SDUI widgets. `@objectstack/spec` has no `ComponentPropsMap` row for them, so each arm is declared here from the keys its widget reads. +- `CloudPlanStatusSchema` — `cloud:plan-status`, the pricing page's "current plan" marker: `properties` must be exactly `{ plan }`, a non-empty plan code. + ## Schema Structure All component schemas follow the @objectstack/spec UI specification format: diff --git a/packages/types/src/zod/cloud.zod.ts b/packages/types/src/zod/cloud.zod.ts new file mode 100644 index 0000000000..b454f939ff --- /dev/null +++ b/packages/types/src/zod/cloud.zod.ts @@ -0,0 +1,80 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types/zod - Cloud Widget Zod Validators + * + * The arm for `cloud:plan-status`, the SDUI widget `@object-ui/app-shell` + * registers for the Cloud pricing page (`console/home/CloudPlanStatus.tsx`, + * objectui#10919). + * + * ## Why the arm lands with the registration + * + * `AnyComponentSchema` refuses a registered type it has no arm for, with one + * `invalid_union` issue at `type`, so a page placing the widget could not pass + * `safeValidateSchema` or `objectui validate`. The count of registered types + * still refused there is ratcheted by + * `packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts`, + * which never rises, so the registration and this arm ship together. + * + * ## Declared here, measured from the widget's read points + * + * `@objectstack/spec` has no `ComponentPropsMap` row for a `cloud:` widget, so + * nothing is read by reference: the members below are the keys the widget + * reads, and no other. + * + * - `properties.plan` — the plan code of the card the node sits on. The page + * authors it in the `properties` bag, the one spelling a page component is + * written in, and the widget reads it there alone. Required: a node that + * names no plan can never mark anything. It is an opaque, non-empty + * string, because the plan catalog belongs to the control plane; ObjectUI + * does not list the codes. + * - `properties` is closed: the widget reads no other key of the bag. + * - `body` / `children` — refused by name. The widget renders a badge and + * reads neither content channel (objectui#9256's rule for such nodes). + * + * ⛔ No `.default()` anywhere in this module — see the "authors no default" + * note in `index.zod.ts`. + * + * @module zod/cloud + * @packageDocumentation + */ + +import { z } from 'zod'; +import { BaseSchema } from './base.zod.js'; +import { retirementTombstone } from './tombstone.zod.js'; + +/** One refusal string for both content channels of `CloudPlanStatusSchema`. */ +const CLOUD_PLAN_STATUS_NEITHER_CHANNEL = + 'REFUSED (objectui#10919) — `cloud:plan-status` reads NEITHER content channel: its registration hands ' + + 'the node to `CloudPlanStatus`, which reads only `properties.plan` and `className`, and ' + + '`SchemaRenderer` strips both channels out of the props bag it spreads, so an authored value would ' + + 'render nothing. What it renders instead: a "Current plan" badge when `properties.plan` is the ' + + 'organization\'s plan, and nothing otherwise.'; + +/** + * `cloud:plan-status` — the "current plan" marker for one plan card + * (objectui#10919). Pinned by `../__tests__/cloud-plan-status-arm-10919.test.ts`. + */ +export const CloudPlanStatusSchema = BaseSchema.extend({ + type: z.literal('cloud:plan-status'), + properties: z + .strictObject({ + plan: z + .string() + .min(1) + .describe( + 'The plan code of the card this node marks, spelled exactly as the control plane\'s ' + + 'entitlements summary reports it (for example `free`). The badge renders only when it equals ' + + 'the organization\'s plan.', + ), + }) + .describe('The `cloud:plan-status` props bag: `plan`, and no other key.'), + body: retirementTombstone(CLOUD_PLAN_STATUS_NEITHER_CHANNEL), + children: retirementTombstone(CLOUD_PLAN_STATUS_NEITHER_CHANNEL), +}); diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index b59abcb66b..6fa23ee31c 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -442,6 +442,11 @@ export { PublicBlockComponentSchema, } from './public-blocks.zod.js'; +// ============================================================================ +// Cloud Widgets - `@object-ui/app-shell`'s `cloud:` SDUI widgets (objectui#10919) +// ============================================================================ +export { CloudPlanStatusSchema } from './cloud.zod.js'; + // ============================================================================ // Union Types - All Component Schemas // ============================================================================ @@ -463,6 +468,7 @@ import { ReportUnionSchema } from './reports.zod.js'; import { ViewComponentSchema } from './views.zod.js'; import { AIComponentSchema } from './ai.zod.js'; import { PublicBlockComponentSchema } from './public-blocks.zod.js'; +import { CloudPlanStatusSchema } from './cloud.zod.js'; /** * Union of all component schemas. @@ -531,6 +537,7 @@ export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion( ViewComponentSchema, AIComponentSchema, PublicBlockComponentSchema, + CloudPlanStatusSchema, ], { // Zod's default message for a missed discriminator spells out EVERY accepted // literal — measured, 1,462 chars naming all 107. That is the "print every From a04bd6baa9f9dbc722a288fcb79022a30a929ed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:05:22 +0000 Subject: [PATCH 2/3] refactor(app-shell): cloud:plan-status reads VITE_SERVER_URL typed, no explicit any Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1 --- packages/app-shell/src/console/home/CloudPlanStatus.tsx | 2 +- .../src/console/home/__tests__/CloudPlanStatus.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app-shell/src/console/home/CloudPlanStatus.tsx b/packages/app-shell/src/console/home/CloudPlanStatus.tsx index a7b946157f..08d1066213 100644 --- a/packages/app-shell/src/console/home/CloudPlanStatus.tsx +++ b/packages/app-shell/src/console/home/CloudPlanStatus.tsx @@ -63,7 +63,7 @@ export function CloudPlanStatus({ properties, className }: CloudPlanStatusProps) enabled: true, dataSource, authFetch, - apiBase: (import.meta as any).env?.VITE_SERVER_URL || '', + apiBase: (import.meta.env.VITE_SERVER_URL as string | undefined) || '', }); const plan = properties?.plan; diff --git a/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx b/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx index ac88cea256..4961cf9cb7 100644 --- a/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx @@ -12,13 +12,13 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, waitFor, cleanup } from '@testing-library/react'; import React from 'react'; -let fetchImpl: (url: string, init?: any) => Promise; +let fetchImpl: (url: string, init?: RequestInit) => Promise; const fetchedUrls: string[] = []; vi.mock('@object-ui/auth', async (importOriginal) => ({ ...(await importOriginal>()), useAuth: () => ({ activeOrganization: { id: 'org_1' } }), - createAuthenticatedFetch: () => (url: string, init?: any) => { + createAuthenticatedFetch: () => (url: string, init?: RequestInit) => { fetchedUrls.push(url); return fetchImpl(url, init); }, From 5c4a1d282b558ddfdda99760adc47af230106b9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:13:05 +0000 Subject: [PATCH 3/3] fix(types): cloud:plan-status's channel refusal names the parser tier's warning The objectui#10928 population pin requires every reads-NEITHER refusal to carry the parser-tier clause; the registration declares no `children` input, so `validateTree` does warn `not-a-container` for this node. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1 --- packages/types/src/zod/cloud.zod.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/types/src/zod/cloud.zod.ts b/packages/types/src/zod/cloud.zod.ts index b454f939ff..2b14c511b8 100644 --- a/packages/types/src/zod/cloud.zod.ts +++ b/packages/types/src/zod/cloud.zod.ts @@ -53,8 +53,10 @@ import { retirementTombstone } from './tombstone.zod.js'; const CLOUD_PLAN_STATUS_NEITHER_CHANNEL = 'REFUSED (objectui#10919) — `cloud:plan-status` reads NEITHER content channel: its registration hands ' + 'the node to `CloudPlanStatus`, which reads only `properties.plan` and `className`, and ' - + '`SchemaRenderer` strips both channels out of the props bag it spreads, so an authored value would ' - + 'render nothing. What it renders instead: a "Current plan" badge when `properties.plan` is the ' + + '`SchemaRenderer` strips both channels out of the props bag it spreads. An authored value would ' + + 'render NOTHING — no render-time error or warning and no element; only the parser tier\'s ' + + '`not-a-container` warning (objectui#9910) noticed it, because the registration declares no ' + + '`children` input. What it renders instead: a "Current plan" badge when `properties.plan` is the ' + 'organization\'s plan, and nothing otherwise.'; /**