|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#7912] A navigation entry whose destination object cannot serve a `list` — |
| 5 | + * refused at authoring time, before it can be published and pruned in silence. |
| 6 | + * |
| 7 | + * ## The defect |
| 8 | + * |
| 9 | + * A `type: 'object'` nav entry names its destination in `objectName`. That |
| 10 | + * object's own `enable` block decides whether the external REST surface can |
| 11 | + * answer a `list` there at all: |
| 12 | + * |
| 13 | + * - `enable.apiEnabled: false` → `OBJECT_API_DISABLED` (404); |
| 14 | + * - an `enable.apiMethods` whitelist without `list` → `OBJECT_API_METHOD_NOT_ALLOWED` (405). |
| 15 | + * |
| 16 | + * Both are pure functions of `enable` — no user, no permissions, no request |
| 17 | + * context — so the destination is dead for EVERY persona, platform admin |
| 18 | + * included. #7544 shipped exactly such an entry for a year: it read as correct |
| 19 | + * to reviewers because the entry carried a `requiredPermissions` gate, and the |
| 20 | + * in-code comment claimed a non-admin "403s server-side", which implies an |
| 21 | + * admin could list. None could. The 404 precedes and ignores permissions |
| 22 | + * entirely, and no combination of permissions on the ENTRY can prune an entry |
| 23 | + * whose OBJECT is API-disabled — they are independent conditions. |
| 24 | + * |
| 25 | + * ## Why this rule exists even though the server now prunes |
| 26 | + * |
| 27 | + * The maintainer ruling of 2026-08-12 chose to DERIVE servability rather than |
| 28 | + * mint a new nav key: `filterAppForUser` (`@objectstack/rest`) drops these |
| 29 | + * entries from the `/meta` payload, so the user never sees a menu item that |
| 30 | + * cannot work. That fixes the user-facing half and opens an authoring-facing |
| 31 | + * one — the ruling names it and makes this rule a mandatory companion, not an |
| 32 | + * optional extra: |
| 33 | + * |
| 34 | + * > A prune the author cannot see is the same failure one layer over — no |
| 35 | + * > silent dead rows, and no silent repairs. |
| 36 | + * |
| 37 | + * An author whose object is accidentally API-disabled would otherwise watch the |
| 38 | + * entry vanish from a running app with no signal anywhere. This rule is the |
| 39 | + * signal, raised at the checkpoint that can still see the whole picture and |
| 40 | + * naming both halves: which entry, and the exact `enable` key that killed it. |
| 41 | + * |
| 42 | + * ## Severity: `error`, unlike its neighbours — and why that is not over-reach |
| 43 | + * |
| 44 | + * `validate-nav-access` (its closest sibling: "navigation exposes an object no |
| 45 | + * permission set grants") is advisory, because a grant can legitimately arrive |
| 46 | + * from a package this stack cannot see. Nothing analogous applies here. |
| 47 | + * `enable` is declared ON the object, in this stack, and this rule judges ONLY |
| 48 | + * objects this stack declares — so when it fires, it has read the whole of the |
| 49 | + * evidence and the entry is dead with certainty. There is no installed package |
| 50 | + * that can make an `apiEnabled: false` object listable. |
| 51 | + * |
| 52 | + * The exemption is therefore the same shape as the sibling's, drawn one axis |
| 53 | + * over: a target this stack does not declare is SKIPPED entirely rather than |
| 54 | + * guessed at, because its `enable` block is not visible from here. |
| 55 | + * |
| 56 | + * ## ⛔ What this rule deliberately does NOT judge |
| 57 | + * |
| 58 | + * - **Whether the object exists at all.** An unresolvable nav target is |
| 59 | + * `validate-object-references` / `defineStack`'s question, and on the |
| 60 | + * serving side it is `requiresObject`'s — a key whose client-only evaluation |
| 61 | + * the same ruling explicitly declined to re-mean. Silence here on an unknown |
| 62 | + * name is that boundary, not an oversight. |
| 63 | + * - **Permissions.** Ungranted-but-listable is `validate-nav-access`; the two |
| 64 | + * conditions are independent and each needs its own finding, which is the |
| 65 | + * load-bearing lesson of #7544. |
| 66 | + * - **Non-`object` entries.** A `component` / `page` / `url` entry has no |
| 67 | + * `objectName` destination to judge, even when it carries `requiresObject`. |
| 68 | + */ |
| 69 | + |
| 70 | +import { canServeApiOperation, type EnableLike } from '@objectstack/spec/data'; |
| 71 | + |
| 72 | +import type { ReferenceIntegrityFinding } from './reference-integrity-suite.js'; |
| 73 | + |
| 74 | +export type NavObjectServabilityFinding = ReferenceIntegrityFinding; |
| 75 | + |
| 76 | +/** Emitted when a nav entry targets an object whose `enable` block cannot serve a list. */ |
| 77 | +export const NAV_OBJECT_UNSERVABLE = 'nav-object-unservable'; |
| 78 | + |
| 79 | +type AnyRec = Record<string, unknown>; |
| 80 | + |
| 81 | +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); |
| 82 | + |
| 83 | +/** Both authoring carriers: an array of documents, or a name-keyed map. */ |
| 84 | +function asArray(v: unknown): AnyRec[] { |
| 85 | + if (Array.isArray(v)) return v.filter(isRec); |
| 86 | + if (isRec(v)) return Object.entries(v).map(([name, def]) => (isRec(def) ? { name, ...def } : { name })); |
| 87 | + return []; |
| 88 | +} |
| 89 | + |
| 90 | +function strName(v: unknown): string | undefined { |
| 91 | + return typeof v === 'string' && v.length > 0 ? v : undefined; |
| 92 | +} |
| 93 | + |
| 94 | +/** |
| 95 | + * An interpolated target resolves at render time — the same conservative |
| 96 | + * exemption `validate-object-references` and `validate-nav-target-refs` use to |
| 97 | + * keep false positives near zero (ADR-0072 D1). |
| 98 | + */ |
| 99 | +const isInterpolated = (s: string): boolean => s.includes('${') || s.includes('{'); |
| 100 | + |
| 101 | +export function validateNavObjectServability(stack: unknown): NavObjectServabilityFinding[] { |
| 102 | + const findings: NavObjectServabilityFinding[] = []; |
| 103 | + if (!isRec(stack)) return findings; |
| 104 | + |
| 105 | + const apps = asArray(stack.apps); |
| 106 | + if (apps.length === 0) return findings; |
| 107 | + |
| 108 | + // Only objects THIS stack declares can be judged — see the header. The map |
| 109 | + // records where each one is declared so a finding can point at the `enable` |
| 110 | + // key that is actually editable, not merely at the nav entry that tripped on |
| 111 | + // it. |
| 112 | + const ownEnable = new Map<string, { enable: EnableLike | undefined; path: string }>(); |
| 113 | + const objects = asArray(stack.objects); |
| 114 | + for (const [oi, obj] of objects.entries()) { |
| 115 | + const n = strName(obj.name); |
| 116 | + if (!n) continue; |
| 117 | + // These rules read UNTYPED authored documents, so the shape is asserted |
| 118 | + // rather than proved. `EnableLike` is deliberately loose (every key |
| 119 | + // optional, plus an index signature) and `canServeApiOperation` treats a |
| 120 | + // missing/garbage block as "declares nothing" — so a non-object `enable` |
| 121 | + // reaches the default-open answer instead of throwing. |
| 122 | + ownEnable.set(n, { enable: obj.enable as EnableLike | undefined, path: `objects[${oi}].enable` }); |
| 123 | + } |
| 124 | + if (ownEnable.size === 0) return findings; |
| 125 | + |
| 126 | + for (const [ai, app] of apps.entries()) { |
| 127 | + const appName = strName(app.name) ?? `#${ai}`; |
| 128 | + |
| 129 | + const walk = (items: unknown, basePath: string): void => { |
| 130 | + if (!Array.isArray(items)) return; |
| 131 | + for (const [ni, raw] of items.entries()) { |
| 132 | + if (!isRec(raw)) continue; |
| 133 | + const nav = raw; |
| 134 | + const navPath = `${basePath}[${ni}]`; |
| 135 | + |
| 136 | + if (nav.type === 'object') { |
| 137 | + const target = strName(nav.objectName); |
| 138 | + const declared = target && !isInterpolated(target) ? ownEnable.get(target) : undefined; |
| 139 | + if (target && declared && !canServeApiOperation(declared.enable, 'list')) { |
| 140 | + const enable = isRec(declared.enable) ? declared.enable : {}; |
| 141 | + // Which of the two conditions fired. `apiEnabled` is judged first |
| 142 | + // and independently — an API-disabled object refuses `list` |
| 143 | + // whatever its whitelist says — so the report follows the same |
| 144 | + // order rather than describing a whitelist the 404 never reaches. |
| 145 | + const apiDisabled = enable.apiEnabled === false; |
| 146 | + const condition = apiDisabled |
| 147 | + ? '`enable.apiEnabled: false`' |
| 148 | + : '`enable.apiMethods` does not grant `list`' |
| 149 | + + (Array.isArray(enable.apiMethods) |
| 150 | + ? ` (declared: ${enable.apiMethods.length === 0 ? '[] — deny-all' : enable.apiMethods.map((m) => `\`${String(m)}\``).join(', ')})` |
| 151 | + : ''); |
| 152 | + const answer = apiDisabled |
| 153 | + ? '404 `OBJECT_API_DISABLED`' |
| 154 | + : '405 `OBJECT_API_METHOD_NOT_ALLOWED`'; |
| 155 | + const offendingKey = apiDisabled |
| 156 | + ? `${declared.path}.apiEnabled` |
| 157 | + : `${declared.path}.apiMethods`; |
| 158 | + |
| 159 | + findings.push({ |
| 160 | + severity: 'error', |
| 161 | + rule: NAV_OBJECT_UNSERVABLE, |
| 162 | + where: `app "${appName}" · nav "${strName(nav.id) ?? strName(nav.label) ?? `#${ni}`}"`, |
| 163 | + // The nav entry is where the dead row is authored; the `enable` |
| 164 | + // key that condemns it is named in the message, because the fix |
| 165 | + // may belong at either end. |
| 166 | + path: `${navPath}.objectName`, |
| 167 | + message: |
| 168 | + `Navigation targets object "${target}", which cannot serve a list: ${condition} ` |
| 169 | + + `(\`${offendingKey}\`), so the list request answers ${answer} for EVERY user — ` |
| 170 | + + `platform administrators included, since that gate reads only the object's \`enable\` ` |
| 171 | + + `block and never the caller. The entry cannot be rescued with ` |
| 172 | + + `\`requiredPermissions\`: they are independent conditions. The server prunes this ` |
| 173 | + + `entry from the served \`/meta\` payload (#7912), so publishing it ships a menu row ` |
| 174 | + + `that silently is not there.`, |
| 175 | + hint: |
| 176 | + `Remove the nav entry, or make "${target}" listable by setting \`enable.apiEnabled: true\` ` |
| 177 | + + `and granting \`list\` in \`enable.apiMethods\`. ⛔ Do NOT open the API on an object that ` |
| 178 | + + `is disabled on purpose — several platform objects hold credential material and are ` |
| 179 | + + `API-disabled deliberately; for those the entry is the mistake, not the \`enable\` block.`, |
| 180 | + }); |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + // Recurse: an `object` nav item carries `children` too, not just a |
| 185 | + // `group` — the same reason `stack.zod.ts` does not gate its recursion |
| 186 | + // on the item type. |
| 187 | + if (Array.isArray(nav.children)) walk(nav.children, `${navPath}.children`); |
| 188 | + } |
| 189 | + }; |
| 190 | + |
| 191 | + walk(app.navigation, `apps[${ai}].navigation`); |
| 192 | + // `areas[]` is the other nav container, and the server gates it through the |
| 193 | + // very same walk (#4722) — so this rule must see it too, or it would pass a |
| 194 | + // stack whose served payload the runtime prunes. |
| 195 | + for (const [ari, area] of asArray(app.areas).entries()) { |
| 196 | + walk(area.items, `apps[${ai}].areas[${ari}].items`); |
| 197 | + walk(area.navigation, `apps[${ai}].areas[${ari}].navigation`); |
| 198 | + } |
| 199 | + } |
| 200 | + |
| 201 | + return findings; |
| 202 | +} |
0 commit comments