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
60 changes: 60 additions & 0 deletions .changeset/nav-servability-prune.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
"@objectstack/spec": patch
"@objectstack/rest": patch
"@objectstack/lint": patch
---

fix(rest,lint,spec): prune nav entries whose destination object cannot serve, and refuse them at authoring time (#7912)

A `type: 'object'` navigation entry pointing at an object that **cannot answer a
list** was served to the client in the `/meta` payload anyway. The user saw a
menu item that could not work, and the console rendered the failure as a generic
empty state — so it read as *"you have no records"* rather than *"this page
cannot work"*.

Two independent conditions make a destination unservable, and **neither was
expressible on a nav entry**:

- `enable.apiEnabled: false` → the list answers `OBJECT_API_DISABLED` (404);
- an `enable.apiMethods` whitelist without `list` → `OBJECT_API_METHOD_NOT_ALLOWED` (405).

Both are pure functions of the object's own `enable` block — no user, no
permissions, no request context — so the destination is dead for **every**
persona, platform administrator included. That is why a `requiredPermissions`
gate could never prune such an entry: the two are independent conditions, and no
combination of permissions on the *entry* rescues an entry whose *object* is
API-disabled. One shipped that way for a year and read as correct to reviewers,
its in-code comment claiming a non-admin "403s server-side" — which implies an
admin could list. None could.

**The fact is now derived, not declared.** `filterAppForUser` consults the
destination's `enable` block for every `type: 'object'` entry and drops the ones
that cannot serve, on both the app-list and the by-name `/meta` routes and
inside `children` and `areas[]` alike. No new authorable key was minted: the
platform already knows this, on the object, in one place.

**And the prune is never silent.** A prune the author cannot see is the same
failure one layer over, so it is refused at authoring time: `os validate` /
`os build` / `os lint` now **fail** with `nav-object-unservable`, naming the
entry, the object, the offending `enable` key path and which of the two
conditions fired. The serving side logs the same facts for an entry that reaches
a running deployment anyway.

The single two-step order these consumers share — `apiEnabled` first and
independently, the whitelist second — is now declared once as
`apiExposureDenialReason` / `canServeApiOperation` in `@objectstack/spec/data`,
beside the `resolveEffectiveApiMethods` / `isApiOperationAllowed` primitives it
composes. The REST data gate, the nav prune and the authoring rule all read that
one export instead of re-spelling the order.

**Deliberately unchanged:**

- `requiresObject` keeps its client-only evaluation. It asks whether an object
is *registered*; this gate asks whether a registered object's `enable` block
lets it answer. An entry whose object this layer cannot find is **served**,
not pruned.
- `visible` (CEL) is still client-side only.
- Fail-open throughout: unreadable object metadata prunes nothing, so a cold
start or a metadata outage cannot empty a healthy deployment's sidebar.
- Objects an authoring stack does not itself declare are not judged by the lint
rule — their `enable` block is not visible from there.
10 changes: 10 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -331,6 +331,16 @@ export type { ObjectRefFinding, ObjectRefSeverity } from './validate-object-refe
export { validateNavTargetRefs, NAV_TARGET_UNRESOLVED } from './validate-nav-target-refs.js';
export type { NavTargetRefFinding, NavTargetRefSeverity } from './validate-nav-target-refs.js';

// [#7912] The servability question about an `object` nav target: not "does the
// name resolve?" but "can the destination answer a list at all?". Gates, and
// gates alone among the nav rules — `enable` is declared on the object in this
// same stack, so a finding is a certainty rather than a suspicion.
export {
validateNavObjectServability,
NAV_OBJECT_UNSERVABLE,
} from './validate-nav-object-servability.js';
export type { NavObjectServabilityFinding } from './validate-nav-object-servability.js';

export {
validateSearchableFields,
SEARCHABLE_FIELD_UNKNOWN,
Expand Down
22 changes: 21 additions & 1 deletion packages/lint/src/reference-integrity-suite.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@ describe('reference-integrity suite — membership', () => {
'validateChartBindings',
'validateNavAccess',
'validateNavTargetRefs',
'validateNavObjectServability',
'validateTranslationReferences',
'validateTranslatableSections',
'validateFlowTemplatePaths',
Expand DownExpand Up@@ -65,6 +66,18 @@ describe('reference-integrity suite — every member actually runs', () => {
searchableFields: ['name', 'budget'],
permissions: {},
},
// validateNavObjectServability (#7912): an object the app puts in its
// navigation while its own `enable` block refuses every API operation.
// A SEPARATE object from `crm_lead` on purpose — putting the dead
// `enable` on the object every other member reads would let this one go
// silent behind their findings, and would change what `nav_leads` means
// to `validateNavAccess`.
{
name: 'crm_secret_token',
fields: { name: { type: 'text', label: 'Name' } },
enable: { apiEnabled: false, apiMethods: [] },
permissions: {},
},
],
actions: [
// validateObjectReferences: a param pointing at an object nothing declares.
Expand DownExpand Up@@ -151,7 +164,13 @@ describe('reference-integrity suite — every member actually runs', () => {
apps: [
{
name: 'crm_app',
navigation: [{ id: 'nav_leads', type: 'object', objectName: 'crm_lead' }],
navigation: [
{ id: 'nav_leads', type: 'object', objectName: 'crm_lead' },
// validateNavObjectServability: the destination answers 404
// `OBJECT_API_DISABLED` for every persona, so the row is dead however
// it is permissioned.
{ id: 'nav_tokens', type: 'object', objectName: 'crm_secret_token' },
],
},
],
// validateNavAccess: a declared permission set that grants nothing on the
Expand DownExpand Up@@ -237,6 +256,7 @@ describe('reference-integrity suite — every member actually runs', () => {
expect(rules).toContain('page-field-unknown');
expect(rules).toContain('chart-measure-unknown');
expect(rules).toContain('nav-object-ungranted');
expect(rules).toContain('nav-object-unservable');
expect(rules).toContain('translation-target-unknown');
expect(rules).toContain('translation-section-name-missing');
expect(rules).toContain('flow-template-unknown-field');
Expand Down
10 changes: 10 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,7 @@ import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
import { validateNavAccess } from './validate-nav-access.js';
import { validateNavTargetRefs } from './validate-nav-target-refs.js';
import { validateNavObjectServability } from './validate-nav-object-servability.js';
import { validateTranslationReferences } from './validate-translation-references.js';
import { validateTranslatableSections } from './validate-translatable-sections.js';
import { validateFlowTemplatePaths } from './validate-flow-template-paths.js';
Expand DownExpand Up@@ -120,6 +121,15 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// `action` is deliberately absent (validateActionNameRefs owns it) and so is
// `component` (an unregistered ref renders a named diagnostic, not silence).
{ name: 'validateNavTargetRefs', run: validateNavTargetRefs },
// [#7912] The THIRD question about a nav entry, after "does the target
// resolve?" (above) and "is it granted?" (`validateNavAccess`): can the
// destination serve at all? An object's own `enable` block can make its list
// answer 404/405 for every persona, and no gate authorable on the entry
// expresses that — which is how #7544's dead row survived review for a year.
// The server now prunes such an entry from the `/meta` payload; the
// maintainer ruling of 2026-08-12 makes THIS the mandatory companion, so the
// prune is never silent to the author who wrote the row.
{ name: 'validateNavObjectServability', run: validateNavObjectServability },
{ name: 'validateTranslationReferences', run: validateTranslationReferences },
// The same family from the other end (#5417). Its sibling above asks "does
// this bundle key resolve?"; this one asks "is there a key at all?" — a form
Expand Down
202 changes: 202 additions & 0 deletions packages/lint/src/validate-nav-object-servability.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#7912] A navigation entry whose destination object cannot serve a `list` —
* refused at authoring time, before it can be published and pruned in silence.
*
* ## The defect
*
* A `type: 'object'` nav entry names its destination in `objectName`. That
* object's own `enable` block decides whether the external REST surface can
* answer a `list` there at all:
*
* - `enable.apiEnabled: false` → `OBJECT_API_DISABLED` (404);
* - an `enable.apiMethods` whitelist without `list` → `OBJECT_API_METHOD_NOT_ALLOWED` (405).
*
* Both are pure functions of `enable` — no user, no permissions, no request
* context — so the destination is dead for EVERY persona, platform admin
* included. #7544 shipped exactly such an entry for a year: it read as correct
* to reviewers because the entry carried a `requiredPermissions` gate, and the
* in-code comment claimed a non-admin "403s server-side", which implies an
* admin could list. None could. The 404 precedes and ignores permissions
* entirely, and no combination of permissions on the ENTRY can prune an entry
* whose OBJECT is API-disabled — they are independent conditions.
*
* ## Why this rule exists even though the server now prunes
*
* The maintainer ruling of 2026-08-12 chose to DERIVE servability rather than
* mint a new nav key: `filterAppForUser` (`@objectstack/rest`) drops these
* entries from the `/meta` payload, so the user never sees a menu item that
* cannot work. That fixes the user-facing half and opens an authoring-facing
* one — the ruling names it and makes this rule a mandatory companion, not an
* optional extra:
*
* > A prune the author cannot see is the same failure one layer over — no
* > silent dead rows, and no silent repairs.
*
* An author whose object is accidentally API-disabled would otherwise watch the
* entry vanish from a running app with no signal anywhere. This rule is the
* signal, raised at the checkpoint that can still see the whole picture and
* naming both halves: which entry, and the exact `enable` key that killed it.
*
* ## Severity: `error`, unlike its neighbours — and why that is not over-reach
*
* `validate-nav-access` (its closest sibling: "navigation exposes an object no
* permission set grants") is advisory, because a grant can legitimately arrive
* from a package this stack cannot see. Nothing analogous applies here.
* `enable` is declared ON the object, in this stack, and this rule judges ONLY
* objects this stack declares — so when it fires, it has read the whole of the
* evidence and the entry is dead with certainty. There is no installed package
* that can make an `apiEnabled: false` object listable.
*
* The exemption is therefore the same shape as the sibling's, drawn one axis
* over: a target this stack does not declare is SKIPPED entirely rather than
* guessed at, because its `enable` block is not visible from here.
*
* ## ⛔ What this rule deliberately does NOT judge
*
* - **Whether the object exists at all.** An unresolvable nav target is
* `validate-object-references` / `defineStack`'s question, and on the
* serving side it is `requiresObject`'s — a key whose client-only evaluation
* the same ruling explicitly declined to re-mean. Silence here on an unknown
* name is that boundary, not an oversight.
* - **Permissions.** Ungranted-but-listable is `validate-nav-access`; the two
* conditions are independent and each needs its own finding, which is the
* load-bearing lesson of #7544.
* - **Non-`object` entries.** A `component` / `page` / `url` entry has no
* `objectName` destination to judge, even when it carries `requiresObject`.
*/

import { canServeApiOperation, type EnableLike } from '@objectstack/spec/data';

import type { ReferenceIntegrityFinding } from './reference-integrity-suite.js';

export type NavObjectServabilityFinding = ReferenceIntegrityFinding;

/** Emitted when a nav entry targets an object whose `enable` block cannot serve a list. */
export const NAV_OBJECT_UNSERVABLE = 'nav-object-unservable';

type AnyRec = Record<string, unknown>;

const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v);

/** Both authoring carriers: an array of documents, or a name-keyed map. */
function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v.filter(isRec);
if (isRec(v)) return Object.entries(v).map(([name, def]) => (isRec(def) ? { name, ...def } : { name }));
return [];
}

function strName(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

/**
* An interpolated target resolves at render time — the same conservative
* exemption `validate-object-references` and `validate-nav-target-refs` use to
* keep false positives near zero (ADR-0072 D1).
*/
const isInterpolated = (s: string): boolean => s.includes('${') || s.includes('{');

export function validateNavObjectServability(stack: unknown): NavObjectServabilityFinding[] {
const findings: NavObjectServabilityFinding[] = [];
if (!isRec(stack)) return findings;

const apps = asArray(stack.apps);
if (apps.length === 0) return findings;

// Only objects THIS stack declares can be judged — see the header. The map
// records where each one is declared so a finding can point at the `enable`
// key that is actually editable, not merely at the nav entry that tripped on
// it.
const ownEnable = new Map<string, { enable: EnableLike | undefined; path: string }>();
const objects = asArray(stack.objects);
for (const [oi, obj] of objects.entries()) {
const n = strName(obj.name);
if (!n) continue;
// These rules read UNTYPED authored documents, so the shape is asserted
// rather than proved. `EnableLike` is deliberately loose (every key
// optional, plus an index signature) and `canServeApiOperation` treats a
// missing/garbage block as "declares nothing" — so a non-object `enable`
// reaches the default-open answer instead of throwing.
ownEnable.set(n, { enable: obj.enable as EnableLike | undefined, path: `objects[${oi}].enable` });
}
if (ownEnable.size === 0) return findings;

for (const [ai, app] of apps.entries()) {
const appName = strName(app.name) ?? `#${ai}`;

const walk = (items: unknown, basePath: string): void => {
if (!Array.isArray(items)) return;
for (const [ni, raw] of items.entries()) {
if (!isRec(raw)) continue;
const nav = raw;
const navPath = `${basePath}[${ni}]`;

if (nav.type === 'object') {
const target = strName(nav.objectName);
const declared = target && !isInterpolated(target) ? ownEnable.get(target) : undefined;
if (target && declared && !canServeApiOperation(declared.enable, 'list')) {
const enable = isRec(declared.enable) ? declared.enable : {};
// Which of the two conditions fired. `apiEnabled` is judged first
// and independently — an API-disabled object refuses `list`
// whatever its whitelist says — so the report follows the same
// order rather than describing a whitelist the 404 never reaches.
const apiDisabled = enable.apiEnabled === false;
const condition = apiDisabled
? '`enable.apiEnabled: false`'
: '`enable.apiMethods` does not grant `list`'
+ (Array.isArray(enable.apiMethods)
? ` (declared: ${enable.apiMethods.length === 0 ? '[] — deny-all' : enable.apiMethods.map((m) => `\`${String(m)}\``).join(', ')})`
: '');
const answer = apiDisabled
? '404 `OBJECT_API_DISABLED`'
: '405 `OBJECT_API_METHOD_NOT_ALLOWED`';
const offendingKey = apiDisabled
? `${declared.path}.apiEnabled`
: `${declared.path}.apiMethods`;

findings.push({
severity: 'error',
rule: NAV_OBJECT_UNSERVABLE,
where: `app "${appName}" · nav "${strName(nav.id) ?? strName(nav.label) ?? `#${ni}`}"`,
// The nav entry is where the dead row is authored; the `enable`
// key that condemns it is named in the message, because the fix
// may belong at either end.
path: `${navPath}.objectName`,
message:
`Navigation targets object "${target}", which cannot serve a list: ${condition} `
+ `(\`${offendingKey}\`), so the list request answers ${answer} for EVERY user — `
+ `platform administrators included, since that gate reads only the object's \`enable\` `
+ `block and never the caller. The entry cannot be rescued with `
+ `\`requiredPermissions\`: they are independent conditions. The server prunes this `
+ `entry from the served \`/meta\` payload (#7912), so publishing it ships a menu row `
+ `that silently is not there.`,
hint:
`Remove the nav entry, or make "${target}" listable by setting \`enable.apiEnabled: true\` `
+ `and granting \`list\` in \`enable.apiMethods\`. ⛔ Do NOT open the API on an object that `
+ `is disabled on purpose — several platform objects hold credential material and are `
+ `API-disabled deliberately; for those the entry is the mistake, not the \`enable\` block.`,
});
}
}

// Recurse: an `object` nav item carries `children` too, not just a
// `group` — the same reason `stack.zod.ts` does not gate its recursion
// on the item type.
if (Array.isArray(nav.children)) walk(nav.children, `${navPath}.children`);
}
};

walk(app.navigation, `apps[${ai}].navigation`);
// `areas[]` is the other nav container, and the server gates it through the
// very same walk (#4722) — so this rule must see it too, or it would pass a
// stack whose served payload the runtime prunes.
for (const [ari, area] of asArray(app.areas).entries()) {
walk(area.items, `apps[${ai}].areas[${ari}].items`);
walk(area.navigation, `apps[${ai}].areas[${ari}].navigation`);
}
}

return findings;
}
Loading
Loading