@murumets-ee/menus 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +94 -0
- package/dist/admin.d.mts +293 -0
- package/dist/admin.d.mts.map +1 -0
- package/dist/admin.mjs +2 -0
- package/dist/admin.mjs.map +1 -0
- package/dist/en-Cr9XUf6B.mjs +2 -0
- package/dist/en-Cr9XUf6B.mjs.map +1 -0
- package/dist/entity-BxLeqoBC.mjs +2 -0
- package/dist/entity-BxLeqoBC.mjs.map +1 -0
- package/dist/et-C_k3aK3T.mjs +2 -0
- package/dist/et-C_k3aK3T.mjs.map +1 -0
- package/dist/i18n.d.mts +16 -0
- package/dist/i18n.d.mts.map +1 -0
- package/dist/i18n.mjs +2 -0
- package/dist/i18n.mjs.map +1 -0
- package/dist/index.d.mts +75 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1 -0
- package/dist/menu-entity-types-BnKnKbif.mjs +2 -0
- package/dist/menu-entity-types-BnKnKbif.mjs.map +1 -0
- package/dist/menu-item-schema-xXWZKw7t.d.mts +296 -0
- package/dist/menu-item-schema-xXWZKw7t.d.mts.map +1 -0
- package/dist/messages/en.json +19 -0
- package/dist/messages/et.json +19 -0
- package/dist/messages/ru.json +19 -0
- package/dist/resolve.d.mts +205 -0
- package/dist/resolve.d.mts.map +1 -0
- package/dist/resolve.mjs +2 -0
- package/dist/resolve.mjs.map +1 -0
- package/dist/ru-CIJipJXJ.mjs +2 -0
- package/dist/ru-CIJipJXJ.mjs.map +1 -0
- package/dist/schema.d.mts +280 -0
- package/dist/schema.d.mts.map +1 -0
- package/dist/schema.mjs +2 -0
- package/dist/schema.mjs.map +1 -0
- package/package.json +57 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin.mjs","names":["MAX_ENTITY_NAME_LENGTH","roots","prefixIssues","isPlainObject","record"],"sources":["../src/menu-item-schema.ts","../src/menu-entity-types.ts","../src/behaviors/menu-entity-types-validation.ts","../src/behaviors/menu-tree-validation.ts","../src/entity.ts","../src/internal/columns.ts","../src/menu-tree-normalize.ts","../src/admin/anchors.ts","../src/admin/summaries.ts","../src/admin/routes.ts"],"sourcesContent":["/**\n * `MenuItem` — the menu tree node — and its recursive Zod validation\n * (plan/routing phase 08 §3).\n *\n * A menu's `tree` column is ordered nested JSONB (D008), so the ONLY thing\n * standing between an admin PATCH and the database is this schema: it is the\n * boundary validation required by CLAUDE.md's C5. It enforces\n *\n * - the `kind`-conditional shape (`link` items carry a `LinkValue`, `header`\n * items carry a label map and no link),\n * - locale keys drawn from the app's configured locale set,\n * - `LinkValue`s that mirror `@murumets-ee/blocks`' union exactly, with `url`\n * kinds protocol-gated through the SAME `sanitizeHref` allowlist the link\n * picker uses (no `javascript:` / `data:` / protocol-relative smuggling),\n * - hard structural bounds on tree size + nesting, checked BEFORE the\n * recursive parse so a hostile payload can't turn validation itself into a\n * stack-overflow / CPU amplification vector.\n *\n * This module is deliberately **pure**: no `@murumets-ee/core`, no content\n * plugin lookup, no DB. The allowed-locale set is passed IN, so the schema can\n * be built server-side (the `menuTreeValidation()` behavior) or client-side\n * (PR10's editor, with locales handed down from the server) from the same code.\n * Cross-field depth validation against the entity's sibling `maxDepth` column\n * lives in {@link validateTreeDepth}, which the behavior calls separately.\n */\n\nimport { sanitizeHref } from '@murumets-ee/blocks/links'\nimport { z } from 'zod'\n\n/**\n * Absolute nesting cap for any menu, independent of a menu's own `maxDepth`\n * (phase 07 §2.7 — \"depth is capped per menu (default 3, max 6)\"). Also the\n * bound that keeps the recursive parse from being a DoS vector.\n */\nexport const MAX_MENU_DEPTH = 6\n\n/** `maxDepth`'s default — a 3-level menu (phase 08 §2). */\nexport const DEFAULT_MENU_DEPTH = 3\n\n/**\n * Upper bound on the number of nodes in one menu tree. Navigation menus are\n * hand-curated: a few dozen items is a big one. The cap exists so a single\n * PATCH can't hand the validator (and `extractRefs`' ref walk, and every\n * subsequent render) an unbounded amount of work — CLAUDE.md's\n * \"no unbounded fan-out\" rule applied to a single request payload.\n */\nexport const MAX_MENU_TREE_NODES = 500\n\n/**\n * Max stored length of a `url`-kind link. Pinned to `entity`'s\n * `MAX_EXTERNAL_URL_LENGTH` (the `external_link_usage.url_raw` column width)\n * at COMPILE time via `satisfies` + a type-only import — so a longer URL is\n * rejected at the boundary instead of being silently dropped from the\n * reference graph. Same drift-proofing trick as content's\n * `DEFAULT_LOCALE_SENTINEL`; `import type` has zero runtime cost, so the pure\n * schema module keeps its (client-safe) dependency-free shape.\n */\nexport const MAX_MENU_URL_LENGTH =\n 2048 satisfies typeof import('@murumets-ee/entity/refs')['MAX_EXTERNAL_URL_LENGTH']\n\n/** Max length of an item's stable id (a UUID today; generated client-side). */\nconst MAX_ITEM_ID_LENGTH = 64\n\n/** Max length of a per-locale label string. */\nconst MAX_LABEL_LENGTH = 200\n\n/**\n * Max length of an entity-link fragment/anchor.\n *\n * Exported because this module is the SINGLE OWNER of the anchor bounds: the\n * write-side schema below, the read-side anchor walk\n * (`admin/anchors.ts`) and the editor's manual-entry fallback\n * (`@murumets-ee/menus-ui`'s anchor picker, via the client-safe `./schema`\n * subpath) all validate against exactly these two constants. Two of those three\n * used to carry their own copy; a drift between them would let the picker offer\n * — or the editor accept — a value the write boundary then rejects.\n */\nexport const MAX_ANCHOR_LENGTH = 128\n\n/**\n * Max length of an entity NAME inside an entity link (`page`, `article`, …).\n *\n * Exported for the same single-owner reason as {@link MAX_ANCHOR_LENGTH}: the\n * per-menu `entityTypes` whitelist (`./menu-entity-types.ts`) stores values of\n * exactly this kind, and a second copy of the bound could drift into a\n * whitelist entry the link schema then refuses.\n */\nexport const MAX_ENTITY_NAME_LENGTH = 64\n\n/**\n * Anchors are slugified by the block editor (phase 08 §5) and rendered into an\n * `href` as `#<anchor>`; restricting them to slug characters keeps that\n * interpolation inert.\n *\n * Exported for the same single-owner reason as {@link MAX_ANCHOR_LENGTH}.\n */\nexport const ANCHOR_PATTERN = /^[A-Za-z0-9_-]+$/\n\n/**\n * A menu item's link target — the item-type union phase 07 §2.2 and phase 08 §3\n * define: `entity` (+ optional anchor) | `url` | `email`.\n *\n * **A NARROWED SUBSET of `@murumets-ee/blocks`' `LinkValue`, deliberately.**\n * Blocks additionally carries a `media` kind; menus does NOT, because no phase\n * gives a menu a media-href seam to resolve one with (phase 11 §2 specifies\n * `href` as \"canonical path from `toolkit_paths`; external URLs verbatim;\n * header items null\" and nothing else). Accepting a `media` link would let it\n * validate, persist, and register an `entity_refs` row — arming delete\n * protection for that file — while the renderer had no way to emit it: the item\n * would silently never appear. Rejecting it at the write boundary turns that\n * dead weight into a real 400. Widening later is additive (an arm here plus a\n * `resolveMediaHref` dep on `MenuResolveDeps`) and needs no shape change.\n *\n * Every `MenuLinkValue` IS a valid blocks `LinkValue` (the subset direction, and\n * the one `resolveLinkHref` needs) — pinned at compile time by an assignment in\n * `menu-item-schema.test.ts`, so a drift in blocks' union is a build error\n * rather than a silent divergence.\n *\n * The `anchor?: string | undefined` is forced by the combination of Zod and this\n * repo's `exactOptionalPropertyTypes`: `.optional()` always widens to\n * `T | undefined`, which is NOT assignable to blocks' `anchor?: string` under\n * that flag.\n */\nexport type MenuLinkValue =\n | { kind: 'entity'; entity: string; id: string; anchor?: string | undefined }\n | { kind: 'url'; url: string }\n | { kind: 'email'; email: string }\n\n/**\n * A menu tree node (phase 08 §3 / D009).\n *\n * `label: null` means \"use the LIVE title of the linked entity, per locale\";\n * a map is an explicit per-locale override. `enabled` is per-locale visibility\n * in BOTH directions — absent/`true` shows the item, `false` hides it in that\n * locale — so one tree serves every locale (no `menu_translations` table).\n *\n * Declared as a `type`, NOT an `interface`, on purpose: only type aliases get\n * TypeScript's implicit index signature, and without it `MenuItem[]` is not\n * assignable to the `Record<string, unknown>[]` arm of `AdminClient.create/\n * update`'s json-field value type — every caller storing a typed tree would\n * need a cast.\n */\nexport type MenuItem = {\n /** Stable id — DND identity + anchor for audit diffs. */\n id: string\n /** `header` = a text-only dropdown parent with no link of its own. */\n kind: 'link' | 'header'\n /**\n * Present iff `kind === 'link'`.\n *\n * The explicit `| undefined` is required by the repo's\n * `exactOptionalPropertyTypes` — Zod's `.optional()` emits\n * `LinkValue | undefined`, and without it the schema wouldn't typecheck\n * against this interface.\n */\n link?: MenuLinkValue | undefined\n /** `null` = live entity title per locale; a map = explicit override. */\n label: Record<string, string> | null\n /** Per-locale visibility. Absent/`true` = visible; `false` = hidden. */\n enabled?: Record<string, boolean> | undefined\n newTab?: boolean | undefined\n children: MenuItem[]\n}\n\n/**\n * One menu item's target, as the editor needs to render its row\n * (`@murumets-ee/menus/admin`'s `resolveMenuItemSummaries`).\n *\n * Declared here — not in `admin/summaries.ts` — for the same reason\n * {@link BlockAnchor} is: it is a pure data shape with no server dependency,\n * so the client-safe `./schema` subpath can re-export it without pulling the\n * server-only resolver along. `import type` is erased at compile time either\n * way, but a single canonical home (this leaf module) is simpler to reason\n * about than \"which of two server-only files owns the type\".\n */\nexport interface MenuItemSummary {\n /**\n * The target row's live title, or `null` when the entity declares no\n * display-able field. Unlike the public render path — which DROPS such an\n * item rather than show a raw uuid as navigation — the admin editor still\n * reports the target as present, just unlabelled.\n */\n title: string | null\n /** `false` = the target row is gone (deleted) → a BROKEN item. */\n exists: boolean\n /** `null` when the entity isn't publishable — no draft/published distinction applies. */\n published: boolean | null\n}\n\n/**\n * One addressable block anchor on a document (`@murumets-ee/menus/admin`'s\n * `collectBlockAnchors` / the anchors endpoint). See {@link MenuItemSummary}'s\n * doc for why this pure shape lives here rather than in `admin/anchors.ts`.\n */\nexport interface BlockAnchor {\n /** The stable id rendered as the block element's `id` attribute. */\n anchor: string\n /** Display text — `anchorLabel` when usable, else the anchor value itself. */\n label: string\n}\n\n/** Options for {@link buildMenuItemSchema} / {@link buildMenuTreeSchema}. */\nexport interface MenuSchemaOptions {\n /**\n * Locale codes accepted as keys in `label` / `enabled` maps. Anything else\n * is a validation error — a typo'd locale would produce a label no renderer\n * ever reads (the same failure mode content's `validateLocale` guards).\n */\n allowedLocales: readonly string[]\n}\n\n/**\n * {@link MenuLinkValue} as Zod. Blocks models its own `LinkValue` as a plain TS\n * type + structural guards (zod-free by design), so this is the first Zod\n * encoding of it — narrowed to the three kinds a menu item may carry.\n *\n * There is deliberately NO `media` arm (see {@link MenuLinkValue}): a media\n * link has no href seam at render time, so accepting one would store a\n * permanently invisible item. `z.discriminatedUnion` rejects it with an\n * `Invalid discriminator value. Expected 'entity' | 'url' | 'email'` issue —\n * which names the allowed set without echoing the caller's own value back.\n *\n * `url` kinds run through `sanitizeHref`, the single source of truth for the\n * href protocol allowlist (`http:`/`https:`/`mailto:`/`tel:` + relative\n * paths); it returns `''` for anything it rejects, including\n * protocol-relative `//evil.com` and obfuscated `java\\nscript:`.\n */\nexport const LinkValueSchema = z.discriminatedUnion('kind', [\n z.object({\n kind: z.literal('entity'),\n entity: z.string().min(1).max(MAX_ENTITY_NAME_LENGTH),\n id: z.string().uuid(),\n anchor: z.string().min(1).max(MAX_ANCHOR_LENGTH).regex(ANCHOR_PATTERN).optional(),\n }),\n z.object({\n kind: z.literal('url'),\n // Transform-then-refine, NOT refine-alone: `sanitizeHref` trims, so\n // validating its output while storing the raw input would let a value\n // that PASSED validation differ from the value that gets STORED (leading\n // /trailing whitespace). Storing the sanitizer's own return value keeps\n // stored == validated. The arrow wrapper is required — `sanitizeHref`'s\n // second parameter is the protocol allowlist, and passing Zod's ctx into\n // it would silently replace the allowlist.\n url: z\n .string()\n .min(1)\n .max(MAX_MENU_URL_LENGTH)\n .transform((value) => sanitizeHref(value))\n .refine((value) => value !== '', {\n message:\n 'link url uses a disallowed protocol (allowed: http, https, mailto, tel, or a relative path)',\n }),\n }),\n z.object({\n kind: z.literal('email'),\n email: z.string().email().max(254),\n }),\n])\n\n/**\n * A `Record<locale, T>` whose keys must all be configured locale codes.\n *\n * The key check is PIPED INTO the record parse rather than refined on top of\n * it, so it runs against the RAW value BEFORE any per-value parsing. That\n * ordering is the whole point: with `z.record(...).superRefine(...)`, zod\n * parses all N values first, so a 1,000,000-key map of wrong-typed values\n * emits 1,000,000 `invalid_type` issues before a width check on top could ever\n * fire (verified empirically against zod 3.25). Piping caps BOTH shapes —\n * unknown keys and bad values — at a single issue.\n */\nfunction localeMap<T extends z.ZodTypeAny>(\n valueSchema: T,\n allowedLocales: readonly string[],\n): z.ZodType<Record<string, z.infer<T>>, z.ZodTypeDef, unknown> {\n const allowed = new Set(allowedLocales)\n return z\n .unknown()\n .superRefine((value, ctx) => {\n // Not a plain object — let the record parse below emit the single\n // \"expected object\" issue rather than duplicating its message here.\n if (value === null || typeof value !== 'object' || Array.isArray(value)) return\n\n const keys = Object.keys(value)\n // Width bound, checked FIRST. A legitimate map holds at most one entry\n // per configured locale, so a wider map is malformed by construction and\n // gets ONE issue for the whole map. Without this, a single node carrying\n // e.g. 1,000,000 keys sails past the tree pre-scan (which bounds node\n // COUNT and DEPTH, never map WIDTH) and turns validation itself into the\n // amplification vector it exists to prevent: a multi-hundred-MB issue\n // array plus an oversized audit-log write, from one request.\n // CLAUDE.md's \"no unbounded fan-out\" applied to a single payload.\n if (keys.length > allowed.size) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `too many locale keys — configured locales are: ${[...allowed].join(', ')}`,\n })\n return\n }\n\n // Past the width bound this loop is bounded by the CONFIGURED locale\n // count, never by the payload.\n for (const key of keys) {\n if (!allowed.has(key)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [key],\n // Names the allowed set (server-side data), never echoes the\n // caller-supplied key into the message — see the api-handler's\n // \"refine()/custom messages must not echo user input\" contract.\n message: `unknown locale key — configured locales are: ${[...allowed].join(', ')}`,\n })\n }\n }\n })\n .pipe(z.record(z.string(), valueSchema))\n}\n\n/**\n * The recursive per-node schema. Input is `unknown` (this parses untrusted\n * JSONB), output is a fully-narrowed {@link MenuItem}. Unknown properties are\n * STRIPPED by Zod's default object behavior, so writing the parse result back\n * onto the payload also sanitizes it — nothing a caller invents gets stored.\n */\nexport function buildMenuItemSchema(\n options: MenuSchemaOptions,\n): z.ZodType<MenuItem, z.ZodTypeDef, unknown> {\n const labelMap = localeMap(z.string().max(MAX_LABEL_LENGTH), options.allowedLocales)\n const enabledMap = localeMap(z.boolean(), options.allowedLocales)\n\n const schema: z.ZodType<MenuItem, z.ZodTypeDef, unknown> = z.lazy(() =>\n z\n .object({\n id: z.string().min(1).max(MAX_ITEM_ID_LENGTH),\n kind: z.enum(['link', 'header']),\n link: LinkValueSchema.optional(),\n label: labelMap.nullable().default(null),\n enabled: enabledMap.optional(),\n newTab: z.boolean().optional(),\n children: z.array(schema).default([]),\n })\n .superRefine((node, ctx) => {\n if (node.kind === 'link') {\n if (node.link === undefined) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['link'],\n message: 'a link item requires a link target',\n })\n }\n return\n }\n // header\n if (node.link !== undefined) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['link'],\n message: 'a header item must not carry a link target',\n })\n }\n // A header has no entity to borrow a live title from, so its label\n // map is required (phase 08 §3) — otherwise it renders as nothing.\n if (node.label === null || Object.keys(node.label).length === 0) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['label'],\n message: 'a header item requires a label for at least one locale',\n })\n }\n }),\n )\n\n return schema\n}\n\n/** The whole tree — an array of {@link buildMenuItemSchema} nodes. */\nexport function buildMenuTreeSchema(\n options: MenuSchemaOptions,\n): z.ZodType<MenuItem[], z.ZodTypeDef, unknown> {\n return z.array(buildMenuItemSchema(options))\n}\n\n/** Result of {@link measureMenuTree}. */\nexport interface MenuTreeMeasurement {\n /** Deepest nesting level; `0` for an empty tree, `1` for flat items. */\n depth: number\n /** Total node count across every level. */\n nodeCount: number\n}\n\n/**\n * Measure a (possibly untrusted) tree's depth + node count **iteratively**, so\n * measuring can never blow the stack the way a recursive walk over a hostile\n * payload would. Stops early once either hard bound is exceeded and reports\n * the over-limit value — the caller turns that into a validation error.\n *\n * Accepts `unknown` on purpose: this runs BEFORE the Zod parse (bounding the\n * work that parse will do), so it can't assume a well-formed shape.\n */\nexport function measureMenuTree(tree: unknown): MenuTreeMeasurement {\n if (!Array.isArray(tree)) return { depth: 0, nodeCount: 0 }\n\n let maxDepth = 0\n let nodeCount = 0\n // `Array.isArray` narrows an `unknown` to `any[]`; re-binding through a\n // `readonly unknown[]` annotation keeps every element typed as `unknown`\n // instead of silently leaking `any` into the walk.\n const roots: readonly unknown[] = tree\n const stack: Array<{ node: unknown; depth: number }> = roots.map((node) => ({ node, depth: 1 }))\n\n while (stack.length > 0) {\n const current = stack.pop()\n if (current === undefined) break\n const { node, depth } = current\n if (node === null || typeof node !== 'object' || Array.isArray(node)) continue\n\n nodeCount++\n if (depth > maxDepth) maxDepth = depth\n // Early abort: past either bound the exact totals no longer matter, only\n // that they're over — and continuing would be the amplification we're\n // guarding against.\n if (nodeCount > MAX_MENU_TREE_NODES || maxDepth > MAX_MENU_DEPTH) {\n return { depth: maxDepth, nodeCount }\n }\n\n const children = (node as { children?: unknown }).children\n if (Array.isArray(children)) {\n const items: readonly unknown[] = children\n for (const child of items) stack.push({ node: child, depth: depth + 1 })\n }\n }\n\n return { depth: maxDepth, nodeCount }\n}\n\n/**\n * Cross-field check: does `tree` nest no deeper than the menu's own\n * `maxDepth`? Lives outside the node schema because a per-node Zod refinement\n * has no visibility of the sibling `maxDepth` COLUMN — the behavior calls this\n * after the recursive parse.\n */\nexport function validateTreeDepth(tree: unknown, maxDepth: number): boolean {\n return measureMenuTree(tree).depth <= maxDepth\n}\n","/**\n * The per-menu `entityTypes` whitelist — \"which content types may be linked\n * from this menu\" (plan/routing phase 07 §2.8, 09 §3, 10 §4).\n *\n * Drupal's per-content-type menu offering, minus the config-entity ceremony: a\n * `page` doesn't declare \"I belong in menus\", each MENU declares which types it\n * accepts. Two surfaces consume it — the menu editor's \"Add link\" picker (which\n * content types its Content tab offers) and the Meta panel's Menus section\n * (which menus an entity's edit screen offers to join).\n *\n * ## Semantics — `null` is \"everything\", and it is the ONLY unrestricted shape\n *\n * `null` (or an absent column) = the menu accepts every entity type. That is the\n * default, and it is what every menu row written before this column existed\n * reads back as, so the whitelist is backward-compatible by construction.\n *\n * An EMPTY array means the same thing, and {@link MenuEntityTypesSchema}\n * normalises it to `null` on the way in — the editor's \"nothing selected\" state\n * naturally produces `[]`, and letting both spellings persist would mean every\n * reader had to remember to check for two. Readers still tolerate a stored `[]`\n * (a hand-edited row, an older dump) via {@link menuAcceptsEntityType}.\n *\n * A menu that accepts NOTHING is deliberately unrepresentable: it could only\n * ever be a mistake, and \"empty selection\" is far more likely to mean \"I haven't\n * narrowed this yet\" than \"refuse everything\".\n *\n * ## Not a security boundary\n *\n * This is an authoring affordance. It decides what a picker OFFERS; it does not\n * decide what a caller may write — `menu:update` on the entity PATCH does, and\n * `menuTreeValidation()` re-checks the tree's shape regardless of the whitelist.\n * So the read path fails OPEN (a malformed column means \"no restriction\"): a\n * junk value must not hide every menu from the admin who needs to fix it. The\n * WRITE path fails closed, as every boundary does.\n *\n * @module\n */\n\nimport { z } from 'zod'\nimport { MAX_ENTITY_NAME_LENGTH } from './menu-item-schema.js'\n\n/**\n * Upper bound on how many entity types one menu's whitelist may name.\n *\n * A real app registers a few dozen entities, so this is a malformed-payload\n * guard rather than a product limit — it keeps a hostile PATCH from parking an\n * unbounded array in the column (and, through it, in every editor's RSC\n * payload).\n *\n * **Not** `MAX_MENU_ENTITY_TYPES` (`./get-menu.ts`, 16). That one bounds RENDER\n * fan-out — how many distinct types one menu render may issue batched lookups\n * for — and applies to the types actually present in the stored tree. This one\n * bounds the stored AUTHORING whitelist, which costs no I/O at all.\n */\nexport const MAX_MENU_ENTITY_TYPE_WHITELIST = 64\n\n/** One entity name in the whitelist — same bound `link.entity` carries. */\nconst EntityTypeName = z.string().min(1).max(MAX_ENTITY_NAME_LENGTH)\n\n/**\n * The write-boundary schema for `menu.entityTypes`.\n *\n * Strict where {@link normalizeMenuEntityTypes} is tolerant: a junk entry is a\n * 400, not a silent drop, because silently discarding half an admin's selection\n * and reporting success is worse than refusing the write. Duplicates ARE folded\n * (an idempotent selection is not an error), and `[]` becomes `null` so the\n * column only ever holds one representation of \"unrestricted\".\n */\nexport const MenuEntityTypesSchema: z.ZodType<string[] | null, z.ZodTypeDef, unknown> = z\n .array(EntityTypeName)\n // DELIBERATE ORDER — `.max()` runs on the RAW array, BEFORE the dedup in the\n // transform below, so 65 copies of `'page'` is a 400 even though it would\n // store one entry. The bound is on the PAYLOAD, not on the result: deduping\n // first would mean accepting an unbounded array to prove it was small.\n .max(MAX_MENU_ENTITY_TYPE_WHITELIST)\n .nullable()\n .transform((value) => {\n if (value === null) return null\n const deduped = [...new Set(value)]\n return deduped.length === 0 ? null : deduped\n })\n\n/**\n * Narrow a raw `entityTypes` column value (untrusted shape) to a usable\n * whitelist, or `null` for \"no restriction\".\n *\n * The read-side counterpart of {@link MenuEntityTypesSchema}, and tolerant for\n * the same reason `normalizeMenuTree` is: rows written before this column\n * existed, hand edits and restored dumps all reach readers, and a render must\n * degrade rather than throw. Anything unusable — a non-array, a non-string\n * entry, a whitelist that reduces to nothing — reads as `null`.\n */\nexport function normalizeMenuEntityTypes(value: unknown): string[] | null {\n if (!Array.isArray(value)) return null\n // `Array.isArray` narrows `unknown` to `any[]`; re-binding through a\n // `readonly unknown[]` annotation keeps every element typed as `unknown`.\n const raw: readonly unknown[] = value\n\n const out: string[] = []\n const seen = new Set<string>()\n for (const entry of raw) {\n if (typeof entry !== 'string' || entry.length === 0) continue\n if (entry.length > MAX_ENTITY_NAME_LENGTH) continue\n if (seen.has(entry)) continue\n seen.add(entry)\n out.push(entry)\n if (out.length === MAX_MENU_ENTITY_TYPE_WHITELIST) break\n }\n return out.length === 0 ? null : out\n}\n\n/**\n * Does this menu accept links to `entityType`?\n *\n * Takes the RAW column value so callers holding a `field.json()`-typed row can\n * ask directly, without narrowing first. Exact string match — entity names are\n * lowercase identifiers, and a case-insensitive or prefix match would silently\n * widen a whitelist the admin wrote precisely.\n *\n * Fails OPEN (see the module header): absent, empty and malformed all mean \"no\n * restriction\".\n */\nexport function menuAcceptsEntityType(value: unknown, entityType: string): boolean {\n const whitelist = normalizeMenuEntityTypes(value)\n return whitelist === null || whitelist.includes(entityType)\n}\n","/**\n * `menuEntityTypesValidation()` — boundary validation for the\n * `menu.entityTypes` JSONB column (plan/routing phase 07 §2.8, F056).\n *\n * `field.json()`'s own validation is the deliberately permissive JSON union, so\n * without this hook an admin PATCH could park any shape — including an unbounded\n * array — in the column, and every reader (editor picker, Meta panel, every RSC\n * payload that carries the row) would pay for it. CLAUDE.md's \"validate at the\n * boundary\" applied to the one column `menuTreeValidation()` doesn't cover.\n *\n * **Why a second behavior rather than a branch inside `menuTreeValidation()`.**\n * The two share nothing: the tree check is CROSS-FIELD (it reads the sibling\n * `maxDepth` column through `loadCurrent`) and CROSS-MODULE (locale keys against\n * the content plugin's registry, hence its injectable resolver option); this one\n * is self-contained and needs no context at all. Folding it in would put an\n * option-less concern behind `MenuTreeValidationOptions` and make a behavior\n * named \"tree validation\" own a non-tree column.\n *\n * Errors are `ZodError`s so the admin API maps them to **400 with structured\n * issues** rather than a 500 — same contract `menuTreeValidation()` relies on.\n * Issues are pathed at `entityTypes` so the client can point at the field.\n */\n\nimport type { Behavior, BulkUpdateInfo } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { MenuEntityTypesSchema } from '../menu-entity-types.js'\n\n/**\n * `AdminClient.updateMany` runs NO per-row hooks, so `beforeUpdate` above never\n * fires on that path — a bulk statement could otherwise park an unbounded or\n * malformed array in the column and every reader would pay for it, which is the\n * exact hole this behavior exists to close. `assertBulkUpdate` is the sanctioned\n * cover for that gap (same mechanism `hierarchical()` uses to stop a partial\n * bulk tree write).\n *\n * This REJECTS rather than validates: `updateMany` sets one value across every\n * matched row, and \"every menu accepts exactly these types\" is not a coherent\n * intent — a whitelist is per-menu by definition. There is no legitimate bulk\n * caller to serve, so refusing is both safer and more honest than silently\n * validating a shape nobody should be writing this way.\n */\nfunction assertNoBulkEntityTypesWrite(info: BulkUpdateInfo): void {\n if (!info.keys.includes('entityTypes')) return\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['entityTypes'],\n message:\n `bulk update on '${info.entityName}' cannot write 'entityTypes': updateMany runs no ` +\n 'per-row hooks, so the whitelist would bypass its schema check. A whitelist is ' +\n 'per-menu — update each row individually, or write a migration for a one-off backfill.',\n },\n ])\n}\n\n/** Prefix every issue path with `entityTypes` so the client can locate it. */\nfunction prefixIssues(error: z.ZodError): z.ZodError {\n return new z.ZodError(\n error.issues.map((issue) => ({ ...issue, path: ['entityTypes', ...issue.path] })),\n )\n}\n\n/**\n * Validate + normalize `data.entityTypes` in place, returning the payload with\n * the PARSED value (so `[]` is persisted as `null` and duplicates are folded —\n * see `../menu-entity-types.ts` for why there is exactly one unrestricted\n * shape).\n *\n * A payload without the key is a partial update that doesn't touch the column;\n * it passes through untouched rather than being defaulted, which would let a\n * `{ tree }`-only PATCH silently clear an authored whitelist.\n */\nfunction validateEntityTypes(data: Record<string, unknown>): Record<string, unknown> {\n if (!('entityTypes' in data)) return data\n\n const parsed = MenuEntityTypesSchema.safeParse(data.entityTypes)\n if (!parsed.success) throw prefixIssues(parsed.error)\n return { ...data, entityTypes: parsed.data }\n}\n\n/**\n * Recursive-free Zod validation of `menu.entityTypes` on every create/update.\n *\n * Contributes NO fields, so the field-map type param is the EMPTY map\n * `Record<never, never>` — NOT `Record<string, never>`, which would merge into\n * the entity's field map and collapse every field to `never`.\n */\nexport function menuEntityTypesValidation(): Behavior<\n Record<never, never>,\n 'menuEntityTypesValidation'\n> {\n return {\n name: 'menuEntityTypesValidation',\n hooks: {\n beforeCreate: async (data) => validateEntityTypes(data),\n beforeUpdate: async (_id, data) => validateEntityTypes(data),\n },\n assertBulkUpdate: assertNoBulkEntityTypesWrite,\n }\n}\n","/**\n * `menuTreeValidation()` — boundary validation for the `menu.tree` JSONB column\n * (plan/routing phase 08 §3, F040 pt.2).\n *\n * Why a behavior and not field-local validation: two of the four rules are\n * CROSS-FIELD or CROSS-MODULE. The tree's nesting depth is checked against the\n * sibling `maxDepth` COLUMN, and the locale keys are checked against the app's\n * configured content locales — neither is visible to `field.json()`'s\n * (deliberately permissive) JSON union. `BehaviorContext` gives a hook both:\n * the update payload and, via `loadCurrent`, the pre-update row.\n *\n * Errors are raised as `ZodError`s on purpose: the admin API handler maps\n * `ZodError` to **400 with structured issues** (a plain `Error` would surface\n * as a 500), so an editor saving a malformed tree gets a per-node message\n * pointing at the offending item instead of \"Internal server error\".\n */\n\nimport { getContentConfig } from '@murumets-ee/content/plugin'\nimport type { Behavior, BehaviorContext } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport {\n buildMenuTreeSchema,\n DEFAULT_MENU_DEPTH,\n MAX_MENU_DEPTH,\n MAX_MENU_TREE_NODES,\n measureMenuTree,\n} from '../menu-item-schema.js'\n\n/** Options for {@link menuTreeValidation}. */\nexport interface MenuTreeValidationOptions {\n /**\n * Resolve the locale codes accepted as `label` / `enabled` keys.\n *\n * Defaults to the `content()` plugin's configured locales, read LAZILY (per\n * write, never at module scope) so the behavior can be constructed before\n * the app boots. Injectable so the behavior is unit-testable without a\n * booted content plugin.\n */\n resolveAllowedLocales?: () => readonly string[]\n}\n\n/** One-issue `ZodError` at `tree`(`.path`) — the 400-mapped failure shape. */\nfunction treeError(message: string, path: Array<string | number> = []): z.ZodError {\n return new z.ZodError([{ code: z.ZodIssueCode.custom, path: ['tree', ...path], message }])\n}\n\n/** Prefix every issue path with `tree` so the client can locate the node. */\nfunction prefixIssues(error: z.ZodError): z.ZodError {\n return new z.ZodError(error.issues.map((issue) => ({ ...issue, path: ['tree', ...issue.path] })))\n}\n\n/** A `maxDepth` value from an (unvalidated, pre-field-validation) payload. */\nfunction readMaxDepth(value: unknown): number | undefined {\n if (typeof value !== 'number' || !Number.isInteger(value)) return undefined\n if (value < 1 || value > MAX_MENU_DEPTH) return undefined\n return value\n}\n\n/**\n * The effective `maxDepth` for this write.\n *\n * Hooks run BEFORE field validation, so a payload's `maxDepth` may be absent\n * or junk; junk falls through to the persisted/default value and is rejected a\n * moment later by the field's own `min`/`max`. On a partial update that\n * touches only `tree`, the persisted value comes from `loadCurrent()` (the\n * eagerly-loaded pre-update snapshot).\n */\nasync function resolveMaxDepth(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n isUpdate: boolean,\n): Promise<number> {\n const fromPayload = readMaxDepth(data.maxDepth)\n if (fromPayload !== undefined) return fromPayload\n if (!isUpdate) return DEFAULT_MENU_DEPTH\n\n if (!ctx?.loadCurrent) {\n // Fail loud rather than silently skipping the cross-field check — an\n // unvalidated depth is a correctness hole. AdminClient always wires\n // `loadCurrent` on the update codepath, so this only trips on a\n // mis-wired / direct hook invocation. (Same posture as `pathParent`.)\n throw new Error(\n 'menu: BehaviorContext.loadCurrent is unavailable — cannot validate the tree ' +\n \"against the menu's maxDepth. Writes must go through AdminClient.update.\",\n )\n }\n const current = await ctx.loadCurrent()\n // A vanished/unreadable row can't supply its own bound, so fall back to the\n // DEFAULT (3) — the same value a create would get — not the absolute cap\n // (6). Falling back to the ceiling would make an edge case the single most\n // PERMISSIVE path in the behavior; the safe fallback is the floor. The\n // absolute cap still applies separately, and the update itself fails\n // downstream when the row is genuinely gone.\n return readMaxDepth(current?.maxDepth) ?? DEFAULT_MENU_DEPTH\n}\n\n/**\n * The REVERSE cross-field check: an update that narrows `maxDepth` without\n * touching `tree`.\n *\n * `{\"maxDepth\": 1}` on a menu whose stored tree is 5 levels deep used to skip\n * validation entirely (the payload has no `tree`, so there was nothing to\n * measure) — the write succeeded and the row then PERMANENTLY violated\n * \"tree depth <= maxDepth\" until someone happened to edit the tree again.\n * Both directions of the invariant have to be enforced, so when `maxDepth`\n * moves we measure the PERSISTED tree against the new bound.\n */\nasync function validateStoredTreeAgainstNewMaxDepth(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n): Promise<void> {\n // Absent (or junk — the field's own min/max rejects that a moment later)\n // means this write doesn't move the bound, so there's nothing to re-check.\n const maxDepth = readMaxDepth(data.maxDepth)\n if (maxDepth === undefined) return\n\n if (!ctx?.loadCurrent) {\n // Same fail-loud posture as `resolveMaxDepth` — silently skipping the\n // check is precisely the hole this function closes.\n throw new Error(\n 'menu: BehaviorContext.loadCurrent is unavailable — cannot validate the stored tree ' +\n 'against the new maxDepth. Writes must go through AdminClient.update.',\n )\n }\n\n const current = await ctx.loadCurrent()\n // Measured iteratively and on a value already bounded at write time, so this\n // is cheap even for the largest legal tree.\n const { depth } = measureMenuTree(current?.tree)\n if (depth > maxDepth) {\n throw treeError(\n `the stored menu tree is nested ${depth} levels deep, deeper than this menu's ` +\n `new maxDepth (${maxDepth}) — flatten the tree first`,\n )\n }\n}\n\n/**\n * Validate + normalize `data.tree` in place. Returns the payload with `tree`\n * replaced by the PARSED value, so unknown properties a caller invented are\n * stripped before they reach the column.\n */\nasync function validateTree(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n isUpdate: boolean,\n resolveAllowedLocales: () => readonly string[],\n): Promise<Record<string, unknown>> {\n const tree = data.tree\n if (tree === undefined) {\n // No tree in the payload — but a `maxDepth`-only update still has to be\n // checked against the tree already in the column.\n if (isUpdate) await validateStoredTreeAgainstNewMaxDepth(data, ctx)\n return data\n }\n if (tree === null || !Array.isArray(tree)) {\n throw treeError('menu tree must be an array of menu items')\n }\n\n // Structural bounds FIRST — this walk is iterative, so it holds even for a\n // payload crafted to blow the recursive parse's stack.\n const { depth, nodeCount } = measureMenuTree(tree)\n if (nodeCount > MAX_MENU_TREE_NODES) {\n throw treeError(`menu tree has too many items (max ${MAX_MENU_TREE_NODES})`)\n }\n if (depth > MAX_MENU_DEPTH) {\n throw treeError(`menu tree is nested too deeply (absolute max ${MAX_MENU_DEPTH} levels)`)\n }\n\n const parsed = buildMenuTreeSchema({ allowedLocales: resolveAllowedLocales() }).safeParse(tree)\n if (!parsed.success) throw prefixIssues(parsed.error)\n\n const maxDepth = await resolveMaxDepth(data, ctx, isUpdate)\n if (depth > maxDepth) {\n throw treeError(`menu tree is nested deeper than this menu's maxDepth (${maxDepth})`)\n }\n\n return { ...data, tree: parsed.data }\n}\n\n/**\n * Recursive Zod validation of `menu.tree` on every create/update.\n *\n * Contributes NO fields, so the field-map type param is the EMPTY map\n * `Record<never, never>` — NOT `Record<string, never>`, which would merge into\n * the entity's field map and collapse every field to `never` (a break that\n * only surfaces through the built `.d.mts`).\n */\nexport function menuTreeValidation(\n options?: MenuTreeValidationOptions,\n): Behavior<Record<never, never>, 'menuTreeValidation'> {\n const resolveAllowedLocales =\n options?.resolveAllowedLocales ?? (() => getContentConfig().locales.map((l) => l.code))\n\n return {\n name: 'menuTreeValidation',\n hooks: {\n beforeCreate: async (data, ctx) => validateTree(data, ctx, false, resolveAllowedLocales),\n beforeUpdate: async (_id, data, ctx) => validateTree(data, ctx, true, resolveAllowedLocales),\n },\n }\n}\n","/**\n * The `menu` entity (plan/routing phase 08 §2, D008/D009).\n *\n * A menu is a normal entity whose item tree is ONE ordered nested JSONB column\n * — the family WordPress migrated to and Statamic uses, and the same storage\n * philosophy as our blocks layouts. Whole-tree edits save atomically in one\n * PATCH, and `publishable()` gives draft-then-publish menu editing for free.\n *\n * Locale variance lives INSIDE the tree (per-item `label` / `enabled` maps),\n * so there is deliberately no `translatable` field and no `menu_translations`\n * table — one structure serves every locale (D009).\n */\n\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { menuEntityTypesValidation } from './behaviors/menu-entity-types-validation.js'\nimport { menuTreeValidation } from './behaviors/menu-tree-validation.js'\nimport { DEFAULT_MENU_DEPTH, MAX_MENU_DEPTH } from './menu-item-schema.js'\n\nexport const Menu = defineEntity({\n name: 'menu',\n admin: {\n label: 'Menus',\n icon: 'menu',\n },\n fields: {\n // Fetch key — 'main', 'footer' (phase 07 §2.8). `from: 'title'` is the\n // admin form's client-side auto-fill source (SlugField watches it); the\n // value itself is author-chosen and stable, so `sluggable()` (which would\n // REGENERATE the handle when the title changes) is deliberately not used.\n // `field.slug()` already sets `unique` + `indexed` — no need to repeat them.\n handle: field.slug({ from: 'title' }),\n title: field.text({ required: true }),\n // 1..6 — the renderer clamps too (phase 07 §2.7).\n maxDepth: field.number({\n default: DEFAULT_MENU_DEPTH,\n min: 1,\n max: MAX_MENU_DEPTH,\n integer: true,\n }),\n // MenuItem[] — shape validated by `menuTreeValidation()`; `refScan` makes\n // every LinkValue in the tree register in `entity_refs` /\n // `external_link_usage` on save, so a menu item pointing at a page shows\n // up in delete-protection warnings and the broken-links dashboard\n // (phase 08 §4) with no menus-side ref code at all.\n tree: field.json({ refScan: true, default: [] }),\n // Which content types may be linked FROM this menu (phase 07 §2.8) — the\n // menu editor's picker and the Meta panel's Menus section both read it.\n // `null` = accepts every type, which is what every row written before this\n // column existed reads back as, so the whitelist is backward-compatible by\n // construction. Shape validated by `menuEntityTypesValidation()`; semantics\n // and the read-side predicate live in `./menu-entity-types.ts`.\n //\n // No `refScan`: the value holds entity NAMES, not `LinkValue`s — there is\n // no row to reference, so a scan would find nothing.\n entityTypes: field.json({ default: null }),\n },\n behaviors: [auditable(), publishable(), menuTreeValidation(), menuEntityTypesValidation()],\n scope: 'global',\n // Advisory metadata (the firewall is deny-by-default + operator grants).\n // Menus are site-wide navigation: a bad edit is visible on EVERY page, and\n // the tree is a structural artifact rather than content — so writes are\n // admin-only, while `view` is public because the frontend reads menus\n // unauthenticated through the content API.\n access: {\n view: 'public',\n create: 'group.admin',\n update: 'group.admin',\n delete: 'group.admin',\n },\n})\n","/**\n * Typed column access for a dynamically-generated entity table.\n *\n * `AdminClient.getTable()` / `QueryClient.getTable()` both return\n * `PgTableWithColumns<any>` (Drizzle cannot type columns that are generated\n * from a runtime entity definition), so reading a column off one directly\n * yields `any` and silently erodes this package's type coverage. Narrowing\n * through this helper pins the ONE column being filtered on — the same idiom\n * `@murumets-ee/content`'s `buildEnumerateDeps` uses.\n *\n * Internal to `@murumets-ee/menus`; not part of any public entry.\n */\n\nimport type { PgColumn, PgTableWithColumns } from 'drizzle-orm/pg-core'\n\n/** Read one named column off a runtime-generated entity table, typed. */\nexport function column<K extends string>(\n // biome-ignore lint/suspicious/noExplicitAny: Drizzle cannot type dynamically-generated column schemas — `PgTableWithColumns<any>` is `getTable()`'s own declared return type (CLAUDE.md's sanctioned case).\n table: PgTableWithColumns<any>,\n name: K,\n): PgColumn {\n return (table as unknown as Record<K, PgColumn>)[name]\n}\n","/**\n * Read-side narrowing for a stored `menu.tree` column, plus the entity-ref\n * collection the editor page needs for `resolveMenuItemSummaries()`.\n *\n * **Why this exists.** `field.json()` types its column as `JsonValue`, so the\n * row `fetchEntityById(Menu, id)` returns hands us an untyped blob. Casting it\n * straight to `MenuItem[]` would need an `as unknown as` (CLAUDE.md: don't) and\n * would also be a lie — the column is only *usually* well-formed. Rows written\n * before `menuTreeValidation()` existed, hand-edited rows, and rows restored\n * from an older dump can all violate the shape, and the editor is exactly the\n * tool you reach for when they do. So this walks the raw value ONCE and returns\n * a properly-typed tree.\n *\n * **Deliberately tolerant, and deliberately NOT `getMenu`.** `getMenu` is the\n * public render path: it also applies the per-locale `enabled` filter, the\n * publish gate and the per-menu `maxDepth` clamp, because a visitor must not\n * see a hidden or unpublished item. The editor is the opposite case — it must\n * show *everything* that is stored, including items hidden in the current\n * locale and items nested deeper than the menu's own `maxDepth` (that is the\n * state you opened the editor to fix). The only things dropped here are nodes\n * whose shape cannot be represented as a `MenuItem` at all.\n *\n * **Bounded** (CLAUDE.md \"no unbounded fan-out\"): at most\n * {@link MAX_MENU_TREE_NODES} nodes are materialised and recursion stops at\n * {@link MAX_MENU_DEPTH}, regardless of what the column contains — so a\n * hand-edited row can't turn a page render into unbounded work or a stack\n * overflow. Whatever is refused is COUNTED and returned, never silently\n * swallowed: the caller logs it, and the editor surfaces it before a save\n * would persist the removal.\n *\n * **Why it lives in `@murumets-ee/menus`.** It was a `@murumets-ee/menus-ui`\n * internal until PR12, when the Meta panel's Menus section (`admin-ui`) needed\n * the same narrowing to turn a `menu` row's `tree` column into a `MenuItem[]`.\n * `menus-ui` depends on `admin-ui`, so exporting it from there would close an\n * `admin-ui → menus-ui → admin-ui` import cycle — same reasoning (and same\n * move) as `./menu-tree-ops.ts`.\n *\n * It pulls `LinkValueSchema` — and therefore zod — and nothing else, so it is\n * client-safe and rides the `./schema` subpath alongside the tree operations.\n *\n * @module\n */\n\nimport {\n LinkValueSchema,\n MAX_MENU_DEPTH,\n MAX_MENU_TREE_NODES,\n type MenuItem,\n type MenuLinkValue,\n} from './menu-item-schema.js'\n\n/** One entity target a menu item points at — the `resolveMenuItemSummaries` input shape. */\nexport interface MenuEntityRef {\n entity: string\n id: string\n}\n\n/** Result of {@link normalizeMenuTree}. */\nexport interface NormalizedMenuTree {\n /** The stored tree, narrowed to `MenuItem[]`. */\n items: MenuItem[]\n /**\n * How many stored nodes were REFUSED — malformed shape, or past one of the\n * hard bounds. An entire refused subtree counts as ONE (we stop at the\n * refusal point rather than descending into it to count more).\n *\n * Non-zero means a whole-tree save would persist those removals, so the\n * editor must warn before writing.\n */\n dropped: number\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n}\n\n/**\n * Keep only the entries whose value passes `predicate`. Locale KEYS are not\n * checked against the app's configured locale set on purpose: the set can\n * change after a tree is written, and dropping a label for a locale that was\n * just disabled would destroy authored text on the next save. An out-of-set key\n * is instead rejected loudly by `menuTreeValidation()` when the editor saves.\n */\nfunction filterMap<T>(\n value: unknown,\n predicate: (candidate: unknown) => candidate is T,\n): Record<string, T> | undefined {\n if (!isPlainObject(value)) return undefined\n const out: Record<string, T> = {}\n for (const [key, entry] of Object.entries(value)) {\n if (predicate(entry)) out[key] = entry\n }\n return out\n}\n\nconst isString = (value: unknown): value is string => typeof value === 'string'\nconst isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'\n\n/**\n * Narrow a raw `menu.tree` JSONB value to `MenuItem[]`.\n *\n * @param value the raw column value (untrusted shape).\n */\nexport function normalizeMenuTree(value: unknown): NormalizedMenuTree {\n let budget = MAX_MENU_TREE_NODES\n let dropped = 0\n\n function walk(raw: unknown, depth: number): MenuItem[] {\n if (!Array.isArray(raw)) return []\n // `Array.isArray` narrows `unknown` to `any[]`; re-binding through a\n // `readonly unknown[]` annotation keeps every element typed as `unknown`.\n const rawNodes: readonly unknown[] = raw\n if (depth > MAX_MENU_DEPTH) {\n // Past the absolute cap the whole level goes, counted once per node so\n // the editor's warning is proportional to what it lost.\n dropped += rawNodes.length\n return []\n }\n\n const out: MenuItem[] = []\n for (const node of rawNodes) {\n if (!isPlainObject(node)) {\n dropped++\n continue\n }\n\n const id = node.id\n const kind = node.kind\n if (typeof id !== 'string' || id.length === 0) {\n dropped++\n continue\n }\n if (kind !== 'link' && kind !== 'header') {\n dropped++\n continue\n }\n\n let link: MenuLinkValue | undefined\n if (kind === 'link') {\n // Re-validate the stored target through the SAME schema the write path\n // uses — notably the `sanitizeHref` protocol allowlist, so a row that\n // predates (or bypassed) that gate can't hand the editor a\n // `javascript:` href to render.\n const parsed = LinkValueSchema.safeParse(node.link)\n if (!parsed.success) {\n dropped++\n continue\n }\n link = parsed.data\n } else if (node.link !== undefined) {\n // A header carrying a link target is a shape violation the type can't\n // represent — drop it rather than render a half-defined node.\n dropped++\n continue\n }\n\n if (budget <= 0) {\n dropped++\n continue\n }\n budget--\n\n // `filterMap` returns `{}` (not `undefined`) when the stored map WAS an\n // object but every entry failed the predicate (e.g. `{ en: 42 }`, a\n // non-string junk value) — `?? null`/`undefined` below wouldn't catch\n // that, since `{}` is truthy. An all-junk map must collapse the same\n // way a genuinely absent one does: for `label`, `{}` surviving into a\n // save would fail the write-side schema's \"a header needs at least one\n // locale key\" check with a server error instead of this module's own\n // documented tolerant behaviour; for `enabled`, `{}` is a harmless but\n // needless stored key (absent already means \"visible everywhere\").\n const rawLabel = filterMap(node.label, isString)\n const label =\n rawLabel !== undefined && Object.keys(rawLabel).length > 0 ? rawLabel : undefined\n const rawEnabled = filterMap(node.enabled, isBoolean)\n const enabled =\n rawEnabled !== undefined && Object.keys(rawEnabled).length > 0 ? rawEnabled : undefined\n out.push({\n id,\n kind,\n link,\n // `null` = \"use the live entity title per locale\" (the storage default).\n label: label ?? null,\n enabled,\n newTab: typeof node.newTab === 'boolean' ? node.newTab : undefined,\n children: walk(node.children, depth + 1),\n })\n }\n return out\n }\n\n return { items: walk(value, 1), dropped }\n}\n\n/**\n * Every distinct entity target in a (already-normalised, therefore bounded)\n * tree, in document order.\n *\n * Deduplicated: two menu items pointing at the same page produce ONE\n * `resolveMenuItemSummaries` ref, so the batched read never scales with item\n * count. The output feeds `resolveMenuItemSummaries(app, refs)`, whose result\n * map is keyed the same `${entity}:${id}` way.\n */\nexport function collectMenuEntityRefs(items: readonly MenuItem[]): MenuEntityRef[] {\n const seen = new Set<string>()\n const refs: MenuEntityRef[] = []\n\n function walk(nodes: readonly MenuItem[]): void {\n for (const node of nodes) {\n const link = node.link\n if (link?.kind === 'entity') {\n const key = `${link.entity}:${link.id}`\n if (!seen.has(key)) {\n seen.add(key)\n refs.push({ entity: link.entity, id: link.id })\n }\n }\n walk(node.children)\n }\n }\n\n walk(items)\n return refs\n}\n","/**\n * Block-anchor collection — the pure half of the anchors endpoint\n * (plan/routing phase 08 §5 / 09 §7, SD027).\n *\n * **Leaf module.** No `@murumets-ee/core`, no DB, no `@murumets-ee/blocks`\n * import: it walks a raw `field.blockTree()` JSONB value structurally, the same\n * way `get-menu.ts` walks a raw menu tree. That keeps the walk unit-testable\n * without a database and keeps this package's dependency direction unchanged\n * (blocks is UPSTREAM of menus, so nothing here reaches back into it).\n *\n * The anchor charset + length bound come from `../menu-item-schema.ts`, this\n * package's single owner of them — an intra-package import of a deliberately\n * pure module, NOT a new dependency direction. They used to be mirrored here;\n * a drift between the READ side (what the picker offers) and the WRITE side\n * (what `LinkValueSchema` accepts) would let the endpoint list anchors the menu\n * schema then rejects. Routing PR10 Task 3b converged them.\n *\n * **Bounded by construction** (CLAUDE.md \"no unbounded fan-out\"). The value\n * being walked is admin-authored, not a public payload — but it is still\n * untrusted-SHAPE JSONB (a hand-edited row, a restored dump, a future editor\n * bug), and this runs on an interactive picker's request path. So the walk is\n * ITERATIVE (never recursive — a deep tree must not blow the stack), carries a\n * hard visited-node budget, and hard-caps the returned list. Both bounds report\n * truncation to the caller so it can log; nothing is ever silently dropped.\n */\n\nimport { ANCHOR_PATTERN, type BlockAnchor, MAX_ANCHOR_LENGTH } from '../menu-item-schema.js'\n\n/**\n * Max length of a picker label. Mirrors `menu-item-schema.ts`'s\n * `MAX_LABEL_LENGTH`. `anchorLabel` is a free-text block field with no\n * declared `maxLength`, so a pathological value falls back to the anchor\n * itself rather than being truncated into something the editor never wrote.\n */\nexport const MAX_ANCHOR_LABEL_LENGTH = 200\n\n/**\n * Visited-node budget for one layout walk.\n *\n * Deliberately larger than `MAX_MENU_TREE_NODES` (500): a page layout is a\n * richer document than a hand-curated navigation menu. Past this the walk\n * stops and reports `truncated` — the bound that keeps a corrupted or hostile\n * layout from turning an interactive picker request into unbounded work.\n */\nexport const MAX_LAYOUT_NODES = 1000\n\n/**\n * Max anchors returned in one response. A picker listing more than this is not\n * a usable UI, and the cap bounds the response body independently of the node\n * budget.\n */\nexport const MAX_ANCHOR_RESULTS = 200\n\n/** Block types that declare the `anchor`/`anchorLabel` field pair (SD027). */\nconst ANCHORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set(['section', 'text'])\n\n/** {@link collectBlockAnchors}' result, including what it had to drop. */\nexport interface CollectAnchorsResult {\n anchors: BlockAnchor[]\n /** `true` when the node budget or the result cap cut the walk short. */\n truncated: boolean\n /** Nodes actually visited — for the truncation log line. */\n visited: number\n}\n\n/** Structural minimum of a pino/toolkit logger (declared, not imported). */\ninterface AnchorWalkLogger {\n warn(obj: object, msg: string): void\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n}\n\n/**\n * Read a usable anchor off a raw block node's `props`, or `undefined`.\n *\n * Returns `undefined` for every failure mode (wrong block type, missing /\n * non-string / empty / over-long / non-slug value) — the picker simply doesn't\n * offer it.\n */\nfunction readAnchor(node: Record<string, unknown>): BlockAnchor | undefined {\n const type = node.type\n if (typeof type !== 'string' || !ANCHORABLE_BLOCK_TYPES.has(type)) return undefined\n\n const props = node.props\n if (!isPlainObject(props)) return undefined\n\n const anchor = props.anchor\n if (typeof anchor !== 'string') return undefined\n if (anchor.length === 0 || anchor.length > MAX_ANCHOR_LENGTH) return undefined\n if (!ANCHOR_PATTERN.test(anchor)) return undefined\n\n const rawLabel = props.anchorLabel\n const label =\n typeof rawLabel === 'string' &&\n rawLabel.length > 0 &&\n rawLabel.length <= MAX_ANCHOR_LABEL_LENGTH\n ? rawLabel\n : anchor\n\n return { anchor, label }\n}\n\n/**\n * Walk a raw `field.blockTree()` value and collect every addressable anchor, in\n * document order.\n *\n * Duplicate anchors are RETURNED AS-IS — cross-block uniqueness is deliberately\n * not enforced in v1 (plan/routing F044 point 6); the endpoint lists whatever\n * exists so a colliding anchor is visible to the editor rather than silently\n * collapsed.\n *\n * @param tree Raw JSONB value. Any non-array input yields an empty result.\n * @param logger Optional — receives one line per bound that fired.\n */\nexport function collectBlockAnchors(\n tree: unknown,\n logger?: AnchorWalkLogger,\n): CollectAnchorsResult {\n const anchors: BlockAnchor[] = []\n if (!Array.isArray(tree)) return { anchors, truncated: false, visited: 0 }\n\n // `Array.isArray` narrows `unknown` to `any[]`; re-binding through a\n // `readonly unknown[]` annotation keeps every element typed as `unknown`\n // instead of leaking `any` into the walk.\n const roots: readonly unknown[] = tree\n\n let visited = 0\n let truncated = false\n\n // Explicit stack, NOT recursion — a 50k-deep layout must not blow the\n // stack. Pushed in reverse so `pop()` yields document order. Bounded the\n // SAME way the children-push path below is: cap how many root entries get\n // copied onto the stack, rather than `[...roots].reverse()`ing the WHOLE\n // array unconditionally before the budget check ever runs — a\n // pathologically large root-level array (a restored dump, a hand-edited\n // row) would otherwise force a full O(N) copy+reverse on the request\n // thread regardless of `MAX_LAYOUT_NODES`, defeating the \"bounded by\n // construction\" guarantee this module's header documents.\n if (roots.length > MAX_LAYOUT_NODES) truncated = true\n const stack: unknown[] = []\n for (let i = Math.min(roots.length, MAX_LAYOUT_NODES) - 1; i >= 0; i--) stack.push(roots[i])\n\n while (stack.length > 0) {\n if (visited >= MAX_LAYOUT_NODES || anchors.length >= MAX_ANCHOR_RESULTS) {\n truncated = true\n break\n }\n const node = stack.pop()\n // Count every stack pop against the budget, not just plain-object nodes —\n // a non-object entry still costs a loop iteration, and the budget exists\n // to bound WORK, not \"real\" nodes. Counting only objects let a `children`\n // array full of non-object junk (or a malformed dump) iterate past the cap\n // entirely, defeating the bound this comment block claims exists.\n visited++\n if (!isPlainObject(node)) continue\n\n const found = readAnchor(node)\n if (found !== undefined) anchors.push(found)\n\n const children = node.children\n if (Array.isArray(children)) {\n const items: readonly unknown[] = children\n // Push at most as many children as remain in the budget — pushing a\n // node's ENTIRE children array regardless of size (the old behaviour)\n // let one node's oversized array blow past the budget in a single step,\n // before the loop ever got to re-check it. Any children left unpushed\n // are exactly as truncated as a node the budget cut off outright.\n const room = Math.max(MAX_LAYOUT_NODES - visited, 0)\n if (items.length > room) truncated = true\n for (let i = Math.min(items.length, room) - 1; i >= 0; i--) stack.push(items[i])\n }\n }\n\n if (truncated) {\n logger?.warn(\n {\n visited,\n collected: anchors.length,\n nodeBudget: MAX_LAYOUT_NODES,\n resultCap: MAX_ANCHOR_RESULTS,\n },\n 'anchor collection stopped at a hard bound — the returned list is incomplete',\n )\n }\n\n return { anchors, truncated, visited }\n}\n","/**\n * `resolveMenuItemSummaries()` — live title + broken/unpublished status for\n * every entity target a stored menu tree points at (plan/routing phase 09 §3:\n * item rows show \"target summary … broken/unpublished badge\").\n *\n * **Deliberately NOT `getMenu`'s machinery.** `getMenu` resolves menus for\n * ANONYMOUS PUBLIC requests, so it elevates to a system context and\n * publish-filters unconditionally. The admin editor is the opposite case:\n *\n * - it MUST surface a DRAFT target's title (an editor may be building\n * navigation for content that isn't published yet), which rules out\n * `QueryClient` — that client filters to `status = 'published'` with no\n * opt-out, so every draft-linked item would look broken;\n * - it MUST respect the REAL signed-in admin's own grants and scope — no\n * elevation. If that admin cannot view an entity type, the editor correctly\n * shows no title for it. Least privilege, not convenience.\n *\n * So this goes through `app.getClient(entity)` — the framework-managed,\n * request-bound `AdminClient` — batched by entity type exactly like\n * `@murumets-ee/admin-ui`'s `resolveRowTitles`, but with richer per-row output.\n *\n * **No unbounded fan-out** (CLAUDE.md). Backend work is bounded by the number\n * of distinct ENTITY TYPES, never by item count: one `findMany` per type group.\n * The ref count is capped at {@link MAX_MENU_TREE_NODES} (the same bound the\n * stored tree the caller walked already carries) and the group count at\n * {@link MAX_MENU_ENTITY_TYPES}. Anything dropped is logged — never silently\n * truncated.\n *\n * Called ONCE, server-side, by the menu editor page (RSC → props; no\n * `useEffect` fetching, per CLAUDE.md). Items added client-side via the link\n * picker already carry their title from the picker, so they need no round-trip.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\nimport { getEntityTitleField } from '@murumets-ee/entity'\nimport { inArray } from 'drizzle-orm'\nimport { MAX_MENU_ENTITY_TYPES } from '../get-menu.js'\nimport { column } from '../internal/columns.js'\nimport { MAX_MENU_TREE_NODES, type MenuItemSummary } from '../menu-item-schema.js'\n\n/** Key shape of {@link resolveMenuItemSummaries}' result map. */\nfunction summaryKey(entity: string, id: string): string {\n return `${entity}:${id}`\n}\n\nconst BROKEN: MenuItemSummary = { title: null, exists: false, published: null }\n\n/**\n * Group refs by entity type, deduping ids and stopping at the hard ref bound.\n *\n * Returns the bounded groups plus what was dropped, so the caller logs once\n * rather than per drop.\n */\nfunction groupRefs(refs: ReadonlyArray<{ entity: string; id: string }>): {\n groups: Map<string, Set<string>>\n droppedRefs: number\n droppedGroups: number\n} {\n const groups = new Map<string, Set<string>>()\n let distinct = 0\n let droppedRefs = 0\n\n for (const ref of refs) {\n if (typeof ref.entity !== 'string' || typeof ref.id !== 'string') continue\n if (ref.entity.length === 0 || ref.id.length === 0) continue\n\n const bucket = groups.get(ref.entity)\n if (bucket?.has(ref.id)) continue // already counted — duplicates are free\n if (distinct >= MAX_MENU_TREE_NODES) {\n droppedRefs++\n continue\n }\n distinct++\n if (bucket) bucket.add(ref.id)\n else groups.set(ref.entity, new Set([ref.id]))\n }\n\n const droppedGroups = Math.max(0, groups.size - MAX_MENU_ENTITY_TYPES)\n return { groups, droppedRefs, droppedGroups }\n}\n\n/**\n * Batch-resolve the live title + existence + publish state of every\n * `{ entity, id }` target, keyed `` `${entity}:${id}` `` (the same key shape\n * `resolveRowTitles` uses).\n *\n * **A MISSING map entry means \"not resolvable here\", NOT \"broken.\"** It happens\n * when the entity type isn't registered in this app, when the caller holds no\n * `view` grant on it, when that group's read failed, or (for a `team`/`user`-\n * scoped entity only) when the row belongs to a different scope than this\n * request's — a scope filter excludes it the same way a delete would, and\n * this function cannot tell the two apart. Callers should render such items\n * as indeterminate — only an explicit `exists: false` (reported ONLY for a\n * `global`-scoped entity, where no scope filter can ever apply) means the\n * target was really deleted.\n *\n * Best-effort per group: one group's failure never takes down the others (and\n * therefore never takes down the editor page).\n */\nexport async function resolveMenuItemSummaries(\n app: ToolkitApp,\n refs: ReadonlyArray<{ entity: string; id: string }>,\n): Promise<Map<string, MenuItemSummary>> {\n const summaries = new Map<string, MenuItemSummary>()\n if (refs.length === 0) return summaries\n\n const logger = app.logger.child({ resolve: 'menu-item-summaries' })\n const { groups, droppedRefs, droppedGroups } = groupRefs(refs)\n\n if (droppedRefs > 0) {\n logger.warn(\n { dropped: droppedRefs, cap: MAX_MENU_TREE_NODES },\n 'menu item summaries dropped targets over the ref budget — some rows will show no status',\n )\n }\n if (droppedGroups > 0) {\n logger.warn(\n { dropped: droppedGroups, cap: MAX_MENU_ENTITY_TYPES },\n 'menu item summaries dropped entity-type groups over the fan-out cap',\n )\n }\n\n // Groups are independent and their count is already hard-capped, so this is a\n // BOUNDED concurrent fan-out (≤ MAX_MENU_ENTITY_TYPES), not an unbounded\n // `Promise.all` over caller-controlled input.\n await Promise.all(\n [...groups.entries()]\n .slice(0, MAX_MENU_ENTITY_TYPES)\n .map(([entityName, idSet]) => resolveGroup(app, logger, entityName, idSet, summaries)),\n )\n\n return summaries\n}\n\nasync function resolveGroup(\n app: ToolkitApp,\n logger: ToolkitApp['logger'],\n entityName: string,\n idSet: ReadonlySet<string>,\n out: Map<string, MenuItemSummary>,\n): Promise<void> {\n // Unregistered type — leave NO entry (see the \"missing entry\" contract):\n // claiming `exists: false` would flag every item as broken just because a\n // plugin isn't loaded in this process.\n const entity: Entity | undefined = app.entities.get(entityName)\n if (entity === undefined) return\n\n const titleField = getEntityTitleField(entity)\n // `getEntityTitleField` degrades to `'id'` when nothing is display-able. The\n // row still EXISTS; it just has no title to show.\n const hasTitle = titleField !== 'id'\n const isPublishable = entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n const ids = [...idSet]\n\n try {\n const client = app.getClient(entity)\n // `AdminClient`, so rows come back regardless of publish state — the whole\n // point (see the module header). Bounded by `ids.length`, itself bounded by\n // `MAX_MENU_TREE_NODES`.\n const rows = await client.findMany({\n where: inArray(column(client.getTable(), 'id'), ids),\n limit: ids.length,\n })\n\n const seen = new Set<string>()\n for (const row of rows) {\n const record: Record<string, unknown> = row\n const id = record.id\n if (typeof id !== 'string') continue\n const rawTitle = hasTitle ? record[titleField] : undefined\n seen.add(id)\n out.set(summaryKey(entityName, id), {\n title: typeof rawTitle === 'string' && rawTitle.length > 0 ? rawTitle : null,\n exists: true,\n published: isPublishable ? record.status === 'published' : null,\n })\n }\n\n // An id asked for and not returned is USUALLY gone — but for a `team`/\n // `user`-scoped entity it can also mean \"belongs to a different scope than\n // this request's\", which `findMany` filters out silently (same\n // `strictScope: false` fail-open every admin read path shares — see\n // `packages/core/src/client-factories.ts`). Claiming `exists: false` there\n // would tell an admin to delete a perfectly live row just because it's\n // outside their current scope — the exact false alarm the \"missing entry\n // is indeterminate\" contract exists to prevent. Only a `global`-scoped\n // entity (no scope filter can ever apply) gets the real broken signal;\n // anything else is left unresolved (indeterminate) instead.\n const canTrustAbsence = (entity.scope ?? 'global') === 'global'\n if (canTrustAbsence) {\n for (const id of ids) {\n if (!seen.has(id)) out.set(summaryKey(entityName, id), BROKEN)\n }\n }\n } catch (error) {\n // Best-effort, per group: a denied entity, a pre-migration table or a\n // transient read failure must degrade THIS group's badges, not blank the\n // whole editor page. Logged (never a silent swallow) and left with no\n // entries, which callers read as indeterminate rather than broken.\n logger.warn(\n { entity: entityName, count: ids.length, error: String(error) },\n 'menu item summaries could not be resolved for this entity type',\n )\n }\n}\n","/**\n * Admin API routes for `@murumets-ee/menus`.\n *\n * **URL surface served by this module:**\n *\n * - GET /api/admin/menus/anchors?entity=&id= — addressable block anchors on\n * one published document, for the menu editor's anchor picker\n * (plan/routing phase 08 §5 / 09 §4 + §7).\n * - GET /api/admin/menus/editor-state?handle= — a menu's full tree + editor\n * metadata (summaries, locales, entityTypes, canPublish) by handle. Feeds\n * `@murumets-ee/menus-ui`'s Tier 3 in-place menu editor surface\n * (`MenuBlock.customEditorSurface`, `plan/blocks/08-adaptive-ui.md`\n * §\"Tier 3\") the same props shape the standalone `/admin/menu/[id]` page\n * builds server-side — see `./editor-state.ts`'s module header for the\n * v1 scope trade-off (no draft/lock parity).\n *\n * Menu CRUD itself is plain entity CRUD (the `menu` entity is served by\n * admin-ui's generic entity family), so it deliberately has no route here —\n * the embedded editor's Save/Publish buttons PATCH straight through that\n * existing entity-CRUD route, same as the standalone page.\n *\n * ## Security posture\n *\n * The anchors endpoint reads ANOTHER entity's content, so it is gated TWICE:\n *\n * 1. The declarative `menu:view` gate the framework wrapper enforces before\n * the handler runs — \"may this caller work with menus at all?\".\n * 2. An in-handler `ctx.checkPermission(<target entity>, 'view')` — \"may this\n * caller see THAT entity's content?\". The static gate can't express this:\n * the target entity is a request parameter (the exact \"multi-entity routes\n * ... per-entity permission checks\" case `AdminRouteHandler`'s contract\n * documents), and without it any menu editor could read the layout of an\n * entity type they hold no grant on.\n *\n * **Every negative outcome INSIDE the handler answers `200 []`, never 403/404**\n * — no permission on the target entity, unknown entity, entity with no block\n * tree, missing / unpublished / deleted row. One uniform response means the\n * endpoint discloses nothing about which entity types exist, which are\n * block-backed, or which ids are real. (Gate 1 above still answers a real\n * `403` for a caller who lacks `menu:view` itself — that gate protects the\n * endpoint as a whole, not a specific target, so there is no oracle risk in\n * it answering truthfully.) A malformed REQUEST (missing/short/over-long\n * `entity`, non-uuid `id`) still 400s — that is a fact about the caller's own\n * request, not about our data.\n *\n * Reads go through a `QueryClient` bound to the REAL caller's identity and\n * scope — deliberately NOT the elevated `SYSTEM_CONTEXT` that\n * `menu-resolve-context.ts` uses for public rendering. `QueryClient`\n * unconditionally publish-filters (and excludes trashed rows), which is\n * exactly phase 08 §5's \"walking its published layout\": a draft page exposes\n * no anchors, and the picker's manual-string entry is the documented fallback\n * for that case (07 §2.6).\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n getContext,\n getCurrentApp,\n getCurrentScope,\n} from '@murumets-ee/core'\nimport type { Entity, SecurityContext } from '@murumets-ee/entity'\nimport { getSearchableConfig } from '@murumets-ee/entity'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { eq } from 'drizzle-orm'\nimport { Menu } from '../entity.js'\nimport { column } from '../internal/columns.js'\nimport { normalizeMenuEntityTypes } from '../menu-entity-types.js'\nimport { DEFAULT_MENU_DEPTH, MAX_MENU_DEPTH } from '../menu-item-schema.js'\nimport { collectMenuEntityRefs, normalizeMenuTree } from '../menu-tree-normalize.js'\nimport { collectBlockAnchors } from './anchors.js'\nimport type { MenuEditorStateResponse } from './editor-state.js'\nimport { resolveMenuItemSummaries } from './summaries.js'\n\n// ---------------------------------------------------------------------------\n// Request validation\n// ---------------------------------------------------------------------------\n\n/** Canonical UUID shape for the `id` query param. */\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/**\n * Longest `entity` param accepted, rejected before any lookup. Mirrors\n * `menu-item-schema.ts`'s `MAX_ENTITY_NAME_LENGTH` — the same bound the write\n * path already applies to an entity name inside a stored link.\n */\nconst MAX_ENTITY_NAME_LENGTH = 64\n\n/**\n * Role stand-in when the authenticated user carries none.\n *\n * `SecurityContext.user.groups` MUST be non-empty: `resolveAuthContext` reads\n * `groups[0]` and throws `ForbiddenError` on an empty array BEFORE it ever\n * consults the checker. The VALUE is inert here — the checker below ignores\n * its `role` argument and delegates to `ctx.checkPermission`, which the API\n * handler already bound to the caller's real role — so the least-privileged\n * built-in role is the honest placeholder.\n */\nconst FALLBACK_ROLE = 'viewer'\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction jsonError(message: string, status: number): Response {\n return jsonResponse({ error: message }, status)\n}\n\n/** The one uniform \"nothing to offer\" answer — see the module header. */\nfunction noAnchors(): Response {\n return jsonResponse([])\n}\n\n/**\n * Name of the entity's `field.blockTree()` slot, or `null` when it has none.\n *\n * A deliberately LOCAL linear scan: the same shape as\n * `@murumets-ee/blocks`' `resolveBlockTreeField` and admin-ui's\n * `getBlocksFieldName`, but neither is reachable from this package (blocks'\n * copy lives behind its server entry, admin-ui is a UI package menus must not\n * depend on) and this has exactly one consumer, so it is not worth widening\n * anyone's public API for.\n */\nfunction findBlockTreeField(entity: Entity): string | null {\n for (const [name, config] of Object.entries(entity.allFields)) {\n if (config.type === 'blockTree') return name\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// GET /api/admin/menus/anchors\n// ---------------------------------------------------------------------------\n\nconst handleAnchors: AdminRouteHandler = async (req, ctx) => {\n const params = new URL(req.url).searchParams\n const entityName = params.get('entity')\n const id = params.get('id')\n\n // Boundary validation. These 400s describe the CALLER'S request, so they\n // disclose nothing about our data.\n if (entityName === null || entityName.length === 0) {\n return jsonError(\"query param 'entity' is required\", 400)\n }\n if (entityName.length > MAX_ENTITY_NAME_LENGTH) {\n return jsonError(\"query param 'entity' is too long\", 400)\n }\n if (id === null || !UUID_RE.test(id)) {\n return jsonError(\"query param 'id' must be a uuid\", 400)\n }\n\n const app = getCurrentApp()\n if (app === undefined) {\n // Handlers always run inside `runWithContextAsync`; no app means the\n // request never got a context, which is an internal wiring fault.\n return jsonError('Internal error', 500)\n }\n const logger = app.logger.child({ route: 'menus/anchors', entity: entityName })\n\n // Gate 2 — the per-target-entity check (see the module header). Answers with\n // the same empty list as every other negative outcome.\n if (!ctx.checkPermission(entityName, 'view')) {\n logger.debug(\n { userId: ctx.user.id, role: ctx.user.role },\n 'anchors lookup returned empty — caller holds no view grant on the target entity',\n )\n return noAnchors()\n }\n\n const entity = app.entities.get(entityName)\n if (entity === undefined) return noAnchors()\n\n const blockTreeField = findBlockTreeField(entity)\n // Not a block-tree-backed content type, so nothing on it is addressable.\n if (blockTreeField === null) return noAnchors()\n\n // Bound to the REAL caller — same identity, role and data scope the rest of\n // this request runs under. `scope` matters: without it a `team`/`user`-scoped\n // entity would skip its isolation filter on this read path.\n const scope = getCurrentScope()\n const client = new QueryClient({\n entity,\n db: app.db.readOnly,\n logger,\n contextResolver: (): SecurityContext => ({\n user: { id: ctx.user.id, groups: [ctx.user.role ?? FALLBACK_ROLE] },\n checker: (_role, resource, action) => ctx.checkPermission(resource, action),\n ...(scope !== undefined && { scope }),\n }),\n })\n\n // `locale` drives BOTH the per-locale publish filter and the translation\n // merge, so a translatable block tree resolves to the layout the admin is\n // actually editing. `QueryClient` publish-filters unconditionally, so a\n // draft / trashed / deleted row simply isn't found.\n const row = await client.findById(id, {\n ...(ctx.locale !== undefined && { locale: ctx.locale }),\n ...(ctx.defaultLocale !== undefined && { defaultLocale: ctx.defaultLocale }),\n })\n if (row === null) return noAnchors()\n\n const record: Record<string, unknown> = row\n const { anchors } = collectBlockAnchors(record[blockTreeField], logger)\n return jsonResponse(anchors)\n}\n\nconst anchorsRoute = defineAdminRoute({\n prefix: 'menus',\n path: 'anchors',\n method: 'GET',\n // Building a menu is the umbrella capability; the per-target-entity `view`\n // check inside the handler is the least-privilege half.\n permission: 'menu:view',\n // Mirrors the `menu` entity's own admin-only write posture (`entity.ts`):\n // only admins build menus today. `registerPermission` UNIONS default roles\n // across registrations, so this can only add, never narrow.\n defaultRoles: ['admin'],\n description:\n \"List a published document's addressable block anchors for the menu editor's anchor picker.\",\n handler: handleAnchors,\n})\n\n// ---------------------------------------------------------------------------\n// GET /api/admin/menus/editor-state — the Tier 3 block-editor surface's read\n// ---------------------------------------------------------------------------\n\n/** Longest `handle` accepted — mirrors `get-menu.ts`'s own local bound. */\nconst MAX_HANDLE_LENGTH = 128\n\nconst handleEditorState: AdminRouteHandler = async (req, ctx) => {\n const handle = new URL(req.url).searchParams.get('handle')\n if (handle === null || handle.length === 0) {\n return jsonError(\"query param 'handle' is required\", 400)\n }\n if (handle.length > MAX_HANDLE_LENGTH) {\n return jsonError(\"query param 'handle' is too long\", 400)\n }\n\n const app = getCurrentApp()\n if (app === undefined) return jsonError('Internal error', 500)\n const logger = app.logger.child({ route: 'menus/editor-state' })\n\n // Bound to the REAL caller (same `app.getClient` framework accessor\n // `resolveMenuItemSummaries` uses below) — no elevation. A caller who\n // cannot view menus never reaches this handler at all (the route's static\n // `permission: 'menu:view'` gate below), so this is the ordinary\n // request-scoped client, not `createSystemAdminClient`.\n const client = app.getClient(Menu)\n const rows = await client.findMany({\n where: eq(column(client.getTable(), 'handle'), handle),\n limit: 1,\n })\n const row = rows[0] as Record<string, unknown> | undefined\n if (row === undefined) return jsonError('Menu not found', 404)\n\n const id = row.id as string\n const title = typeof row.title === 'string' ? row.title : ''\n const storedMaxDepth = row.maxDepth\n const maxDepth =\n typeof storedMaxDepth === 'number' && Number.isInteger(storedMaxDepth) && storedMaxDepth >= 1\n ? Math.min(storedMaxDepth, MAX_MENU_DEPTH)\n : DEFAULT_MENU_DEPTH\n const status: 'draft' | 'published' = row.status === 'published' ? 'published' : 'draft'\n\n // Codes only (no display labels) — see `RequestContext.availableLocales`.\n // Falling back to the code itself as the label is the same idiom\n // `generic-block-editor.tsx` uses when no richer locale registry is wired.\n const localeCodes = getContext()?.availableLocales\n const defaultLocale = ctx.defaultLocale ?? localeCodes?.[0] ?? 'en'\n // `MenuTreeEditorProps.defaultLocale` is contractually \"always present in\n // `locales`\", but the two come from DIFFERENT sources — `defaultLocale` from\n // `ctx.defaultLocale`, the list from the request's enabled `availableLocales`\n // — so they can disagree (a default locale that was later disabled, or a\n // context that carries one but not the other). When they do, prepend it:\n // otherwise the editor's preview switcher omits the very locale the tree is\n // authored in, and \"preview as default\" becomes unreachable.\n const enabled = localeCodes && localeCodes.length > 0 ? [...localeCodes] : []\n const codes = enabled.includes(defaultLocale) ? enabled : [defaultLocale, ...enabled]\n const locales = codes.map((code) => ({ code, label: code }))\n\n const draft = await loadPendingDraft(app, id, logger)\n const draftData = draft?.data\n\n // Seed from the pending DRAFT when there is one, else the live row — the\n // same precedence `MenuEditorPage` applies, and the same `EntityForm`\n // applies generally. Without it the editor would show the LIVE tree under a\n // banner saying \"you are editing a pending draft\", and a Save would silently\n // republish the pre-draft structure.\n const treeSource = draftData !== undefined && 'tree' in draftData ? draftData.tree : row.tree\n\n // The SAME tolerant per-node narrowing the standalone `/admin/menu/[id]`\n // page uses (`../menu-tree-normalize.ts` — one implementation, deliberately).\n //\n // NOT a strict whole-tree `buildMenuTreeSchema().safeParse`: that whitelists\n // locale KEYS against the app's currently-ENABLED locales, so disabling a\n // locale after items were authored for it failed the entire parse and\n // collapsed the tree to `[]` — a subsequent save from this surface would\n // then persist that wipe, while the standalone page (already tolerant) kept\n // every valid node from the same row. Two editing paths to the same data\n // must not disagree on malformed input.\n //\n // Whatever IS unreadable is counted, not swallowed: `droppedItemCount`\n // rides the response, and `<MenuTreeEditor>` refuses to save past it\n // without an explicit confirm.\n const { items: tree, dropped: droppedItemCount } = normalizeMenuTree(treeSource)\n if (droppedItemCount > 0) {\n logger.warn(\n { menuId: id, handle, dropped: droppedItemCount },\n 'menu editor-state: stored tree contained items that could not be read — the embedded editor shows the rest',\n )\n }\n\n const summaryMap = await resolveMenuItemSummaries(app, collectMenuEntityRefs(tree))\n const summaries = Object.fromEntries(summaryMap)\n\n // Same derivation `MenuEditorPage.resolveLinkEntityTypes` uses, minus the\n // admin-ui label helper (this package must not depend on `admin-ui` — see\n // the module header on `../plugin.ts`'s sibling `menus-ui` for why the\n // dependency runs the other way). `labelSingular` is the closest built-in\n // equivalent to a type-picker's \"one item of this type\" label.\n const entityTypes = [...app.entities.values()]\n .filter(\n (entity) =>\n getSearchableConfig(entity) !== undefined && ctx.checkPermission(entity.name, 'view'),\n )\n .map((entity) => ({\n name: entity.name,\n label: entity.admin?.labelSingular ?? entity.admin?.label ?? entity.name,\n }))\n\n const canPublish = ctx.checkPermission('menu', 'publish')\n\n // Same draft-then-live precedence as the tree above (routing PR12 added the\n // per-menu whitelist): a pending draft that narrowed which content types the\n // menu accepts must be what the editor shows, or a Save would silently\n // republish the pre-draft selection.\n const acceptedEntityTypes = normalizeMenuEntityTypes(\n draftData !== undefined && 'entityTypes' in draftData ? draftData.entityTypes : row.entityTypes,\n )\n\n const body: MenuEditorStateResponse = {\n menu: { id, handle, title, maxDepth, status, tree, entityTypes: acceptedEntityTypes },\n summaries,\n locales,\n defaultLocale,\n droppedItemCount,\n entityTypes,\n canPublish,\n ...(draft !== undefined && { draft }),\n }\n return jsonResponse(body)\n}\n\n/**\n * A menu's draft + lock are keyed by the DEFAULT snapshot key, never the\n * admin's current content locale: one tree serves every locale (D009), so a\n * per-locale draft would let two locales hold divergent copies of the same\n * structure. `'_'` is the api-handler's own default-snapshot key.\n * `MenuEditorPage` uses the same constant for the same reason.\n */\nconst MENU_SNAPSHOT_LOCALE = '_'\n\n/**\n * The pending draft for this menu, or `undefined`.\n *\n * Read through the REQUEST'S own `AdminClient` (`app.getClient`), so the\n * entity firewall applies exactly as it does everywhere else — no elevation.\n *\n * Failure degrades to \"no draft\" rather than failing the request, matching\n * `MenuEditorPage.loadMenuEditState`: a partially-down draft subsystem should\n * not take the editing surface down with it, and every write is re-authorized\n * server-side regardless of what this returns.\n *\n * ⚠️ **`ContentClient` is imported LAZILY, and must stay that way.**\n * `@murumets-ee/content/client` is `server-only`-tagged, and this module is on\n * the `lumi.config.ts` load path: `menus-ui`'s `menus()` plugin factory\n * statically imports `menusRoutes` from this entry, and the CLI / worker load\n * that config through jiti, which does NOT apply the `react-server` export\n * condition — so a MODULE-SCOPE import of it throws the instant `lumi migrate`\n * (or any other command) loads the config, taking every CLI command down with\n * it. A static import here did exactly that; `pnpm verify:plugin-loads` is the\n * gate that catches it (`packages/menus/dist/admin.mjs` failed to load).\n *\n * The dynamic import defers the module to CALL time, which only ever happens\n * inside a real admin-API request — i.e. inside RSC, where `server-only`\n * resolves to its empty stub. Exactly the same lazy-thunk technique, and the\n * same reasoning, as the page factories in `menus-ui`'s `plugin.ts`.\n */\nasync function loadPendingDraft(\n app: NonNullable<ReturnType<typeof getCurrentApp>>,\n menuId: string,\n logger: { warn(obj: object, msg: string): void },\n): Promise<MenuEditorStateResponse['draft']> {\n try {\n const { ContentClient } = await import('@murumets-ee/content/client')\n const contentClient = new ContentClient({ admin: app.getClient(Menu), entity: Menu })\n const entry = await contentClient.getDraft(menuId, MENU_SNAPSHOT_LOCALE)\n if (!entry) return undefined\n return {\n data: entry.data,\n createdBy: entry.createdBy,\n createdByName: entry.createdByName,\n // The response is JSON, so normalise `Date | string` to a string here\n // rather than letting `JSON.stringify` do it implicitly — the declared\n // response type then matches what actually crosses the wire.\n updatedAt:\n entry.updatedAt instanceof Date ? entry.updatedAt.toISOString() : String(entry.updatedAt),\n }\n } catch (err) {\n logger.warn({ err, menuId }, 'menu editor-state: draft state unavailable — continuing without')\n return undefined\n }\n}\n\nconst editorStateRoute = defineAdminRoute({\n prefix: 'menus',\n path: 'editor-state',\n method: 'GET',\n // Same gate the standalone `/admin/menu/[id]` page uses (`menus-ui/plugin.ts`\n // — `requires: 'menu:view'`). Mutations still go through the entity's own\n // `menu:update` / `menu:publish` gates on the generic entity CRUD routes —\n // this endpoint is read-only.\n permission: 'menu:view',\n defaultRoles: ['admin'],\n description:\n \"Read a menu's full tree + editor metadata by handle, for the blocks editor's in-place Tier 3 menu surface.\",\n handler: handleEditorState,\n})\n\n/**\n * Every admin route this package contributes, in the `AdminRoute[]` shape\n * `createAdminApiHandler` consumes.\n *\n * Wired declaratively via the plugin's `server.routes` slot — see\n * `@murumets-ee/menus-ui`.\n *\n * @example\n * ```ts\n * import { menusRoutes } from '@murumets-ee/menus/admin'\n *\n * definePlugin({ name: '…', server: { routes: menusRoutes() } })\n * ```\n */\nexport function menusRoutes(): AdminRoute[] {\n return combineAdminRoutes([anchorsRoute, editorStateRoute])\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"en-Cr9XUf6B.mjs","names":[],"sources":["../src/messages/en.json"],"sourcesContent":[""],"mappings":""}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{m as e,n as t,p as n}from"./menu-entity-types-BnKnKbif.mjs";import{z as r}from"zod";import{getContentConfig as i}from"@murumets-ee/content/plugin";import{auditable as a,defineEntity as o,field as s,publishable as c}from"@murumets-ee/entity";function l(e){if(e.keys.includes(`entityTypes`))throw new r.ZodError([{code:`custom`,path:[`entityTypes`],message:`bulk update on '${e.entityName}' cannot write 'entityTypes': updateMany runs no per-row hooks, so the whitelist would bypass its schema check. A whitelist is per-menu — update each row individually, or write a migration for a one-off backfill.`}])}function u(e){return new r.ZodError(e.issues.map(e=>({...e,path:[`entityTypes`,...e.path]})))}function d(e){if(!(`entityTypes`in e))return e;let n=t.safeParse(e.entityTypes);if(!n.success)throw u(n.error);return{...e,entityTypes:n.data}}function f(){return{name:`menuEntityTypesValidation`,hooks:{beforeCreate:async e=>d(e),beforeUpdate:async(e,t)=>d(t)},assertBulkUpdate:l}}function p(e,t=[]){return new r.ZodError([{code:r.ZodIssueCode.custom,path:[`tree`,...t],message:e}])}function m(e){return new r.ZodError(e.issues.map(e=>({...e,path:[`tree`,...e.path]})))}function h(e){if(!(typeof e!=`number`||!Number.isInteger(e))&&!(e<1||e>6))return e}async function g(e,t,n){let r=h(e.maxDepth);if(r!==void 0)return r;if(!n)return 3;if(!t?.loadCurrent)throw Error(`menu: BehaviorContext.loadCurrent is unavailable — cannot validate the tree against the menu's maxDepth. Writes must go through AdminClient.update.`);return h((await t.loadCurrent())?.maxDepth)??3}async function _(t,n){let r=h(t.maxDepth);if(r===void 0)return;if(!n?.loadCurrent)throw Error(`menu: BehaviorContext.loadCurrent is unavailable — cannot validate the stored tree against the new maxDepth. Writes must go through AdminClient.update.`);let{depth:i}=e((await n.loadCurrent())?.tree);if(i>r)throw p(`the stored menu tree is nested ${i} levels deep, deeper than this menu's new maxDepth (${r}) — flatten the tree first`)}async function v(t,r,i,a){let o=t.tree;if(o===void 0)return i&&await _(t,r),t;if(o===null||!Array.isArray(o))throw p(`menu tree must be an array of menu items`);let{depth:s,nodeCount:c}=e(o);if(c>500)throw p(`menu tree has too many items (max 500)`);if(s>6)throw p(`menu tree is nested too deeply (absolute max 6 levels)`);let l=n({allowedLocales:a()}).safeParse(o);if(!l.success)throw m(l.error);let u=await g(t,r,i);if(s>u)throw p(`menu tree is nested deeper than this menu's maxDepth (${u})`);return{...t,tree:l.data}}function y(e){let t=e?.resolveAllowedLocales??(()=>i().locales.map(e=>e.code));return{name:`menuTreeValidation`,hooks:{beforeCreate:async(e,n)=>v(e,n,!1,t),beforeUpdate:async(e,n,r)=>v(n,r,!0,t)}}}const b=o({name:`menu`,admin:{label:`Menus`,icon:`menu`},fields:{handle:s.slug({from:`title`}),title:s.text({required:!0}),maxDepth:s.number({default:3,min:1,max:6,integer:!0}),tree:s.json({refScan:!0,default:[]}),entityTypes:s.json({default:null})},behaviors:[a(),c(),y(),f()],scope:`global`,access:{view:`public`,create:`group.admin`,update:`group.admin`,delete:`group.admin`}});export{y as n,f as r,b as t};
|
|
2
|
+
//# sourceMappingURL=entity-BxLeqoBC.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entity-BxLeqoBC.mjs","names":["prefixIssues"],"sources":["../src/behaviors/menu-entity-types-validation.ts","../src/behaviors/menu-tree-validation.ts","../src/entity.ts"],"sourcesContent":["/**\n * `menuEntityTypesValidation()` — boundary validation for the\n * `menu.entityTypes` JSONB column (plan/routing phase 07 §2.8, F056).\n *\n * `field.json()`'s own validation is the deliberately permissive JSON union, so\n * without this hook an admin PATCH could park any shape — including an unbounded\n * array — in the column, and every reader (editor picker, Meta panel, every RSC\n * payload that carries the row) would pay for it. CLAUDE.md's \"validate at the\n * boundary\" applied to the one column `menuTreeValidation()` doesn't cover.\n *\n * **Why a second behavior rather than a branch inside `menuTreeValidation()`.**\n * The two share nothing: the tree check is CROSS-FIELD (it reads the sibling\n * `maxDepth` column through `loadCurrent`) and CROSS-MODULE (locale keys against\n * the content plugin's registry, hence its injectable resolver option); this one\n * is self-contained and needs no context at all. Folding it in would put an\n * option-less concern behind `MenuTreeValidationOptions` and make a behavior\n * named \"tree validation\" own a non-tree column.\n *\n * Errors are `ZodError`s so the admin API maps them to **400 with structured\n * issues** rather than a 500 — same contract `menuTreeValidation()` relies on.\n * Issues are pathed at `entityTypes` so the client can point at the field.\n */\n\nimport type { Behavior, BulkUpdateInfo } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { MenuEntityTypesSchema } from '../menu-entity-types.js'\n\n/**\n * `AdminClient.updateMany` runs NO per-row hooks, so `beforeUpdate` above never\n * fires on that path — a bulk statement could otherwise park an unbounded or\n * malformed array in the column and every reader would pay for it, which is the\n * exact hole this behavior exists to close. `assertBulkUpdate` is the sanctioned\n * cover for that gap (same mechanism `hierarchical()` uses to stop a partial\n * bulk tree write).\n *\n * This REJECTS rather than validates: `updateMany` sets one value across every\n * matched row, and \"every menu accepts exactly these types\" is not a coherent\n * intent — a whitelist is per-menu by definition. There is no legitimate bulk\n * caller to serve, so refusing is both safer and more honest than silently\n * validating a shape nobody should be writing this way.\n */\nfunction assertNoBulkEntityTypesWrite(info: BulkUpdateInfo): void {\n if (!info.keys.includes('entityTypes')) return\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['entityTypes'],\n message:\n `bulk update on '${info.entityName}' cannot write 'entityTypes': updateMany runs no ` +\n 'per-row hooks, so the whitelist would bypass its schema check. A whitelist is ' +\n 'per-menu — update each row individually, or write a migration for a one-off backfill.',\n },\n ])\n}\n\n/** Prefix every issue path with `entityTypes` so the client can locate it. */\nfunction prefixIssues(error: z.ZodError): z.ZodError {\n return new z.ZodError(\n error.issues.map((issue) => ({ ...issue, path: ['entityTypes', ...issue.path] })),\n )\n}\n\n/**\n * Validate + normalize `data.entityTypes` in place, returning the payload with\n * the PARSED value (so `[]` is persisted as `null` and duplicates are folded —\n * see `../menu-entity-types.ts` for why there is exactly one unrestricted\n * shape).\n *\n * A payload without the key is a partial update that doesn't touch the column;\n * it passes through untouched rather than being defaulted, which would let a\n * `{ tree }`-only PATCH silently clear an authored whitelist.\n */\nfunction validateEntityTypes(data: Record<string, unknown>): Record<string, unknown> {\n if (!('entityTypes' in data)) return data\n\n const parsed = MenuEntityTypesSchema.safeParse(data.entityTypes)\n if (!parsed.success) throw prefixIssues(parsed.error)\n return { ...data, entityTypes: parsed.data }\n}\n\n/**\n * Recursive-free Zod validation of `menu.entityTypes` on every create/update.\n *\n * Contributes NO fields, so the field-map type param is the EMPTY map\n * `Record<never, never>` — NOT `Record<string, never>`, which would merge into\n * the entity's field map and collapse every field to `never`.\n */\nexport function menuEntityTypesValidation(): Behavior<\n Record<never, never>,\n 'menuEntityTypesValidation'\n> {\n return {\n name: 'menuEntityTypesValidation',\n hooks: {\n beforeCreate: async (data) => validateEntityTypes(data),\n beforeUpdate: async (_id, data) => validateEntityTypes(data),\n },\n assertBulkUpdate: assertNoBulkEntityTypesWrite,\n }\n}\n","/**\n * `menuTreeValidation()` — boundary validation for the `menu.tree` JSONB column\n * (plan/routing phase 08 §3, F040 pt.2).\n *\n * Why a behavior and not field-local validation: two of the four rules are\n * CROSS-FIELD or CROSS-MODULE. The tree's nesting depth is checked against the\n * sibling `maxDepth` COLUMN, and the locale keys are checked against the app's\n * configured content locales — neither is visible to `field.json()`'s\n * (deliberately permissive) JSON union. `BehaviorContext` gives a hook both:\n * the update payload and, via `loadCurrent`, the pre-update row.\n *\n * Errors are raised as `ZodError`s on purpose: the admin API handler maps\n * `ZodError` to **400 with structured issues** (a plain `Error` would surface\n * as a 500), so an editor saving a malformed tree gets a per-node message\n * pointing at the offending item instead of \"Internal server error\".\n */\n\nimport { getContentConfig } from '@murumets-ee/content/plugin'\nimport type { Behavior, BehaviorContext } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport {\n buildMenuTreeSchema,\n DEFAULT_MENU_DEPTH,\n MAX_MENU_DEPTH,\n MAX_MENU_TREE_NODES,\n measureMenuTree,\n} from '../menu-item-schema.js'\n\n/** Options for {@link menuTreeValidation}. */\nexport interface MenuTreeValidationOptions {\n /**\n * Resolve the locale codes accepted as `label` / `enabled` keys.\n *\n * Defaults to the `content()` plugin's configured locales, read LAZILY (per\n * write, never at module scope) so the behavior can be constructed before\n * the app boots. Injectable so the behavior is unit-testable without a\n * booted content plugin.\n */\n resolveAllowedLocales?: () => readonly string[]\n}\n\n/** One-issue `ZodError` at `tree`(`.path`) — the 400-mapped failure shape. */\nfunction treeError(message: string, path: Array<string | number> = []): z.ZodError {\n return new z.ZodError([{ code: z.ZodIssueCode.custom, path: ['tree', ...path], message }])\n}\n\n/** Prefix every issue path with `tree` so the client can locate the node. */\nfunction prefixIssues(error: z.ZodError): z.ZodError {\n return new z.ZodError(error.issues.map((issue) => ({ ...issue, path: ['tree', ...issue.path] })))\n}\n\n/** A `maxDepth` value from an (unvalidated, pre-field-validation) payload. */\nfunction readMaxDepth(value: unknown): number | undefined {\n if (typeof value !== 'number' || !Number.isInteger(value)) return undefined\n if (value < 1 || value > MAX_MENU_DEPTH) return undefined\n return value\n}\n\n/**\n * The effective `maxDepth` for this write.\n *\n * Hooks run BEFORE field validation, so a payload's `maxDepth` may be absent\n * or junk; junk falls through to the persisted/default value and is rejected a\n * moment later by the field's own `min`/`max`. On a partial update that\n * touches only `tree`, the persisted value comes from `loadCurrent()` (the\n * eagerly-loaded pre-update snapshot).\n */\nasync function resolveMaxDepth(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n isUpdate: boolean,\n): Promise<number> {\n const fromPayload = readMaxDepth(data.maxDepth)\n if (fromPayload !== undefined) return fromPayload\n if (!isUpdate) return DEFAULT_MENU_DEPTH\n\n if (!ctx?.loadCurrent) {\n // Fail loud rather than silently skipping the cross-field check — an\n // unvalidated depth is a correctness hole. AdminClient always wires\n // `loadCurrent` on the update codepath, so this only trips on a\n // mis-wired / direct hook invocation. (Same posture as `pathParent`.)\n throw new Error(\n 'menu: BehaviorContext.loadCurrent is unavailable — cannot validate the tree ' +\n \"against the menu's maxDepth. Writes must go through AdminClient.update.\",\n )\n }\n const current = await ctx.loadCurrent()\n // A vanished/unreadable row can't supply its own bound, so fall back to the\n // DEFAULT (3) — the same value a create would get — not the absolute cap\n // (6). Falling back to the ceiling would make an edge case the single most\n // PERMISSIVE path in the behavior; the safe fallback is the floor. The\n // absolute cap still applies separately, and the update itself fails\n // downstream when the row is genuinely gone.\n return readMaxDepth(current?.maxDepth) ?? DEFAULT_MENU_DEPTH\n}\n\n/**\n * The REVERSE cross-field check: an update that narrows `maxDepth` without\n * touching `tree`.\n *\n * `{\"maxDepth\": 1}` on a menu whose stored tree is 5 levels deep used to skip\n * validation entirely (the payload has no `tree`, so there was nothing to\n * measure) — the write succeeded and the row then PERMANENTLY violated\n * \"tree depth <= maxDepth\" until someone happened to edit the tree again.\n * Both directions of the invariant have to be enforced, so when `maxDepth`\n * moves we measure the PERSISTED tree against the new bound.\n */\nasync function validateStoredTreeAgainstNewMaxDepth(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n): Promise<void> {\n // Absent (or junk — the field's own min/max rejects that a moment later)\n // means this write doesn't move the bound, so there's nothing to re-check.\n const maxDepth = readMaxDepth(data.maxDepth)\n if (maxDepth === undefined) return\n\n if (!ctx?.loadCurrent) {\n // Same fail-loud posture as `resolveMaxDepth` — silently skipping the\n // check is precisely the hole this function closes.\n throw new Error(\n 'menu: BehaviorContext.loadCurrent is unavailable — cannot validate the stored tree ' +\n 'against the new maxDepth. Writes must go through AdminClient.update.',\n )\n }\n\n const current = await ctx.loadCurrent()\n // Measured iteratively and on a value already bounded at write time, so this\n // is cheap even for the largest legal tree.\n const { depth } = measureMenuTree(current?.tree)\n if (depth > maxDepth) {\n throw treeError(\n `the stored menu tree is nested ${depth} levels deep, deeper than this menu's ` +\n `new maxDepth (${maxDepth}) — flatten the tree first`,\n )\n }\n}\n\n/**\n * Validate + normalize `data.tree` in place. Returns the payload with `tree`\n * replaced by the PARSED value, so unknown properties a caller invented are\n * stripped before they reach the column.\n */\nasync function validateTree(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n isUpdate: boolean,\n resolveAllowedLocales: () => readonly string[],\n): Promise<Record<string, unknown>> {\n const tree = data.tree\n if (tree === undefined) {\n // No tree in the payload — but a `maxDepth`-only update still has to be\n // checked against the tree already in the column.\n if (isUpdate) await validateStoredTreeAgainstNewMaxDepth(data, ctx)\n return data\n }\n if (tree === null || !Array.isArray(tree)) {\n throw treeError('menu tree must be an array of menu items')\n }\n\n // Structural bounds FIRST — this walk is iterative, so it holds even for a\n // payload crafted to blow the recursive parse's stack.\n const { depth, nodeCount } = measureMenuTree(tree)\n if (nodeCount > MAX_MENU_TREE_NODES) {\n throw treeError(`menu tree has too many items (max ${MAX_MENU_TREE_NODES})`)\n }\n if (depth > MAX_MENU_DEPTH) {\n throw treeError(`menu tree is nested too deeply (absolute max ${MAX_MENU_DEPTH} levels)`)\n }\n\n const parsed = buildMenuTreeSchema({ allowedLocales: resolveAllowedLocales() }).safeParse(tree)\n if (!parsed.success) throw prefixIssues(parsed.error)\n\n const maxDepth = await resolveMaxDepth(data, ctx, isUpdate)\n if (depth > maxDepth) {\n throw treeError(`menu tree is nested deeper than this menu's maxDepth (${maxDepth})`)\n }\n\n return { ...data, tree: parsed.data }\n}\n\n/**\n * Recursive Zod validation of `menu.tree` on every create/update.\n *\n * Contributes NO fields, so the field-map type param is the EMPTY map\n * `Record<never, never>` — NOT `Record<string, never>`, which would merge into\n * the entity's field map and collapse every field to `never` (a break that\n * only surfaces through the built `.d.mts`).\n */\nexport function menuTreeValidation(\n options?: MenuTreeValidationOptions,\n): Behavior<Record<never, never>, 'menuTreeValidation'> {\n const resolveAllowedLocales =\n options?.resolveAllowedLocales ?? (() => getContentConfig().locales.map((l) => l.code))\n\n return {\n name: 'menuTreeValidation',\n hooks: {\n beforeCreate: async (data, ctx) => validateTree(data, ctx, false, resolveAllowedLocales),\n beforeUpdate: async (_id, data, ctx) => validateTree(data, ctx, true, resolveAllowedLocales),\n },\n }\n}\n","/**\n * The `menu` entity (plan/routing phase 08 §2, D008/D009).\n *\n * A menu is a normal entity whose item tree is ONE ordered nested JSONB column\n * — the family WordPress migrated to and Statamic uses, and the same storage\n * philosophy as our blocks layouts. Whole-tree edits save atomically in one\n * PATCH, and `publishable()` gives draft-then-publish menu editing for free.\n *\n * Locale variance lives INSIDE the tree (per-item `label` / `enabled` maps),\n * so there is deliberately no `translatable` field and no `menu_translations`\n * table — one structure serves every locale (D009).\n */\n\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { menuEntityTypesValidation } from './behaviors/menu-entity-types-validation.js'\nimport { menuTreeValidation } from './behaviors/menu-tree-validation.js'\nimport { DEFAULT_MENU_DEPTH, MAX_MENU_DEPTH } from './menu-item-schema.js'\n\nexport const Menu = defineEntity({\n name: 'menu',\n admin: {\n label: 'Menus',\n icon: 'menu',\n },\n fields: {\n // Fetch key — 'main', 'footer' (phase 07 §2.8). `from: 'title'` is the\n // admin form's client-side auto-fill source (SlugField watches it); the\n // value itself is author-chosen and stable, so `sluggable()` (which would\n // REGENERATE the handle when the title changes) is deliberately not used.\n // `field.slug()` already sets `unique` + `indexed` — no need to repeat them.\n handle: field.slug({ from: 'title' }),\n title: field.text({ required: true }),\n // 1..6 — the renderer clamps too (phase 07 §2.7).\n maxDepth: field.number({\n default: DEFAULT_MENU_DEPTH,\n min: 1,\n max: MAX_MENU_DEPTH,\n integer: true,\n }),\n // MenuItem[] — shape validated by `menuTreeValidation()`; `refScan` makes\n // every LinkValue in the tree register in `entity_refs` /\n // `external_link_usage` on save, so a menu item pointing at a page shows\n // up in delete-protection warnings and the broken-links dashboard\n // (phase 08 §4) with no menus-side ref code at all.\n tree: field.json({ refScan: true, default: [] }),\n // Which content types may be linked FROM this menu (phase 07 §2.8) — the\n // menu editor's picker and the Meta panel's Menus section both read it.\n // `null` = accepts every type, which is what every row written before this\n // column existed reads back as, so the whitelist is backward-compatible by\n // construction. Shape validated by `menuEntityTypesValidation()`; semantics\n // and the read-side predicate live in `./menu-entity-types.ts`.\n //\n // No `refScan`: the value holds entity NAMES, not `LinkValue`s — there is\n // no row to reference, so a scan would find nothing.\n entityTypes: field.json({ default: null }),\n },\n behaviors: [auditable(), publishable(), menuTreeValidation(), menuEntityTypesValidation()],\n scope: 'global',\n // Advisory metadata (the firewall is deny-by-default + operator grants).\n // Menus are site-wide navigation: a bad edit is visible on EVERY page, and\n // the tree is a structural artifact rather than content — so writes are\n // admin-only, while `view` is public because the frontend reads menus\n // unauthenticated through the content API.\n access: {\n view: 'public',\n create: 'group.admin',\n update: 'group.admin',\n delete: 'group.admin',\n },\n})\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"et-C_k3aK3T.mjs","names":[],"sources":["../src/messages/et.json"],"sourcesContent":[""],"mappings":""}
|
package/dist/i18n.d.mts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/i18n.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Load menus messages for a given locale.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* import { getMenusMessages } from '@murumets-ee/menus/i18n'
|
|
8
|
+
* import { mergeMessages } from '@murumets-ee/i18n'
|
|
9
|
+
*
|
|
10
|
+
* const messages = mergeMessages(appMessages, await getMenusMessages(locale))
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
declare function getMenusMessages(locale: string): Promise<Record<string, unknown>>;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { getMenusMessages };
|
|
16
|
+
//# sourceMappingURL=i18n.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n.d.mts","names":[],"sources":["../src/i18n.ts"],"mappings":";;AAWA;;;;;;;;AAAsE;;iBAAhD,gBAAA,CAAiB,MAAA,WAAiB,OAAO,CAAC,MAAA"}
|
package/dist/i18n.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n.mjs","names":[],"sources":["../src/i18n.ts"],"sourcesContent":["/**\n * Load menus messages for a given locale.\n *\n * @example\n * ```typescript\n * import { getMenusMessages } from '@murumets-ee/menus/i18n'\n * import { mergeMessages } from '@murumets-ee/i18n'\n *\n * const messages = mergeMessages(appMessages, await getMenusMessages(locale))\n * ```\n */\nexport async function getMenusMessages(locale: string): Promise<Record<string, unknown>> {\n switch (locale) {\n case 'et':\n return (await import('./messages/et.json')).default\n case 'ru':\n return (await import('./messages/ru.json')).default\n default:\n return (await import('./messages/en.json')).default\n }\n}\n"],"mappings":"AAWA,eAAsB,EAAiB,EAAkD,CACvF,OAAQ,EAAR,CACE,IAAK,KACH,OAAQ,MAAM,OAAO,qBAAA,CAAuB,QAC9C,IAAK,KACH,OAAQ,MAAM,OAAO,qBAAA,CAAuB,QAC9C,QACE,OAAQ,MAAM,OAAO,qBAAA,CAAuB,OAChD,CACF"}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { _ as validateTreeDepth, b as menuAcceptsEntityType, c as MAX_MENU_URL_LENGTH, d as MenuLinkValue, f as MenuSchemaOptions, g as measureMenuTree, h as buildMenuTreeSchema, i as LinkValueSchema, l as MenuItem, m as buildMenuItemSchema, o as MAX_MENU_DEPTH, p as MenuTreeMeasurement, r as DEFAULT_MENU_DEPTH, s as MAX_MENU_TREE_NODES, v as MAX_MENU_ENTITY_TYPE_WHITELIST, x as normalizeMenuEntityTypes, y as MenuEntityTypesSchema } from "./menu-item-schema-xXWZKw7t.mjs";
|
|
2
|
+
import { Behavior } from "@murumets-ee/entity";
|
|
3
|
+
|
|
4
|
+
//#region src/behaviors/menu-entity-types-validation.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Recursive-free Zod validation of `menu.entityTypes` on every create/update.
|
|
7
|
+
*
|
|
8
|
+
* Contributes NO fields, so the field-map type param is the EMPTY map
|
|
9
|
+
* `Record<never, never>` — NOT `Record<string, never>`, which would merge into
|
|
10
|
+
* the entity's field map and collapse every field to `never`.
|
|
11
|
+
*/
|
|
12
|
+
declare function menuEntityTypesValidation(): Behavior<Record<never, never>, 'menuEntityTypesValidation'>;
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/behaviors/menu-tree-validation.d.ts
|
|
15
|
+
/** Options for {@link menuTreeValidation}. */
|
|
16
|
+
interface MenuTreeValidationOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the locale codes accepted as `label` / `enabled` keys.
|
|
19
|
+
*
|
|
20
|
+
* Defaults to the `content()` plugin's configured locales, read LAZILY (per
|
|
21
|
+
* write, never at module scope) so the behavior can be constructed before
|
|
22
|
+
* the app boots. Injectable so the behavior is unit-testable without a
|
|
23
|
+
* booted content plugin.
|
|
24
|
+
*/
|
|
25
|
+
resolveAllowedLocales?: () => readonly string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Recursive Zod validation of `menu.tree` on every create/update.
|
|
29
|
+
*
|
|
30
|
+
* Contributes NO fields, so the field-map type param is the EMPTY map
|
|
31
|
+
* `Record<never, never>` — NOT `Record<string, never>`, which would merge into
|
|
32
|
+
* the entity's field map and collapse every field to `never` (a break that
|
|
33
|
+
* only surfaces through the built `.d.mts`).
|
|
34
|
+
*/
|
|
35
|
+
declare function menuTreeValidation(options?: MenuTreeValidationOptions): Behavior<Record<never, never>, 'menuTreeValidation'>;
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/entity.d.ts
|
|
38
|
+
/**
|
|
39
|
+
* The `menu` entity (plan/routing phase 08 §2, D008/D009).
|
|
40
|
+
*
|
|
41
|
+
* A menu is a normal entity whose item tree is ONE ordered nested JSONB column
|
|
42
|
+
* — the family WordPress migrated to and Statamic uses, and the same storage
|
|
43
|
+
* philosophy as our blocks layouts. Whole-tree edits save atomically in one
|
|
44
|
+
* PATCH, and `publishable()` gives draft-then-publish menu editing for free.
|
|
45
|
+
*
|
|
46
|
+
* Locale variance lives INSIDE the tree (per-item `label` / `enabled` maps),
|
|
47
|
+
* so there is deliberately no `translatable` field and no `menu_translations`
|
|
48
|
+
* table — one structure serves every locale (D009).
|
|
49
|
+
*/
|
|
50
|
+
declare const Menu: import("@murumets-ee/entity").Entity<{
|
|
51
|
+
id: import("@murumets-ee/entity").IdField;
|
|
52
|
+
} & import("@murumets-ee/entity").AuditableFields & import("@murumets-ee/entity").PublishableFields & Record<never, never> & {
|
|
53
|
+
handle: import("@murumets-ee/entity").SlugField & {
|
|
54
|
+
readonly from: "title";
|
|
55
|
+
};
|
|
56
|
+
title: import("@murumets-ee/entity").TextField & {
|
|
57
|
+
readonly required: true;
|
|
58
|
+
};
|
|
59
|
+
maxDepth: import("@murumets-ee/entity").NumberField & {
|
|
60
|
+
readonly default: 3;
|
|
61
|
+
readonly min: 1;
|
|
62
|
+
readonly max: 6;
|
|
63
|
+
readonly integer: true;
|
|
64
|
+
};
|
|
65
|
+
tree: import("@murumets-ee/entity").JsonField & {
|
|
66
|
+
readonly refScan: true;
|
|
67
|
+
readonly default: readonly [];
|
|
68
|
+
};
|
|
69
|
+
entityTypes: import("@murumets-ee/entity").JsonField & {
|
|
70
|
+
readonly default: null;
|
|
71
|
+
};
|
|
72
|
+
}, "menu", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, import("@murumets-ee/entity").Behavior<Record<never, never>, "menuTreeValidation">, import("@murumets-ee/entity").Behavior<Record<never, never>, "menuEntityTypesValidation">]>;
|
|
73
|
+
//#endregion
|
|
74
|
+
export { DEFAULT_MENU_DEPTH, LinkValueSchema, MAX_MENU_DEPTH, MAX_MENU_ENTITY_TYPE_WHITELIST, MAX_MENU_TREE_NODES, MAX_MENU_URL_LENGTH, Menu, MenuEntityTypesSchema, type MenuItem, type MenuLinkValue, type MenuSchemaOptions, type MenuTreeMeasurement, type MenuTreeValidationOptions, buildMenuItemSchema, buildMenuTreeSchema, measureMenuTree, menuAcceptsEntityType, menuEntityTypesValidation, menuTreeValidation, normalizeMenuEntityTypes, validateTreeDepth };
|
|
75
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/behaviors/menu-entity-types-validation.ts","../src/behaviors/menu-tree-validation.ts","../src/entity.ts"],"mappings":";;;;;;;;;;;iBAuFgB,yBAAA,CAAA,GAA6B,QAAQ,CACnD,MAAA;;;ACoGF;AAAA,UA/JiB,yBAAA;;;;;;;;;EASf,qBAAqB;AAAA;;AAwJL;;;;AC5KlB;;;iBD0KgB,kBAAA,CACd,OAAA,GAAU,yBAAA,GACT,QAAA,CAAS,MAAA;;;;;;;ADvGZ;;;;AACQ;;;;cEtEK,IAAA,gCAAI,MAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{d as e,f as t,h as n,i as r,l as i,m as a,n as o,o as s,p as c,r as l,s as u,t as d,u as f}from"./menu-entity-types-BnKnKbif.mjs";import{n as p,r as m,t as h}from"./entity-BxLeqoBC.mjs";export{s as DEFAULT_MENU_DEPTH,u as LinkValueSchema,i as MAX_MENU_DEPTH,d as MAX_MENU_ENTITY_TYPE_WHITELIST,f as MAX_MENU_TREE_NODES,e as MAX_MENU_URL_LENGTH,h as Menu,o as MenuEntityTypesSchema,t as buildMenuItemSchema,c as buildMenuTreeSchema,a as measureMenuTree,l as menuAcceptsEntityType,m as menuEntityTypesValidation,p as menuTreeValidation,r as normalizeMenuEntityTypes,n as validateTreeDepth};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{z as e}from"zod";import{sanitizeHref as t}from"@murumets-ee/blocks/links";const n=6,r=3,i=500,a=2048,o=128,s=/^[A-Za-z0-9_-]+$/,c=e.discriminatedUnion(`kind`,[e.object({kind:e.literal(`entity`),entity:e.string().min(1).max(64),id:e.string().uuid(),anchor:e.string().min(1).max(128).regex(s).optional()}),e.object({kind:e.literal(`url`),url:e.string().min(1).max(a).transform(e=>t(e)).refine(e=>e!==``,{message:`link url uses a disallowed protocol (allowed: http, https, mailto, tel, or a relative path)`})}),e.object({kind:e.literal(`email`),email:e.string().email().max(254)})]);function l(t,n){let r=new Set(n);return e.unknown().superRefine((t,n)=>{if(typeof t!=`object`||!t||Array.isArray(t))return;let i=Object.keys(t);if(i.length>r.size){n.addIssue({code:e.ZodIssueCode.custom,message:`too many locale keys — configured locales are: ${[...r].join(`, `)}`});return}for(let t of i)r.has(t)||n.addIssue({code:e.ZodIssueCode.custom,path:[t],message:`unknown locale key — configured locales are: ${[...r].join(`, `)}`})}).pipe(e.record(e.string(),t))}function u(t){let n=l(e.string().max(200),t.allowedLocales),r=l(e.boolean(),t.allowedLocales),i=e.lazy(()=>e.object({id:e.string().min(1).max(64),kind:e.enum([`link`,`header`]),link:c.optional(),label:n.nullable().default(null),enabled:r.optional(),newTab:e.boolean().optional(),children:e.array(i).default([])}).superRefine((t,n)=>{if(t.kind===`link`){t.link===void 0&&n.addIssue({code:e.ZodIssueCode.custom,path:[`link`],message:`a link item requires a link target`});return}t.link!==void 0&&n.addIssue({code:e.ZodIssueCode.custom,path:[`link`],message:`a header item must not carry a link target`}),(t.label===null||Object.keys(t.label).length===0)&&n.addIssue({code:e.ZodIssueCode.custom,path:[`label`],message:`a header item requires a label for at least one locale`})}));return i}function d(t){return e.array(u(t))}function f(e){if(!Array.isArray(e))return{depth:0,nodeCount:0};let t=0,n=0,r=e.map(e=>({node:e,depth:1}));for(;r.length>0;){let e=r.pop();if(e===void 0)break;let{node:i,depth:a}=e;if(typeof i!=`object`||!i||Array.isArray(i))continue;if(n++,a>t&&(t=a),n>500||t>6)return{depth:t,nodeCount:n};let o=i.children;if(Array.isArray(o)){let e=o;for(let t of e)r.push({node:t,depth:a+1})}}return{depth:t,nodeCount:n}}function p(e,t){return f(e).depth<=t}const m=64,h=e.string().min(1).max(64),g=e.array(h).max(64).nullable().transform(e=>{if(e===null)return null;let t=[...new Set(e)];return t.length===0?null:t});function _(e){if(!Array.isArray(e))return null;let t=e,n=[],r=new Set;for(let e of t)if(!(typeof e!=`string`||e.length===0)&&!(e.length>64)&&!r.has(e)&&(r.add(e),n.push(e),n.length===64))break;return n.length===0?null:n}function v(e,t){let n=_(e);return n===null||n.includes(t)}export{s as a,o as c,a as d,u as f,p as h,_ as i,n as l,f as m,g as n,r as o,d as p,v as r,c as s,m as t,i as u};
|
|
2
|
+
//# sourceMappingURL=menu-entity-types-BnKnKbif.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"menu-entity-types-BnKnKbif.mjs","names":["roots"],"sources":["../src/menu-item-schema.ts","../src/menu-entity-types.ts"],"sourcesContent":["/**\n * `MenuItem` — the menu tree node — and its recursive Zod validation\n * (plan/routing phase 08 §3).\n *\n * A menu's `tree` column is ordered nested JSONB (D008), so the ONLY thing\n * standing between an admin PATCH and the database is this schema: it is the\n * boundary validation required by CLAUDE.md's C5. It enforces\n *\n * - the `kind`-conditional shape (`link` items carry a `LinkValue`, `header`\n * items carry a label map and no link),\n * - locale keys drawn from the app's configured locale set,\n * - `LinkValue`s that mirror `@murumets-ee/blocks`' union exactly, with `url`\n * kinds protocol-gated through the SAME `sanitizeHref` allowlist the link\n * picker uses (no `javascript:` / `data:` / protocol-relative smuggling),\n * - hard structural bounds on tree size + nesting, checked BEFORE the\n * recursive parse so a hostile payload can't turn validation itself into a\n * stack-overflow / CPU amplification vector.\n *\n * This module is deliberately **pure**: no `@murumets-ee/core`, no content\n * plugin lookup, no DB. The allowed-locale set is passed IN, so the schema can\n * be built server-side (the `menuTreeValidation()` behavior) or client-side\n * (PR10's editor, with locales handed down from the server) from the same code.\n * Cross-field depth validation against the entity's sibling `maxDepth` column\n * lives in {@link validateTreeDepth}, which the behavior calls separately.\n */\n\nimport { sanitizeHref } from '@murumets-ee/blocks/links'\nimport { z } from 'zod'\n\n/**\n * Absolute nesting cap for any menu, independent of a menu's own `maxDepth`\n * (phase 07 §2.7 — \"depth is capped per menu (default 3, max 6)\"). Also the\n * bound that keeps the recursive parse from being a DoS vector.\n */\nexport const MAX_MENU_DEPTH = 6\n\n/** `maxDepth`'s default — a 3-level menu (phase 08 §2). */\nexport const DEFAULT_MENU_DEPTH = 3\n\n/**\n * Upper bound on the number of nodes in one menu tree. Navigation menus are\n * hand-curated: a few dozen items is a big one. The cap exists so a single\n * PATCH can't hand the validator (and `extractRefs`' ref walk, and every\n * subsequent render) an unbounded amount of work — CLAUDE.md's\n * \"no unbounded fan-out\" rule applied to a single request payload.\n */\nexport const MAX_MENU_TREE_NODES = 500\n\n/**\n * Max stored length of a `url`-kind link. Pinned to `entity`'s\n * `MAX_EXTERNAL_URL_LENGTH` (the `external_link_usage.url_raw` column width)\n * at COMPILE time via `satisfies` + a type-only import — so a longer URL is\n * rejected at the boundary instead of being silently dropped from the\n * reference graph. Same drift-proofing trick as content's\n * `DEFAULT_LOCALE_SENTINEL`; `import type` has zero runtime cost, so the pure\n * schema module keeps its (client-safe) dependency-free shape.\n */\nexport const MAX_MENU_URL_LENGTH =\n 2048 satisfies typeof import('@murumets-ee/entity/refs')['MAX_EXTERNAL_URL_LENGTH']\n\n/** Max length of an item's stable id (a UUID today; generated client-side). */\nconst MAX_ITEM_ID_LENGTH = 64\n\n/** Max length of a per-locale label string. */\nconst MAX_LABEL_LENGTH = 200\n\n/**\n * Max length of an entity-link fragment/anchor.\n *\n * Exported because this module is the SINGLE OWNER of the anchor bounds: the\n * write-side schema below, the read-side anchor walk\n * (`admin/anchors.ts`) and the editor's manual-entry fallback\n * (`@murumets-ee/menus-ui`'s anchor picker, via the client-safe `./schema`\n * subpath) all validate against exactly these two constants. Two of those three\n * used to carry their own copy; a drift between them would let the picker offer\n * — or the editor accept — a value the write boundary then rejects.\n */\nexport const MAX_ANCHOR_LENGTH = 128\n\n/**\n * Max length of an entity NAME inside an entity link (`page`, `article`, …).\n *\n * Exported for the same single-owner reason as {@link MAX_ANCHOR_LENGTH}: the\n * per-menu `entityTypes` whitelist (`./menu-entity-types.ts`) stores values of\n * exactly this kind, and a second copy of the bound could drift into a\n * whitelist entry the link schema then refuses.\n */\nexport const MAX_ENTITY_NAME_LENGTH = 64\n\n/**\n * Anchors are slugified by the block editor (phase 08 §5) and rendered into an\n * `href` as `#<anchor>`; restricting them to slug characters keeps that\n * interpolation inert.\n *\n * Exported for the same single-owner reason as {@link MAX_ANCHOR_LENGTH}.\n */\nexport const ANCHOR_PATTERN = /^[A-Za-z0-9_-]+$/\n\n/**\n * A menu item's link target — the item-type union phase 07 §2.2 and phase 08 §3\n * define: `entity` (+ optional anchor) | `url` | `email`.\n *\n * **A NARROWED SUBSET of `@murumets-ee/blocks`' `LinkValue`, deliberately.**\n * Blocks additionally carries a `media` kind; menus does NOT, because no phase\n * gives a menu a media-href seam to resolve one with (phase 11 §2 specifies\n * `href` as \"canonical path from `toolkit_paths`; external URLs verbatim;\n * header items null\" and nothing else). Accepting a `media` link would let it\n * validate, persist, and register an `entity_refs` row — arming delete\n * protection for that file — while the renderer had no way to emit it: the item\n * would silently never appear. Rejecting it at the write boundary turns that\n * dead weight into a real 400. Widening later is additive (an arm here plus a\n * `resolveMediaHref` dep on `MenuResolveDeps`) and needs no shape change.\n *\n * Every `MenuLinkValue` IS a valid blocks `LinkValue` (the subset direction, and\n * the one `resolveLinkHref` needs) — pinned at compile time by an assignment in\n * `menu-item-schema.test.ts`, so a drift in blocks' union is a build error\n * rather than a silent divergence.\n *\n * The `anchor?: string | undefined` is forced by the combination of Zod and this\n * repo's `exactOptionalPropertyTypes`: `.optional()` always widens to\n * `T | undefined`, which is NOT assignable to blocks' `anchor?: string` under\n * that flag.\n */\nexport type MenuLinkValue =\n | { kind: 'entity'; entity: string; id: string; anchor?: string | undefined }\n | { kind: 'url'; url: string }\n | { kind: 'email'; email: string }\n\n/**\n * A menu tree node (phase 08 §3 / D009).\n *\n * `label: null` means \"use the LIVE title of the linked entity, per locale\";\n * a map is an explicit per-locale override. `enabled` is per-locale visibility\n * in BOTH directions — absent/`true` shows the item, `false` hides it in that\n * locale — so one tree serves every locale (no `menu_translations` table).\n *\n * Declared as a `type`, NOT an `interface`, on purpose: only type aliases get\n * TypeScript's implicit index signature, and without it `MenuItem[]` is not\n * assignable to the `Record<string, unknown>[]` arm of `AdminClient.create/\n * update`'s json-field value type — every caller storing a typed tree would\n * need a cast.\n */\nexport type MenuItem = {\n /** Stable id — DND identity + anchor for audit diffs. */\n id: string\n /** `header` = a text-only dropdown parent with no link of its own. */\n kind: 'link' | 'header'\n /**\n * Present iff `kind === 'link'`.\n *\n * The explicit `| undefined` is required by the repo's\n * `exactOptionalPropertyTypes` — Zod's `.optional()` emits\n * `LinkValue | undefined`, and without it the schema wouldn't typecheck\n * against this interface.\n */\n link?: MenuLinkValue | undefined\n /** `null` = live entity title per locale; a map = explicit override. */\n label: Record<string, string> | null\n /** Per-locale visibility. Absent/`true` = visible; `false` = hidden. */\n enabled?: Record<string, boolean> | undefined\n newTab?: boolean | undefined\n children: MenuItem[]\n}\n\n/**\n * One menu item's target, as the editor needs to render its row\n * (`@murumets-ee/menus/admin`'s `resolveMenuItemSummaries`).\n *\n * Declared here — not in `admin/summaries.ts` — for the same reason\n * {@link BlockAnchor} is: it is a pure data shape with no server dependency,\n * so the client-safe `./schema` subpath can re-export it without pulling the\n * server-only resolver along. `import type` is erased at compile time either\n * way, but a single canonical home (this leaf module) is simpler to reason\n * about than \"which of two server-only files owns the type\".\n */\nexport interface MenuItemSummary {\n /**\n * The target row's live title, or `null` when the entity declares no\n * display-able field. Unlike the public render path — which DROPS such an\n * item rather than show a raw uuid as navigation — the admin editor still\n * reports the target as present, just unlabelled.\n */\n title: string | null\n /** `false` = the target row is gone (deleted) → a BROKEN item. */\n exists: boolean\n /** `null` when the entity isn't publishable — no draft/published distinction applies. */\n published: boolean | null\n}\n\n/**\n * One addressable block anchor on a document (`@murumets-ee/menus/admin`'s\n * `collectBlockAnchors` / the anchors endpoint). See {@link MenuItemSummary}'s\n * doc for why this pure shape lives here rather than in `admin/anchors.ts`.\n */\nexport interface BlockAnchor {\n /** The stable id rendered as the block element's `id` attribute. */\n anchor: string\n /** Display text — `anchorLabel` when usable, else the anchor value itself. */\n label: string\n}\n\n/** Options for {@link buildMenuItemSchema} / {@link buildMenuTreeSchema}. */\nexport interface MenuSchemaOptions {\n /**\n * Locale codes accepted as keys in `label` / `enabled` maps. Anything else\n * is a validation error — a typo'd locale would produce a label no renderer\n * ever reads (the same failure mode content's `validateLocale` guards).\n */\n allowedLocales: readonly string[]\n}\n\n/**\n * {@link MenuLinkValue} as Zod. Blocks models its own `LinkValue` as a plain TS\n * type + structural guards (zod-free by design), so this is the first Zod\n * encoding of it — narrowed to the three kinds a menu item may carry.\n *\n * There is deliberately NO `media` arm (see {@link MenuLinkValue}): a media\n * link has no href seam at render time, so accepting one would store a\n * permanently invisible item. `z.discriminatedUnion` rejects it with an\n * `Invalid discriminator value. Expected 'entity' | 'url' | 'email'` issue —\n * which names the allowed set without echoing the caller's own value back.\n *\n * `url` kinds run through `sanitizeHref`, the single source of truth for the\n * href protocol allowlist (`http:`/`https:`/`mailto:`/`tel:` + relative\n * paths); it returns `''` for anything it rejects, including\n * protocol-relative `//evil.com` and obfuscated `java\\nscript:`.\n */\nexport const LinkValueSchema = z.discriminatedUnion('kind', [\n z.object({\n kind: z.literal('entity'),\n entity: z.string().min(1).max(MAX_ENTITY_NAME_LENGTH),\n id: z.string().uuid(),\n anchor: z.string().min(1).max(MAX_ANCHOR_LENGTH).regex(ANCHOR_PATTERN).optional(),\n }),\n z.object({\n kind: z.literal('url'),\n // Transform-then-refine, NOT refine-alone: `sanitizeHref` trims, so\n // validating its output while storing the raw input would let a value\n // that PASSED validation differ from the value that gets STORED (leading\n // /trailing whitespace). Storing the sanitizer's own return value keeps\n // stored == validated. The arrow wrapper is required — `sanitizeHref`'s\n // second parameter is the protocol allowlist, and passing Zod's ctx into\n // it would silently replace the allowlist.\n url: z\n .string()\n .min(1)\n .max(MAX_MENU_URL_LENGTH)\n .transform((value) => sanitizeHref(value))\n .refine((value) => value !== '', {\n message:\n 'link url uses a disallowed protocol (allowed: http, https, mailto, tel, or a relative path)',\n }),\n }),\n z.object({\n kind: z.literal('email'),\n email: z.string().email().max(254),\n }),\n])\n\n/**\n * A `Record<locale, T>` whose keys must all be configured locale codes.\n *\n * The key check is PIPED INTO the record parse rather than refined on top of\n * it, so it runs against the RAW value BEFORE any per-value parsing. That\n * ordering is the whole point: with `z.record(...).superRefine(...)`, zod\n * parses all N values first, so a 1,000,000-key map of wrong-typed values\n * emits 1,000,000 `invalid_type` issues before a width check on top could ever\n * fire (verified empirically against zod 3.25). Piping caps BOTH shapes —\n * unknown keys and bad values — at a single issue.\n */\nfunction localeMap<T extends z.ZodTypeAny>(\n valueSchema: T,\n allowedLocales: readonly string[],\n): z.ZodType<Record<string, z.infer<T>>, z.ZodTypeDef, unknown> {\n const allowed = new Set(allowedLocales)\n return z\n .unknown()\n .superRefine((value, ctx) => {\n // Not a plain object — let the record parse below emit the single\n // \"expected object\" issue rather than duplicating its message here.\n if (value === null || typeof value !== 'object' || Array.isArray(value)) return\n\n const keys = Object.keys(value)\n // Width bound, checked FIRST. A legitimate map holds at most one entry\n // per configured locale, so a wider map is malformed by construction and\n // gets ONE issue for the whole map. Without this, a single node carrying\n // e.g. 1,000,000 keys sails past the tree pre-scan (which bounds node\n // COUNT and DEPTH, never map WIDTH) and turns validation itself into the\n // amplification vector it exists to prevent: a multi-hundred-MB issue\n // array plus an oversized audit-log write, from one request.\n // CLAUDE.md's \"no unbounded fan-out\" applied to a single payload.\n if (keys.length > allowed.size) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `too many locale keys — configured locales are: ${[...allowed].join(', ')}`,\n })\n return\n }\n\n // Past the width bound this loop is bounded by the CONFIGURED locale\n // count, never by the payload.\n for (const key of keys) {\n if (!allowed.has(key)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [key],\n // Names the allowed set (server-side data), never echoes the\n // caller-supplied key into the message — see the api-handler's\n // \"refine()/custom messages must not echo user input\" contract.\n message: `unknown locale key — configured locales are: ${[...allowed].join(', ')}`,\n })\n }\n }\n })\n .pipe(z.record(z.string(), valueSchema))\n}\n\n/**\n * The recursive per-node schema. Input is `unknown` (this parses untrusted\n * JSONB), output is a fully-narrowed {@link MenuItem}. Unknown properties are\n * STRIPPED by Zod's default object behavior, so writing the parse result back\n * onto the payload also sanitizes it — nothing a caller invents gets stored.\n */\nexport function buildMenuItemSchema(\n options: MenuSchemaOptions,\n): z.ZodType<MenuItem, z.ZodTypeDef, unknown> {\n const labelMap = localeMap(z.string().max(MAX_LABEL_LENGTH), options.allowedLocales)\n const enabledMap = localeMap(z.boolean(), options.allowedLocales)\n\n const schema: z.ZodType<MenuItem, z.ZodTypeDef, unknown> = z.lazy(() =>\n z\n .object({\n id: z.string().min(1).max(MAX_ITEM_ID_LENGTH),\n kind: z.enum(['link', 'header']),\n link: LinkValueSchema.optional(),\n label: labelMap.nullable().default(null),\n enabled: enabledMap.optional(),\n newTab: z.boolean().optional(),\n children: z.array(schema).default([]),\n })\n .superRefine((node, ctx) => {\n if (node.kind === 'link') {\n if (node.link === undefined) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['link'],\n message: 'a link item requires a link target',\n })\n }\n return\n }\n // header\n if (node.link !== undefined) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['link'],\n message: 'a header item must not carry a link target',\n })\n }\n // A header has no entity to borrow a live title from, so its label\n // map is required (phase 08 §3) — otherwise it renders as nothing.\n if (node.label === null || Object.keys(node.label).length === 0) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['label'],\n message: 'a header item requires a label for at least one locale',\n })\n }\n }),\n )\n\n return schema\n}\n\n/** The whole tree — an array of {@link buildMenuItemSchema} nodes. */\nexport function buildMenuTreeSchema(\n options: MenuSchemaOptions,\n): z.ZodType<MenuItem[], z.ZodTypeDef, unknown> {\n return z.array(buildMenuItemSchema(options))\n}\n\n/** Result of {@link measureMenuTree}. */\nexport interface MenuTreeMeasurement {\n /** Deepest nesting level; `0` for an empty tree, `1` for flat items. */\n depth: number\n /** Total node count across every level. */\n nodeCount: number\n}\n\n/**\n * Measure a (possibly untrusted) tree's depth + node count **iteratively**, so\n * measuring can never blow the stack the way a recursive walk over a hostile\n * payload would. Stops early once either hard bound is exceeded and reports\n * the over-limit value — the caller turns that into a validation error.\n *\n * Accepts `unknown` on purpose: this runs BEFORE the Zod parse (bounding the\n * work that parse will do), so it can't assume a well-formed shape.\n */\nexport function measureMenuTree(tree: unknown): MenuTreeMeasurement {\n if (!Array.isArray(tree)) return { depth: 0, nodeCount: 0 }\n\n let maxDepth = 0\n let nodeCount = 0\n // `Array.isArray` narrows an `unknown` to `any[]`; re-binding through a\n // `readonly unknown[]` annotation keeps every element typed as `unknown`\n // instead of silently leaking `any` into the walk.\n const roots: readonly unknown[] = tree\n const stack: Array<{ node: unknown; depth: number }> = roots.map((node) => ({ node, depth: 1 }))\n\n while (stack.length > 0) {\n const current = stack.pop()\n if (current === undefined) break\n const { node, depth } = current\n if (node === null || typeof node !== 'object' || Array.isArray(node)) continue\n\n nodeCount++\n if (depth > maxDepth) maxDepth = depth\n // Early abort: past either bound the exact totals no longer matter, only\n // that they're over — and continuing would be the amplification we're\n // guarding against.\n if (nodeCount > MAX_MENU_TREE_NODES || maxDepth > MAX_MENU_DEPTH) {\n return { depth: maxDepth, nodeCount }\n }\n\n const children = (node as { children?: unknown }).children\n if (Array.isArray(children)) {\n const items: readonly unknown[] = children\n for (const child of items) stack.push({ node: child, depth: depth + 1 })\n }\n }\n\n return { depth: maxDepth, nodeCount }\n}\n\n/**\n * Cross-field check: does `tree` nest no deeper than the menu's own\n * `maxDepth`? Lives outside the node schema because a per-node Zod refinement\n * has no visibility of the sibling `maxDepth` COLUMN — the behavior calls this\n * after the recursive parse.\n */\nexport function validateTreeDepth(tree: unknown, maxDepth: number): boolean {\n return measureMenuTree(tree).depth <= maxDepth\n}\n","/**\n * The per-menu `entityTypes` whitelist — \"which content types may be linked\n * from this menu\" (plan/routing phase 07 §2.8, 09 §3, 10 §4).\n *\n * Drupal's per-content-type menu offering, minus the config-entity ceremony: a\n * `page` doesn't declare \"I belong in menus\", each MENU declares which types it\n * accepts. Two surfaces consume it — the menu editor's \"Add link\" picker (which\n * content types its Content tab offers) and the Meta panel's Menus section\n * (which menus an entity's edit screen offers to join).\n *\n * ## Semantics — `null` is \"everything\", and it is the ONLY unrestricted shape\n *\n * `null` (or an absent column) = the menu accepts every entity type. That is the\n * default, and it is what every menu row written before this column existed\n * reads back as, so the whitelist is backward-compatible by construction.\n *\n * An EMPTY array means the same thing, and {@link MenuEntityTypesSchema}\n * normalises it to `null` on the way in — the editor's \"nothing selected\" state\n * naturally produces `[]`, and letting both spellings persist would mean every\n * reader had to remember to check for two. Readers still tolerate a stored `[]`\n * (a hand-edited row, an older dump) via {@link menuAcceptsEntityType}.\n *\n * A menu that accepts NOTHING is deliberately unrepresentable: it could only\n * ever be a mistake, and \"empty selection\" is far more likely to mean \"I haven't\n * narrowed this yet\" than \"refuse everything\".\n *\n * ## Not a security boundary\n *\n * This is an authoring affordance. It decides what a picker OFFERS; it does not\n * decide what a caller may write — `menu:update` on the entity PATCH does, and\n * `menuTreeValidation()` re-checks the tree's shape regardless of the whitelist.\n * So the read path fails OPEN (a malformed column means \"no restriction\"): a\n * junk value must not hide every menu from the admin who needs to fix it. The\n * WRITE path fails closed, as every boundary does.\n *\n * @module\n */\n\nimport { z } from 'zod'\nimport { MAX_ENTITY_NAME_LENGTH } from './menu-item-schema.js'\n\n/**\n * Upper bound on how many entity types one menu's whitelist may name.\n *\n * A real app registers a few dozen entities, so this is a malformed-payload\n * guard rather than a product limit — it keeps a hostile PATCH from parking an\n * unbounded array in the column (and, through it, in every editor's RSC\n * payload).\n *\n * **Not** `MAX_MENU_ENTITY_TYPES` (`./get-menu.ts`, 16). That one bounds RENDER\n * fan-out — how many distinct types one menu render may issue batched lookups\n * for — and applies to the types actually present in the stored tree. This one\n * bounds the stored AUTHORING whitelist, which costs no I/O at all.\n */\nexport const MAX_MENU_ENTITY_TYPE_WHITELIST = 64\n\n/** One entity name in the whitelist — same bound `link.entity` carries. */\nconst EntityTypeName = z.string().min(1).max(MAX_ENTITY_NAME_LENGTH)\n\n/**\n * The write-boundary schema for `menu.entityTypes`.\n *\n * Strict where {@link normalizeMenuEntityTypes} is tolerant: a junk entry is a\n * 400, not a silent drop, because silently discarding half an admin's selection\n * and reporting success is worse than refusing the write. Duplicates ARE folded\n * (an idempotent selection is not an error), and `[]` becomes `null` so the\n * column only ever holds one representation of \"unrestricted\".\n */\nexport const MenuEntityTypesSchema: z.ZodType<string[] | null, z.ZodTypeDef, unknown> = z\n .array(EntityTypeName)\n // DELIBERATE ORDER — `.max()` runs on the RAW array, BEFORE the dedup in the\n // transform below, so 65 copies of `'page'` is a 400 even though it would\n // store one entry. The bound is on the PAYLOAD, not on the result: deduping\n // first would mean accepting an unbounded array to prove it was small.\n .max(MAX_MENU_ENTITY_TYPE_WHITELIST)\n .nullable()\n .transform((value) => {\n if (value === null) return null\n const deduped = [...new Set(value)]\n return deduped.length === 0 ? null : deduped\n })\n\n/**\n * Narrow a raw `entityTypes` column value (untrusted shape) to a usable\n * whitelist, or `null` for \"no restriction\".\n *\n * The read-side counterpart of {@link MenuEntityTypesSchema}, and tolerant for\n * the same reason `normalizeMenuTree` is: rows written before this column\n * existed, hand edits and restored dumps all reach readers, and a render must\n * degrade rather than throw. Anything unusable — a non-array, a non-string\n * entry, a whitelist that reduces to nothing — reads as `null`.\n */\nexport function normalizeMenuEntityTypes(value: unknown): string[] | null {\n if (!Array.isArray(value)) return null\n // `Array.isArray` narrows `unknown` to `any[]`; re-binding through a\n // `readonly unknown[]` annotation keeps every element typed as `unknown`.\n const raw: readonly unknown[] = value\n\n const out: string[] = []\n const seen = new Set<string>()\n for (const entry of raw) {\n if (typeof entry !== 'string' || entry.length === 0) continue\n if (entry.length > MAX_ENTITY_NAME_LENGTH) continue\n if (seen.has(entry)) continue\n seen.add(entry)\n out.push(entry)\n if (out.length === MAX_MENU_ENTITY_TYPE_WHITELIST) break\n }\n return out.length === 0 ? null : out\n}\n\n/**\n * Does this menu accept links to `entityType`?\n *\n * Takes the RAW column value so callers holding a `field.json()`-typed row can\n * ask directly, without narrowing first. Exact string match — entity names are\n * lowercase identifiers, and a case-insensitive or prefix match would silently\n * widen a whitelist the admin wrote precisely.\n *\n * Fails OPEN (see the module header): absent, empty and malformed all mean \"no\n * restriction\".\n */\nexport function menuAcceptsEntityType(value: unknown, entityType: string): boolean {\n const whitelist = normalizeMenuEntityTypes(value)\n return whitelist === null || whitelist.includes(entityType)\n}\n"],"mappings":"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"}
|