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
66 changes: 66 additions & 0 deletions .changeset/6799-authored-columns-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
'@object-ui/plugin-grid': patch
---

`ObjectGrid` re-applies field-level security on its authored `columns` path too,
so all three of `generateColumns()`'s default paths now go through the field
gate (objectui#6799, maintainer ruling 2026-08-30).

objectui#6723 closed the inline-data path and left this one. It was the worst of
the three to leave, because it is the **most reachable**: the inline-data path
needs a host to hand rows down, while the authored `columns` path runs whether
the grid fetches its own rows or not.

| path | reached when | FLS re-applied |
| --- | --- | --- |
| authored `columns` (`ListColumn[]` and `string[]` arms) | `schema.columns` present and non-empty | **no, until now** |
| inline-data | host passes `data` and `fields` is declared | yes (objectui#6723) |
| object-schema | everything else | yes |

Both arms now filter through `perms.checkField(objectName, fieldName, 'read')`
when `perms.isLoaded && schema.objectName` — the same gate and the same deferral
condition the other two paths use.

**What a consumer will feel.** A grid that composes `ObjectGrid` directly with
an authored `columns` projection will now render *fewer* columns for a principal
whose field policy denies them: a column naming a declared field the user may
not read disappears, where it previously rendered with its values. If your host
already filters its projection through `checkField` before forwarding — as
`ListView` does — nothing changes at all; this is a measured no-op on that path.
Hosts that did **not** filter first will see the difference, and that is the
point of the change rather than a side effect of it.

⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing
rather than an optimisation.** Host-joined and derived columns pass through
untouched. It matters more here than on the inline-data path: a `ListColumn`
carries `label` / `link` / `action` / `prefix` / `width`, so a column whose
`field` the object does not declare is not a mistake but a legitimate authored
derived column, and dropping it would destroy authoring work. A field policy
that enumerates readable fields answers "no" for a key it has never heard of, so
judging derived keys would silently delete them. Declaration is read with
`hasOwnProperty`, so an inherited name (`constructor`) is not mistaken for a
declared field.

**The judged key is read through `columnIdentity`, never off a bare string.** It
folds the three authored identity spellings — `'salary'`, `{ field: 'salary' }`
and the legacy `{ name: 'salary' }` — which is why one predicate serves both
arms. A gate reading `col.field` directly would find no identity on the legacy
spelling and wave a denied declared field straight through.
`resolvesToDataColumn` still owns its own decisions and runs first, so the gate
narrows what survives and never resurrects a hidden or unresolvable column.

**Defence in depth, not a reachable exploit through `ListView`.** Measured in
this repo: three shipped compositions reach this path without filtering first —
`ObjectView`, and the designer's `ObjectManager` and `FieldDesigner` — plus two
dev/demo harnesses. `ListView` filters its own `effectiveFields` through the
same gate before forwarding, and that redundancy is the point: the invariant
must not rest on every future host having read the docs.

objectui#6598's `hasAuthoredColumns` predicate is unchanged and its rationale is
rewritten in the same change: it used to rest on "the grid would not re-check",
which is no longer true, and it now rests on the half that never depended on the
grid — an empty projection is the author's projection after filtering, and the
object's default columns are not what was authored whether or not they are
FLS-checked on the way out.

Pinned in `packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx`.
52 changes: 52 additions & 0 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1959,6 +1959,56 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
const cols = normalizeColumns(schemaColumns);

if (cols) {
// FLS on the AUTHORED `columns` path (objectui#6799 — maintainer ruling
// 2026-08-30, inheriting objectui#6723's 2026-08-29 reasoning verbatim).
//
// This was the LAST of `generateColumns()`'s three default paths that did
// not re-apply field-level security. The object-schema path always did;
// the inline-data path does as of objectui#6723. Leaving this one out was
// the worst of the three to leave, because it is the MOST REACHABLE:
// objectui#6723's path needs a host to hand rows down, while this one runs
// whether the grid fetches its own rows or not. Three paths of one
// function, two checking and one not, is a bypass around the field gate
// rather than an inconsistency.
//
// ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION — and it bites harder
// here than on the inline-data path. Only keys the OBJECT DECLARES are
// judged; everything else passes through untouched. A `ListColumn` carries
// `label` / `link` / `action` / `prefix` / `width`, so a column whose
// `field` the object does not declare is not a mistake — it is a
// legitimate authored derived or host-joined column (`computed_score`, a
// flattened `account.name`), and deleting it would destroy authoring work.
// `checkField` answers `false` for a field the policy has never heard of,
// so asking it about a derived key is not a stricter reading of the same
// rule — it is a different, wrong one. `hasOwnProperty` rather than a
// truthiness read so an inherited name (`constructor`, `toString`) cannot
// be mistaken for a declared field.
//
// ⛔ THE JUDGED KEY IS READ THROUGH `columnIdentity`, NEVER OFF A BARE
// STRING (the ruling says so by name). `columnIdentity` folds the three
// authored identity spellings — `'salary'`, `{ field: 'salary' }` and the
// legacy `{ name: 'salary' }` — which is why ONE predicate serves both
// arms below. A gate reading `col.field` directly would find no identity
// on the legacy spelling and wave a denied declared field straight
// through. `resolvesToDataColumn` keeps owning its own decisions and runs
// first: this gate narrows what survives, it never resurrects a hidden or
// unresolvable column.
//
// Redundant through `ListView`, which filters its own `effectiveFields`
// through this same gate before forwarding them as `columns` — and that
// redundancy IS the point: the invariant must not rest on every future
// host having read the docs. Measured in-repo hosts that do NOT filter
// first: `ObjectView`, `ObjectManager`, `FieldDesigner`. Pinned in
// `authoredColumnsFls-6799.test.tsx`.
const passesFieldGate = (entry: unknown): boolean => {
if (!perms?.isLoaded || !schema.objectName) return true;
const fieldName = columnIdentity(entry);
// No readable identity ⇒ nothing to ask the policy about.
if (!fieldName) return true;
// Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
};
// ObjectStack's DECLARED column spelling is the only one read
// (objectui#5068). `ObjectGridSchema.columns` is `string[] | ListColumn[]`,
// and `ListColumnSchema` in `@objectstack/spec/ui` is a STRICT object:
Expand DownExpand Up@@ -1991,6 +2041,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `col?.field && typeof col.field === 'string' && !col.hidden`.
return (cols as ListColumn[])
.filter((col) => resolvesToDataColumn(col))
.filter((col) => passesFieldGate(col))
.map((col, colIndex) => {
// Fall back to the SCHEMA FIELD's label before prettifying the machine
// name — otherwise a column declared as bare { field } shows an English
Expand DownExpand Up@@ -2203,6 +2254,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// String array format - enrich with objectDef field metadata for type-aware rendering
return (cols as string[])
.filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
.filter((fieldName) => passesFieldGate(fieldName))
.map((fieldName, colIndex) => {
const fieldDef = objectSchema?.fields?.[fieldName];
const rawFieldLabel = fieldDef?.label;
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
66 changes: 66 additions & 0 deletions .changeset/6799-authored-columns-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
'@object-ui/plugin-grid': patch
---

`ObjectGrid` re-applies field-level security on its authored `columns` path too,
so all three of `generateColumns()`'s default paths now go through the field
gate (objectui#6799, maintainer ruling 2026-08-30).

objectui#6723 closed the inline-data path and left this one. It was the worst of
the three to leave, because it is the **most reachable**: the inline-data path
needs a host to hand rows down, while the authored `columns` path runs whether
the grid fetches its own rows or not.

| path | reached when | FLS re-applied |
| --- | --- | --- |
| authored `columns` (`ListColumn[]` and `string[]` arms) | `schema.columns` present and non-empty | **no, until now** |
| inline-data | host passes `data` and `fields` is declared | yes (objectui#6723) |
| object-schema | everything else | yes |

Both arms now filter through `perms.checkField(objectName, fieldName, 'read')`
when `perms.isLoaded && schema.objectName` — the same gate and the same deferral
condition the other two paths use.

**What a consumer will feel.** A grid that composes `ObjectGrid` directly with
an authored `columns` projection will now render *fewer* columns for a principal
whose field policy denies them: a column naming a declared field the user may
not read disappears, where it previously rendered with its values. If your host
already filters its projection through `checkField` before forwarding — as
`ListView` does — nothing changes at all; this is a measured no-op on that path.
Hosts that did **not** filter first will see the difference, and that is the
point of the change rather than a side effect of it.

⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing
rather than an optimisation.** Host-joined and derived columns pass through
untouched. It matters more here than on the inline-data path: a `ListColumn`
carries `label` / `link` / `action` / `prefix` / `width`, so a column whose
`field` the object does not declare is not a mistake but a legitimate authored
derived column, and dropping it would destroy authoring work. A field policy
that enumerates readable fields answers "no" for a key it has never heard of, so
judging derived keys would silently delete them. Declaration is read with
`hasOwnProperty`, so an inherited name (`constructor`) is not mistaken for a
declared field.

**The judged key is read through `columnIdentity`, never off a bare string.** It
folds the three authored identity spellings — `'salary'`, `{ field: 'salary' }`
and the legacy `{ name: 'salary' }` — which is why one predicate serves both
arms. A gate reading `col.field` directly would find no identity on the legacy
spelling and wave a denied declared field straight through.
`resolvesToDataColumn` still owns its own decisions and runs first, so the gate
narrows what survives and never resurrects a hidden or unresolvable column.

**Defence in depth, not a reachable exploit through `ListView`.** Measured in
this repo: three shipped compositions reach this path without filtering first —
`ObjectView`, and the designer's `ObjectManager` and `FieldDesigner` — plus two
dev/demo harnesses. `ListView` filters its own `effectiveFields` through the
same gate before forwarding, and that redundancy is the point: the invariant
must not rest on every future host having read the docs.

objectui#6598's `hasAuthoredColumns` predicate is unchanged and its rationale is
rewritten in the same change: it used to rest on "the grid would not re-check",
which is no longer true, and it now rests on the half that never depended on the
grid — an empty projection is the author's projection after filtering, and the
object's default columns are not what was authored whether or not they are
FLS-checked on the way out.

Pinned in `packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx`.
52 changes: 52 additions & 0 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1959,6 +1959,56 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
const cols = normalizeColumns(schemaColumns);

if (cols) {
// FLS on the AUTHORED `columns` path (objectui#6799 — maintainer ruling
// 2026-08-30, inheriting objectui#6723's 2026-08-29 reasoning verbatim).
//
// This was the LAST of `generateColumns()`'s three default paths that did
// not re-apply field-level security. The object-schema path always did;
// the inline-data path does as of objectui#6723. Leaving this one out was
// the worst of the three to leave, because it is the MOST REACHABLE:
// objectui#6723's path needs a host to hand rows down, while this one runs
// whether the grid fetches its own rows or not. Three paths of one
// function, two checking and one not, is a bypass around the field gate
// rather than an inconsistency.
//
// ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION — and it bites harder
// here than on the inline-data path. Only keys the OBJECT DECLARES are
// judged; everything else passes through untouched. A `ListColumn` carries
// `label` / `link` / `action` / `prefix` / `width`, so a column whose
// `field` the object does not declare is not a mistake — it is a
// legitimate authored derived or host-joined column (`computed_score`, a
// flattened `account.name`), and deleting it would destroy authoring work.
// `checkField` answers `false` for a field the policy has never heard of,
// so asking it about a derived key is not a stricter reading of the same
// rule — it is a different, wrong one. `hasOwnProperty` rather than a
// truthiness read so an inherited name (`constructor`, `toString`) cannot
// be mistaken for a declared field.
//
// ⛔ THE JUDGED KEY IS READ THROUGH `columnIdentity`, NEVER OFF A BARE
// STRING (the ruling says so by name). `columnIdentity` folds the three
// authored identity spellings — `'salary'`, `{ field: 'salary' }` and the
// legacy `{ name: 'salary' }` — which is why ONE predicate serves both
// arms below. A gate reading `col.field` directly would find no identity
// on the legacy spelling and wave a denied declared field straight
// through. `resolvesToDataColumn` keeps owning its own decisions and runs
// first: this gate narrows what survives, it never resurrects a hidden or
// unresolvable column.
//
// Redundant through `ListView`, which filters its own `effectiveFields`
// through this same gate before forwarding them as `columns` — and that
// redundancy IS the point: the invariant must not rest on every future
// host having read the docs. Measured in-repo hosts that do NOT filter
// first: `ObjectView`, `ObjectManager`, `FieldDesigner`. Pinned in
// `authoredColumnsFls-6799.test.tsx`.
const passesFieldGate = (entry: unknown): boolean => {
if (!perms?.isLoaded || !schema.objectName) return true;
const fieldName = columnIdentity(entry);
// No readable identity ⇒ nothing to ask the policy about.
if (!fieldName) return true;
// Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
};
// ObjectStack's DECLARED column spelling is the only one read
// (objectui#5068). `ObjectGridSchema.columns` is `string[] | ListColumn[]`,
// and `ListColumnSchema` in `@objectstack/spec/ui` is a STRICT object:
Expand DownExpand Up@@ -1991,6 +2041,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `col?.field && typeof col.field === 'string' && !col.hidden`.
return (cols as ListColumn[])
.filter((col) => resolvesToDataColumn(col))
.filter((col) => passesFieldGate(col))
.map((col, colIndex) => {
// Fall back to the SCHEMA FIELD's label before prettifying the machine
// name — otherwise a column declared as bare { field } shows an English
Expand DownExpand Up@@ -2203,6 +2254,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// String array format - enrich with objectDef field metadata for type-aware rendering
return (cols as string[])
.filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
.filter((fieldName) => passesFieldGate(fieldName))
.map((fieldName, colIndex) => {
const fieldDef = objectSchema?.fields?.[fieldName];
const rawFieldLabel = fieldDef?.label;
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
66 changes: 66 additions & 0 deletions .changeset/6799-authored-columns-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
'@object-ui/plugin-grid': patch
---

`ObjectGrid` re-applies field-level security on its authored `columns` path too,
so all three of `generateColumns()`'s default paths now go through the field
gate (objectui#6799, maintainer ruling 2026-08-30).

objectui#6723 closed the inline-data path and left this one. It was the worst of
the three to leave, because it is the **most reachable**: the inline-data path
needs a host to hand rows down, while the authored `columns` path runs whether
the grid fetches its own rows or not.

| path | reached when | FLS re-applied |
| --- | --- | --- |
| authored `columns` (`ListColumn[]` and `string[]` arms) | `schema.columns` present and non-empty | **no, until now** |
| inline-data | host passes `data` and `fields` is declared | yes (objectui#6723) |
| object-schema | everything else | yes |

Both arms now filter through `perms.checkField(objectName, fieldName, 'read')`
when `perms.isLoaded && schema.objectName` — the same gate and the same deferral
condition the other two paths use.

**What a consumer will feel.** A grid that composes `ObjectGrid` directly with
an authored `columns` projection will now render *fewer* columns for a principal
whose field policy denies them: a column naming a declared field the user may
not read disappears, where it previously rendered with its values. If your host
already filters its projection through `checkField` before forwarding — as
`ListView` does — nothing changes at all; this is a measured no-op on that path.
Hosts that did **not** filter first will see the difference, and that is the
point of the change rather than a side effect of it.

⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing
rather than an optimisation.** Host-joined and derived columns pass through
untouched. It matters more here than on the inline-data path: a `ListColumn`
carries `label` / `link` / `action` / `prefix` / `width`, so a column whose
`field` the object does not declare is not a mistake but a legitimate authored
derived column, and dropping it would destroy authoring work. A field policy
that enumerates readable fields answers "no" for a key it has never heard of, so
judging derived keys would silently delete them. Declaration is read with
`hasOwnProperty`, so an inherited name (`constructor`) is not mistaken for a
declared field.

**The judged key is read through `columnIdentity`, never off a bare string.** It
folds the three authored identity spellings — `'salary'`, `{ field: 'salary' }`
and the legacy `{ name: 'salary' }` — which is why one predicate serves both
arms. A gate reading `col.field` directly would find no identity on the legacy
spelling and wave a denied declared field straight through.
`resolvesToDataColumn` still owns its own decisions and runs first, so the gate
narrows what survives and never resurrects a hidden or unresolvable column.

**Defence in depth, not a reachable exploit through `ListView`.** Measured in
this repo: three shipped compositions reach this path without filtering first —
`ObjectView`, and the designer's `ObjectManager` and `FieldDesigner` — plus two
dev/demo harnesses. `ListView` filters its own `effectiveFields` through the
same gate before forwarding, and that redundancy is the point: the invariant
must not rest on every future host having read the docs.

objectui#6598's `hasAuthoredColumns` predicate is unchanged and its rationale is
rewritten in the same change: it used to rest on "the grid would not re-check",
which is no longer true, and it now rests on the half that never depended on the
grid — an empty projection is the author's projection after filtering, and the
object's default columns are not what was authored whether or not they are
FLS-checked on the way out.

Pinned in `packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx`.
52 changes: 52 additions & 0 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1959,6 +1959,56 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
const cols = normalizeColumns(schemaColumns);

if (cols) {
// FLS on the AUTHORED `columns` path (objectui#6799 — maintainer ruling
// 2026-08-30, inheriting objectui#6723's 2026-08-29 reasoning verbatim).
//
// This was the LAST of `generateColumns()`'s three default paths that did
// not re-apply field-level security. The object-schema path always did;
// the inline-data path does as of objectui#6723. Leaving this one out was
// the worst of the three to leave, because it is the MOST REACHABLE:
// objectui#6723's path needs a host to hand rows down, while this one runs
// whether the grid fetches its own rows or not. Three paths of one
// function, two checking and one not, is a bypass around the field gate
// rather than an inconsistency.
//
// ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION — and it bites harder
// here than on the inline-data path. Only keys the OBJECT DECLARES are
// judged; everything else passes through untouched. A `ListColumn` carries
// `label` / `link` / `action` / `prefix` / `width`, so a column whose
// `field` the object does not declare is not a mistake — it is a
// legitimate authored derived or host-joined column (`computed_score`, a
// flattened `account.name`), and deleting it would destroy authoring work.
// `checkField` answers `false` for a field the policy has never heard of,
// so asking it about a derived key is not a stricter reading of the same
// rule — it is a different, wrong one. `hasOwnProperty` rather than a
// truthiness read so an inherited name (`constructor`, `toString`) cannot
// be mistaken for a declared field.
//
// ⛔ THE JUDGED KEY IS READ THROUGH `columnIdentity`, NEVER OFF A BARE
// STRING (the ruling says so by name). `columnIdentity` folds the three
// authored identity spellings — `'salary'`, `{ field: 'salary' }` and the
// legacy `{ name: 'salary' }` — which is why ONE predicate serves both
// arms below. A gate reading `col.field` directly would find no identity
// on the legacy spelling and wave a denied declared field straight
// through. `resolvesToDataColumn` keeps owning its own decisions and runs
// first: this gate narrows what survives, it never resurrects a hidden or
// unresolvable column.
//
// Redundant through `ListView`, which filters its own `effectiveFields`
// through this same gate before forwarding them as `columns` — and that
// redundancy IS the point: the invariant must not rest on every future
// host having read the docs. Measured in-repo hosts that do NOT filter
// first: `ObjectView`, `ObjectManager`, `FieldDesigner`. Pinned in
// `authoredColumnsFls-6799.test.tsx`.
const passesFieldGate = (entry: unknown): boolean => {
if (!perms?.isLoaded || !schema.objectName) return true;
const fieldName = columnIdentity(entry);
// No readable identity ⇒ nothing to ask the policy about.
if (!fieldName) return true;
// Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
};
// ObjectStack's DECLARED column spelling is the only one read
// (objectui#5068). `ObjectGridSchema.columns` is `string[] | ListColumn[]`,
// and `ListColumnSchema` in `@objectstack/spec/ui` is a STRICT object:
Expand DownExpand Up@@ -1991,6 +2041,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `col?.field && typeof col.field === 'string' && !col.hidden`.
return (cols as ListColumn[])
.filter((col) => resolvesToDataColumn(col))
.filter((col) => passesFieldGate(col))
.map((col, colIndex) => {
// Fall back to the SCHEMA FIELD's label before prettifying the machine
// name — otherwise a column declared as bare { field } shows an English
Expand DownExpand Up@@ -2203,6 +2254,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// String array format - enrich with objectDef field metadata for type-aware rendering
return (cols as string[])
.filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
.filter((fieldName) => passesFieldGate(fieldName))
.map((fieldName, colIndex) => {
const fieldDef = objectSchema?.fields?.[fieldName];
const rawFieldLabel = fieldDef?.label;
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
66 changes: 66 additions & 0 deletions .changeset/6799-authored-columns-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
'@object-ui/plugin-grid': patch
---

`ObjectGrid` re-applies field-level security on its authored `columns` path too,
so all three of `generateColumns()`'s default paths now go through the field
gate (objectui#6799, maintainer ruling 2026-08-30).

objectui#6723 closed the inline-data path and left this one. It was the worst of
the three to leave, because it is the **most reachable**: the inline-data path
needs a host to hand rows down, while the authored `columns` path runs whether
the grid fetches its own rows or not.

| path | reached when | FLS re-applied |
| --- | --- | --- |
| authored `columns` (`ListColumn[]` and `string[]` arms) | `schema.columns` present and non-empty | **no, until now** |
| inline-data | host passes `data` and `fields` is declared | yes (objectui#6723) |
| object-schema | everything else | yes |

Both arms now filter through `perms.checkField(objectName, fieldName, 'read')`
when `perms.isLoaded && schema.objectName` — the same gate and the same deferral
condition the other two paths use.

**What a consumer will feel.** A grid that composes `ObjectGrid` directly with
an authored `columns` projection will now render *fewer* columns for a principal
whose field policy denies them: a column naming a declared field the user may
not read disappears, where it previously rendered with its values. If your host
already filters its projection through `checkField` before forwarding — as
`ListView` does — nothing changes at all; this is a measured no-op on that path.
Hosts that did **not** filter first will see the difference, and that is the
point of the change rather than a side effect of it.

⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing
rather than an optimisation.** Host-joined and derived columns pass through
untouched. It matters more here than on the inline-data path: a `ListColumn`
carries `label` / `link` / `action` / `prefix` / `width`, so a column whose
`field` the object does not declare is not a mistake but a legitimate authored
derived column, and dropping it would destroy authoring work. A field policy
that enumerates readable fields answers "no" for a key it has never heard of, so
judging derived keys would silently delete them. Declaration is read with
`hasOwnProperty`, so an inherited name (`constructor`) is not mistaken for a
declared field.

**The judged key is read through `columnIdentity`, never off a bare string.** It
folds the three authored identity spellings — `'salary'`, `{ field: 'salary' }`
and the legacy `{ name: 'salary' }` — which is why one predicate serves both
arms. A gate reading `col.field` directly would find no identity on the legacy
spelling and wave a denied declared field straight through.
`resolvesToDataColumn` still owns its own decisions and runs first, so the gate
narrows what survives and never resurrects a hidden or unresolvable column.

**Defence in depth, not a reachable exploit through `ListView`.** Measured in
this repo: three shipped compositions reach this path without filtering first —
`ObjectView`, and the designer's `ObjectManager` and `FieldDesigner` — plus two
dev/demo harnesses. `ListView` filters its own `effectiveFields` through the
same gate before forwarding, and that redundancy is the point: the invariant
must not rest on every future host having read the docs.

objectui#6598's `hasAuthoredColumns` predicate is unchanged and its rationale is
rewritten in the same change: it used to rest on "the grid would not re-check",
which is no longer true, and it now rests on the half that never depended on the
grid — an empty projection is the author's projection after filtering, and the
object's default columns are not what was authored whether or not they are
FLS-checked on the way out.

Pinned in `packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx`.
52 changes: 52 additions & 0 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1959,6 +1959,56 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
const cols = normalizeColumns(schemaColumns);

if (cols) {
// FLS on the AUTHORED `columns` path (objectui#6799 — maintainer ruling
// 2026-08-30, inheriting objectui#6723's 2026-08-29 reasoning verbatim).
//
// This was the LAST of `generateColumns()`'s three default paths that did
// not re-apply field-level security. The object-schema path always did;
// the inline-data path does as of objectui#6723. Leaving this one out was
// the worst of the three to leave, because it is the MOST REACHABLE:
// objectui#6723's path needs a host to hand rows down, while this one runs
// whether the grid fetches its own rows or not. Three paths of one
// function, two checking and one not, is a bypass around the field gate
// rather than an inconsistency.
//
// ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION — and it bites harder
// here than on the inline-data path. Only keys the OBJECT DECLARES are
// judged; everything else passes through untouched. A `ListColumn` carries
// `label` / `link` / `action` / `prefix` / `width`, so a column whose
// `field` the object does not declare is not a mistake — it is a
// legitimate authored derived or host-joined column (`computed_score`, a
// flattened `account.name`), and deleting it would destroy authoring work.
// `checkField` answers `false` for a field the policy has never heard of,
// so asking it about a derived key is not a stricter reading of the same
// rule — it is a different, wrong one. `hasOwnProperty` rather than a
// truthiness read so an inherited name (`constructor`, `toString`) cannot
// be mistaken for a declared field.
//
// ⛔ THE JUDGED KEY IS READ THROUGH `columnIdentity`, NEVER OFF A BARE
// STRING (the ruling says so by name). `columnIdentity` folds the three
// authored identity spellings — `'salary'`, `{ field: 'salary' }` and the
// legacy `{ name: 'salary' }` — which is why ONE predicate serves both
// arms below. A gate reading `col.field` directly would find no identity
// on the legacy spelling and wave a denied declared field straight
// through. `resolvesToDataColumn` keeps owning its own decisions and runs
// first: this gate narrows what survives, it never resurrects a hidden or
// unresolvable column.
//
// Redundant through `ListView`, which filters its own `effectiveFields`
// through this same gate before forwarding them as `columns` — and that
// redundancy IS the point: the invariant must not rest on every future
// host having read the docs. Measured in-repo hosts that do NOT filter
// first: `ObjectView`, `ObjectManager`, `FieldDesigner`. Pinned in
// `authoredColumnsFls-6799.test.tsx`.
const passesFieldGate = (entry: unknown): boolean => {
if (!perms?.isLoaded || !schema.objectName) return true;
const fieldName = columnIdentity(entry);
// No readable identity ⇒ nothing to ask the policy about.
if (!fieldName) return true;
// Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
};
// ObjectStack's DECLARED column spelling is the only one read
// (objectui#5068). `ObjectGridSchema.columns` is `string[] | ListColumn[]`,
// and `ListColumnSchema` in `@objectstack/spec/ui` is a STRICT object:
Expand DownExpand Up@@ -1991,6 +2041,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `col?.field && typeof col.field === 'string' && !col.hidden`.
return (cols as ListColumn[])
.filter((col) => resolvesToDataColumn(col))
.filter((col) => passesFieldGate(col))
.map((col, colIndex) => {
// Fall back to the SCHEMA FIELD's label before prettifying the machine
// name — otherwise a column declared as bare { field } shows an English
Expand DownExpand Up@@ -2203,6 +2254,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// String array format - enrich with objectDef field metadata for type-aware rendering
return (cols as string[])
.filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
.filter((fieldName) => passesFieldGate(fieldName))
.map((fieldName, colIndex) => {
const fieldDef = objectSchema?.fields?.[fieldName];
const rawFieldLabel = fieldDef?.label;
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
66 changes: 66 additions & 0 deletions .changeset/6799-authored-columns-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
'@object-ui/plugin-grid': patch
---

`ObjectGrid` re-applies field-level security on its authored `columns` path too,
so all three of `generateColumns()`'s default paths now go through the field
gate (objectui#6799, maintainer ruling 2026-08-30).

objectui#6723 closed the inline-data path and left this one. It was the worst of
the three to leave, because it is the **most reachable**: the inline-data path
needs a host to hand rows down, while the authored `columns` path runs whether
the grid fetches its own rows or not.

| path | reached when | FLS re-applied |
| --- | --- | --- |
| authored `columns` (`ListColumn[]` and `string[]` arms) | `schema.columns` present and non-empty | **no, until now** |
| inline-data | host passes `data` and `fields` is declared | yes (objectui#6723) |
| object-schema | everything else | yes |

Both arms now filter through `perms.checkField(objectName, fieldName, 'read')`
when `perms.isLoaded && schema.objectName` — the same gate and the same deferral
condition the other two paths use.

**What a consumer will feel.** A grid that composes `ObjectGrid` directly with
an authored `columns` projection will now render *fewer* columns for a principal
whose field policy denies them: a column naming a declared field the user may
not read disappears, where it previously rendered with its values. If your host
already filters its projection through `checkField` before forwarding — as
`ListView` does — nothing changes at all; this is a measured no-op on that path.
Hosts that did **not** filter first will see the difference, and that is the
point of the change rather than a side effect of it.

⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing
rather than an optimisation.** Host-joined and derived columns pass through
untouched. It matters more here than on the inline-data path: a `ListColumn`
carries `label` / `link` / `action` / `prefix` / `width`, so a column whose
`field` the object does not declare is not a mistake but a legitimate authored
derived column, and dropping it would destroy authoring work. A field policy
that enumerates readable fields answers "no" for a key it has never heard of, so
judging derived keys would silently delete them. Declaration is read with
`hasOwnProperty`, so an inherited name (`constructor`) is not mistaken for a
declared field.

**The judged key is read through `columnIdentity`, never off a bare string.** It
folds the three authored identity spellings — `'salary'`, `{ field: 'salary' }`
and the legacy `{ name: 'salary' }` — which is why one predicate serves both
arms. A gate reading `col.field` directly would find no identity on the legacy
spelling and wave a denied declared field straight through.
`resolvesToDataColumn` still owns its own decisions and runs first, so the gate
narrows what survives and never resurrects a hidden or unresolvable column.

**Defence in depth, not a reachable exploit through `ListView`.** Measured in
this repo: three shipped compositions reach this path without filtering first —
`ObjectView`, and the designer's `ObjectManager` and `FieldDesigner` — plus two
dev/demo harnesses. `ListView` filters its own `effectiveFields` through the
same gate before forwarding, and that redundancy is the point: the invariant
must not rest on every future host having read the docs.

objectui#6598's `hasAuthoredColumns` predicate is unchanged and its rationale is
rewritten in the same change: it used to rest on "the grid would not re-check",
which is no longer true, and it now rests on the half that never depended on the
grid — an empty projection is the author's projection after filtering, and the
object's default columns are not what was authored whether or not they are
FLS-checked on the way out.

Pinned in `packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx`.
52 changes: 52 additions & 0 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1959,6 +1959,56 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
const cols = normalizeColumns(schemaColumns);

if (cols) {
// FLS on the AUTHORED `columns` path (objectui#6799 — maintainer ruling
// 2026-08-30, inheriting objectui#6723's 2026-08-29 reasoning verbatim).
//
// This was the LAST of `generateColumns()`'s three default paths that did
// not re-apply field-level security. The object-schema path always did;
// the inline-data path does as of objectui#6723. Leaving this one out was
// the worst of the three to leave, because it is the MOST REACHABLE:
// objectui#6723's path needs a host to hand rows down, while this one runs
// whether the grid fetches its own rows or not. Three paths of one
// function, two checking and one not, is a bypass around the field gate
// rather than an inconsistency.
//
// ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION — and it bites harder
// here than on the inline-data path. Only keys the OBJECT DECLARES are
// judged; everything else passes through untouched. A `ListColumn` carries
// `label` / `link` / `action` / `prefix` / `width`, so a column whose
// `field` the object does not declare is not a mistake — it is a
// legitimate authored derived or host-joined column (`computed_score`, a
// flattened `account.name`), and deleting it would destroy authoring work.
// `checkField` answers `false` for a field the policy has never heard of,
// so asking it about a derived key is not a stricter reading of the same
// rule — it is a different, wrong one. `hasOwnProperty` rather than a
// truthiness read so an inherited name (`constructor`, `toString`) cannot
// be mistaken for a declared field.
//
// ⛔ THE JUDGED KEY IS READ THROUGH `columnIdentity`, NEVER OFF A BARE
// STRING (the ruling says so by name). `columnIdentity` folds the three
// authored identity spellings — `'salary'`, `{ field: 'salary' }` and the
// legacy `{ name: 'salary' }` — which is why ONE predicate serves both
// arms below. A gate reading `col.field` directly would find no identity
// on the legacy spelling and wave a denied declared field straight
// through. `resolvesToDataColumn` keeps owning its own decisions and runs
// first: this gate narrows what survives, it never resurrects a hidden or
// unresolvable column.
//
// Redundant through `ListView`, which filters its own `effectiveFields`
// through this same gate before forwarding them as `columns` — and that
// redundancy IS the point: the invariant must not rest on every future
// host having read the docs. Measured in-repo hosts that do NOT filter
// first: `ObjectView`, `ObjectManager`, `FieldDesigner`. Pinned in
// `authoredColumnsFls-6799.test.tsx`.
const passesFieldGate = (entry: unknown): boolean => {
if (!perms?.isLoaded || !schema.objectName) return true;
const fieldName = columnIdentity(entry);
// No readable identity ⇒ nothing to ask the policy about.
if (!fieldName) return true;
// Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
};
// ObjectStack's DECLARED column spelling is the only one read
// (objectui#5068). `ObjectGridSchema.columns` is `string[] | ListColumn[]`,
// and `ListColumnSchema` in `@objectstack/spec/ui` is a STRICT object:
Expand DownExpand Up@@ -1991,6 +2041,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `col?.field && typeof col.field === 'string' && !col.hidden`.
return (cols as ListColumn[])
.filter((col) => resolvesToDataColumn(col))
.filter((col) => passesFieldGate(col))
.map((col, colIndex) => {
// Fall back to the SCHEMA FIELD's label before prettifying the machine
// name — otherwise a column declared as bare { field } shows an English
Expand DownExpand Up@@ -2203,6 +2254,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// String array format - enrich with objectDef field metadata for type-aware rendering
return (cols as string[])
.filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
.filter((fieldName) => passesFieldGate(fieldName))
.map((fieldName, colIndex) => {
const fieldDef = objectSchema?.fields?.[fieldName];
const rawFieldLabel = fieldDef?.label;
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
66 changes: 66 additions & 0 deletions .changeset/6799-authored-columns-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
'@object-ui/plugin-grid': patch
---

`ObjectGrid` re-applies field-level security on its authored `columns` path too,
so all three of `generateColumns()`'s default paths now go through the field
gate (objectui#6799, maintainer ruling 2026-08-30).

objectui#6723 closed the inline-data path and left this one. It was the worst of
the three to leave, because it is the **most reachable**: the inline-data path
needs a host to hand rows down, while the authored `columns` path runs whether
the grid fetches its own rows or not.

| path | reached when | FLS re-applied |
| --- | --- | --- |
| authored `columns` (`ListColumn[]` and `string[]` arms) | `schema.columns` present and non-empty | **no, until now** |
| inline-data | host passes `data` and `fields` is declared | yes (objectui#6723) |
| object-schema | everything else | yes |

Both arms now filter through `perms.checkField(objectName, fieldName, 'read')`
when `perms.isLoaded && schema.objectName` — the same gate and the same deferral
condition the other two paths use.

**What a consumer will feel.** A grid that composes `ObjectGrid` directly with
an authored `columns` projection will now render *fewer* columns for a principal
whose field policy denies them: a column naming a declared field the user may
not read disappears, where it previously rendered with its values. If your host
already filters its projection through `checkField` before forwarding — as
`ListView` does — nothing changes at all; this is a measured no-op on that path.
Hosts that did **not** filter first will see the difference, and that is the
point of the change rather than a side effect of it.

⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing
rather than an optimisation.** Host-joined and derived columns pass through
untouched. It matters more here than on the inline-data path: a `ListColumn`
carries `label` / `link` / `action` / `prefix` / `width`, so a column whose
`field` the object does not declare is not a mistake but a legitimate authored
derived column, and dropping it would destroy authoring work. A field policy
that enumerates readable fields answers "no" for a key it has never heard of, so
judging derived keys would silently delete them. Declaration is read with
`hasOwnProperty`, so an inherited name (`constructor`) is not mistaken for a
declared field.

**The judged key is read through `columnIdentity`, never off a bare string.** It
folds the three authored identity spellings — `'salary'`, `{ field: 'salary' }`
and the legacy `{ name: 'salary' }` — which is why one predicate serves both
arms. A gate reading `col.field` directly would find no identity on the legacy
spelling and wave a denied declared field straight through.
`resolvesToDataColumn` still owns its own decisions and runs first, so the gate
narrows what survives and never resurrects a hidden or unresolvable column.

**Defence in depth, not a reachable exploit through `ListView`.** Measured in
this repo: three shipped compositions reach this path without filtering first —
`ObjectView`, and the designer's `ObjectManager` and `FieldDesigner` — plus two
dev/demo harnesses. `ListView` filters its own `effectiveFields` through the
same gate before forwarding, and that redundancy is the point: the invariant
must not rest on every future host having read the docs.

objectui#6598's `hasAuthoredColumns` predicate is unchanged and its rationale is
rewritten in the same change: it used to rest on "the grid would not re-check",
which is no longer true, and it now rests on the half that never depended on the
grid — an empty projection is the author's projection after filtering, and the
object's default columns are not what was authored whether or not they are
FLS-checked on the way out.

Pinned in `packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx`.
52 changes: 52 additions & 0 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1959,6 +1959,56 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
const cols = normalizeColumns(schemaColumns);

if (cols) {
// FLS on the AUTHORED `columns` path (objectui#6799 — maintainer ruling
// 2026-08-30, inheriting objectui#6723's 2026-08-29 reasoning verbatim).
//
// This was the LAST of `generateColumns()`'s three default paths that did
// not re-apply field-level security. The object-schema path always did;
// the inline-data path does as of objectui#6723. Leaving this one out was
// the worst of the three to leave, because it is the MOST REACHABLE:
// objectui#6723's path needs a host to hand rows down, while this one runs
// whether the grid fetches its own rows or not. Three paths of one
// function, two checking and one not, is a bypass around the field gate
// rather than an inconsistency.
//
// ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION — and it bites harder
// here than on the inline-data path. Only keys the OBJECT DECLARES are
// judged; everything else passes through untouched. A `ListColumn` carries
// `label` / `link` / `action` / `prefix` / `width`, so a column whose
// `field` the object does not declare is not a mistake — it is a
// legitimate authored derived or host-joined column (`computed_score`, a
// flattened `account.name`), and deleting it would destroy authoring work.
// `checkField` answers `false` for a field the policy has never heard of,
// so asking it about a derived key is not a stricter reading of the same
// rule — it is a different, wrong one. `hasOwnProperty` rather than a
// truthiness read so an inherited name (`constructor`, `toString`) cannot
// be mistaken for a declared field.
//
// ⛔ THE JUDGED KEY IS READ THROUGH `columnIdentity`, NEVER OFF A BARE
// STRING (the ruling says so by name). `columnIdentity` folds the three
// authored identity spellings — `'salary'`, `{ field: 'salary' }` and the
// legacy `{ name: 'salary' }` — which is why ONE predicate serves both
// arms below. A gate reading `col.field` directly would find no identity
// on the legacy spelling and wave a denied declared field straight
// through. `resolvesToDataColumn` keeps owning its own decisions and runs
// first: this gate narrows what survives, it never resurrects a hidden or
// unresolvable column.
//
// Redundant through `ListView`, which filters its own `effectiveFields`
// through this same gate before forwarding them as `columns` — and that
// redundancy IS the point: the invariant must not rest on every future
// host having read the docs. Measured in-repo hosts that do NOT filter
// first: `ObjectView`, `ObjectManager`, `FieldDesigner`. Pinned in
// `authoredColumnsFls-6799.test.tsx`.
const passesFieldGate = (entry: unknown): boolean => {
if (!perms?.isLoaded || !schema.objectName) return true;
const fieldName = columnIdentity(entry);
// No readable identity ⇒ nothing to ask the policy about.
if (!fieldName) return true;
// Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
};
// ObjectStack's DECLARED column spelling is the only one read
// (objectui#5068). `ObjectGridSchema.columns` is `string[] | ListColumn[]`,
// and `ListColumnSchema` in `@objectstack/spec/ui` is a STRICT object:
Expand DownExpand Up@@ -1991,6 +2041,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `col?.field && typeof col.field === 'string' && !col.hidden`.
return (cols as ListColumn[])
.filter((col) => resolvesToDataColumn(col))
.filter((col) => passesFieldGate(col))
.map((col, colIndex) => {
// Fall back to the SCHEMA FIELD's label before prettifying the machine
// name — otherwise a column declared as bare { field } shows an English
Expand DownExpand Up@@ -2203,6 +2254,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// String array format - enrich with objectDef field metadata for type-aware rendering
return (cols as string[])
.filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
.filter((fieldName) => passesFieldGate(fieldName))
.map((fieldName, colIndex) => {
const fieldDef = objectSchema?.fields?.[fieldName];
const rawFieldLabel = fieldDef?.label;
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
66 changes: 66 additions & 0 deletions .changeset/6799-authored-columns-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
'@object-ui/plugin-grid': patch
---

`ObjectGrid` re-applies field-level security on its authored `columns` path too,
so all three of `generateColumns()`'s default paths now go through the field
gate (objectui#6799, maintainer ruling 2026-08-30).

objectui#6723 closed the inline-data path and left this one. It was the worst of
the three to leave, because it is the **most reachable**: the inline-data path
needs a host to hand rows down, while the authored `columns` path runs whether
the grid fetches its own rows or not.

| path | reached when | FLS re-applied |
| --- | --- | --- |
| authored `columns` (`ListColumn[]` and `string[]` arms) | `schema.columns` present and non-empty | **no, until now** |
| inline-data | host passes `data` and `fields` is declared | yes (objectui#6723) |
| object-schema | everything else | yes |

Both arms now filter through `perms.checkField(objectName, fieldName, 'read')`
when `perms.isLoaded && schema.objectName` — the same gate and the same deferral
condition the other two paths use.

**What a consumer will feel.** A grid that composes `ObjectGrid` directly with
an authored `columns` projection will now render *fewer* columns for a principal
whose field policy denies them: a column naming a declared field the user may
not read disappears, where it previously rendered with its values. If your host
already filters its projection through `checkField` before forwarding — as
`ListView` does — nothing changes at all; this is a measured no-op on that path.
Hosts that did **not** filter first will see the difference, and that is the
point of the change rather than a side effect of it.

⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing
rather than an optimisation.** Host-joined and derived columns pass through
untouched. It matters more here than on the inline-data path: a `ListColumn`
carries `label` / `link` / `action` / `prefix` / `width`, so a column whose
`field` the object does not declare is not a mistake but a legitimate authored
derived column, and dropping it would destroy authoring work. A field policy
that enumerates readable fields answers "no" for a key it has never heard of, so
judging derived keys would silently delete them. Declaration is read with
`hasOwnProperty`, so an inherited name (`constructor`) is not mistaken for a
declared field.

**The judged key is read through `columnIdentity`, never off a bare string.** It
folds the three authored identity spellings — `'salary'`, `{ field: 'salary' }`
and the legacy `{ name: 'salary' }` — which is why one predicate serves both
arms. A gate reading `col.field` directly would find no identity on the legacy
spelling and wave a denied declared field straight through.
`resolvesToDataColumn` still owns its own decisions and runs first, so the gate
narrows what survives and never resurrects a hidden or unresolvable column.

**Defence in depth, not a reachable exploit through `ListView`.** Measured in
this repo: three shipped compositions reach this path without filtering first —
`ObjectView`, and the designer's `ObjectManager` and `FieldDesigner` — plus two
dev/demo harnesses. `ListView` filters its own `effectiveFields` through the
same gate before forwarding, and that redundancy is the point: the invariant
must not rest on every future host having read the docs.

objectui#6598's `hasAuthoredColumns` predicate is unchanged and its rationale is
rewritten in the same change: it used to rest on "the grid would not re-check",
which is no longer true, and it now rests on the half that never depended on the
grid — an empty projection is the author's projection after filtering, and the
object's default columns are not what was authored whether or not they are
FLS-checked on the way out.

Pinned in `packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx`.
52 changes: 52 additions & 0 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1959,6 +1959,56 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
const cols = normalizeColumns(schemaColumns);

if (cols) {
// FLS on the AUTHORED `columns` path (objectui#6799 — maintainer ruling
// 2026-08-30, inheriting objectui#6723's 2026-08-29 reasoning verbatim).
//
// This was the LAST of `generateColumns()`'s three default paths that did
// not re-apply field-level security. The object-schema path always did;
// the inline-data path does as of objectui#6723. Leaving this one out was
// the worst of the three to leave, because it is the MOST REACHABLE:
// objectui#6723's path needs a host to hand rows down, while this one runs
// whether the grid fetches its own rows or not. Three paths of one
// function, two checking and one not, is a bypass around the field gate
// rather than an inconsistency.
//
// ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION — and it bites harder
// here than on the inline-data path. Only keys the OBJECT DECLARES are
// judged; everything else passes through untouched. A `ListColumn` carries
// `label` / `link` / `action` / `prefix` / `width`, so a column whose
// `field` the object does not declare is not a mistake — it is a
// legitimate authored derived or host-joined column (`computed_score`, a
// flattened `account.name`), and deleting it would destroy authoring work.
// `checkField` answers `false` for a field the policy has never heard of,
// so asking it about a derived key is not a stricter reading of the same
// rule — it is a different, wrong one. `hasOwnProperty` rather than a
// truthiness read so an inherited name (`constructor`, `toString`) cannot
// be mistaken for a declared field.
//
// ⛔ THE JUDGED KEY IS READ THROUGH `columnIdentity`, NEVER OFF A BARE
// STRING (the ruling says so by name). `columnIdentity` folds the three
// authored identity spellings — `'salary'`, `{ field: 'salary' }` and the
// legacy `{ name: 'salary' }` — which is why ONE predicate serves both
// arms below. A gate reading `col.field` directly would find no identity
// on the legacy spelling and wave a denied declared field straight
// through. `resolvesToDataColumn` keeps owning its own decisions and runs
// first: this gate narrows what survives, it never resurrects a hidden or
// unresolvable column.
//
// Redundant through `ListView`, which filters its own `effectiveFields`
// through this same gate before forwarding them as `columns` — and that
// redundancy IS the point: the invariant must not rest on every future
// host having read the docs. Measured in-repo hosts that do NOT filter
// first: `ObjectView`, `ObjectManager`, `FieldDesigner`. Pinned in
// `authoredColumnsFls-6799.test.tsx`.
const passesFieldGate = (entry: unknown): boolean => {
if (!perms?.isLoaded || !schema.objectName) return true;
const fieldName = columnIdentity(entry);
// No readable identity ⇒ nothing to ask the policy about.
if (!fieldName) return true;
// Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
};
// ObjectStack's DECLARED column spelling is the only one read
// (objectui#5068). `ObjectGridSchema.columns` is `string[] | ListColumn[]`,
// and `ListColumnSchema` in `@objectstack/spec/ui` is a STRICT object:
Expand DownExpand Up@@ -1991,6 +2041,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `col?.field && typeof col.field === 'string' && !col.hidden`.
return (cols as ListColumn[])
.filter((col) => resolvesToDataColumn(col))
.filter((col) => passesFieldGate(col))
.map((col, colIndex) => {
// Fall back to the SCHEMA FIELD's label before prettifying the machine
// name — otherwise a column declared as bare { field } shows an English
Expand DownExpand Up@@ -2203,6 +2254,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// String array format - enrich with objectDef field metadata for type-aware rendering
return (cols as string[])
.filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
.filter((fieldName) => passesFieldGate(fieldName))
.map((fieldName, colIndex) => {
const fieldDef = objectSchema?.fields?.[fieldName];
const rawFieldLabel = fieldDef?.label;
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
66 changes: 66 additions & 0 deletions .changeset/6799-authored-columns-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
'@object-ui/plugin-grid': patch
---

`ObjectGrid` re-applies field-level security on its authored `columns` path too,
so all three of `generateColumns()`'s default paths now go through the field
gate (objectui#6799, maintainer ruling 2026-08-30).

objectui#6723 closed the inline-data path and left this one. It was the worst of
the three to leave, because it is the **most reachable**: the inline-data path
needs a host to hand rows down, while the authored `columns` path runs whether
the grid fetches its own rows or not.

| path | reached when | FLS re-applied |
| --- | --- | --- |
| authored `columns` (`ListColumn[]` and `string[]` arms) | `schema.columns` present and non-empty | **no, until now** |
| inline-data | host passes `data` and `fields` is declared | yes (objectui#6723) |
| object-schema | everything else | yes |

Both arms now filter through `perms.checkField(objectName, fieldName, 'read')`
when `perms.isLoaded && schema.objectName` — the same gate and the same deferral
condition the other two paths use.

**What a consumer will feel.** A grid that composes `ObjectGrid` directly with
an authored `columns` projection will now render *fewer* columns for a principal
whose field policy denies them: a column naming a declared field the user may
not read disappears, where it previously rendered with its values. If your host
already filters its projection through `checkField` before forwarding — as
`ListView` does — nothing changes at all; this is a measured no-op on that path.
Hosts that did **not** filter first will see the difference, and that is the
point of the change rather than a side effect of it.

⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing
rather than an optimisation.** Host-joined and derived columns pass through
untouched. It matters more here than on the inline-data path: a `ListColumn`
carries `label` / `link` / `action` / `prefix` / `width`, so a column whose
`field` the object does not declare is not a mistake but a legitimate authored
derived column, and dropping it would destroy authoring work. A field policy
that enumerates readable fields answers "no" for a key it has never heard of, so
judging derived keys would silently delete them. Declaration is read with
`hasOwnProperty`, so an inherited name (`constructor`) is not mistaken for a
declared field.

**The judged key is read through `columnIdentity`, never off a bare string.** It
folds the three authored identity spellings — `'salary'`, `{ field: 'salary' }`
and the legacy `{ name: 'salary' }` — which is why one predicate serves both
arms. A gate reading `col.field` directly would find no identity on the legacy
spelling and wave a denied declared field straight through.
`resolvesToDataColumn` still owns its own decisions and runs first, so the gate
narrows what survives and never resurrects a hidden or unresolvable column.

**Defence in depth, not a reachable exploit through `ListView`.** Measured in
this repo: three shipped compositions reach this path without filtering first —
`ObjectView`, and the designer's `ObjectManager` and `FieldDesigner` — plus two
dev/demo harnesses. `ListView` filters its own `effectiveFields` through the
same gate before forwarding, and that redundancy is the point: the invariant
must not rest on every future host having read the docs.

objectui#6598's `hasAuthoredColumns` predicate is unchanged and its rationale is
rewritten in the same change: it used to rest on "the grid would not re-check",
which is no longer true, and it now rests on the half that never depended on the
grid — an empty projection is the author's projection after filtering, and the
object's default columns are not what was authored whether or not they are
FLS-checked on the way out.

Pinned in `packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx`.
52 changes: 52 additions & 0 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1959,6 +1959,56 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
const cols = normalizeColumns(schemaColumns);

if (cols) {
// FLS on the AUTHORED `columns` path (objectui#6799 — maintainer ruling
// 2026-08-30, inheriting objectui#6723's 2026-08-29 reasoning verbatim).
//
// This was the LAST of `generateColumns()`'s three default paths that did
// not re-apply field-level security. The object-schema path always did;
// the inline-data path does as of objectui#6723. Leaving this one out was
// the worst of the three to leave, because it is the MOST REACHABLE:
// objectui#6723's path needs a host to hand rows down, while this one runs
// whether the grid fetches its own rows or not. Three paths of one
// function, two checking and one not, is a bypass around the field gate
// rather than an inconsistency.
//
// ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION — and it bites harder
// here than on the inline-data path. Only keys the OBJECT DECLARES are
// judged; everything else passes through untouched. A `ListColumn` carries
// `label` / `link` / `action` / `prefix` / `width`, so a column whose
// `field` the object does not declare is not a mistake — it is a
// legitimate authored derived or host-joined column (`computed_score`, a
// flattened `account.name`), and deleting it would destroy authoring work.
// `checkField` answers `false` for a field the policy has never heard of,
// so asking it about a derived key is not a stricter reading of the same
// rule — it is a different, wrong one. `hasOwnProperty` rather than a
// truthiness read so an inherited name (`constructor`, `toString`) cannot
// be mistaken for a declared field.
//
// ⛔ THE JUDGED KEY IS READ THROUGH `columnIdentity`, NEVER OFF A BARE
// STRING (the ruling says so by name). `columnIdentity` folds the three
// authored identity spellings — `'salary'`, `{ field: 'salary' }` and the
// legacy `{ name: 'salary' }` — which is why ONE predicate serves both
// arms below. A gate reading `col.field` directly would find no identity
// on the legacy spelling and wave a denied declared field straight
// through. `resolvesToDataColumn` keeps owning its own decisions and runs
// first: this gate narrows what survives, it never resurrects a hidden or
// unresolvable column.
//
// Redundant through `ListView`, which filters its own `effectiveFields`
// through this same gate before forwarding them as `columns` — and that
// redundancy IS the point: the invariant must not rest on every future
// host having read the docs. Measured in-repo hosts that do NOT filter
// first: `ObjectView`, `ObjectManager`, `FieldDesigner`. Pinned in
// `authoredColumnsFls-6799.test.tsx`.
const passesFieldGate = (entry: unknown): boolean => {
if (!perms?.isLoaded || !schema.objectName) return true;
const fieldName = columnIdentity(entry);
// No readable identity ⇒ nothing to ask the policy about.
if (!fieldName) return true;
// Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
};
// ObjectStack's DECLARED column spelling is the only one read
// (objectui#5068). `ObjectGridSchema.columns` is `string[] | ListColumn[]`,
// and `ListColumnSchema` in `@objectstack/spec/ui` is a STRICT object:
Expand DownExpand Up@@ -1991,6 +2041,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `col?.field && typeof col.field === 'string' && !col.hidden`.
return (cols as ListColumn[])
.filter((col) => resolvesToDataColumn(col))
.filter((col) => passesFieldGate(col))
.map((col, colIndex) => {
// Fall back to the SCHEMA FIELD's label before prettifying the machine
// name — otherwise a column declared as bare { field } shows an English
Expand DownExpand Up@@ -2203,6 +2254,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// String array format - enrich with objectDef field metadata for type-aware rendering
return (cols as string[])
.filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
.filter((fieldName) => passesFieldGate(fieldName))
.map((fieldName, colIndex) => {
const fieldDef = objectSchema?.fields?.[fieldName];
const rawFieldLabel = fieldDef?.label;
Expand Down
Loading
Loading