Skip to content

Commit 9eb932b

Browse files
baozhoutaoclaude
andauthored
fix(console): 403 blamed on the network, ⌘K search capped at 8 objects, nav gating fields inert (#3044)
Three real-user console failures from platform dogfooding: 1. plugin-list: a 403/401 rendered the same 'check your connection' panel as a genuine outage. The error panel now classifies by status/code and shows permission-denied / sign-in-required copy (all nine locales). 2. react/useRecordSearch: maxObjectsQueried caps the per-object fanout, not the search scope — it used to truncate the objects whitelist sent to /api/v1/search to the first 8 nav objects, so which sidebar group came first decided which records were findable. 3. app-shell: evaluateVisibility now evaluates the { dialect: 'cel' } envelopes the spec normalizes nav visible predicates into (they used to fall through to a blanket 'visible'), and the sidebars' requiredPermissions check treats a bare name as an ADR-0066 system capability against the user's permission-set systemPermissions union, matching the server's AppSchema.requiredPermissions rule. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a149e90 commit 9eb932b

19 files changed

Lines changed: 363 additions & 32 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
"@object-ui/plugin-list": patch
4+
"@object-ui/react": patch
5+
"@object-ui/i18n": patch
6+
---
7+
8+
fix(console): three real-user console failures — 403 blamed on the network, ⌘K search capped at 8 objects, nav gating fields inert
9+
10+
1. **List error panel classifies the failure** (`plugin-list`, `i18n`): a 403/401 from the data source used to render the same "check your connection" copy as a genuine outage, sending users to debug their network while the server was correctly denying access. The panel now classifies by `httpStatus`/`status`/`statusCode`, the `PERMISSION_DENIED`/`UNAUTHORIZED` error codes, or an `HTTP <status>` message prefix, and renders dedicated permission-denied / sign-in-required copy (all nine locales).
11+
12+
2. **⌘K / full-page search scope is no longer truncated** (`react`): `maxObjectsQueried` caps the per-object fanout fallback, not the search scope — it used to slice the candidate pool itself, so the `objects` whitelist sent to the platform's `/api/v1/search` only ever named the first 8 nav objects. Which sidebar group came first decided which records were findable; everything later in the nav was unsearchable no matter what the user typed.
13+
14+
3. **Nav gating fields finally gate** (`app-shell`): `evaluateVisibility` only evaluated `${…}` template strings, so the `{ dialect: 'cel', source }` envelopes the spec normalizes every authored `visible` predicate into fell through to a blanket "visible" — a constant-false predicate still rendered for everyone. It now delegates to `ExpressionEvaluator.evaluateCondition`, which routes CEL envelopes to the canonical `@objectstack/formula` engine. And the sidebars' `requiredPermissions` check treats a bare name as an ADR-0066 system capability (union of the user's permission-set `systemPermissions` from `/me/permissions`) — the same subset rule the server applies to `AppSchema.requiredPermissions` — instead of misreading it as `can(<name>, 'read')`, which had degraded `requiredPermissions` into a hide-from-everyone switch (admins included). The `object:action` form and the legacy object-read fallback keep working.

packages/app-shell/src/layout/AppSidebar.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -237,17 +237,20 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
237237
[evaluator],
238238
);
239239

240-
// Permission check from Console permissions context
241-
const { can } = usePermissions();
240+
// Permission check from Console permissions context. Mirrors
241+
// UnifiedSidebar: `object:action` → object CRUD gate; a bare name is an
242+
// ADR-0066 system capability (union of permission-set `systemPermissions`),
243+
// with the legacy "can read <object>" reading kept as fallback.
244+
const { can, hasCapabilities } = usePermissions();
242245
const checkPerm = React.useCallback(
243246
(permissions: string[]) => permissions.every((perm: string) => {
244247
const parts = perm.split(':');
245-
const [object, action] = parts.length >= 2
246-
? [parts[0], parts[1]]
247-
: [perm, 'read'];
248-
return can(object, action as any);
248+
if (parts.length >= 2) {
249+
return can(parts[0], parts[1] as any);
250+
}
251+
return hasCapabilities([perm]) || can(perm, 'read');
249252
}),
250-
[can],
253+
[can, hasCapabilities],
251254
);
252255

253256
// Runtime capability checker — gates nav entries with `requiresObject` /

packages/app-shell/src/layout/UnifiedSidebar.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -311,17 +311,28 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
311311
[evaluator],
312312
);
313313

314-
// Permission check
315-
const { can } = usePermissions();
314+
// Permission check for nav `requiredPermissions` entries.
315+
//
316+
// Two authored forms:
317+
// - `object:action` → object CRUD gate.
318+
// - bare name → an ADR-0066 system capability, checked against the union of
319+
// the user's permission-set `systemPermissions` (from /me/permissions) —
320+
// the SAME subset rule the server applies to `AppSchema.requiredPermissions`.
321+
// This used to be misread as `can(<name>, 'read')` only, so a nav item
322+
// requiring a capability was hidden even from users whose permission set
323+
// granted it (admins included) — `requiredPermissions` degenerated into a
324+
// "hide from everyone" switch. The object-read fallback stays for nav
325+
// items that gate on a plain object name.
326+
const { can, hasCapabilities } = usePermissions();
316327
const checkPerm = React.useCallback(
317328
(permissions: string[]) => permissions.every((perm: string) => {
318329
const parts = perm.split(':');
319-
const [object, action] = parts.length >= 2
320-
? [parts[0], parts[1]]
321-
: [perm, 'read'];
322-
return can(object, action as any);
330+
if (parts.length >= 2) {
331+
return can(parts[0], parts[1] as any);
332+
}
333+
return hasCapabilities([perm]) || can(perm, 'read');
323334
}),
324-
[can],
335+
[can, hasCapabilities],
325336
);
326337

327338
// Runtime capability gate: hide nav items targeting objects/services
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { ExpressionEvaluator } from '@object-ui/core';
11+
import { evaluateVisibility } from './ExpressionProvider';
12+
13+
/**
14+
* Regression: nav/area `visible` predicates arrive from the server as
15+
* `{ dialect: 'cel', source }` envelopes (the spec's ExpressionInputSchema
16+
* normalizes every authored string into that shape). evaluateVisibility used
17+
* to fall through to a blanket `return true` for anything that wasn't a
18+
* `${…}` template string — so a constant-false CEL predicate still rendered
19+
* the menu item for everyone, and "hide this nav item by role" was
20+
* unimplementable from app metadata.
21+
*/
22+
23+
function makeEvaluator(user: Record<string, unknown>) {
24+
const context = { current_user: user, user, ctx: { user }, os: { user }, app: {}, data: {}, features: {} };
25+
return new ExpressionEvaluator(context as any);
26+
}
27+
28+
describe('evaluateVisibility', () => {
29+
const worker = makeEvaluator({ id: 'u1', positions: ['worker'] });
30+
const orgAdmin = makeEvaluator({ id: 'u2', positions: ['org_admin'] });
31+
32+
it('keeps literal handling: booleans and "true"/"false" strings', () => {
33+
expect(evaluateVisibility(undefined, worker)).toBe(true);
34+
expect(evaluateVisibility(true, worker)).toBe(true);
35+
expect(evaluateVisibility('true', worker)).toBe(true);
36+
expect(evaluateVisibility(false, worker)).toBe(false);
37+
expect(evaluateVisibility('false', worker)).toBe(false);
38+
});
39+
40+
it('evaluates a CEL envelope against current_user (spec P`…` form)', () => {
41+
const visible = { dialect: 'cel', source: "'org_admin' in current_user.positions" };
42+
expect(evaluateVisibility(visible, orgAdmin)).toBe(true);
43+
expect(evaluateVisibility(visible, worker)).toBe(false);
44+
});
45+
46+
it('still evaluates ${…} template expressions', () => {
47+
const evaluator = makeEvaluator({ role: 'admin' });
48+
expect(evaluateVisibility("${user.role === 'admin'}", evaluator)).toBe(true);
49+
expect(evaluateVisibility("${user.role === 'guest'}", evaluator)).toBe(false);
50+
});
51+
52+
it('fails open (visible) on an unevaluable predicate', () => {
53+
expect(evaluateVisibility({ dialect: 'cel', source: 'not ] valid (' }, worker)).toBe(true);
54+
});
55+
});

packages/app-shell/src/providers/ExpressionProvider.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -99,25 +99,32 @@ export function useExpressionContext(): ExpressionContextValue {
9999
* - boolean: true/false
100100
* - string "true"/"false"
101101
* - template expression: "${user.role === 'admin'}"
102+
* - `{ dialect: 'cel', source }` envelopes — the shape the spec's
103+
* `ExpressionInputSchema` normalizes every authored `visible` string into,
104+
* which is what nav/area items carry after the server serves the app schema
105+
* - bare expression strings (evaluated as one boolean expression)
102106
*
103-
* Returns true if the item should be visible.
107+
* Everything non-literal is delegated to `evaluator.evaluateCondition`, which
108+
* routes CEL envelopes to the canonical `@objectstack/formula` engine. The
109+
* envelope and bare-string shapes used to fall through to a blanket
110+
* `return true`, so a constant-false nav `visible` predicate (e.g.
111+
* ``P`'org_admin' in current_user.positions` ``) still rendered for everyone —
112+
* the app author had no working way to hide a menu item by role.
113+
*
114+
* Returns true if the item should be visible (fail-open on evaluation errors,
115+
* matching the evaluator's own default).
104116
*/
105117
export function evaluateVisibility(
106-
expression: string | boolean | undefined,
118+
expression: string | boolean | { dialect?: string; source?: string } | undefined,
107119
evaluator: ExpressionEvaluator,
108120
): boolean {
109121
if (expression === undefined || expression === null) return true;
110122
if (expression === true || expression === 'true') return true;
111123
if (expression === false || expression === 'false') return false;
112124

113-
if (typeof expression === 'string' && expression.includes('${')) {
114-
try {
115-
const result = evaluator.evaluateCondition(expression);
116-
return result;
117-
} catch {
118-
return true; // Default to visible on error
119-
}
125+
try {
126+
return evaluator.evaluateCondition(expression);
127+
} catch {
128+
return true; // Default to visible on error
120129
}
121-
122-
return true;
123130
}

packages/i18n/src/locales/ar.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,10 @@ const ar = {
448448
noMatchesMessage: "لا توجد سجلات تطابق عوامل التصفية أو البحث الحالية. حاول تعديلها أو مسحها.",
449449
loadErrorTitle: "تعذّر تحميل السجلات",
450450
loadErrorMessage: "حدث خطأ أثناء تحميل هذه البيانات. تحقق من اتصالك وحاول مرة أخرى.",
451+
loadErrorForbiddenTitle: "ليس لديك صلاحية الوصول",
452+
loadErrorForbiddenMessage: "ليس لديك إذن لعرض هذه السجلات. تواصل مع المسؤول إذا كنت تعتقد أنه يجب أن يكون لديك وصول.",
453+
loadErrorUnauthorizedTitle: "يلزم تسجيل الدخول",
454+
loadErrorUnauthorizedMessage: "انتهت صلاحية جلستك أو تم تسجيل خروجك. سجّل الدخول مرة أخرى لعرض هذه السجلات.",
451455
retry: "إعادة المحاولة",
452456
managedBy: {
453457
system: {

packages/i18n/src/locales/de.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,10 @@ const de = {
448448
noMatchesMessage: "Keine Datensätze entsprechen Ihren aktuellen Filtern oder Ihrer Suche. Passen Sie sie an oder setzen Sie sie zurück.",
449449
loadErrorTitle: "Datensätze konnten nicht geladen werden",
450450
loadErrorMessage: "Beim Laden dieser Daten ist etwas schiefgelaufen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
451+
loadErrorForbiddenTitle: "Kein Zugriff",
452+
loadErrorForbiddenMessage: "Sie haben keine Berechtigung, diese Datensätze anzuzeigen. Wenden Sie sich an Ihren Administrator, wenn Sie Zugriff benötigen.",
453+
loadErrorUnauthorizedTitle: "Anmeldung erforderlich",
454+
loadErrorUnauthorizedMessage: "Ihre Sitzung ist abgelaufen oder Sie sind abgemeldet. Melden Sie sich erneut an, um diese Datensätze anzuzeigen.",
451455
retry: "Erneut versuchen",
452456
managedBy: {
453457
system: {

packages/i18n/src/locales/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,10 @@ const en = {
477477
noMatchesMessage: 'No records match your current filters or search. Try adjusting or clearing them.',
478478
loadErrorTitle: 'Couldn\u2019t load records',
479479
loadErrorMessage: 'Something went wrong while loading this data. Check your connection and try again.',
480+
loadErrorForbiddenTitle: 'You don\u2019t have access',
481+
loadErrorForbiddenMessage: 'You don\u2019t have permission to view these records. Contact your administrator if you think you should have access.',
482+
loadErrorUnauthorizedTitle: 'Sign in required',
483+
loadErrorUnauthorizedMessage: 'Your session has expired or you are signed out. Sign in again to view these records.',
480484
retry: 'Retry',
481485
managedBy: {
482486
system: {

packages/i18n/src/locales/es.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,10 @@ const es = {
448448
noMatchesMessage: "Ningún registro coincide con sus filtros o su búsqueda actuales. Pruebe a ajustarlos o borrarlos.",
449449
loadErrorTitle: "No se pudieron cargar los registros",
450450
loadErrorMessage: "Algo salió mal al cargar estos datos. Compruebe su conexión y vuelva a intentarlo.",
451+
loadErrorForbiddenTitle: "No tiene acceso",
452+
loadErrorForbiddenMessage: "No tiene permiso para ver estos registros. Contacte a su administrador si cree que debería tener acceso.",
453+
loadErrorUnauthorizedTitle: "Se requiere iniciar sesión",
454+
loadErrorUnauthorizedMessage: "Su sesión ha expirado o ha cerrado sesión. Inicie sesión de nuevo para ver estos registros.",
451455
retry: "Reintentar",
452456
managedBy: {
453457
system: {

packages/i18n/src/locales/fr.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,10 @@ const fr = {
448448
noMatchesMessage: "Aucun enregistrement ne correspond à vos filtres ou à votre recherche. Essayez de les ajuster ou de les effacer.",
449449
loadErrorTitle: "Impossible de charger les enregistrements",
450450
loadErrorMessage: "Une erreur s'est produite lors du chargement de ces données. Vérifiez votre connexion et réessayez.",
451+
loadErrorForbiddenTitle: "Accès refusé",
452+
loadErrorForbiddenMessage: "Vous n'avez pas la permission de consulter ces enregistrements. Contactez votre administrateur si vous pensez devoir y accéder.",
453+
loadErrorUnauthorizedTitle: "Connexion requise",
454+
loadErrorUnauthorizedMessage: "Votre session a expiré ou vous êtes déconnecté. Reconnectez-vous pour consulter ces enregistrements.",
451455
retry: "Réessayer",
452456
managedBy: {
453457
system: {

0 commit comments

Comments
 (0)