From 1557f96e0abecb192cd07531c1d4e6fc5b07a4b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:44:49 +0000 Subject: [PATCH 1/3] fix(rest,lint,spec): prune nav entries whose destination object cannot serve, and refuse them at authoring time (#7912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `type: 'object'` nav entry pointing at an object that cannot answer a list was served in the `/meta` payload anyway. Two independent conditions make a destination unservable — `enable.apiEnabled: false` (404) and an `enable.apiMethods` whitelist without `list` (405) — and both are pure functions of the object's own `enable` block, so the destination is dead for every persona. That is why no `requiredPermissions` gate on the entry could ever prune it. Per the maintainer ruling of 2026-08-12 (option (c)): derive the fact, mint no new key. - `filterAppForUser` consults the destination's `enable` for every `type: 'object'` entry, on both `/meta` app routes and inside `children` and `areas[]`. Fail-open on unknown objects and unreadable metadata, so `requiresObject`'s client-only pin and #3770 both stand. - The mandatory companion: `os validate` / `os build` / `os lint` now fail with `nav-object-unservable`, naming the entry, the object, the offending `enable` key path and which condition fired. The serving side logs the same facts. - The two-step gate order is declared once as `apiExposureDenialReason` / `canServeApiOperation` in `@objectstack/spec/data`. The REST data gate, the nav prune and #7909's invariant test all read that one export instead of re-spelling it — the third spelling is what this extraction removes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj --- .changeset/nav-servability-prune.md | 60 ++++ packages/lint/src/index.ts | 10 + .../src/reference-integrity-suite.test.ts | 22 +- .../lint/src/reference-integrity-suite.ts | 10 + .../src/validate-nav-object-servability.ts | 202 +++++++++++ .../src/platform-objects.test.ts | 19 +- .../src/meta-app-nav-servability-gate.test.ts | 329 ++++++++++++++++++ packages/rest/src/rest-server.ts | 195 ++++++++++- packages/spec/api-surface/data.json | 3 + packages/spec/export-origins/data.json | 3 + packages/spec/src/data/api-derivation.ts | 72 ++++ 11 files changed, 905 insertions(+), 20 deletions(-) create mode 100644 .changeset/nav-servability-prune.md create mode 100644 packages/lint/src/validate-nav-object-servability.ts create mode 100644 packages/rest/src/meta-app-nav-servability-gate.test.ts diff --git a/.changeset/nav-servability-prune.md b/.changeset/nav-servability-prune.md new file mode 100644 index 0000000000..5320be9bf2 --- /dev/null +++ b/.changeset/nav-servability-prune.md @@ -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. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index a9780107d3..e35d1b4c57 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -330,6 +330,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, diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index cc35fcabf5..5cb8337a27 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -22,6 +22,7 @@ describe('reference-integrity suite — membership', () => { 'validateChartBindings', 'validateNavAccess', 'validateNavTargetRefs', + 'validateNavObjectServability', 'validateTranslationReferences', 'validateTranslatableSections', 'validateFlowTemplatePaths', @@ -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. @@ -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 @@ -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'); diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 6c1c90005d..590cd900c6 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -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'; @@ -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 diff --git a/packages/lint/src/validate-nav-object-servability.ts b/packages/lint/src/validate-nav-object-servability.ts new file mode 100644 index 0000000000..6fc7ff4291 --- /dev/null +++ b/packages/lint/src/validate-nav-object-servability.ts @@ -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; + +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(); + 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; +} diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 9c300ce7ff..01eb8b01a5 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -38,7 +38,7 @@ import { import { SysSecret, SysSetting } from './system/index.js'; import { ACCOUNT_APP, SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from './apps/index.js'; import { AppSchema } from '@objectstack/spec/ui'; -import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data'; +import { resolveEffectiveApiMethods, isApiOperationAllowed, canServeApiOperation } from '@objectstack/spec/data'; const systemObjects = [ ['SysUser', SysUser, 'sys_user'], @@ -387,11 +387,18 @@ describe('@objectstack/platform-objects', () => { return out; }; - /** The rest-server gate order, over an object's declared `enable`. */ - const canList = (enable: unknown): boolean => { - if ((enable as { apiEnabled?: unknown } | undefined)?.apiEnabled === false) return false; - return isApiOperationAllowed(resolveEffectiveApiMethods(enable as never), 'list'); - }; + /** + * The rest-server gate order, over an object's declared `enable`. + * + * [#7912] This USED to re-spell the two steps here. It no longer does: + * the order is the spec's own `canServeApiOperation`, the single export + * `filterAppForUser`'s nav prune and `os validate`'s + * `nav-object-unservable` rule both read. A local copy would let this + * gate keep passing while the server it claims to mirror had changed — + * exactly the drift #7544 asked for a single source to prevent. + */ + const canList = (enable: unknown): boolean => + canServeApiOperation(enable as never, 'list'); it('every contributed object entry targets a listable object', () => { const entries = objectEntries(); diff --git a/packages/rest/src/meta-app-nav-servability-gate.test.ts b/packages/rest/src/meta-app-nav-servability-gate.test.ts new file mode 100644 index 0000000000..0181945df9 --- /dev/null +++ b/packages/rest/src/meta-app-nav-servability-gate.test.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7912] `filterAppForUser` prunes a `type: 'object'` nav entry whose + * destination object cannot serve a `list`. + * + * The gap this closes, measured on #7544: nav filtering gated `_unpublished`, + * `requiredPermissions`, `requiresService` and empty groups, and consulted + * `enable.apiEnabled` NOWHERE. So an entry pointing at an API-disabled object + * shipped in the `/meta` payload for every persona — and because the denial is + * a pure function of the object's `enable` block (no user, no permissions, no + * context), no gate authorable on the ENTRY could prune it. The maintainer + * ruling of 2026-08-12 chose to DERIVE the fact rather than mint a key. + * + * ⚠️ The control is as load-bearing as the prune: `nav_api_keys` → + * `sys_api_key` rides the same machinery with a whitelist that DOES grant + * `list`, and must survive. A derivation that pruned both would be over-reach, + * and must fail here. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { RestServer } from './rest-server'; + +const ANON_API = { api: { requireAuth: false } }; + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +/** + * The object metadata the gate reads. Deliberately the REAL shapes from + * `@objectstack/platform-objects`, not invented ones — the whole point of the + * control is that it is the platform's own pair. + */ +const OBJECTS = [ + // The #7544 shape: API-disabled outright → every operation answers 404. + { name: 'sys_jwks', enable: { apiEnabled: false, apiMethods: [] } }, + // ⭐ THE CONTROL — `sys_api_key`'s real whitelist. Grants `list`. + { name: 'sys_api_key', enable: { apiEnabled: true, apiMethods: ['get', 'list', 'update'] } }, + // Exposed, but the whitelist omits `list` → the list answers 405 while + // `GET /:id` works. A second, independent condition (`sys_verification`'s + // real shape). + { name: 'sys_verification', enable: { apiEnabled: true, apiMethods: ['get'] } }, + // No `enable` block at all → declares nothing, restricts nothing. + { name: 'sys_inbox_message' }, + // `enable` present but silent about the API → unrestricted. + { name: 'crm_lead', enable: { searchable: true } }, +]; + +function createMockProtocol(items: unknown[] = OBJECTS) { + return { + getMetaItems: vi.fn().mockResolvedValue({ items }), + getMetaItem: vi.fn().mockResolvedValue({}), + }; +} + +const make = (items?: unknown[]) => + new RestServer(createMockServer() as any, createMockProtocol(items) as any, ANON_API as any) as any; + +const ids = (a: any): string[] => (a?.navigation ?? []).map((e: any) => e.id); +const areaIds = (a: any, i: number): string[] => (a?.areas?.[i]?.navigation ?? []).map((e: any) => e.id); + +/** Resolve the gate the way the `/meta` routes do. */ +async function gateOf(rest: any, items?: unknown[]) { + const p = createMockProtocol(items); + return rest.resolveNavServability(p, undefined); +} + +describe('[#7912] nav servability — the prune', () => { + let warn: ReturnType; + beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); + afterEach(() => { warn.mockRestore(); }); + + const app = () => ({ + name: 'setup', + navigation: [ + { id: 'nav_jwks', type: 'object', label: 'Signing Keys', objectName: 'sys_jwks' }, + { id: 'nav_api_keys', type: 'object', label: 'API Keys', objectName: 'sys_api_key' }, + { id: 'nav_verification', type: 'object', label: 'Verification', objectName: 'sys_verification' }, + { id: 'nav_inbox', type: 'object', label: 'Inbox', objectName: 'sys_inbox_message' }, + { id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'crm_lead' }, + ], + }); + + it('drops an entry whose object is `apiEnabled: false` (404 OBJECT_API_DISABLED)', async () => { + const rest = make(); + const gate = await gateOf(rest); + expect(ids(rest.filterAppForUser(app(), new Set(), undefined, gate))).not.toContain('nav_jwks'); + }); + + it('drops an entry whose `apiMethods` whitelist omits `list` (405), a SEPARATE condition', async () => { + // The two conditions are independent — this object is fully API-enabled + // and still cannot answer the list its nav entry navigates to. + const rest = make(); + const gate = await gateOf(rest); + expect(ids(rest.filterAppForUser(app(), new Set(), undefined, gate))).not.toContain('nav_verification'); + }); + + it('⭐ CONTROL: `nav_api_keys` → `sys_api_key` SURVIVES — the prune must not over-reach', async () => { + const rest = make(); + const gate = await gateOf(rest); + const out = ids(rest.filterAppForUser(app(), new Set(), undefined, gate)); + expect(out).toContain('nav_api_keys'); + // The card's own control, stated as the whole surviving set so an + // over-pruning derivation cannot pass by keeping one entry alive. + expect(out).toEqual(['nav_api_keys', 'nav_inbox', 'nav_leads']); + }); + + it('an object that declares no `enable` block is served (default-open)', async () => { + const rest = make(); + const gate = await gateOf(rest); + expect(ids(rest.filterAppForUser(app(), new Set(), undefined, gate))).toContain('nav_inbox'); + }); + + it('an `enable` block silent about the API is served (unrestricted)', async () => { + const rest = make(); + const gate = await gateOf(rest); + expect(ids(rest.filterAppForUser(app(), new Set(), undefined, gate))).toContain('nav_leads'); + }); +}); + +describe('[#7912] nav servability — what it deliberately does NOT judge', () => { + beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); }); + afterEach(() => { vi.restoreAllMocks(); }); + + it('an object absent from metadata is SERVED — `requiresObject` keeps its client-only pin', async () => { + // The 2026-08-12 ruling rejected re-meaning `requiresObject` + // server-side. "Is this object registered?" is that key's question and + // this gate does not answer it: an unknown name has no declared + // exposure policy to enforce (#3770), so it passes through. + const rest = make(); + const gate = await gateOf(rest); + const app = { + name: 'setup', + navigation: [ + { id: 'nav_ghost', type: 'object', objectName: 'not_registered_anywhere' }, + { id: 'nav_gated_ghost', type: 'object', objectName: 'also_absent', requiresObject: 'also_absent' }, + ], + }; + expect(ids(rest.filterAppForUser(app, new Set(), undefined, gate))) + .toEqual(['nav_ghost', 'nav_gated_ghost']); + }); + + it('non-`object` entries are untouched, even when an object of that name is dead', async () => { + const rest = make(); + const gate = await gateOf(rest); + const app = { + name: 'account', + navigation: [ + { id: 'nav_component', type: 'component', componentRef: 'x:y', objectName: 'sys_jwks' }, + { id: 'nav_page', type: 'page', pageName: 'p', objectName: 'sys_jwks' }, + { id: 'nav_url', type: 'url', url: 'https://example.com' }, + ], + }; + expect(ids(rest.filterAppForUser(app, new Set(), undefined, gate))) + .toEqual(['nav_component', 'nav_page', 'nav_url']); + }); + + it('fail-open: with no gate resolved, nothing is pruned (prior behaviour)', async () => { + const rest = make(); + const app = { name: 'setup', navigation: [{ id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }] }; + expect(ids(rest.filterAppForUser(app, new Set()))).toEqual(['nav_jwks']); + }); + + it('fail-open: unreadable/empty object metadata resolves NO gate at all', async () => { + // A cold start or a metadata outage must not empty the sidebar of a + // healthy deployment — the #3545 trade, which binds harder here. + const rest = make(); + expect(await gateOf(rest, [])).toBeNull(); + }); +}); + +describe('[#7912] nav servability — reaches every tree the other gates reach', () => { + beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); }); + afterEach(() => { vi.restoreAllMocks(); }); + + it('prunes inside `children` and inside `areas[]` — one `filterNav`, not three', async () => { + const rest = make(); + const gate = await gateOf(rest); + const app = { + name: 'setup', + navigation: [{ + id: 'grp_advanced', type: 'group', label: 'Advanced', + children: [ + { id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }, + { id: 'nav_api_keys', type: 'object', objectName: 'sys_api_key' }, + ], + }], + areas: [{ + id: 'area_admin', label: 'Admin', + navigation: [ + { id: 'nav_jwks_area', type: 'object', objectName: 'sys_jwks' }, + { id: 'nav_api_keys_area', type: 'object', objectName: 'sys_api_key' }, + ], + }], + }; + const out = rest.filterAppForUser(app, new Set(), undefined, gate); + expect(out.navigation[0].children.map((c: any) => c.id)).toEqual(['nav_api_keys']); + expect(areaIds(out, 0)).toEqual(['nav_api_keys_area']); + }); + + it('[#7380] a group left childless BY this prune collapses, like any other gate', async () => { + const rest = make(); + const gate = await gateOf(rest); + const app = { + name: 'setup', + navigation: [{ + id: 'grp_dead', type: 'group', label: 'Dead', + children: [{ id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }], + }], + }; + expect(ids(rest.filterAppForUser(app, new Set(), undefined, gate))).toEqual([]); + }); + + it('does not mutate the app it filters', async () => { + const rest = make(); + const gate = await gateOf(rest); + const app = { + name: 'setup', + navigation: [ + { id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }, + { id: 'nav_api_keys', type: 'object', objectName: 'sys_api_key' }, + ], + }; + const before = JSON.stringify(app); + rest.filterAppForUser(app, new Set(), undefined, gate); + expect(JSON.stringify(app)).toBe(before); + }); +}); + +describe('[#7912] the prune is never silent — the serving-side diagnostic', () => { + let warn: ReturnType; + beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); + afterEach(() => { warn.mockRestore(); }); + + const lines = (w: ReturnType): string[] => + (w.mock.calls as unknown[][]).map((c) => String(c[0])); + + it('names the app, the entry AND the condition that pruned it (apiEnabled)', async () => { + const rest = make(); + const gate = await gateOf(rest); + rest.filterAppForUser( + { name: 'setup', navigation: [{ id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }] }, + new Set(), undefined, gate, + ); + const line = lines(warn).find((l) => l.includes('nav_jwks')); + expect(line).toBeDefined(); + // The ruling's requirement is BOTH halves: which row died, and why. + expect(line).toContain("app 'setup'"); + expect(line).toContain('sys_jwks'); + expect(line).toContain('enable.apiEnabled: false'); + expect(line).toContain('OBJECT_API_DISABLED'); + }); + + it('names the OTHER condition distinctly (apiMethods without `list`)', async () => { + const rest = make(); + const gate = await gateOf(rest); + rest.filterAppForUser( + { name: 'setup', navigation: [{ id: 'nav_verification', type: 'object', objectName: 'sys_verification' }] }, + new Set(), undefined, gate, + ); + const line = lines(warn).find((l) => l.includes('nav_verification')); + expect(line).toBeDefined(); + expect(line).toContain('enable.apiMethods'); + expect(line).toContain('OBJECT_API_METHOD_NOT_ALLOWED'); + // ⛔ Must NOT describe the wrong condition — a diagnostic that names a + // key the author did not write is worse than none. + expect(line).not.toContain('enable.apiEnabled: false'); + }); + + it('logs once per app|entry|object|reason — a console re-fetches `/meta` constantly', async () => { + const rest = make(); + const gate = await gateOf(rest); + const app = { name: 'setup', navigation: [{ id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }] }; + for (let i = 0; i < 5; i++) rest.filterAppForUser(app, new Set(), undefined, gate); + expect(lines(warn).filter((l) => l.includes('nav_jwks'))).toHaveLength(1); + }); +}); + +describe('[#7912] the served Account app is unaffected — the QA-sweep sibling', () => { + beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); }); + afterEach(() => { vi.restoreAllMocks(); }); + + it('every Account-app object destination survives the prune', async () => { + // #7555 (same QA run #7514) was the Account-app sibling, and its cause + // was permission COMPOSITION — closed by #7605, not by servability. + // What this card owes it is proof of no regression: all six object + // destinations declare an `enable` that grants `list`, so the derived + // gate leaves the app exactly as it found it. + const rest = make([ + { name: 'sys_inbox_message' }, + { name: 'sys_member', enable: { apiEnabled: true, apiMethods: ['get', 'list'] } }, + { name: 'sys_account', enable: { apiEnabled: true, apiMethods: ['get', 'list'] } }, + { name: 'sys_session', enable: { apiEnabled: true, apiMethods: ['get', 'list'] } }, + { name: 'sys_api_key', enable: { apiEnabled: true, apiMethods: ['get', 'list', 'update'] } }, + { name: 'sys_oauth_application', enable: { apiEnabled: true, apiMethods: ['get', 'list'] } }, + ]); + const gate = await gateOf(rest, [ + { name: 'sys_inbox_message' }, + { name: 'sys_member', enable: { apiEnabled: true, apiMethods: ['get', 'list'] } }, + { name: 'sys_account', enable: { apiEnabled: true, apiMethods: ['get', 'list'] } }, + { name: 'sys_session', enable: { apiEnabled: true, apiMethods: ['get', 'list'] } }, + { name: 'sys_api_key', enable: { apiEnabled: true, apiMethods: ['get', 'list', 'update'] } }, + { name: 'sys_oauth_application', enable: { apiEnabled: true, apiMethods: ['get', 'list'] } }, + ]); + const app = { + name: 'account', + navigation: [ + { id: 'nav_account_notifications', type: 'object', objectName: 'sys_inbox_message', requiresObject: 'sys_inbox_message' }, + { id: 'nav_account_orgs', type: 'object', objectName: 'sys_member' }, + { id: 'nav_account_linked', type: 'object', objectName: 'sys_account', requiresObject: 'sys_account' }, + { id: 'nav_account_sessions', type: 'object', objectName: 'sys_session', requiresObject: 'sys_session' }, + { id: 'nav_account_api_keys', type: 'object', objectName: 'sys_api_key', requiresObject: 'sys_api_key' }, + { id: 'nav_account_oauth_apps', type: 'object', objectName: 'sys_oauth_application', requiresObject: 'sys_oauth_application' }, + ], + }; + expect(ids(rest.filterAppForUser(app, new Set(), undefined, gate))).toEqual([ + 'nav_account_notifications', + 'nav_account_orgs', + 'nav_account_linked', + 'nav_account_sessions', + 'nav_account_api_keys', + 'nav_account_oauth_apps', + ]); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 4d2e7b54bd..119ff7b216 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -72,8 +72,8 @@ import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import type { ISecurityService } from '@objectstack/spec/contracts'; import { resolveEffectiveApiMethods, - isApiOperationAllowed, effectiveOperationsArray, + apiExposureDenialReason, DATA_ACTION_TO_API_OPERATION, } from '@objectstack/spec/data'; // [#8013] The SHARED envelope writer (#3973), aliased: this module already has a @@ -1421,6 +1421,22 @@ interface ApiAccessOpts { bulkChild?: string; } +/** + * [#7912] The nav-servability gate handed to `filterAppForUser`: given the + * `objectName` a `type: 'object'` entry targets (and the entry itself, for the + * diagnostic), answer whether the destination can serve a `list`. + * + * `true` = serve the entry. That includes every case this layer cannot judge — + * an object absent from metadata, or metadata that could not be read at all — + * because the gate is a SURFACE-AREA control, not an authorization boundary, + * and the same fail-open reasoning `loadObjectItems` records applies here. + * + * `appName` is passed in rather than captured because ONE gate serves the whole + * app list: the list route resolves object metadata once and gates every app + * with the same closure, so the app being filtered is a per-call fact. + */ +type NavServabilityGate = (objectName: string, entry: any, appName: string) => boolean; + /** * Pure per-object API-exposure check: given an object's `enable` block, decide * whether `operation` is denied on the *external* REST surface (ADR-0049 / @@ -1435,6 +1451,14 @@ interface ApiAccessOpts { * identically everywhere. The 405 body's `allowed` array is the EFFECTIVE * operation set (enum-ordered), the single "effective" channel the frontend * consumes — never the raw whitelist. + * + * [#7912] The two-step ORDER those primitives compose into — `apiEnabled` + * first and independently, the whitelist second — is now the spec's + * `apiExposureDenialReason`, and this function is its ENVELOPE half: it turns + * the reason into the 404/405 body this surface sends. The extraction is what + * lets the nav-servability prune below (and the authoring-time lint that warns + * about the same entry) reach the identical verdict without a second spelling + * of the order to drift from this one. */ export function apiAccessDenialFromEnable( enable: any, @@ -1442,8 +1466,13 @@ export function apiAccessDenialFromEnable( operation: string, opts?: ApiAccessOpts, ): { status: number; body: Record } | null { - if (!enable) return null; - if (enable.apiEnabled === false) { + // Canonicalization stays HERE: `operation` arrives as a runtime action name + // on this surface, while the spec helper's contract is a canonical + // `ApiOperation`. + const canonical = DATA_ACTION_TO_API_OPERATION[operation] ?? operation; + const reason = apiExposureDenialReason(enable, canonical, opts); + if (!reason) return null; + if (reason === 'api-disabled') { return { status: 404, body: { @@ -1453,18 +1482,13 @@ export function apiAccessDenialFromEnable( }, }; } - const eff = resolveEffectiveApiMethods(enable); - // Unrestricted (no whitelist) → default-allow, exactly as before. - if (eff.mode === 'unrestricted') return null; - const canonical = DATA_ACTION_TO_API_OPERATION[operation] ?? operation; - if (isApiOperationAllowed(eff, canonical, opts)) return null; return { status: 405, body: { error: `API operation '${operation}' is not allowed on object '${objectName}'`, code: 'OBJECT_API_METHOD_NOT_ALLOWED', object: objectName, - allowed: effectiveOperationsArray(eff), + allowed: effectiveOperationsArray(resolveEffectiveApiMethods(enable)), }, }; } @@ -1936,6 +1960,15 @@ export class RestServer { * in-flight Promise so concurrent callers share one resolution. */ private readonly execCtxMemo = new WeakMap>>(); + /** + * [#7912] De-duplication keys for the nav-servability prune log — one line + * per `app|entry|object|reason` per process. See + * {@link resolveNavServability}: a console session re-fetches `/meta/app` + * on every navigation, so an unthrottled warning would bury its own first + * occurrence. Process-lifetime by design (the set is bounded by the number + * of dead nav entries authored, not by traffic). + */ + private readonly navPruneLogged = new Set(); private defaultEnvironmentIdProvider?: () => string | undefined; private authServiceProvider?: (environmentId?: string) => Promise; private objectQLProvider?: (environmentId?: string) => Promise; @@ -2925,12 +2958,26 @@ export class RestServer { * enforced by the shell alone: the entry (with its `objectName` / * `pageName` / `componentRef` target) still shipped in the `/meta` body, * so reading the JSON defeated it. + * - [#7912] SERVABILITY: drops a `type: 'object'` entry whose destination + * object could not answer a `list` for anyone — see `servabilityGate`. * * NOT gated here: `visible` (CEL) at any level, and `requiresObject` — both * are still evaluated client-side only. That asymmetry is deliberate and * pinned in `rest.test.ts`: server-side CEL needs a bound `user` context * that this layer does not have, and is its own change. * + * ⚠️ [#7912] `requiresObject` STAYS on that list, and the servability gate + * is not it wearing a new hat. `requiresObject` asks whether the named + * object is REGISTERED — a question about deployment composition, whose + * answer this filter deliberately leaves to the client (the maintainer + * ruling of 2026-08-12 rejected re-meaning the key server-side precisely + * because the docblock calls that asymmetry deliberate). The servability + * gate asks a different question of an object that IS registered: does its + * own `enable` block let the destination answer at all? An entry whose + * object this layer cannot find is therefore SERVED, not pruned — the + * `requiresObject` pin and #3770's "no declared policy ⇒ nothing to + * enforce" both survive unchanged. + * * Returns `null` when the app should be withheld from the user entirely. * Returns a shallow copy with filtered `navigation` / `areas` otherwise — * the original is never mutated so cached metadata stays clean. @@ -2943,8 +2990,13 @@ export class RestServer { * `requiredPermissions` and the ADR-0057 D10 `requiresService` gate — which * is why this used to sniff the shape. There is one shape now. */ - private filterAppForUser(item: any, sysPerms: Set, serviceGate?: (name: string) => boolean): any | null { - return this.filterAppForUserWithReason(item, sysPerms, serviceGate).app; + private filterAppForUser( + item: any, + sysPerms: Set, + serviceGate?: (name: string) => boolean, + servabilityGate?: NavServabilityGate, + ): any | null { + return this.filterAppForUserWithReason(item, sysPerms, serviceGate, servabilityGate).app; } /** @@ -2995,6 +3047,7 @@ export class RestServer { item: any, sysPerms: Set, serviceGate?: (name: string) => boolean, + servabilityGate?: NavServabilityGate, ): { app: any | null; withheld?: 'unpublished' | 'permission' | 'service' } { if (!item || typeof item !== 'object') return { app: item }; // ADR-0045 §3 (as revised 2026-08, #4829) — the publish gate. An @@ -3036,6 +3089,26 @@ export class RestServer { const req = Array.isArray(e.requiredPermissions) ? e.requiredPermissions : []; if (req.length > 0 && !req.every((p: string) => sysPerms.has(p))) continue; if (typeof e.requiresService === 'string' && serviceGate && serviceGate(e.requiresService) === false) continue; + // [#7912] SERVABILITY — the gate this filter had no vocabulary + // for. A `type: 'object'` entry names its destination in + // `objectName`; the object's own `enable` block decides whether + // a `list` can be answered there, and that decision takes no + // user, no permissions and no context. So an entry whose + // destination is API-disabled (404 `OBJECT_API_DISABLED`) or + // whose whitelist omits `list` (405 + // `OBJECT_API_METHOD_NOT_ALLOWED`) is dead for EVERY persona, + // platform admin included — which is why no combination of + // `requiredPermissions` on the entry could ever prune it + // (#7544 shipped exactly that combination for a year). + // + // The verdict comes from the same derivation the data route + // enforces (`apiExposureDenialReason`, #3391), reached through + // the gate the caller built — never a second reading of + // `enable` here. + if (servabilityGate && e.type === 'object' && typeof e.objectName === 'string') { + const appName = typeof item.name === 'string' ? item.name : '(unnamed)'; + if (servabilityGate(e.objectName, e, appName) === false) continue; + } // [#7380] A `group` is judged on what SURVIVES, never on how it // got there. Both childless shapes render the same dead sidebar // label, so both are dropped: @@ -3211,6 +3284,92 @@ export class RestServer { return registered; } + /** + * [#7912] Build the nav-servability gate for one request: which objects can + * actually answer a `list` on the external REST surface. + * + * ## Shape, and why it mirrors `resolveRegisteredServices` + * + * Same contract as the ADR-0057 D10 service gate one method up: resolve the + * facts ONCE per request, hand `filterAppForUser` a closure, and return + * `null` when the facts cannot be established so the caller skips the gate + * entirely. Nav filtering already runs over a whole app list; re-reading + * object metadata per entry would turn one read into dozens. + * + * ## Fail-open, in three distinct cases — each deliberate + * + * 1. **Metadata unreadable** — `loadObjectItems` answers `[]` and logs. + * This method then answers `null` (no gate), so nothing is pruned. The + * alternative fails CLOSED during every cold start, emptying the + * sidebar of a healthy deployment; #3545 already settled that trade for + * the data-route twin and the same reasoning binds harder here, where + * the consequence is a user staring at an app with no navigation. + * 2. **Object not in metadata** — served. There is no declared exposure + * policy to enforce (#3770), and "is this object registered at all?" is + * `requiresObject`'s question, which this layer deliberately does not + * answer (see {@link filterAppForUser}). + * 3. **No `enable` block** — served, by `apiExposureDenialReason`'s own + * default-open contract. An object that declares nothing restricts + * nothing. + * + * Only case (3)'s opposite — a declared `enable` that refuses `list` — ever + * prunes. + * + * ## The prune is LOGGED, never silent + * + * The maintainer ruling of 2026-08-12 makes the author-visible diagnostic a + * mandatory companion, not an optional one: "a prune the author cannot see + * is the same failure one layer over — no silent dead rows, and no silent + * repairs." The authoring-time half of that is + * `validate-nav-object-servability` in `@objectstack/lint`, which refuses + * the stack at `os validate` / `os build` / `os lint` before it can ever be + * served. This log is the serving-side half, for an entry that reached a + * running deployment anyway (a `sys_metadata` overlay row, or a stack built + * before the lint existed): it names the app, the entry id, the object AND + * the condition, so the pruned row is discoverable from the server log + * rather than being an unexplained gap in a menu. + * + * One line per `app|entry|object|reason` per process — a console session + * re-fetches `/meta/app` on every navigation, and an unthrottled log would + * bury the first occurrence under thousands of repeats. + */ + private async resolveNavServability( + p: RestProtocol, + environmentId: string | undefined, + ): Promise { + const items = await this.loadObjectItems(p, environmentId); + // Case (1): nothing to judge with. `loadObjectItems` has already logged + // a THROWN read; a legitimately empty registry is silent and equally + // ungated, which is correct — an empty registry declares no policy. + if (items.length === 0) return null; + const enableByName = new Map(); + for (const o of items) { + if (o && typeof o.name === 'string') enableByName.set(o.name, o.enable); + } + return (objectName: string, entry: any, appName: string): boolean => { + // Case (2): unknown object → no declared policy to enforce here. + if (!enableByName.has(objectName)) return true; + const reason = apiExposureDenialReason(enableByName.get(objectName), 'list'); + if (!reason) return true; + const entryId = (entry && (entry.id ?? entry.label)) ?? '(unnamed)'; + const key = `${appName}|${entryId}|${objectName}|${reason}`; + if (!this.navPruneLogged.has(key)) { + this.navPruneLogged.add(key); + logWarn( + `[REST] [#7912] nav entry '${entryId}' pruned from app '${appName}': its destination ` + + `object '${objectName}' cannot serve a list — ` + + (reason === 'api-disabled' + ? `\`enable.apiEnabled: false\` (the list answers 404 OBJECT_API_DISABLED for every user).` + : `\`enable.apiMethods\` does not grant \`list\` (the list answers 405 ` + + `OBJECT_API_METHOD_NOT_ALLOWED for every user).`) + + ` Remove the entry, or expose the object — \`os validate\` refuses this stack ` + + `(nav-object-unservable).`, + ); + } + return false; + }; + } + /** * Build a `TranslationBundle` (`Record`) from an * `II18nService` instance. Returns `undefined` when no locales are @@ -4797,8 +4956,12 @@ export class RestServer { ); const registered = await this.resolveRegisteredServices((ctx as any).__kernel, list); const serviceGate = registered ? (n: string) => registered.has(n) : undefined; + // [#7912] Resolved ONCE for the whole list — + // object metadata is a per-request fact, not + // a per-app one. + const servabilityGate = await this.resolveNavServability(p, environmentId) ?? undefined; const filtered = list - .map((it: any) => this.filterAppForUser(it, sysPerms, serviceGate)) + .map((it: any) => this.filterAppForUser(it, sysPerms, serviceGate, servabilityGate)) .filter((it: any) => it != null); visible = Array.isArray(raw) ? filtered @@ -5636,7 +5799,13 @@ export class RestServer { ); const registered = await this.resolveRegisteredServices((ctx as any).__kernel, [visible]); const serviceGate = registered ? (n: string) => registered.has(n) : undefined; - const gated = this.filterAppForUserWithReason(visible, sysPerms, serviceGate); + // [#7912] Same gate as the list route — the + // by-name route must not serve a nav entry + // the list route prunes, or reading the + // single-app JSON defeats the filter (the + // #4722 lesson, one gate over). + const servabilityGate = await this.resolveNavServability(p, environmentId) ?? undefined; + const gated = this.filterAppForUserWithReason(visible, sysPerms, serviceGate, servabilityGate); visible = gated.app; if (visible == null) { // [#8013] A PERMISSION denial is reported as diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index e90c801d2d..0ea8114328 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -29,6 +29,7 @@ "AggregationStageSchema (const)", "AnalyticsQuery (type)", "AnalyticsQuerySchema (const)", + "ApiExposureDenialReason (type)", "ApiMethod (type)", "ApiMethodsMode (type)", "ApiOperation (type)", @@ -601,8 +602,10 @@ "ValueForm (type)", "ValueShapeFieldDef (interface)", "ZeroLimitConformanceCase (interface)", + "apiExposureDenialReason (function)", "asciiCaseInsensitiveContains (function)", "asciiCaseInsensitiveRegexSource (function)", + "canServeApiOperation (function)", "canonicalAstOperator (function)", "canonicalizeSqlType (function)", "checkLiteralDefaultValue (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index bdc33b0812..0db47be401 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -29,6 +29,7 @@ "AggregationStageSchema": "src/data/driver-nosql.zod.ts#AggregationStageSchema (const)", "AnalyticsQuery": "src/data/analytics.zod.ts#AnalyticsQuery (type)", "AnalyticsQuerySchema": "src/data/analytics.zod.ts#AnalyticsQuerySchema (const)", + "ApiExposureDenialReason": "src/data/api-derivation.ts#ApiExposureDenialReason (type)", "ApiMethod": "src/data/object.zod.ts#ApiMethod (type)", "ApiMethodsMode": "src/data/api-derivation.ts#ApiMethodsMode (type)", "ApiOperation": "src/data/object.zod.ts#ApiOperation (type)", @@ -601,8 +602,10 @@ "ValueForm": "src/data/field-value.zod.ts#ValueForm (type)", "ValueShapeFieldDef": "src/data/field-value.zod.ts#ValueShapeFieldDef (interface)", "ZeroLimitConformanceCase": "src/data/pagination-conformance.ts#ZeroLimitConformanceCase (interface)", + "apiExposureDenialReason": "src/data/api-derivation.ts#apiExposureDenialReason (function)", "asciiCaseInsensitiveContains": "src/data/filter.zod.ts#asciiCaseInsensitiveContains (function)", "asciiCaseInsensitiveRegexSource": "src/data/filter.zod.ts#asciiCaseInsensitiveRegexSource (function)", + "canServeApiOperation": "src/data/api-derivation.ts#canServeApiOperation (function)", "canonicalAstOperator": "src/data/filter.zod.ts#canonicalAstOperator (function)", "canonicalizeSqlType": "src/data/type-compat.ts#canonicalizeSqlType (function)", "checkLiteralDefaultValue": "src/data/default-value-shape.ts#checkLiteralDefaultValue (function)", diff --git a/packages/spec/src/data/api-derivation.ts b/packages/spec/src/data/api-derivation.ts index b5dc39bd7a..f6d5167f79 100644 --- a/packages/spec/src/data/api-derivation.ts +++ b/packages/spec/src/data/api-derivation.ts @@ -374,3 +374,75 @@ export function isApiOperationAllowed( export function effectiveOperationsArray(eff: EffectiveApiMethods): ApiOperation[] { return API_OPERATION_ORDER.filter((m) => eff.operations.has(m)); } + +/** + * Why an object's `enable` block refuses `operation` on the EXTERNAL REST + * surface — or `null` when it does not refuse. + * + * - `api-disabled` — `enable.apiEnabled === false`. The object is not exposed + * at all; the REST surface answers 404 so its existence is not revealed. + * - `method-not-allowed` — exposed, but `enable.apiMethods` resolves to an + * effective set that does not contain `operation` (405). + */ +export type ApiExposureDenialReason = 'api-disabled' | 'method-not-allowed'; + +/** + * The ORDER in which an object's `enable` block refuses an operation — declared + * once, here, beside the two primitives it composes (#3391). + * + * ## Why this exists as its own export + * + * The order is two steps and both matter: `apiEnabled === false` is judged + * FIRST and independently of any whitelist, so an object that is API-disabled + * refuses every operation whatever `apiMethods` says. Spelling those two steps + * out at each call site is how they drift — and by #7912 there were already + * three spellings of it (the REST data gate, the `platform-objects` nav + * invariant, and the nav prune this export was extracted for), each free to + * disagree about a case the others had not met. + * + * Everything about the decision is a pure function of `enable`: it takes no + * user, no permissions and no request context, so the answer is identical for + * every persona — platform admin included. That property is what lets a + * SERVING-side filter (which nav entries can work at all) and an AUTHORING-side + * lint (which nav entries could never work) read the same function and reach + * the same verdict, instead of approximating each other. + * + * Callers that need to SEND the refusal turn the reason into their own envelope + * (`apiAccessDenialFromEnable` in `@objectstack/rest` builds the 404/405 bodies + * from it); callers that only need the verdict use {@link canServeApiOperation}. + * + * @param enable The object's `enable` capability block (or `undefined`). + * @param operation A canonical {@link ApiOperation} name — normalize runtime + * action names through {@link DATA_ACTION_TO_API_OPERATION} first. + * @param opts `writeMode` (import precision) / `bulkChild` (bulk∧child). + */ +export function apiExposureDenialReason( + enable: EnableLike | null | undefined, + operation: string, + opts?: OperationCheckOptions, +): ApiExposureDenialReason | null { + // No `enable` block at all → nothing declared, nothing to refuse. Kept + // ahead of the `apiEnabled` test so the default-open case is one branch and + // cannot be reached by a falsy-`enable` slip. + if (!enable) return null; + if (enable.apiEnabled === false) return 'api-disabled'; + const eff = resolveEffectiveApiMethods(enable); + if (eff.mode === 'unrestricted') return null; + return isApiOperationAllowed(eff, operation, opts) ? null : 'method-not-allowed'; +} + +/** + * The boolean face of {@link apiExposureDenialReason}: can this object serve + * `operation` on the external REST surface at all? + * + * `canServeApiOperation(enable, 'list')` is the question a navigation entry + * asks — "if a user clicks this, can the destination answer?" — and it is the + * one every nav-servability consumer shares. + */ +export function canServeApiOperation( + enable: EnableLike | null | undefined, + operation: string, + opts?: OperationCheckOptions, +): boolean { + return apiExposureDenialReason(enable, operation, opts) === null; +} From 4d9b950467bc3ef95ccb1b6e0998a3c9fcfb6305 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:48:45 +0000 Subject: [PATCH 2/3] chore(spec): regenerate api-surface / export-origins after merging origin/main (#7912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both artifacts were merged without a text merge: this branch and #8342 each added exports to `@objectstack/spec/data`, so neither side's baseline covered the union. Regenerated from the merged tree, after rebuilding dist — the gate reads the BUILT dist, and on a stale one it reports phantom removals that the generator would then write. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj --- packages/spec/api-surface/data.json | 10 ++++++++++ packages/spec/export-origins/data.json | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 0ea8114328..1e59d3be72 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -11,6 +11,7 @@ "API_METHOD_ORDER (const)", "API_OPERATION_ORDER (const)", "API_PRIMITIVES (const)", + "AUDIT_FIELD_DEFS (const)", "AUDIT_PROVENANCE_FIELDS (const)", "Address (type)", "AddressSchema (const)", @@ -319,6 +320,7 @@ "ImportFieldMappingParsed (type)", "ImportFieldMappingSchema (const)", "IndexSchema (const)", + "InjectedColumnProvenance (type)", "InjectedSystemColumnPlan (interface)", "InstantValue (type)", "InstantValueSchema (const)", @@ -390,6 +392,8 @@ "NormalizedFilter (type)", "NormalizedFilterSchema (const)", "OBJECT_KEY_GUIDANCE (const)", + "OWNER_FIELD_DEF (const)", + "OWNING_BUSINESS_UNIT_FIELD_DEF (const)", "ObjectAccessConfig (type)", "ObjectAccessConfigParsed (type)", "ObjectAccessConfigSchema (const)", @@ -571,6 +575,7 @@ "TEMPORAL_ROWS (const)", "TEMPORAL_TIME_CASES (const)", "TEMPORAL_TIME_ROWS (const)", + "TENANT_SCOPE_FIELD_DEF (const)", "TITLE_ELIGIBLE (const)", "TITLE_ELIGIBLE_TYPES (const)", "TITLE_INELIGIBLE_TYPES (const)", @@ -646,6 +651,7 @@ "hasDanglingLikeEscape (function)", "hasDynamicTokens (function)", "hookForm (const)", + "injectedSystemColumnDefs (function)", "isAcceptedFilterComparand (function)", "isApiOperationAllowed (function)", "isApiPrimitive (function)", @@ -659,6 +665,7 @@ "isFilterAST (function)", "isGlobalUnique (function)", "isIncoherentAggregate (function)", + "isInjectedColumnDefinition (function)", "isKnownFilterToken (function)", "isLegacyApiMethod (function)", "isMultiValueField (function)", @@ -683,6 +690,7 @@ "parseDateMacroParam (function)", "parseFilterAST (function)", "percentScaleOf (function)", + "platformProvisionsStorage (function)", "provisionPrimary (function)", "readAutonumberCounter (function)", "reduceFilterKeyVerdict (function)", @@ -698,6 +706,7 @@ "resolveDisplayField (function)", "resolveDriverId (function)", "resolveEffectiveApiMethods (function)", + "resolveInjectedColumnProvenance (function)", "resolveInjectedSystemColumns (function)", "resolveRecordDisplayName (function)", "resolveSearchFieldResolution (function)", @@ -706,6 +715,7 @@ "stripLegacyApiMethods (function)", "suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType (function)", + "unprovisionedInjectedColumns (function)", "urlUserinfoPassword (function)", "utcInstantMs (function)", "validateDriverConfig (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 0db47be401..a485fe79c0 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -11,6 +11,7 @@ "API_METHOD_ORDER": "src/data/api-derivation.ts#API_METHOD_ORDER (const)", "API_OPERATION_ORDER": "src/data/object.zod.ts#API_OPERATION_ORDER (const)", "API_PRIMITIVES": "src/data/api-derivation.ts#API_PRIMITIVES (const)", + "AUDIT_FIELD_DEFS": "src/data/injected-system-column-provenance.ts#AUDIT_FIELD_DEFS (const)", "AUDIT_PROVENANCE_FIELDS": "src/data/field-group-layout.ts#AUDIT_PROVENANCE_FIELDS (const)", "Address": "src/data/field.zod.ts#Address (type)", "AddressSchema": "src/data/field-value.zod.ts#AddressSchema (const)", @@ -319,6 +320,7 @@ "ImportFieldMappingParsed": "src/data/mapping.zod.ts#ImportFieldMappingParsed (type)", "ImportFieldMappingSchema": "src/data/mapping.zod.ts#ImportFieldMappingSchema (const)", "IndexSchema": "src/data/object.zod.ts#IndexSchema (const)", + "InjectedColumnProvenance": "src/data/injected-system-column-provenance.ts#InjectedColumnProvenance (type)", "InjectedSystemColumnPlan": "src/data/injected-system-columns.ts#InjectedSystemColumnPlan (interface)", "InstantValue": "src/data/field-value.zod.ts#InstantValue (type)", "InstantValueSchema": "src/data/field-value.zod.ts#InstantValueSchema (const)", @@ -390,6 +392,8 @@ "NormalizedFilter": "src/data/filter.zod.ts#NormalizedFilter (type)", "NormalizedFilterSchema": "src/data/filter.zod.ts#NormalizedFilterSchema (const)", "OBJECT_KEY_GUIDANCE": "src/data/authoring-key-lint.ts#OBJECT_KEY_GUIDANCE (const)", + "OWNER_FIELD_DEF": "src/data/injected-system-column-provenance.ts#OWNER_FIELD_DEF (const)", + "OWNING_BUSINESS_UNIT_FIELD_DEF": "src/data/injected-system-column-provenance.ts#OWNING_BUSINESS_UNIT_FIELD_DEF (const)", "ObjectAccessConfig": "src/data/object.zod.ts#ObjectAccessConfig (type)", "ObjectAccessConfigParsed": "src/data/object.zod.ts#ObjectAccessConfigParsed (type)", "ObjectAccessConfigSchema": "src/data/object.zod.ts#ObjectAccessConfigSchema (const)", @@ -571,6 +575,7 @@ "TEMPORAL_ROWS": "src/data/temporal-conformance.ts#TEMPORAL_ROWS (const)", "TEMPORAL_TIME_CASES": "src/data/temporal-conformance.ts#TEMPORAL_TIME_CASES (const)", "TEMPORAL_TIME_ROWS": "src/data/temporal-conformance.ts#TEMPORAL_TIME_ROWS (const)", + "TENANT_SCOPE_FIELD_DEF": "src/data/injected-system-column-provenance.ts#TENANT_SCOPE_FIELD_DEF (const)", "TITLE_ELIGIBLE": "src/data/display-name.ts#TITLE_ELIGIBLE (const)", "TITLE_ELIGIBLE_TYPES": "src/data/display-name.ts#TITLE_ELIGIBLE_TYPES (const)", "TITLE_INELIGIBLE_TYPES": "src/data/display-name.ts#TITLE_INELIGIBLE_TYPES (const)", @@ -646,6 +651,7 @@ "hasDanglingLikeEscape": "src/data/filter.zod.ts#hasDanglingLikeEscape (function)", "hasDynamicTokens": "src/data/autonumber-format.ts#hasDynamicTokens (function)", "hookForm": "src/data/hook.form.ts#hookForm (const)", + "injectedSystemColumnDefs": "src/data/injected-system-column-provenance.ts#injectedSystemColumnDefs (function)", "isAcceptedFilterComparand": "src/data/filter-comparand-type.ts#isAcceptedFilterComparand (function)", "isApiOperationAllowed": "src/data/api-derivation.ts#isApiOperationAllowed (function)", "isApiPrimitive": "src/data/api-derivation.ts#isApiPrimitive (function)", @@ -659,6 +665,7 @@ "isFilterAST": "src/data/filter.zod.ts#isFilterAST (function)", "isGlobalUnique": "src/data/field.zod.ts#isGlobalUnique (function)", "isIncoherentAggregate": "src/data/aggregation-policy.ts#isIncoherentAggregate (function)", + "isInjectedColumnDefinition": "src/data/injected-system-column-provenance.ts#isInjectedColumnDefinition (function)", "isKnownFilterToken": "src/data/context-tokens.zod.ts#isKnownFilterToken (function)", "isLegacyApiMethod": "src/data/api-derivation.ts#isLegacyApiMethod (function)", "isMultiValueField": "src/data/field-value.zod.ts#isMultiValueField (function)", @@ -683,6 +690,7 @@ "parseDateMacroParam": "src/data/date-macros.zod.ts#parseDateMacroParam (function)", "parseFilterAST": "src/data/filter.zod.ts#parseFilterAST (function)", "percentScaleOf": "src/data/percent-scale.ts#percentScaleOf (function)", + "platformProvisionsStorage": "src/data/injected-system-column-provenance.ts#platformProvisionsStorage (function)", "provisionPrimary": "src/data/display-name.ts#provisionPrimary (function)", "readAutonumberCounter": "src/data/autonumber-format.ts#readAutonumberCounter (function)", "reduceFilterKeyVerdict": "src/data/filter-verdict.ts#reduceFilterKeyVerdict (function)", @@ -698,6 +706,7 @@ "resolveDisplayField": "src/data/display-name.ts#resolveDisplayField (function)", "resolveDriverId": "src/data/driver/config-registry.zod.ts#resolveDriverId (function)", "resolveEffectiveApiMethods": "src/data/api-derivation.ts#resolveEffectiveApiMethods (function)", + "resolveInjectedColumnProvenance": "src/data/injected-system-column-provenance.ts#resolveInjectedColumnProvenance (function)", "resolveInjectedSystemColumns": "src/data/injected-system-columns.ts#resolveInjectedSystemColumns (function)", "resolveRecordDisplayName": "src/data/display-name.ts#resolveRecordDisplayName (function)", "resolveSearchFieldResolution": "src/data/search-fields.ts#resolveSearchFieldResolution (function)", @@ -706,6 +715,7 @@ "stripLegacyApiMethods": "src/data/object.zod.ts#stripLegacyApiMethods (function)", "suggestDefaultValueToken": "src/data/default-value-shape.ts#suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)", + "unprovisionedInjectedColumns": "src/data/injected-system-column-provenance.ts#unprovisionedInjectedColumns (function)", "urlUserinfoPassword": "src/data/driver/common.zod.ts#urlUserinfoPassword (function)", "utcInstantMs": "src/data/calendar-day.ts#utcInstantMs (function)", "validateDriverConfig": "src/data/driver/config-registry.zod.ts#validateDriverConfig (function)", From eed19eeeff54897ffa49d03ba849b3fce2321d5e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:17:08 +0000 Subject: [PATCH 3/3] fix(rest): give the #7912 nav-servability test's import an explicit .js extension (#7912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:type-check-debt` went red: @objectstack/rest's TEST_DEBT records 155 raw tsc errors and the re-measure reported 156 (+1). Attributed, not assumed. The package-level `typecheck` is genuinely clean — `packages/rest` is one of the 20 packages whose tsconfig hides its own tests from tsc, so nothing local can see the test layer. Replaying the ledger's own program (the package tsconfig with the test globs dropped from `exclude`) gives 156, of which exactly ONE is in the new file and exactly 155 are not — matching the recorded number, so nothing pre-existing on main drifted: meta-app-nav-servability-gate.test.ts(22,28): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './rest-server.js'? The package resolves NodeNext, so the extensionless spelling the older test files use is a TS2835 — 67 of them are frozen in the ledger. The fix is the extension, so a new file does not add the 68th. ⛔ The TEST_DEBT entry was NOT raised, and `scripts/check-type-check-coverage.mjs` was not touched. The ledger is a ratchet: `--re-measure` now reports "none above its recorded number / surplus: none — every entry sits exactly at its measurement". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj --- packages/rest/src/meta-app-nav-servability-gate.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/meta-app-nav-servability-gate.test.ts b/packages/rest/src/meta-app-nav-servability-gate.test.ts index 0181945df9..bc1a304563 100644 --- a/packages/rest/src/meta-app-nav-servability-gate.test.ts +++ b/packages/rest/src/meta-app-nav-servability-gate.test.ts @@ -19,7 +19,11 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { RestServer } from './rest-server'; +// Explicit `.js` extension: this package's tsconfig resolves NodeNext, so the +// extensionless spelling its older test files use is a TS2835 — 67 of them are +// frozen in the TEST_DEBT ledger, which only ever shrinks. A new file must not +// add the 68th. +import { RestServer } from './rest-server.js'; const ANON_API = { api: { requireAuth: false } };