Skip to content

Commit d4a9a55

Browse files
fix(app-shell): publish the shared current_user normaliser from RecordFormPage (#6515) (#6540)
`RecordFormPage` built its own predicate identity — `{ name, email, role, positions }` — instead of calling `buildExpressionUser`. Against the normaliser that shape is missing `id` and `isPlatformAdmin`, both named by real gates (`ctx.user.isPlatformAdmin == true` on `sys_environment`'s "Change Plan (admin)"; `record.id == ctx.user.id` throughout `sys_user`). An absent key is not `false`: the predicate FAULTS, and a faulting visibility predicate fails OPEN, so the gate silently did not bite. The signed-out branch diverged on its own account too — it carried no `isPlatformAdmin` key at all. The normaliser moves from `console/AppContent.tsx` to `providers/expressionUser.ts`, a leaf module beside the `ExpressionProvider` it feeds. That move is what makes the fix available at all: this view is `lazy()`-loaded BY `AppContent`, so importing the normaliser from its old home would put a static edge from the split chunk back into the module it was split out of — the edge `check-eager-closure-budget` weighs. `console/AppContent.js` and the package entry both re-export the name, so nothing published moved. Fail-open on a genuine evaluation error is deliberately unchanged (objectui#6443 / #6487 / #6445); what changed is that these predicates no longer fault. Adds the shape pin objectui#6110's contract implied but never got: a render-level assertion that a mount site publishes exactly `buildExpressionUser(session)` under all four identity spellings, plus a source ratchet that refuses a NEW mount site which derives the descriptor by hand. Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 10725fb commit d4a9a55

7 files changed

Lines changed: 681 additions & 57 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Field- and action-visibility gates on the full-screen record form page now see the same
6+
`current_user` every other console surface sees (objectui#6515). `RecordFormPage` built its
7+
own descriptor — `{ name, email, role, positions }` — instead of calling the shared
8+
`buildExpressionUser` normaliser, so `id` and `isPlatformAdmin` were simply absent from the
9+
predicate scope that page publishes.
10+
11+
An absent key is not `false`. A predicate naming one of them FAULTS, and a faulting
12+
visibility predicate fails OPEN, so the gate silently did not bite: a field gated on
13+
`ctx.user.isPlatformAdmin == true` (the shape `sys_environment`'s "Change Plan (admin)"
14+
action uses) rendered for every user on this page, and an id comparison against
15+
`ctx.user.id` (the shape `sys_user`'s own gates use throughout `platform-objects`) did the
16+
same. Nothing on screen distinguished that from a gate that had said yes. The signed-out
17+
branch diverged on its own account too — it carried no `isPlatformAdmin` key at all, where
18+
`buildExpressionUser(null)` carries `false`.
19+
20+
Fail-open on a genuine evaluation error is deliberately unchanged (objectui#6443 / #6487 /
21+
#6445); what changed is that these predicates no longer fault in the first place.
22+
23+
The normaliser moved from `console/AppContent.tsx` to `providers/expressionUser.ts`, beside
24+
the `ExpressionProvider` it feeds. That move is what made the fix available: `RecordFormPage`
25+
is `lazy()`-loaded BY `AppContent`, so importing the normaliser from its old home would have
26+
put a static edge from the split chunk back into the module it was split out of. Both
27+
`console/AppContent.js` and the package entry re-export the name, so `buildExpressionUser`
28+
is published exactly as before.

‎packages/app-shell/src/console/AppContent.tsx‎

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

167139
export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = {}) {
168140
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');

‎packages/app-shell/src/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,15 @@ export { ExpressionProvider, useExpressionContext, evaluateVisibility } from './
2020
* `'x' in current_user.positions` an unbound-key fault, which fails OPEN, so a
2121
* second mount site re-deriving this by hand would reintroduce exactly the
2222
* asymmetry #6010's parity pin exists to refuse.
23+
*
24+
* objectui#6515 — that is exactly what `RecordFormPage` did, because the
25+
* normaliser used to live in `console/AppContent.js` and the view is
26+
* `lazy()`-loaded BY that module. The specifier below moved to the leaf module
27+
* beside the provider it feeds; the NAME published from this entry did not.
28+
* `console/AppContent.js` re-exports it too, for importers already reaching it
29+
* there.
2330
*/
24-
export { buildExpressionUser } from './console/AppContent.js';
31+
export { buildExpressionUser } from './providers/expressionUser.js';
2532

2633
// Hooks
2734
export { useObjectActions } from './hooks/useObjectActions.js';

0 commit comments

Comments
 (0)