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 f39ebc168f..4511e0e9ea 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -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, 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..bc1a304563 --- /dev/null +++ b/packages/rest/src/meta-app-nav-servability-gate.test.ts @@ -0,0 +1,333 @@ +// 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'; +// 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 } }; + +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 bbd1770914..1e59d3be72 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -30,6 +30,7 @@ "AggregationStageSchema (const)", "AnalyticsQuery (type)", "AnalyticsQuerySchema (const)", + "ApiExposureDenialReason (type)", "ApiMethod (type)", "ApiMethodsMode (type)", "ApiOperation (type)", @@ -606,8 +607,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 fbb638d6bc..a485fe79c0 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -30,6 +30,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)", @@ -606,8 +607,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; +}