Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/entitlement-error-context-reads-details.md
Original file line number Diff line number Diff line change
@@ -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.<key>` 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.
123 changes: 85 additions & 38 deletions packages/app-shell/src/environment/__tests__/entitlements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
entitlementDialogFromError,
decideEnvironmentCta,
upgradeDialogSpec,
DEFAULT_UPGRADE_URL,
type EnvironmentEntitlementsState,
} from '../entitlements';

Expand All @@ -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');
Expand All @@ -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
Expand All @@ -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
});
Expand Down Expand Up @@ -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);
});
Expand Down
57 changes: 34 additions & 23 deletions packages/app-shell/src/environment/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,41 +71,52 @@ 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
* server reports — keeps this dialog in the user's language either way, and
* 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 {
Expand Down Expand Up @@ -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.',
}),
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down
Loading