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
28 changes: 28 additions & 0 deletions .changeset/6515-record-form-current-user-normaliser.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
'@object-ui/app-shell': patch
---

Field- and action-visibility gates on the full-screen record form page now see the same
`current_user` every other console surface sees (objectui#6515). `RecordFormPage` built its
own descriptor — `{ name, email, role, positions }` — instead of calling the shared
`buildExpressionUser` normaliser, so `id` and `isPlatformAdmin` were simply absent from the
predicate scope that page publishes.

An absent key is not `false`. A predicate naming one of them FAULTS, and a faulting
visibility predicate fails OPEN, so the gate silently did not bite: a field gated on
`ctx.user.isPlatformAdmin == true` (the shape `sys_environment`'s "Change Plan (admin)"
action uses) rendered for every user on this page, and an id comparison against
`ctx.user.id` (the shape `sys_user`'s own gates use throughout `platform-objects`) did the
same. Nothing on screen distinguished that from a gate that had said yes. The signed-out
branch diverged on its own account too — it carried no `isPlatformAdmin` key at all, where
`buildExpressionUser(null)` carries `false`.

Fail-open on a genuine evaluation error is deliberately unchanged (objectui#6443 / #6487 /
#6445); what changed is that these predicates no longer fault in the first place.

The normaliser moved from `console/AppContent.tsx` to `providers/expressionUser.ts`, beside
the `ExpressionProvider` it feeds. That move is what made the fix available: `RecordFormPage`
is `lazy()`-loaded BY `AppContent`, so importing the normaliser from its old home would have
put a static edge from the split chunk back into the module it was split out of. Both
`console/AppContent.js` and the package entry re-export the name, so `buildExpressionUser`
is published exactly as before.
56 changes: 14 additions & 42 deletions packages/app-shell/src/console/AppContent.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ import {
createExpressionEvaluator,
evaluateVisibility,
} from '../providers/ExpressionProvider.js';
import { buildExpressionUser } from '../providers/expressionUser.js';
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js';
import { resolveRecordFormTarget, resolveFormViewLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget } from '../utils/recordFormNavigation.js';
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
Expand DownExpand Up@@ -119,50 +120,21 @@ function DraftReviewNavigator({ appName }: { appName: string | undefined }) {
* The predicate-evaluation identity `ExpressionProvider` binds as
* `current_user` / `ctx.user` / `os.user`.
*
* Extracted from `AppContent`'s body in objectui#5424 so the SHAPE it
* advertises is assertable on its own — the defect it carried was a key that
* was always `undefined`, which no render-level assertion can see.
* MOVED to `../providers/expressionUser.js` in objectui#6515 and re-exported
* here so the published name is unchanged. It moved because `AppContent`
* `lazy()`-loads `views/RecordFormPage.tsx`, which mounts an
* `ExpressionProvider` of its own: importing the normaliser from THIS module
* would put a static edge from that split chunk back into the module it was
* split out of — the edge `scripts/check-eager-closure-budget.mjs` weighs — so
* the view hand-rolled a narrower descriptor instead, and the `id` /
* `isPlatformAdmin` it dropped made every predicate naming them FAULT and fail
* OPEN. The new home is a leaf module beside the provider it feeds, which every
* mount site can import without dragging a chunk along.
*
* `roles` is deliberately ABSENT, not merely empty. It used to be forwarded as
* `roles: (user as any).roles`, and the protocol-17 session face emits no
* `roles` key at all (framework ADR-0090 D3 renamed it to `positions` with no
* deprecation window — measured in objectui#5389), so the key reached every CEL
* predicate as `undefined`: an author writing `'manager' in current_user.roles`
* got a shape that answered, wrongly, rather than one that was plainly not
* there. `positions` below is the published spelling and carries the same
* names. Not paired as a fallback — that is what ADR-0090 D3 forbids
* (`packages/auth/src/types.ts`).
*
* The signed-out branch never had `roles` either, so removing it also makes the
* two branches agree on one shape.
* This re-export is the back-compat half of that move; the doc comment that
* explains the SHAPE lives with the function.
*/
export function buildExpressionUser(user: unknown): Record<string, unknown> {
const u = user as
| { id?: string; name?: string; email?: string; role?: string; [key: string]: unknown }
| null
| undefined;
if (!u) {
return { name: 'Anonymous', email: '', role: 'guest', isPlatformAdmin: false, positions: [] };
}
return {
id: u.id,
name: u.name,
email: u.email,
role: u.role ?? 'user',
// Surface the platform-admin flag so action `visible` CEL predicates
// gated on `ctx.user.isPlatformAdmin == true` (e.g. sys_environment
// "Change Plan (admin)") evaluate correctly. Previously only
// name/email/role were forwarded → isPlatformAdmin-gated actions were
// hidden even for platform admins.
isPlatformAdmin: u.isPlatformAdmin ?? false,
// Positions are what the SERVER binds as `current_user` for per-option
// `visibleWhen` authorization gating (ADR-0058; framework EvalUser —
// objectui#2284). Forwarding them lets a position-gated option
// (`'admin' in current_user.positions`) hide client-side too, instead
// of failing open as visible and only being rejected on submit.
positions: u.positions ?? [],
};
}
export { buildExpressionUser };

export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = {}) {
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
Expand Down
9 changes: 8 additions & 1 deletion packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,8 +20,15 @@ export { ExpressionProvider, useExpressionContext, evaluateVisibility } from './
* `'x' in current_user.positions` an unbound-key fault, which fails OPEN, so a
* second mount site re-deriving this by hand would reintroduce exactly the
* asymmetry #6010's parity pin exists to refuse.
*
* objectui#6515 — that is exactly what `RecordFormPage` did, because the
* normaliser used to live in `console/AppContent.js` and the view is
* `lazy()`-loaded BY that module. The specifier below moved to the leaf module
* beside the provider it feeds; the NAME published from this entry did not.
* `console/AppContent.js` re-exports it too, for importers already reaching it
* there.
*/
export { buildExpressionUser } from './console/AppContent.js';
export { buildExpressionUser } from './providers/expressionUser.js';

// Hooks
export { useObjectActions } from './hooks/useObjectActions.js';
Expand Down
Loading
Loading