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
45 changes: 45 additions & 0 deletions .changeset/7216-listview-speculative-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-list': patch
---

FLS-gate the speculative half of `ListView`'s `$select` projection (objectui#7216).

`ListView` builds `$select` from two populations and, until now, asked them different
questions. The user-declared `columns` were passed through `perms.checkField(...)` by
objectui#6898. Everything the builder adds ON TOP — the kanban / gantt / timeline /
calendar / gallery field bindings, the timeline's auto-added `status` / `priority`
badge fields, and the operands harvested from row-action and conditional-formatting
predicates (objectui#3501) — went through `addSpeculative`, which intersected them
against the object's declared fields and then added them unconditionally.

The two gates it needed answer unrelated questions and neither substitutes for the
other. The known-field gate keeps an **unknown** key out, because some backends answer
an unknown `$select` key with an empty result set rather than ignoring it. The FLS gate
keeps a **known but denied** key out, because sending it leaks the value at the server
boundary even though the UI hides it. A field can be perfectly well-declared and still
denied, and that was the case this path did not handle: a kanban grouped by a denied
field, or a gantt bound to a denied date, still named it in the request.

**Reproduced before it was fixed.** Eight of fourteen new pins fail on the unmodified
tree, each reaching the denied field only through a view binding — a denied *column* has
been dropped since objectui#6898, so a pin naming one would have proved nothing.

The gate goes inside `addSpeculative` rather than at the five call sites, so every route
into the speculative union is covered at once; gating call sites one at a time is how
the asymmetry arose. It runs **after** the known-field intersection, the ordering
objectui#7179 established: `checkField` answers false for an undeclared key, so asking
it first would drop derived and computed bindings — and would be the reason they were
dropped. The platform record columns (`created_at`, `owner_id`, the audit FKs) are
carved out for the reason they already are elsewhere in this builder: every object
carries them and none declares them, so no field policy mentions them and an FLS answer
about them is always false. Without the carve-out a calendar bound to `created_at` would
go blank for everybody.

**Nothing a permitted view did stops working.** A permitted binding is still projected,
an unanswered permission policy filters nothing, and the projection is rebuilt when the
policy answers — pinned, because a gate that only runs before `/me/permissions` resolves
is a dead gate that passes almost every test written for it.

objectui#7179's `addGroupingField` wrapper is removed: its predicate was identical to
the one now inside `addSpeculative`, and two spellings of one gate is the shape that
lets them drift. That path keeps its behaviour and gains the FLS pin it never had.
62 changes: 48 additions & 14 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1731,9 +1731,44 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
s.add('id'); s.add('created_at'); s.add('updated_at');
return s;
})();
// [objectui#7216] TWO gates, asked in this order and of this
// population — they answer unrelated questions and neither
// substitutes for the other:
//
// 1. KNOWN-FIELD gate (above) — keeps an UNKNOWN key out, because
// some backends answer an unknown `$select` key with an EMPTY
// result set rather than ignoring it.
// 2. FLS gate (here) — keeps a KNOWN BUT DENIED key out, because
// sending it leaks the value at the server boundary even though
// the UI hides it (objectui#6898).
//
// A field can be perfectly well-declared and still denied; that is
// the case this helper did not handle. The user-declared `cols` are
// FLS-filtered a few lines up, so every OTHER route into `$select`
// was gated and this one — the auto-included view bindings, the
// predicate refs, the grouping fields — was not.
//
// ⚠️ ORDER IS LOAD-BEARING (objectui#7179's shape): intersect against
// the declared fields FIRST, ask `checkField` only about the
// survivors. `checkField` answers **false for an undeclared key**, so
// asking it first would drop derived and computed bindings that are
// not real object fields — and would be the REASON they were dropped,
// which is a different and much worse failure than the known-field
// gate declining them.
//
// ⚠️ The platform columns are carved out for the same reason they are
// carved out of `addPredicateField`: every object CARRIES them and
// none DECLARES them, so no field policy mentions them and
// `checkField` answers false for every one. Without the carve-out a
// calendar bound to `created_at` would go blank for everybody.
const addSpeculative = (f: unknown) => {
if (typeof f !== 'string' || !f) return;
if (!knownObjectFields || knownObjectFields.has(f)) required.add(f);
if (knownObjectFields && !knownObjectFields.has(f)) return;
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
required.add(f);
};
// Predicate refs take the same guard, widened by the platform columns
// every object carries but no object DECLARES (`owner_id` and the
Expand DownExpand Up@@ -1821,19 +1856,18 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
//
// FLS-gated on top (objectui#6898): the grid half of this same fix
// takes `passesProjectionGate`, and a grouping field can name a
// denied field exactly as a column can. Safe to ask `checkField`
// here precisely BECAUSE `addSpeculative` ran first — everything
// reaching this point is a field the object declares, so the
// "`checkField` answers false for an undeclared key" trap that makes
// this gate wrong on derived columns cannot fire.
const addGroupingField = (f: string) => {
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
addSpeculative(f);
};
for (const f of collectGroupingFieldRefs(groupingConfig)) addGroupingField(f);
// denied field exactly as a column can.
//
// [objectui#7216] That FLS check used to live in an `addGroupingField`
// wrapper right here. It now lives INSIDE `addSpeculative`, with a
// byte-identical predicate, because every OTHER caller of that helper
// needed the same gate and gating them one call site at a time is how
// this asymmetry arose in the first place. The wrapper is gone rather
// than left as a duplicate: two spellings of one gate is the shape
// that lets them drift. `ListView.speculativeFls-7216.test.tsx` PIN 9
// pins this path through the moved gate — plugin-list had no FLS pin
// for grouping before it.
for (const f of collectGroupingFieldRefs(groupingConfig)) addSpeculative(f);

for (const f of collectPredicateFieldRefs(listViewPredicates({
conditionalFormatting: schema.conditionalFormatting as unknown[] | undefined,
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
45 changes: 45 additions & 0 deletions .changeset/7216-listview-speculative-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-list': patch
---

FLS-gate the speculative half of `ListView`'s `$select` projection (objectui#7216).

`ListView` builds `$select` from two populations and, until now, asked them different
questions. The user-declared `columns` were passed through `perms.checkField(...)` by
objectui#6898. Everything the builder adds ON TOP — the kanban / gantt / timeline /
calendar / gallery field bindings, the timeline's auto-added `status` / `priority`
badge fields, and the operands harvested from row-action and conditional-formatting
predicates (objectui#3501) — went through `addSpeculative`, which intersected them
against the object's declared fields and then added them unconditionally.

The two gates it needed answer unrelated questions and neither substitutes for the
other. The known-field gate keeps an **unknown** key out, because some backends answer
an unknown `$select` key with an empty result set rather than ignoring it. The FLS gate
keeps a **known but denied** key out, because sending it leaks the value at the server
boundary even though the UI hides it. A field can be perfectly well-declared and still
denied, and that was the case this path did not handle: a kanban grouped by a denied
field, or a gantt bound to a denied date, still named it in the request.

**Reproduced before it was fixed.** Eight of fourteen new pins fail on the unmodified
tree, each reaching the denied field only through a view binding — a denied *column* has
been dropped since objectui#6898, so a pin naming one would have proved nothing.

The gate goes inside `addSpeculative` rather than at the five call sites, so every route
into the speculative union is covered at once; gating call sites one at a time is how
the asymmetry arose. It runs **after** the known-field intersection, the ordering
objectui#7179 established: `checkField` answers false for an undeclared key, so asking
it first would drop derived and computed bindings — and would be the reason they were
dropped. The platform record columns (`created_at`, `owner_id`, the audit FKs) are
carved out for the reason they already are elsewhere in this builder: every object
carries them and none declares them, so no field policy mentions them and an FLS answer
about them is always false. Without the carve-out a calendar bound to `created_at` would
go blank for everybody.

**Nothing a permitted view did stops working.** A permitted binding is still projected,
an unanswered permission policy filters nothing, and the projection is rebuilt when the
policy answers — pinned, because a gate that only runs before `/me/permissions` resolves
is a dead gate that passes almost every test written for it.

objectui#7179's `addGroupingField` wrapper is removed: its predicate was identical to
the one now inside `addSpeculative`, and two spellings of one gate is the shape that
lets them drift. That path keeps its behaviour and gains the FLS pin it never had.
62 changes: 48 additions & 14 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1731,9 +1731,44 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
s.add('id'); s.add('created_at'); s.add('updated_at');
return s;
})();
// [objectui#7216] TWO gates, asked in this order and of this
// population — they answer unrelated questions and neither
// substitutes for the other:
//
// 1. KNOWN-FIELD gate (above) — keeps an UNKNOWN key out, because
// some backends answer an unknown `$select` key with an EMPTY
// result set rather than ignoring it.
// 2. FLS gate (here) — keeps a KNOWN BUT DENIED key out, because
// sending it leaks the value at the server boundary even though
// the UI hides it (objectui#6898).
//
// A field can be perfectly well-declared and still denied; that is
// the case this helper did not handle. The user-declared `cols` are
// FLS-filtered a few lines up, so every OTHER route into `$select`
// was gated and this one — the auto-included view bindings, the
// predicate refs, the grouping fields — was not.
//
// ⚠️ ORDER IS LOAD-BEARING (objectui#7179's shape): intersect against
// the declared fields FIRST, ask `checkField` only about the
// survivors. `checkField` answers **false for an undeclared key**, so
// asking it first would drop derived and computed bindings that are
// not real object fields — and would be the REASON they were dropped,
// which is a different and much worse failure than the known-field
// gate declining them.
//
// ⚠️ The platform columns are carved out for the same reason they are
// carved out of `addPredicateField`: every object CARRIES them and
// none DECLARES them, so no field policy mentions them and
// `checkField` answers false for every one. Without the carve-out a
// calendar bound to `created_at` would go blank for everybody.
const addSpeculative = (f: unknown) => {
if (typeof f !== 'string' || !f) return;
if (!knownObjectFields || knownObjectFields.has(f)) required.add(f);
if (knownObjectFields && !knownObjectFields.has(f)) return;
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
required.add(f);
};
// Predicate refs take the same guard, widened by the platform columns
// every object carries but no object DECLARES (`owner_id` and the
Expand DownExpand Up@@ -1821,19 +1856,18 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
//
// FLS-gated on top (objectui#6898): the grid half of this same fix
// takes `passesProjectionGate`, and a grouping field can name a
// denied field exactly as a column can. Safe to ask `checkField`
// here precisely BECAUSE `addSpeculative` ran first — everything
// reaching this point is a field the object declares, so the
// "`checkField` answers false for an undeclared key" trap that makes
// this gate wrong on derived columns cannot fire.
const addGroupingField = (f: string) => {
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
addSpeculative(f);
};
for (const f of collectGroupingFieldRefs(groupingConfig)) addGroupingField(f);
// denied field exactly as a column can.
//
// [objectui#7216] That FLS check used to live in an `addGroupingField`
// wrapper right here. It now lives INSIDE `addSpeculative`, with a
// byte-identical predicate, because every OTHER caller of that helper
// needed the same gate and gating them one call site at a time is how
// this asymmetry arose in the first place. The wrapper is gone rather
// than left as a duplicate: two spellings of one gate is the shape
// that lets them drift. `ListView.speculativeFls-7216.test.tsx` PIN 9
// pins this path through the moved gate — plugin-list had no FLS pin
// for grouping before it.
for (const f of collectGroupingFieldRefs(groupingConfig)) addSpeculative(f);

for (const f of collectPredicateFieldRefs(listViewPredicates({
conditionalFormatting: schema.conditionalFormatting as unknown[] | undefined,
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
45 changes: 45 additions & 0 deletions .changeset/7216-listview-speculative-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-list': patch
---

FLS-gate the speculative half of `ListView`'s `$select` projection (objectui#7216).

`ListView` builds `$select` from two populations and, until now, asked them different
questions. The user-declared `columns` were passed through `perms.checkField(...)` by
objectui#6898. Everything the builder adds ON TOP — the kanban / gantt / timeline /
calendar / gallery field bindings, the timeline's auto-added `status` / `priority`
badge fields, and the operands harvested from row-action and conditional-formatting
predicates (objectui#3501) — went through `addSpeculative`, which intersected them
against the object's declared fields and then added them unconditionally.

The two gates it needed answer unrelated questions and neither substitutes for the
other. The known-field gate keeps an **unknown** key out, because some backends answer
an unknown `$select` key with an empty result set rather than ignoring it. The FLS gate
keeps a **known but denied** key out, because sending it leaks the value at the server
boundary even though the UI hides it. A field can be perfectly well-declared and still
denied, and that was the case this path did not handle: a kanban grouped by a denied
field, or a gantt bound to a denied date, still named it in the request.

**Reproduced before it was fixed.** Eight of fourteen new pins fail on the unmodified
tree, each reaching the denied field only through a view binding — a denied *column* has
been dropped since objectui#6898, so a pin naming one would have proved nothing.

The gate goes inside `addSpeculative` rather than at the five call sites, so every route
into the speculative union is covered at once; gating call sites one at a time is how
the asymmetry arose. It runs **after** the known-field intersection, the ordering
objectui#7179 established: `checkField` answers false for an undeclared key, so asking
it first would drop derived and computed bindings — and would be the reason they were
dropped. The platform record columns (`created_at`, `owner_id`, the audit FKs) are
carved out for the reason they already are elsewhere in this builder: every object
carries them and none declares them, so no field policy mentions them and an FLS answer
about them is always false. Without the carve-out a calendar bound to `created_at` would
go blank for everybody.

**Nothing a permitted view did stops working.** A permitted binding is still projected,
an unanswered permission policy filters nothing, and the projection is rebuilt when the
policy answers — pinned, because a gate that only runs before `/me/permissions` resolves
is a dead gate that passes almost every test written for it.

objectui#7179's `addGroupingField` wrapper is removed: its predicate was identical to
the one now inside `addSpeculative`, and two spellings of one gate is the shape that
lets them drift. That path keeps its behaviour and gains the FLS pin it never had.
62 changes: 48 additions & 14 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1731,9 +1731,44 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
s.add('id'); s.add('created_at'); s.add('updated_at');
return s;
})();
// [objectui#7216] TWO gates, asked in this order and of this
// population — they answer unrelated questions and neither
// substitutes for the other:
//
// 1. KNOWN-FIELD gate (above) — keeps an UNKNOWN key out, because
// some backends answer an unknown `$select` key with an EMPTY
// result set rather than ignoring it.
// 2. FLS gate (here) — keeps a KNOWN BUT DENIED key out, because
// sending it leaks the value at the server boundary even though
// the UI hides it (objectui#6898).
//
// A field can be perfectly well-declared and still denied; that is
// the case this helper did not handle. The user-declared `cols` are
// FLS-filtered a few lines up, so every OTHER route into `$select`
// was gated and this one — the auto-included view bindings, the
// predicate refs, the grouping fields — was not.
//
// ⚠️ ORDER IS LOAD-BEARING (objectui#7179's shape): intersect against
// the declared fields FIRST, ask `checkField` only about the
// survivors. `checkField` answers **false for an undeclared key**, so
// asking it first would drop derived and computed bindings that are
// not real object fields — and would be the REASON they were dropped,
// which is a different and much worse failure than the known-field
// gate declining them.
//
// ⚠️ The platform columns are carved out for the same reason they are
// carved out of `addPredicateField`: every object CARRIES them and
// none DECLARES them, so no field policy mentions them and
// `checkField` answers false for every one. Without the carve-out a
// calendar bound to `created_at` would go blank for everybody.
const addSpeculative = (f: unknown) => {
if (typeof f !== 'string' || !f) return;
if (!knownObjectFields || knownObjectFields.has(f)) required.add(f);
if (knownObjectFields && !knownObjectFields.has(f)) return;
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
required.add(f);
};
// Predicate refs take the same guard, widened by the platform columns
// every object carries but no object DECLARES (`owner_id` and the
Expand DownExpand Up@@ -1821,19 +1856,18 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
//
// FLS-gated on top (objectui#6898): the grid half of this same fix
// takes `passesProjectionGate`, and a grouping field can name a
// denied field exactly as a column can. Safe to ask `checkField`
// here precisely BECAUSE `addSpeculative` ran first — everything
// reaching this point is a field the object declares, so the
// "`checkField` answers false for an undeclared key" trap that makes
// this gate wrong on derived columns cannot fire.
const addGroupingField = (f: string) => {
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
addSpeculative(f);
};
for (const f of collectGroupingFieldRefs(groupingConfig)) addGroupingField(f);
// denied field exactly as a column can.
//
// [objectui#7216] That FLS check used to live in an `addGroupingField`
// wrapper right here. It now lives INSIDE `addSpeculative`, with a
// byte-identical predicate, because every OTHER caller of that helper
// needed the same gate and gating them one call site at a time is how
// this asymmetry arose in the first place. The wrapper is gone rather
// than left as a duplicate: two spellings of one gate is the shape
// that lets them drift. `ListView.speculativeFls-7216.test.tsx` PIN 9
// pins this path through the moved gate — plugin-list had no FLS pin
// for grouping before it.
for (const f of collectGroupingFieldRefs(groupingConfig)) addSpeculative(f);

for (const f of collectPredicateFieldRefs(listViewPredicates({
conditionalFormatting: schema.conditionalFormatting as unknown[] | undefined,
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
45 changes: 45 additions & 0 deletions .changeset/7216-listview-speculative-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-list': patch
---

FLS-gate the speculative half of `ListView`'s `$select` projection (objectui#7216).

`ListView` builds `$select` from two populations and, until now, asked them different
questions. The user-declared `columns` were passed through `perms.checkField(...)` by
objectui#6898. Everything the builder adds ON TOP — the kanban / gantt / timeline /
calendar / gallery field bindings, the timeline's auto-added `status` / `priority`
badge fields, and the operands harvested from row-action and conditional-formatting
predicates (objectui#3501) — went through `addSpeculative`, which intersected them
against the object's declared fields and then added them unconditionally.

The two gates it needed answer unrelated questions and neither substitutes for the
other. The known-field gate keeps an **unknown** key out, because some backends answer
an unknown `$select` key with an empty result set rather than ignoring it. The FLS gate
keeps a **known but denied** key out, because sending it leaks the value at the server
boundary even though the UI hides it. A field can be perfectly well-declared and still
denied, and that was the case this path did not handle: a kanban grouped by a denied
field, or a gantt bound to a denied date, still named it in the request.

**Reproduced before it was fixed.** Eight of fourteen new pins fail on the unmodified
tree, each reaching the denied field only through a view binding — a denied *column* has
been dropped since objectui#6898, so a pin naming one would have proved nothing.

The gate goes inside `addSpeculative` rather than at the five call sites, so every route
into the speculative union is covered at once; gating call sites one at a time is how
the asymmetry arose. It runs **after** the known-field intersection, the ordering
objectui#7179 established: `checkField` answers false for an undeclared key, so asking
it first would drop derived and computed bindings — and would be the reason they were
dropped. The platform record columns (`created_at`, `owner_id`, the audit FKs) are
carved out for the reason they already are elsewhere in this builder: every object
carries them and none declares them, so no field policy mentions them and an FLS answer
about them is always false. Without the carve-out a calendar bound to `created_at` would
go blank for everybody.

**Nothing a permitted view did stops working.** A permitted binding is still projected,
an unanswered permission policy filters nothing, and the projection is rebuilt when the
policy answers — pinned, because a gate that only runs before `/me/permissions` resolves
is a dead gate that passes almost every test written for it.

objectui#7179's `addGroupingField` wrapper is removed: its predicate was identical to
the one now inside `addSpeculative`, and two spellings of one gate is the shape that
lets them drift. That path keeps its behaviour and gains the FLS pin it never had.
62 changes: 48 additions & 14 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1731,9 +1731,44 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
s.add('id'); s.add('created_at'); s.add('updated_at');
return s;
})();
// [objectui#7216] TWO gates, asked in this order and of this
// population — they answer unrelated questions and neither
// substitutes for the other:
//
// 1. KNOWN-FIELD gate (above) — keeps an UNKNOWN key out, because
// some backends answer an unknown `$select` key with an EMPTY
// result set rather than ignoring it.
// 2. FLS gate (here) — keeps a KNOWN BUT DENIED key out, because
// sending it leaks the value at the server boundary even though
// the UI hides it (objectui#6898).
//
// A field can be perfectly well-declared and still denied; that is
// the case this helper did not handle. The user-declared `cols` are
// FLS-filtered a few lines up, so every OTHER route into `$select`
// was gated and this one — the auto-included view bindings, the
// predicate refs, the grouping fields — was not.
//
// ⚠️ ORDER IS LOAD-BEARING (objectui#7179's shape): intersect against
// the declared fields FIRST, ask `checkField` only about the
// survivors. `checkField` answers **false for an undeclared key**, so
// asking it first would drop derived and computed bindings that are
// not real object fields — and would be the REASON they were dropped,
// which is a different and much worse failure than the known-field
// gate declining them.
//
// ⚠️ The platform columns are carved out for the same reason they are
// carved out of `addPredicateField`: every object CARRIES them and
// none DECLARES them, so no field policy mentions them and
// `checkField` answers false for every one. Without the carve-out a
// calendar bound to `created_at` would go blank for everybody.
const addSpeculative = (f: unknown) => {
if (typeof f !== 'string' || !f) return;
if (!knownObjectFields || knownObjectFields.has(f)) required.add(f);
if (knownObjectFields && !knownObjectFields.has(f)) return;
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
required.add(f);
};
// Predicate refs take the same guard, widened by the platform columns
// every object carries but no object DECLARES (`owner_id` and the
Expand DownExpand Up@@ -1821,19 +1856,18 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
//
// FLS-gated on top (objectui#6898): the grid half of this same fix
// takes `passesProjectionGate`, and a grouping field can name a
// denied field exactly as a column can. Safe to ask `checkField`
// here precisely BECAUSE `addSpeculative` ran first — everything
// reaching this point is a field the object declares, so the
// "`checkField` answers false for an undeclared key" trap that makes
// this gate wrong on derived columns cannot fire.
const addGroupingField = (f: string) => {
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
addSpeculative(f);
};
for (const f of collectGroupingFieldRefs(groupingConfig)) addGroupingField(f);
// denied field exactly as a column can.
//
// [objectui#7216] That FLS check used to live in an `addGroupingField`
// wrapper right here. It now lives INSIDE `addSpeculative`, with a
// byte-identical predicate, because every OTHER caller of that helper
// needed the same gate and gating them one call site at a time is how
// this asymmetry arose in the first place. The wrapper is gone rather
// than left as a duplicate: two spellings of one gate is the shape
// that lets them drift. `ListView.speculativeFls-7216.test.tsx` PIN 9
// pins this path through the moved gate — plugin-list had no FLS pin
// for grouping before it.
for (const f of collectGroupingFieldRefs(groupingConfig)) addSpeculative(f);

for (const f of collectPredicateFieldRefs(listViewPredicates({
conditionalFormatting: schema.conditionalFormatting as unknown[] | undefined,
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
45 changes: 45 additions & 0 deletions .changeset/7216-listview-speculative-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-list': patch
---

FLS-gate the speculative half of `ListView`'s `$select` projection (objectui#7216).

`ListView` builds `$select` from two populations and, until now, asked them different
questions. The user-declared `columns` were passed through `perms.checkField(...)` by
objectui#6898. Everything the builder adds ON TOP — the kanban / gantt / timeline /
calendar / gallery field bindings, the timeline's auto-added `status` / `priority`
badge fields, and the operands harvested from row-action and conditional-formatting
predicates (objectui#3501) — went through `addSpeculative`, which intersected them
against the object's declared fields and then added them unconditionally.

The two gates it needed answer unrelated questions and neither substitutes for the
other. The known-field gate keeps an **unknown** key out, because some backends answer
an unknown `$select` key with an empty result set rather than ignoring it. The FLS gate
keeps a **known but denied** key out, because sending it leaks the value at the server
boundary even though the UI hides it. A field can be perfectly well-declared and still
denied, and that was the case this path did not handle: a kanban grouped by a denied
field, or a gantt bound to a denied date, still named it in the request.

**Reproduced before it was fixed.** Eight of fourteen new pins fail on the unmodified
tree, each reaching the denied field only through a view binding — a denied *column* has
been dropped since objectui#6898, so a pin naming one would have proved nothing.

The gate goes inside `addSpeculative` rather than at the five call sites, so every route
into the speculative union is covered at once; gating call sites one at a time is how
the asymmetry arose. It runs **after** the known-field intersection, the ordering
objectui#7179 established: `checkField` answers false for an undeclared key, so asking
it first would drop derived and computed bindings — and would be the reason they were
dropped. The platform record columns (`created_at`, `owner_id`, the audit FKs) are
carved out for the reason they already are elsewhere in this builder: every object
carries them and none declares them, so no field policy mentions them and an FLS answer
about them is always false. Without the carve-out a calendar bound to `created_at` would
go blank for everybody.

**Nothing a permitted view did stops working.** A permitted binding is still projected,
an unanswered permission policy filters nothing, and the projection is rebuilt when the
policy answers — pinned, because a gate that only runs before `/me/permissions` resolves
is a dead gate that passes almost every test written for it.

objectui#7179's `addGroupingField` wrapper is removed: its predicate was identical to
the one now inside `addSpeculative`, and two spellings of one gate is the shape that
lets them drift. That path keeps its behaviour and gains the FLS pin it never had.
62 changes: 48 additions & 14 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1731,9 +1731,44 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
s.add('id'); s.add('created_at'); s.add('updated_at');
return s;
})();
// [objectui#7216] TWO gates, asked in this order and of this
// population — they answer unrelated questions and neither
// substitutes for the other:
//
// 1. KNOWN-FIELD gate (above) — keeps an UNKNOWN key out, because
// some backends answer an unknown `$select` key with an EMPTY
// result set rather than ignoring it.
// 2. FLS gate (here) — keeps a KNOWN BUT DENIED key out, because
// sending it leaks the value at the server boundary even though
// the UI hides it (objectui#6898).
//
// A field can be perfectly well-declared and still denied; that is
// the case this helper did not handle. The user-declared `cols` are
// FLS-filtered a few lines up, so every OTHER route into `$select`
// was gated and this one — the auto-included view bindings, the
// predicate refs, the grouping fields — was not.
//
// ⚠️ ORDER IS LOAD-BEARING (objectui#7179's shape): intersect against
// the declared fields FIRST, ask `checkField` only about the
// survivors. `checkField` answers **false for an undeclared key**, so
// asking it first would drop derived and computed bindings that are
// not real object fields — and would be the REASON they were dropped,
// which is a different and much worse failure than the known-field
// gate declining them.
//
// ⚠️ The platform columns are carved out for the same reason they are
// carved out of `addPredicateField`: every object CARRIES them and
// none DECLARES them, so no field policy mentions them and
// `checkField` answers false for every one. Without the carve-out a
// calendar bound to `created_at` would go blank for everybody.
const addSpeculative = (f: unknown) => {
if (typeof f !== 'string' || !f) return;
if (!knownObjectFields || knownObjectFields.has(f)) required.add(f);
if (knownObjectFields && !knownObjectFields.has(f)) return;
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
required.add(f);
};
// Predicate refs take the same guard, widened by the platform columns
// every object carries but no object DECLARES (`owner_id` and the
Expand DownExpand Up@@ -1821,19 +1856,18 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
//
// FLS-gated on top (objectui#6898): the grid half of this same fix
// takes `passesProjectionGate`, and a grouping field can name a
// denied field exactly as a column can. Safe to ask `checkField`
// here precisely BECAUSE `addSpeculative` ran first — everything
// reaching this point is a field the object declares, so the
// "`checkField` answers false for an undeclared key" trap that makes
// this gate wrong on derived columns cannot fire.
const addGroupingField = (f: string) => {
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
addSpeculative(f);
};
for (const f of collectGroupingFieldRefs(groupingConfig)) addGroupingField(f);
// denied field exactly as a column can.
//
// [objectui#7216] That FLS check used to live in an `addGroupingField`
// wrapper right here. It now lives INSIDE `addSpeculative`, with a
// byte-identical predicate, because every OTHER caller of that helper
// needed the same gate and gating them one call site at a time is how
// this asymmetry arose in the first place. The wrapper is gone rather
// than left as a duplicate: two spellings of one gate is the shape
// that lets them drift. `ListView.speculativeFls-7216.test.tsx` PIN 9
// pins this path through the moved gate — plugin-list had no FLS pin
// for grouping before it.
for (const f of collectGroupingFieldRefs(groupingConfig)) addSpeculative(f);

for (const f of collectPredicateFieldRefs(listViewPredicates({
conditionalFormatting: schema.conditionalFormatting as unknown[] | undefined,
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
45 changes: 45 additions & 0 deletions .changeset/7216-listview-speculative-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-list': patch
---

FLS-gate the speculative half of `ListView`'s `$select` projection (objectui#7216).

`ListView` builds `$select` from two populations and, until now, asked them different
questions. The user-declared `columns` were passed through `perms.checkField(...)` by
objectui#6898. Everything the builder adds ON TOP — the kanban / gantt / timeline /
calendar / gallery field bindings, the timeline's auto-added `status` / `priority`
badge fields, and the operands harvested from row-action and conditional-formatting
predicates (objectui#3501) — went through `addSpeculative`, which intersected them
against the object's declared fields and then added them unconditionally.

The two gates it needed answer unrelated questions and neither substitutes for the
other. The known-field gate keeps an **unknown** key out, because some backends answer
an unknown `$select` key with an empty result set rather than ignoring it. The FLS gate
keeps a **known but denied** key out, because sending it leaks the value at the server
boundary even though the UI hides it. A field can be perfectly well-declared and still
denied, and that was the case this path did not handle: a kanban grouped by a denied
field, or a gantt bound to a denied date, still named it in the request.

**Reproduced before it was fixed.** Eight of fourteen new pins fail on the unmodified
tree, each reaching the denied field only through a view binding — a denied *column* has
been dropped since objectui#6898, so a pin naming one would have proved nothing.

The gate goes inside `addSpeculative` rather than at the five call sites, so every route
into the speculative union is covered at once; gating call sites one at a time is how
the asymmetry arose. It runs **after** the known-field intersection, the ordering
objectui#7179 established: `checkField` answers false for an undeclared key, so asking
it first would drop derived and computed bindings — and would be the reason they were
dropped. The platform record columns (`created_at`, `owner_id`, the audit FKs) are
carved out for the reason they already are elsewhere in this builder: every object
carries them and none declares them, so no field policy mentions them and an FLS answer
about them is always false. Without the carve-out a calendar bound to `created_at` would
go blank for everybody.

**Nothing a permitted view did stops working.** A permitted binding is still projected,
an unanswered permission policy filters nothing, and the projection is rebuilt when the
policy answers — pinned, because a gate that only runs before `/me/permissions` resolves
is a dead gate that passes almost every test written for it.

objectui#7179's `addGroupingField` wrapper is removed: its predicate was identical to
the one now inside `addSpeculative`, and two spellings of one gate is the shape that
lets them drift. That path keeps its behaviour and gains the FLS pin it never had.
62 changes: 48 additions & 14 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1731,9 +1731,44 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
s.add('id'); s.add('created_at'); s.add('updated_at');
return s;
})();
// [objectui#7216] TWO gates, asked in this order and of this
// population — they answer unrelated questions and neither
// substitutes for the other:
//
// 1. KNOWN-FIELD gate (above) — keeps an UNKNOWN key out, because
// some backends answer an unknown `$select` key with an EMPTY
// result set rather than ignoring it.
// 2. FLS gate (here) — keeps a KNOWN BUT DENIED key out, because
// sending it leaks the value at the server boundary even though
// the UI hides it (objectui#6898).
//
// A field can be perfectly well-declared and still denied; that is
// the case this helper did not handle. The user-declared `cols` are
// FLS-filtered a few lines up, so every OTHER route into `$select`
// was gated and this one — the auto-included view bindings, the
// predicate refs, the grouping fields — was not.
//
// ⚠️ ORDER IS LOAD-BEARING (objectui#7179's shape): intersect against
// the declared fields FIRST, ask `checkField` only about the
// survivors. `checkField` answers **false for an undeclared key**, so
// asking it first would drop derived and computed bindings that are
// not real object fields — and would be the REASON they were dropped,
// which is a different and much worse failure than the known-field
// gate declining them.
//
// ⚠️ The platform columns are carved out for the same reason they are
// carved out of `addPredicateField`: every object CARRIES them and
// none DECLARES them, so no field policy mentions them and
// `checkField` answers false for every one. Without the carve-out a
// calendar bound to `created_at` would go blank for everybody.
const addSpeculative = (f: unknown) => {
if (typeof f !== 'string' || !f) return;
if (!knownObjectFields || knownObjectFields.has(f)) required.add(f);
if (knownObjectFields && !knownObjectFields.has(f)) return;
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
required.add(f);
};
// Predicate refs take the same guard, widened by the platform columns
// every object carries but no object DECLARES (`owner_id` and the
Expand DownExpand Up@@ -1821,19 +1856,18 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
//
// FLS-gated on top (objectui#6898): the grid half of this same fix
// takes `passesProjectionGate`, and a grouping field can name a
// denied field exactly as a column can. Safe to ask `checkField`
// here precisely BECAUSE `addSpeculative` ran first — everything
// reaching this point is a field the object declares, so the
// "`checkField` answers false for an undeclared key" trap that makes
// this gate wrong on derived columns cannot fire.
const addGroupingField = (f: string) => {
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
addSpeculative(f);
};
for (const f of collectGroupingFieldRefs(groupingConfig)) addGroupingField(f);
// denied field exactly as a column can.
//
// [objectui#7216] That FLS check used to live in an `addGroupingField`
// wrapper right here. It now lives INSIDE `addSpeculative`, with a
// byte-identical predicate, because every OTHER caller of that helper
// needed the same gate and gating them one call site at a time is how
// this asymmetry arose in the first place. The wrapper is gone rather
// than left as a duplicate: two spellings of one gate is the shape
// that lets them drift. `ListView.speculativeFls-7216.test.tsx` PIN 9
// pins this path through the moved gate — plugin-list had no FLS pin
// for grouping before it.
for (const f of collectGroupingFieldRefs(groupingConfig)) addSpeculative(f);

for (const f of collectPredicateFieldRefs(listViewPredicates({
conditionalFormatting: schema.conditionalFormatting as unknown[] | undefined,
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
45 changes: 45 additions & 0 deletions .changeset/7216-listview-speculative-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-list': patch
---

FLS-gate the speculative half of `ListView`'s `$select` projection (objectui#7216).

`ListView` builds `$select` from two populations and, until now, asked them different
questions. The user-declared `columns` were passed through `perms.checkField(...)` by
objectui#6898. Everything the builder adds ON TOP — the kanban / gantt / timeline /
calendar / gallery field bindings, the timeline's auto-added `status` / `priority`
badge fields, and the operands harvested from row-action and conditional-formatting
predicates (objectui#3501) — went through `addSpeculative`, which intersected them
against the object's declared fields and then added them unconditionally.

The two gates it needed answer unrelated questions and neither substitutes for the
other. The known-field gate keeps an **unknown** key out, because some backends answer
an unknown `$select` key with an empty result set rather than ignoring it. The FLS gate
keeps a **known but denied** key out, because sending it leaks the value at the server
boundary even though the UI hides it. A field can be perfectly well-declared and still
denied, and that was the case this path did not handle: a kanban grouped by a denied
field, or a gantt bound to a denied date, still named it in the request.

**Reproduced before it was fixed.** Eight of fourteen new pins fail on the unmodified
tree, each reaching the denied field only through a view binding — a denied *column* has
been dropped since objectui#6898, so a pin naming one would have proved nothing.

The gate goes inside `addSpeculative` rather than at the five call sites, so every route
into the speculative union is covered at once; gating call sites one at a time is how
the asymmetry arose. It runs **after** the known-field intersection, the ordering
objectui#7179 established: `checkField` answers false for an undeclared key, so asking
it first would drop derived and computed bindings — and would be the reason they were
dropped. The platform record columns (`created_at`, `owner_id`, the audit FKs) are
carved out for the reason they already are elsewhere in this builder: every object
carries them and none declares them, so no field policy mentions them and an FLS answer
about them is always false. Without the carve-out a calendar bound to `created_at` would
go blank for everybody.

**Nothing a permitted view did stops working.** A permitted binding is still projected,
an unanswered permission policy filters nothing, and the projection is rebuilt when the
policy answers — pinned, because a gate that only runs before `/me/permissions` resolves
is a dead gate that passes almost every test written for it.

objectui#7179's `addGroupingField` wrapper is removed: its predicate was identical to
the one now inside `addSpeculative`, and two spellings of one gate is the shape that
lets them drift. That path keeps its behaviour and gains the FLS pin it never had.
62 changes: 48 additions & 14 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1731,9 +1731,44 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
s.add('id'); s.add('created_at'); s.add('updated_at');
return s;
})();
// [objectui#7216] TWO gates, asked in this order and of this
// population — they answer unrelated questions and neither
// substitutes for the other:
//
// 1. KNOWN-FIELD gate (above) — keeps an UNKNOWN key out, because
// some backends answer an unknown `$select` key with an EMPTY
// result set rather than ignoring it.
// 2. FLS gate (here) — keeps a KNOWN BUT DENIED key out, because
// sending it leaks the value at the server boundary even though
// the UI hides it (objectui#6898).
//
// A field can be perfectly well-declared and still denied; that is
// the case this helper did not handle. The user-declared `cols` are
// FLS-filtered a few lines up, so every OTHER route into `$select`
// was gated and this one — the auto-included view bindings, the
// predicate refs, the grouping fields — was not.
//
// ⚠️ ORDER IS LOAD-BEARING (objectui#7179's shape): intersect against
// the declared fields FIRST, ask `checkField` only about the
// survivors. `checkField` answers **false for an undeclared key**, so
// asking it first would drop derived and computed bindings that are
// not real object fields — and would be the REASON they were dropped,
// which is a different and much worse failure than the known-field
// gate declining them.
//
// ⚠️ The platform columns are carved out for the same reason they are
// carved out of `addPredicateField`: every object CARRIES them and
// none DECLARES them, so no field policy mentions them and
// `checkField` answers false for every one. Without the carve-out a
// calendar bound to `created_at` would go blank for everybody.
const addSpeculative = (f: unknown) => {
if (typeof f !== 'string' || !f) return;
if (!knownObjectFields || knownObjectFields.has(f)) required.add(f);
if (knownObjectFields && !knownObjectFields.has(f)) return;
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
required.add(f);
};
// Predicate refs take the same guard, widened by the platform columns
// every object carries but no object DECLARES (`owner_id` and the
Expand DownExpand Up@@ -1821,19 +1856,18 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
//
// FLS-gated on top (objectui#6898): the grid half of this same fix
// takes `passesProjectionGate`, and a grouping field can name a
// denied field exactly as a column can. Safe to ask `checkField`
// here precisely BECAUSE `addSpeculative` ran first — everything
// reaching this point is a field the object declares, so the
// "`checkField` answers false for an undeclared key" trap that makes
// this gate wrong on derived columns cannot fire.
const addGroupingField = (f: string) => {
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
addSpeculative(f);
};
for (const f of collectGroupingFieldRefs(groupingConfig)) addGroupingField(f);
// denied field exactly as a column can.
//
// [objectui#7216] That FLS check used to live in an `addGroupingField`
// wrapper right here. It now lives INSIDE `addSpeculative`, with a
// byte-identical predicate, because every OTHER caller of that helper
// needed the same gate and gating them one call site at a time is how
// this asymmetry arose in the first place. The wrapper is gone rather
// than left as a duplicate: two spellings of one gate is the shape
// that lets them drift. `ListView.speculativeFls-7216.test.tsx` PIN 9
// pins this path through the moved gate — plugin-list had no FLS pin
// for grouping before it.
for (const f of collectGroupingFieldRefs(groupingConfig)) addSpeculative(f);

for (const f of collectPredicateFieldRefs(listViewPredicates({
conditionalFormatting: schema.conditionalFormatting as unknown[] | undefined,
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
45 changes: 45 additions & 0 deletions .changeset/7216-listview-speculative-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-list': patch
---

FLS-gate the speculative half of `ListView`'s `$select` projection (objectui#7216).

`ListView` builds `$select` from two populations and, until now, asked them different
questions. The user-declared `columns` were passed through `perms.checkField(...)` by
objectui#6898. Everything the builder adds ON TOP — the kanban / gantt / timeline /
calendar / gallery field bindings, the timeline's auto-added `status` / `priority`
badge fields, and the operands harvested from row-action and conditional-formatting
predicates (objectui#3501) — went through `addSpeculative`, which intersected them
against the object's declared fields and then added them unconditionally.

The two gates it needed answer unrelated questions and neither substitutes for the
other. The known-field gate keeps an **unknown** key out, because some backends answer
an unknown `$select` key with an empty result set rather than ignoring it. The FLS gate
keeps a **known but denied** key out, because sending it leaks the value at the server
boundary even though the UI hides it. A field can be perfectly well-declared and still
denied, and that was the case this path did not handle: a kanban grouped by a denied
field, or a gantt bound to a denied date, still named it in the request.

**Reproduced before it was fixed.** Eight of fourteen new pins fail on the unmodified
tree, each reaching the denied field only through a view binding — a denied *column* has
been dropped since objectui#6898, so a pin naming one would have proved nothing.

The gate goes inside `addSpeculative` rather than at the five call sites, so every route
into the speculative union is covered at once; gating call sites one at a time is how
the asymmetry arose. It runs **after** the known-field intersection, the ordering
objectui#7179 established: `checkField` answers false for an undeclared key, so asking
it first would drop derived and computed bindings — and would be the reason they were
dropped. The platform record columns (`created_at`, `owner_id`, the audit FKs) are
carved out for the reason they already are elsewhere in this builder: every object
carries them and none declares them, so no field policy mentions them and an FLS answer
about them is always false. Without the carve-out a calendar bound to `created_at` would
go blank for everybody.

**Nothing a permitted view did stops working.** A permitted binding is still projected,
an unanswered permission policy filters nothing, and the projection is rebuilt when the
policy answers — pinned, because a gate that only runs before `/me/permissions` resolves
is a dead gate that passes almost every test written for it.

objectui#7179's `addGroupingField` wrapper is removed: its predicate was identical to
the one now inside `addSpeculative`, and two spellings of one gate is the shape that
lets them drift. That path keeps its behaviour and gains the FLS pin it never had.
62 changes: 48 additions & 14 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1731,9 +1731,44 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
s.add('id'); s.add('created_at'); s.add('updated_at');
return s;
})();
// [objectui#7216] TWO gates, asked in this order and of this
// population — they answer unrelated questions and neither
// substitutes for the other:
//
// 1. KNOWN-FIELD gate (above) — keeps an UNKNOWN key out, because
// some backends answer an unknown `$select` key with an EMPTY
// result set rather than ignoring it.
// 2. FLS gate (here) — keeps a KNOWN BUT DENIED key out, because
// sending it leaks the value at the server boundary even though
// the UI hides it (objectui#6898).
//
// A field can be perfectly well-declared and still denied; that is
// the case this helper did not handle. The user-declared `cols` are
// FLS-filtered a few lines up, so every OTHER route into `$select`
// was gated and this one — the auto-included view bindings, the
// predicate refs, the grouping fields — was not.
//
// ⚠️ ORDER IS LOAD-BEARING (objectui#7179's shape): intersect against
// the declared fields FIRST, ask `checkField` only about the
// survivors. `checkField` answers **false for an undeclared key**, so
// asking it first would drop derived and computed bindings that are
// not real object fields — and would be the REASON they were dropped,
// which is a different and much worse failure than the known-field
// gate declining them.
//
// ⚠️ The platform columns are carved out for the same reason they are
// carved out of `addPredicateField`: every object CARRIES them and
// none DECLARES them, so no field policy mentions them and
// `checkField` answers false for every one. Without the carve-out a
// calendar bound to `created_at` would go blank for everybody.
const addSpeculative = (f: unknown) => {
if (typeof f !== 'string' || !f) return;
if (!knownObjectFields || knownObjectFields.has(f)) required.add(f);
if (knownObjectFields && !knownObjectFields.has(f)) return;
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
required.add(f);
};
// Predicate refs take the same guard, widened by the platform columns
// every object carries but no object DECLARES (`owner_id` and the
Expand DownExpand Up@@ -1821,19 +1856,18 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
//
// FLS-gated on top (objectui#6898): the grid half of this same fix
// takes `passesProjectionGate`, and a grouping field can name a
// denied field exactly as a column can. Safe to ask `checkField`
// here precisely BECAUSE `addSpeculative` ran first — everything
// reaching this point is a field the object declares, so the
// "`checkField` answers false for an undeclared key" trap that makes
// this gate wrong on derived columns cannot fire.
const addGroupingField = (f: string) => {
if (perms?.isLoaded && schema.objectName
&& knownObjectFields?.has(f)
&& !PLATFORM_RECORD_COLUMNS.has(f)
&& !perms.checkField(schema.objectName, f, 'read')) return;
addSpeculative(f);
};
for (const f of collectGroupingFieldRefs(groupingConfig)) addGroupingField(f);
// denied field exactly as a column can.
//
// [objectui#7216] That FLS check used to live in an `addGroupingField`
// wrapper right here. It now lives INSIDE `addSpeculative`, with a
// byte-identical predicate, because every OTHER caller of that helper
// needed the same gate and gating them one call site at a time is how
// this asymmetry arose in the first place. The wrapper is gone rather
// than left as a duplicate: two spellings of one gate is the shape
// that lets them drift. `ListView.speculativeFls-7216.test.tsx` PIN 9
// pins this path through the moved gate — plugin-list had no FLS pin
// for grouping before it.
for (const f of collectGroupingFieldRefs(groupingConfig)) addSpeculative(f);

for (const f of collectPredicateFieldRefs(listViewPredicates({
conditionalFormatting: schema.conditionalFormatting as unknown[] | undefined,
Expand Down
Loading
Loading