@murumets-ee/content 0.76.0 → 0.78.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.
@@ -28,6 +28,32 @@ interface BlockMediaProjection {
28
28
  y: number;
29
29
  };
30
30
  blurDataUrl: string | null;
31
+ /**
32
+ * The files cut for each spot crop the photograph has files for (images
33
+ * PR16) — present only when the resolver was ASKED (`{ spots: true }`) and
34
+ * the photograph records some. Never embedded in a tree: {@link writeSlot}
35
+ * picks the one list a placement's own `crop` names and drops the rest.
36
+ */
37
+ spotSources?: BlockSpotSources[];
38
+ }
39
+ /** One spot crop's files, as the media resolver publishes them. */
40
+ interface BlockSpotSources {
41
+ crop: {
42
+ x: number;
43
+ y: number;
44
+ w: number;
45
+ h: number;
46
+ };
47
+ sources: Array<{
48
+ url: string;
49
+ width: number;
50
+ height: number;
51
+ }>;
52
+ avifSources?: Array<{
53
+ url: string;
54
+ width: number;
55
+ height: number;
56
+ }>;
31
57
  }
32
58
  /**
33
59
  * Structural mirror of `content-api`'s `ResolveMedia`.
@@ -38,6 +64,12 @@ interface BlockMediaProjection {
38
64
  */
39
65
  type BlockMediaResolver = (ids: readonly string[], opts?: {
40
66
  locale?: string | undefined;
67
+ /**
68
+ * Also publish the files cut for spot crops (images PR16). Asked by the
69
+ * BLOCK walk only — a top-level media field has no placement crop, so its
70
+ * projection stays exactly what it was.
71
+ */
72
+ spots?: boolean | undefined;
41
73
  }) => Promise<Map<string, BlockMediaProjection>>;
42
74
  /** Structural mirror of `content-api`'s `UnresolvedBlockRef`. */
43
75
  interface UnresolvedBlockRef {
@@ -184,5 +216,5 @@ declare function createBlockResolver(getApp: () => Promise<ToolkitApp> | Toolkit
184
216
  boundValues?: Readonly<Record<string, unknown>> | undefined;
185
217
  }) => Promise<ResolvedBlockTree>;
186
218
  //#endregion
187
- export { BlockCollectionDeps, BlockMediaProjection, BlockMediaResolver, BlockResolutionBudget, BlockResolverDeps, BlockResolverLogger, BlockTreeRef, ResolvedBlockTree, UnresolvedBlockRef, createBlockResolver, resolveBlockTree };
219
+ export { BlockCollectionDeps, BlockMediaProjection, BlockMediaResolver, BlockResolutionBudget, BlockResolverDeps, BlockResolverLogger, BlockSpotSources, BlockTreeRef, ResolvedBlockTree, UnresolvedBlockRef, createBlockResolver, resolveBlockTree };
188
220
  //# sourceMappingURL=block-resolver.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"block-resolver.d.mts","names":[],"sources":["../src/block-resolver.ts"],"mappings":";;;;;;UA8GiB,oBAAA;EACf,EAAA;EACA,GAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EACA,QAAA,EAAU,MAAA;EACV,OAAA,EAAS,MAAA,SAAe,KAAA;IAAQ,GAAA;IAAa,KAAA;IAAe,MAAA;EAAA;EAC5D,WAAA,GAAc,MAAA,SAAe,KAAA;IAAQ,GAAA;IAAa,KAAA;IAAe,MAAA;EAAA;EACjE,UAAA;IAAc,CAAA;IAAW,CAAA;EAAA;EACzB,WAAA;AAAA;;;;;;;;KAUU,kBAAA,IACV,GAAA,qBACA,IAAA;EAAS,MAAA;AAAA,MACN,OAAA,CAAQ,GAAA,SAAY,oBAAA;;UAGR,kBAAA;EAAA,SACN,IAAA;EAAA,SACA,GAAA;EAAA,SACA,MAAA;AAAA;AA4BiD;AAAA,UAxB3C,YAAA;EAAA,SACN,MAAA;EAAA,SACA,EAAE;AAAA;;UAII,iBAAA;EAAA,SACN,QAAA;EAAA,SACA,MAAA;EAAA,SACA,KAAA,EAAO,QAAA,CAAS,MAAA;EAAA,SAChB,UAAA,YAAsB,kBAAA;EAAA,SACtB,IAAA,YAAgB,YAAA;EAAA,SAChB,WAAA,GAAc,QAAA,CAAS,MAAA,SAAe,gBAAA;AAAA;;;;;UAOhC,mBAAA;EAAA,SACN,OAAA,WAAkB,oBAAA;EAAA,SAClB,aAAA,GAAgB,SAAA;EAAA,SAChB,SAAA,GAAY,IAAA,aAAiB,MAAA;EAqBvB;EAAA,SAnBN,SAAA,GAAY,MAAA,EAAQ,MAAA,KAAW,kBAAA;AAAA;;;;;;;UASzB,qBAAA;EACf,KAAA,GAAQ,IAAA,WAAe,CAAA;AAAA;AAAA,UAGR,mBAAA;EACf,KAAA,GAAQ,GAAA,UAAa,GAAA;EACrB,IAAA,GAAO,GAAA,UAAa,GAAA;AAAA;;UAIL,iBAAA;EAgBF;EAdb,YAAA,GAAe,kBAAA;EAeN;;;;;;;;;EALT,qBAAA,GACE,UAAA,UACA,GAAA,qBACA,MAAA,aACG,OAAA,CAAQ,GAAA;EACb,MAAA,GAAS,mBAAA;EAyBkC;EAvB3C,WAAA,GAAc,mBAAA;EAiWM;;;;;;;;EAxVpB,mBAAA,KAEM,QAAA,UACA,SAAA,EAAW,iBAAA;IAAA,SAEF,MAAA;IAAA,SACA,YAAA;EAAA;EAqVb;;;;;EA7UF,cAAA,KAAmB,IAAA;AAAA;;;;;;;;;;;;AA2VO;AAwgB5B;;;;;;iBAzhBsB,gBAAA,CACpB,MAAA,WACA,IAAA;EACE,MAAA;EACA,MAAA,EAAQ,qBAAA;EACR,MAAA;EACA,KAAA,UA4iBC;EA1iBD,aAAA,KAAkB,UAAA,mCA0iBV;EAxiBR,eAAA,gCAghBoB;EA9gBpB,QAAA,uBA8gBF;EA5gBE,WAAA,GAAc,QAAA,CAAS,MAAA;AAAA,GAEzB,IAAA,EAAM,iBAAA,GACL,OAAA,CAAQ,iBAAA;;;;;;;;;;;;;;;;;iBAwgBK,mBAAA,CACd,MAAA,QAAc,OAAA,CAAQ,UAAA,IAAc,UAAA,EACpC,OAAA;EAuBW;;AAAiB;;;;;EAf1B,aAAA;EACA,YAAA,GAAe,kBAAA;AAAA,KAGjB,MAAA,WACA,IAAA;EACE,MAAA;EACA,MAAA,EAAQ,qBAAA;EACR,MAAA;EACA,KAAA;EACA,aAAA,KAAkB,UAAA;EAClB,eAAA;EACA,QAAA;EACA,WAAA,GAAc,QAAA,CAAS,MAAA;AAAA,MAEtB,OAAA,CAAQ,iBAAA"}
1
+ {"version":3,"file":"block-resolver.d.mts","names":[],"sources":["../src/block-resolver.ts"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{r as e}from"./resolve-context-MRZZ3rQl.mjs";import{r as t}from"./enumerate-CBqu63Kp.mjs";import{collectLinkValues as n,linkValueKey as r}from"@murumets-ee/blocks/links";import{collectBoundColumnsInTree as i,createCollectionReader as a,effectiveCollectionSources as o,materializeServerTemplate as s,resolveLinks as c,resolveTreeCollections as l,serverBlockStructureLookup as u,serverBoundColumnsInTree as d,serverCollectionFieldLookup as f,serverTemplateMaterializer as p,templateNamespace as m}from"@murumets-ee/blocks/server";const h=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function g(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function _(e){return Array.isArray(e)}const v=new Set(Object.keys({id:!0,url:!0,mimeType:!0,width:!0,height:!0,alt:!0,title:!0,variants:!0,sources:!0,avifSources:!0,focalPoint:!0,blurDataUrl:!0}));function y(e,t){if(!g(e))return null;let n=e.id;if(typeof n!=`string`||!h.test(n)||typeof e.url!=`string`||e.url===``)return null;let r=Object.keys(e).filter(e=>!v.has(e));return r.length>0?(t?.(r),null):!b(e.alt,`string`)||!b(e.title,`string`)||!b(e.mimeType,`string`)||!b(e.width,`number`)||!b(e.height,`number`)||!b(e.blurDataUrl,`string`)||e.variants!==void 0&&e.variants!==null&&!g(e.variants)||e.focalPoint!==void 0&&e.focalPoint!==null&&!g(e.focalPoint)||e.sources!==void 0&&e.sources!==null&&(!g(e.sources)||!Object.values(e.sources).every(e=>Array.isArray(e)))||e.avifSources!==void 0&&e.avifSources!==null&&(!g(e.avifSources)||!Object.values(e.avifSources).every(e=>Array.isArray(e)))?null:n}function b(e,t){return e==null||typeof e===t}function x(e,t,n,r){if(n>256){r.truncated=!0;return}let i=e=>{for(let t of e)r.nearMissKeys.add(t)};if(_(e)){for(let a=0;a<e.length;a+=1){let o=y(e[a],i);o===null?x(e[a],t,n+1,r):t.push({container:e,key:a,id:o})}return}if(g(e))for(let[a,o]of Object.entries(e)){let s=y(o,i);s===null?x(o,t,n+1,r):t.push({container:e,key:a,id:s})}}function S(e){let t={id:e.id,url:e.url,variants:e.variants,focalPoint:e.focalPoint,sources:e.sources};return e.avifSources!==void 0&&Object.keys(e.avifSources).length>0&&(t.avifSources=e.avifSources),e.mimeType!==null&&(t.mimeType=e.mimeType),e.width!==null&&(t.width=e.width),e.height!==null&&(t.height=e.height),e.alt!==null&&(t.alt=e.alt),e.title!==null&&(t.title=e.title),e.blurDataUrl!==null&&(t.blurDataUrl=e.blurDataUrl),t}function C(e,t){let n=t===null?null:S(t);if(Array.isArray(e.container)){typeof e.key==`number`&&(e.container[e.key]=n);return}typeof e.key==`string`&&(e.container[e.key]=n)}async function w(e,t,i){let a=e,o=[];if(t.template!==void 0){let e=t.boundValues?.id;if(typeof e==`string`&&e!==``){let n=(i.materializeTemplate??s)(t.template,m(t.entity,e));a=n.blocks,o=n.boundColumns}else a=[],i.logger?.warn({entity:t.entity,field:t.field},`content: a templated document arrived without its row id, so its template is served EMPTY — its block ids could not be namespaced to it (editor F124)`)}else if(t.boundValues!==void 0&&_(e))try{o=(i.boundColumnsOf??d)(e)}catch(e){i.logger?.warn({err:e,entity:t.entity,field:t.field},`content: could not enumerate the bound columns of a block tree, so its bound-field links render plain`)}let l=t.boundValues??{},u=o.filter(e=>Object.hasOwn(l,e)).map(e=>l[e]);if(!_(a))throw i.logger?.warn({entity:t.entity,field:t.field},`content: stored block tree is not an array — nothing to resolve, so this field is served EMPTY rather than as stored (the caller retries this throw with an exhausted budget, and a non-array fails that identically)`),TypeError(`content: block tree \`${t.entity}.${t.field}\` is not an array; nothing to resolve.`);let f=[],p={truncated:!1,nearMissKeys:new Set};x(a,f,0,p),p.truncated&&i.logger?.warn({entity:t.entity,field:t.field,cap:256},`content: block tree is nested deeper than the media walk descends — any media ref below the bound keeps the url recorded when an editor picked it, which may be signed`),p.nearMissKeys.size>0&&i.logger?.debug({entity:t.entity,field:t.field,keys:[...p.nearMissKeys].sort()},`content: a value looked like a media ref but carried keys outside the projection, so it was left alone. If these are media refs, they keep their stored urls`);let g=[...n(a,64),...n(u,64)],v=[],y=[];for(let e of g)e.kind===`media`&&(h.test(e.id)?v.push(e.id):y.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let b=D([...f.map(e=>e.id),...v]),S=[];for(let e of g)e.kind===`entity`&&(h.test(e.id)?S.push(O(e.entity,e.id)):y.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let w=[...y];!i.resolveMedia&&b.length>0&&i.logger?.debug({entity:t.entity,field:t.field,count:b.length},`content: no media resolver wired for block trees — every media ref resolves to null`);let M=i.resolveMedia?t.budget.claim(`block`,b.length):0,N=b.slice(0,M),P=i.resolveMedia?new Set(b.slice(M)):new Set;for(let e of b.slice(M))w.push({kind:`media`,ref:e,reason:P.has(e)?`budget`:`unresolvable`});let F=D(S),I=t.budget.claim(`block`,F.length),L=F.slice(0,I),R=new Set(F.slice(I)),z=await A(N,t.locale,i,t.entity);for(let e of f)C(e,z.get(e.id)??null);for(let e of N)z.has(e)||w.push({kind:`media`,ref:e,reason:`unresolvable`});let B=await j(L,t.locale,i,t.entity),V=await c(g,{renderContext:`request`,locale:t.locale,resolveEntityHref:({entity:e,id:t})=>B.get(O(e,t))??null,resolveMediaHref:e=>z.get(e)?.url??null}),H={};for(let[e,t]of V)H[e]=t.href;for(let e of g){if(e.kind!==`entity`&&e.kind!==`media`)continue;let t=r(e);H[t]!==null&&H[t]!==void 0||w.push({kind:`link`,ref:t,reason:E(e,R,P)})}let{results:U,cardMedia:W}=await T(a,t,i),G=new Map;for(let e of z.keys())G.set(O(`media`,e),{entity:`media`,id:e});for(let e of W)G.set(O(`media`,e),{entity:`media`,id:e});for(let e of U.values())if(e.entity!==null)for(let t of e.rows)G.set(O(e.entity,t.id),{entity:e.entity,id:t.id});for(let e of B.keys()){if(G.has(e))continue;let t=e.indexOf(` `);if(t<=0)continue;let n=e.slice(0,t),r=e.slice(t+1);r!==``&&G.set(e,{entity:n,id:r})}return{resolved:1,blocks:a,links:H,...w.length>0&&{unresolved:k(w)},...G.size>0&&{refs:[...G.values()]},...U.size>0&&{collections:Object.fromEntries(U)}}}async function T(e,t,n){let r=n.collections,i=t.canReadEntity,a=t.claimCollection;if(r===void 0||i===void 0||a===void 0)return{results:new Map,cardMedia:new Set};let o=n.resolveMedia,s=new Set;return{results:await l(e,r.sourceFieldOf,{sources:r.sources,getEntity:r.getEntity,clientFor:r.clientFor,canRead:i,resolveImages:o===void 0?void 0:async e=>{let n=[...await o(e,{locale:t.locale})].filter(([,e])=>e.mimeType?.startsWith(`image/`)===!0);for(let[e]of n)s.add(e);return new Map(n.map(([e,t])=>[e,{url:t.url,alt:t.alt,width:t.width,height:t.height,focalPoint:t.focalPoint,sources:t.sources,...t.avifSources!==void 0&&Object.keys(t.avifSources).length>0&&{avifSources:t.avifSources},blurDataUrl:t.blurDataUrl}]))},resolveHrefs:(e,r)=>n.resolvePublishedPaths(e,r,t.locale),locale:t.locale,renderContext:`request`,claim:()=>a()&&t.budget.claim(`block`,1)>=1,logger:n.logger}),cardMedia:s}}function E(e,t,n){return e.kind===`entity`&&t.has(O(e.entity,e.id))||e.kind===`media`&&n.has(e.id)?`budget`:`unresolvable`}function D(e){return[...new Set(e)]}function O(e,t){return[e,t].join(` `)}function k(e){let t=new Set,n=[];for(let r of e){let e=`${r.kind} ${r.ref}`;t.has(e)||(t.add(e),n.push(r))}return n}async function A(e,t,n,r){if(e.length===0||!n.resolveMedia)return new Map;try{return await n.resolveMedia(e,{locale:t})}catch(e){return n.logger?.warn({entity:r,err:e},"content: media resolver threw while resolving a block tree — its media refs resolve to null (if this is a ForbiddenError, the requesting role is missing `media:view`)"),new Map}}async function j(e,t,n,r){let i=new Map;if(e.length===0)return i;let a=new Map;for(let t of e){let e=t.indexOf(` `),n=t.slice(0,e),r=t.slice(e+1),i=a.get(n);i?i.push(r):a.set(n,[r])}for(let[e,o]of a)try{let r=await n.resolvePublishedPaths(e,o,t);for(let[t,n]of r)i.set(O(e,t),n)}catch(t){n.logger?.warn({entity:r,entityType:e,err:t},`content: path resolution threw for a block-tree link target — its links render as plain text`)}return i}function M(n,r){let s=null,c=n=>{if(s?.app===n)return s.deps;let c=e(n),{sources:l}=o(n),d=l.length===0?void 0:{sources:l,sourceFieldOf:f(),getEntity:e=>n.entities.get(e),clientFor:e=>a(e,n)},m=p(),h=u(),g={...d!==void 0&&{collections:d},boundColumnsOf:e=>i(e,h),materializeTemplate:(e,t)=>m(e,t),resolveMedia:r.resolveMedia,resolvePublishedPaths:async(e,n,r)=>{let i=await c.filterPublished(e,n,r);return i.size===0?new Map:t(e,[...i],r,c)},logger:n.logger};return s={app:n,deps:g},g};return async(e,t)=>{let i=await n();return w(e,{...t,locale:t.locale??r.defaultLocale},c(i))}}export{M as createBlockResolver,w as resolveBlockTree};
1
+ import{r as e}from"./resolve-context-MRZZ3rQl.mjs";import{r as t}from"./enumerate-CBqu63Kp.mjs";import{collectLinkValues as n,linkValueKey as r}from"@murumets-ee/blocks/links";import{collectBoundColumnsInTree as i,createCollectionReader as a,effectiveCollectionSources as o,materializeServerTemplate as s,readSpotCrop as c,resolveLinks as l,resolveTreeCollections as u,sameSpotCrop as d,serverBlockStructureLookup as f,serverBoundColumnsInTree as p,serverCollectionFieldLookup as m,serverTemplateMaterializer as h,templateNamespace as g}from"@murumets-ee/blocks/server";const _=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function v(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function y(e){return Array.isArray(e)}const b=new Set(Object.keys({id:!0,url:!0,mimeType:!0,width:!0,height:!0,alt:!0,title:!0,variants:!0,sources:!0,avifSources:!0,focalPoint:!0,blurDataUrl:!0,crop:!0,spotSources:!0}));function x(e,t){if(!v(e))return null;let n=e.id;if(typeof n!=`string`||!_.test(n)||typeof e.url!=`string`||e.url===``)return null;let r=Object.keys(e).filter(e=>!b.has(e));return r.length>0?(t?.(r),null):!S(e.alt,`string`)||!S(e.title,`string`)||!S(e.mimeType,`string`)||!S(e.width,`number`)||!S(e.height,`number`)||!S(e.blurDataUrl,`string`)||e.variants!==void 0&&e.variants!==null&&!v(e.variants)||e.focalPoint!==void 0&&e.focalPoint!==null&&!v(e.focalPoint)||e.sources!==void 0&&e.sources!==null&&(!v(e.sources)||!Object.values(e.sources).every(e=>Array.isArray(e)))||e.avifSources!==void 0&&e.avifSources!==null&&(!v(e.avifSources)||!Object.values(e.avifSources).every(e=>Array.isArray(e)))||e.crop!==void 0&&e.crop!==null&&!v(e.crop)||e.spotSources!==void 0&&e.spotSources!==null&&!Array.isArray(e.spotSources)?null:n}function S(e,t){return e==null||typeof e===t}function C(e,t,n,r){if(n>256){r.truncated=!0;return}let i=e=>{for(let t of e)r.nearMissKeys.add(t)};if(y(e)){for(let a=0;a<e.length;a+=1){let o=x(e[a],i);o===null?C(e[a],t,n+1,r):t.push({container:e,key:a,id:o,crop:c(e[a])})}return}if(v(e))for(let[a,o]of Object.entries(e)){let s=x(o,i);s===null?C(o,t,n+1,r):t.push({container:e,key:a,id:s,crop:c(o)})}}function w(e){let t={id:e.id,url:e.url,variants:e.variants,focalPoint:e.focalPoint,sources:e.sources};return e.avifSources!==void 0&&Object.keys(e.avifSources).length>0&&(t.avifSources=e.avifSources),e.mimeType!==null&&(t.mimeType=e.mimeType),e.width!==null&&(t.width=e.width),e.height!==null&&(t.height=e.height),e.alt!==null&&(t.alt=e.alt),e.title!==null&&(t.title=e.title),e.blurDataUrl!==null&&(t.blurDataUrl=e.blurDataUrl),t}function T(e,t){if(!(t===void 0||e.spotSources===void 0))return e.spotSources.find(e=>d(e.crop,t)&&e.sources.length>0)}function E(e,t){let n=w(e),r=T(e,t),i=r?.sources[r.sources.length-1];if(r!==void 0&&i!==void 0){n.url=i.url,n.width=i.width,n.height=i.height,n.focalPoint={x:.5,y:.5},n.sources={natural:r.sources};let e=r.avifSources;e!==void 0&&e.length===r.sources.length&&e.every((e,t)=>e.width===r.sources[t]?.width&&e.height===r.sources[t]?.height)?n.avifSources={natural:e}:delete n.avifSources}return n.crop={x:t.x,y:t.y,w:t.w,h:t.h},n}function D(e,t){let n=t===null?null:e.crop===void 0?w(t):E(t,e.crop);if(Array.isArray(e.container)){typeof e.key==`number`&&(e.container[e.key]=n);return}typeof e.key==`string`&&(e.container[e.key]=n)}async function O(e,t,i){let a=e,o=[];if(t.template!==void 0){let e=t.boundValues?.id;if(typeof e==`string`&&e!==``){let n=(i.materializeTemplate??s)(t.template,g(t.entity,e));a=n.blocks,o=n.boundColumns}else a=[],i.logger?.warn({entity:t.entity,field:t.field},`content: a templated document arrived without its row id, so its template is served EMPTY — its block ids could not be namespaced to it (editor F124)`)}else if(t.boundValues!==void 0&&y(e))try{o=(i.boundColumnsOf??p)(e)}catch(e){i.logger?.warn({err:e,entity:t.entity,field:t.field},`content: could not enumerate the bound columns of a block tree, so its bound-field links render plain`)}let c=t.boundValues??{},u=o.filter(e=>Object.hasOwn(c,e)).map(e=>c[e]);if(!y(a))throw i.logger?.warn({entity:t.entity,field:t.field},`content: stored block tree is not an array — nothing to resolve, so this field is served EMPTY rather than as stored (the caller retries this throw with an exhausted budget, and a non-array fails that identically)`),TypeError(`content: block tree \`${t.entity}.${t.field}\` is not an array; nothing to resolve.`);let d=[],f={truncated:!1,nearMissKeys:new Set};C(a,d,0,f),f.truncated&&i.logger?.warn({entity:t.entity,field:t.field,cap:256},`content: block tree is nested deeper than the media walk descends — any media ref below the bound keeps the url recorded when an editor picked it, which may be signed`),f.nearMissKeys.size>0&&i.logger?.debug({entity:t.entity,field:t.field,keys:[...f.nearMissKeys].sort()},`content: a value looked like a media ref but carried keys outside the projection, so it was left alone. If these are media refs, they keep their stored urls`);let m=[...n(a,64),...n(u,64)],h=[],v=[];for(let e of m)e.kind===`media`&&(_.test(e.id)?h.push(e.id):v.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let b=j([...d.map(e=>e.id),...h]),x=[];for(let e of m)e.kind===`entity`&&(_.test(e.id)?x.push(M(e.entity,e.id)):v.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let S=[...v];!i.resolveMedia&&b.length>0&&i.logger?.debug({entity:t.entity,field:t.field,count:b.length},`content: no media resolver wired for block trees — every media ref resolves to null`);let w=i.resolveMedia?t.budget.claim(`block`,b.length):0,T=b.slice(0,w),E=i.resolveMedia?new Set(b.slice(w)):new Set;for(let e of b.slice(w))S.push({kind:`media`,ref:e,reason:E.has(e)?`budget`:`unresolvable`});let O=j(x),I=t.budget.claim(`block`,O.length),L=O.slice(0,I),R=new Set(O.slice(I)),z=await P(T,t.locale,i,t.entity);for(let e of d)D(e,z.get(e.id)??null);for(let e of T)z.has(e)||S.push({kind:`media`,ref:e,reason:`unresolvable`});let B=await F(L,t.locale,i,t.entity),V=await l(m,{renderContext:`request`,locale:t.locale,resolveEntityHref:({entity:e,id:t})=>B.get(M(e,t))??null,resolveMediaHref:e=>z.get(e)?.url??null}),H={};for(let[e,t]of V)H[e]=t.href;for(let e of m){if(e.kind!==`entity`&&e.kind!==`media`)continue;let t=r(e);H[t]!==null&&H[t]!==void 0||S.push({kind:`link`,ref:t,reason:A(e,R,E)})}let{results:U,cardMedia:W}=await k(a,t,i),G=new Map;for(let e of z.keys())G.set(M(`media`,e),{entity:`media`,id:e});for(let e of W)G.set(M(`media`,e),{entity:`media`,id:e});for(let e of U.values())if(e.entity!==null)for(let t of e.rows)G.set(M(e.entity,t.id),{entity:e.entity,id:t.id});for(let e of B.keys()){if(G.has(e))continue;let t=e.indexOf(` `);if(t<=0)continue;let n=e.slice(0,t),r=e.slice(t+1);r!==``&&G.set(e,{entity:n,id:r})}return{resolved:1,blocks:a,links:H,...S.length>0&&{unresolved:N(S)},...G.size>0&&{refs:[...G.values()]},...U.size>0&&{collections:Object.fromEntries(U)}}}async function k(e,t,n){let r=n.collections,i=t.canReadEntity,a=t.claimCollection;if(r===void 0||i===void 0||a===void 0)return{results:new Map,cardMedia:new Set};let o=n.resolveMedia,s=new Set;return{results:await u(e,r.sourceFieldOf,{sources:r.sources,getEntity:r.getEntity,clientFor:r.clientFor,canRead:i,resolveImages:o===void 0?void 0:async e=>{let n=[...await o(e,{locale:t.locale})].filter(([,e])=>e.mimeType?.startsWith(`image/`)===!0);for(let[e]of n)s.add(e);return new Map(n.map(([e,t])=>[e,{url:t.url,alt:t.alt,width:t.width,height:t.height,focalPoint:t.focalPoint,sources:t.sources,...t.avifSources!==void 0&&Object.keys(t.avifSources).length>0&&{avifSources:t.avifSources},blurDataUrl:t.blurDataUrl}]))},resolveHrefs:(e,r)=>n.resolvePublishedPaths(e,r,t.locale),locale:t.locale,renderContext:`request`,claim:()=>a()&&t.budget.claim(`block`,1)>=1,logger:n.logger}),cardMedia:s}}function A(e,t,n){return e.kind===`entity`&&t.has(M(e.entity,e.id))||e.kind===`media`&&n.has(e.id)?`budget`:`unresolvable`}function j(e){return[...new Set(e)]}function M(e,t){return[e,t].join(` `)}function N(e){let t=new Set,n=[];for(let r of e){let e=`${r.kind} ${r.ref}`;t.has(e)||(t.add(e),n.push(r))}return n}async function P(e,t,n,r){if(e.length===0||!n.resolveMedia)return new Map;try{return await n.resolveMedia(e,{locale:t,spots:!0})}catch(e){return n.logger?.warn({entity:r,err:e},"content: media resolver threw while resolving a block tree — its media refs resolve to null (if this is a ForbiddenError, the requesting role is missing `media:view`)"),new Map}}async function F(e,t,n,r){let i=new Map;if(e.length===0)return i;let a=new Map;for(let t of e){let e=t.indexOf(` `),n=t.slice(0,e),r=t.slice(e+1),i=a.get(n);i?i.push(r):a.set(n,[r])}for(let[e,o]of a)try{let r=await n.resolvePublishedPaths(e,o,t);for(let[t,n]of r)i.set(M(e,t),n)}catch(t){n.logger?.warn({entity:r,entityType:e,err:t},`content: path resolution threw for a block-tree link target — its links render as plain text`)}return i}function I(n,r){let s=null,c=n=>{if(s?.app===n)return s.deps;let c=e(n),{sources:l}=o(n),u=l.length===0?void 0:{sources:l,sourceFieldOf:m(),getEntity:e=>n.entities.get(e),clientFor:e=>a(e,n)},d=h(),p=f(),g={...u!==void 0&&{collections:u},boundColumnsOf:e=>i(e,p),materializeTemplate:(e,t)=>d(e,t),resolveMedia:r.resolveMedia,resolvePublishedPaths:async(e,n,r)=>{let i=await c.filterPublished(e,n,r);return i.size===0?new Map:t(e,[...i],r,c)},logger:n.logger};return s={app:n,deps:g},g};return async(e,t)=>{let i=await n();return O(e,{...t,locale:t.locale??r.defaultLocale},c(i))}}export{I as createBlockResolver,O as resolveBlockTree};
2
2
  //# sourceMappingURL=block-resolver.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"block-resolver.mjs","names":[],"sources":["../src/block-resolver.ts"],"sourcesContent":["/**\n * The block-tree resolver for `@murumets-ee/content-api`'s `resolveBlocks` seam\n * (`frontend-delivery` D019).\n *\n * This is the half `content-api` structurally cannot do: it walks a stored\n * tree, re-resolves the media refs inside it through the public-only media\n * path, and turns the `LinkValue`s inside it into hrefs against the published\n * routing index. `content-api` is a leaf — `blocks`, `media` and this package\n * all depend back on it — so it takes this as an injected provider, exactly as\n * it takes chrome, menus and routing resolution.\n *\n * ## What it emits, and why that shape\n *\n * A `ResolvedBlockTree` envelope: `{ resolved: 1, blocks, links, unresolved? }`.\n * The reasoning is D019's and is restated where it binds the code below, but\n * the two-sentence version is: links go into a `linkValueKey`-keyed sidecar\n * because that is exactly what every renderer already consumes, and media is\n * replaced IN PLACE because a media node is not an opaque pointer — it already\n * carries a url that every render reads, and that url has to be overwritten\n * regardless.\n *\n * ## 🔴 The security property this exists for\n *\n * A `field.media()` value inside a block tree is stored as a whole `MediaRef`\n * including a `url`, denormalised when the editor picked it. That url comes\n * from `StorageClient.getUrl`, which returns a **signed** url for any file that\n * is not public — and `StorageClient.upload` defaults to private. So the stored\n * tree can carry a bearer capability, and serving it unchanged puts that\n * capability on a `public, max-age=60` response any shared CDN will hand to\n * every later anonymous visitor. That is precisely the leak `getPublicUrls` and\n * `plan/api` D021 made structurally impossible for TOP-LEVEL media fields; the\n * block-tree path routed around it because the url stopped being a url and\n * became content.\n *\n * So the stored url is **discarded, never used as a fallback**. An id that does\n * not resolve becomes `null`, because \"did not resolve\" and \"is not public\" are\n * the same branch in the shipped resolver — a fallback would preserve the leak\n * exactly in the case that matters.\n *\n * ## 🔴 The fan-out property\n *\n * `resolveLinks`' own docblock hands the COUNT bound to whoever wires it: it\n * caps peak concurrency and deliberately does not cap how many links it\n * resolves. A naive wiring would give it a seam that hits the database per\n * distinct link — one publish check plus one path read each — on an anonymous\n * request path, which is verbatim what CLAUDE.md's no-unbounded-fan-out section\n * forbids. So this module resolves everything FIRST, batched by entity type\n * (two index reads per type, regardless of link count), and hands `resolveLinks`\n * a pure map lookup. What it costs is bounded by the number of distinct entity\n * TYPES a document links to, not by the number of links.\n *\n * ## Collections — rows, through the caller's own gate (editor PR53)\n *\n * A COLLECTION block's rows are resolved here too, into the envelope's additive\n * `collections` sidecar, through `@murumets-ee/blocks/server`'s\n * `resolveTreeCollections` — the ONE resolver the canvas calls as well. Three\n * properties make that safe on an anonymous path:\n *\n * - The reader is the CALLER, not an elevated identity: rows come through a\n * `QueryClient` bound to the ambient request context, and the provider may\n * only list an entity content-api's own whitelist ∧ `view` gate admits\n * (`canReadEntity`, handed in by the handler). Without that predicate, no\n * collection resolves at all — deny-by-default, never a guess.\n * - Only the card's mapped slots cross, never the row.\n * - It is bounded: at most `MAX_COLLECTIONS_PER_DOCUMENT` per tree, serial,\n * each claiming one slot of the host's per-RESPONSE collection allowance\n * (`claimCollection` — a list response resolves many trees) and one unit of\n * the response's resolution budget. No allowance, no collections.\n *\n * ## What it deliberately does not do\n *\n * Plain `query()` fields (the fixed-entity `articleList` / `relatedPosts`\n * shells) stay unresolved: nothing renders their rows, and the collection block\n * is the dynamic listing D001 asked for.\n */\n\nimport { collectLinkValues, type LinkValue, linkValueKey } from '@murumets-ee/blocks/links'\nimport {\n type CollectionCardImage,\n type CollectionResult,\n type CollectionSourceSpec,\n collectBoundColumnsInTree,\n createCollectionReader,\n effectiveCollectionSources,\n type FindManyClientLike,\n materializeServerTemplate,\n resolveLinks,\n resolveTreeCollections,\n serverBlockStructureLookup,\n serverBoundColumnsInTree,\n serverCollectionFieldLookup,\n serverTemplateMaterializer,\n type TemplateNamespace,\n templateNamespace,\n} from '@murumets-ee/blocks/server'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\n\nimport { getCanonicalPaths } from './paths/enumerate.js'\nimport { buildEnumerateDeps } from './paths/resolve-context.js'\n\n// ---------------------------------------------------------------------------\n// Structural mirrors of `content-api`'s wire types\n// ---------------------------------------------------------------------------\n//\n// Mirrored rather than imported, for the reason every other provider in this\n// package mirrors its counterpart: `content-api` is the leaf, and the app that\n// wires the two is where TypeScript checks they agree.\n\n/** Structural mirror of `content-api`'s `MediaProjection`. */\nexport interface BlockMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n variants: Record<string, string>\n sources: Record<string, Array<{ url: string; width: number; height: number }>>\n avifSources?: Record<string, Array<{ url: string; width: number; height: number }>>\n focalPoint: { x: number; y: number }\n blurDataUrl: string | null\n}\n\n/**\n * Structural mirror of `content-api`'s `ResolveMedia`.\n *\n * The SAME function an app passes as `ContentApiConfig.resolveMedia` — pass one\n * value to both, so a media ref inside a block cannot resolve through a\n * different path (or a different publish gate) than a top-level media field.\n */\nexport type BlockMediaResolver = (\n ids: readonly string[],\n opts?: { locale?: string | undefined },\n) => Promise<Map<string, BlockMediaProjection>>\n\n/** Structural mirror of `content-api`'s `UnresolvedBlockRef`. */\nexport interface UnresolvedBlockRef {\n readonly kind: 'media' | 'link'\n readonly ref: string\n readonly reason: 'unresolvable' | 'budget'\n}\n\n/** Structural mirror of `content-api`'s `BlockTreeRef`. */\nexport interface BlockTreeRef {\n readonly entity: string\n readonly id: string\n}\n\n/** Structural mirror of `content-api`'s `ResolvedBlockTree`. */\nexport interface ResolvedBlockTree {\n readonly resolved: 1\n readonly blocks: readonly unknown[]\n readonly links: Readonly<Record<string, string | null>>\n readonly unresolved?: readonly UnresolvedBlockRef[]\n readonly refs?: readonly BlockTreeRef[]\n readonly collections?: Readonly<Record<string, CollectionResult>>\n}\n\n/**\n * What resolving COLLECTION blocks needs from the app — built once per app by\n * {@link createBlockResolver}, absent when the app declares no sources.\n */\nexport interface BlockCollectionDeps {\n readonly sources: readonly CollectionSourceSpec[]\n readonly sourceFieldOf: (blockType: string) => string | undefined\n readonly getEntity: (name: string) => Entity | undefined\n /** A PUBLISHED-only reader bound to the ambient request context — the caller. */\n readonly clientFor: (entity: Entity) => FindManyClientLike\n}\n\n/**\n * The slice of `content-api`'s `ResolutionBudget` this provider uses —\n * narrowed to `claim`, for the reason `ChromeResolutionBudget` states: a\n * provider asks for work and is told how much it may do; it has no business\n * reading the remaining count or the drop summary.\n */\nexport interface BlockResolutionBudget {\n claim: (kind: 'block', n: number) => number\n}\n\nexport interface BlockResolverLogger {\n debug: (obj: object, msg: string) => void\n warn: (obj: object, msg: string) => void\n}\n\n/** Everything the pure resolver needs, injected so it is testable without an app. */\nexport interface BlockResolverDeps {\n /** Public-only media resolution. Omit and every media ref resolves to `null`. */\n resolveMedia?: BlockMediaResolver | undefined\n /**\n * PUBLISHED-only canonical paths for many ids of one entity type, in one\n * locale, as `entityId → path`.\n *\n * **The publish gate is this function's responsibility and the whole reason\n * it is batched rather than per-id.** An unpublished target must yield no\n * entry, or a block link leaks an unpublished document's URL — the obligation\n * `ResolveBlocks`' own docblock puts on the wiring.\n */\n resolvePublishedPaths: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<Map<string, string>>\n logger?: BlockResolverLogger | undefined\n /** Collection resolution. Omit and collection blocks resolve no rows. */\n collections?: BlockCollectionDeps | undefined\n /**\n * Rebuild a CODED TEMPLATE and name the columns its blocks bind (editor\n * D017, frontend-delivery F109). Defaults to the server block registry's\n * `materializeServerTemplate`; injectable so a test need not seed a registry.\n *\n * `namespace` is the document the ids belong to (editor F124) — see\n * `resolveBlockTree`, which derives it from the entity and the row's id.\n */\n materializeTemplate?:\n | ((\n template: string,\n namespace: TemplateNamespace,\n ) => {\n readonly blocks: unknown[]\n readonly boundColumns: readonly string[]\n })\n | undefined\n /**\n * The columns the blocks in an ORDINARY stored tree bind. Defaults to the\n * server registry's `serverBoundColumnsInTree`; injectable for the same reason\n * as {@link materializeTemplate}.\n */\n boundColumnsOf?: ((tree: readonly unknown[]) => readonly string[]) | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Media refs inside a tree\n// ---------------------------------------------------------------------------\n\n/**\n * How deep the media walk descends, in JSON levels.\n *\n * A stored tree is authenticated-editor JSON, not anonymous input — but it is\n * still data crossing a process boundary, and an unbounded recursion over it is\n * a stack overflow waiting for one pathological document, which under on-demand\n * rendering is a 500 on a live page for every visitor.\n *\n * 🔴 It counts JSON levels, not BLOCK levels, and the two are not the same: one\n * block of nesting costs about three (`block → children → block`, plus one for\n * `props`). This was 64, matching `collectLinkValues`, which sounded principled\n * and was wrong — `op-commit` permitted 64 BLOCK levels then, so a media ref\n * below roughly the 21st block was never visited, its stored url crossed\n * unchanged, and nothing said so. A bound that silently stops enforcing a\n * security control is worse than no bound, because it reads as coverage.\n *\n * 256 covered those 64 block levels with room to spare, and now covers\n * `MAX_BLOCK_DEPTH` (32, the one reconciled persist-and-render cap) with twice\n * that. Left at 256 rather than retuned: the headroom costs nothing, and a\n * bound that only just fits is the shape this comment exists to warn about.\n * Exceeding it is REPORTED rather than returned in silence. `collectLinkValues`\n * keeps its own 64 — that one bounds a display concern (an unresolved link\n * renders as plain text), not a value that must not reach the wire.\n */\nconst MAX_WALK_DEPTH = 256\n\n/**\n * How deep LINK collection descends — `collectLinkValues`' own default, stated\n * here so the two bounds are visibly independent rather than accidentally\n * shared. An unresolved link renders as plain text; an unvisited media ref\n * keeps a url that may be a capability. Different stakes, different bounds.\n *\n * ⚠️ Unlike the media walk, this one truncates SILENTLY: `collectLinkValues`\n * returns what it found and reports nothing, so a link below the bound is\n * absent from the sidecar, absent from `unresolved`, and renders as plain text\n * with no signal anywhere. That is tolerable only because the failure is\n * cosmetic — and it is stated rather than left to be discovered, because the\n * media walk one constant up made exactly the opposite call for exactly the\n * opposite reason.\n */\nconst LINK_WALK_DEPTH = 64\n\n/**\n * Entity ids are UUIDs (user ids are not, but no user id is ever a media ref).\n * Requiring the shape here is what makes structural detection safe: a prop\n * object carrying BOTH a uuid `id` AND a non-empty `url` string, with every\n * optional `MediaRef` field well-typed, is a media ref by any usable\n * definition.\n */\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * `Array.isArray(x)` on an `unknown` narrows to `any[]`, which would put an\n * `any` into every element of a walk over untyped JSON — in a package holding a\n * type-coverage floor. Narrowing through an explicit predicate keeps elements\n * `unknown`.\n */\nfunction isUnknownArray(value: unknown): value is unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * Every key a `MediaRef` or a `MediaProjection` may carry — and NOTHING ELSE\n * may be present for a value to count as one.\n *\n * 🔴 This is a WHITELIST, and the difference is not stylistic. Checking only\n * that the known keys are well-typed made the detector match any object with a\n * uuid `id` and a `url` — a project's `{ id, url, provider: 'youtube' }` prop,\n * say. That object would then be REPLACED by `null` (no media row for its id),\n * the consumer-side validator would drop the whole prop, and the block would\n * render empty with an `unresolved` entry calling it media. Silent data loss,\n * misdescribed in the one channel meant to report it.\n *\n * An unrecognised key means \"this is not a media ref\", so the failure direction\n * is leaving a value alone rather than deleting it.\n */\nconst MEDIA_REF_KEY_MAP: Record<keyof BlockMediaProjection, true> = {\n id: true,\n url: true,\n mimeType: true,\n width: true,\n height: true,\n alt: true,\n title: true,\n variants: true,\n // A PROJECTION key, like `variants` and `focalPoint` — listed so a projection\n // is still recognised on a second pass. The STORED `MediaRef` never carries it\n // (images D007), so no stored ref changes shape.\n sources: true,\n // Projection-only too (images PR12), and absent unless AVIF is on.\n avifSources: true,\n focalPoint: true,\n // Projection-only as well (images PR11) — the stored ref never carries it.\n blurDataUrl: true,\n}\n\n/**\n * Derived from the map above rather than written out beside it, so ADDING a\n * field to the projection is a COMPILE error until this list learns about it.\n *\n * That tie is the whole reason the map exists. A bare literal set would have\n * made the whitelist fail-OPEN and silent: give `MediaRef` a `caption` (which\n * the image block already renders, so it is a plausible next change) and every\n * captioned ref quietly stops being recognised — not collected, not\n * re-resolved, not nulled, and not reported — so its stored url crosses\n * verbatim. That is the same \"a bound that silently stops enforcing a security\n * control reads as coverage\" this file says about {@link MAX_WALK_DEPTH}, and\n * it would have been true one field later.\n */\nconst MEDIA_REF_KEYS = new Set(Object.keys(MEDIA_REF_KEY_MAP))\n\n/**\n * The media id in `value`, or `null` when it is not a media ref.\n *\n * Structural, deliberately — the same technique `asLinkValue` uses for links,\n * and for the same reason: a resolver that needed the block REGISTRY to know\n * which props are media would need every project to register its vocabulary at\n * the content-api mount, which is a second place to forget. Detection that\n * reads the value cannot go stale.\n *\n * Optional fields accept `null` as well as `undefined` so the check is\n * idempotent: a `MediaProjection` (whose optionals are `T | null`) is still\n * recognised, rather than being silently skipped on a second pass.\n */\nfunction mediaRefId(value: unknown, onNearMiss?: (keys: readonly string[]) => void): string | null {\n if (!isRecord(value)) return null\n const id = value.id\n if (typeof id !== 'string' || !UUID_RE.test(id)) return null\n if (typeof value.url !== 'string' || value.url === '') return null\n // The two cheap discriminators are checked FIRST, so the key scan runs only\n // for the handful of values that already look like a ref rather than for\n // every object in the tree.\n const unknownKeys = Object.keys(value).filter((key) => !MEDIA_REF_KEYS.has(key))\n if (unknownKeys.length > 0) {\n // 🔴 REPORTED, not silently declined. A value with a uuid `id` and a `url`\n // that this detector walks past is either a project prop we correctly left\n // alone or a media ref we failed to recognise — and the second is a stored\n // url reaching the wire with nothing anywhere saying so. The caller cannot\n // tell the two apart, and neither can this function; what it can do is\n // refuse to be quiet about the ambiguity.\n onNearMiss?.(unknownKeys)\n return null\n }\n if (!isOptional(value.alt, 'string')) return null\n if (!isOptional(value.title, 'string')) return null\n if (!isOptional(value.mimeType, 'string')) return null\n if (!isOptional(value.width, 'number')) return null\n if (!isOptional(value.height, 'number')) return null\n // Projection-only, like `focalPoint`: a string when present, so a project\n // prop holding something else there is left alone rather than claimed.\n if (!isOptional(value.blurDataUrl, 'string')) return null\n // `variants` is the one whitelisted key the type checks above do not cover,\n // and skipping it left an over-match: `{ id: <uuid>, url: '…', variants: 'x' }`\n // passed the whitelist and was replaced by a projection or by `null`. That is\n // precisely the direction the whitelist exists to prevent.\n if (value.variants !== undefined && value.variants !== null && !isRecord(value.variants)) {\n return null\n }\n // Same reasoning for `focalPoint`, which only a PROJECTION carries (a stored\n // ref never does — images D007): recognised on a second pass, never an\n // over-match on a project prop that happens to hold a non-object there.\n if (value.focalPoint !== undefined && value.focalPoint !== null && !isRecord(value.focalPoint)) {\n return null\n }\n // And `sources`, for the same reason — projection-only, and when present an\n // object of ARRAYS (shape name → files). Checked one level deep, because a\n // bare \"is an object\" let `{ id: <uuid>, url, sources: { natural: 'x' } }`,\n // a project's own prop, be claimed as media and replaced (CodeRabbit).\n if (value.sources !== undefined && value.sources !== null) {\n if (!isRecord(value.sources)) return null\n if (!Object.values(value.sources).every((list) => Array.isArray(list))) return null\n }\n // `avifSources` has `sources`' shape and the same over-match risk.\n if (value.avifSources !== undefined && value.avifSources !== null) {\n if (!isRecord(value.avifSources)) return null\n if (!Object.values(value.avifSources).every((list) => Array.isArray(list))) return null\n }\n return id\n}\n\nfunction isOptional(value: unknown, type: 'string' | 'number'): boolean {\n return value === undefined || value === null || typeof value === type\n}\n\n/** One place in the tree holding a media ref, so it can be overwritten after resolution. */\ninterface MediaSlot {\n readonly container: Record<string, unknown> | unknown[]\n readonly key: string | number\n readonly id: string\n}\n\n/**\n * Collect every media ref in the tree, recording WHERE it sits.\n *\n * Slots rather than a returned copy because the replacement is in place, which\n * is the same shape `projectMedia` uses on entity rows for the same reason: the\n * tree came from a per-request read and copying it would double the cost of the\n * largest thing on the page.\n */\n/** What the walk noticed but could not act on. Both are ambiguity, not failure. */\ninterface WalkReport {\n /** A subtree below {@link MAX_WALK_DEPTH} was never visited. */\n truncated: boolean\n /** Keys that made a ref-shaped value fall outside the whitelist, deduped. */\n nearMissKeys: Set<string>\n}\n\nfunction collectMediaSlots(\n node: unknown,\n out: MediaSlot[],\n depth: number,\n report: WalkReport,\n): void {\n // Flagged, never silent — and flagged rather than logged, so a pathological\n // tree cannot turn the operator log into a hot loop. Below the bound a media\n // ref is not visited, so its stored url would cross unchanged.\n if (depth > MAX_WALK_DEPTH) {\n report.truncated = true\n return\n }\n const noteNearMiss = (keys: readonly string[]): void => {\n for (const key of keys) report.nearMissKeys.add(key)\n }\n if (isUnknownArray(node)) {\n for (let index = 0; index < node.length; index += 1) {\n const id = mediaRefId(node[index], noteNearMiss)\n if (id !== null) out.push({ container: node, key: index, id })\n else collectMediaSlots(node[index], out, depth + 1, report)\n }\n return\n }\n if (!isRecord(node)) return\n for (const [key, value] of Object.entries(node)) {\n const id = mediaRefId(value, noteNearMiss)\n if (id !== null) out.push({ container: node, key, id })\n else collectMediaSlots(value, out, depth + 1, report)\n }\n}\n\n/**\n * The projection as it goes INTO a block tree: null-valued optionals OMITTED.\n *\n * 🔴 Found by rendering a real page in a browser, not by a unit test — the\n * tests all used a projection whose `width`/`height`/`alt` happened to be\n * populated. `MediaProjection` declares its optionals `T | null` because a\n * top-level media field publishes a fixed key set. `MediaRef` — the type every\n * block render is typed against, and the one the consumer-side validator\n * narrows to — declares them `T | undefined`, so a JSON `null` there is not a\n * value any render can hold. The front-end validator did exactly what it should\n * with one: dropped it, and logged \"block props were dropped as unusable\" for\n * `image.width`, `image.height` and `image.alt` on EVERY render of every page\n * carrying an image. Correct behaviour, useless signal — the log that exists to\n * report real data loss would have been drowned by it from day one.\n *\n * Absent and null mean the same thing for every one of these fields, so\n * omitting is lossless. What it buys is a value that is assignable to the type\n * the renders declare, which is the whole reason `MediaRef` carries a url at\n * all.\n */\nfunction toEmbeddedProjection(projection: BlockMediaProjection): Record<string, unknown> {\n const out: Record<string, unknown> = {\n id: projection.id,\n url: projection.url,\n variants: projection.variants,\n // Always embedded, like `variants`: the render reads it to crop a shaped\n // slot, and a projection always carries one (`{0.5, 0.5}` when the row has\n // none), so there is no null to omit.\n focalPoint: projection.focalPoint,\n // Always embedded too: an empty map is \"nothing generated yet\", which a\n // render reads as \"no srcset\" — there is no null to omit.\n sources: projection.sources,\n }\n // Only when there are some (images PR12): the tree is serialised into the\n // page, so an empty key would change every page of a site with AVIF off.\n if (projection.avifSources !== undefined && Object.keys(projection.avifSources).length > 0) {\n out.avifSources = projection.avifSources\n }\n if (projection.mimeType !== null) out.mimeType = projection.mimeType\n if (projection.width !== null) out.width = projection.width\n if (projection.height !== null) out.height = projection.height\n if (projection.alt !== null) out.alt = projection.alt\n if (projection.title !== null) out.title = projection.title\n // Omitted when there is none, like the other optionals: most photographs on\n // a site that predates images PR11 have none until the catch-up reaches them.\n if (projection.blurDataUrl !== null) out.blurDataUrl = projection.blurDataUrl\n return out\n}\n\nfunction writeSlot(slot: MediaSlot, value: BlockMediaProjection | null): void {\n const written = value === null ? null : toEmbeddedProjection(value)\n if (Array.isArray(slot.container)) {\n if (typeof slot.key === 'number') slot.container[slot.key] = written\n return\n }\n if (typeof slot.key === 'string') slot.container[slot.key] = written\n}\n\n// ---------------------------------------------------------------------------\n// The resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve one stored block tree into a {@link ResolvedBlockTree}.\n *\n * **Throws when `blocks` is not an array**, which is how a corrupt stored value\n * is handled rather than papered over — a resolver that returned `{ resolved: 1,\n * blocks: [] }` for it would report success while deleting a page's content,\n * and the caller could not tell the two apart.\n *\n * ⚠️ **What the caller does with that throw CHANGED** (editor D010, closing\n * F056). It used to serve the stored tree unchanged, so a malformed row cost\n * its own hrefs and nothing else. It no longer does: it re-calls this function\n * with an exhausted budget and, if that fails too, serves an empty envelope. A\n * non-array `blocks` fails this guard DETERMINISTICALLY, so a corrupt column is\n * now precisely the case that reaches that third state and is blanked. That is\n * the intended outcome rather than a regression — a non-array stored value\n * cannot be rendered by any consumer, and it may still be an object carrying\n * media refs whose urls were signed at author time.\n */\nexport async function resolveBlockTree(\n blocks: unknown,\n opts: {\n locale: string\n budget: BlockResolutionBudget\n entity: string\n field: string\n /** content-api's whitelist ∧ `view` gate for THIS caller. Absent → no collection rows. */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /** One slot of the response's collection allowance. Absent → no collection rows. */\n claimCollection?: (() => boolean) | undefined\n /** A CODED TEMPLATE's root block name — `blocks` is then ignored and the template rebuilt. */\n template?: string | undefined\n /** The row a templated document's bound blocks read. Read, never written. */\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n deps: BlockResolverDeps,\n): Promise<ResolvedBlockTree> {\n // A CODED TEMPLATE has no stored tree (editor D017): rebuild it from the\n // registry, and remember which COLUMNS its blocks bind — a bound rich-text\n // column's link marks live in the row, and they are resolved below in the\n // same batch as the tree's own (F109). Only the columns THIS template binds\n // are read, so a same-named column bound by some other block is never walked.\n //\n // An ORDINARY tree can carry bound blocks too — `documentBody` dropped into a\n // composable page renders that page's column — so when the caller hands over\n // the row, its bound columns are walked the same way. Without that the canvas\n // (which composes bound values into any tree) resolved links the site then\n // rendered as plain text.\n let tree: unknown = blocks\n let boundColumns: readonly string[] = []\n if (opts.template !== undefined) {\n // 🔴 The ids belong to THIS document (editor F124): namespaced by the entity\n // and the row's own id, exactly as the editor and the bound-field route\n // namespace them, so the delivered tree carries the ids the canvas edits.\n //\n // The id is read off the row because the row is what the caller hands over\n // — content-api always projects `id` (`query-params.ts`, \"Always include\n // id\"), so a templated field never arrives without one. If it somehow did,\n // the template is served EMPTY rather than with ids that name no document:\n // an empty body is a visible fault, a shared id space is the defect F124\n // closed.\n const documentId = opts.boundValues?.id\n if (typeof documentId === 'string' && documentId !== '') {\n const built = (deps.materializeTemplate ?? materializeServerTemplate)(\n opts.template,\n templateNamespace(opts.entity, documentId),\n )\n tree = built.blocks\n boundColumns = built.boundColumns\n } else {\n tree = []\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: a templated document arrived without its row id, so its template is served ' +\n 'EMPTY — its block ids could not be namespaced to it (editor F124)',\n )\n }\n } else if (opts.boundValues !== undefined && isUnknownArray(blocks)) {\n // Degrades to \"no bound columns\" rather than failing the tree: bound-column\n // links are an enhancement, and a throw here would otherwise blank the whole\n // document (the zero-budget retry would throw identically).\n try {\n boundColumns = (deps.boundColumnsOf ?? serverBoundColumnsInTree)(blocks)\n } catch (err) {\n deps.logger?.warn(\n { err, entity: opts.entity, field: opts.field },\n 'content: could not enumerate the bound columns of a block tree, so its bound-field links render plain',\n )\n }\n }\n const row = opts.boundValues ?? {}\n const boundSources = boundColumns\n .filter((column) => Object.hasOwn(row, column))\n .map((column) => row[column])\n\n if (!isUnknownArray(tree)) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: stored block tree is not an array — nothing to resolve, so this field is ' +\n 'served EMPTY rather than as stored (the caller retries this throw with an exhausted ' +\n 'budget, and a non-array fails that identically)',\n )\n throw new TypeError(\n `content: block tree \\`${opts.entity}.${opts.field}\\` is not an array; nothing to resolve.`,\n )\n }\n\n const slots: MediaSlot[] = []\n const report: WalkReport = { truncated: false, nearMissKeys: new Set() }\n collectMediaSlots(tree, slots, 0, report)\n // ONE line per call at most, whatever the tree looks like — a per-node log on\n // an anonymous request path is the amplification this file bounds everywhere\n // else. Both are hedged (\"any\", \"may\") because the walk cannot know whether\n // the thing it declined to visit was a media ref at all; what it knows is\n // that it did not look.\n if (report.truncated) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field, cap: MAX_WALK_DEPTH },\n 'content: block tree is nested deeper than the media walk descends — any media ref below ' +\n 'the bound keeps the url recorded when an editor picked it, which may be signed',\n )\n }\n if (report.nearMissKeys.size > 0) {\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, keys: [...report.nearMissKeys].sort() },\n 'content: a value looked like a media ref but carried keys outside the projection, so it ' +\n 'was left alone. If these are media refs, they keep their stored urls',\n )\n }\n // Its OWN bound, not this module's. Link resolution is a display concern — an\n // unresolved link renders as plain text — while the media walk is a security\n // control, which is why only one of the two was raised. Passing\n // `MAX_WALK_DEPTH` here would have silently quadrupled link collection depth\n // as a side effect of a change that had nothing to do with links.\n //\n // The bound columns' links join the SAME list, so they share every step below\n // — one media batch, one published-path lookup per linked entity type, one\n // pair of claims against the response's budget. That is F109's fan-out bound:\n // links are resolved per DOCUMENT, never one request per link and never one\n // batch per column.\n const links = [\n ...collectLinkValues(tree, LINK_WALK_DEPTH),\n ...collectLinkValues(boundSources, LINK_WALK_DEPTH),\n ]\n\n // Media LINKS resolve to a media url, so they join the same batch — one\n // lookup covers a `{kind:'media'}` link and an image of the same asset.\n //\n // 🔴 FILTERED to the uuid shape, and the filter is load-bearing rather than\n // defensive. `mediaRefId` already requires a uuid, but `asLinkValue` accepts\n // ANY non-empty string as a `{kind:'media'}` id — so one legacy or\n // hand-authored link with `id: 'x'` reached `inArray(idColumn, ids)` and\n // Postgres raised `22P02 invalid input syntax for type uuid`. The batch's\n // catch then returned an EMPTY map, and every slot on the page was written\n // `null`: one malformed link deleted every image in the tree. Rejects are\n // reported as `unresolvable` rather than dropped, because a link that can\n // never resolve is exactly what that channel is for.\n const wellFormedMediaLinkIds: string[] = []\n const malformedRefs: UnresolvedBlockRef[] = []\n for (const link of links) {\n if (link.kind !== 'media') continue\n if (UUID_RE.test(link.id)) wellFormedMediaLinkIds.push(link.id)\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n const mediaIds = distinct([...slots.map((slot) => slot.id), ...wellFormedMediaLinkIds])\n\n const entityTargets: string[] = []\n for (const link of links) {\n if (link.kind !== 'entity') continue\n // Same reason: `filterPublished` issues one `inArray` per entity type, so a\n // single non-uuid id throws and takes every link of that type with it.\n if (UUID_RE.test(link.id)) entityTargets.push(targetKey(link.entity, link.id))\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n\n const unresolved: UnresolvedBlockRef[] = [...malformedRefs]\n\n // TWO claims against the ONE shared counter, media first.\n //\n // Two rather than one so each pass learns its own grant without splitting a\n // combined number, and media first because a vanished image is the more\n // visible failure — when a page is over budget, the deterministic outcome is\n // \"images render, some links go plain\" rather than whichever the scheduler\n // reached first. Both record their shortfall under the same `block` kind, so\n // the operator log is unchanged.\n //\n // Nothing is claimed for media when no resolver is wired: the budget reports\n // work the server DECLINED, and there is no work to decline. Claiming anyway\n // burned the whole ceiling on a pass that did no I/O and then reported every\n // LINK as `budget` — a drop log naming the wrong cause.\n if (!deps.resolveMedia && mediaIds.length > 0) {\n // The signal for \"this deployment cannot resolve media at all\", emitted\n // HERE because this is now the only place that can tell. `resolveMediaBatch`\n // used to carry it, and the no-claim rule above made its branch\n // unreachable — the ids never reach it. It is the likeliest cause of \"every\n // image on the site is gone\", and the `unresolvable` the envelope reports\n // deliberately does not distinguish causes, so the operator log is the only\n // place it can be said.\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, count: mediaIds.length },\n 'content: no media resolver wired for block trees — every media ref resolves to null',\n )\n }\n\n const mediaGranted = deps.resolveMedia ? opts.budget.claim('block', mediaIds.length) : 0\n const coveredMedia = mediaIds.slice(0, mediaGranted)\n // 🔴 EMPTY when no resolver is wired, and that is not the same set as\n // \"everything\". `budgetDroppedMedia` means \"a ceiling refused this\", and it\n // is read again below by `unresolvedLinkReason` — so filling it on the\n // no-resolver path reported every media LINK as `reason: 'budget'` and sent\n // an operator to raise a limit that was never involved. The reason was fixed\n // for the media entries and missed for the links; one set, read twice, is\n // what keeps them consistent.\n const budgetDroppedMedia = deps.resolveMedia\n ? new Set(mediaIds.slice(mediaGranted))\n : new Set<string>()\n for (const id of mediaIds.slice(mediaGranted)) {\n unresolved.push({\n kind: 'media',\n ref: id,\n reason: budgetDroppedMedia.has(id) ? 'budget' : 'unresolvable',\n })\n }\n\n const distinctTargets = distinct(entityTargets)\n const linkGranted = opts.budget.claim('block', distinctTargets.length)\n const coveredTargets = distinctTargets.slice(0, linkGranted)\n const budgetDroppedTargets = new Set(distinctTargets.slice(linkGranted))\n\n const media = await resolveMediaBatch(coveredMedia, opts.locale, deps, opts.entity)\n\n // Every slot is written, including the ones that did not resolve. Leaving a\n // stored ref in place for an id the resolver refused is the leak this module\n // exists to close (see the file header) — \"not public\" and \"not found\" are\n // the same branch downstream, so the ONE case where a fallback would matter\n // is exactly the case where it must not happen.\n for (const slot of slots) {\n const projection = media.get(slot.id) ?? null\n writeSlot(slot, projection)\n }\n for (const id of coveredMedia) {\n if (!media.has(id)) unresolved.push({ kind: 'media', ref: id, reason: 'unresolvable' })\n }\n\n const paths = await resolveEntityPaths(coveredTargets, opts.locale, deps, opts.entity)\n\n // `resolveLinks` still owns dedup, the `sanitizeHref` protocol gate on\n // resolved hrefs, anchor composition and the inline url/email cases. What it\n // is handed here are PURE MAP LOOKUPS, so its `runBounded` fan-out does no\n // I/O at all — the batching above already happened.\n const resolvedLinks = await resolveLinks(links, {\n renderContext: 'request',\n locale: opts.locale,\n resolveEntityHref: ({ entity, id }) => paths.get(targetKey(entity, id)) ?? null,\n resolveMediaHref: (id) => media.get(id)?.url ?? null,\n })\n\n const linkRecord: Record<string, string | null> = {}\n for (const [key, resolved] of resolvedLinks) linkRecord[key] = resolved.href\n // Only `entity`/`media` links are REPORTED as unresolved. A `url` or `email`\n // link with a null href was refused by `sanitizeHref` — an editor typed\n // `javascript:` — which is a content problem the security gate handled, not a\n // resolution this response failed to perform, and listing it would invite a\n // consumer to retry it.\n for (const link of links) {\n if (link.kind !== 'entity' && link.kind !== 'media') continue\n const key = linkValueKey(link)\n if (linkRecord[key] !== null && linkRecord[key] !== undefined) continue\n unresolved.push({\n kind: 'link',\n ref: key,\n reason: unresolvedLinkReason(link, budgetDroppedTargets, budgetDroppedMedia),\n })\n }\n\n // What this tree DEPENDS ON, for whatever cache is holding the response.\n //\n // 🔒 Built from what actually RESOLVED, not from what was asked for. `media`\n // holds only ids the public resolver returned, and `paths` only targets the\n // published-path lookup answered — so an unpublished link target contributes\n // no ref, and its id is not disclosed. That is the same gate the hrefs above\n // pass through, applied to identity; the refusal is already reported through\n // `unresolved`, which carries a `linkValueKey` the consumer already holds.\n //\n // Media BEFORE links, and both deduped by the Map: an image embedded as a\n // `field.media()` and linked as `{kind:'media'}` is one row and one tag.\n const { results: collections, cardMedia } = await resolveCollectionsIn(tree, opts, deps)\n\n const refByKey = new Map<string, BlockTreeRef>()\n for (const id of media.keys()) refByKey.set(targetKey('media', id), { entity: 'media', id })\n for (const id of cardMedia) refByKey.set(targetKey('media', id), { entity: 'media', id })\n // A listed row is a row this tree depends on, and it passed the caller's gate\n // to be listed — the same rule the links below follow.\n for (const result of collections.values()) {\n if (result.entity === null) continue\n for (const row of result.rows) {\n refByKey.set(targetKey(result.entity, row.id), { entity: result.entity, id: row.id })\n }\n }\n for (const key of paths.keys()) {\n if (refByKey.has(key)) continue\n const split = key.indexOf(' ')\n if (split <= 0) continue\n const entity = key.slice(0, split)\n const id = key.slice(split + 1)\n if (id === '') continue\n refByKey.set(key, { entity, id })\n }\n\n return {\n resolved: 1,\n blocks: tree,\n links: linkRecord,\n // OMITTED when empty, never `[]`. Its presence is itself information —\n // Contentful's rule for `errors[]`, and the property that stops \"nothing\n // failed\" from looking like \"nothing was attempted\".\n ...(unresolved.length > 0 && { unresolved: dedupeRefs(unresolved) }),\n // Same rule, same reason: a tree that references nothing says so by\n // omitting the field rather than by shipping `[]`.\n ...(refByKey.size > 0 && { refs: [...refByKey.values()] }),\n // And again: a tree with no collection block carries no sidecar.\n ...(collections.size > 0 && { collections: Object.fromEntries(collections) }),\n }\n}\n\n/**\n * Resolve every collection block in `blocks` through the shared resolver.\n *\n * DENY-BY-DEFAULT at three levels: an app with no declared sources has no\n * `deps.collections`, and a call without `canReadEntity` — a host that did not\n * say who may read what — or without `claimCollection` — a host that did not\n * say how many — resolves nothing. Either way the blocks render empty on the\n * site, which is the safe direction.\n *\n * Images go through the SAME public media resolver the tree's own media refs\n * do, and hrefs through the same published-path lookup its links do, so a card\n * cannot show an asset or a URL the rest of the page could not.\n */\nasync function resolveCollectionsIn(\n blocks: readonly unknown[],\n opts: {\n locale: string\n budget: BlockResolutionBudget\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n },\n deps: BlockResolverDeps,\n): Promise<{\n readonly results: ReadonlyMap<string, CollectionResult>\n readonly cardMedia: ReadonlySet<string>\n}> {\n const collections = deps.collections\n const canRead = opts.canReadEntity\n const claimCollection = opts.claimCollection\n if (collections === undefined || canRead === undefined || claimCollection === undefined) {\n return { results: new Map(), cardMedia: new Set() }\n }\n const resolveMedia = deps.resolveMedia\n // Every media id a card will draw, so the tree names it in `refs` like its\n // own media: a replaced image or an edited alt must evict the cached page\n // (CodeRabbit review, PR53).\n const cardMedia = new Set<string>()\n const results = await resolveTreeCollections(blocks, collections.sourceFieldOf, {\n sources: collections.sources,\n getEntity: collections.getEntity,\n clientFor: collections.clientFor,\n canRead,\n resolveImages:\n resolveMedia === undefined\n ? undefined\n : async (ids): Promise<ReadonlyMap<string, CollectionCardImage>> => {\n const found = await resolveMedia(ids, { locale: opts.locale })\n // A card image must BE an image. A media field's `kind: 'image'`\n // is only a picker filter (editor F116), so a video can sit in\n // one — and the card would draw it as a broken `<img>`.\n const shown = [...found].filter(\n ([, projection]) => projection.mimeType?.startsWith('image/') === true,\n )\n for (const [id] of shown) cardMedia.add(id)\n // All seven keys a card needs — beyond the four: where to crop\n // around and which generated files to offer (images PR07, F021),\n // and the preview it shows while it loads (PR11).\n // `resolveCollectionSource` normalises what this returns\n // (`cardImageOf`: a centre point, an empty `sources` and a missing\n // preview are left out), the SAME step the canvas's injected resolver goes through,\n // so a card carries the same keys on both surfaces.\n return new Map(\n shown.map(([id, projection]) => [\n id,\n {\n url: projection.url,\n alt: projection.alt,\n width: projection.width,\n height: projection.height,\n focalPoint: projection.focalPoint,\n sources: projection.sources,\n // Non-empty only, the rule the embedded projection follows\n // (`cardImageOf` would drop `{}` too; one rule, both paths).\n ...(projection.avifSources !== undefined &&\n Object.keys(projection.avifSources).length > 0 && {\n avifSources: projection.avifSources,\n }),\n blurDataUrl: projection.blurDataUrl,\n },\n ]),\n )\n },\n resolveHrefs: (entityType, ids) => deps.resolvePublishedPaths(entityType, ids, opts.locale),\n locale: opts.locale,\n renderContext: 'request',\n // A slot of the response's collection allowance, THEN one unit of its ONE\n // budget — so a page whose references and media spent the ceiling lists\n // nothing rather than exceeding it, and a list response stops at the\n // allowance however many rows it carries. Allowance first: once it is\n // spent, a refused collection must not also burn budget the rest of the\n // response's media and links need.\n claim: () => claimCollection() && opts.budget.claim('block', 1) >= 1,\n logger: deps.logger,\n })\n return { results, cardMedia }\n}\n\nfunction unresolvedLinkReason(\n link: LinkValue,\n budgetDroppedTargets: ReadonlySet<string>,\n budgetDroppedMedia: ReadonlySet<string>,\n): 'unresolvable' | 'budget' {\n if (link.kind === 'entity' && budgetDroppedTargets.has(targetKey(link.entity, link.id))) {\n return 'budget'\n }\n if (link.kind === 'media' && budgetDroppedMedia.has(link.id)) return 'budget'\n return 'unresolvable'\n}\n\nfunction distinct(values: readonly string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * `(entityType, id)` as one map key. A space is unambiguous as a separator\n * because entity names are identifiers — `defineEntity` rejects anything else —\n * so no name can contain one.\n */\nfunction targetKey(entityType: string, id: string): string {\n return [entityType, id].join(' ')\n}\n\n/**\n * One entry per `(kind, ref)`.\n *\n * What this actually collapses is the SAME LinkValue appearing several times —\n * `collectLinkValues` does not dedupe, so a page linking twice to one target\n * would otherwise report it twice. It deliberately does NOT collapse an image\n * and a media LINK to the same asset: those are `media <id>` and\n * `link media:<id>`, two different failures a consumer may want to act on\n * differently, and the suite asserts exactly that.\n */\nfunction dedupeRefs(refs: readonly UnresolvedBlockRef[]): UnresolvedBlockRef[] {\n const seen = new Set<string>()\n const out: UnresolvedBlockRef[] = []\n for (const ref of refs) {\n const key = `${ref.kind} ${ref.ref}`\n if (seen.has(key)) continue\n seen.add(key)\n out.push(ref)\n }\n return out\n}\n\nasync function resolveMediaBatch(\n ids: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, BlockMediaProjection>> {\n // No log for the unwired case HERE: the caller does not claim, so `ids` is\n // always empty on that path and a branch guarding on it could never fire.\n // The signal lives at the call site, which is the only place that still knows.\n if (ids.length === 0 || !deps.resolveMedia) return new Map()\n try {\n return await deps.resolveMedia(ids, { locale })\n } catch (err) {\n deps.logger?.warn(\n { entity, err },\n 'content: media resolver threw while resolving a block tree — its media refs resolve to ' +\n 'null (if this is a ForbiddenError, the requesting role is missing `media:view`)',\n )\n return new Map()\n }\n}\n\n/**\n * {@link targetKey} → path, for the covered targets only.\n *\n * ONE call to {@link BlockResolverDeps.resolvePublishedPaths} per entity TYPE,\n * never per link. A document linking 200 times to 3 entity types costs 3 calls.\n */\nasync function resolveEntityPaths(\n targets: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, string>> {\n const out = new Map<string, string>()\n if (targets.length === 0) return out\n\n const byType = new Map<string, string[]>()\n for (const target of targets) {\n const separator = target.indexOf(' ')\n const entityType = target.slice(0, separator)\n const id = target.slice(separator + 1)\n const bucket = byType.get(entityType)\n if (bucket) bucket.push(id)\n else byType.set(entityType, [id])\n }\n\n // SERIAL over types, matching `resolveBlockTrees`' serial-over-rows rule: the\n // set is tiny (a document links to a handful of types at most) and serial\n // keeps the read pattern predictable under load rather than bursting one\n // query per type at once.\n for (const [entityType, ids] of byType) {\n try {\n const paths = await deps.resolvePublishedPaths(entityType, ids, locale)\n for (const [id, path] of paths) out.set(targetKey(entityType, id), path)\n } catch (err) {\n // One broken entity type costs its own links, not the page's whole tree.\n deps.logger?.warn(\n { entity, entityType, err },\n 'content: path resolution threw for a block-tree link target — its links render as ' +\n 'plain text',\n )\n }\n }\n return out\n}\n\n// ---------------------------------------------------------------------------\n// App wiring\n// ---------------------------------------------------------------------------\n\n/**\n * Wire {@link resolveBlockTree} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveBlocks })`.\n *\n * Pass the SAME `resolveMedia` value the handler gets, so a media ref inside a\n * block and a top-level `media()` field resolve through one path and one gate.\n *\n * **The publish gate comes from `buildEnumerateDeps`' batched `filterPublished`,\n * which reads through an elevated REACH context** (`createSystemReachResolver`,\n * which grants `view` and withholds `view_unpublished`), so unpublished and\n * trashed targets are dropped. That is deliberately the SAME gate `GET /_resolve`\n * applies and no stricter one: the alternative — additionally gating on the\n * caller's per-entity `view` — would make the same URL a working link on one\n * endpoint and plain text on another, while the document behind the href stays\n * whitelist- and permission-gated where it is actually fetched.\n */\nexport function createBlockResolver(\n getApp: () => Promise<ToolkitApp> | ToolkitApp,\n options: {\n /**\n * The locale to resolve in when a request carried none. Pass the SAME value\n * given to `createContentApiHandler({ defaultLocale })` — the handler\n * already defaults every entity read's locale before it reaches a resolver,\n * so this only binds on a path that somehow did not, and disagreeing with\n * the handler would resolve a link in a locale the page is not rendered in.\n */\n defaultLocale: string\n resolveMedia?: BlockMediaResolver | undefined\n },\n): (\n blocks: unknown,\n opts: {\n locale?: string | undefined\n budget: BlockResolutionBudget\n entity: string\n field: string\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n template?: string | undefined\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n) => Promise<ResolvedBlockTree> {\n // Built ONCE per app, not once per field per row. `buildEnumerateDeps` makes\n // a table client, and its `filterPublished` makes a fresh `QueryClient` and a\n // child logger per call — cheap individually, and paid once per `blockTree`\n // field on every row of every list page if it lives inside the closure.\n //\n // Cached against the app IDENTITY rather than assumed singleton: `getApp` is\n // a callback precisely because a host may hand back a different app (tests\n // do), and a cache that ignored that would silently read another app's\n // database.\n let cached: { app: ToolkitApp; deps: BlockResolverDeps } | null = null\n\n const depsFor = (app: ToolkitApp): BlockResolverDeps => {\n if (cached?.app === app) return cached.deps\n // `pathsClient` here is the BATCHED (`{ in }`-capable) reader, which is what\n // `getCanonicalPaths` needs — the point-lookup `buildCanonicalPathDeps`\n // reader cannot express an id batch, and reaching for it is how this ends\n // up one query per link.\n const enumerateDeps = buildEnumerateDeps(app)\n // editor PR53 — only when the app declares collection sources. The reader\n // is `createQueryClient`, which is published-only and bound to the AMBIENT\n // request context: the same caller-scoped read content-api's own list\n // endpoint performs, so a collection sees exactly the rows that caller\n // could fetch directly — nothing wider, no elevated identity.\n const { sources } = effectiveCollectionSources(app)\n const collections: BlockCollectionDeps | undefined =\n sources.length === 0\n ? undefined\n : {\n sources,\n sourceFieldOf: serverCollectionFieldLookup(),\n getEntity: (name) => app.entities.get(name),\n clientFor: (entity) => createCollectionReader(entity, app),\n }\n // A template is REGISTRY code, fixed after boot, so the registry lookups it\n // is rebuilt from are built ONCE per app rather than per row: a list\n // response of a hundred templated rows would otherwise rebuild the\n // block-definition map two hundred times on the anonymous path. The TREE is\n // still rebuilt per row, because its ids belong to that row's document\n // (editor F124) — the part that was cheap. Each call answers a fresh,\n // deep-cloned tree (see `serverTemplateMaterializer`), so the media slots\n // this resolver writes in place never reach another response or the\n // registry.\n const templates = serverTemplateMaterializer()\n // The structure lookup for ORDINARY trees' bound columns, built once per app\n // for the same reason: `serverBoundColumnsInTree` would otherwise rebuild the\n // block-definition map per row per field on the anonymous path.\n const structureLookup = serverBlockStructureLookup()\n const deps: BlockResolverDeps = {\n ...(collections !== undefined && { collections }),\n boundColumnsOf: (tree) => collectBoundColumnsInTree(tree, structureLookup),\n materializeTemplate: (template, namespace) => templates(template, namespace),\n resolveMedia: options.resolveMedia,\n resolvePublishedPaths: async (entityType, ids, locale) => {\n const published = await enumerateDeps.filterPublished(entityType, ids, locale)\n if (published.size === 0) return new Map()\n return getCanonicalPaths(entityType, [...published], locale, enumerateDeps)\n },\n logger: app.logger,\n }\n cached = { app, deps }\n return deps\n }\n\n return async (blocks, opts) => {\n const app = await getApp()\n return resolveBlockTree(\n blocks,\n { ...opts, locale: opts.locale ?? options.defaultLocale },\n depsFor(app),\n )\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"block-resolver.mjs","names":[],"sources":["../src/block-resolver.ts"],"sourcesContent":["/**\n * The block-tree resolver for `@murumets-ee/content-api`'s `resolveBlocks` seam\n * (`frontend-delivery` D019).\n *\n * This is the half `content-api` structurally cannot do: it walks a stored\n * tree, re-resolves the media refs inside it through the public-only media\n * path, and turns the `LinkValue`s inside it into hrefs against the published\n * routing index. `content-api` is a leaf — `blocks`, `media` and this package\n * all depend back on it — so it takes this as an injected provider, exactly as\n * it takes chrome, menus and routing resolution.\n *\n * ## What it emits, and why that shape\n *\n * A `ResolvedBlockTree` envelope: `{ resolved: 1, blocks, links, unresolved? }`.\n * The reasoning is D019's and is restated where it binds the code below, but\n * the two-sentence version is: links go into a `linkValueKey`-keyed sidecar\n * because that is exactly what every renderer already consumes, and media is\n * replaced IN PLACE because a media node is not an opaque pointer — it already\n * carries a url that every render reads, and that url has to be overwritten\n * regardless.\n *\n * ## 🔴 The security property this exists for\n *\n * A `field.media()` value inside a block tree is stored as a whole `MediaRef`\n * including a `url`, denormalised when the editor picked it. That url comes\n * from `StorageClient.getUrl`, which returns a **signed** url for any file that\n * is not public — and `StorageClient.upload` defaults to private. So the stored\n * tree can carry a bearer capability, and serving it unchanged puts that\n * capability on a `public, max-age=60` response any shared CDN will hand to\n * every later anonymous visitor. That is precisely the leak `getPublicUrls` and\n * `plan/api` D021 made structurally impossible for TOP-LEVEL media fields; the\n * block-tree path routed around it because the url stopped being a url and\n * became content.\n *\n * So the stored url is **discarded, never used as a fallback**. An id that does\n * not resolve becomes `null`, because \"did not resolve\" and \"is not public\" are\n * the same branch in the shipped resolver — a fallback would preserve the leak\n * exactly in the case that matters.\n *\n * ## 🔴 The fan-out property\n *\n * `resolveLinks`' own docblock hands the COUNT bound to whoever wires it: it\n * caps peak concurrency and deliberately does not cap how many links it\n * resolves. A naive wiring would give it a seam that hits the database per\n * distinct link — one publish check plus one path read each — on an anonymous\n * request path, which is verbatim what CLAUDE.md's no-unbounded-fan-out section\n * forbids. So this module resolves everything FIRST, batched by entity type\n * (two index reads per type, regardless of link count), and hands `resolveLinks`\n * a pure map lookup. What it costs is bounded by the number of distinct entity\n * TYPES a document links to, not by the number of links.\n *\n * ## Collections — rows, through the caller's own gate (editor PR53)\n *\n * A COLLECTION block's rows are resolved here too, into the envelope's additive\n * `collections` sidecar, through `@murumets-ee/blocks/server`'s\n * `resolveTreeCollections` — the ONE resolver the canvas calls as well. Three\n * properties make that safe on an anonymous path:\n *\n * - The reader is the CALLER, not an elevated identity: rows come through a\n * `QueryClient` bound to the ambient request context, and the provider may\n * only list an entity content-api's own whitelist ∧ `view` gate admits\n * (`canReadEntity`, handed in by the handler). Without that predicate, no\n * collection resolves at all — deny-by-default, never a guess.\n * - Only the card's mapped slots cross, never the row.\n * - It is bounded: at most `MAX_COLLECTIONS_PER_DOCUMENT` per tree, serial,\n * each claiming one slot of the host's per-RESPONSE collection allowance\n * (`claimCollection` — a list response resolves many trees) and one unit of\n * the response's resolution budget. No allowance, no collections.\n *\n * ## What it deliberately does not do\n *\n * Plain `query()` fields (the fixed-entity `articleList` / `relatedPosts`\n * shells) stay unresolved: nothing renders their rows, and the collection block\n * is the dynamic listing D001 asked for.\n */\n\nimport { collectLinkValues, type LinkValue, linkValueKey } from '@murumets-ee/blocks/links'\nimport {\n type CollectionCardImage,\n type CollectionResult,\n type CollectionSourceSpec,\n collectBoundColumnsInTree,\n createCollectionReader,\n effectiveCollectionSources,\n type FindManyClientLike,\n materializeServerTemplate,\n readSpotCrop,\n resolveLinks,\n resolveTreeCollections,\n type SpotCrop,\n sameSpotCrop,\n serverBlockStructureLookup,\n serverBoundColumnsInTree,\n serverCollectionFieldLookup,\n serverTemplateMaterializer,\n type TemplateNamespace,\n templateNamespace,\n} from '@murumets-ee/blocks/server'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\n\nimport { getCanonicalPaths } from './paths/enumerate.js'\nimport { buildEnumerateDeps } from './paths/resolve-context.js'\n\n// ---------------------------------------------------------------------------\n// Structural mirrors of `content-api`'s wire types\n// ---------------------------------------------------------------------------\n//\n// Mirrored rather than imported, for the reason every other provider in this\n// package mirrors its counterpart: `content-api` is the leaf, and the app that\n// wires the two is where TypeScript checks they agree.\n\n/** Structural mirror of `content-api`'s `MediaProjection`. */\nexport interface BlockMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n variants: Record<string, string>\n sources: Record<string, Array<{ url: string; width: number; height: number }>>\n avifSources?: Record<string, Array<{ url: string; width: number; height: number }>>\n focalPoint: { x: number; y: number }\n blurDataUrl: string | null\n /**\n * The files cut for each spot crop the photograph has files for (images\n * PR16) — present only when the resolver was ASKED (`{ spots: true }`) and\n * the photograph records some. Never embedded in a tree: {@link writeSlot}\n * picks the one list a placement's own `crop` names and drops the rest.\n */\n spotSources?: BlockSpotSources[]\n}\n\n/** One spot crop's files, as the media resolver publishes them. */\nexport interface BlockSpotSources {\n crop: { x: number; y: number; w: number; h: number }\n sources: Array<{ url: string; width: number; height: number }>\n avifSources?: Array<{ url: string; width: number; height: number }>\n}\n\n/**\n * Structural mirror of `content-api`'s `ResolveMedia`.\n *\n * The SAME function an app passes as `ContentApiConfig.resolveMedia` — pass one\n * value to both, so a media ref inside a block cannot resolve through a\n * different path (or a different publish gate) than a top-level media field.\n */\nexport type BlockMediaResolver = (\n ids: readonly string[],\n opts?: {\n locale?: string | undefined\n /**\n * Also publish the files cut for spot crops (images PR16). Asked by the\n * BLOCK walk only — a top-level media field has no placement crop, so its\n * projection stays exactly what it was.\n */\n spots?: boolean | undefined\n },\n) => Promise<Map<string, BlockMediaProjection>>\n\n/** Structural mirror of `content-api`'s `UnresolvedBlockRef`. */\nexport interface UnresolvedBlockRef {\n readonly kind: 'media' | 'link'\n readonly ref: string\n readonly reason: 'unresolvable' | 'budget'\n}\n\n/** Structural mirror of `content-api`'s `BlockTreeRef`. */\nexport interface BlockTreeRef {\n readonly entity: string\n readonly id: string\n}\n\n/** Structural mirror of `content-api`'s `ResolvedBlockTree`. */\nexport interface ResolvedBlockTree {\n readonly resolved: 1\n readonly blocks: readonly unknown[]\n readonly links: Readonly<Record<string, string | null>>\n readonly unresolved?: readonly UnresolvedBlockRef[]\n readonly refs?: readonly BlockTreeRef[]\n readonly collections?: Readonly<Record<string, CollectionResult>>\n}\n\n/**\n * What resolving COLLECTION blocks needs from the app — built once per app by\n * {@link createBlockResolver}, absent when the app declares no sources.\n */\nexport interface BlockCollectionDeps {\n readonly sources: readonly CollectionSourceSpec[]\n readonly sourceFieldOf: (blockType: string) => string | undefined\n readonly getEntity: (name: string) => Entity | undefined\n /** A PUBLISHED-only reader bound to the ambient request context — the caller. */\n readonly clientFor: (entity: Entity) => FindManyClientLike\n}\n\n/**\n * The slice of `content-api`'s `ResolutionBudget` this provider uses —\n * narrowed to `claim`, for the reason `ChromeResolutionBudget` states: a\n * provider asks for work and is told how much it may do; it has no business\n * reading the remaining count or the drop summary.\n */\nexport interface BlockResolutionBudget {\n claim: (kind: 'block', n: number) => number\n}\n\nexport interface BlockResolverLogger {\n debug: (obj: object, msg: string) => void\n warn: (obj: object, msg: string) => void\n}\n\n/** Everything the pure resolver needs, injected so it is testable without an app. */\nexport interface BlockResolverDeps {\n /** Public-only media resolution. Omit and every media ref resolves to `null`. */\n resolveMedia?: BlockMediaResolver | undefined\n /**\n * PUBLISHED-only canonical paths for many ids of one entity type, in one\n * locale, as `entityId → path`.\n *\n * **The publish gate is this function's responsibility and the whole reason\n * it is batched rather than per-id.** An unpublished target must yield no\n * entry, or a block link leaks an unpublished document's URL — the obligation\n * `ResolveBlocks`' own docblock puts on the wiring.\n */\n resolvePublishedPaths: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<Map<string, string>>\n logger?: BlockResolverLogger | undefined\n /** Collection resolution. Omit and collection blocks resolve no rows. */\n collections?: BlockCollectionDeps | undefined\n /**\n * Rebuild a CODED TEMPLATE and name the columns its blocks bind (editor\n * D017, frontend-delivery F109). Defaults to the server block registry's\n * `materializeServerTemplate`; injectable so a test need not seed a registry.\n *\n * `namespace` is the document the ids belong to (editor F124) — see\n * `resolveBlockTree`, which derives it from the entity and the row's id.\n */\n materializeTemplate?:\n | ((\n template: string,\n namespace: TemplateNamespace,\n ) => {\n readonly blocks: unknown[]\n readonly boundColumns: readonly string[]\n })\n | undefined\n /**\n * The columns the blocks in an ORDINARY stored tree bind. Defaults to the\n * server registry's `serverBoundColumnsInTree`; injectable for the same reason\n * as {@link materializeTemplate}.\n */\n boundColumnsOf?: ((tree: readonly unknown[]) => readonly string[]) | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Media refs inside a tree\n// ---------------------------------------------------------------------------\n\n/**\n * How deep the media walk descends, in JSON levels.\n *\n * A stored tree is authenticated-editor JSON, not anonymous input — but it is\n * still data crossing a process boundary, and an unbounded recursion over it is\n * a stack overflow waiting for one pathological document, which under on-demand\n * rendering is a 500 on a live page for every visitor.\n *\n * 🔴 It counts JSON levels, not BLOCK levels, and the two are not the same: one\n * block of nesting costs about three (`block → children → block`, plus one for\n * `props`). This was 64, matching `collectLinkValues`, which sounded principled\n * and was wrong — `op-commit` permitted 64 BLOCK levels then, so a media ref\n * below roughly the 21st block was never visited, its stored url crossed\n * unchanged, and nothing said so. A bound that silently stops enforcing a\n * security control is worse than no bound, because it reads as coverage.\n *\n * 256 covered those 64 block levels with room to spare, and now covers\n * `MAX_BLOCK_DEPTH` (32, the one reconciled persist-and-render cap) with twice\n * that. Left at 256 rather than retuned: the headroom costs nothing, and a\n * bound that only just fits is the shape this comment exists to warn about.\n * Exceeding it is REPORTED rather than returned in silence. `collectLinkValues`\n * keeps its own 64 — that one bounds a display concern (an unresolved link\n * renders as plain text), not a value that must not reach the wire.\n */\nconst MAX_WALK_DEPTH = 256\n\n/**\n * How deep LINK collection descends — `collectLinkValues`' own default, stated\n * here so the two bounds are visibly independent rather than accidentally\n * shared. An unresolved link renders as plain text; an unvisited media ref\n * keeps a url that may be a capability. Different stakes, different bounds.\n *\n * ⚠️ Unlike the media walk, this one truncates SILENTLY: `collectLinkValues`\n * returns what it found and reports nothing, so a link below the bound is\n * absent from the sidecar, absent from `unresolved`, and renders as plain text\n * with no signal anywhere. That is tolerable only because the failure is\n * cosmetic — and it is stated rather than left to be discovered, because the\n * media walk one constant up made exactly the opposite call for exactly the\n * opposite reason.\n */\nconst LINK_WALK_DEPTH = 64\n\n/**\n * Entity ids are UUIDs (user ids are not, but no user id is ever a media ref).\n * Requiring the shape here is what makes structural detection safe: a prop\n * object carrying BOTH a uuid `id` AND a non-empty `url` string, with every\n * optional `MediaRef` field well-typed, is a media ref by any usable\n * definition.\n */\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * `Array.isArray(x)` on an `unknown` narrows to `any[]`, which would put an\n * `any` into every element of a walk over untyped JSON — in a package holding a\n * type-coverage floor. Narrowing through an explicit predicate keeps elements\n * `unknown`.\n */\nfunction isUnknownArray(value: unknown): value is unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * Every key a `MediaRef` or a `MediaProjection` may carry — and NOTHING ELSE\n * may be present for a value to count as one.\n *\n * 🔴 This is a WHITELIST, and the difference is not stylistic. Checking only\n * that the known keys are well-typed made the detector match any object with a\n * uuid `id` and a `url` — a project's `{ id, url, provider: 'youtube' }` prop,\n * say. That object would then be REPLACED by `null` (no media row for its id),\n * the consumer-side validator would drop the whole prop, and the block would\n * render empty with an `unresolved` entry calling it media. Silent data loss,\n * misdescribed in the one channel meant to report it.\n *\n * An unrecognised key means \"this is not a media ref\", so the failure direction\n * is leaving a value alone rather than deleting it.\n */\nconst MEDIA_REF_KEY_MAP: Record<keyof BlockMediaProjection | 'crop', true> = {\n id: true,\n url: true,\n mimeType: true,\n width: true,\n height: true,\n alt: true,\n title: true,\n variants: true,\n // A PROJECTION key, like `variants` and `focalPoint` — listed so a projection\n // is still recognised on a second pass. The STORED `MediaRef` never carries it\n // (images D007), so no stored ref changes shape.\n sources: true,\n // Projection-only too (images PR12), and absent unless AVIF is on.\n avifSources: true,\n focalPoint: true,\n // Projection-only as well (images PR11) — the stored ref never carries it.\n blurDataUrl: true,\n // A STORED key (images PR16, D002): the one crop a placement carries — author\n // data about the spot, validated at every write. Listed in the same commit\n // that let the ref carry it: an unrecognised key makes the detector walk\n // past the ref, and its stored url would cross unresolved (CLAUDE.md,\n // Images fact 2). Kept on the embedded value, so a second pass still sees it.\n crop: true,\n // Projection-only, and never embedded — `writeSlot` consumes it. Listed\n // because the map is tied to the projection's keys by type.\n spotSources: true,\n}\n\n/**\n * Derived from the map above rather than written out beside it, so ADDING a\n * field to the projection is a COMPILE error until this list learns about it.\n *\n * That tie is the whole reason the map exists. A bare literal set would have\n * made the whitelist fail-OPEN and silent: give `MediaRef` a `caption` (which\n * the image block already renders, so it is a plausible next change) and every\n * captioned ref quietly stops being recognised — not collected, not\n * re-resolved, not nulled, and not reported — so its stored url crosses\n * verbatim. That is the same \"a bound that silently stops enforcing a security\n * control reads as coverage\" this file says about {@link MAX_WALK_DEPTH}, and\n * it would have been true one field later.\n */\nconst MEDIA_REF_KEYS = new Set(Object.keys(MEDIA_REF_KEY_MAP))\n\n/**\n * The media id in `value`, or `null` when it is not a media ref.\n *\n * Structural, deliberately — the same technique `asLinkValue` uses for links,\n * and for the same reason: a resolver that needed the block REGISTRY to know\n * which props are media would need every project to register its vocabulary at\n * the content-api mount, which is a second place to forget. Detection that\n * reads the value cannot go stale.\n *\n * Optional fields accept `null` as well as `undefined` so the check is\n * idempotent: a `MediaProjection` (whose optionals are `T | null`) is still\n * recognised, rather than being silently skipped on a second pass.\n */\nfunction mediaRefId(value: unknown, onNearMiss?: (keys: readonly string[]) => void): string | null {\n if (!isRecord(value)) return null\n const id = value.id\n if (typeof id !== 'string' || !UUID_RE.test(id)) return null\n if (typeof value.url !== 'string' || value.url === '') return null\n // The two cheap discriminators are checked FIRST, so the key scan runs only\n // for the handful of values that already look like a ref rather than for\n // every object in the tree.\n const unknownKeys = Object.keys(value).filter((key) => !MEDIA_REF_KEYS.has(key))\n if (unknownKeys.length > 0) {\n // 🔴 REPORTED, not silently declined. A value with a uuid `id` and a `url`\n // that this detector walks past is either a project prop we correctly left\n // alone or a media ref we failed to recognise — and the second is a stored\n // url reaching the wire with nothing anywhere saying so. The caller cannot\n // tell the two apart, and neither can this function; what it can do is\n // refuse to be quiet about the ambiguity.\n onNearMiss?.(unknownKeys)\n return null\n }\n if (!isOptional(value.alt, 'string')) return null\n if (!isOptional(value.title, 'string')) return null\n if (!isOptional(value.mimeType, 'string')) return null\n if (!isOptional(value.width, 'number')) return null\n if (!isOptional(value.height, 'number')) return null\n // Projection-only, like `focalPoint`: a string when present, so a project\n // prop holding something else there is left alone rather than claimed.\n if (!isOptional(value.blurDataUrl, 'string')) return null\n // `variants` is the one whitelisted key the type checks above do not cover,\n // and skipping it left an over-match: `{ id: <uuid>, url: '…', variants: 'x' }`\n // passed the whitelist and was replaced by a projection or by `null`. That is\n // precisely the direction the whitelist exists to prevent.\n if (value.variants !== undefined && value.variants !== null && !isRecord(value.variants)) {\n return null\n }\n // Same reasoning for `focalPoint`, which only a PROJECTION carries (a stored\n // ref never does — images D007): recognised on a second pass, never an\n // over-match on a project prop that happens to hold a non-object there.\n if (value.focalPoint !== undefined && value.focalPoint !== null && !isRecord(value.focalPoint)) {\n return null\n }\n // And `sources`, for the same reason — projection-only, and when present an\n // object of ARRAYS (shape name → files). Checked one level deep, because a\n // bare \"is an object\" let `{ id: <uuid>, url, sources: { natural: 'x' } }`,\n // a project's own prop, be claimed as media and replaced (CodeRabbit).\n if (value.sources !== undefined && value.sources !== null) {\n if (!isRecord(value.sources)) return null\n if (!Object.values(value.sources).every((list) => Array.isArray(list))) return null\n }\n // `avifSources` has `sources`' shape and the same over-match risk.\n if (value.avifSources !== undefined && value.avifSources !== null) {\n if (!isRecord(value.avifSources)) return null\n if (!Object.values(value.avifSources).every((list) => Array.isArray(list))) return null\n }\n // A spot crop is an object. A project prop holding something else under\n // `crop` is left alone rather than claimed. An object that is not a VALID\n // crop is still a media ref — the write gate refuses those, and a stored one\n // is drawn around the focal point (`readSpotCrop` is lenient) rather than\n // being left unresolved with its stored url.\n if (value.crop !== undefined && value.crop !== null && !isRecord(value.crop)) return null\n if (value.spotSources !== undefined && value.spotSources !== null) {\n if (!Array.isArray(value.spotSources)) return null\n }\n return id\n}\n\nfunction isOptional(value: unknown, type: 'string' | 'number'): boolean {\n return value === undefined || value === null || typeof value === type\n}\n\n/** One place in the tree holding a media ref, so it can be overwritten after resolution. */\ninterface MediaSlot {\n readonly container: Record<string, unknown> | unknown[]\n readonly key: string | number\n readonly id: string\n /** The placement's own crop (images PR16), read when the slot was found. */\n readonly crop?: SpotCrop | undefined\n}\n\n/**\n * Collect every media ref in the tree, recording WHERE it sits.\n *\n * Slots rather than a returned copy because the replacement is in place, which\n * is the same shape `projectMedia` uses on entity rows for the same reason: the\n * tree came from a per-request read and copying it would double the cost of the\n * largest thing on the page.\n */\n/** What the walk noticed but could not act on. Both are ambiguity, not failure. */\ninterface WalkReport {\n /** A subtree below {@link MAX_WALK_DEPTH} was never visited. */\n truncated: boolean\n /** Keys that made a ref-shaped value fall outside the whitelist, deduped. */\n nearMissKeys: Set<string>\n}\n\nfunction collectMediaSlots(\n node: unknown,\n out: MediaSlot[],\n depth: number,\n report: WalkReport,\n): void {\n // Flagged, never silent — and flagged rather than logged, so a pathological\n // tree cannot turn the operator log into a hot loop. Below the bound a media\n // ref is not visited, so its stored url would cross unchanged.\n if (depth > MAX_WALK_DEPTH) {\n report.truncated = true\n return\n }\n const noteNearMiss = (keys: readonly string[]): void => {\n for (const key of keys) report.nearMissKeys.add(key)\n }\n if (isUnknownArray(node)) {\n for (let index = 0; index < node.length; index += 1) {\n const id = mediaRefId(node[index], noteNearMiss)\n if (id !== null)\n out.push({ container: node, key: index, id, crop: readSpotCrop(node[index]) })\n else collectMediaSlots(node[index], out, depth + 1, report)\n }\n return\n }\n if (!isRecord(node)) return\n for (const [key, value] of Object.entries(node)) {\n const id = mediaRefId(value, noteNearMiss)\n if (id !== null) out.push({ container: node, key, id, crop: readSpotCrop(value) })\n else collectMediaSlots(value, out, depth + 1, report)\n }\n}\n\n/**\n * The projection as it goes INTO a block tree: null-valued optionals OMITTED.\n *\n * 🔴 Found by rendering a real page in a browser, not by a unit test — the\n * tests all used a projection whose `width`/`height`/`alt` happened to be\n * populated. `MediaProjection` declares its optionals `T | null` because a\n * top-level media field publishes a fixed key set. `MediaRef` — the type every\n * block render is typed against, and the one the consumer-side validator\n * narrows to — declares them `T | undefined`, so a JSON `null` there is not a\n * value any render can hold. The front-end validator did exactly what it should\n * with one: dropped it, and logged \"block props were dropped as unusable\" for\n * `image.width`, `image.height` and `image.alt` on EVERY render of every page\n * carrying an image. Correct behaviour, useless signal — the log that exists to\n * report real data loss would have been drowned by it from day one.\n *\n * Absent and null mean the same thing for every one of these fields, so\n * omitting is lossless. What it buys is a value that is assignable to the type\n * the renders declare, which is the whole reason `MediaRef` carries a url at\n * all.\n */\nfunction toEmbeddedProjection(projection: BlockMediaProjection): Record<string, unknown> {\n const out: Record<string, unknown> = {\n id: projection.id,\n url: projection.url,\n variants: projection.variants,\n // Always embedded, like `variants`: the render reads it to crop a shaped\n // slot, and a projection always carries one (`{0.5, 0.5}` when the row has\n // none), so there is no null to omit.\n focalPoint: projection.focalPoint,\n // Always embedded too: an empty map is \"nothing generated yet\", which a\n // render reads as \"no srcset\" — there is no null to omit.\n sources: projection.sources,\n }\n // Only when there are some (images PR12): the tree is serialised into the\n // page, so an empty key would change every page of a site with AVIF off.\n if (projection.avifSources !== undefined && Object.keys(projection.avifSources).length > 0) {\n out.avifSources = projection.avifSources\n }\n if (projection.mimeType !== null) out.mimeType = projection.mimeType\n if (projection.width !== null) out.width = projection.width\n if (projection.height !== null) out.height = projection.height\n if (projection.alt !== null) out.alt = projection.alt\n if (projection.title !== null) out.title = projection.title\n // Omitted when there is none, like the other optionals: most photographs on\n // a site that predates images PR11 have none until the catch-up reaches them.\n if (projection.blurDataUrl !== null) out.blurDataUrl = projection.blurDataUrl\n return out\n}\n\n/**\n * The files cut for THIS placement's crop, or `undefined` when the worker has\n * not cut them yet (or the placement has no crop). Matched on the four-decimal\n * grid (`sameSpotCrop`).\n */\nfunction spotSourcesFor(\n projection: BlockMediaProjection,\n crop: SpotCrop | undefined,\n): BlockSpotSources | undefined {\n if (crop === undefined || projection.spotSources === undefined) return undefined\n return projection.spotSources.find(\n (spot) => sameSpotCrop(spot.crop, crop) && spot.sources.length > 0,\n )\n}\n\n/**\n * The embedded value for a placement with a spot crop (images PR16, D002).\n *\n * - **Its files exist** — the placement's value becomes THOSE files: `sources`\n * holds the crop's list under `natural` (the crop IS this placement's\n * picture, at the crop's own ratio), `url`/`width`/`height` are the largest\n * file's, and the focal point is the centre (the file is already the crop).\n * So a render selects from the crop's files alone — the original is never\n * appended above them, because it is no longer larger than the value's own\n * width — and CSS has nothing left to crop. `avifSources` follows only when\n * the crop's AVIF files are exactly its WebP list, re-encoded.\n * - **They do not exist yet** — the projection as any placement gets it: the\n * focal-point files, never the original (D002). The crop is kept on the\n * value either way, so a second pass sees the same placement.\n *\n * A placement with NO crop never reaches this function, so its embedded value\n * — and the markup a render makes of it — is byte-identical to before.\n */\nfunction embedCropped(projection: BlockMediaProjection, crop: SpotCrop): Record<string, unknown> {\n const out = toEmbeddedProjection(projection)\n const spot = spotSourcesFor(projection, crop)\n const largest = spot?.sources[spot.sources.length - 1]\n if (spot !== undefined && largest !== undefined) {\n out.url = largest.url\n out.width = largest.width\n out.height = largest.height\n out.focalPoint = { x: 0.5, y: 0.5 }\n out.sources = { natural: spot.sources }\n const avif = spot.avifSources\n const avifMatches =\n avif !== undefined &&\n avif.length === spot.sources.length &&\n avif.every(\n (file, i) =>\n file.width === spot.sources[i]?.width && file.height === spot.sources[i]?.height,\n )\n if (avifMatches) out.avifSources = { natural: avif }\n else delete out.avifSources\n }\n out.crop = { x: crop.x, y: crop.y, w: crop.w, h: crop.h }\n return out\n}\n\nfunction writeSlot(slot: MediaSlot, value: BlockMediaProjection | null): void {\n const written =\n value === null\n ? null\n : slot.crop === undefined\n ? toEmbeddedProjection(value)\n : embedCropped(value, slot.crop)\n if (Array.isArray(slot.container)) {\n if (typeof slot.key === 'number') slot.container[slot.key] = written\n return\n }\n if (typeof slot.key === 'string') slot.container[slot.key] = written\n}\n\n// ---------------------------------------------------------------------------\n// The resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve one stored block tree into a {@link ResolvedBlockTree}.\n *\n * **Throws when `blocks` is not an array**, which is how a corrupt stored value\n * is handled rather than papered over — a resolver that returned `{ resolved: 1,\n * blocks: [] }` for it would report success while deleting a page's content,\n * and the caller could not tell the two apart.\n *\n * ⚠️ **What the caller does with that throw CHANGED** (editor D010, closing\n * F056). It used to serve the stored tree unchanged, so a malformed row cost\n * its own hrefs and nothing else. It no longer does: it re-calls this function\n * with an exhausted budget and, if that fails too, serves an empty envelope. A\n * non-array `blocks` fails this guard DETERMINISTICALLY, so a corrupt column is\n * now precisely the case that reaches that third state and is blanked. That is\n * the intended outcome rather than a regression — a non-array stored value\n * cannot be rendered by any consumer, and it may still be an object carrying\n * media refs whose urls were signed at author time.\n */\nexport async function resolveBlockTree(\n blocks: unknown,\n opts: {\n locale: string\n budget: BlockResolutionBudget\n entity: string\n field: string\n /** content-api's whitelist ∧ `view` gate for THIS caller. Absent → no collection rows. */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /** One slot of the response's collection allowance. Absent → no collection rows. */\n claimCollection?: (() => boolean) | undefined\n /** A CODED TEMPLATE's root block name — `blocks` is then ignored and the template rebuilt. */\n template?: string | undefined\n /** The row a templated document's bound blocks read. Read, never written. */\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n deps: BlockResolverDeps,\n): Promise<ResolvedBlockTree> {\n // A CODED TEMPLATE has no stored tree (editor D017): rebuild it from the\n // registry, and remember which COLUMNS its blocks bind — a bound rich-text\n // column's link marks live in the row, and they are resolved below in the\n // same batch as the tree's own (F109). Only the columns THIS template binds\n // are read, so a same-named column bound by some other block is never walked.\n //\n // An ORDINARY tree can carry bound blocks too — `documentBody` dropped into a\n // composable page renders that page's column — so when the caller hands over\n // the row, its bound columns are walked the same way. Without that the canvas\n // (which composes bound values into any tree) resolved links the site then\n // rendered as plain text.\n let tree: unknown = blocks\n let boundColumns: readonly string[] = []\n if (opts.template !== undefined) {\n // 🔴 The ids belong to THIS document (editor F124): namespaced by the entity\n // and the row's own id, exactly as the editor and the bound-field route\n // namespace them, so the delivered tree carries the ids the canvas edits.\n //\n // The id is read off the row because the row is what the caller hands over\n // — content-api always projects `id` (`query-params.ts`, \"Always include\n // id\"), so a templated field never arrives without one. If it somehow did,\n // the template is served EMPTY rather than with ids that name no document:\n // an empty body is a visible fault, a shared id space is the defect F124\n // closed.\n const documentId = opts.boundValues?.id\n if (typeof documentId === 'string' && documentId !== '') {\n const built = (deps.materializeTemplate ?? materializeServerTemplate)(\n opts.template,\n templateNamespace(opts.entity, documentId),\n )\n tree = built.blocks\n boundColumns = built.boundColumns\n } else {\n tree = []\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: a templated document arrived without its row id, so its template is served ' +\n 'EMPTY — its block ids could not be namespaced to it (editor F124)',\n )\n }\n } else if (opts.boundValues !== undefined && isUnknownArray(blocks)) {\n // Degrades to \"no bound columns\" rather than failing the tree: bound-column\n // links are an enhancement, and a throw here would otherwise blank the whole\n // document (the zero-budget retry would throw identically).\n try {\n boundColumns = (deps.boundColumnsOf ?? serverBoundColumnsInTree)(blocks)\n } catch (err) {\n deps.logger?.warn(\n { err, entity: opts.entity, field: opts.field },\n 'content: could not enumerate the bound columns of a block tree, so its bound-field links render plain',\n )\n }\n }\n const row = opts.boundValues ?? {}\n const boundSources = boundColumns\n .filter((column) => Object.hasOwn(row, column))\n .map((column) => row[column])\n\n if (!isUnknownArray(tree)) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: stored block tree is not an array — nothing to resolve, so this field is ' +\n 'served EMPTY rather than as stored (the caller retries this throw with an exhausted ' +\n 'budget, and a non-array fails that identically)',\n )\n throw new TypeError(\n `content: block tree \\`${opts.entity}.${opts.field}\\` is not an array; nothing to resolve.`,\n )\n }\n\n const slots: MediaSlot[] = []\n const report: WalkReport = { truncated: false, nearMissKeys: new Set() }\n collectMediaSlots(tree, slots, 0, report)\n // ONE line per call at most, whatever the tree looks like — a per-node log on\n // an anonymous request path is the amplification this file bounds everywhere\n // else. Both are hedged (\"any\", \"may\") because the walk cannot know whether\n // the thing it declined to visit was a media ref at all; what it knows is\n // that it did not look.\n if (report.truncated) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field, cap: MAX_WALK_DEPTH },\n 'content: block tree is nested deeper than the media walk descends — any media ref below ' +\n 'the bound keeps the url recorded when an editor picked it, which may be signed',\n )\n }\n if (report.nearMissKeys.size > 0) {\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, keys: [...report.nearMissKeys].sort() },\n 'content: a value looked like a media ref but carried keys outside the projection, so it ' +\n 'was left alone. If these are media refs, they keep their stored urls',\n )\n }\n // Its OWN bound, not this module's. Link resolution is a display concern — an\n // unresolved link renders as plain text — while the media walk is a security\n // control, which is why only one of the two was raised. Passing\n // `MAX_WALK_DEPTH` here would have silently quadrupled link collection depth\n // as a side effect of a change that had nothing to do with links.\n //\n // The bound columns' links join the SAME list, so they share every step below\n // — one media batch, one published-path lookup per linked entity type, one\n // pair of claims against the response's budget. That is F109's fan-out bound:\n // links are resolved per DOCUMENT, never one request per link and never one\n // batch per column.\n const links = [\n ...collectLinkValues(tree, LINK_WALK_DEPTH),\n ...collectLinkValues(boundSources, LINK_WALK_DEPTH),\n ]\n\n // Media LINKS resolve to a media url, so they join the same batch — one\n // lookup covers a `{kind:'media'}` link and an image of the same asset.\n //\n // 🔴 FILTERED to the uuid shape, and the filter is load-bearing rather than\n // defensive. `mediaRefId` already requires a uuid, but `asLinkValue` accepts\n // ANY non-empty string as a `{kind:'media'}` id — so one legacy or\n // hand-authored link with `id: 'x'` reached `inArray(idColumn, ids)` and\n // Postgres raised `22P02 invalid input syntax for type uuid`. The batch's\n // catch then returned an EMPTY map, and every slot on the page was written\n // `null`: one malformed link deleted every image in the tree. Rejects are\n // reported as `unresolvable` rather than dropped, because a link that can\n // never resolve is exactly what that channel is for.\n const wellFormedMediaLinkIds: string[] = []\n const malformedRefs: UnresolvedBlockRef[] = []\n for (const link of links) {\n if (link.kind !== 'media') continue\n if (UUID_RE.test(link.id)) wellFormedMediaLinkIds.push(link.id)\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n const mediaIds = distinct([...slots.map((slot) => slot.id), ...wellFormedMediaLinkIds])\n\n const entityTargets: string[] = []\n for (const link of links) {\n if (link.kind !== 'entity') continue\n // Same reason: `filterPublished` issues one `inArray` per entity type, so a\n // single non-uuid id throws and takes every link of that type with it.\n if (UUID_RE.test(link.id)) entityTargets.push(targetKey(link.entity, link.id))\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n\n const unresolved: UnresolvedBlockRef[] = [...malformedRefs]\n\n // TWO claims against the ONE shared counter, media first.\n //\n // Two rather than one so each pass learns its own grant without splitting a\n // combined number, and media first because a vanished image is the more\n // visible failure — when a page is over budget, the deterministic outcome is\n // \"images render, some links go plain\" rather than whichever the scheduler\n // reached first. Both record their shortfall under the same `block` kind, so\n // the operator log is unchanged.\n //\n // Nothing is claimed for media when no resolver is wired: the budget reports\n // work the server DECLINED, and there is no work to decline. Claiming anyway\n // burned the whole ceiling on a pass that did no I/O and then reported every\n // LINK as `budget` — a drop log naming the wrong cause.\n if (!deps.resolveMedia && mediaIds.length > 0) {\n // The signal for \"this deployment cannot resolve media at all\", emitted\n // HERE because this is now the only place that can tell. `resolveMediaBatch`\n // used to carry it, and the no-claim rule above made its branch\n // unreachable — the ids never reach it. It is the likeliest cause of \"every\n // image on the site is gone\", and the `unresolvable` the envelope reports\n // deliberately does not distinguish causes, so the operator log is the only\n // place it can be said.\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, count: mediaIds.length },\n 'content: no media resolver wired for block trees — every media ref resolves to null',\n )\n }\n\n const mediaGranted = deps.resolveMedia ? opts.budget.claim('block', mediaIds.length) : 0\n const coveredMedia = mediaIds.slice(0, mediaGranted)\n // 🔴 EMPTY when no resolver is wired, and that is not the same set as\n // \"everything\". `budgetDroppedMedia` means \"a ceiling refused this\", and it\n // is read again below by `unresolvedLinkReason` — so filling it on the\n // no-resolver path reported every media LINK as `reason: 'budget'` and sent\n // an operator to raise a limit that was never involved. The reason was fixed\n // for the media entries and missed for the links; one set, read twice, is\n // what keeps them consistent.\n const budgetDroppedMedia = deps.resolveMedia\n ? new Set(mediaIds.slice(mediaGranted))\n : new Set<string>()\n for (const id of mediaIds.slice(mediaGranted)) {\n unresolved.push({\n kind: 'media',\n ref: id,\n reason: budgetDroppedMedia.has(id) ? 'budget' : 'unresolvable',\n })\n }\n\n const distinctTargets = distinct(entityTargets)\n const linkGranted = opts.budget.claim('block', distinctTargets.length)\n const coveredTargets = distinctTargets.slice(0, linkGranted)\n const budgetDroppedTargets = new Set(distinctTargets.slice(linkGranted))\n\n const media = await resolveMediaBatch(coveredMedia, opts.locale, deps, opts.entity)\n\n // Every slot is written, including the ones that did not resolve. Leaving a\n // stored ref in place for an id the resolver refused is the leak this module\n // exists to close (see the file header) — \"not public\" and \"not found\" are\n // the same branch downstream, so the ONE case where a fallback would matter\n // is exactly the case where it must not happen.\n for (const slot of slots) {\n const projection = media.get(slot.id) ?? null\n writeSlot(slot, projection)\n }\n for (const id of coveredMedia) {\n if (!media.has(id)) unresolved.push({ kind: 'media', ref: id, reason: 'unresolvable' })\n }\n\n const paths = await resolveEntityPaths(coveredTargets, opts.locale, deps, opts.entity)\n\n // `resolveLinks` still owns dedup, the `sanitizeHref` protocol gate on\n // resolved hrefs, anchor composition and the inline url/email cases. What it\n // is handed here are PURE MAP LOOKUPS, so its `runBounded` fan-out does no\n // I/O at all — the batching above already happened.\n const resolvedLinks = await resolveLinks(links, {\n renderContext: 'request',\n locale: opts.locale,\n resolveEntityHref: ({ entity, id }) => paths.get(targetKey(entity, id)) ?? null,\n resolveMediaHref: (id) => media.get(id)?.url ?? null,\n })\n\n const linkRecord: Record<string, string | null> = {}\n for (const [key, resolved] of resolvedLinks) linkRecord[key] = resolved.href\n // Only `entity`/`media` links are REPORTED as unresolved. A `url` or `email`\n // link with a null href was refused by `sanitizeHref` — an editor typed\n // `javascript:` — which is a content problem the security gate handled, not a\n // resolution this response failed to perform, and listing it would invite a\n // consumer to retry it.\n for (const link of links) {\n if (link.kind !== 'entity' && link.kind !== 'media') continue\n const key = linkValueKey(link)\n if (linkRecord[key] !== null && linkRecord[key] !== undefined) continue\n unresolved.push({\n kind: 'link',\n ref: key,\n reason: unresolvedLinkReason(link, budgetDroppedTargets, budgetDroppedMedia),\n })\n }\n\n // What this tree DEPENDS ON, for whatever cache is holding the response.\n //\n // 🔒 Built from what actually RESOLVED, not from what was asked for. `media`\n // holds only ids the public resolver returned, and `paths` only targets the\n // published-path lookup answered — so an unpublished link target contributes\n // no ref, and its id is not disclosed. That is the same gate the hrefs above\n // pass through, applied to identity; the refusal is already reported through\n // `unresolved`, which carries a `linkValueKey` the consumer already holds.\n //\n // Media BEFORE links, and both deduped by the Map: an image embedded as a\n // `field.media()` and linked as `{kind:'media'}` is one row and one tag.\n const { results: collections, cardMedia } = await resolveCollectionsIn(tree, opts, deps)\n\n const refByKey = new Map<string, BlockTreeRef>()\n for (const id of media.keys()) refByKey.set(targetKey('media', id), { entity: 'media', id })\n for (const id of cardMedia) refByKey.set(targetKey('media', id), { entity: 'media', id })\n // A listed row is a row this tree depends on, and it passed the caller's gate\n // to be listed — the same rule the links below follow.\n for (const result of collections.values()) {\n if (result.entity === null) continue\n for (const row of result.rows) {\n refByKey.set(targetKey(result.entity, row.id), { entity: result.entity, id: row.id })\n }\n }\n for (const key of paths.keys()) {\n if (refByKey.has(key)) continue\n const split = key.indexOf(' ')\n if (split <= 0) continue\n const entity = key.slice(0, split)\n const id = key.slice(split + 1)\n if (id === '') continue\n refByKey.set(key, { entity, id })\n }\n\n return {\n resolved: 1,\n blocks: tree,\n links: linkRecord,\n // OMITTED when empty, never `[]`. Its presence is itself information —\n // Contentful's rule for `errors[]`, and the property that stops \"nothing\n // failed\" from looking like \"nothing was attempted\".\n ...(unresolved.length > 0 && { unresolved: dedupeRefs(unresolved) }),\n // Same rule, same reason: a tree that references nothing says so by\n // omitting the field rather than by shipping `[]`.\n ...(refByKey.size > 0 && { refs: [...refByKey.values()] }),\n // And again: a tree with no collection block carries no sidecar.\n ...(collections.size > 0 && { collections: Object.fromEntries(collections) }),\n }\n}\n\n/**\n * Resolve every collection block in `blocks` through the shared resolver.\n *\n * DENY-BY-DEFAULT at three levels: an app with no declared sources has no\n * `deps.collections`, and a call without `canReadEntity` — a host that did not\n * say who may read what — or without `claimCollection` — a host that did not\n * say how many — resolves nothing. Either way the blocks render empty on the\n * site, which is the safe direction.\n *\n * Images go through the SAME public media resolver the tree's own media refs\n * do, and hrefs through the same published-path lookup its links do, so a card\n * cannot show an asset or a URL the rest of the page could not.\n */\nasync function resolveCollectionsIn(\n blocks: readonly unknown[],\n opts: {\n locale: string\n budget: BlockResolutionBudget\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n },\n deps: BlockResolverDeps,\n): Promise<{\n readonly results: ReadonlyMap<string, CollectionResult>\n readonly cardMedia: ReadonlySet<string>\n}> {\n const collections = deps.collections\n const canRead = opts.canReadEntity\n const claimCollection = opts.claimCollection\n if (collections === undefined || canRead === undefined || claimCollection === undefined) {\n return { results: new Map(), cardMedia: new Set() }\n }\n const resolveMedia = deps.resolveMedia\n // Every media id a card will draw, so the tree names it in `refs` like its\n // own media: a replaced image or an edited alt must evict the cached page\n // (CodeRabbit review, PR53).\n const cardMedia = new Set<string>()\n const results = await resolveTreeCollections(blocks, collections.sourceFieldOf, {\n sources: collections.sources,\n getEntity: collections.getEntity,\n clientFor: collections.clientFor,\n canRead,\n resolveImages:\n resolveMedia === undefined\n ? undefined\n : async (ids): Promise<ReadonlyMap<string, CollectionCardImage>> => {\n const found = await resolveMedia(ids, { locale: opts.locale })\n // A card image must BE an image. A media field's `kind: 'image'`\n // is only a picker filter (editor F116), so a video can sit in\n // one — and the card would draw it as a broken `<img>`.\n const shown = [...found].filter(\n ([, projection]) => projection.mimeType?.startsWith('image/') === true,\n )\n for (const [id] of shown) cardMedia.add(id)\n // All seven keys a card needs — beyond the four: where to crop\n // around and which generated files to offer (images PR07, F021),\n // and the preview it shows while it loads (PR11).\n // `resolveCollectionSource` normalises what this returns\n // (`cardImageOf`: a centre point, an empty `sources` and a missing\n // preview are left out), the SAME step the canvas's injected resolver goes through,\n // so a card carries the same keys on both surfaces.\n return new Map(\n shown.map(([id, projection]) => [\n id,\n {\n url: projection.url,\n alt: projection.alt,\n width: projection.width,\n height: projection.height,\n focalPoint: projection.focalPoint,\n sources: projection.sources,\n // Non-empty only, the rule the embedded projection follows\n // (`cardImageOf` would drop `{}` too; one rule, both paths).\n ...(projection.avifSources !== undefined &&\n Object.keys(projection.avifSources).length > 0 && {\n avifSources: projection.avifSources,\n }),\n blurDataUrl: projection.blurDataUrl,\n },\n ]),\n )\n },\n resolveHrefs: (entityType, ids) => deps.resolvePublishedPaths(entityType, ids, opts.locale),\n locale: opts.locale,\n renderContext: 'request',\n // A slot of the response's collection allowance, THEN one unit of its ONE\n // budget — so a page whose references and media spent the ceiling lists\n // nothing rather than exceeding it, and a list response stops at the\n // allowance however many rows it carries. Allowance first: once it is\n // spent, a refused collection must not also burn budget the rest of the\n // response's media and links need.\n claim: () => claimCollection() && opts.budget.claim('block', 1) >= 1,\n logger: deps.logger,\n })\n return { results, cardMedia }\n}\n\nfunction unresolvedLinkReason(\n link: LinkValue,\n budgetDroppedTargets: ReadonlySet<string>,\n budgetDroppedMedia: ReadonlySet<string>,\n): 'unresolvable' | 'budget' {\n if (link.kind === 'entity' && budgetDroppedTargets.has(targetKey(link.entity, link.id))) {\n return 'budget'\n }\n if (link.kind === 'media' && budgetDroppedMedia.has(link.id)) return 'budget'\n return 'unresolvable'\n}\n\nfunction distinct(values: readonly string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * `(entityType, id)` as one map key. A space is unambiguous as a separator\n * because entity names are identifiers — `defineEntity` rejects anything else —\n * so no name can contain one.\n */\nfunction targetKey(entityType: string, id: string): string {\n return [entityType, id].join(' ')\n}\n\n/**\n * One entry per `(kind, ref)`.\n *\n * What this actually collapses is the SAME LinkValue appearing several times —\n * `collectLinkValues` does not dedupe, so a page linking twice to one target\n * would otherwise report it twice. It deliberately does NOT collapse an image\n * and a media LINK to the same asset: those are `media <id>` and\n * `link media:<id>`, two different failures a consumer may want to act on\n * differently, and the suite asserts exactly that.\n */\nfunction dedupeRefs(refs: readonly UnresolvedBlockRef[]): UnresolvedBlockRef[] {\n const seen = new Set<string>()\n const out: UnresolvedBlockRef[] = []\n for (const ref of refs) {\n const key = `${ref.kind} ${ref.ref}`\n if (seen.has(key)) continue\n seen.add(key)\n out.push(ref)\n }\n return out\n}\n\nasync function resolveMediaBatch(\n ids: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, BlockMediaProjection>> {\n // No log for the unwired case HERE: the caller does not claim, so `ids` is\n // always empty on that path and a branch guarding on it could never fire.\n // The signal lives at the call site, which is the only place that still knows.\n if (ids.length === 0 || !deps.resolveMedia) return new Map()\n try {\n return await deps.resolveMedia(ids, { locale, spots: true })\n } catch (err) {\n deps.logger?.warn(\n { entity, err },\n 'content: media resolver threw while resolving a block tree — its media refs resolve to ' +\n 'null (if this is a ForbiddenError, the requesting role is missing `media:view`)',\n )\n return new Map()\n }\n}\n\n/**\n * {@link targetKey} → path, for the covered targets only.\n *\n * ONE call to {@link BlockResolverDeps.resolvePublishedPaths} per entity TYPE,\n * never per link. A document linking 200 times to 3 entity types costs 3 calls.\n */\nasync function resolveEntityPaths(\n targets: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, string>> {\n const out = new Map<string, string>()\n if (targets.length === 0) return out\n\n const byType = new Map<string, string[]>()\n for (const target of targets) {\n const separator = target.indexOf(' ')\n const entityType = target.slice(0, separator)\n const id = target.slice(separator + 1)\n const bucket = byType.get(entityType)\n if (bucket) bucket.push(id)\n else byType.set(entityType, [id])\n }\n\n // SERIAL over types, matching `resolveBlockTrees`' serial-over-rows rule: the\n // set is tiny (a document links to a handful of types at most) and serial\n // keeps the read pattern predictable under load rather than bursting one\n // query per type at once.\n for (const [entityType, ids] of byType) {\n try {\n const paths = await deps.resolvePublishedPaths(entityType, ids, locale)\n for (const [id, path] of paths) out.set(targetKey(entityType, id), path)\n } catch (err) {\n // One broken entity type costs its own links, not the page's whole tree.\n deps.logger?.warn(\n { entity, entityType, err },\n 'content: path resolution threw for a block-tree link target — its links render as ' +\n 'plain text',\n )\n }\n }\n return out\n}\n\n// ---------------------------------------------------------------------------\n// App wiring\n// ---------------------------------------------------------------------------\n\n/**\n * Wire {@link resolveBlockTree} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveBlocks })`.\n *\n * Pass the SAME `resolveMedia` value the handler gets, so a media ref inside a\n * block and a top-level `media()` field resolve through one path and one gate.\n *\n * **The publish gate comes from `buildEnumerateDeps`' batched `filterPublished`,\n * which reads through an elevated REACH context** (`createSystemReachResolver`,\n * which grants `view` and withholds `view_unpublished`), so unpublished and\n * trashed targets are dropped. That is deliberately the SAME gate `GET /_resolve`\n * applies and no stricter one: the alternative — additionally gating on the\n * caller's per-entity `view` — would make the same URL a working link on one\n * endpoint and plain text on another, while the document behind the href stays\n * whitelist- and permission-gated where it is actually fetched.\n */\nexport function createBlockResolver(\n getApp: () => Promise<ToolkitApp> | ToolkitApp,\n options: {\n /**\n * The locale to resolve in when a request carried none. Pass the SAME value\n * given to `createContentApiHandler({ defaultLocale })` — the handler\n * already defaults every entity read's locale before it reaches a resolver,\n * so this only binds on a path that somehow did not, and disagreeing with\n * the handler would resolve a link in a locale the page is not rendered in.\n */\n defaultLocale: string\n resolveMedia?: BlockMediaResolver | undefined\n },\n): (\n blocks: unknown,\n opts: {\n locale?: string | undefined\n budget: BlockResolutionBudget\n entity: string\n field: string\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n template?: string | undefined\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n) => Promise<ResolvedBlockTree> {\n // Built ONCE per app, not once per field per row. `buildEnumerateDeps` makes\n // a table client, and its `filterPublished` makes a fresh `QueryClient` and a\n // child logger per call — cheap individually, and paid once per `blockTree`\n // field on every row of every list page if it lives inside the closure.\n //\n // Cached against the app IDENTITY rather than assumed singleton: `getApp` is\n // a callback precisely because a host may hand back a different app (tests\n // do), and a cache that ignored that would silently read another app's\n // database.\n let cached: { app: ToolkitApp; deps: BlockResolverDeps } | null = null\n\n const depsFor = (app: ToolkitApp): BlockResolverDeps => {\n if (cached?.app === app) return cached.deps\n // `pathsClient` here is the BATCHED (`{ in }`-capable) reader, which is what\n // `getCanonicalPaths` needs — the point-lookup `buildCanonicalPathDeps`\n // reader cannot express an id batch, and reaching for it is how this ends\n // up one query per link.\n const enumerateDeps = buildEnumerateDeps(app)\n // editor PR53 — only when the app declares collection sources. The reader\n // is `createQueryClient`, which is published-only and bound to the AMBIENT\n // request context: the same caller-scoped read content-api's own list\n // endpoint performs, so a collection sees exactly the rows that caller\n // could fetch directly — nothing wider, no elevated identity.\n const { sources } = effectiveCollectionSources(app)\n const collections: BlockCollectionDeps | undefined =\n sources.length === 0\n ? undefined\n : {\n sources,\n sourceFieldOf: serverCollectionFieldLookup(),\n getEntity: (name) => app.entities.get(name),\n clientFor: (entity) => createCollectionReader(entity, app),\n }\n // A template is REGISTRY code, fixed after boot, so the registry lookups it\n // is rebuilt from are built ONCE per app rather than per row: a list\n // response of a hundred templated rows would otherwise rebuild the\n // block-definition map two hundred times on the anonymous path. The TREE is\n // still rebuilt per row, because its ids belong to that row's document\n // (editor F124) — the part that was cheap. Each call answers a fresh,\n // deep-cloned tree (see `serverTemplateMaterializer`), so the media slots\n // this resolver writes in place never reach another response or the\n // registry.\n const templates = serverTemplateMaterializer()\n // The structure lookup for ORDINARY trees' bound columns, built once per app\n // for the same reason: `serverBoundColumnsInTree` would otherwise rebuild the\n // block-definition map per row per field on the anonymous path.\n const structureLookup = serverBlockStructureLookup()\n const deps: BlockResolverDeps = {\n ...(collections !== undefined && { collections }),\n boundColumnsOf: (tree) => collectBoundColumnsInTree(tree, structureLookup),\n materializeTemplate: (template, namespace) => templates(template, namespace),\n resolveMedia: options.resolveMedia,\n resolvePublishedPaths: async (entityType, ids, locale) => {\n const published = await enumerateDeps.filterPublished(entityType, ids, locale)\n if (published.size === 0) return new Map()\n return getCanonicalPaths(entityType, [...published], locale, enumerateDeps)\n },\n logger: app.logger,\n }\n cached = { app, deps }\n return deps\n }\n\n return async (blocks, opts) => {\n const app = await getApp()\n return resolveBlockTree(\n blocks,\n { ...opts, locale: opts.locale ?? options.defaultLocale },\n depsFor(app),\n )\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index-C2G4IJck.d.mts","names":[],"sources":["../src/behaviors/with-overrides.ts","../src/behaviors/workflowable.ts","../src/capabilities.ts","../src/diff/types.ts","../src/errors.ts","../src/locale-registry.ts","../src/message-registry.ts"],"mappings":";;;;;;;;;;;KAqDK,iBAAA;EACC,IAAA;AAAA;EAEA,IAAA;EACA,KAAA,GAAQ,MAAA;EACR,MAAA,GAAS,MAAA,SAAe,MAAA;AAAA;EAExB,IAAA;EAAiB,KAAA,EAAO,MAAA;AAAA;AAAA,KAEzB,YAAA,GAAe,MAAM,SAAS,iBAAA;AAAA,KAEvB,mBAAA;EAcV,UAAA,EAAY,SAAS;AAAA;;;AC/B4B;AASnD;iBD4LgB,aAAA,CAAA,GAAiB,QAAQ,CAAC,mBAAA;;;;;;iBA4C1B,gBAAA,CACd,SAAA,EAAW,aAAa;EAAG,IAAA;AAAA,IAAiB,MAAA;;;cCnPjC,eAAA;AAAA,KACD,aAAA,WAAwB,eAAe;ADqMnD;;;;AAA6D;AA4C7D;;AA5CA,cC5La,oBAAA;EAAA;;;;;;ADyOqE;;;AAAA,iBC9NlE,mBAAA,CAAoB,IAAA,EAAM,aAAA,EAAe,EAAA,EAAI,aAAa;AArB1E;;;;AAAA,cA+Ba,uBAAA,SAAgC,KAAA;EAAA,SAClC,IAAA,EAAM,aAAA;EAAA,SACN,EAAA,EAAI,aAAA;cAED,IAAA,EAAM,aAAA,EAAe,EAAA,EAAI,aAAA;AAAA;AAAA,KAW3B,kBAAA;EACV,eAAA,EAAiB,WAAA;IAAgB,QAAA;EAAA;EACjC,iBAAA,EAAmB,SAAA;EACnB,aAAA,EAAe,SAAA;EACf,kBAAA,EAAoB,SAAA;AAAA;AAAA,iBAGN,YAAA,CAAA,GAAgB,QAAQ,CAAC,kBAAA;;;KC7E7B,gBAAA;;;;;;iBAOI,cAAA,CAAe,MAAA,EAAQ,MAAA,EAAQ,UAAA,EAAY,gBAAgB;;;;;;;UCxB1D,WAAA;EACf,IAAA;EACA,KAAK;AAAA;AHsCuD;AAAA,UGlC7C,SAAA;EACf,KAAA;EACA,IAAA;EAWA,IAAA;EACA,EAAA;EHmC4B;EGjC5B,QAAA,GAAW,WAAA;EHiCuB;EG/BlC,UAAA,GAAa,cAAc;AAAA;;KAIjB,eAAA;;UAGK,cAAA;EACf,OAAA;EACA,SAAA;EACA,MAAA,EAAQ,eAAA;EACR,SAAA;EACA,OAAA;EHmBkC;EGjBlC,SAAA,GAAY,QAAQ;AAAA;;UAIL,QAAA;EACf,KAAA;EACA,IAAA;EACA,EAAA;;EAEA,QAAA,GAAW,WAAW;AAAA;;;;;;;;;;;AHPsC;;;;cIhCjD,qBAAA,SAA8B,KAAK;EAAA,SACrC,UAAA;EAAA,SACA,UAAA;cAGP,UAAA,UACA,UAAA,EAAY,qBAAA,gBACZ,OAAA;AAAA;;;;;;;;;;cAsBS,kBAAA,SAA2B,KAAK;EAAA,SAClC,QAAA;EAAA,SACA,MAAA;cAEG,QAAA,UAAkB,MAAA;AAAA;;;AJgBoB;AAEpD;;;;AAcuB;AAsKvB;;;;cI5Ka,oBAAA,SAA6B,KAAK;EAAA,SACpC,QAAA;EAAA,SACA,OAAA;EAAA,SACA,MAAA;cAEG,QAAA,UAAkB,OAAA,UAAiB,MAAA;AAAA;;;;;AJoNiC;;;;ACnPlF;cGiDa,kBAAA,SAA2B,KAAK;EAAA,SAClC,MAAA;EAAA,SACA,OAAA;cAEG,MAAA,UAAgB,OAAA;AAAA;;;;;;;;iBCZR,uBAAA,CAAwB,GAAA,EAAK,UAAA,GAAa,OAAA,CAAQ,WAAA;;iBAoBxD,kBAAA,CAAmB,OAA+B,WAAb,WAAW;;;AL/C5B;AAAA;;;;iBK0Dd,iBAAA,CACpB,GAAA,EAAK,UAAA,EACL,OAAA,YAAmB,WAAA,KAClB,OAAA,CAAQ,UAAA;;;;;;;;iBC1EW,uBAAA,CACpB,GAAA,EAAK,UAAA,EACL,MAAA,WACC,OAAA,CAAQ,YAAA"}
1
+ {"version":3,"file":"index-C2G4IJck.d.mts","names":[],"sources":["../src/behaviors/with-overrides.ts","../src/behaviors/workflowable.ts","../src/capabilities.ts","../src/diff/types.ts","../src/errors.ts","../src/locale-registry.ts","../src/message-registry.ts"],"mappings":";;;;;;;;;;;KAqDK,iBAAA;EACC,IAAA;AAAA;EAEA,IAAA;EACA,KAAA,GAAQ,MAAA;EACR,MAAA,GAAS,MAAA,SAAe,MAAA;AAAA;EAExB,IAAA;EAAiB,KAAA,EAAO,MAAA;AAAA;AAAA,KAEzB,YAAA,GAAe,MAAM,SAAS,iBAAA;AAAA,KAEvB,mBAAA;EAcV,UAAA,EAAY,SAAS;AAAA;;;AC/B4B;AASnD;iBD4LgB,aAAA,CAAA,GAAiB,QAAQ,CAAC,mBAAA;;;;;;iBAmD1B,gBAAA,CACd,SAAA,EAAW,aAAa;EAAG,IAAA;AAAA,IAAiB,MAAA;;;cC1PjC,eAAA;AAAA,KACD,aAAA,WAAwB,eAAe;ADqMnD;;;;AAA6D;AAmD7D;;AAnDA,cC5La,oBAAA;EAAA;;;;;;ADgPqE;;;AAAA,iBCrOlE,mBAAA,CAAoB,IAAA,EAAM,aAAA,EAAe,EAAA,EAAI,aAAa;AArB1E;;;;AAAA,cA+Ba,uBAAA,SAAgC,KAAA;EAAA,SAClC,IAAA,EAAM,aAAA;EAAA,SACN,EAAA,EAAI,aAAA;cAED,IAAA,EAAM,aAAA,EAAe,EAAA,EAAI,aAAA;AAAA;AAAA,KAW3B,kBAAA;EACV,eAAA,EAAiB,WAAA;IAAgB,QAAA;EAAA;EACjC,iBAAA,EAAmB,SAAA;EACnB,aAAA,EAAe,SAAA;EACf,kBAAA,EAAoB,SAAA;AAAA;AAAA,iBAGN,YAAA,CAAA,GAAgB,QAAQ,CAAC,kBAAA;;;KC7E7B,gBAAA;;;;;;iBAOI,cAAA,CAAe,MAAA,EAAQ,MAAA,EAAQ,UAAA,EAAY,gBAAgB;;;;;;;UCxB1D,WAAA;EACf,IAAA;EACA,KAAK;AAAA;AHsCuD;AAAA,UGlC7C,SAAA;EACf,KAAA;EACA,IAAA;EAWA,IAAA;EACA,EAAA;EHmC4B;EGjC5B,QAAA,GAAW,WAAA;EHiCuB;EG/BlC,UAAA,GAAa,cAAc;AAAA;;KAIjB,eAAA;;UAGK,cAAA;EACf,OAAA;EACA,SAAA;EACA,MAAA,EAAQ,eAAA;EACR,SAAA;EACA,OAAA;EHmBkC;EGjBlC,SAAA,GAAY,QAAQ;AAAA;;UAIL,QAAA;EACf,KAAA;EACA,IAAA;EACA,EAAA;;EAEA,QAAA,GAAW,WAAW;AAAA;;;;;;;;;;;AHPsC;;;;cIhCjD,qBAAA,SAA8B,KAAK;EAAA,SACrC,UAAA;EAAA,SACA,UAAA;cAGP,UAAA,UACA,UAAA,EAAY,qBAAA,gBACZ,OAAA;AAAA;;;;;;;;;;cAsBS,kBAAA,SAA2B,KAAK;EAAA,SAClC,QAAA;EAAA,SACA,MAAA;cAEG,QAAA,UAAkB,MAAA;AAAA;;;AJgBoB;AAEpD;;;;AAcuB;AAsKvB;;;;cI5Ka,oBAAA,SAA6B,KAAK;EAAA,SACpC,QAAA;EAAA,SACA,OAAA;EAAA,SACA,MAAA;cAEG,QAAA,UAAkB,OAAA,UAAiB,MAAA;AAAA;;;;;AJ2NiC;;;;AC1PlF;cGiDa,kBAAA,SAA2B,KAAK;EAAA,SAClC,MAAA;EAAA,SACA,OAAA;cAEG,MAAA,UAAgB,OAAA;AAAA;;;;;;;;iBCZR,uBAAA,CAAwB,GAAA,EAAK,UAAA,GAAa,OAAA,CAAQ,WAAA;;iBAoBxD,kBAAA,CAAmB,OAA+B,WAAb,WAAW;;;AL/C5B;AAAA;;;;iBK0Dd,iBAAA,CACpB,GAAA,EAAK,UAAA,EACL,OAAA,YAAmB,WAAA,KAClB,OAAA,CAAQ,UAAA;;;;;;;;iBC1EW,uBAAA,CACpB,GAAA,EAAK,UAAA,EACL,MAAA,WACC,OAAA,CAAQ,YAAA"}
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-DQGPPBlW.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})},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{a as o,i as s,n as c,o as l,r as u,t as d}from"./routing-strategy-DQGPPBlW.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};
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 _overrides: field.json({\n default: () => ({}),\n internal: 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":["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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/content",
3
- "version": "0.76.0",
3
+ "version": "0.78.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,14 +56,14 @@
56
56
  "drizzle-orm": "^0.45.2",
57
57
  "server-only": "^0.0.1",
58
58
  "zod": "^3.24.1",
59
- "@murumets-ee/blocks": "0.76.0",
60
- "@murumets-ee/core": "0.76.0",
61
- "@murumets-ee/db": "0.76.0",
62
- "@murumets-ee/entity": "0.76.0",
63
- "@murumets-ee/i18n": "0.76.0",
64
- "@murumets-ee/logging": "0.76.0",
65
- "@murumets-ee/queue": "0.76.0",
66
- "@murumets-ee/settings": "0.76.0"
59
+ "@murumets-ee/blocks": "0.78.0",
60
+ "@murumets-ee/core": "0.78.0",
61
+ "@murumets-ee/db": "0.78.0",
62
+ "@murumets-ee/entity": "0.78.0",
63
+ "@murumets-ee/i18n": "0.78.0",
64
+ "@murumets-ee/logging": "0.78.0",
65
+ "@murumets-ee/queue": "0.78.0",
66
+ "@murumets-ee/settings": "0.78.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/node": "^20.19.42",