@murumets-ee/content 0.87.0 → 0.89.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.
Files changed (53) hide show
  1. package/dist/admin.d.mts.map +1 -1
  2. package/dist/admin.mjs +1 -1
  3. package/dist/block-resolver.mjs +1 -1
  4. package/dist/client.d.mts +1 -1
  5. package/dist/client.mjs +1 -1
  6. package/dist/client.mjs.map +1 -1
  7. package/dist/{enumerate-CBqu63Kp.mjs → enumerate-CYHJKQz6.mjs} +2 -2
  8. package/dist/{enumerate-CBqu63Kp.mjs.map → enumerate-CYHJKQz6.mjs.map} +1 -1
  9. package/dist/{index-C2G4IJck.d.mts → index-C3VyZLjf.d.mts} +55 -2
  10. package/dist/{index-C2G4IJck.d.mts.map → index-C3VyZLjf.d.mts.map} +1 -1
  11. package/dist/index.d.mts +3 -3
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/locale-entity-CoSq3C77.mjs +2 -0
  15. package/dist/locale-entity-CoSq3C77.mjs.map +1 -0
  16. package/dist/locale-registry-Bn6e_nM7.mjs +2 -0
  17. package/dist/locale-registry-Bn6e_nM7.mjs.map +1 -0
  18. package/dist/plugin-BmxghXzV.mjs +2 -0
  19. package/dist/plugin-BmxghXzV.mjs.map +1 -0
  20. package/dist/plugin.d.mts +4 -4
  21. package/dist/plugin.mjs +1 -1
  22. package/dist/resolve-context-Cvc1aNJe.mjs +2 -0
  23. package/dist/resolve-context-Cvc1aNJe.mjs.map +1 -0
  24. package/dist/resolve.d.mts +54 -16
  25. package/dist/resolve.d.mts.map +1 -1
  26. package/dist/resolve.mjs +6 -6
  27. package/dist/resolve.mjs.map +1 -1
  28. package/dist/routes-BmNSYyuT.mjs +2 -0
  29. package/dist/{routes-C9I1iZw8.mjs.map → routes-BmNSYyuT.mjs.map} +1 -1
  30. package/dist/routing-strategy-D6B4ngT9.mjs +2 -0
  31. package/dist/routing-strategy-D6B4ngT9.mjs.map +1 -0
  32. package/dist/{routing-strategy-D3Qowm3D.d.mts → routing-strategy-DV7KGarX.d.mts} +4 -4
  33. package/dist/{routing-strategy-D3Qowm3D.d.mts.map → routing-strategy-DV7KGarX.d.mts.map} +1 -1
  34. package/dist/schema-BYrYzsmO.mjs +2 -0
  35. package/dist/schema-BYrYzsmO.mjs.map +1 -0
  36. package/dist/schema-IML1rRU_.mjs +2 -0
  37. package/dist/{schema-0zayzpD_.mjs.map → schema-IML1rRU_.mjs.map} +1 -1
  38. package/dist/seed.d.mts.map +1 -1
  39. package/dist/seed.mjs +1 -1
  40. package/dist/seed.mjs.map +1 -1
  41. package/dist/{versions-C9A4rUAR.mjs → versions-DLrR7hIZ.mjs} +2 -2
  42. package/dist/{versions-C9A4rUAR.mjs.map → versions-DLrR7hIZ.mjs.map} +1 -1
  43. package/package.json +9 -9
  44. package/dist/plugin-D0i6odXQ.mjs +0 -2
  45. package/dist/plugin-D0i6odXQ.mjs.map +0 -1
  46. package/dist/resolve-context-MRZZ3rQl.mjs +0 -2
  47. package/dist/resolve-context-MRZZ3rQl.mjs.map +0 -1
  48. package/dist/routes-C9I1iZw8.mjs +0 -2
  49. package/dist/routing-strategy-CQdu1qV7.mjs +0 -2
  50. package/dist/routing-strategy-CQdu1qV7.mjs.map +0 -1
  51. package/dist/schema-0zayzpD_.mjs +0 -2
  52. package/dist/schema-D8sBQozw.mjs +0 -2
  53. package/dist/schema-D8sBQozw.mjs.map +0 -1
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{n as e,t}from"./versionable-Br_G5VGR.mjs";import{i as n,n as r,r as i,t as a}from"./errors-NRNILhhm.mjs";import{a as o,i as s,n as c,o as l,r as u,t as d}from"./routing-strategy-CQdu1qV7.mjs";import{t as f}from"./content-config-store-8omQF5p5.mjs";import{USER_ID_MAX_LENGTH as p,field as m}from"@murumets-ee/entity";import{createSystemReachResolver as h,mergeConfigOverrides as g}from"@murumets-ee/core";import{QueryClient as _}from"@murumets-ee/entity/query";import{schemaRegistry as v}from"@murumets-ee/db";import{eq as y}from"drizzle-orm";const b=new Set([`hide`,`patch`,`replace`]),x=Symbol.for(`@murumets-ee/content:withOverrides`),S=new Set([`__proto__`,`constructor`,`prototype`]);function C(e,t,n=0){if(e!=null&&!(typeof e!=`object`||Array.isArray(e)))for(let r of Object.keys(e)){if(S.has(r))throw Error(`withOverrides: reserved key '${r}' at ${t} is not allowed.`);n<3&&C(e[r],`${t}.${r}`,n+1)}}function w(e){if(e==null)return{};if(typeof e!=`object`||Array.isArray(e))throw Error(`withOverrides: _overrides must be a plain object map.`);let t=e,n=Object.keys(t);if(n.length>100)throw Error(`withOverrides: _overrides has ${n.length} entries; cap is 100.`);let r={};for(let e of n){if(S.has(e))throw Error(`withOverrides: reserved key '${e}' is not allowed.`);let n=t[e];if(typeof n!=`object`||!n||Array.isArray(n))throw Error(`withOverrides: _overrides['${e}'] must be a directive object.`);let i=n.kind;if(typeof i!=`string`||!b.has(i))throw Error(`withOverrides: _overrides['${e}'].kind must be one of [hide, patch, replace]; got ${String(i)}.`);if(i===`patch`){let t=n;T(t.props,`_overrides['${e}'].props`),T(t.traits,`_overrides['${e}'].traits`)}if(i===`replace`){let t=n.block;if(typeof t!=`object`||!t||Array.isArray(t)||typeof t.id!=`string`)throw Error(`withOverrides: _overrides['${e}'] replace payload must include an object 'block' with a string 'id'.`)}C(n,`_overrides['${e}']`),r[e]=n}return r}function T(e,t){if(e!==void 0&&(typeof e!=`object`||!e||Array.isArray(e)))throw Error(`withOverrides: ${t} must be an object.`)}function E(){return{name:`withOverrides`,fields:{_overrides:m.json({default:()=>({}),internal:!0,refScan:!0})},hooks:{beforeCreate:async e=>(e._overrides===void 0?e._overrides={}:e._overrides=w(e._overrides),e),beforeUpdate:async(e,t)=>(t._overrides===void 0||(t._overrides=w(t._overrides)),t)},[x]:!0}}function D(e){return e?e.some(e=>!!e?.[x]):!1}const O=[`draft`,`in_review`,`approved`,`rejected`,`published`],k={draft:[`in_review`],in_review:[`approved`,`rejected`],approved:[`published`,`in_review`],rejected:[`in_review`,`draft`],published:[`draft`]};function A(e,t){return e===t?!0:k[e]?.includes(t)??!1}var j=class extends Error{from;to;constructor(e,t){super(`Invalid workflow transition: ${e} → ${t}. Allowed transitions from "${e}": [${(k[e]??[]).join(`, `)}].`),this.name=`WorkflowTransitionError`,this.from=e,this.to=t}};function M(){return{name:`workflowable`,fields:{_workflowStatus:m.select({options:O,default:`draft`,indexed:!0,required:!0,internal:!0}),_workflowAssignee:m.text({required:!1,internal:!0,maxLength:p}),_workflowNote:m.text({required:!1,internal:!0}),_workflowUpdatedAt:m.date({internal:!0})},hooks:{beforeCreate:async e=>(e._workflowStatus=`draft`,e._workflowUpdatedAt=new Date,e),beforeUpdate:async(e,t,n)=>{let r=t._workflowStatus;if(r===void 0)return t;if(!O.includes(r))throw new j(`draft`,r);let i=(await n?.loadCurrent?.())?._workflowStatus??`draft`,a=r;if(!A(i,a))throw new j(i,a);return t._workflowUpdatedAt=new Date,t}}}}function N(e,t){switch(t){case`drafts`:return P(e,`publishable`);case`versions`:return P(e,`versionable`);case`localeStatus`:return P(e,`publishable`)&&F(e);case`translations`:return F(e)}}function P(e,t){return e.behaviors?.some(e=>e.name===t)??!1}function F(e){let t=e.allFields;for(let e of Object.keys(t)){let n=t[e];if(n&&n.translatable===!0)return!0}return!1}const I=h(`locale-registry`);function L(){return f().locales.map((e,t)=>({code:e.code,label:e.label,enabled:!0,direction:`ltr`,sortOrder:t}))}function R(e,t){return e.sortOrder-t.sortOrder||e.code.localeCompare(t.code)}async function z(e){return e.entities?.has(`locale`)?(await new _({entity:s,db:e.db.readOnly,logger:e.logger.child({registry:`locale`}),contextResolver:I}).findMany({limit:1e3})).map(e=>{let t=e;return{code:String(t.code),label:typeof t.label==`string`&&t.label.length>0?t.label:String(t.code),enabled:t.enabled!==!1,direction:t.direction===`rtl`?`rtl`:`ltr`,sortOrder:typeof t.sortOrder==`number`?t.sortOrder:0}}):[]}async function B(e){let t;try{t=await z(e)}catch(t){return e.logger.warn({err:t},`locale registry read failed — falling back to content() defaults`),[...L()].sort(R)}return g({defaults:L(),overrides:t,idOfDefault:e=>e.code,idOfOverride:e=>e.code}).sort(R)}function V(e){return e.filter(e=>e.enabled).map(e=>e.code)}async function H(e,t){let n=V(t??await B(e)),r=f().defaultLocale,i={};try{let{createSettingsClient:t}=await import(`@murumets-ee/settings/runtime`);i=await t(l,{app:e}).getAll()??{}}catch(t){e.logger.warn({err:t},`i18n settings read failed — falling back to content() defaults`)}let a=typeof i.defaultLocale==`string`&&i.defaultLocale.length>0?i.defaultLocale:r,s=n.includes(a)?a:n.includes(r)?r:n[0]??r,c=i.routingStrategy;return{defaultLocale:s,routingStrategy:o.includes(typeof c==`string`?c:``)?c:`no-prefix-default`}}const U=h(`message-registry`),W=2e4;async function G(e,t){if(!e.entities?.has(`message`))return[];let n=v.get(`message`);if(!n)return[];let r=new _({entity:u,db:e.db.readOnly,logger:e.logger.child({registry:`message`}),contextResolver:U});try{let i=await r.findMany({where:y(n.locale,t),limit:W});return i.length>=W&&e.logger.warn({locale:t,cap:W},`message override read hit the row cap — some overrides were not applied`),i.map(e=>{let t=e;return{namespace:String(t.namespace),key:String(t.key),value:String(t.value)}})}catch(n){return e.logger.warn({err:n,locale:t},`message registry read failed — falling back to shipped defaults only`),[]}}export{a as DraftNotFoundError,r as EntityCapabilityError,i as InvalidLocaleError,s as Locale,u as Message,o as ROUTING_STRATEGIES,n as VersionNotFoundError,O as WORKFLOW_STATES,k as WORKFLOW_TRANSITIONS,j as WorkflowTransitionError,V as enabledLocaleCodes,N as entitySupports,t as getVersionableOptions,D as hasWithOverrides,l as i18nSettings,A as isAllowedTransition,d as localePrefixForStrategy,c as prefixDefaultLocaleForStrategy,B as resolveEffectiveLocales,H as resolveI18nConfig,G as resolveMessageOverrides,e as versionable,E as withOverrides,M as workflowable};
1
+ import{n as e,t}from"./versionable-Br_G5VGR.mjs";import{i as n,n as r,r as i,t as a}from"./errors-NRNILhhm.mjs";import{n as o,r as s,t as c}from"./locale-entity-CoSq3C77.mjs";import{a as l,i as u,n as d,r as f,t as p}from"./locale-registry-Bn6e_nM7.mjs";import{n as m,r as h,t as g}from"./routing-strategy-D6B4ngT9.mjs";import{USER_ID_MAX_LENGTH as _,field as v}from"@murumets-ee/entity";import{createSystemReachResolver as y}from"@murumets-ee/core";import{QueryClient as b}from"@murumets-ee/entity/query";import{schemaRegistry as x}from"@murumets-ee/db";import{eq as S}from"drizzle-orm";const C=new Set([`hide`,`patch`,`replace`]),w=Symbol.for(`@murumets-ee/content:withOverrides`),T=new Set([`__proto__`,`constructor`,`prototype`]);function E(e,t,n=0){if(e!=null&&!(typeof e!=`object`||Array.isArray(e)))for(let r of Object.keys(e)){if(T.has(r))throw Error(`withOverrides: reserved key '${r}' at ${t} is not allowed.`);n<3&&E(e[r],`${t}.${r}`,n+1)}}function D(e){if(e==null)return{};if(typeof e!=`object`||Array.isArray(e))throw Error(`withOverrides: _overrides must be a plain object map.`);let t=e,n=Object.keys(t);if(n.length>100)throw Error(`withOverrides: _overrides has ${n.length} entries; cap is 100.`);let r={};for(let e of n){if(T.has(e))throw Error(`withOverrides: reserved key '${e}' is not allowed.`);let n=t[e];if(typeof n!=`object`||!n||Array.isArray(n))throw Error(`withOverrides: _overrides['${e}'] must be a directive object.`);let i=n.kind;if(typeof i!=`string`||!C.has(i))throw Error(`withOverrides: _overrides['${e}'].kind must be one of [hide, patch, replace]; got ${String(i)}.`);if(i===`patch`){let t=n;O(t.props,`_overrides['${e}'].props`),O(t.traits,`_overrides['${e}'].traits`)}if(i===`replace`){let t=n.block;if(typeof t!=`object`||!t||Array.isArray(t)||typeof t.id!=`string`)throw Error(`withOverrides: _overrides['${e}'] replace payload must include an object 'block' with a string 'id'.`)}E(n,`_overrides['${e}']`),r[e]=n}return r}function O(e,t){if(e!==void 0&&(typeof e!=`object`||!e||Array.isArray(e)))throw Error(`withOverrides: ${t} must be an object.`)}function k(){return{name:`withOverrides`,fields:{_overrides:v.json({default:()=>({}),internal:!0,refScan:!0})},hooks:{beforeCreate:async e=>(e._overrides===void 0?e._overrides={}:e._overrides=D(e._overrides),e),beforeUpdate:async(e,t)=>(t._overrides===void 0||(t._overrides=D(t._overrides)),t)},[w]:!0}}function A(e){return e?e.some(e=>!!e?.[w]):!1}const j=[`draft`,`in_review`,`approved`,`rejected`,`published`],M={draft:[`in_review`],in_review:[`approved`,`rejected`],approved:[`published`,`in_review`],rejected:[`in_review`,`draft`],published:[`draft`]};function N(e,t){return e===t?!0:M[e]?.includes(t)??!1}var P=class extends Error{from;to;constructor(e,t){super(`Invalid workflow transition: ${e} → ${t}. Allowed transitions from "${e}": [${(M[e]??[]).join(`, `)}].`),this.name=`WorkflowTransitionError`,this.from=e,this.to=t}};function F(){return{name:`workflowable`,fields:{_workflowStatus:v.select({options:j,default:`draft`,indexed:!0,required:!0,internal:!0}),_workflowAssignee:v.text({required:!1,internal:!0,maxLength:_}),_workflowNote:v.text({required:!1,internal:!0}),_workflowUpdatedAt:v.date({internal:!0})},hooks:{beforeCreate:async e=>(e._workflowStatus=`draft`,e._workflowUpdatedAt=new Date,e),beforeUpdate:async(e,t,n)=>{let r=t._workflowStatus;if(r===void 0)return t;if(!j.includes(r))throw new P(`draft`,r);let i=(await n?.loadCurrent?.())?._workflowStatus??`draft`,a=r;if(!N(i,a))throw new P(i,a);return t._workflowUpdatedAt=new Date,t}}}}function I(e,t){switch(t){case`drafts`:return L(e,`publishable`);case`versions`:return L(e,`versionable`);case`localeStatus`:return L(e,`publishable`)&&R(e);case`translations`:return R(e)}}function L(e,t){return e.behaviors?.some(e=>e.name===t)??!1}function R(e){let t=e.allFields;for(let e of Object.keys(t)){let n=t[e];if(n&&n.translatable===!0)return!0}return!1}const z=y(`message-registry`),B=2e4;async function V(e,t){if(!e.entities?.has(`message`))return[];let n=x.get(`message`);if(!n)return[];let r=new b({entity:h,db:e.db.readOnly,logger:e.logger.child({registry:`message`}),contextResolver:z});try{let i=await r.findMany({where:S(n.locale,t),limit:B});return i.length>=B&&e.logger.warn({locale:t,cap:B},`message override read hit the row cap — some overrides were not applied`),i.map(e=>{let t=e;return{namespace:String(t.namespace),key:String(t.key),value:String(t.value)}})}catch(n){return e.logger.warn({err:n,locale:t},`message registry read failed — falling back to shipped defaults only`),[]}}export{a as DraftNotFoundError,r as EntityCapabilityError,i as InvalidLocaleError,c as Locale,h as Message,o as ROUTING_STRATEGIES,n as VersionNotFoundError,j as WORKFLOW_STATES,M as WORKFLOW_TRANSITIONS,P as WorkflowTransitionError,p as enabledLocaleCodes,I as entitySupports,t as getVersionableOptions,A as hasWithOverrides,s as i18nSettings,N as isAllowedTransition,g as localePrefixForStrategy,m as prefixDefaultLocaleForStrategy,d as resolveEffectiveLocales,f as resolveI18nConfig,V as resolveMessageOverrides,u as resolveSiteI18n,l as resolveSiteLocales,e as versionable,k as withOverrides,F as workflowable};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["SYSTEM_CONTEXT"],"sources":["../src/behaviors/with-overrides.ts","../src/behaviors/workflowable.ts","../src/capabilities.ts","../src/locale-registry.ts","../src/message-registry.ts"],"sourcesContent":["/**\n * `withOverrides()` — opt-in behavior wiring a document entity into the\n * page-scoped override resolver shipped by phase 05.\n *\n * Adds a single internal field `_overrides: Record<string, OverrideDirective>`\n * to the host entity, defaulted to an empty object. The composition step\n * (`composeChromeRegion` in `@murumets-ee/blocks`) reads this field and calls\n * `applyOverrides()` on each chrome region AFTER its blocks have been\n * scope-tagged.\n *\n * Apps opt-in:\n *\n * @example\n * ```ts\n * defineEntity({\n * name: 'page',\n * fields: { ... },\n * behaviors: [\n * publishable(),\n * versionable({ maxPerDoc: 30 }),\n * workflowable(),\n * withOpLog({ retentionDays: 7 }),\n * withOverrides(), // ← enables page-level overrides on scoped blocks\n * ],\n * })\n * ```\n *\n * Why a behavior (not a plain field):\n * - Discoverability — `entitySupports(entity, 'overrides')` lets the boot\n * orphan-sweep find every overrides-eligible entity without hard-coding\n * a list.\n * - Validation — the behavior's `beforeUpdate` hook validates the shape\n * of the `_overrides` map (Zod-equivalent runtime check) so a malformed\n * PATCH body can't poison the field.\n * - Defensive defaults — `_overrides: {}` is auto-seeded on `beforeCreate`\n * so consumers always see a value, never `undefined`.\n *\n * `internal: true` on the field keeps it out of HTTP PATCH input — the\n * `doc:scope-override:set` op is the only path that can mutate it (writes\n * are routed through `AdminClient.updateInternal()` after permission\n * gating).\n *\n * See `plan/blocks/05-scoping-overrides.md` §5 + `progress/findings/F009`.\n */\n\nimport type { Behavior, JsonField } from '@murumets-ee/entity'\nimport { field } from '@murumets-ee/entity'\n\n/**\n * Override-directive shape. Mirrored from `@murumets-ee/blocks` to avoid\n * a circular dep (content → blocks → content); content stores the\n * persisted JSON, blocks owns the resolver. Keep the two shapes in sync.\n */\ntype OverrideDirective =\n | { kind: 'hide' }\n | {\n kind: 'patch'\n props?: Record<string, unknown>\n traits?: Record<string, Record<string, unknown>>\n }\n | { kind: 'replace'; block: Record<string, unknown> }\n\ntype OverridesMap = Record<string, OverrideDirective>\n\nexport type WithOverridesFields = {\n // `required: false` at the type level — the `beforeCreate` hook\n // seeds the field with `{}` when callers omit it, so making the\n // INPUT optional is more ergonomic than threading `_overrides: {}`\n // through every consumer's create-input.\n //\n // NOTE: the DB column is NULLABLE in the generated migration\n // (`_overrides jsonb`) — the JsonField `default` is a runtime\n // function the entity layer calls on create, NOT a SQL DEFAULT\n // clause the migration generator sees. Readers MUST treat NULL\n // as equivalent to `{}` (see `compose-chrome.ts`'s `asOverridesMap`\n // for the canonical pattern). A row inserted\n // via raw SQL or migration that bypasses the entity layer can\n // leave NULL; that's safe.\n _overrides: JsonField\n}\n\n/**\n * Allowed directive kinds — closed set. Unknown discriminators are rejected\n * at the behavior boundary so a malformed PATCH (or future plugin)\n * can't write opaque shapes that the resolver silently ignores.\n */\nconst ALLOWED_KINDS: ReadonlySet<string> = new Set(['hide', 'patch', 'replace'])\n\n/**\n * Maximum number of override entries per document. Bounded to keep the\n * page row's JSON column compact + the rendering hot path predictable.\n * Per phase 05 §9: a page overriding more than ~50 scoped blocks is a\n * smell — at that point the shared chrome is the wrong chrome for that\n * page, and the answer is a different `site_chrome` row or page-local blocks,\n * not more patching.\n */\nconst MAX_OVERRIDES_PER_DOC = 100\n\n/** Marker symbol so introspection can pick out the behavior reliably. */\nconst WITH_OVERRIDES_MARKER = Symbol.for('@murumets-ee/content:withOverrides')\n\ninterface WithOverridesMarker {\n [WITH_OVERRIDES_MARKER]: true\n}\n\nconst FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])\n\n/**\n * Walk a plain-object value's OWN keys and reject any that match a\n * prototype-pollution vector. JSON.parse can create own properties\n * with these names; the dispatcher already filters at the route +\n * op-handler boundary, but the behavior is the third layer of\n * defense (any persistence path that bypasses the dispatcher — DB\n * seed, raw SQL migration, future internal API — lands here on the\n * next update).\n *\n * The depth cap of 3 covers every documented over-the-wire shape:\n *\n * - patch.props.__proto__ → depth 1\n * - patch.traits.spacing.__proto__ → depth 2\n * - replace.block.props.__proto__ → depth 2\n * - replace.block.traits.spacing.__proto__ → depth 3\n *\n * (Counting from the directive root at depth 0. The recursion CHECKS\n * keys at the entered depth, then recurses while `depth < 3`, so the\n * deepest check happens at depth=3.)\n *\n * Beyond that depth the structure is field VALUES which are caller-\n * payload (block field type / trait field type) — those are the\n * field-type validator's job, not the override-map gate.\n *\n * `path` is appended to the thrown error for triage.\n */\nfunction assertNoForbiddenKeys(value: unknown, path: string, depth = 0): void {\n if (value === null || value === undefined) return\n if (typeof value !== 'object' || Array.isArray(value)) return\n for (const key of Object.keys(value as Record<string, unknown>)) {\n if (FORBIDDEN_KEYS.has(key)) {\n throw new Error(`withOverrides: reserved key '${key}' at ${path} is not allowed.`)\n }\n if (depth < 3) {\n assertNoForbiddenKeys((value as Record<string, unknown>)[key], `${path}.${key}`, depth + 1)\n }\n }\n}\n\n/**\n * Validate an override-map payload. Throws when malformed; returns the\n * (defensively-cloned, sanitised) value when ok. Called from the\n * `beforeUpdate` hook — `internal: true` blocks PATCH-side input but\n * the `doc:scope-override:set` op writes via `updateInternal` and so\n * still flows through here.\n *\n * Defense layered: outer scope-block-id keys + each directive's\n * recursive nested keys (patch.props / patch.traits / replace.block.props\n * / replace.block.traits) all walk through `assertNoForbiddenKeys`. The\n * dispatcher's own handler validates the same set; this is the\n * defense-in-depth replay at the persistence boundary.\n */\nfunction validateOverridesMap(value: unknown): OverridesMap {\n if (value === null || value === undefined) return {}\n if (typeof value !== 'object' || Array.isArray(value)) {\n throw new Error('withOverrides: _overrides must be a plain object map.')\n }\n const map = value as Record<string, unknown>\n const keys = Object.keys(map)\n if (keys.length > MAX_OVERRIDES_PER_DOC) {\n throw new Error(\n `withOverrides: _overrides has ${keys.length} entries; cap is ${MAX_OVERRIDES_PER_DOC}.`,\n )\n }\n const sanitised: OverridesMap = {}\n for (const key of keys) {\n // Skip prototype-pollution vectors at the OUTER (scopeBlockId)\n // key boundary first.\n if (FORBIDDEN_KEYS.has(key)) {\n throw new Error(`withOverrides: reserved key '${key}' is not allowed.`)\n }\n const directive = map[key]\n if (\n directive === null ||\n directive === undefined ||\n typeof directive !== 'object' ||\n Array.isArray(directive)\n ) {\n throw new Error(`withOverrides: _overrides['${key}'] must be a directive object.`)\n }\n const kind = (directive as { kind?: unknown }).kind\n if (typeof kind !== 'string' || !ALLOWED_KINDS.has(kind)) {\n throw new Error(\n `withOverrides: _overrides['${key}'].kind must be one of [hide, patch, replace]; got ${String(\n kind,\n )}.`,\n )\n }\n // Kind-specific shape enforcement (CR bot major) — `kind` alone\n // isn't enough; a malformed `{ kind: 'replace' }` (no block) or\n // `{ kind: 'patch', props: 123 }` would otherwise pass the gate\n // and land invalid data in persistence. Mirror the dispatcher\n // handler's shape checks at this third defense layer.\n if (kind === 'patch') {\n const d = directive as { props?: unknown; traits?: unknown }\n assertPlainObjectOrUndefined(d.props, `_overrides['${key}'].props`)\n assertPlainObjectOrUndefined(d.traits, `_overrides['${key}'].traits`)\n }\n if (kind === 'replace') {\n const block = (directive as { block?: unknown }).block\n if (\n block === null ||\n block === undefined ||\n typeof block !== 'object' ||\n Array.isArray(block) ||\n typeof (block as { id?: unknown }).id !== 'string'\n ) {\n throw new Error(\n `withOverrides: _overrides['${key}'] replace payload must include an object 'block' with a string 'id'.`,\n )\n }\n }\n // Recurse into the directive payload — patch.props / patch.traits\n // / replace.block.props / replace.block.traits — and reject any\n // forbidden own-property keys at every level.\n assertNoForbiddenKeys(directive, `_overrides['${key}']`)\n sanitised[key] = directive as OverrideDirective\n }\n return sanitised\n}\n\n/**\n * Reject a directive sub-field that's present but not a plain object\n * (array / primitive / null). `undefined` is allowed — the field is\n * optional. Mirrors the dispatcher handler's `assertPlainObjectOrUndefined`.\n */\nfunction assertPlainObjectOrUndefined(value: unknown, label: string): void {\n if (value === undefined) return\n if (value === null || typeof value !== 'object' || Array.isArray(value)) {\n throw new Error(`withOverrides: ${label} must be an object.`)\n }\n}\n\n/**\n * Mark an entity as participating in the page-scoped override resolver.\n * See module-level JSDoc for the full contract.\n */\nexport function withOverrides(): Behavior<WithOverridesFields, 'withOverrides'> {\n const behavior: Behavior<WithOverridesFields, 'withOverrides'> & WithOverridesMarker = {\n name: 'withOverrides',\n fields: {\n // `internal: true` blocks PATCH-input poisoning (the\n // `doc:scope-override:set` op writes via updateInternal which\n // bypasses the input filter). `default: () => ({})` seeds the\n // value on every create call, so callers can omit `_overrides`\n // entirely (no `required: true` needed at the input level).\n // `refScan: true` (images PR16): replacement blocks in the map hold media\n // refs — with a placement's own spot crop — exactly as the block tree\n // does, so the usage index records them. Without it an image placed only\n // in an override was used NOWHERE as far as delete protection and the\n // spot-crop sweep could tell, and the sweep would collect the files of a\n // crop a published page still shows.\n _overrides: field.json({\n default: () => ({}),\n internal: true,\n refScan: true,\n }),\n },\n hooks: {\n beforeCreate: async (data) => {\n // Seed the empty map on create so the column is never null in DB.\n // Caller-supplied `_overrides` on create is unusual (overrides\n // are typically dispatched op-by-op) but if they DO pass one,\n // validate it.\n if (data._overrides === undefined) {\n data._overrides = {}\n } else {\n data._overrides = validateOverridesMap(data._overrides)\n }\n return data\n },\n beforeUpdate: async (_id, data) => {\n // Only validate when the patch tries to write `_overrides`.\n if (data._overrides === undefined) return data\n data._overrides = validateOverridesMap(data._overrides)\n return data\n },\n },\n [WITH_OVERRIDES_MARKER]: true,\n }\n return behavior\n}\n\n/**\n * Check whether an entity declares `withOverrides()`. Returns `true`\n * when the entity's behaviors list contains a behavior tagged with\n * the marker symbol. Used by the boot orphan-sweep + admin UI gating.\n */\nexport function hasWithOverrides(\n behaviors: ReadonlyArray<{ name: string } & Record<symbol, unknown>> | undefined,\n): boolean {\n if (!behaviors) return false\n return behaviors.some((b) => Boolean(b?.[WITH_OVERRIDES_MARKER]))\n}\n\n/**\n * Export the directive type alias for downstream consumers that want\n * to type-narrow without depending on `@murumets-ee/blocks`. The\n * resolver lives in blocks; the persisted shape lives here.\n */\nexport type { OverrideDirective, OverridesMap }\n","/**\n * Workflowable behavior — editorial workflow with approval gates.\n *\n * Adds workflow status tracking fields to an entity and enforces the\n * documented transition state machine in `beforeUpdate`. Works alongside\n * `publishable()` — the workflow status tracks the editorial pipeline,\n * while publishable's `status` remains the public-facing state.\n *\n * Workflow state machine:\n *\n * draft ─submit─► in_review ─approve─► approved ─publish─► published\n * │ ▲ │\n * │ └────resubmit─────┐ │\n * └─reject─► rejected ─┘ │\n * │\n * unpublish ◄──────────────┘\n * (back to draft)\n *\n * Internal-write only: every workflow field is declared `internal: true`,\n * so the HTTP PATCH surface strips them from caller input. Workflow\n * transitions are performed by trusted server-side routes via\n * `AdminClient.updateInternal()` after authorizing the action with the\n * permission checker. The `beforeUpdate` hook validates that the\n * proposed transition is in the allowed table.\n *\n * Permission gating lives in the route handler (admin-ui workflow\n * routes), not in this behavior — by the time the hook fires, the\n * route has already authorized via `checkPermission`. The hook is\n * the structural defense: even a bug in the route layer cannot push\n * a workflow row into an illegal state.\n *\n * UI integration is tracked in GitHub issue #124. Until the workflow\n * UX ships, the behavior can still be added to entities — it'll simply\n * seed `_workflowStatus = 'draft'` on create and reject anyone trying\n * to write the field via PATCH.\n */\n\nimport type {\n Behavior,\n BehaviorContext,\n DateField,\n SelectField,\n TextField,\n} from '@murumets-ee/entity'\nimport { field, USER_ID_MAX_LENGTH } from '@murumets-ee/entity'\n\nexport const WORKFLOW_STATES = ['draft', 'in_review', 'approved', 'rejected', 'published'] as const\nexport type WorkflowState = (typeof WORKFLOW_STATES)[number]\n\n/**\n * Allowed transitions table. Each key is the *current* workflow status;\n * the value is the set of statuses it can move to.\n *\n * Encoded as a const-asserted record so a typo in the source produces\n * a compile error rather than a silent no-match at runtime.\n */\nexport const WORKFLOW_TRANSITIONS = {\n draft: ['in_review'],\n in_review: ['approved', 'rejected'],\n approved: ['published', 'in_review'],\n rejected: ['in_review', 'draft'],\n published: ['draft'],\n} as const satisfies Record<WorkflowState, readonly WorkflowState[]>\n\n/**\n * Is `to` an allowed transition from `from`? Pure, no I/O.\n */\nexport function isAllowedTransition(from: WorkflowState, to: WorkflowState): boolean {\n if (from === to) return true // no-op self-transition is fine\n const allowed = WORKFLOW_TRANSITIONS[from] as readonly WorkflowState[] | undefined\n return allowed?.includes(to) ?? false\n}\n\n/**\n * Thrown when `beforeUpdate` rejects a workflow transition. Distinct\n * subclass so admin-ui's API handler can map it to HTTP 400.\n */\nexport class WorkflowTransitionError extends Error {\n readonly from: WorkflowState\n readonly to: WorkflowState\n\n constructor(from: WorkflowState, to: WorkflowState) {\n super(\n `Invalid workflow transition: ${from} → ${to}. ` +\n `Allowed transitions from \"${from}\": [${(WORKFLOW_TRANSITIONS[from] ?? []).join(', ')}].`,\n )\n this.name = 'WorkflowTransitionError'\n this.from = from\n this.to = to\n }\n}\n\nexport type WorkflowableFields = {\n _workflowStatus: SelectField & { required: true }\n _workflowAssignee: TextField\n _workflowNote: TextField\n _workflowUpdatedAt: DateField\n}\n\nexport function workflowable(): Behavior<WorkflowableFields> {\n return {\n name: 'workflowable',\n fields: {\n _workflowStatus: field.select({\n options: WORKFLOW_STATES as unknown as string[],\n default: 'draft',\n indexed: true,\n required: true,\n internal: true,\n }) as SelectField & { required: true },\n // `_workflowAssignee` stores a better-auth user ID — varchar(255)\n // per CLAUDE.md §\"User IDs\", emitted via `maxLength: 255` (#430).\n // `_workflowNote` is free-text, so it stays unbounded `text`.\n _workflowAssignee: field.text({\n required: false,\n internal: true,\n maxLength: USER_ID_MAX_LENGTH,\n }),\n _workflowNote: field.text({ required: false, internal: true }),\n _workflowUpdatedAt: field.date({ internal: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n data._workflowStatus = 'draft'\n data._workflowUpdatedAt = new Date()\n return data\n },\n beforeUpdate: async (\n _id: string,\n data: Record<string, unknown>,\n ctx?: BehaviorContext,\n ): Promise<Record<string, unknown>> => {\n // Only validate when the patch tries to change the workflow status.\n // Updates that don't touch _workflowStatus pass through (the field's\n // `internal: true` already prevents it from sneaking in via PATCH;\n // only updateInternal callers reach this branch with a value).\n const proposed = data._workflowStatus\n if (proposed === undefined) return data\n\n if (!WORKFLOW_STATES.includes(proposed as WorkflowState)) {\n throw new WorkflowTransitionError('draft', proposed as WorkflowState)\n }\n\n // Need the current row to validate the from→to transition. The\n // lazy loader is provided by AdminClient — present on update,\n // absent on create.\n const current = await ctx?.loadCurrent?.()\n const fromState = (current?._workflowStatus as WorkflowState | undefined) ?? 'draft'\n const toState = proposed as WorkflowState\n\n if (!isAllowedTransition(fromState, toState)) {\n throw new WorkflowTransitionError(fromState, toState)\n }\n\n data._workflowUpdatedAt = new Date()\n return data\n },\n },\n }\n}\n","/**\n * Per-entity content capability gates.\n *\n * The content plugin attaches different infrastructure tables to an\n * entity based on which behaviors it has:\n * - `publishable()` → drafts table\n * - `versionable()` → versions table\n * - `publishable() + translatable fields` → locale_status table\n * - any translatable field → translations table (also written by the\n * entity package; included for symmetric capability checks)\n *\n * Calling a capability-specific ContentClient method (`saveDraft`,\n * `publishDraft`, `setLocaleStatus`, `restoreVersion`, ...) on an\n * entity that doesn't have the required behavior is a programming\n * error. ContentClient throws `EntityCapabilityError` on writes and\n * silently returns falsy on reads. Callers that want to gate ahead —\n * \"should I show the draft button on this entity row?\" — use\n * `entitySupports()` instead of catching the error.\n */\n\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\n\nexport type EntityCapability = 'drafts' | 'versions' | 'localeStatus' | 'translations'\n\n/**\n * Does this entity support the given content capability? Pure check\n * against the entity's declared behaviors and field translatability —\n * no plugin lookup, no DB call.\n */\nexport function entitySupports(entity: Entity, capability: EntityCapability): boolean {\n switch (capability) {\n case 'drafts':\n return hasBehavior(entity, 'publishable')\n case 'versions':\n return hasBehavior(entity, 'versionable')\n case 'localeStatus':\n return hasBehavior(entity, 'publishable') && hasTranslatableField(entity)\n case 'translations':\n return hasTranslatableField(entity)\n }\n}\n\nfunction hasBehavior(entity: Entity, name: string): boolean {\n return entity.behaviors?.some((b) => b.name === name) ?? false\n}\n\nfunction hasTranslatableField(entity: Entity): boolean {\n const fields = entity.allFields as Record<string, FieldConfig>\n for (const key of Object.keys(fields)) {\n const f = fields[key]\n if (f && (f as { translatable?: boolean }).translatable === true) return true\n }\n return false\n}\n","/**\n * The content-locale **registry resolvers** (plan/config phase 05 §7, D004/D009/SD006).\n *\n * The effective content-locale set = **read-time-merge** of the `content()` shipped\n * defaults ⊕ the DB `locale` config-entity override rows. Resolved **once per request**\n * at context establishment (the admin API handler + `withAdminContext`) and threaded\n * onto {@link RequestContext.availableLocales}, so `validateLocale`'s synchronous\n * whitelist reflects runtime-added / disabled locales with **no rebuild and no\n * globalThis cache** — multi-process / multi-tenant correct.\n *\n * The registry read is a **system-context** query: managing the language set is\n * admin-only (`locale:*`), but every role must resolve valid locales to edit content,\n * exactly like the app-wide roles read. Reads degrade to the static `content()`\n * defaults on any error (fresh app pre-migration, transient DB failure) — deny-by-\n * default is preserved (a not-yet-known locale simply isn't whitelisted).\n */\n\nimport { createSystemReachResolver, mergeConfigOverrides, type ToolkitApp } from '@murumets-ee/core'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { getContentConfig } from './content-config-store.js'\nimport { i18nSettings, ROUTING_STRATEGIES } from './i18n-settings.js'\nimport { Locale } from './locale-entity.js'\nimport type { I18nConfig, LocaleDirection, LocaleEntry, RoutingStrategy } from './types.js'\n\n/**\n * Elevated **reach** resolver for the app-wide registry read (the roles-read\n * analog). It grants `view` and nothing else — this module only ever READS the\n * `locale` rows, so an all-allow checker would have handed every caller of this\n * resolver a write grant it has no use for. The named constructor owns that\n * exception centrally rather than leaving it to be re-derived per call site;\n * a hand-written `checker: () => true` here is refused by `verify:boundaries`.\n *\n * The explicit id is the audit identity — two elevated readers sharing the\n * default `'system'` are indistinguishable in the log.\n */\nconst SYSTEM_CONTEXT = createSystemReachResolver('locale-registry')\n\n/** The `content()`-shipped locales projected to full {@link LocaleEntry}s (all enabled). */\nfunction shippedDefaults(): LocaleEntry[] {\n return getContentConfig().locales.map((l, i) => ({\n code: l.code,\n label: l.label,\n enabled: true,\n direction: 'ltr' as LocaleDirection,\n sortOrder: i,\n }))\n}\n\n/** Ascending by `sortOrder`, then `code` for a stable tie-break. */\nfunction bySortOrder(a: LocaleEntry, b: LocaleEntry): number {\n return a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)\n}\n\n/**\n * Defensive upper bound on `locale` rows to read (a tenant realistically has <20). The\n * Languages-editor route MUST reconcile against the SAME bound so it can't miss/strand\n * rows — see admin-ui `locale-routes` `MAX_EXISTING_LOCALE_ROWS` (kept in sync).\n */\nconst MAX_LOCALE_ROWS = 1000\n\n/** Read + normalize the DB `locale` override rows (system context). */\nasync function readLocaleRows(app: ToolkitApp): Promise<LocaleEntry[]> {\n if (!app.entities?.has('locale')) return []\n const client = new QueryClient({\n entity: Locale,\n db: app.db.readOnly,\n logger: app.logger.child({ registry: 'locale' }),\n contextResolver: SYSTEM_CONTEXT,\n })\n const rows = await client.findMany({ limit: MAX_LOCALE_ROWS })\n return rows.map((r) => {\n const row = r as Record<string, unknown>\n return {\n code: String(row.code),\n label: typeof row.label === 'string' && row.label.length > 0 ? row.label : String(row.code),\n enabled: row.enabled !== false,\n direction: row.direction === 'rtl' ? 'rtl' : 'ltr',\n sortOrder: typeof row.sortOrder === 'number' ? row.sortOrder : 0,\n }\n })\n}\n\n/**\n * The effective content-locale set — shipped defaults ⊕ DB overrides, sorted by\n * `sortOrder`. Includes **disabled** locales (listable in the Languages editor);\n * callers that want only switchable/valid locales use {@link enabledLocaleCodes}.\n */\nexport async function resolveEffectiveLocales(app: ToolkitApp): Promise<LocaleEntry[]> {\n let rows: LocaleEntry[]\n try {\n rows = await readLocaleRows(app)\n } catch (err) {\n app.logger.warn({ err }, 'locale registry read failed — falling back to content() defaults')\n return [...shippedDefaults()].sort(bySortOrder)\n }\n const merged = mergeConfigOverrides<LocaleEntry, LocaleEntry>({\n defaults: shippedDefaults(),\n overrides: rows,\n idOfDefault: (l) => l.code,\n idOfOverride: (r) => r.code,\n // No `isDisabled`: a disabled locale stays in the merged list so the editor\n // can re-enable it — the switcher + validateLocale filter on `enabled`.\n })\n return (merged as LocaleEntry[]).sort(bySortOrder)\n}\n\n/** The **enabled** codes from an effective set — the switcher + validateLocale whitelist. */\nexport function enabledLocaleCodes(entries: readonly LocaleEntry[]): string[] {\n return entries.filter((e) => e.enabled).map((e) => e.code)\n}\n\n/**\n * The resolved global i18n singletons (`i18n` settings ⊕ `content()` defaults).\n * `defaultLocale` is guaranteed to be an **enabled** locale: the configured value\n * wins if enabled, else the `content()` default if enabled, else the first enabled\n * locale (last-resort, deterministic). Pass `entries` to reuse an already-resolved\n * effective set (avoids a second registry read within one request).\n */\nexport async function resolveI18nConfig(\n app: ToolkitApp,\n entries?: readonly LocaleEntry[],\n): Promise<I18nConfig> {\n const effective = entries ?? (await resolveEffectiveLocales(app))\n const enabled = enabledLocaleCodes(effective)\n const shippedDefault = getContentConfig().defaultLocale\n\n let values: Record<string, unknown> = {}\n try {\n // Dynamic import: `@murumets-ee/settings/runtime` evaluates the settings schema\n // (`defineTable`) at module load; importing it eagerly would drag that graph into\n // `@murumets-ee/content`'s main index (where only `resolveEffectiveLocales` — which\n // needs no settings — is on the hot import path). Loaded lazily here, only when the\n // i18n singletons are actually read. (Same pattern as rent/ticketing/consent.)\n const { createSettingsClient } = await import('@murumets-ee/settings/runtime')\n const client = createSettingsClient(i18nSettings, { app })\n values = (await client.getAll()) ?? {}\n } catch (err) {\n app.logger.warn({ err }, 'i18n settings read failed — falling back to content() defaults')\n }\n\n const configuredDefault =\n typeof values.defaultLocale === 'string' && values.defaultLocale.length > 0\n ? values.defaultLocale\n : shippedDefault\n\n const defaultLocale = enabled.includes(configuredDefault)\n ? configuredDefault\n : enabled.includes(shippedDefault)\n ? shippedDefault\n : (enabled[0] ?? shippedDefault)\n\n const rawStrategy = values.routingStrategy\n const routingStrategy: RoutingStrategy = (ROUTING_STRATEGIES as readonly string[]).includes(\n typeof rawStrategy === 'string' ? rawStrategy : '',\n )\n ? (rawStrategy as RoutingStrategy)\n : 'no-prefix-default'\n\n return { defaultLocale, routingStrategy }\n}\n","/**\n * The interface-message **registry resolver** (plan/config phase 06, D004/D010).\n *\n * The effective interface strings = **read-time-merge** of the per-package / per-app\n * message JSON defaults ⊕ the DB `message` config-entity override rows. This resolver\n * returns the OVERRIDE rows for one locale as flat {@link MessageEntry}s; the caller\n * (the admin `i18n/request.ts`) nests them via `overridesToCatalog` and passes the\n * result as the last `mergeMessages(...)` argument so DB overrides win — live edits,\n * no rebuild.\n *\n * The read is a **system-context** query on the read-only connection: interface strings\n * must reflect customizations on EVERY request (incl. the unauthenticated login page's\n * `AuthUI` strings), exactly like the app-wide locale/roles reads. It degrades to \"no\n * overrides\" (shipped defaults only) on any error — a fresh app pre-migration or a\n * transient DB blip simply renders the shipped strings.\n */\n\nimport { createSystemReachResolver, type ToolkitApp } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport type { MessageEntry } from '@murumets-ee/i18n'\nimport { eq } from 'drizzle-orm'\nimport { Message } from './message-entity.js'\n\n/**\n * Elevated **reach** resolver for the app-wide message read (the locale/roles-read\n * analog). Grants `view` and nothing else: this module only READS override rows,\n * and the editor route that writes them is separately gated. Named constructor\n * rather than a `checker: () => true` literal so the identity is enumerable —\n * see the elevation allowlist in `scripts/verify-package-boundaries.mjs`.\n */\nconst SYSTEM_CONTEXT = createSystemReachResolver('message-registry')\n\n/**\n * Defensive upper bound on override rows read for one locale. A fully-translated app\n * across several locales legitimately accumulates thousands of rows, but per-locale the\n * count is bounded by the number of *customized* strings. The read is `log()`-ged when it\n * hits the cap so a silent truncation can't read as \"all overrides applied\" (CLAUDE.md\n * no-unbounded-fan-out rule).\n */\nconst MAX_MESSAGE_OVERRIDE_ROWS = 20000\n\n/**\n * The DB `message` override rows for `locale`, as flat `(namespace, key, value)` entries.\n * Empty when the `message` entity isn't registered (no `messageEditor()` / pre-migration)\n * or on any read error.\n */\nexport async function resolveMessageOverrides(\n app: ToolkitApp,\n locale: string,\n): Promise<MessageEntry[]> {\n if (!app.entities?.has('message')) return []\n const table = schemaRegistry.get('message')\n if (!table) return []\n\n const client = new QueryClient({\n entity: Message,\n db: app.db.readOnly,\n logger: app.logger.child({ registry: 'message' }),\n contextResolver: SYSTEM_CONTEXT,\n })\n\n try {\n const rows = await client.findMany({\n where: eq(table.locale, locale),\n limit: MAX_MESSAGE_OVERRIDE_ROWS,\n })\n if (rows.length >= MAX_MESSAGE_OVERRIDE_ROWS) {\n app.logger.warn(\n { locale, cap: MAX_MESSAGE_OVERRIDE_ROWS },\n 'message override read hit the row cap — some overrides were not applied',\n )\n }\n return rows.map((r) => {\n const row = r as Record<string, unknown>\n return {\n namespace: String(row.namespace),\n key: String(row.key),\n value: String(row.value),\n }\n })\n } catch (err) {\n app.logger.warn(\n { err, locale },\n 'message registry read failed — falling back to shipped defaults only',\n )\n return []\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/behaviors/with-overrides.ts","../src/behaviors/workflowable.ts","../src/capabilities.ts","../src/message-registry.ts"],"sourcesContent":["/**\n * `withOverrides()` — opt-in behavior wiring a document entity into the\n * page-scoped override resolver shipped by phase 05.\n *\n * Adds a single internal field `_overrides: Record<string, OverrideDirective>`\n * to the host entity, defaulted to an empty object. The composition step\n * (`composeChromeRegion` in `@murumets-ee/blocks`) reads this field and calls\n * `applyOverrides()` on each chrome region AFTER its blocks have been\n * scope-tagged.\n *\n * Apps opt-in:\n *\n * @example\n * ```ts\n * defineEntity({\n * name: 'page',\n * fields: { ... },\n * behaviors: [\n * publishable(),\n * versionable({ maxPerDoc: 30 }),\n * workflowable(),\n * withOpLog({ retentionDays: 7 }),\n * withOverrides(), // ← enables page-level overrides on scoped blocks\n * ],\n * })\n * ```\n *\n * Why a behavior (not a plain field):\n * - Discoverability — `entitySupports(entity, 'overrides')` lets the boot\n * orphan-sweep find every overrides-eligible entity without hard-coding\n * a list.\n * - Validation — the behavior's `beforeUpdate` hook validates the shape\n * of the `_overrides` map (Zod-equivalent runtime check) so a malformed\n * PATCH body can't poison the field.\n * - Defensive defaults — `_overrides: {}` is auto-seeded on `beforeCreate`\n * so consumers always see a value, never `undefined`.\n *\n * `internal: true` on the field keeps it out of HTTP PATCH input — the\n * `doc:scope-override:set` op is the only path that can mutate it (writes\n * are routed through `AdminClient.updateInternal()` after permission\n * gating).\n *\n * See `plan/blocks/05-scoping-overrides.md` §5 + `progress/findings/F009`.\n */\n\nimport type { Behavior, JsonField } from '@murumets-ee/entity'\nimport { field } from '@murumets-ee/entity'\n\n/**\n * Override-directive shape. Mirrored from `@murumets-ee/blocks` to avoid\n * a circular dep (content → blocks → content); content stores the\n * persisted JSON, blocks owns the resolver. Keep the two shapes in sync.\n */\ntype OverrideDirective =\n | { kind: 'hide' }\n | {\n kind: 'patch'\n props?: Record<string, unknown>\n traits?: Record<string, Record<string, unknown>>\n }\n | { kind: 'replace'; block: Record<string, unknown> }\n\ntype OverridesMap = Record<string, OverrideDirective>\n\nexport type WithOverridesFields = {\n // `required: false` at the type level — the `beforeCreate` hook\n // seeds the field with `{}` when callers omit it, so making the\n // INPUT optional is more ergonomic than threading `_overrides: {}`\n // through every consumer's create-input.\n //\n // NOTE: the DB column is NULLABLE in the generated migration\n // (`_overrides jsonb`) — the JsonField `default` is a runtime\n // function the entity layer calls on create, NOT a SQL DEFAULT\n // clause the migration generator sees. Readers MUST treat NULL\n // as equivalent to `{}` (see `compose-chrome.ts`'s `asOverridesMap`\n // for the canonical pattern). A row inserted\n // via raw SQL or migration that bypasses the entity layer can\n // leave NULL; that's safe.\n _overrides: JsonField\n}\n\n/**\n * Allowed directive kinds — closed set. Unknown discriminators are rejected\n * at the behavior boundary so a malformed PATCH (or future plugin)\n * can't write opaque shapes that the resolver silently ignores.\n */\nconst ALLOWED_KINDS: ReadonlySet<string> = new Set(['hide', 'patch', 'replace'])\n\n/**\n * Maximum number of override entries per document. Bounded to keep the\n * page row's JSON column compact + the rendering hot path predictable.\n * Per phase 05 §9: a page overriding more than ~50 scoped blocks is a\n * smell — at that point the shared chrome is the wrong chrome for that\n * page, and the answer is a different `site_chrome` row or page-local blocks,\n * not more patching.\n */\nconst MAX_OVERRIDES_PER_DOC = 100\n\n/** Marker symbol so introspection can pick out the behavior reliably. */\nconst WITH_OVERRIDES_MARKER = Symbol.for('@murumets-ee/content:withOverrides')\n\ninterface WithOverridesMarker {\n [WITH_OVERRIDES_MARKER]: true\n}\n\nconst FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])\n\n/**\n * Walk a plain-object value's OWN keys and reject any that match a\n * prototype-pollution vector. JSON.parse can create own properties\n * with these names; the dispatcher already filters at the route +\n * op-handler boundary, but the behavior is the third layer of\n * defense (any persistence path that bypasses the dispatcher — DB\n * seed, raw SQL migration, future internal API — lands here on the\n * next update).\n *\n * The depth cap of 3 covers every documented over-the-wire shape:\n *\n * - patch.props.__proto__ → depth 1\n * - patch.traits.spacing.__proto__ → depth 2\n * - replace.block.props.__proto__ → depth 2\n * - replace.block.traits.spacing.__proto__ → depth 3\n *\n * (Counting from the directive root at depth 0. The recursion CHECKS\n * keys at the entered depth, then recurses while `depth < 3`, so the\n * deepest check happens at depth=3.)\n *\n * Beyond that depth the structure is field VALUES which are caller-\n * payload (block field type / trait field type) — those are the\n * field-type validator's job, not the override-map gate.\n *\n * `path` is appended to the thrown error for triage.\n */\nfunction assertNoForbiddenKeys(value: unknown, path: string, depth = 0): void {\n if (value === null || value === undefined) return\n if (typeof value !== 'object' || Array.isArray(value)) return\n for (const key of Object.keys(value as Record<string, unknown>)) {\n if (FORBIDDEN_KEYS.has(key)) {\n throw new Error(`withOverrides: reserved key '${key}' at ${path} is not allowed.`)\n }\n if (depth < 3) {\n assertNoForbiddenKeys((value as Record<string, unknown>)[key], `${path}.${key}`, depth + 1)\n }\n }\n}\n\n/**\n * Validate an override-map payload. Throws when malformed; returns the\n * (defensively-cloned, sanitised) value when ok. Called from the\n * `beforeUpdate` hook — `internal: true` blocks PATCH-side input but\n * the `doc:scope-override:set` op writes via `updateInternal` and so\n * still flows through here.\n *\n * Defense layered: outer scope-block-id keys + each directive's\n * recursive nested keys (patch.props / patch.traits / replace.block.props\n * / replace.block.traits) all walk through `assertNoForbiddenKeys`. The\n * dispatcher's own handler validates the same set; this is the\n * defense-in-depth replay at the persistence boundary.\n */\nfunction validateOverridesMap(value: unknown): OverridesMap {\n if (value === null || value === undefined) return {}\n if (typeof value !== 'object' || Array.isArray(value)) {\n throw new Error('withOverrides: _overrides must be a plain object map.')\n }\n const map = value as Record<string, unknown>\n const keys = Object.keys(map)\n if (keys.length > MAX_OVERRIDES_PER_DOC) {\n throw new Error(\n `withOverrides: _overrides has ${keys.length} entries; cap is ${MAX_OVERRIDES_PER_DOC}.`,\n )\n }\n const sanitised: OverridesMap = {}\n for (const key of keys) {\n // Skip prototype-pollution vectors at the OUTER (scopeBlockId)\n // key boundary first.\n if (FORBIDDEN_KEYS.has(key)) {\n throw new Error(`withOverrides: reserved key '${key}' is not allowed.`)\n }\n const directive = map[key]\n if (\n directive === null ||\n directive === undefined ||\n typeof directive !== 'object' ||\n Array.isArray(directive)\n ) {\n throw new Error(`withOverrides: _overrides['${key}'] must be a directive object.`)\n }\n const kind = (directive as { kind?: unknown }).kind\n if (typeof kind !== 'string' || !ALLOWED_KINDS.has(kind)) {\n throw new Error(\n `withOverrides: _overrides['${key}'].kind must be one of [hide, patch, replace]; got ${String(\n kind,\n )}.`,\n )\n }\n // Kind-specific shape enforcement (CR bot major) — `kind` alone\n // isn't enough; a malformed `{ kind: 'replace' }` (no block) or\n // `{ kind: 'patch', props: 123 }` would otherwise pass the gate\n // and land invalid data in persistence. Mirror the dispatcher\n // handler's shape checks at this third defense layer.\n if (kind === 'patch') {\n const d = directive as { props?: unknown; traits?: unknown }\n assertPlainObjectOrUndefined(d.props, `_overrides['${key}'].props`)\n assertPlainObjectOrUndefined(d.traits, `_overrides['${key}'].traits`)\n }\n if (kind === 'replace') {\n const block = (directive as { block?: unknown }).block\n if (\n block === null ||\n block === undefined ||\n typeof block !== 'object' ||\n Array.isArray(block) ||\n typeof (block as { id?: unknown }).id !== 'string'\n ) {\n throw new Error(\n `withOverrides: _overrides['${key}'] replace payload must include an object 'block' with a string 'id'.`,\n )\n }\n }\n // Recurse into the directive payload — patch.props / patch.traits\n // / replace.block.props / replace.block.traits — and reject any\n // forbidden own-property keys at every level.\n assertNoForbiddenKeys(directive, `_overrides['${key}']`)\n sanitised[key] = directive as OverrideDirective\n }\n return sanitised\n}\n\n/**\n * Reject a directive sub-field that's present but not a plain object\n * (array / primitive / null). `undefined` is allowed — the field is\n * optional. Mirrors the dispatcher handler's `assertPlainObjectOrUndefined`.\n */\nfunction assertPlainObjectOrUndefined(value: unknown, label: string): void {\n if (value === undefined) return\n if (value === null || typeof value !== 'object' || Array.isArray(value)) {\n throw new Error(`withOverrides: ${label} must be an object.`)\n }\n}\n\n/**\n * Mark an entity as participating in the page-scoped override resolver.\n * See module-level JSDoc for the full contract.\n */\nexport function withOverrides(): Behavior<WithOverridesFields, 'withOverrides'> {\n const behavior: Behavior<WithOverridesFields, 'withOverrides'> & WithOverridesMarker = {\n name: 'withOverrides',\n fields: {\n // `internal: true` blocks PATCH-input poisoning (the\n // `doc:scope-override:set` op writes via updateInternal which\n // bypasses the input filter). `default: () => ({})` seeds the\n // value on every create call, so callers can omit `_overrides`\n // entirely (no `required: true` needed at the input level).\n // `refScan: true` (images PR16): replacement blocks in the map hold media\n // refs — with a placement's own spot crop — exactly as the block tree\n // does, so the usage index records them. Without it an image placed only\n // in an override was used NOWHERE as far as delete protection and the\n // spot-crop sweep could tell, and the sweep would collect the files of a\n // crop a published page still shows.\n _overrides: field.json({\n default: () => ({}),\n internal: true,\n refScan: true,\n }),\n },\n hooks: {\n beforeCreate: async (data) => {\n // Seed the empty map on create so the column is never null in DB.\n // Caller-supplied `_overrides` on create is unusual (overrides\n // are typically dispatched op-by-op) but if they DO pass one,\n // validate it.\n if (data._overrides === undefined) {\n data._overrides = {}\n } else {\n data._overrides = validateOverridesMap(data._overrides)\n }\n return data\n },\n beforeUpdate: async (_id, data) => {\n // Only validate when the patch tries to write `_overrides`.\n if (data._overrides === undefined) return data\n data._overrides = validateOverridesMap(data._overrides)\n return data\n },\n },\n [WITH_OVERRIDES_MARKER]: true,\n }\n return behavior\n}\n\n/**\n * Check whether an entity declares `withOverrides()`. Returns `true`\n * when the entity's behaviors list contains a behavior tagged with\n * the marker symbol. Used by the boot orphan-sweep + admin UI gating.\n */\nexport function hasWithOverrides(\n behaviors: ReadonlyArray<{ name: string } & Record<symbol, unknown>> | undefined,\n): boolean {\n if (!behaviors) return false\n return behaviors.some((b) => Boolean(b?.[WITH_OVERRIDES_MARKER]))\n}\n\n/**\n * Export the directive type alias for downstream consumers that want\n * to type-narrow without depending on `@murumets-ee/blocks`. The\n * resolver lives in blocks; the persisted shape lives here.\n */\nexport type { OverrideDirective, OverridesMap }\n","/**\n * Workflowable behavior — editorial workflow with approval gates.\n *\n * Adds workflow status tracking fields to an entity and enforces the\n * documented transition state machine in `beforeUpdate`. Works alongside\n * `publishable()` — the workflow status tracks the editorial pipeline,\n * while publishable's `status` remains the public-facing state.\n *\n * Workflow state machine:\n *\n * draft ─submit─► in_review ─approve─► approved ─publish─► published\n * │ ▲ │\n * │ └────resubmit─────┐ │\n * └─reject─► rejected ─┘ │\n * │\n * unpublish ◄──────────────┘\n * (back to draft)\n *\n * Internal-write only: every workflow field is declared `internal: true`,\n * so the HTTP PATCH surface strips them from caller input. Workflow\n * transitions are performed by trusted server-side routes via\n * `AdminClient.updateInternal()` after authorizing the action with the\n * permission checker. The `beforeUpdate` hook validates that the\n * proposed transition is in the allowed table.\n *\n * Permission gating lives in the route handler (admin-ui workflow\n * routes), not in this behavior — by the time the hook fires, the\n * route has already authorized via `checkPermission`. The hook is\n * the structural defense: even a bug in the route layer cannot push\n * a workflow row into an illegal state.\n *\n * UI integration is tracked in GitHub issue #124. Until the workflow\n * UX ships, the behavior can still be added to entities — it'll simply\n * seed `_workflowStatus = 'draft'` on create and reject anyone trying\n * to write the field via PATCH.\n */\n\nimport type {\n Behavior,\n BehaviorContext,\n DateField,\n SelectField,\n TextField,\n} from '@murumets-ee/entity'\nimport { field, USER_ID_MAX_LENGTH } from '@murumets-ee/entity'\n\nexport const WORKFLOW_STATES = ['draft', 'in_review', 'approved', 'rejected', 'published'] as const\nexport type WorkflowState = (typeof WORKFLOW_STATES)[number]\n\n/**\n * Allowed transitions table. Each key is the *current* workflow status;\n * the value is the set of statuses it can move to.\n *\n * Encoded as a const-asserted record so a typo in the source produces\n * a compile error rather than a silent no-match at runtime.\n */\nexport const WORKFLOW_TRANSITIONS = {\n draft: ['in_review'],\n in_review: ['approved', 'rejected'],\n approved: ['published', 'in_review'],\n rejected: ['in_review', 'draft'],\n published: ['draft'],\n} as const satisfies Record<WorkflowState, readonly WorkflowState[]>\n\n/**\n * Is `to` an allowed transition from `from`? Pure, no I/O.\n */\nexport function isAllowedTransition(from: WorkflowState, to: WorkflowState): boolean {\n if (from === to) return true // no-op self-transition is fine\n const allowed = WORKFLOW_TRANSITIONS[from] as readonly WorkflowState[] | undefined\n return allowed?.includes(to) ?? false\n}\n\n/**\n * Thrown when `beforeUpdate` rejects a workflow transition. Distinct\n * subclass so admin-ui's API handler can map it to HTTP 400.\n */\nexport class WorkflowTransitionError extends Error {\n readonly from: WorkflowState\n readonly to: WorkflowState\n\n constructor(from: WorkflowState, to: WorkflowState) {\n super(\n `Invalid workflow transition: ${from} → ${to}. ` +\n `Allowed transitions from \"${from}\": [${(WORKFLOW_TRANSITIONS[from] ?? []).join(', ')}].`,\n )\n this.name = 'WorkflowTransitionError'\n this.from = from\n this.to = to\n }\n}\n\nexport type WorkflowableFields = {\n _workflowStatus: SelectField & { required: true }\n _workflowAssignee: TextField\n _workflowNote: TextField\n _workflowUpdatedAt: DateField\n}\n\nexport function workflowable(): Behavior<WorkflowableFields> {\n return {\n name: 'workflowable',\n fields: {\n _workflowStatus: field.select({\n options: WORKFLOW_STATES as unknown as string[],\n default: 'draft',\n indexed: true,\n required: true,\n internal: true,\n }) as SelectField & { required: true },\n // `_workflowAssignee` stores a better-auth user ID — varchar(255)\n // per CLAUDE.md §\"User IDs\", emitted via `maxLength: 255` (#430).\n // `_workflowNote` is free-text, so it stays unbounded `text`.\n _workflowAssignee: field.text({\n required: false,\n internal: true,\n maxLength: USER_ID_MAX_LENGTH,\n }),\n _workflowNote: field.text({ required: false, internal: true }),\n _workflowUpdatedAt: field.date({ internal: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n data._workflowStatus = 'draft'\n data._workflowUpdatedAt = new Date()\n return data\n },\n beforeUpdate: async (\n _id: string,\n data: Record<string, unknown>,\n ctx?: BehaviorContext,\n ): Promise<Record<string, unknown>> => {\n // Only validate when the patch tries to change the workflow status.\n // Updates that don't touch _workflowStatus pass through (the field's\n // `internal: true` already prevents it from sneaking in via PATCH;\n // only updateInternal callers reach this branch with a value).\n const proposed = data._workflowStatus\n if (proposed === undefined) return data\n\n if (!WORKFLOW_STATES.includes(proposed as WorkflowState)) {\n throw new WorkflowTransitionError('draft', proposed as WorkflowState)\n }\n\n // Need the current row to validate the from→to transition. The\n // lazy loader is provided by AdminClient — present on update,\n // absent on create.\n const current = await ctx?.loadCurrent?.()\n const fromState = (current?._workflowStatus as WorkflowState | undefined) ?? 'draft'\n const toState = proposed as WorkflowState\n\n if (!isAllowedTransition(fromState, toState)) {\n throw new WorkflowTransitionError(fromState, toState)\n }\n\n data._workflowUpdatedAt = new Date()\n return data\n },\n },\n }\n}\n","/**\n * Per-entity content capability gates.\n *\n * The content plugin attaches different infrastructure tables to an\n * entity based on which behaviors it has:\n * - `publishable()` → drafts table\n * - `versionable()` → versions table\n * - `publishable() + translatable fields` → locale_status table\n * - any translatable field → translations table (also written by the\n * entity package; included for symmetric capability checks)\n *\n * Calling a capability-specific ContentClient method (`saveDraft`,\n * `publishDraft`, `setLocaleStatus`, `restoreVersion`, ...) on an\n * entity that doesn't have the required behavior is a programming\n * error. ContentClient throws `EntityCapabilityError` on writes and\n * silently returns falsy on reads. Callers that want to gate ahead —\n * \"should I show the draft button on this entity row?\" — use\n * `entitySupports()` instead of catching the error.\n */\n\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\n\nexport type EntityCapability = 'drafts' | 'versions' | 'localeStatus' | 'translations'\n\n/**\n * Does this entity support the given content capability? Pure check\n * against the entity's declared behaviors and field translatability —\n * no plugin lookup, no DB call.\n */\nexport function entitySupports(entity: Entity, capability: EntityCapability): boolean {\n switch (capability) {\n case 'drafts':\n return hasBehavior(entity, 'publishable')\n case 'versions':\n return hasBehavior(entity, 'versionable')\n case 'localeStatus':\n return hasBehavior(entity, 'publishable') && hasTranslatableField(entity)\n case 'translations':\n return hasTranslatableField(entity)\n }\n}\n\nfunction hasBehavior(entity: Entity, name: string): boolean {\n return entity.behaviors?.some((b) => b.name === name) ?? false\n}\n\nfunction hasTranslatableField(entity: Entity): boolean {\n const fields = entity.allFields as Record<string, FieldConfig>\n for (const key of Object.keys(fields)) {\n const f = fields[key]\n if (f && (f as { translatable?: boolean }).translatable === true) return true\n }\n return false\n}\n","/**\n * The interface-message **registry resolver** (plan/config phase 06, D004/D010).\n *\n * The effective interface strings = **read-time-merge** of the per-package / per-app\n * message JSON defaults ⊕ the DB `message` config-entity override rows. This resolver\n * returns the OVERRIDE rows for one locale as flat {@link MessageEntry}s; the caller\n * (the admin `i18n/request.ts`) nests them via `overridesToCatalog` and passes the\n * result as the last `mergeMessages(...)` argument so DB overrides win — live edits,\n * no rebuild.\n *\n * The read is a **system-context** query on the read-only connection: interface strings\n * must reflect customizations on EVERY request (incl. the unauthenticated login page's\n * `AuthUI` strings), exactly like the app-wide locale/roles reads. It degrades to \"no\n * overrides\" (shipped defaults only) on any error — a fresh app pre-migration or a\n * transient DB blip simply renders the shipped strings.\n */\n\nimport { createSystemReachResolver, type ToolkitApp } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport type { MessageEntry } from '@murumets-ee/i18n'\nimport { eq } from 'drizzle-orm'\nimport { Message } from './message-entity.js'\n\n/**\n * Elevated **reach** resolver for the app-wide message read (the locale/roles-read\n * analog). Grants `view` and nothing else: this module only READS override rows,\n * and the editor route that writes them is separately gated. Named constructor\n * rather than a `checker: () => true` literal so the identity is enumerable —\n * see the elevation allowlist in `scripts/verify-package-boundaries.mjs`.\n */\nconst SYSTEM_CONTEXT = createSystemReachResolver('message-registry')\n\n/**\n * Defensive upper bound on override rows read for one locale. A fully-translated app\n * across several locales legitimately accumulates thousands of rows, but per-locale the\n * count is bounded by the number of *customized* strings. The read is `log()`-ged when it\n * hits the cap so a silent truncation can't read as \"all overrides applied\" (CLAUDE.md\n * no-unbounded-fan-out rule).\n */\nconst MAX_MESSAGE_OVERRIDE_ROWS = 20000\n\n/**\n * The DB `message` override rows for `locale`, as flat `(namespace, key, value)` entries.\n * Empty when the `message` entity isn't registered (no `messageEditor()` / pre-migration)\n * or on any read error.\n */\nexport async function resolveMessageOverrides(\n app: ToolkitApp,\n locale: string,\n): Promise<MessageEntry[]> {\n if (!app.entities?.has('message')) return []\n const table = schemaRegistry.get('message')\n if (!table) return []\n\n const client = new QueryClient({\n entity: Message,\n db: app.db.readOnly,\n logger: app.logger.child({ registry: 'message' }),\n contextResolver: SYSTEM_CONTEXT,\n })\n\n try {\n const rows = await client.findMany({\n where: eq(table.locale, locale),\n limit: MAX_MESSAGE_OVERRIDE_ROWS,\n })\n if (rows.length >= MAX_MESSAGE_OVERRIDE_ROWS) {\n app.logger.warn(\n { locale, cap: MAX_MESSAGE_OVERRIDE_ROWS },\n 'message override read hit the row cap — some overrides were not applied',\n )\n }\n return rows.map((r) => {\n const row = r as Record<string, unknown>\n return {\n namespace: String(row.namespace),\n key: String(row.key),\n value: String(row.value),\n }\n })\n } catch (err) {\n app.logger.warn(\n { err, locale },\n 'message registry read failed — falling back to shipped defaults only',\n )\n return []\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{behavior as e,defineEntity as t,field as n}from"@murumets-ee/entity";import{defineSettings as r,setting as i}from"@murumets-ee/settings/define";const a=[`prefix-all`,`no-prefix-default`,`cookie`],o=r({namespace:`i18n`,scope:`global`,label:`Languages`,iconName:`languages`,hideFromMenu:!0,schema:{defaultLocale:i.text({default:``}),routingStrategy:i.select({options:a,default:`no-prefix-default`})}}),s=t({name:`locale`,class:`config`,scope:`global`,site:`shared`,access:{view:`locale:view`,create:`locale:create`,update:`locale:update`,delete:`locale:delete`},fields:{code:n.text({required:!0,unique:!0,maxLength:10}),label:n.text({required:!0,maxLength:64}),enabled:n.boolean({default:!0}),direction:n.select({options:[`ltr`,`rtl`],default:`ltr`}),sortOrder:n.number({default:0})},behaviors:[e.auditable()],admin:{hidden:!0,label:`Languages`,labelSingular:`Language`}});export{a as n,o as r,s as t};
2
+ //# sourceMappingURL=locale-entity-CoSq3C77.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale-entity-CoSq3C77.mjs","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts"],"sourcesContent":["/**\n * `i18n` — the global i18n singletons **simple-config** namespace (plan/config\n * phase 05 §3, D002/D009/SD006): `defaultLocale` + `routingStrategy`.\n *\n * Simple config (D002) rather than a config entity because these are singletons,\n * not a list. Contributed via the content plugin's `shared.settings` slot, so it\n * auto-registers as a config type (D006) and rides export/import (phase 09) for\n * free. Edited through the bespoke **Languages** editor (`hideFromMenu`), which\n * also enforces `defaultLocale ∈ enabled locales`.\n *\n * Imports from `@murumets-ee/settings/define` (the pure builder subpath, no\n * `server-only`) so the content plugin stays jiti-safe at config-load time.\n */\n\nimport { defineSettings, setting } from '@murumets-ee/settings/define'\n\n/** The three URL locale-routing strategies (D009). */\nexport const ROUTING_STRATEGIES = ['prefix-all', 'no-prefix-default', 'cookie'] as const\n\nexport const i18nSettings = defineSettings({\n namespace: 'i18n',\n scope: 'global',\n label: 'Languages',\n iconName: 'languages',\n // Edited via the Languages editor, not the generic per-namespace settings page.\n hideFromMenu: true,\n schema: {\n /**\n * The site-wide default content locale. Empty ⇒ inherit the `content()`\n * shipped `defaultLocale`. The resolver ({@link resolveI18nConfig}) validates\n * it is an **enabled** locale and falls back to the `content()` default if not.\n */\n defaultLocale: setting.text({ default: '' }),\n /**\n * How the public URL encodes locale. Maps to next-intl `localePrefix` /\n * Astro `prefixDefaultLocale` via `./routing-strategy.js`.\n */\n routingStrategy: setting.select({\n options: ROUTING_STRATEGIES,\n default: 'no-prefix-default',\n }),\n },\n})\n","/**\n * `locale` — the content-locale registry **config entity** (plan/config phase 05\n * §3, D006/D009/SD006).\n *\n * One row per content locale the operator adds or customizes. The row is a\n * **read-time-merge override** (D004): a row whose `code` matches a `content()`\n * shipped default replaces that default (relabel / set direction / reorder /\n * `enabled: false` to hide it); a row with a new `code` adds a locale. Locales\n * with no row are the shipped defaults. See {@link resolveEffectiveLocales}.\n *\n * `class: 'config'` (SD001) marks it a configuration entity: it auto-registers\n * into the `ConfigTypeRegistry` (phase-09 export/import picks it up for free),\n * defaults to audit-only (no publish / no trash — trash auto-enable skips config\n * entities), and is edited through the bespoke **Languages** editor, not the\n * generic auto-CRUD (`admin.hidden`).\n *\n * `access` is admin-only: managing the language set is an admin capability. The\n * app-wide *effective-locale read* ({@link resolveEffectiveLocales}) uses a\n * system-context query so every role still resolves valid locales for editing\n * content, without being able to list/edit the registry itself.\n *\n * Leaf-safe: imports only `@murumets-ee/entity`, so the content plugin can pull\n * it in without a `server-only` chain through the jiti config load.\n */\n\nimport { behavior, defineEntity, field } from '@murumets-ee/entity'\n\nexport const Locale = defineEntity({\n name: 'locale',\n class: 'config',\n scope: 'global',\n // The locale registry is global; each site lists the subset it serves (D007).\n site: 'shared',\n access: {\n view: 'locale:view',\n create: 'locale:create',\n update: 'locale:update',\n delete: 'locale:delete',\n },\n fields: {\n /**\n * BCP47 locale code (e.g. `en`, `et`, `pt-BR`). Unique — one row per locale.\n * Capped at 10 to match the `varchar(10)` locale columns in the\n * versions / drafts / locale_status tables (truncation past 10 would break\n * later equality — see `locale-utils.ts`).\n */\n code: field.text({ required: true, unique: true, maxLength: 10 }),\n /** Human label shown in the switcher + editor (e.g. `English`). */\n label: field.text({ required: true, maxLength: 64 }),\n /**\n * When `false` the locale is hidden from the content switcher + rejected by\n * `validateLocale` (the D004 disable-not-delete edge), but stays listable in\n * the Languages editor so it can be re-enabled.\n */\n enabled: field.boolean({ default: true }),\n /** Text direction (`ltr`/`rtl`) — persisted per locale for RTL-aware surfaces to read\n * when setting `dir`; the surface-level `dir` wiring lands with RTL support. */\n direction: field.select({ options: ['ltr', 'rtl'] as const, default: 'ltr' }),\n /** Display order in the switcher + editor (ascending). */\n sortOrder: field.number({ default: 0 }),\n },\n behaviors: [behavior.auditable()],\n admin: {\n // Edited via the Languages editor, not the generic CRUD — keep it out of the\n // auto entity list + sidebar.\n hidden: true,\n label: 'Languages',\n labelSingular: 'Language',\n },\n})\n"],"mappings":"uJAiBA,MAAa,EAAqB,CAAC,aAAc,oBAAqB,QAAQ,EAEjE,EAAe,EAAe,CACzC,UAAW,OACX,MAAO,SACP,MAAO,YACP,SAAU,YAEV,aAAc,GACd,OAAQ,CAMN,cAAe,EAAQ,KAAK,CAAE,QAAS,EAAG,CAAC,EAK3C,gBAAiB,EAAQ,OAAO,CAC9B,QAAS,EACT,QAAS,mBACX,CAAC,CACH,CACF,CAAC,ECfY,EAAS,EAAa,CACjC,KAAM,SACN,MAAO,SACP,MAAO,SAEP,KAAM,SACN,OAAQ,CACN,KAAM,cACN,OAAQ,gBACR,OAAQ,gBACR,OAAQ,eACV,EACA,OAAQ,CAON,KAAM,EAAM,KAAK,CAAE,SAAU,GAAM,OAAQ,GAAM,UAAW,EAAG,CAAC,EAEhE,MAAO,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,EAAG,CAAC,EAMnD,QAAS,EAAM,QAAQ,CAAE,QAAS,EAAK,CAAC,EAGxC,UAAW,EAAM,OAAO,CAAE,QAAS,CAAC,MAAO,KAAK,EAAY,QAAS,KAAM,CAAC,EAE5E,UAAW,EAAM,OAAO,CAAE,QAAS,CAAE,CAAC,CACxC,EACA,UAAW,CAAC,EAAS,UAAU,CAAC,EAChC,MAAO,CAGL,OAAQ,GACR,MAAO,YACP,cAAe,UACjB,CACF,CAAC"}
@@ -0,0 +1,2 @@
1
+ import{n as e,r as t,t as n}from"./locale-entity-CoSq3C77.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{createSystemReachResolver as i,getCurrentSite as a,mergeConfigOverrides as o,readSiteRecord as s,resolveImplicitSite as c}from"@murumets-ee/core";import{QueryClient as l}from"@murumets-ee/entity/query";const u=i(`locale-registry`);function d(){return r().locales.map((e,t)=>({code:e.code,label:e.label,enabled:!0,direction:`ltr`,sortOrder:t}))}function f(e,t){return e.sortOrder-t.sortOrder||e.code.localeCompare(t.code)}async function p(e){return e.entities?.has(`locale`)?(await new l({entity:n,db:e.db.readOnly,logger:e.logger.child({registry:`locale`}),contextResolver:u}).findMany({limit:1e3})).map(e=>{let t=e;return{code:String(t.code),label:typeof t.label==`string`&&t.label.length>0?t.label:String(t.code),enabled:t.enabled!==!1,direction:t.direction===`rtl`?`rtl`:`ltr`,sortOrder:typeof t.sortOrder==`number`?t.sortOrder:0}}):[]}async function m(e){let t;try{t=await p(e)}catch(t){return e.logger.warn({err:t},`locale registry read failed — falling back to content() defaults`),[...d()].sort(f)}return o({defaults:d(),overrides:t,idOfDefault:e=>e.code,idOfOverride:e=>e.code}).sort(f)}function h(e){return e.filter(e=>e.enabled).map(e=>e.code)}async function g(n,i){let a=h(i??await m(n)),o=r().defaultLocale,s={};try{let{createSettingsClient:e}=await import(`@murumets-ee/settings/runtime`);s=await e(t,{app:n}).getAll()??{}}catch(e){n.logger.warn({err:e},`i18n settings read failed — falling back to content() defaults`)}let c=typeof s.defaultLocale==`string`&&s.defaultLocale.length>0?s.defaultLocale:o,l=a.includes(c)?c:a.includes(o)?o:a[0]??o,u=s.routingStrategy;return{defaultLocale:l,routingStrategy:e.includes(typeof u==`string`?u:``)?u:`no-prefix-default`}}async function _(e,t){return t??a()??await c(e).catch(()=>void 0)}function v(e,t){return s(e,t)}function y(e,t){let n=t?.locales??[];return n.length===0?e:e.filter(e=>n.includes(e.code))}async function b(e,t={}){let n=[...t.entries??await m(e)],r=await _(e,t.site);return r===void 0?{site:r,entries:n}:{site:r,entries:y(n,await v(e,r))}}async function x(t,n={}){let r=[...n.entries??await m(t)],i=await g(t,r),a=await _(t,n.site);if(a===void 0)return{site:a,entries:r,i18n:i,install:i};let o=await v(t,a),s=y(r,o),c=h(s),l=t.config.sites?.multiple===!0,u=l?o?.defaultLocale:void 0,d=u!==void 0&&c.includes(u)?u:c.includes(i.defaultLocale)?i.defaultLocale:c[0]??i.defaultLocale,f=l?o?.routingStrategy:void 0;return{site:a,entries:s,i18n:{defaultLocale:d,routingStrategy:e.includes(f??``)?f:i.routingStrategy},install:i}}export{b as a,x as i,m as n,g as r,h as t};
2
+ //# sourceMappingURL=locale-registry-Bn6e_nM7.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale-registry-Bn6e_nM7.mjs","names":[],"sources":["../src/locale-registry.ts"],"sourcesContent":["/**\n * The content-locale **registry resolvers** (plan/config phase 05 §7, D004/D009/SD006).\n *\n * The effective content-locale set = **read-time-merge** of the `content()` shipped\n * defaults ⊕ the DB `locale` config-entity override rows. Resolved **once per request**\n * at context establishment (the admin API handler + `withAdminContext`) and threaded\n * onto {@link RequestContext.availableLocales}, so `validateLocale`'s synchronous\n * whitelist reflects runtime-added / disabled locales with **no rebuild and no\n * globalThis cache** — multi-process / multi-tenant correct.\n *\n * The registry read is a **system-context** query: managing the language set is\n * admin-only (`locale:*`), but every role must resolve valid locales to edit content,\n * exactly like the app-wide roles read. Reads degrade to the static `content()`\n * defaults on any error (fresh app pre-migration, transient DB failure) — deny-by-\n * default is preserved (a not-yet-known locale simply isn't whitelisted).\n */\n\nimport {\n createSystemReachResolver,\n getCurrentSite,\n mergeConfigOverrides,\n readSiteRecord,\n resolveImplicitSite,\n type SiteRecordView,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { getContentConfig } from './content-config-store.js'\nimport { i18nSettings, ROUTING_STRATEGIES } from './i18n-settings.js'\nimport { Locale } from './locale-entity.js'\nimport type { I18nConfig, LocaleDirection, LocaleEntry, RoutingStrategy } from './types.js'\n\n/**\n * Elevated **reach** resolver for the app-wide registry read (the roles-read\n * analog). It grants `view` and nothing else — this module only ever READS the\n * `locale` rows, so an all-allow checker would have handed every caller of this\n * resolver a write grant it has no use for. The named constructor owns that\n * exception centrally rather than leaving it to be re-derived per call site;\n * a hand-written `checker: () => true` here is refused by `verify:boundaries`.\n *\n * The explicit id is the audit identity — two elevated readers sharing the\n * default `'system'` are indistinguishable in the log.\n */\nconst SYSTEM_CONTEXT = createSystemReachResolver('locale-registry')\n\n/** The `content()`-shipped locales projected to full {@link LocaleEntry}s (all enabled). */\nfunction shippedDefaults(): LocaleEntry[] {\n return getContentConfig().locales.map((l, i) => ({\n code: l.code,\n label: l.label,\n enabled: true,\n direction: 'ltr' as LocaleDirection,\n sortOrder: i,\n }))\n}\n\n/** Ascending by `sortOrder`, then `code` for a stable tie-break. */\nfunction bySortOrder(a: LocaleEntry, b: LocaleEntry): number {\n return a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)\n}\n\n/**\n * Defensive upper bound on `locale` rows to read (a tenant realistically has <20). The\n * Languages-editor route MUST reconcile against the SAME bound so it can't miss/strand\n * rows — see admin-ui `locale-routes` `MAX_EXISTING_LOCALE_ROWS` (kept in sync).\n */\nconst MAX_LOCALE_ROWS = 1000\n\n/** Read + normalize the DB `locale` override rows (system context). */\nasync function readLocaleRows(app: ToolkitApp): Promise<LocaleEntry[]> {\n if (!app.entities?.has('locale')) return []\n const client = new QueryClient({\n entity: Locale,\n db: app.db.readOnly,\n logger: app.logger.child({ registry: 'locale' }),\n contextResolver: SYSTEM_CONTEXT,\n })\n const rows = await client.findMany({ limit: MAX_LOCALE_ROWS })\n return rows.map((r) => {\n const row = r as Record<string, unknown>\n return {\n code: String(row.code),\n label: typeof row.label === 'string' && row.label.length > 0 ? row.label : String(row.code),\n enabled: row.enabled !== false,\n direction: row.direction === 'rtl' ? 'rtl' : 'ltr',\n sortOrder: typeof row.sortOrder === 'number' ? row.sortOrder : 0,\n }\n })\n}\n\n/**\n * The effective content-locale set — shipped defaults ⊕ DB overrides, sorted by\n * `sortOrder`. Includes **disabled** locales (listable in the Languages editor);\n * callers that want only switchable/valid locales use {@link enabledLocaleCodes}.\n */\nexport async function resolveEffectiveLocales(app: ToolkitApp): Promise<LocaleEntry[]> {\n let rows: LocaleEntry[]\n try {\n rows = await readLocaleRows(app)\n } catch (err) {\n app.logger.warn({ err }, 'locale registry read failed — falling back to content() defaults')\n return [...shippedDefaults()].sort(bySortOrder)\n }\n const merged = mergeConfigOverrides<LocaleEntry, LocaleEntry>({\n defaults: shippedDefaults(),\n overrides: rows,\n idOfDefault: (l) => l.code,\n idOfOverride: (r) => r.code,\n // No `isDisabled`: a disabled locale stays in the merged list so the editor\n // can re-enable it — the switcher + validateLocale filter on `enabled`.\n })\n return (merged as LocaleEntry[]).sort(bySortOrder)\n}\n\n/** The **enabled** codes from an effective set — the switcher + validateLocale whitelist. */\nexport function enabledLocaleCodes(entries: readonly LocaleEntry[]): string[] {\n return entries.filter((e) => e.enabled).map((e) => e.code)\n}\n\n/**\n * The resolved global i18n singletons (`i18n` settings ⊕ `content()` defaults).\n * `defaultLocale` is guaranteed to be an **enabled** locale: the configured value\n * wins if enabled, else the `content()` default if enabled, else the first enabled\n * locale (last-resort, deterministic). Pass `entries` to reuse an already-resolved\n * effective set (avoids a second registry read within one request).\n */\nexport async function resolveI18nConfig(\n app: ToolkitApp,\n entries?: readonly LocaleEntry[],\n): Promise<I18nConfig> {\n const effective = entries ?? (await resolveEffectiveLocales(app))\n const enabled = enabledLocaleCodes(effective)\n const shippedDefault = getContentConfig().defaultLocale\n\n let values: Record<string, unknown> = {}\n try {\n // Dynamic import: `@murumets-ee/settings/runtime` evaluates the settings schema\n // (`defineTable`) at module load; importing it eagerly would drag that graph into\n // `@murumets-ee/content`'s main index (where only `resolveEffectiveLocales` — which\n // needs no settings — is on the hot import path). Loaded lazily here, only when the\n // i18n singletons are actually read. (Same pattern as rent/ticketing/consent.)\n const { createSettingsClient } = await import('@murumets-ee/settings/runtime')\n const client = createSettingsClient(i18nSettings, { app })\n values = (await client.getAll()) ?? {}\n } catch (err) {\n app.logger.warn({ err }, 'i18n settings read failed — falling back to content() defaults')\n }\n\n const configuredDefault =\n typeof values.defaultLocale === 'string' && values.defaultLocale.length > 0\n ? values.defaultLocale\n : shippedDefault\n\n const defaultLocale = enabled.includes(configuredDefault)\n ? configuredDefault\n : enabled.includes(shippedDefault)\n ? shippedDefault\n : (enabled[0] ?? shippedDefault)\n\n const rawStrategy = values.routingStrategy\n const routingStrategy: RoutingStrategy = (ROUTING_STRATEGIES as readonly string[]).includes(\n typeof rawStrategy === 'string' ? rawStrategy : '',\n )\n ? (rawStrategy as RoutingStrategy)\n : 'no-prefix-default'\n\n return { defaultLocale, routingStrategy }\n}\n\n/** What {@link resolveSiteI18n} returns. */\nexport interface SiteI18n {\n /** The site resolved, or `undefined` when none could be named (see below). */\n site: string | undefined\n /** The effective registry narrowed to the site (disabled locales kept). */\n entries: LocaleEntry[]\n /**\n * The site's PRESENTATION defaults: the locale a switcher, a sitemap or an\n * unlocalised request falls back to (an ENABLED locale of the site), and its\n * routing strategy.\n *\n * ⚠️ Not the STORAGE default. Which locale a row's base columns hold is an\n * install-wide fact (`content({ defaultLocale })`, `i18n.defaultLocale`) —\n * shared rows (media) are written from every site, so it cannot vary by\n * site. Read {@link install} for it.\n */\n i18n: I18nConfig\n /** The install-wide i18n singletons — the storage default. */\n install: I18nConfig\n}\n\n/** The site of `options.site`, the request, or the one site of a single-site install. */\nasync function nameSite(app: ToolkitApp, site: string | undefined): Promise<string | undefined> {\n return site ?? getCurrentSite() ?? (await resolveImplicitSite(app).catch(() => undefined))\n}\n\n/**\n * The site's registry row. A failed read PROPAGATES: answering the install-wide\n * set instead would silently lift the site's locale restriction (fail open).\n */\nfunction siteRecord(app: ToolkitApp, site: string): Promise<SiteRecordView | undefined> {\n return readSiteRecord(app, site)\n}\n\nfunction narrowTo(effective: LocaleEntry[], record: SiteRecordView | undefined): LocaleEntry[] {\n const served = record?.locales ?? []\n return served.length === 0 ? effective : effective.filter((e) => served.includes(e.code))\n}\n\n/**\n * The effective registry narrowed to ONE SITE's locales (plan `multisite`\n * phase 03) — the whitelist alone, without the i18n settings read, for the\n * per-request callers (the admin API handler) that need only the set.\n */\nexport async function resolveSiteLocales(\n app: ToolkitApp,\n options: { site?: string; entries?: readonly LocaleEntry[] } = {},\n): Promise<{ site: string | undefined; entries: LocaleEntry[] }> {\n const effective = [...(options.entries ?? (await resolveEffectiveLocales(app)))]\n const site = await nameSite(app, options.site)\n if (site === undefined) return { site, entries: effective }\n return { site, entries: narrowTo(effective, await siteRecord(app, site)) }\n}\n\n/**\n * The locale set and presentation defaults of ONE SITE (plan `multisite` D007,\n * phase 03): the effective registry narrowed to the site record's `locales`\n * (empty = every locale), with the site's own default and routing strategy.\n *\n * - `entries` keeps disabled locales (like {@link resolveEffectiveLocales});\n * the whitelist is `enabledLocaleCodes(entries)`.\n * - `i18n.defaultLocale` is an ENABLED locale of the site. On an app serving\n * several sites the record's `defaultLocale` / `routingStrategy` win; on a\n * single-site install the install's i18n settings stay authoritative (the\n * record only holds a one-time copy of them, and the Languages editor writes\n * the settings), so nothing about a one-site install changes.\n * - The site is `options.site`, else the request's, else the single site of a\n * one-site install. With none of those (a multi-site request that names no\n * site) the install-wide set is returned — what a SHARED entity (media) edits\n * against; a bound entity refuses on its own, without a site.\n */\nexport async function resolveSiteI18n(\n app: ToolkitApp,\n options: { site?: string; entries?: readonly LocaleEntry[] } = {},\n): Promise<SiteI18n> {\n const effective = [...(options.entries ?? (await resolveEffectiveLocales(app)))]\n const install = await resolveI18nConfig(app, effective)\n const site = await nameSite(app, options.site)\n if (site === undefined) return { site, entries: effective, i18n: install, install }\n\n const record = await siteRecord(app, site)\n const narrowed = narrowTo(effective, record)\n const enabled = enabledLocaleCodes(narrowed)\n const multiSite = app.config.sites?.multiple === true\n const wanted = multiSite ? record?.defaultLocale : undefined\n const defaultLocale =\n wanted !== undefined && enabled.includes(wanted)\n ? wanted\n : enabled.includes(install.defaultLocale)\n ? install.defaultLocale\n : (enabled[0] ?? install.defaultLocale)\n const strategy = multiSite ? record?.routingStrategy : undefined\n const routingStrategy: RoutingStrategy = (ROUTING_STRATEGIES as readonly string[]).includes(\n strategy ?? '',\n )\n ? (strategy as RoutingStrategy)\n : install.routingStrategy\n return { site, entries: narrowed, i18n: { defaultLocale, routingStrategy }, install }\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./versionable-Br_G5VGR.mjs";import{r as t}from"./errors-NRNILhhm.mjs";import{r as n,t as r}from"./locale-entity-CoSq3C77.mjs";import{n as i,t as a}from"./content-config-store-8omQF5p5.mjs";import{r as o}from"./routing-strategy-D6B4ngT9.mjs";import{n as s,t as c}from"./registry-CaPbY5lm.mjs";import"./locale-utils-B0mFoV47.mjs";import{n as l}from"./chrome-entity-names-zYJzP-_U.mjs";import{c as u,i as d,n as f,o as p,r as m,t as h}from"./schema-BYrYzsmO.mjs";import{l as g,t as _,u as v}from"./schema-IML1rRU_.mjs";import{r as y,t as b}from"./routes-BmNSYyuT.mjs";import{n as x}from"./locks-5779ugSW.mjs";import{MAX_PATH_DEPTH as S,buildPartitionCondition as C,hasPathParent as w,isSiteBound as T,resolveEffectiveSlugMode as E,resolvePattern as D}from"@murumets-ee/entity";import{definePlugin as O,getApp as k,getAppOrUndefined as A,getContext as j,getCurrentSite as M,requireRequestSite as N,requireSiteFor as P,resolveImplicitSite as F,runAsCli as I,runInEverySite as L,runInSite as R}from"@murumets-ee/core";import{column as z,defineTable as B,schemaRegistry as V}from"@murumets-ee/db";import{and as H,asc as ee,eq as U,getTableColumns as W,inArray as G,sql as te}from"drizzle-orm";import{z as K}from"zod";import{createHash as ne}from"node:crypto";const re=Symbol.for(`@murumets-ee/content:locale-status-writer`);async function q(e,t,n,r){let i=r===`published`?new Date:null;await e.upsert({entityId:t,locale:n,status:r,publishedAt:i},{target:[`entityId`,`locale`],set:{status:r,publishedAt:i}})}function ie(e){globalThis[re]=(n,r)=>{let{localeStatus:i}=c(n.name);if(i)return async(n,a,o,s)=>{let c=j()?.availableLocales??e;if(!c.includes(a))throw new t(a,[...c]);await q(i.makeClient(r,s),n,a,o)}}}async function J(e){let t=j()?.app??A();if(!t)return;let n=t.entities.get(e);if(n)return P(n,t)}function ae(){return{async writeDraft({documentType:e,documentId:t,locale:n,dataPatch:r,user:i,tx:a}){let{drafts:o}=c(e);if(!o)return!1;let s=o.makeClient(a,await J(e)),l=new Date;return await s.upsert({entityId:t,locale:n,data:r,createdBy:i.id,createdByName:i.name??null,createdAt:l,updatedAt:l},{target:[`entityId`,`locale`],set:{updatedAt:l},mergeJsonb:[`data`]}),!0},async readDraft({documentType:e,documentId:t,locale:n,db:r}){let{drafts:i}=c(e);if(!i)return null;let a=await i.makeClient(r,await J(e)).findOne({entityId:t,locale:n});return a?{data:a.data??{},createdBy:a.createdBy??null,createdByName:a.createdByName??null,updatedAt:a.updatedAt??null}:null},async readLocaleStatus({documentType:e,documentId:t,locale:n,db:r}){let{localeStatus:i}=c(e);if(!i)return null;let a=await i.makeClient(r,await J(e)).findOne({entityId:t,locale:n});return a?a.status===`published`?`published`:`draft`:null},async hasAnyDraft({documentType:e,documentId:t,db:n}){let{drafts:r}=c(e);return r?r.makeClient(n,await J(e)).exists({entityId:t}):!1},async hasAnyLocaleActivity({documentType:e,documentId:t,db:n}){let{localeStatus:r}=c(e),i=await J(e);if(r&&await r.makeClient(n,i).exists({entityId:t}))return!0;let a=V.get(`${e}_translations`);if(a){let r=a.entityId;if(r!==void 0){let o=i===void 0?U(r,t):H(U(r,t),C(a,{site:i},e));if((await n.select({one:te`1`}).from(a).where(o).limit(1)).length>0)return!0}}return!1}}}function oe(e){let{defaultLocale:t}=a();return e===`_`||e===t?{defaultLocale:t}:{locale:e,defaultLocale:t}}function se(e){let{defaultLocale:t}=a();return e===`_`||e===t?`_`:e}async function ce(e,t,n,r){let{drafts:i}=c(e);if(!i)return;let a=await i.makeClient(r,await J(e)).findOne({entityId:t,locale:n});return a?a.data??void 0:void 0}function le(){return{async snapshotCurrentState({documentType:t,documentId:n,locale:r,user:i}){let a=k(),o=a.entities.get(t);if(!o)return!1;let s=e(o.behaviors);if(!s)return!1;let c=a.getClient(o),l=c.getDb(),u=se(r),d=await c.findById(n,oe(u));if(!d)return!1;let f=await ce(t,n,u,l),p=f?{...d,...f}:{...d},[{createContentContext:m},{createVersionSnapshot:h}]=await Promise.all([import(`./context-D7eEItCY.mjs`).then(e=>e.t),import(`./versions-DLrR7hIZ.mjs`).then(e=>e.c)]);return await h(m({admin:c,entity:o},s),n,p,d,u,{id:i.id,...i.name==null?{}:{name:i.name}}),!0}}}async function Y(e,t,n){let r=W(h),i=r[m],a=r.entityType,o=r.entityId;if(!i||!a||!o)throw Error(`toolkit_paths is missing '${m}'/entity columns`);let s=await e.selectDistinct({site:i}).from(h).where(H(U(a,t),U(o,n))).limit(2);if(s.length>1)return null;let c=s[0]?.site;return typeof c==`string`?c:void 0}const ue=Symbol.for(`@murumets-ee/content:path-writers-factory`),X=Symbol.for(`@murumets-ee/content:cascade-trigger-factory`);function de(e){globalThis[X]=e}function fe(){let e=globalThis[X];return typeof e==`function`?e:void 0}function pe(e,t){return async r=>{let i=await me(e,t,r);if(i===null)return;let a=f.makeClient(e.db.readWrite,i),o=M();if(o!==void 0&&o!==i&&!(r.action===`delete`||r.action===`release`)){e.logger.warn({entity:r.entityName,id:r.id,site:i,requestSite:o},"toolkit_paths: a cascade reached a document on another site — its path is left for `lumi paths:rebuild` on that site rather than recomputed from this request");return}return o===void 0&&r.viaCascade===!0&&T(t)?R(i,()=>n(a,r)):n(a,r)};async function n(n,r){if(r.action===`delete`){await n.deleteMany({entityType:r.entityName,entityId:r.id});return}if(r.action===`release`){await n.deleteMany({entityType:r.entityName,entityId:r.id,isCanonical:!0});return}let i=a(),o=i.defaultLocale,s=i.reservedPathPrefixes??[],c=r.locale?[r.locale]:i.locales.map(e=>e.code),l=r.viaCascade===!0?e.getSystemClient(t):e.getClient(t),u=async n=>{if(!w(t))return;let i=fe();if(i)try{await i(e,t,r.id,n)}catch(t){e.logger.warn({err:t,entity:r.entityName,id:r.id,locale:n},`toolkit_paths: descendant-cascade trigger threw — the entity write already committed, so tolerating and continuing (paths are rebuildable, D005)`)}};for(let i of c){let a=i===r.locale?r.row??null:await l.findById(r.id,{locale:i,defaultLocale:o});if(!a){await p({pathsClient:n,entityType:r.entityName,entityId:r.id,locale:i,path:null,skipReason:`row-unreadable`,reservedPathPrefixes:s,logger:e.logger});continue}let c=await g({entity:t,row:a,locale:i,defaultLocale:o,pathsClient:n}),d=await p({pathsClient:n,entityType:r.entityName,entityId:r.id,locale:i,path:c.path,...c.skipReason!==void 0&&{skipReason:c.skipReason},reservedPathPrefixes:s,logger:e.logger});(d.outcome===`renamed`||r.action===`restore`&&d.outcome===`created`)&&await u(i)}}}async function me(e,t,n){if(n.viaCascade===!0&&T(t)){let t=await Y(e.db.readWrite,n.entityName,n.id);if(t===null)return e.logger.warn({entity:n.entityName,id:n.id},"toolkit_paths: a document has path rows on SEVERAL sites — refusing to guess which one this cascade maintains; `lumi paths:rebuild` per site repairs it"),null;if(t!==void 0)return t;let r=M();if(r!==void 0)return r;let i=await F(e).catch(()=>void 0);return i===void 0?(e.logger.warn({entity:n.entityName,id:n.id,action:n.action},"toolkit_paths: cascade child has no path rows and the request names no site — nothing to maintain here; `lumi paths:rebuild` recomputes it per site"),null):i}return N(`toolkit_paths`,e)}function he(e,t){return async n=>{let r=f.makeClient(e.db.readWrite,await N(`toolkit_paths`,e)),i=a(),o=i.defaultLocale,s=i.reservedPathPrefixes??[],c=n.locale??o,l=null;n.id!==void 0&&(l=await e.getClient(t).findById(n.id,{locale:c,defaultLocale:o}));let u=E(n.data.slugMode,l?.slugMode);await v({entity:t,data:n.data,currentRow:l,id:n.id,locale:c,defaultLocale:o,slugMode:u,reservedPathPrefixes:s,pathsClient:r})}}function ge(){globalThis[ue]=(e,t)=>({pathIndexWriter:pe(e,t),pathConflictGuard:he(e,t)})}const Z=K.object({entityType:K.string().min(1),parentId:K.string().uuid(),locale:K.string().min(1)}),Q=K.object({entity:K.string().min(1).optional(),locale:K.string().min(1).optional(),dryRun:K.boolean()});async function _e(e,t,n={}){let{entityType:r,parentId:i,locale:o}=t,s=n.maxItems??2e3,c=n.concurrency??5,l={processed:0,regenerated:0,unchanged:0,skipped:0,failed:0,truncated:!1},d=e=>({phase:e,...l}),m=()=>({entityType:r,parentId:i,locale:o,cap:s,...l}),h=e.entities.get(r);if(!h)return e.logger.warn({entityType:r,parentId:i,locale:o},`paths-cascade: unknown entity type — nothing to regenerate`),n.onProgress?.(d(`done`)),m();if(!w(h))return e.logger.warn({entityType:r,parentId:i,locale:o},`paths-cascade: entity is not a pathParent() hierarchy — nothing to regenerate`),n.onProgress?.(d(`done`)),m();let _=a(),v=_.defaultLocale,y=_.reservedPathPrefixes??[],b=f.makeClient(e.db.readWrite,await N(`toolkit_paths`,e)),x=e.getClient(h),S=x.getTable().parent,C=async t=>{let n=t.id;try{let i=await g({entity:h,row:t,locale:o,defaultLocale:v,pathsClient:b});switch((await p({pathsClient:b,entityType:r,entityId:n,locale:o,path:i.path,...i.skipReason!==void 0&&{skipReason:i.skipReason},reservedPathPrefixes:y,logger:e.logger})).outcome){case`created`:case`renamed`:l.regenerated+=1;break;case`unchanged`:l.unchanged+=1;break;default:l.skipped+=1;break}}catch(t){l.failed+=1,e.logger.warn({err:t,entityType:r,id:n,locale:o},`paths-cascade: failed to regenerate one descendant path — continuing (per-item isolation)`)}},T=[i];for(;T.length>0;){let e=s-l.processed;if(e<=0){l.truncated=!0;break}let t=await x.findMany({where:G(S,T),locale:o,defaultLocale:v,limit:e+1});if(t.length===0)break;let r=t;if(t.length>e&&(r=t.slice(0,e),l.truncated=!0),await u(r,c,C),l.processed+=r.length,n.onProgress?.(d(`walking`)),l.truncated)break;T=r.map(e=>e.id)}return l.truncated&&e.logger.warn({entityType:r,parentId:i,locale:o,processed:l.processed,cap:s},`paths-cascade: descendant subtree exceeded the cap — deeper/remaining descendants were NOT regenerated. Run \`lumi paths:rebuild --entity ${r}\` for guaranteed full coverage.`),n.onProgress?.(d(`done`)),m()}async function ve(e,t,n={}){let r=n.pageSize??200,i=n.concurrency??5,o=n.sampleCap??25,s=t.dryRun,c=a(),l=c.defaultLocale,d=c.reservedPathPrefixes??[],m=c.locales.map(e=>e.code),h=t.locale?m.filter(e=>e===t.locale):m,_=[...e.entities.values()].filter(e=>!!e.frontend?.path&&(t.entity===void 0||e.name===t.entity)),v=t.entity!==void 0&&_.length===0,y=t.locale!==void 0&&h.length===0;v&&e.logger.warn({entity:t.entity,dryRun:s},`paths-rebuild: --entity matches no routable entity (one declaring frontend.path) — nothing to rebuild (likely a typo in the entity name)`),y&&e.logger.warn({locale:t.locale,configuredLocales:m,dryRun:s},`paths-rebuild: --locale matches no configured locale — nothing to rebuild (likely a typo in the locale code)`);let b=v||y?{entity:v,locale:y}:void 0,x=_.some(e=>w(e)),C=s&&x?`Preview reflects a SINGLE pass; a pathParent() hierarchy drifted at multiple levels may need the real (non-dry-run) rebuild's multi-pass convergence to fully resolve. The real run always converges.`:void 0,T=f.makeClient(e.db.readWrite,await N(`toolkit_paths`,e)),E={scanned:0,changed:0,applied:0,failed:0},D=new Map,O={missing:0,conflicts:0,unchanged:0,skipped:0},k=(e,t)=>{let n=D.get(e);n!==t&&(n!==void 0&&n!==`resolved`&&--O[n],D.set(e,t),t!==`resolved`&&(O[t]+=1))},A=[],j=!1,M=e=>{A.length<o?A.push(e):j=!0},P=0,F=e=>({phase:e,dryRun:s,scope:{entity:t.entity??null,locale:t.locale??null},scanned:E.scanned,changed:E.changed,applied:E.applied,failed:E.failed,missing:O.missing,conflicts:O.conflicts,unchanged:O.unchanged,skipped:O.skipped,passes:P,sample:A,sampleTruncated:j,...C===void 0?{}:{hierarchyNote:C},...b===void 0?{}:{unmatchedScope:b}}),I=async(t,n,r,i)=>{let a=r.id,o=!1;try{let c=await g({entity:t,row:r,locale:i,defaultLocale:l,pathsClient:T}),u=await p({pathsClient:T,entityType:n,entityId:a,locale:i,path:c.path,...c.skipReason!==void 0&&{skipReason:c.skipReason},suppressNoteworthy:[`parent-not-indexed`],reservedPathPrefixes:d,logger:e.logger,dryRun:s}),f=`${n}:${a}:${i}`;switch(u.outcome){case`unchanged`:k(f,`unchanged`);break;case`skipped-null`:case`skipped-reserved`:k(f,`skipped`);break;case`conflict`:k(f,`conflicts`),M({kind:`conflict`,entityType:n,entityId:a,locale:i,oldPath:u.oldPath,newPath:u.newPath,...u.conflictWith?{conflictWith:u.conflictWith.entityId}:{}});break;case`created`:s?k(f,`missing`):(E.applied+=1,o=!0,k(f,`resolved`)),M({kind:`missing`,entityType:n,entityId:a,locale:i,oldPath:null,newPath:u.newPath});break;case`renamed`:E.changed+=1,s||(E.applied+=1,o=!0),k(f,`resolved`),M({kind:`changed`,entityType:n,entityId:a,locale:i,oldPath:u.oldPath,newPath:u.newPath});break}}catch(t){E.failed+=1,e.logger.warn({err:t,entityType:n,id:a,locale:i},`paths-rebuild: failed to process one document — continuing (per-item isolation)`)}return o},L=async t=>{let a=0;for(let o of t){let t=e.getClient(o),s=o.name,c=t.getTable().id;for(let e of h){let d;for(;;){let f=await t.findMany({locale:e,defaultLocale:l,limit:r,orderBy:ee(c),...d===void 0?{}:{cursor:{field:`id`,value:d,direction:`asc`}}});if(f.length===0)break;await u(f,i,async t=>{await I(o,s,t,e)&&(a+=1)}),E.scanned+=f.length;let p=f[f.length-1];if(p&&(d=p.id),n.onProgress?.(F(`scanning`)),f.length<r)break}}}return a},R=async t=>{let r=[];for(let[n,i]of D){if(i!==`conflicts`&&i!==`skipped`&&i!==`missing`)continue;let a=n.indexOf(`:`),o=n.lastIndexOf(`:`);if(a<=0||o<=a)continue;let s=n.slice(0,a);if(t.has(s))continue;let c=e.entities.get(s);c!==void 0&&r.push({entity:c,entityType:s,entityId:n.slice(a+1,o),locale:n.slice(o+1)})}if(r.length===0)return 0;let a=0;return await u(r,i,async t=>{let n=await e.getClient(t.entity).findById(t.entityId,{locale:t.locale,defaultLocale:l});n!==null&&(E.scanned+=1,await I(t.entity,t.entityType,n,t.locale)&&(a+=1))}),n.onProgress?.(F(`scanning`)),a},z=_.filter(e=>w(e)),B=new Set(z.map(e=>e.name)),V=s||!x?1:S;for(let e=0;e<V;e++){P=e+1;let t=await L(e===0?_:z);if(e>0&&(t+=await R(B)),t===0)break}j&&e.logger.warn({scanned:E.scanned,changed:E.changed,missing:O.missing,conflicts:O.conflicts,sampleCap:o},`paths-rebuild: more diff entries than the progress sample can hold — see the aggregate counts; the stored sample is truncated`);let H=F(`done`);n.onProgress?.(H);let{phase:U,...W}=H;return W}async function ye(e){let{defineJob:t,registerJob:n,QueueClient:r}=await import(`@murumets-ee/queue/client`),i=t({name:`content:paths-cascade`,description:`Regenerate toolkit_paths canonical rows for the descendants of a renamed routable-hierarchy ancestor (one locale). Walks the full bounded subtree in a single invocation.`,schema:Z}),a=t({name:`content:paths-rebuild`,description:`Recompute every routable document's canonical toolkit_paths row (optionally scoped to one entity/locale); dryRun reports the diff without writing. On-demand (lumi paths:rebuild).`,schema:Q});n(i,async t=>{await I(async()=>{let n=await _e(e,Z.parse(t.payload),{onProgress:e=>t.updateProgress(e)});e.logger.info(n,`paths-cascade: descendant regeneration complete`)})}),n(a,async t=>{await I(()=>L(async()=>{let n=await ve(e,Q.parse(t.payload),{onProgress:e=>t.updateProgress({...e,site:M()??null})});e.logger.info({dryRun:n.dryRun,scanned:n.scanned,changed:n.changed,missing:n.missing,conflicts:n.conflicts,applied:n.applied,failed:n.failed,passes:n.passes,...n.hierarchyNote===void 0?{}:{hierarchyNote:n.hierarchyNote}},`paths-rebuild: sweep complete`)},{app:e,label:`paths-rebuild`}))}),de(async(e,t,n,a)=>{await new r({app:e,logger:e.logger}).enqueue(i,{entityType:t.name,parentId:n,locale:a})})}const $=B({name:`toolkit_path_patterns`,columns:{id:z.uuid({primaryKey:!0,defaultRandom:!0}),entityType:z.varchar({length:100,notNull:!0,pgName:`entity_type`}),locale:z.varchar({length:10,notNull:!0}),patternHash:z.varchar({length:64,notNull:!0,pgName:`pattern_hash`}),updatedAt:z.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`entityType`,`locale`]}]}),be=$.table;function xe(e,t,n){let r=D(e,t,n);if(r===null)return null;let i=typeof r==`function`?r.toString():JSON.stringify(r);return ne(`sha256`).update(i).digest(`hex`)}async function Se(e){let t=a(),n=t.defaultLocale,r=t.locales.map(e=>e.code),i=$.makeClient(e.db.readWrite);for(let[t,a]of e.entities){let o=a.frontend?.path;if(o!==void 0)for(let a of r){let r=xe(o,a,n);if(r===null)continue;let s=await i.findOne({entityType:t,locale:a});s!==null&&s.patternHash===r||(s!==null&&e.logger.warn({entity:t,locale:a,previousHash:s.patternHash,currentHash:r},`toolkit_paths: frontend.path pattern for '${t}' (locale '${a}') changed since last boot — stale toolkit_paths rows may exist; run \`lumi paths:rebuild --entity ${t}\``),await i.upsert({entityType:t,locale:a,patternHash:r},{target:[`entityType`,`locale`],set:{patternHash:r,updatedAt:new Date}}))}}}const Ce=async(e,t,n)=>{if(t.length===0)return new Map;let[{buildEnumerateDeps:r},{getCanonicalPaths:i}]=await Promise.all([import(`./resolve-context-Cvc1aNJe.mjs`).then(e=>e.l),import(`./enumerate-CYHJKQz6.mjs`).then(e=>e.n)]),a=r(k()),o=await a.filterPublished(e,t,n);return o.size===0?new Map:i(e,[...o],n,a)};function we(e){return d(e,{name:`${e.name}_drafts`,columns:{id:z.uuid({primaryKey:!0,defaultRandom:!0}),entityId:z.uuid({notNull:!0,pgName:`entity_id`}),locale:z.varchar({length:10,notNull:!0,default:`_`}),data:z.jsonb({notNull:!0}),createdBy:z.varchar({length:255,notNull:!0,pgName:`created_by`}),createdByName:z.varchar({length:255,pgName:`created_by_name`}),createdAt:z.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:z.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`entityId`,`locale`]}]})}function Te(e){return d(e,{name:`${e.name}_locale_status`,columns:{id:z.uuid({primaryKey:!0,defaultRandom:!0}),entityId:z.uuid({notNull:!0,pgName:`entity_id`}),locale:z.varchar({length:10,notNull:!0}),status:z.varchar({length:20,notNull:!0,default:`draft`}),publishedAt:z.timestamp({withTimezone:!0,pgName:`published_at`})},unique:[{on:[`entityId`,`locale`]}]})}function Ee(e){return d(e,{name:`${e.name}_versions`,columns:{id:z.uuid({primaryKey:!0,defaultRandom:!0}),entityId:z.uuid({notNull:!0,pgName:`entity_id`}),version:z.integer({notNull:!0}),locale:z.varchar({length:10,notNull:!0,default:`_`}),data:z.jsonb({notNull:!0}),delta:z.jsonb(),status:z.varchar({length:20}),createdBy:z.varchar({length:255,pgName:`created_by`}),createdByName:z.varchar({length:255,pgName:`created_by_name`}),createdAt:z.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),isAutosave:z.boolean({notNull:!0,default:!1,pgName:`is_autosave`})},unique:[{on:[`entityId`,`version`,`locale`]}]})}function De(e){if(!e.locales||e.locales.length===0)throw Error(`content() requires at least one locale`);if(!e.defaultLocale)throw Error(`content() requires a defaultLocale`);if(!e.locales.some(t=>t.code===e.defaultLocale))throw Error(`defaultLocale "${e.defaultLocale}" not found in locales: [${e.locales.map(e=>e.code).join(`, `)}]`);if(e.reservedPathPrefixes){for(let t of e.reservedPathPrefixes)if(typeof t!=`string`||!t.startsWith(`/`))throw Error(`content(): reservedPathPrefixes entries must be absolute paths starting with '/', got ${JSON.stringify(t)}`)}let t=e.autoTrash??!0,a=t===!1?void 0:{publishable:!0,...typeof t==`object`&&t.exclude?{exclude:t.exclude}:{}};return O({name:`@murumets-ee/content`,contributes:{"blocks.documentDraftStore":ae(),"blocks.documentVersionStore":le(),"blocks.publishedPathResolver":Ce,...a?{"trash.autoEnable":a}:{},...e.reservedPathPrefixes?{"routing.reservedPathPrefixes":e.reservedPathPrefixes}:{}},shared:{settings:[n],features:[{resource:`redirect`,actions:[`manage`],defaultRoles:[`admin`],description:`Create, edit, and delete manual URL redirects (toolkit_redirects).`},{resource:`path`,actions:[`resolve`],defaultRoles:[`admin`,`editor`],description:`Resolve a site URL path to the CMS document that serves it.`}]},server:{entities:[r,o],tables:{toolkitContentLocks:x,toolkitPaths:h,toolkitPathPatterns:be,toolkitRedirects:_},routes:[...b(),...y()],seeders:[{name:`content:site-chrome`,description:`Create the empty site_chrome row for each placement (header, footer), as a DRAFT, so they can be edited on the page canvas. Publishing one is what makes /v1/_chrome serve it instead of the delivery template's own fallback. Idempotent — an existing row for a placement is left alone.`,available:l,bootstrap:!0,run:async()=>{let{seedSiteChrome:e}=await import(`@murumets-ee/content/seed`);return await e()}}],init:async t=>{i(e);let n=0,r=0,a=0;for(let[e,i]of t.entities){if(i.behaviors?.some(e=>e.name===`versionable`)){let t=`${e}_versions`;if(!V.has(t)){let n=Ee(i);V.register(t,n.table),s(e,{versions:n})}n++}let t=i.behaviors?.some(e=>e.name===`publishable`),o=i.allFields,c=Object.values(o).some(e=>e.translatable);if(t&&c){let t=`${e}_locale_status`;if(!V.has(t)){let n=Te(i);V.register(t,n.table),s(e,{localeStatus:n})}r++}if(t){let t=`${e}_drafts`;if(!V.has(t)){let n=we(i);V.register(t,n.table),s(e,{drafts:n})}a++}}ie(e.locales.map(e=>e.code)),ge();try{await ye(t)}catch(e){t.logger.warn({err:e},"content: failed to register routing path jobs — descendant cascade + `lumi paths:rebuild` will be unavailable (path writes still succeed)")}t.logger.info({locales:e.locales.map(e=>e.code),defaultLocale:e.defaultLocale,versionableEntities:n,localeStatusEntities:r,draftEntities:a},`Content plugin initialized`)},onReady:async e=>{try{await Se(e)}catch(t){e.logger.error({err:t},"content: boot-time frontend-path pattern-drift check failed — continuing app boot without it. Inspect the logged `err` for the cause: a transient DB error can be retried (and `lumi paths:rebuild` re-derives the index if a pattern actually changed), whereas a structural cause — e.g. a missing migration leaving `toolkit_path_patterns` absent — will recur on every boot and needs `lumi migrate`")}}}})}export{q as n,De as t};
2
+ //# sourceMappingURL=plugin-BmxghXzV.mjs.map