From ccca3c06dce8919a49049f4d6662a9800e43d64f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 06:06:50 +0000 Subject: [PATCH] =?UTF-8?q?fix(app-shell):=20entitlement=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E4=B8=8A=E4=B8=8B=E6=96=87=E5=8F=AA=E8=AF=BB=20error.?= =?UTF-8?q?details.*,=E5=88=A0=E9=99=A4=E5=B9=B3=E9=93=BA=E5=8F=8C?= =?UTF-8?q?=E6=96=B9=E8=A8=80=E5=9B=9E=E9=80=80=20(#3329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cloud#1046 两仓变更的 objectui 先行半边。entitlement 键 (upgrade_url / contact_url / plan / current / limit)从 `error.` 顶层改为 `error.details.` 唯一位置,并删除 `entitlementErrorFields()` 的 `body?.error ?? body` 平铺容错 —— 变更后只接受一种形状,不留 `??` 回退。 `code` / `message` 是 ApiErrorSchema 的声明字段,仍留在 `error` 上。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t --- ...entitlement-error-context-reads-details.md | 46 +++++++ .../__tests__/entitlements.test.ts | 123 ++++++++++++------ .../app-shell/src/environment/entitlements.ts | 57 ++++---- .../useConsoleActionRuntime.test.tsx | 48 ++++++- 4 files changed, 209 insertions(+), 65 deletions(-) create mode 100644 .changeset/entitlement-error-context-reads-details.md diff --git a/.changeset/entitlement-error-context-reads-details.md b/.changeset/entitlement-error-context-reads-details.md new file mode 100644 index 000000000..8b2205465 --- /dev/null +++ b/.changeset/entitlement-error-context-reads-details.md @@ -0,0 +1,46 @@ +--- +"@object-ui/app-shell": minor +--- + +The environment entitlement dialog now reads its context from +`error.details.*` — the single declared location — and the flat dual-dialect +tolerance is deleted (objectui#3329, the objectui half of cloud#1046). + +`entitlementDialogFromError()` maps a cloud env-create 403 into the friendly +upgrade / limit dialog. It read `upgrade_url`, `contact_url`, `plan`, `current` +and `limit` off the error object's **top level**, where the control plane used +to put them as undeclared siblings of `code`. Those keys are conformant only by +evaporating: `ApiErrorSchema` is a plain `z.object` that STRIPS unknown keys, so +they survive to the Console purely because this path consumes the raw wire body +before any parse. ADR-0112 (with framework#4224 and cloud#930's `AiErrorExtra`) +declares `details` as the slot for structured error context, and cloud#1046 +moves the producer there. + +## What changed + +- All entitlement context is read from `error.details.` and **nowhere + else**. `code` and `message` are declared `ApiErrorSchema` fields and stay on + `error` itself. +- `entitlementErrorFields()` — the `body?.error ?? body` flat/nested tolerance — + is **removed**. A flat body (`error` as a string with `code` at the top level) + no longer produces a dialog; it takes the caller's ordinary error path. +- No `??` chain between shapes was added in its place: exactly one shape is + accepted after this change, and tests pin both directions (details is read; + the retired locations are not). + +## Breaking note — read before tracking objectui `main` directly + +This is a wire-shape change with no consumer-side fallback, by decision on +cloud#1046 (option A). It is safe for the **hosted** product because the cloud +image pins objectui by `.objectui-sha`: cloud#1046's second half lands the +producer change and the pin bump in one PR, so producer and consumer flip +atomically and the hosted Console never runs one against the other. + +**Self-hosted deployments that track objectui `main` ahead of their control +plane** will, until that control plane emits `error.details.*`, see the +entitlement dialog lose its context: the upgrade CTA falls back to +`/settings/billing`, `PRODUCTION_ENV_LIMIT` drops its "Contact sales" CTA, +`DEV_ENV_PLAN_LOCKED` says "free plan" regardless of the real plan, and +`DEV_ENV_LIMIT` drops the "using X of Y" counts. The dialog itself still opens +and its titles/messages are unaffected — `code` did not move. Upgrade the +control plane past cloud#1046 to restore the context. diff --git a/packages/app-shell/src/environment/__tests__/entitlements.test.ts b/packages/app-shell/src/environment/__tests__/entitlements.test.ts index d8ad1b771..080e14d3f 100644 --- a/packages/app-shell/src/environment/__tests__/entitlements.test.ts +++ b/packages/app-shell/src/environment/__tests__/entitlements.test.ts @@ -11,6 +11,7 @@ import { entitlementDialogFromError, decideEnvironmentCta, upgradeDialogSpec, + DEFAULT_UPGRADE_URL, type EnvironmentEntitlementsState, } from '../entitlements'; @@ -36,15 +37,18 @@ describe('entitlementDialogFromError', () => { it('returns null for non-entitlement errors (so a normal toast still fires)', () => { expect(entitlementDialogFromError({ error: 'Boom' })).toBeNull(); expect(entitlementDialogFromError(null)).toBeNull(); - expect(entitlementDialogFromError({ code: 'VALIDATION' })).toBeNull(); + expect(entitlementDialogFromError({ error: { code: 'VALIDATION' } })).toBeNull(); }); - it('maps DEV_ENV_PLAN_LOCKED to an upgrade dialog with the server upgrade_url', () => { + it('maps DEV_ENV_PLAN_LOCKED to an upgrade dialog with the upgrade_url from error.details', () => { const spec = entitlementDialogFromError({ - error: 'Development environments are a paid feature...', - code: 'DEV_ENV_PLAN_LOCKED', - upgrade_url: '/settings/billing', - plan: 'free', + success: false, + error: { + code: 'DEV_ENV_PLAN_LOCKED', + message: 'Development environments are a paid feature...', + httpStatus: 403, + details: { upgrade_url: '/settings/billing', plan: 'free' }, + }, }); expect(spec).not.toBeNull(); expect(spec!.code).toBe('DEV_ENV_PLAN_LOCKED'); @@ -54,49 +58,90 @@ describe('entitlementDialogFromError', () => { }); it('names a paid plan in the plan-locked copy', () => { - const spec = entitlementDialogFromError({ code: 'DEV_ENV_PLAN_LOCKED', plan: 'team' }); + const spec = entitlementDialogFromError({ error: { code: 'DEV_ENV_PLAN_LOCKED', details: { plan: 'team' } } }); expect(spec!.message).toContain('Your team plan includes'); }); it('maps DEV_ENV_LIMIT to an upgrade dialog (limit-reached title)', () => { - const spec = entitlementDialogFromError({ code: 'DEV_ENV_LIMIT', upgrade_url: '/u' }); + const spec = entitlementDialogFromError({ error: { code: 'DEV_ENV_LIMIT', details: { upgrade_url: '/u' } } }); expect(spec!.title).toBe('Development environment limit reached'); expect(spec!.cta!.url).toBe('/u'); expect(spec!.message).toContain('Capacity scales with AI seats'); }); it('quotes the seat-pool usage when the server reports counts', () => { - const spec = entitlementDialogFromError({ code: 'DEV_ENV_LIMIT', current: 3, limit: 3 }); + const spec = entitlementDialogFromError({ error: { code: 'DEV_ENV_LIMIT', details: { current: 3, limit: 3 } } }); expect(spec!.message).toContain('using 3 of 3 development environments'); }); - // cloud#948 nested every coded error under `error: { … }`. Reading `code` off - // the top level made this mapper return null against an up-to-date control - // plane, degrading the friendly dialog to a generic red toast. - it('reads the nested cloud#948 error envelope', () => { - const spec = entitlementDialogFromError({ - success: false, - error: { - code: 'DEV_ENV_PLAN_LOCKED', - message: 'Development environments are a paid feature. …', - httpStatus: 403, - plan: 'free', - upgrade_url: '/settings/billing', - }, + // ─── strictness pins (cloud#1046 / objectui#3329) ────────────────────────── + // + // `error.details` is the ONLY accepted home for entitlement context. These + // pins are the "provably strict" half: they fail the moment anyone + // reintroduces a `??` chain to an older location. `code` / `message` are + // declared `ApiErrorSchema` fields and legitimately stay on `error` itself. + describe('reads error.details and nowhere else', () => { + it('ignores entitlement keys sitting as undeclared siblings of `code`', () => { + // The pre-cloud#1046 wire shape. `code` is declared, so the dialog still + // opens — but every context key degrades to its default, proving none of + // them is read from the old top-level position. + const spec = entitlementDialogFromError({ + success: false, + error: { + code: 'DEV_ENV_LIMIT', + message: 'Development environment limit reached.', + httpStatus: 403, + upgrade_url: '/sibling-upgrade', + current: 3, + limit: 3, + seatCount: 2, + }, + }); + expect(spec!.cta!.url).toBe(DEFAULT_UPGRADE_URL); + expect(spec!.message).not.toContain('3 of 3'); + // …the no-counts copy ("— add an AI seat" is the with-counts variant). + expect(spec!.message).toContain('Capacity scales with AI seats. Add an AI seat'); }); - expect(spec).not.toBeNull(); - expect(spec!.code).toBe('DEV_ENV_PLAN_LOCKED'); - expect(spec!.cta).toEqual({ label: 'Upgrade plan', url: '/settings/billing' }); - }); - it('still reads the legacy flat body (older control planes)', () => { - const spec = entitlementDialogFromError({ - success: false, - error: 'Development environments are a paid feature. …', - code: 'DEV_ENV_PLAN_LOCKED', - upgrade_url: '/legacy', + it('ignores a sibling `plan`, so the plan-locked copy degrades to the free-plan phrase', () => { + const spec = entitlementDialogFromError({ error: { code: 'DEV_ENV_PLAN_LOCKED', plan: 'team' } }); + expect(spec!.message).toContain('Your free plan includes'); + expect(spec!.message).not.toContain('team plan'); + }); + + it('ignores a sibling `contact_url`, so PRODUCTION_ENV_LIMIT drops its CTA', () => { + const spec = entitlementDialogFromError({ + error: { code: 'PRODUCTION_ENV_LIMIT', contact_url: 'mailto:sales@objectos.ai' }, + }); + expect(spec!.cta).toBeUndefined(); + }); + + it('returns null for the legacy FLAT body — the dual-dialect tolerance is gone', () => { + // Pre-cloud#948: `error` is a string and `code` rides the top level. The + // `body?.error ?? body` fallback that used to accept this is deleted, so + // this now takes the caller's generic error path (no dialog). + expect( + entitlementDialogFromError({ + success: false, + error: 'Development environments are a paid feature. …', + code: 'DEV_ENV_PLAN_LOCKED', + upgrade_url: '/legacy', + }), + ).toBeNull(); + }); + + it('returns null for a bare body with no `error` envelope at all', () => { + expect( + entitlementDialogFromError({ code: 'DEV_ENV_PLAN_LOCKED', upgrade_url: '/legacy', plan: 'free' }), + ).toBeNull(); + }); + + it('tolerates a non-object `details` without reaching for another location', () => { + const spec = entitlementDialogFromError({ + error: { code: 'DEV_ENV_PLAN_LOCKED', details: 'not-an-object', upgrade_url: '/sibling' }, + }); + expect(spec!.cta!.url).toBe(DEFAULT_UPGRADE_URL); }); - expect(spec!.cta!.url).toBe('/legacy'); }); // cloud#959 — the dialog is a paid-conversion surface, so its copy comes from @@ -121,15 +166,17 @@ describe('entitlementDialogFromError', () => { it('maps PRODUCTION_ENV_LIMIT to a contact-sales dialog (no upgrade CTA)', () => { const spec = entitlementDialogFromError({ - code: 'PRODUCTION_ENV_LIMIT', - error: 'You already have your production environment.', - contact_url: 'mailto:sales@objectos.ai', + error: { + code: 'PRODUCTION_ENV_LIMIT', + message: 'You already have your production environment.', + details: { contact_url: 'mailto:sales@objectos.ai' }, + }, }); expect(spec!.cta).toEqual({ label: 'Contact sales', url: 'mailto:sales@objectos.ai' }); }); it('falls back to a default upgrade_url when the server omits one', () => { - const spec = entitlementDialogFromError({ code: 'DEV_ENV_PLAN_LOCKED' }); + const spec = entitlementDialogFromError({ error: { code: 'DEV_ENV_PLAN_LOCKED' } }); expect(spec!.cta!.url).toBe('/settings/billing'); expect(spec!.message).toBeTruthy(); // default copy when server message absent }); @@ -168,7 +215,7 @@ describe('upgradeDialogSpec', () => { it('reads identically to the reactive DEV_ENV_PLAN_LOCKED dialog', () => { const proactive = upgradeDialogSpec(base({ plan: 'free', upgradeUrl: '/settings/billing' })); const reactive = entitlementDialogFromError({ - error: { code: 'DEV_ENV_PLAN_LOCKED', plan: 'free', upgrade_url: '/settings/billing' }, + error: { code: 'DEV_ENV_PLAN_LOCKED', details: { plan: 'free', upgrade_url: '/settings/billing' } }, }); expect(reactive).toEqual(proactive); }); diff --git a/packages/app-shell/src/environment/entitlements.ts b/packages/app-shell/src/environment/entitlements.ts index 6eba4692a..a56e65f83 100644 --- a/packages/app-shell/src/environment/entitlements.ts +++ b/packages/app-shell/src/environment/entitlements.ts @@ -71,28 +71,37 @@ function planPhrase(t: EntitlementTranslate, plan: unknown): string { : t('environment.entitlement.freePlan', { defaultValue: 'free plan' }); } -/** - * Read the coded-error fields off a cloud error body, tolerating BOTH shapes: - * - * • nested (cloud#948 and later) — `{ success, error: { code, message, … } }` - * • flat (older control planes) — `{ success, error: '…', code, … }` - * - * The nested envelope moved `code` a level down, which made this mapper return - * `null` for every entitlement 403 — so the friendly upgrade dialog silently - * degraded to a generic red toast against an up-to-date control plane. Control - * planes upgrade independently of the Console, so both shapes stay supported. - */ -function entitlementErrorFields(body: any): any { - const nested = body?.error; - return nested && typeof nested === 'object' ? nested : body; -} - /** * Map a cloud env-create 403 body to a dialog spec. Returns `null` for any * non-entitlement error so the caller falls back to its normal error handling * (a red toast). This is the safety net: it fires regardless of whether the * up-front state-aware presentation was right. * + * ## Exactly ONE accepted wire shape (cloud#1046) + * + * ```jsonc + * { "success": false, + * "error": { "code": "DEV_ENV_LIMIT", "message": "…", "httpStatus": 403, + * "details": { "current": 3, "limit": 3, "upgrade_url": "…" } } } + * ``` + * + * `code` and `message` are DECLARED `ApiErrorSchema` fields and stay on + * `error`. The business context (`upgrade_url`, `contact_url`, `plan`, + * `current`, `limit`) is read from `error.details` and NOWHERE ELSE — that is + * the slot ADR-0112 declares for it (framework#4224; cloud#930's + * `AiErrorExtra`). These keys used to ride as undeclared siblings of `code`, + * where they only ever "parsed clean" because `ApiErrorSchema` is a plain + * `z.object` that STRIPS unknown keys: conformant by evaporating rather than + * by declaration. + * + * There is deliberately no fallback to the older locations — neither the + * pre-`details` siblings nor the flat pre-cloud#948 body. Producer and + * consumer ship atomically in the hosted product (the cloud image pins + * objectui by `.objectui-sha`; cloud#1046 lands the producer change together + * with the pin bump), so a tolerant reader would buy nothing and would + * fossilize the retired dialects into a second de-facto contract — exactly + * the multi-dialect drift cloud#944 is retiring. One strict contract beats N. + * * The copy is the CONSOLE's, not the server's: a control plane may be older * than the Console (or newer), and its prose is only localized from cloud#959 * on. Rendering our own localized strings — from the same code + counts the @@ -100,12 +109,14 @@ function entitlementErrorFields(body: any): any { * keeps it identical to the proactive prompt in {@link upgradeDialogSpec}. */ export function entitlementDialogFromError(body: any, t: EntitlementTranslate = fallbackTranslate): EntitlementDialogSpec | null { - const fields = entitlementErrorFields(body); - const code = fields?.code; + const error = body?.error; + if (!error || typeof error !== 'object') return null; + const code = error.code; if (!isEntitlementErrorCode(code)) return null; + const details = error.details && typeof error.details === 'object' ? error.details : undefined; const upgradeUrl = - typeof fields?.upgrade_url === 'string' && fields.upgrade_url ? fields.upgrade_url : DEFAULT_UPGRADE_URL; - const contactUrl = typeof fields?.contact_url === 'string' && fields.contact_url ? fields.contact_url : ''; + typeof details?.upgrade_url === 'string' && details.upgrade_url ? details.upgrade_url : DEFAULT_UPGRADE_URL; + const contactUrl = typeof details?.contact_url === 'string' && details.contact_url ? details.contact_url : ''; if (code === 'PRODUCTION_ENV_LIMIT') { return { @@ -135,7 +146,7 @@ export function entitlementDialogFromError(body: any, t: EntitlementTranslate = defaultValue: 'Development environments are a paid feature', }), message: t('environment.entitlement.planLockedBody', { - plan: planPhrase(t, fields?.plan), + plan: planPhrase(t, details?.plan), defaultValue: 'Your {{plan}} includes one production environment. Upgrade to add development environments — build in dev, then publish to production.', }), @@ -146,8 +157,8 @@ export function entitlementDialogFromError(body: any, t: EntitlementTranslate = // DEV_ENV_LIMIT — a paid org that exhausted its seat-scaled pool. Quote the // usage when the server reported it; the counts carry the same information // the server's own prose did. - const used = Number(fields?.current); - const limit = Number(fields?.limit); + const used = Number(details?.current); + const limit = Number(details?.limit); const hasCounts = Number.isFinite(used) && Number.isFinite(limit); return { code, diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx index 473f59304..52e0af589 100644 --- a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx @@ -221,10 +221,14 @@ describe('useConsoleActionRuntime — authenticated handlers', () => { ok: false, status: 403, json: async () => ({ - error: 'Development environments are a paid feature. Upgrade to add them.', - code: 'DEV_ENV_PLAN_LOCKED', - upgrade_url: '/settings/billing', - plan: 'free', + success: false, + error: { + code: 'DEV_ENV_PLAN_LOCKED', + message: 'Development environments are a paid feature. Upgrade to add them.', + httpStatus: 403, + // Business context lives in the declared `details` slot (cloud#1046). + details: { upgrade_url: '/settings/billing', plan: 'free' }, + }, }), }); const onRefresh = vi.fn(); @@ -249,6 +253,42 @@ describe('useConsoleActionRuntime — authenticated handlers', () => { expect(await screen.findByText('Development environments are a paid feature')).toBeTruthy(); }); + it('apiHandler does NOT open the entitlement dialog for the retired flat error shape', async () => { + // objectui#3329 / cloud#1046: `error.details` is the only accepted home for + // entitlement context, and the flat `body?.error ?? body` tolerance is + // deleted. This body — a pre-cloud#948 flat shape — must therefore take the + // ordinary error path (a red toast), not the friendly dialog. Pinned here + // because this handler feeds `entitlementDialogFromError` the RAW wire body. + authFetchSpy.mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ + success: false, + error: 'Development environments are a paid feature. Upgrade to add them.', + code: 'DEV_ENV_PLAN_LOCKED', + upgrade_url: '/settings/billing', + plan: 'free', + }), + }); + const { result } = renderHook(() => + useConsoleActionRuntime({ dataSource: {}, objects: [] }), + ); + + let res: any; + await act(async () => { + res = await result.current.apiHandler({ + type: 'api', name: 'create_environment', target: '/api/v1/cloud/environments', + } as any); + }); + + expect(res).toEqual({ + success: false, + error: 'Development environments are a paid feature. Upgrade to add them.', + }); + render(<>{result.current.dialogs}); + expect(screen.queryByText('Development environments are a paid feature')).toBeNull(); + }); + it('apiHandler merges bodyExtra into the dataSource update payload (pure-confirmation action)', async () => { // A pure-confirmation action carries no params array; its mutation lives in // `bodyExtra`. Without merging it, `fields` is empty and the update below is