@murumets-ee/content-api 0.93.0 → 0.95.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.
@@ -66,6 +66,8 @@ declare const MAX_SEARCH_LENGTH = 200;
66
66
  * 2 matches `DEFAULT_MECHANICS.minQueryLength` in `@murumets-ee/search`.
67
67
  */
68
68
  declare const MIN_SEARCH_LENGTH = 2;
69
+ /** Trim, then discard zero-width padding, for length measurement only. */
70
+ declare function normalizeSearchLengthInput(raw: string): string;
69
71
  /** Longest `?cursor=` accepted. Opaque base64 JSON; the cap bounds the decode. */
70
72
  declare const MAX_CURSOR_LENGTH = 500;
71
73
  /** Longest `?path=` accepted by `_resolve` — a generous URL-path cap. */
@@ -183,19 +185,35 @@ declare const listParamsSchema: z.ZodObject<{
183
185
  cursor?: string | undefined;
184
186
  populate?: string | undefined;
185
187
  }>;
188
+ /**
189
+ * The highest `?page=` a document read accepts — the page a PAGED collection in
190
+ * its block trees shows (davatoidud-toolkit PR03). Equal to
191
+ * `COLLECTION_MAX_PAGE` in `@murumets-ee/blocks` (a test there pins the two;
192
+ * this package cannot import that one).
193
+ */
194
+ declare const MAX_COLLECTION_PAGE = 100;
195
+ /**
196
+ * A page number in its ONE canonical spelling — digits, no leading zero, 1 to
197
+ * {@link MAX_COLLECTION_PAGE}. Anything else is a 400, so `?page=02` is never a
198
+ * second address of `?page=2` in a cache in front of this API.
199
+ */
200
+ declare const COLLECTION_PAGE_PATTERN: RegExp;
186
201
  /** Single entity query params. */
187
202
  declare const singleParamsSchema: z.ZodObject<{
188
203
  locale: z.ZodOptional<z.ZodString>;
189
204
  fields: z.ZodOptional<z.ZodString>;
190
- populate: z.ZodOptional<z.ZodString>;
205
+ populate: z.ZodOptional<z.ZodString>; /** The page a paged collection in the document's block trees shows. */
206
+ page: z.ZodOptional<z.ZodEffects<z.ZodString, number, string>>;
191
207
  }, "strict", z.ZodTypeAny, {
192
208
  locale?: string | undefined;
193
209
  fields?: string | undefined;
194
210
  populate?: string | undefined;
211
+ page?: number | undefined;
195
212
  }, {
196
213
  locale?: string | undefined;
197
214
  fields?: string | undefined;
198
215
  populate?: string | undefined;
216
+ page?: string | undefined;
199
217
  }>;
200
218
  /** Count endpoint query params. */
201
219
  declare const countParamsSchema: z.ZodObject<{
@@ -342,5 +360,5 @@ interface ParsedPopulate {
342
360
  */
343
361
  declare function parsePopulate(populateParam: string | undefined, allFields: Record<string, FieldConfig>): ParsedPopulate;
344
362
  //#endregion
345
- export { DEFAULT_LIMIT, DEFAULT_OFFSET, DEFAULT_PATHS_LIMIT, FILTER_PARAM_PATTERN, MAX_CURSOR_LENGTH, MAX_ENTITY_NAME_LENGTH, MAX_FILTER_TEXT_LENGTH, MAX_LIMIT, MAX_MENU_HANDLE_LENGTH, MAX_OFFSET, MAX_POPULATE_FIELDS, MAX_POPULATE_LENGTH, MAX_RESOLVE_PATH_LENGTH, MAX_SEARCH_LENGTH, MIN_SEARCH_LENGTH, ParsedPopulate, countParamsSchema, escapeLikePattern, isInternalField, isUuid, isValidIsoDate, listParamsSchema, parseFieldSelection, parsePopulate, singleParamsSchema, toParamBag, validateFilterValue };
363
+ export { COLLECTION_PAGE_PATTERN, DEFAULT_LIMIT, DEFAULT_OFFSET, DEFAULT_PATHS_LIMIT, FILTER_PARAM_PATTERN, MAX_COLLECTION_PAGE, MAX_CURSOR_LENGTH, MAX_ENTITY_NAME_LENGTH, MAX_FILTER_TEXT_LENGTH, MAX_LIMIT, MAX_MENU_HANDLE_LENGTH, MAX_OFFSET, MAX_POPULATE_FIELDS, MAX_POPULATE_LENGTH, MAX_RESOLVE_PATH_LENGTH, MAX_SEARCH_LENGTH, MIN_SEARCH_LENGTH, ParsedPopulate, countParamsSchema, escapeLikePattern, isInternalField, isUuid, isValidIsoDate, listParamsSchema, normalizeSearchLengthInput, parseFieldSelection, parsePopulate, singleParamsSchema, toParamBag, validateFilterValue };
346
364
  //# sourceMappingURL=query-params.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"query-params.d.mts","names":[],"sources":["../src/query-params.ts"],"mappings":";;;;;cAiBa,SAAA;;;;AAiBsB;AAUnC;;;;AAA0B;AAG1B;;;;cAfa,UAAA;AAAA,cAEA,sBAAA;;;;AAgBiB;AAmC9B;;;;cAzCa,aAAA;AAqEb;AAAA,cAlEa,cAAA;;cAGA,iBAAA;AA+DiB;AAG9B;;;;AAAoC;AAGpC;;;;AAAmC;AASnC;;;;AAAmC;AAGnC;;;;AAAgC;AAiBhC;;;;AAAgC;AAOhC;;;;AAAgC;AAyChC;;AAnF8B,cA5BjB,iBAAA;;cA4BA,iBAAA;;cAGA,uBAAA;;cAGA,sBAAA;;;;;;;;cASA,sBAAA;;cAGA,mBAAA;;;;;;;;;;;;;;;;cAiBA,mBAAA;;;;;;cAOA,mBAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAyCA,gBAAA,EAAgB,CAAA,CAAA,SAAA;;;;;;;;AAqD7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAAa,kBAAA,EAAkB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;cASlB,iBAAA,EAAiB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;AA8B9B;;;;AAAiE;AAmBjE;;;;;;;;;;AAA+E;AAqC/E;cAxDa,oBAAA,EAAoB,MAAgC;;;AAwDvB;AAmB1C;;;;AAAiC;AAYjC;;;;;;;;AAAyE;iBApEzD,UAAA,CAAW,GAAA,EAAK,GAAA,EAAK,IAAA;EAAS,YAAA;AAAA,IAA2B,MAAM;;;;;AA6HF;AAkB7E;iBA1GgB,cAAA,CAAe,GAAW;;iBAmB1B,MAAA,CAAO,EAAU;;;;;;AAyFO;AAuBxC;;iBApGgB,mBAAA,CAAoB,WAAA,EAAa,WAAW,EAAE,GAAA;;;;;;AAgHnD;AAqBX;;;;;;;;;;;;;;AAGiB;;;;;;;iBA/ED,eAAA,CAAgB,IAAA,UAAc,MAAA,EAAQ,WAAW;;;;;;;;;;;iBAkBjD,mBAAA,CACd,WAAA,sBACA,SAAA,EAAW,MAAM,SAAS,WAAA;;UAuBX,cAAA;;EAEf,MAAA;;EAEA,OAAA;;;;;;;;EAQA,SAAA;AAAA;;;;;;;;;;;;;;;;;;;iBAqBc,aAAA,CACd,aAAA,sBACA,SAAA,EAAW,MAAA,SAAe,WAAA,IACzB,cAAA"}
1
+ {"version":3,"file":"query-params.d.mts","names":[],"sources":["../src/query-params.ts"],"mappings":";;;;;cAiBa,SAAA;;;;AAiBsB;AAUnC;;;;AAA0B;AAG1B;;;;cAfa,UAAA;AAAA,cAEA,sBAAA;;;;AAgBiB;AAmC9B;;;;cAzCa,aAAA;AAgEb;AAAA,cA7Da,cAAA;;cAGA,iBAAA;AA0DyC;AAKtD;;;;AAA8B;AAG9B;;;;AAAoC;AAGpC;;;;AAAmC;AASnC;;;;AAAmC;AAGnC;;;;AAAgC;AAiBhC;;;;AAAgC;AAOhC;;AA/CsD,cAvBzC,iBAAA;;iBAuBG,0BAAA,CAA2B,GAAW;AAwFtD;AAAA,cAnFa,iBAAA;;cAGA,uBAAA;;cAGA,sBAAA;;;;;;;;cASA,sBAAA;;cAGA,mBAAA;;;;;;;;;;;;;;;;cAiBA,mBAAA;;;;;;cAOA,mBAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAyCA,gBAAA,EAAgB,CAAA,CAAA,SAAA;;;;;;;;;;;AA0D7B;;;EAAgC;AAOhC;;;;AAA8D;;;;;;;;;;;;;;;;;;;;;;;;;;;cAPjD,mBAAA;;;;;;cAOA,uBAAA,EAAuB,MAA0B;;cAGjD,kBAAA,EAAkB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;cAWlB,iBAAA,EAAiB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;AA8B9B;;;;AAAiE;AAmBjE;;;;cAnBa,oBAAA,EAAoB,MAAgC;;;;;;AAmBc;AAqC/E;;;;AAA0C;AAmB1C;;;;AAAiC;AAYjC;iBApEgB,UAAA,CAAW,GAAA,EAAK,GAAA,EAAK,IAAA;EAAS,YAAA;AAAA,IAA2B,MAAM;;;;AAoEN;AAyDzE;;iBAxFgB,cAAA,CAAe,GAAW;;iBAmB1B,MAAA,CAAO,EAAU;;;;AAqE4C;AAkB7E;;;;iBA3EgB,mBAAA,CAAoB,WAAA,EAAa,WAAW,EAAE,GAAA;;;;;AA6EtB;AAuBxC;;;;;;;;AAYW;AAqBX;;;;;;;;;;;;;iBA5EgB,eAAA,CAAgB,IAAA,UAAc,MAAA,EAAQ,WAAW;AA+EhD;;;;;;;;;;AAAA,iBA7DD,mBAAA,CACd,WAAA,sBACA,SAAA,EAAW,MAAM,SAAS,WAAA;;UAuBX,cAAA;;EAEf,MAAA;;EAEA,OAAA;;;;;;;;EAQA,SAAA;AAAA;;;;;;;;;;;;;;;;;;;iBAqBc,aAAA,CACd,aAAA,sBACA,SAAA,EAAW,MAAA,SAAe,WAAA,IACzB,cAAA"}
@@ -1,2 +1,2 @@
1
- import{LOCALE_PATTERN as e,escapeLikePattern as t}from"@murumets-ee/core";import{z as n}from"zod";const r=100,i=1e3,a=200,o=20,s=0,c=200,l=2,u=/[\u200B-\u200D\u2060\uFEFF]/g;function d(e){return e.replace(u,``).trim()}const f=500,p=2048,m=100,h=128,g=20,_=5,v=200,y=n.object({limit:n.coerce.number().int().min(1).max(100).default(20),offset:n.coerce.number().int().min(0).default(0),sort:n.string().regex(/^-?[a-zA-Z_]+$/).optional(),locale:n.string().regex(e).optional(),fields:n.string().optional(),search:n.string().max(200).refine(e=>{let t=d(e);return t.length===0||t.length>=2},{message:`search must be at least 2 characters`}).optional(),cursor:n.string().max(500).optional(),populate:n.string().max(200).optional()}).strict(),b=n.object({locale:n.string().regex(e).optional(),fields:n.string().optional(),populate:n.string().max(200).optional()}).strict(),x=n.object({locale:n.string().regex(e).optional()}).strict(),S=/^filter\[([a-zA-Z0-9_]+)\]$/;function C(e,t){let n={};for(let[r,i]of e.searchParams.entries())t?.allowFilters===!0&&S.test(r)||(n[r]=i);return n}const w=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,T=/^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:[Zz]|[+-]\d{2}:?\d{2})?)?$/;function E(e){if(!T.test(e))return!1;let t=Date.parse(e);return Number.isNaN(t)?!1:e.length===10?new Date(t).toISOString().slice(0,10)===e:!0}function D(e){if(e.trim()===``)return!1;let t=Number(e);return Number.isFinite(t)}function O(e){return w.test(e)}function k(e,t){switch(e.type){case`boolean`:return t===`true`?!0:t===`false`?!1:null;case`reference`:case`media`:return w.test(t)?t:null;case`select`:return e.options.includes(t)?t:null;case`date`:return E(t.startsWith(`gte:`)||t.startsWith(`lte:`)?t.slice(4):t)?t:null;case`number`:return D(t)?Number(t):null;case`text`:return t.length<=200?t:null;default:return null}}function A(e,t){return e.startsWith(`_`)||t?.internal===!0||t?.adminOnly===!0}function j(e,t){if(!e)return;let n=e.split(`,`).map(e=>e.trim()).filter(e=>{if(!Object.hasOwn(t,e))return!1;let n=t[e];return n!==void 0&&!A(e,n)&&n.type!==`blockTree`});return n.includes(`id`)||n.unshift(`id`),n}function M(e,t){if(!e)return{fields:[],dropped:[],requested:0};let n=[...new Set(e.split(`,`).map(e=>e.trim()).filter(e=>e.length>0))],r=[],i=[];for(let e of n){if(!Object.hasOwn(t,e)){i.push(e);continue}let n=t[e];if(!n||A(e,n)||n.type!==`reference`){i.push(e);continue}r.push(e)}return{fields:r,dropped:i,requested:n.length}}export{o as DEFAULT_LIMIT,s as DEFAULT_OFFSET,g as DEFAULT_PATHS_LIMIT,S as FILTER_PARAM_PATTERN,f as MAX_CURSOR_LENGTH,m as MAX_ENTITY_NAME_LENGTH,a as MAX_FILTER_TEXT_LENGTH,r as MAX_LIMIT,h as MAX_MENU_HANDLE_LENGTH,i as MAX_OFFSET,_ as MAX_POPULATE_FIELDS,v as MAX_POPULATE_LENGTH,p as MAX_RESOLVE_PATH_LENGTH,c as MAX_SEARCH_LENGTH,l as MIN_SEARCH_LENGTH,x as countParamsSchema,t as escapeLikePattern,A as isInternalField,O as isUuid,E as isValidIsoDate,y as listParamsSchema,j as parseFieldSelection,M as parsePopulate,b as singleParamsSchema,C as toParamBag,k as validateFilterValue};
1
+ import{LOCALE_PATTERN as e,escapeLikePattern as t}from"@murumets-ee/core";import{z as n}from"zod";const r=100,i=1e3,a=200,o=20,s=0,c=200,l=2,u=/[\u200B-\u200D\u2060\uFEFF]/g;function d(e){return e.replace(u,``).trim()}const f=500,p=2048,m=100,h=128,g=20,_=5,v=200,y=n.object({limit:n.coerce.number().int().min(1).max(100).default(20),offset:n.coerce.number().int().min(0).default(0),sort:n.string().regex(/^-?[a-zA-Z_]+$/).optional(),locale:n.string().regex(e).optional(),fields:n.string().optional(),search:n.string().max(200).refine(e=>{let t=d(e);return t.length===0||t.length>=2},{message:`search must be at least 2 characters`}).optional(),cursor:n.string().max(500).optional(),populate:n.string().max(200).optional()}).strict(),b=100,x=/^(?:[1-9][0-9]?|100)$/,S=n.object({locale:n.string().regex(e).optional(),fields:n.string().optional(),populate:n.string().max(200).optional(),page:n.string().regex(x).transform(Number).optional()}).strict(),C=n.object({locale:n.string().regex(e).optional()}).strict(),w=/^filter\[([a-zA-Z0-9_]+)\]$/;function T(e,t){let n={};for(let[r,i]of e.searchParams.entries())t?.allowFilters===!0&&w.test(r)||(n[r]=i);return n}const E=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,D=/^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:[Zz]|[+-]\d{2}:?\d{2})?)?$/;function O(e){if(!D.test(e))return!1;let t=Date.parse(e);return Number.isNaN(t)?!1:e.length===10?new Date(t).toISOString().slice(0,10)===e:!0}function k(e){if(e.trim()===``)return!1;let t=Number(e);return Number.isFinite(t)}function A(e){return E.test(e)}function j(e,t){switch(e.type){case`boolean`:return t===`true`?!0:t===`false`?!1:null;case`reference`:case`media`:return E.test(t)?t:null;case`select`:return e.options.includes(t)?t:null;case`date`:return O(t.startsWith(`gte:`)||t.startsWith(`lte:`)?t.slice(4):t)?t:null;case`number`:return k(t)?Number(t):null;case`text`:return t.length<=200?t:null;default:return null}}function M(e,t){return e.startsWith(`_`)||t?.internal===!0||t?.adminOnly===!0}function N(e,t){if(!e)return;let n=e.split(`,`).map(e=>e.trim()).filter(e=>{if(!Object.hasOwn(t,e))return!1;let n=t[e];return n!==void 0&&!M(e,n)&&n.type!==`blockTree`});return n.includes(`id`)||n.unshift(`id`),n}function P(e,t){if(!e)return{fields:[],dropped:[],requested:0};let n=[...new Set(e.split(`,`).map(e=>e.trim()).filter(e=>e.length>0))],r=[],i=[];for(let e of n){if(!Object.hasOwn(t,e)){i.push(e);continue}let n=t[e];if(!n||M(e,n)||n.type!==`reference`){i.push(e);continue}r.push(e)}return{fields:r,dropped:i,requested:n.length}}export{x as COLLECTION_PAGE_PATTERN,o as DEFAULT_LIMIT,s as DEFAULT_OFFSET,g as DEFAULT_PATHS_LIMIT,w as FILTER_PARAM_PATTERN,b as MAX_COLLECTION_PAGE,f as MAX_CURSOR_LENGTH,m as MAX_ENTITY_NAME_LENGTH,a as MAX_FILTER_TEXT_LENGTH,r as MAX_LIMIT,h as MAX_MENU_HANDLE_LENGTH,i as MAX_OFFSET,_ as MAX_POPULATE_FIELDS,v as MAX_POPULATE_LENGTH,p as MAX_RESOLVE_PATH_LENGTH,c as MAX_SEARCH_LENGTH,l as MIN_SEARCH_LENGTH,C as countParamsSchema,t as escapeLikePattern,M as isInternalField,A as isUuid,O as isValidIsoDate,y as listParamsSchema,d as normalizeSearchLengthInput,N as parseFieldSelection,P as parsePopulate,S as singleParamsSchema,T as toParamBag,j as validateFilterValue};
2
2
  //# sourceMappingURL=query-params.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"query-params.mjs","names":[],"sources":["../src/query-params.ts"],"sourcesContent":["/**\n * Query parameter validation — Zod schemas for all content API endpoints.\n *\n * All user input is validated before it touches QueryClient.\n * Pagination hard-capped at MAX_LIMIT / MAX_OFFSET — exceeding either is a 400.\n * NOTE for consumers of this published subpath: only `limit`'s cap is enforced\n * by the schema. `offset`'s is enforced by the HANDLER (`ceilingViolation`), so\n * that an over-large offset can carry the `?cursor=` remedy instead of a bare\n * \"Invalid query parameters\" — see the comment on the field. Parsing a request\n * with this schema alone therefore bounds `limit` and not `offset`,\n * never a clamp. Sort/filter restricted to queryable columns.\n */\n\nimport { escapeLikePattern, LOCALE_PATTERN } from '@murumets-ee/core'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { z } from 'zod'\n\nexport const MAX_LIMIT = 100\n\n/**\n * Absolute ceiling on `?offset=`. Beyond it the answer is a 400\n * `VALIDATION_ERROR`, never a silent clamp — a clamp cannot be observed\n * failing, and it would leave a deep-paging client re-reading the same page\n * forever. `?cursor=` is the supported path past this point.\n *\n * The cost being bounded is a full traversal of the MATCHING set on every\n * request: `QueryClient` issues a plain SQL `OFFSET`, so the work is\n * `O(min(offset, matching rows))` — bounded by the result set, not by the\n * caller's integer. That makes a large offset cheap on a small collection and\n * real on a large one, from a caller who is unauthenticated by default with no\n * rate limit in front of them.\n */\nexport const MAX_OFFSET = 1000\n\nexport const MAX_FILTER_TEXT_LENGTH = 200\n\n/**\n * Page size applied when the caller sends no `?limit=`.\n *\n * Named rather than inlined because `GET /_schema` PUBLISHES it: a client that\n * reads the default off the wire and one that hard-codes it can never disagree,\n * and lowering it here lowers what the contract advertises in the same edit.\n * Same reasoning for every other cap in this block.\n */\nexport const DEFAULT_LIMIT = 20\n\n/** Offset applied when the caller sends no `?offset=`. */\nexport const DEFAULT_OFFSET = 0\n\n/** Longest `?search=` accepted — rejected as a 400 before any DB work. */\nexport const MAX_SEARCH_LENGTH = 200\n\n/**\n * Shortest `?search=` accepted, measured AFTER trim. Below it: `400\n * VALIDATION_ERROR` (D046).\n *\n * **Its direction is inverted from every other ceiling here, deliberately.** A\n * limit like `maxLimit` is protective when LOWERED, so a host may only lower it.\n * A minimum is protective when RAISED, so the package sets the floor and a host\n * may make it stricter and never looser. Same rule in both cases — the caller\n * cannot move it, and the host cannot move it toward more work.\n *\n * No new error code: D031's partition rule is that a code names what the client\n * should do next, never which parameter was wrong, and `VALIDATION_ERROR`\n * already means \"fix your request\". Rejecting rather than silently returning an\n * empty set is the other half — a silent empty result is unobservable, so the\n * check could never be SEEN to fail, and a frontend could not tell the user to\n * type more.\n *\n * **BE HONEST ABOUT WHAT THIS BUYS.** It removes the most degenerate case —\n * `%a%` matching essentially every row, forcing a full scan and returning an\n * arbitrary page — and removes nothing else; `%ab%` on a Latin-script corpus\n * still matches almost everything. THE BOUND ON SEARCH COST IS THE SURVIVAL\n * GUARD'S WALL CLOCK AND SEMAPHORE (see `limits.ts`), NOT THIS FLOOR, and it\n * must not be presented in `_schema`, in a docstring or in review as though it\n * were the protection.\n *\n * Nor is it peer convention: R15 verified that Algolia enforces no server-side\n * minimum (its `minLength` is a client-side InstantSearch affordance) and found\n * no documented one for Contentful or Sanity. The justification is local — an\n * unindexed `ILIKE '%q%'` OR'd with a correlated per-row `EXISTS` on the\n * translations table, with no trigram index anywhere in this repo.\n *\n * 2 matches `DEFAULT_MECHANICS.minQueryLength` in `@murumets-ee/search`.\n */\nexport const MIN_SEARCH_LENGTH = 2\n\n/**\n * Zero-width and word-joiner characters, stripped before the floor is measured.\n *\n * `String.prototype.trim()` removes Unicode `White_Space`, which does NOT\n * include U+200B ZERO WIDTH SPACE and friends. So `?search=a​` trims to\n * length 2, clears the floor, and produces the `ILIKE '%a​%'` full scan the\n * floor exists to refuse — while matching nothing, so the caller pays no penalty\n * for the trick. Stripping them costs one regex on a string already capped at\n * 200 characters.\n *\n * Not exhaustive against every homoglyph or combining sequence, and not meant to\n * be: per {@link MIN_SEARCH_LENGTH}, the floor is a cheap filter on the worst\n * input and the survival guard is the actual bound. This closes the one bypass\n * that costs nothing to close.\n */\n// Written as escapes, not literals: the characters are invisible in source, so\n// a literal class is unreviewable and un-greppable.\n// U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+2060 WORD JOINER, U+FEFF BOM.\nconst ZERO_WIDTH_RE = /[\\u200B-\\u200D\\u2060\\uFEFF]/g\n\n/** Trim, then discard zero-width padding, for length measurement only. */\nfunction normalizeSearchLengthInput(raw: string): string {\n return raw.replace(ZERO_WIDTH_RE, '').trim()\n}\n\n/** Longest `?cursor=` accepted. Opaque base64 JSON; the cap bounds the decode. */\nexport const MAX_CURSOR_LENGTH = 500\n\n/** Longest `?path=` accepted by `_resolve` — a generous URL-path cap. */\nexport const MAX_RESOLVE_PATH_LENGTH = 2048\n\n/** Longest `?entity=` accepted by `_paths` / `_alternates`. */\nexport const MAX_ENTITY_NAME_LENGTH = 100\n\n/**\n * Longest `?handle=` accepted by `_menu` — a defensive cap, deliberately NOT\n * tied to the menu entity's `handle` column width (`varchar(255)`). A handle is\n * a hand-authored navigation key, so 128 is far above any real one; the point is\n * only to bound the string before it reaches the allowlist check, never to\n * mirror the column. An over-long value is a 400 either way.\n */\nexport const MAX_MENU_HANDLE_LENGTH = 128\n\n/** Page size `_paths` applies when the caller sends no `?limit=`. */\nexport const DEFAULT_PATHS_LIMIT = 20\n\n/**\n * Most reference fields one request may `?populate=` (D013).\n *\n * Server-owned and non-raisable: full expansion replaces a fixed-size\n * projection with the target's whole public DTO, so each named field makes the\n * response's size a function of what it happens to point at. Exceeding this is\n * a 400 `VALIDATION_ERROR`, never a silent truncation — the same reject-never-\n * clamp posture D007 fixed for `?offset=`, and for the same reason: a clamp\n * answers 200 with something the caller did not ask for and cannot be observed\n * failing from the outside.\n *\n * Five, because it bounds a MULTIPLIER rather than a count. The number of rows\n * is already bounded by `maxLimit`, and the number of resolutions by the\n * per-document budget; this bounds how much each row can grow.\n */\nexport const MAX_POPULATE_FIELDS = 5\n\n/**\n * Longest `?populate=` string accepted, before it is split. Bounds the parse\n * itself, independently of the field-count ceiling — a caller must not be able\n * to hand us a megabyte to `split(',')`.\n */\nexport const MAX_POPULATE_LENGTH = 200\n\n/** List endpoint query params. */\n/**\n * `.strict()` on every schema in this module: an unrecognized parameter is a 400\n * `VALIDATION_ERROR`, not a silently-stripped key (D042).\n *\n * Zod's default is `strip`, so `?limit=20` and `?limit=20&anything=1` returned a\n * BYTE-IDENTICAL body under two distinct URLs — and so did the next million\n * values of `anything`. On a URL-keyed shared cache that is unbounded,\n * caller-controlled key fragmentation, worse than an ordinary miss in both\n * directions at once: every fabricated key MISSES (so it reaches the origin) and\n * OCCUPIES an entry (so it evicts the working set serving real visitors). No\n * credential required, a few bytes per request (F031).\n *\n * This is the whitelist rule reaching the last input surface still running\n * blacklist-shaped — filter keys, sort fields, cursor fields and `?fields=`\n * already go through `isInternalField`, and `config.entities` / `menus.handles`\n * are already deny-by-default allowlists.\n *\n * **What this does and does not stop, stated precisely.** The parse precedes\n * every query, so a fabricated-key request is refused BEFORE any database work —\n * that is the whole of the protection, and it is real: the flood costs a schema\n * parse rather than two queries. It is NOT absorbed at the edge. An earlier\n * revision of this docblock said it was, on the strength of D037 giving that 4xx\n * a cacheable directive; SD005 made every refusal `no-store`, so 100% of such a\n * flood reaches the origin and is metered by the survival guard and the fairness\n * limit instead (D025). Left corrected rather than deleted because a stale\n * security rationale is the kind of comment a later risk assessment leans on.\n *\n * Breaking-shaped and free: D010 records that no content-api consumer is live,\n * and D008's \"a client derives nothing\" means a conforming client reads its\n * parameter set from `_schema` and can never send one the server did not\n * publish.\n *\n * **What it does NOT bound: parameter VALUES.** `?search=` is a whitelisted name\n * with an unbounded value space inside its 200-character cap, so a fabricated\n * search value is still a guaranteed miss against the most expensive query on\n * the surface. That is F040, and it is the survival guard's and the fairness\n * limit's to meter, not this schema's.\n */\nexport const listParamsSchema = z\n .object({\n limit: z.coerce.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_LIMIT),\n // NO `.max(MAX_OFFSET)` here — deliberately, and it is not an oversight.\n //\n // The handler's `ceilingViolation` answers an over-large offset with the\n // remedy the caller actually needs: \"?offset=N exceeds the maximum of M. Use\n // ?cursor= for deep pagination.\" A zod `.max()` fires FIRST and flattens that\n // to a bare \"Invalid query parameters\", so the remedy was unreachable for the\n // package ceiling and only appeared for a host-lowered one — the deep-paging\n // caller, i.e. exactly the caller who needs the hint, never saw it.\n //\n // Nothing is unbounded by removing it: `maxOffset` is `Math.min(config ?? MAX_OFFSET,\n // MAX_OFFSET)`, so the effective ceiling is still capped at the package value,\n // and `ceilingViolation` runs before any DB work. `limit` keeps its `.max()`\n // because it has no equivalent remedy to lose.\n offset: z.coerce.number().int().min(0).default(DEFAULT_OFFSET),\n sort: z\n .string()\n .regex(/^-?[a-zA-Z_]+$/)\n .optional(),\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n fields: z.string().optional(),\n /**\n * `?search=` — see {@link MIN_SEARCH_LENGTH} for why the floor exists and\n * what it does NOT buy. Whitespace-only is treated as absent, matching the\n * pre-existing `buildSearchCondition` behaviour; anything else below the floor\n * is a 400.\n */\n search: z\n .string()\n .max(MAX_SEARCH_LENGTH)\n .refine(\n (raw) => {\n const trimmed = normalizeSearchLengthInput(raw)\n return trimmed.length === 0 || trimmed.length >= MIN_SEARCH_LENGTH\n },\n { message: `search must be at least ${MIN_SEARCH_LENGTH} characters` },\n )\n .optional(),\n /** Opaque cursor for keyset pagination (base64-encoded JSON). */\n cursor: z.string().max(MAX_CURSOR_LENGTH).optional(),\n /**\n * Comma-separated reference fields to expand to their target's full public\n * DTO (D013). The LENGTH cap is here so a malformed value is a 400 before it\n * is split; the FIELD-COUNT ceiling is enforced by the handler so the 400 can\n * name the ceiling — same division of labour as `limit` vs `offset` above.\n */\n populate: z.string().max(MAX_POPULATE_LENGTH).optional(),\n })\n .strict()\n\n/** Single entity query params. */\nexport const singleParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n fields: z.string().optional(),\n populate: z.string().max(MAX_POPULATE_LENGTH).optional(),\n })\n .strict()\n\n/** Count endpoint query params. */\nexport const countParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n })\n .strict()\n\n/**\n * Parameter NAMES this surface accepts dynamically rather than by fixed key.\n *\n * `filter[<field>]` is a published parameter family, read by\n * `buildFilterConditions` straight off `url.searchParams` in a pass separate\n * from the zod parse. It must be partitioned out before a `.strict()` parse or\n * every filtered request would 400 — see {@link toParamBag}.\n *\n * **ONE pattern, exported, used by BOTH the partition and the extraction.** They\n * were two copies of the same literal, and they had already drifted from what an\n * entity field name can actually be: the class excluded DIGITS, so\n * `filter[heading2]` silently matched neither. Before D042 that meant the filter\n * was quietly ignored; with `.strict()` in front it would have become a 400 on a\n * request that used to work — a regression manufactured by two copies\n * disagreeing about the same rule. Sharing the pattern makes that\n * unrepresentable rather than merely fixed.\n *\n * The capture group is the inner field name. It is whitelisted by a LATER layer\n * (`isInternalField` plus the entity's own field map), which is the right\n * division of labour: this bounds the cache-key space by parameter NAME, that\n * bounds which columns a filter may touch. A `filter[...]` whose inner name does\n * not match — `filter[a.b]`, `filter[]` — is NOT dynamic, falls through to the\n * strict parse, and is refused, which is the whitelist direction D042 asks for.\n */\nexport const FILTER_PARAM_PATTERN = /^filter\\[([a-zA-Z0-9_]+)\\]$/\n\n/**\n * Flatten a URL's query string into the object the param schemas parse, with\n * dynamically-named parameters removed.\n *\n * **This exists because `.strict()` cannot be applied to the raw bag (D042).**\n * Every endpoint parses `Object.fromEntries(url.searchParams)`, which mixes the\n * fixed keys with the dynamic `filter[...]` ones; a naive `.strict()` therefore\n * rejects every filtered list request. Verified by live repro against the pinned\n * `zod@3.25.76` (R15), which also confirmed zod v3 has no pattern-matched key\n * support — `.catchall()` accepts ANY extra key, which would defeat the purpose.\n * So the partition happens here, before the parse.\n *\n * Known and unchanged by this: a repeated parameter (`?limit=1&limit=2`)\n * collapses to the last value, because `Object.fromEntries` over\n * `URLSearchParams.entries()` is last-write-wins. That is a property of the\n * flattening step, not of strictness.\n */\nexport function toParamBag(url: URL, opts?: { allowFilters?: boolean }): Record<string, string> {\n const bag: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) {\n // The exemption is SCOPED to the one endpoint that reads these keys.\n // Stripping them everywhere made `_menu?handle=main&filter[x]=y` silently\n // ignore the filter, which is the blacklist-shaped behaviour D042 exists to\n // replace — and it is precisely the endpoint whose responses are the most\n // cacheable, so an ignored-but-accepted parameter is a free cache-buster\n // there. On every other route a `filter[...]` is simply unknown, and\n // unknown means 400.\n if (opts?.allowFilters === true && FILTER_PARAM_PATTERN.test(key)) continue\n bag[key] = value\n }\n return bag\n}\n\n// Re-export from core so existing imports from this module continue to work\nexport { escapeLikePattern }\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\n/**\n * ISO-8601 calendar date or full timestamp.\n * Accepts: 2024-01-31, 2024-01-31T12:34:56Z, 2024-01-31T12:34:56.789+02:00.\n * Note: shape only — calendar validity (e.g. month 1–12, valid day-of-month)\n * is enforced separately by `isValidIsoDate` so impossible-date values cannot\n * reach Postgres and trigger a 500 cast error.\n */\nconst ISO_DATE_RE =\n /^\\d{4}-\\d{2}-\\d{2}(?:[Tt ]\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:[Zz]|[+-]\\d{2}:?\\d{2})?)?$/\n\n/**\n * Validate ISO-8601 shape AND calendar validity.\n * `Date.parse('2024-13-01')` returns NaN; `Date.parse('2024-02-30')` rolls over\n * to 2024-03-01 in some hosts but produces a different YYYY-MM-DD prefix from\n * the input — comparing the round-tripped date prefix catches both cases.\n */\nexport function isValidIsoDate(raw: string): boolean {\n if (!ISO_DATE_RE.test(raw)) return false\n const ms = Date.parse(raw)\n if (Number.isNaN(ms)) return false\n // For bare YYYY-MM-DD, confirm the parsed date round-trips to the same prefix.\n if (raw.length === 10) {\n return new Date(ms).toISOString().slice(0, 10) === raw\n }\n return true\n}\n\n/** True if `s` parses as a finite number with no trailing junk. */\nfunction isNumericLiteral(s: string): boolean {\n if (s.trim() === '') return false\n const n = Number(s)\n return Number.isFinite(n)\n}\n\n/** True if `id` matches the canonical UUID format used by entity primary keys. */\nexport function isUuid(id: string): boolean {\n return UUID_RE.test(id)\n}\n\n/**\n * Validate a filter value against the field type.\n * Returns the validated value (possibly coerced) or null if invalid.\n *\n * For `date`, accepts a `gte:` / `lte:` prefix; the prefix itself is preserved\n * so the caller can route to the right comparison operator. The bare value\n * (with prefix stripped) must match ISO-8601.\n */\nexport function validateFilterValue(fieldConfig: FieldConfig, raw: string): unknown {\n switch (fieldConfig.type) {\n case 'boolean':\n if (raw === 'true') return true\n if (raw === 'false') return false\n return null\n\n case 'reference':\n case 'media':\n return UUID_RE.test(raw) ? raw : null\n\n case 'select':\n return fieldConfig.options.includes(raw) ? raw : null\n\n case 'date': {\n const bare = raw.startsWith('gte:') || raw.startsWith('lte:') ? raw.slice(4) : raw\n return isValidIsoDate(bare) ? raw : null\n }\n\n case 'number':\n return isNumericLiteral(raw) ? Number(raw) : null\n\n case 'text':\n return raw.length <= MAX_FILTER_TEXT_LENGTH ? raw : null\n\n default:\n return null\n }\n}\n\n/**\n * True when a field is off-limits on this public surface — server-managed\n * (INTERNAL) or ADMIN-ONLY — and therefore absent from rows and `_schema` and\n * refused as a caller-supplied field name (filter, sort, cursor, `?fields=`).\n *\n * The single predicate behind every caller-input boundary in this package, and\n * the two rules `shapeDto` applies to internal fields, plus a third:\n * 1. **Naming convention** — a `_`-prefixed name (`_scopeId`, `_versionId`,\n * `_workflowStatus`, …), checked even when the field isn't declared, so\n * generator-added infrastructure columns are covered too.\n * 2. **Explicit flag** — `internal: true`, which catches fields that don't\n * follow the underscore convention.\n * 3. **Admin-only** — `adminOnly: true` (`auditable()`'s `createdBy` /\n * `updatedBy`). `shapeDto` strips these by a separate gate, so `QueryClient`\n * never returns them; this rule keeps them out of filter/sort too (#1141).\n *\n * Rule 2 matters independently of rule 1: without it a field declared\n * `internal: true` under a plain name is filterable and sortable by any\n * anonymous caller, which is a blind oracle over a server-managed value even\n * though the field never appears in the response body.\n *\n * Since D024 removed `ContentApiConfig.exposeInternalFields`, this predicate is\n * the WHOLE of the rule rather than half of it: an internal field is neither\n * emitted in the read projection nor reachable as a filter, sort, cursor or\n * `?fields=` name — in any configuration, because there is no configuration.\n * That is what lets `_schema` describe the read projection exactly.\n */\nexport function isInternalField(name: string, config: FieldConfig | undefined): boolean {\n // `adminOnly` (e.g. `auditable()`'s `createdBy`/`updatedBy`, #1141) is not\n // internal to the admin, but on THIS surface it is exactly as off-limits:\n // `QueryClient` already never emits it, and without this rule it would stay\n // filterable and sortable — a blind oracle over which user edited what.\n return name.startsWith('_') || config?.internal === true || config?.adminOnly === true\n}\n\n/**\n * Parse field selection string into validated field names.\n * Unknown/internal field names are silently dropped (no info leakage).\n *\n * `undefined` (= \"no projection, return every public field\") is returned ONLY\n * when the caller supplied no `fields` param at all. A caller who DID ask to\n * narrow but named nothing valid gets `['id']`, not `undefined` — otherwise the\n * most restrictive request possible (`?fields=_overrides`) would widen to the\n * full row, which is the opposite of what was asked for.\n */\nexport function parseFieldSelection(\n fieldsParam: string | undefined,\n allFields: Record<string, FieldConfig>,\n): string[] | undefined {\n if (!fieldsParam) return undefined\n\n const requested = fieldsParam.split(',').map((f) => f.trim())\n const valid = requested.filter((name) => {\n // `Object.hasOwn` (not a bare lookup) so prototype-chain keys\n // (`constructor`, `toString`, …) can never resolve to a \"config\".\n if (!Object.hasOwn(allFields, name)) return false\n const config = allFields[name]\n return config !== undefined && !isInternalField(name, config) && config.type !== 'blockTree'\n })\n\n // Always include id — including when nothing else survived, so a narrowing\n // request can never widen back to the full row (see JSDoc).\n if (!valid.includes('id')) {\n valid.unshift('id')\n }\n\n return valid\n}\n\n/** Outcome of parsing `?populate=` — accepted field names plus what was refused. */\nexport interface ParsedPopulate {\n /** Reference fields to expand. De-duplicated, declaration order not preserved. */\n fields: string[]\n /** Names refused by the whitelist — logged server-side at debug, never on the wire. */\n dropped: string[]\n /**\n * How many DISTINCT names the caller asked for, before the whitelist ran.\n * The ceiling is checked against what was ASKED, not against what survived:\n * counting survivors would let a caller name 50 fields and get a 200 as long\n * as 45 of them were typos, so the ceiling would refuse a correct request and\n * wave through a malformed one.\n */\n requested: number\n}\n\n/**\n * Parse `?populate=a,b` into the reference fields to expand (D013).\n *\n * **Whitelist, not blacklist, and it is the SAME whitelist every other\n * caller-supplied field name passes** — `Object.hasOwn` against the declared\n * fields (so prototype-chain keys cannot resolve to a \"config\"), then\n * {@link isInternalField}, then a type check. A name that does not exist, is\n * internal, or is not a `reference` field is DROPPED rather than refused: on\n * the wire that is indistinguishable from an entity that has no such field,\n * which is the same 404-not-403 reasoning the entity gate uses one level up. An\n * over-large field COUNT is a different thing and is refused loudly by the\n * handler — the server never explains a missing target, but it always rejects a\n * malformed request.\n *\n * Note `dropped` carries the refused names for the operator log ONLY. Echoing\n * them back would turn `?populate=` into a field-existence oracle over every\n * entity a caller can view.\n */\nexport function parsePopulate(\n populateParam: string | undefined,\n allFields: Record<string, FieldConfig>,\n): ParsedPopulate {\n if (!populateParam) return { fields: [], dropped: [], requested: 0 }\n\n const requested = [\n ...new Set(\n populateParam\n .split(',')\n .map((f) => f.trim())\n .filter((f) => f.length > 0),\n ),\n ]\n\n const fields: string[] = []\n const dropped: string[] = []\n for (const name of requested) {\n if (!Object.hasOwn(allFields, name)) {\n dropped.push(name)\n continue\n }\n const config = allFields[name]\n if (!config || isInternalField(name, config) || config.type !== 'reference') {\n dropped.push(name)\n continue\n }\n fields.push(name)\n }\n\n return { fields, dropped, requested: requested.length }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"query-params.mjs","names":[],"sources":["../src/query-params.ts"],"sourcesContent":["/**\n * Query parameter validation — Zod schemas for all content API endpoints.\n *\n * All user input is validated before it touches QueryClient.\n * Pagination hard-capped at MAX_LIMIT / MAX_OFFSET — exceeding either is a 400.\n * NOTE for consumers of this published subpath: only `limit`'s cap is enforced\n * by the schema. `offset`'s is enforced by the HANDLER (`ceilingViolation`), so\n * that an over-large offset can carry the `?cursor=` remedy instead of a bare\n * \"Invalid query parameters\" — see the comment on the field. Parsing a request\n * with this schema alone therefore bounds `limit` and not `offset`,\n * never a clamp. Sort/filter restricted to queryable columns.\n */\n\nimport { escapeLikePattern, LOCALE_PATTERN } from '@murumets-ee/core'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { z } from 'zod'\n\nexport const MAX_LIMIT = 100\n\n/**\n * Absolute ceiling on `?offset=`. Beyond it the answer is a 400\n * `VALIDATION_ERROR`, never a silent clamp — a clamp cannot be observed\n * failing, and it would leave a deep-paging client re-reading the same page\n * forever. `?cursor=` is the supported path past this point.\n *\n * The cost being bounded is a full traversal of the MATCHING set on every\n * request: `QueryClient` issues a plain SQL `OFFSET`, so the work is\n * `O(min(offset, matching rows))` — bounded by the result set, not by the\n * caller's integer. That makes a large offset cheap on a small collection and\n * real on a large one, from a caller who is unauthenticated by default with no\n * rate limit in front of them.\n */\nexport const MAX_OFFSET = 1000\n\nexport const MAX_FILTER_TEXT_LENGTH = 200\n\n/**\n * Page size applied when the caller sends no `?limit=`.\n *\n * Named rather than inlined because `GET /_schema` PUBLISHES it: a client that\n * reads the default off the wire and one that hard-codes it can never disagree,\n * and lowering it here lowers what the contract advertises in the same edit.\n * Same reasoning for every other cap in this block.\n */\nexport const DEFAULT_LIMIT = 20\n\n/** Offset applied when the caller sends no `?offset=`. */\nexport const DEFAULT_OFFSET = 0\n\n/** Longest `?search=` accepted — rejected as a 400 before any DB work. */\nexport const MAX_SEARCH_LENGTH = 200\n\n/**\n * Shortest `?search=` accepted, measured AFTER trim. Below it: `400\n * VALIDATION_ERROR` (D046).\n *\n * **Its direction is inverted from every other ceiling here, deliberately.** A\n * limit like `maxLimit` is protective when LOWERED, so a host may only lower it.\n * A minimum is protective when RAISED, so the package sets the floor and a host\n * may make it stricter and never looser. Same rule in both cases — the caller\n * cannot move it, and the host cannot move it toward more work.\n *\n * No new error code: D031's partition rule is that a code names what the client\n * should do next, never which parameter was wrong, and `VALIDATION_ERROR`\n * already means \"fix your request\". Rejecting rather than silently returning an\n * empty set is the other half — a silent empty result is unobservable, so the\n * check could never be SEEN to fail, and a frontend could not tell the user to\n * type more.\n *\n * **BE HONEST ABOUT WHAT THIS BUYS.** It removes the most degenerate case —\n * `%a%` matching essentially every row, forcing a full scan and returning an\n * arbitrary page — and removes nothing else; `%ab%` on a Latin-script corpus\n * still matches almost everything. THE BOUND ON SEARCH COST IS THE SURVIVAL\n * GUARD'S WALL CLOCK AND SEMAPHORE (see `limits.ts`), NOT THIS FLOOR, and it\n * must not be presented in `_schema`, in a docstring or in review as though it\n * were the protection.\n *\n * Nor is it peer convention: R15 verified that Algolia enforces no server-side\n * minimum (its `minLength` is a client-side InstantSearch affordance) and found\n * no documented one for Contentful or Sanity. The justification is local — an\n * unindexed `ILIKE '%q%'` OR'd with a correlated per-row `EXISTS` on the\n * translations table, with no trigram index anywhere in this repo.\n *\n * 2 matches `DEFAULT_MECHANICS.minQueryLength` in `@murumets-ee/search`.\n */\nexport const MIN_SEARCH_LENGTH = 2\n\n/**\n * Zero-width and word-joiner characters, stripped before the floor is measured.\n *\n * `String.prototype.trim()` removes Unicode `White_Space`, which does NOT\n * include U+200B ZERO WIDTH SPACE and friends. So `?search=a​` trims to\n * length 2, clears the floor, and produces the `ILIKE '%a​%'` full scan the\n * floor exists to refuse — while matching nothing, so the caller pays no penalty\n * for the trick. Stripping them costs one regex on a string already capped at\n * 200 characters.\n *\n * Not exhaustive against every homoglyph or combining sequence, and not meant to\n * be: per {@link MIN_SEARCH_LENGTH}, the floor is a cheap filter on the worst\n * input and the survival guard is the actual bound. This closes the one bypass\n * that costs nothing to close.\n */\n// Written as escapes, not literals: the characters are invisible in source, so\n// a literal class is unreviewable and un-greppable.\n// U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+2060 WORD JOINER, U+FEFF BOM.\nconst ZERO_WIDTH_RE = /[\\u200B-\\u200D\\u2060\\uFEFF]/g\n\n/** Trim, then discard zero-width padding, for length measurement only. */\nexport function normalizeSearchLengthInput(raw: string): string {\n return raw.replace(ZERO_WIDTH_RE, '').trim()\n}\n\n/** Longest `?cursor=` accepted. Opaque base64 JSON; the cap bounds the decode. */\nexport const MAX_CURSOR_LENGTH = 500\n\n/** Longest `?path=` accepted by `_resolve` — a generous URL-path cap. */\nexport const MAX_RESOLVE_PATH_LENGTH = 2048\n\n/** Longest `?entity=` accepted by `_paths` / `_alternates`. */\nexport const MAX_ENTITY_NAME_LENGTH = 100\n\n/**\n * Longest `?handle=` accepted by `_menu` — a defensive cap, deliberately NOT\n * tied to the menu entity's `handle` column width (`varchar(255)`). A handle is\n * a hand-authored navigation key, so 128 is far above any real one; the point is\n * only to bound the string before it reaches the allowlist check, never to\n * mirror the column. An over-long value is a 400 either way.\n */\nexport const MAX_MENU_HANDLE_LENGTH = 128\n\n/** Page size `_paths` applies when the caller sends no `?limit=`. */\nexport const DEFAULT_PATHS_LIMIT = 20\n\n/**\n * Most reference fields one request may `?populate=` (D013).\n *\n * Server-owned and non-raisable: full expansion replaces a fixed-size\n * projection with the target's whole public DTO, so each named field makes the\n * response's size a function of what it happens to point at. Exceeding this is\n * a 400 `VALIDATION_ERROR`, never a silent truncation — the same reject-never-\n * clamp posture D007 fixed for `?offset=`, and for the same reason: a clamp\n * answers 200 with something the caller did not ask for and cannot be observed\n * failing from the outside.\n *\n * Five, because it bounds a MULTIPLIER rather than a count. The number of rows\n * is already bounded by `maxLimit`, and the number of resolutions by the\n * per-document budget; this bounds how much each row can grow.\n */\nexport const MAX_POPULATE_FIELDS = 5\n\n/**\n * Longest `?populate=` string accepted, before it is split. Bounds the parse\n * itself, independently of the field-count ceiling — a caller must not be able\n * to hand us a megabyte to `split(',')`.\n */\nexport const MAX_POPULATE_LENGTH = 200\n\n/** List endpoint query params. */\n/**\n * `.strict()` on every schema in this module: an unrecognized parameter is a 400\n * `VALIDATION_ERROR`, not a silently-stripped key (D042).\n *\n * Zod's default is `strip`, so `?limit=20` and `?limit=20&anything=1` returned a\n * BYTE-IDENTICAL body under two distinct URLs — and so did the next million\n * values of `anything`. On a URL-keyed shared cache that is unbounded,\n * caller-controlled key fragmentation, worse than an ordinary miss in both\n * directions at once: every fabricated key MISSES (so it reaches the origin) and\n * OCCUPIES an entry (so it evicts the working set serving real visitors). No\n * credential required, a few bytes per request (F031).\n *\n * This is the whitelist rule reaching the last input surface still running\n * blacklist-shaped — filter keys, sort fields, cursor fields and `?fields=`\n * already go through `isInternalField`, and `config.entities` / `menus.handles`\n * are already deny-by-default allowlists.\n *\n * **What this does and does not stop, stated precisely.** The parse precedes\n * every query, so a fabricated-key request is refused BEFORE any database work —\n * that is the whole of the protection, and it is real: the flood costs a schema\n * parse rather than two queries. It is NOT absorbed at the edge. An earlier\n * revision of this docblock said it was, on the strength of D037 giving that 4xx\n * a cacheable directive; SD005 made every refusal `no-store`, so 100% of such a\n * flood reaches the origin and is metered by the survival guard and the fairness\n * limit instead (D025). Left corrected rather than deleted because a stale\n * security rationale is the kind of comment a later risk assessment leans on.\n *\n * Breaking-shaped and free: D010 records that no content-api consumer is live,\n * and D008's \"a client derives nothing\" means a conforming client reads its\n * parameter set from `_schema` and can never send one the server did not\n * publish.\n *\n * **What it does NOT bound: parameter VALUES.** `?search=` is a whitelisted name\n * with an unbounded value space inside its 200-character cap, so a fabricated\n * search value is still a guaranteed miss against the most expensive query on\n * the surface. That is F040, and it is the survival guard's and the fairness\n * limit's to meter, not this schema's.\n */\nexport const listParamsSchema = z\n .object({\n limit: z.coerce.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_LIMIT),\n // NO `.max(MAX_OFFSET)` here — deliberately, and it is not an oversight.\n //\n // The handler's `ceilingViolation` answers an over-large offset with the\n // remedy the caller actually needs: \"?offset=N exceeds the maximum of M. Use\n // ?cursor= for deep pagination.\" A zod `.max()` fires FIRST and flattens that\n // to a bare \"Invalid query parameters\", so the remedy was unreachable for the\n // package ceiling and only appeared for a host-lowered one — the deep-paging\n // caller, i.e. exactly the caller who needs the hint, never saw it.\n //\n // Nothing is unbounded by removing it: `maxOffset` is `Math.min(config ?? MAX_OFFSET,\n // MAX_OFFSET)`, so the effective ceiling is still capped at the package value,\n // and `ceilingViolation` runs before any DB work. `limit` keeps its `.max()`\n // because it has no equivalent remedy to lose.\n offset: z.coerce.number().int().min(0).default(DEFAULT_OFFSET),\n sort: z\n .string()\n .regex(/^-?[a-zA-Z_]+$/)\n .optional(),\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n fields: z.string().optional(),\n /**\n * `?search=` — see {@link MIN_SEARCH_LENGTH} for why the floor exists and\n * what it does NOT buy. Whitespace-only is treated as absent, matching the\n * pre-existing `buildSearchCondition` behaviour; anything else below the floor\n * is a 400.\n */\n search: z\n .string()\n .max(MAX_SEARCH_LENGTH)\n .refine(\n (raw) => {\n const trimmed = normalizeSearchLengthInput(raw)\n return trimmed.length === 0 || trimmed.length >= MIN_SEARCH_LENGTH\n },\n { message: `search must be at least ${MIN_SEARCH_LENGTH} characters` },\n )\n .optional(),\n /** Opaque cursor for keyset pagination (base64-encoded JSON). */\n cursor: z.string().max(MAX_CURSOR_LENGTH).optional(),\n /**\n * Comma-separated reference fields to expand to their target's full public\n * DTO (D013). The LENGTH cap is here so a malformed value is a 400 before it\n * is split; the FIELD-COUNT ceiling is enforced by the handler so the 400 can\n * name the ceiling — same division of labour as `limit` vs `offset` above.\n */\n populate: z.string().max(MAX_POPULATE_LENGTH).optional(),\n })\n .strict()\n\n/**\n * The highest `?page=` a document read accepts — the page a PAGED collection in\n * its block trees shows (davatoidud-toolkit PR03). Equal to\n * `COLLECTION_MAX_PAGE` in `@murumets-ee/blocks` (a test there pins the two;\n * this package cannot import that one).\n */\nexport const MAX_COLLECTION_PAGE = 100\n\n/**\n * A page number in its ONE canonical spelling — digits, no leading zero, 1 to\n * {@link MAX_COLLECTION_PAGE}. Anything else is a 400, so `?page=02` is never a\n * second address of `?page=2` in a cache in front of this API.\n */\nexport const COLLECTION_PAGE_PATTERN = /^(?:[1-9][0-9]?|100)$/\n\n/** Single entity query params. */\nexport const singleParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n fields: z.string().optional(),\n populate: z.string().max(MAX_POPULATE_LENGTH).optional(),\n /** The page a paged collection in the document's block trees shows. */\n page: z.string().regex(COLLECTION_PAGE_PATTERN).transform(Number).optional(),\n })\n .strict()\n\n/** Count endpoint query params. */\nexport const countParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n })\n .strict()\n\n/**\n * Parameter NAMES this surface accepts dynamically rather than by fixed key.\n *\n * `filter[<field>]` is a published parameter family, read by\n * `buildFilterConditions` straight off `url.searchParams` in a pass separate\n * from the zod parse. It must be partitioned out before a `.strict()` parse or\n * every filtered request would 400 — see {@link toParamBag}.\n *\n * **ONE pattern, exported, used by BOTH the partition and the extraction.** They\n * were two copies of the same literal, and they had already drifted from what an\n * entity field name can actually be: the class excluded DIGITS, so\n * `filter[heading2]` silently matched neither. Before D042 that meant the filter\n * was quietly ignored; with `.strict()` in front it would have become a 400 on a\n * request that used to work — a regression manufactured by two copies\n * disagreeing about the same rule. Sharing the pattern makes that\n * unrepresentable rather than merely fixed.\n *\n * The capture group is the inner field name. It is whitelisted by a LATER layer\n * (`isInternalField` plus the entity's own field map), which is the right\n * division of labour: this bounds the cache-key space by parameter NAME, that\n * bounds which columns a filter may touch. A `filter[...]` whose inner name does\n * not match — `filter[a.b]`, `filter[]` — is NOT dynamic, falls through to the\n * strict parse, and is refused, which is the whitelist direction D042 asks for.\n */\nexport const FILTER_PARAM_PATTERN = /^filter\\[([a-zA-Z0-9_]+)\\]$/\n\n/**\n * Flatten a URL's query string into the object the param schemas parse, with\n * dynamically-named parameters removed.\n *\n * **This exists because `.strict()` cannot be applied to the raw bag (D042).**\n * Every endpoint parses `Object.fromEntries(url.searchParams)`, which mixes the\n * fixed keys with the dynamic `filter[...]` ones; a naive `.strict()` therefore\n * rejects every filtered list request. Verified by live repro against the pinned\n * `zod@3.25.76` (R15), which also confirmed zod v3 has no pattern-matched key\n * support — `.catchall()` accepts ANY extra key, which would defeat the purpose.\n * So the partition happens here, before the parse.\n *\n * Known and unchanged by this: a repeated parameter (`?limit=1&limit=2`)\n * collapses to the last value, because `Object.fromEntries` over\n * `URLSearchParams.entries()` is last-write-wins. That is a property of the\n * flattening step, not of strictness.\n */\nexport function toParamBag(url: URL, opts?: { allowFilters?: boolean }): Record<string, string> {\n const bag: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) {\n // The exemption is SCOPED to the one endpoint that reads these keys.\n // Stripping them everywhere made `_menu?handle=main&filter[x]=y` silently\n // ignore the filter, which is the blacklist-shaped behaviour D042 exists to\n // replace — and it is precisely the endpoint whose responses are the most\n // cacheable, so an ignored-but-accepted parameter is a free cache-buster\n // there. On every other route a `filter[...]` is simply unknown, and\n // unknown means 400.\n if (opts?.allowFilters === true && FILTER_PARAM_PATTERN.test(key)) continue\n bag[key] = value\n }\n return bag\n}\n\n// Re-export from core so existing imports from this module continue to work\nexport { escapeLikePattern }\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\n/**\n * ISO-8601 calendar date or full timestamp.\n * Accepts: 2024-01-31, 2024-01-31T12:34:56Z, 2024-01-31T12:34:56.789+02:00.\n * Note: shape only — calendar validity (e.g. month 1–12, valid day-of-month)\n * is enforced separately by `isValidIsoDate` so impossible-date values cannot\n * reach Postgres and trigger a 500 cast error.\n */\nconst ISO_DATE_RE =\n /^\\d{4}-\\d{2}-\\d{2}(?:[Tt ]\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:[Zz]|[+-]\\d{2}:?\\d{2})?)?$/\n\n/**\n * Validate ISO-8601 shape AND calendar validity.\n * `Date.parse('2024-13-01')` returns NaN; `Date.parse('2024-02-30')` rolls over\n * to 2024-03-01 in some hosts but produces a different YYYY-MM-DD prefix from\n * the input — comparing the round-tripped date prefix catches both cases.\n */\nexport function isValidIsoDate(raw: string): boolean {\n if (!ISO_DATE_RE.test(raw)) return false\n const ms = Date.parse(raw)\n if (Number.isNaN(ms)) return false\n // For bare YYYY-MM-DD, confirm the parsed date round-trips to the same prefix.\n if (raw.length === 10) {\n return new Date(ms).toISOString().slice(0, 10) === raw\n }\n return true\n}\n\n/** True if `s` parses as a finite number with no trailing junk. */\nfunction isNumericLiteral(s: string): boolean {\n if (s.trim() === '') return false\n const n = Number(s)\n return Number.isFinite(n)\n}\n\n/** True if `id` matches the canonical UUID format used by entity primary keys. */\nexport function isUuid(id: string): boolean {\n return UUID_RE.test(id)\n}\n\n/**\n * Validate a filter value against the field type.\n * Returns the validated value (possibly coerced) or null if invalid.\n *\n * For `date`, accepts a `gte:` / `lte:` prefix; the prefix itself is preserved\n * so the caller can route to the right comparison operator. The bare value\n * (with prefix stripped) must match ISO-8601.\n */\nexport function validateFilterValue(fieldConfig: FieldConfig, raw: string): unknown {\n switch (fieldConfig.type) {\n case 'boolean':\n if (raw === 'true') return true\n if (raw === 'false') return false\n return null\n\n case 'reference':\n case 'media':\n return UUID_RE.test(raw) ? raw : null\n\n case 'select':\n return fieldConfig.options.includes(raw) ? raw : null\n\n case 'date': {\n const bare = raw.startsWith('gte:') || raw.startsWith('lte:') ? raw.slice(4) : raw\n return isValidIsoDate(bare) ? raw : null\n }\n\n case 'number':\n return isNumericLiteral(raw) ? Number(raw) : null\n\n case 'text':\n return raw.length <= MAX_FILTER_TEXT_LENGTH ? raw : null\n\n default:\n return null\n }\n}\n\n/**\n * True when a field is off-limits on this public surface — server-managed\n * (INTERNAL) or ADMIN-ONLY — and therefore absent from rows and `_schema` and\n * refused as a caller-supplied field name (filter, sort, cursor, `?fields=`).\n *\n * The single predicate behind every caller-input boundary in this package, and\n * the two rules `shapeDto` applies to internal fields, plus a third:\n * 1. **Naming convention** — a `_`-prefixed name (`_scopeId`, `_versionId`,\n * `_workflowStatus`, …), checked even when the field isn't declared, so\n * generator-added infrastructure columns are covered too.\n * 2. **Explicit flag** — `internal: true`, which catches fields that don't\n * follow the underscore convention.\n * 3. **Admin-only** — `adminOnly: true` (`auditable()`'s `createdBy` /\n * `updatedBy`). `shapeDto` strips these by a separate gate, so `QueryClient`\n * never returns them; this rule keeps them out of filter/sort too (#1141).\n *\n * Rule 2 matters independently of rule 1: without it a field declared\n * `internal: true` under a plain name is filterable and sortable by any\n * anonymous caller, which is a blind oracle over a server-managed value even\n * though the field never appears in the response body.\n *\n * Since D024 removed `ContentApiConfig.exposeInternalFields`, this predicate is\n * the WHOLE of the rule rather than half of it: an internal field is neither\n * emitted in the read projection nor reachable as a filter, sort, cursor or\n * `?fields=` name — in any configuration, because there is no configuration.\n * That is what lets `_schema` describe the read projection exactly.\n */\nexport function isInternalField(name: string, config: FieldConfig | undefined): boolean {\n // `adminOnly` (e.g. `auditable()`'s `createdBy`/`updatedBy`, #1141) is not\n // internal to the admin, but on THIS surface it is exactly as off-limits:\n // `QueryClient` already never emits it, and without this rule it would stay\n // filterable and sortable — a blind oracle over which user edited what.\n return name.startsWith('_') || config?.internal === true || config?.adminOnly === true\n}\n\n/**\n * Parse field selection string into validated field names.\n * Unknown/internal field names are silently dropped (no info leakage).\n *\n * `undefined` (= \"no projection, return every public field\") is returned ONLY\n * when the caller supplied no `fields` param at all. A caller who DID ask to\n * narrow but named nothing valid gets `['id']`, not `undefined` — otherwise the\n * most restrictive request possible (`?fields=_overrides`) would widen to the\n * full row, which is the opposite of what was asked for.\n */\nexport function parseFieldSelection(\n fieldsParam: string | undefined,\n allFields: Record<string, FieldConfig>,\n): string[] | undefined {\n if (!fieldsParam) return undefined\n\n const requested = fieldsParam.split(',').map((f) => f.trim())\n const valid = requested.filter((name) => {\n // `Object.hasOwn` (not a bare lookup) so prototype-chain keys\n // (`constructor`, `toString`, …) can never resolve to a \"config\".\n if (!Object.hasOwn(allFields, name)) return false\n const config = allFields[name]\n return config !== undefined && !isInternalField(name, config) && config.type !== 'blockTree'\n })\n\n // Always include id — including when nothing else survived, so a narrowing\n // request can never widen back to the full row (see JSDoc).\n if (!valid.includes('id')) {\n valid.unshift('id')\n }\n\n return valid\n}\n\n/** Outcome of parsing `?populate=` — accepted field names plus what was refused. */\nexport interface ParsedPopulate {\n /** Reference fields to expand. De-duplicated, declaration order not preserved. */\n fields: string[]\n /** Names refused by the whitelist — logged server-side at debug, never on the wire. */\n dropped: string[]\n /**\n * How many DISTINCT names the caller asked for, before the whitelist ran.\n * The ceiling is checked against what was ASKED, not against what survived:\n * counting survivors would let a caller name 50 fields and get a 200 as long\n * as 45 of them were typos, so the ceiling would refuse a correct request and\n * wave through a malformed one.\n */\n requested: number\n}\n\n/**\n * Parse `?populate=a,b` into the reference fields to expand (D013).\n *\n * **Whitelist, not blacklist, and it is the SAME whitelist every other\n * caller-supplied field name passes** — `Object.hasOwn` against the declared\n * fields (so prototype-chain keys cannot resolve to a \"config\"), then\n * {@link isInternalField}, then a type check. A name that does not exist, is\n * internal, or is not a `reference` field is DROPPED rather than refused: on\n * the wire that is indistinguishable from an entity that has no such field,\n * which is the same 404-not-403 reasoning the entity gate uses one level up. An\n * over-large field COUNT is a different thing and is refused loudly by the\n * handler — the server never explains a missing target, but it always rejects a\n * malformed request.\n *\n * Note `dropped` carries the refused names for the operator log ONLY. Echoing\n * them back would turn `?populate=` into a field-existence oracle over every\n * entity a caller can view.\n */\nexport function parsePopulate(\n populateParam: string | undefined,\n allFields: Record<string, FieldConfig>,\n): ParsedPopulate {\n if (!populateParam) return { fields: [], dropped: [], requested: 0 }\n\n const requested = [\n ...new Set(\n populateParam\n .split(',')\n .map((f) => f.trim())\n .filter((f) => f.length > 0),\n ),\n ]\n\n const fields: string[] = []\n const dropped: string[] = []\n for (const name of requested) {\n if (!Object.hasOwn(allFields, name)) {\n dropped.push(name)\n continue\n }\n const config = allFields[name]\n if (!config || isInternalField(name, config) || config.type !== 'reference') {\n dropped.push(name)\n continue\n }\n fields.push(name)\n }\n\n return { fields, dropped, requested: requested.length }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/content-api",
3
- "version": "0.93.0",
3
+ "version": "0.95.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,9 +19,9 @@
19
19
  "dependencies": {
20
20
  "drizzle-orm": "^0.45.2",
21
21
  "zod": "^3.24.1",
22
- "@murumets-ee/core": "0.93.0",
23
- "@murumets-ee/entity": "0.93.0",
24
- "@murumets-ee/db": "0.93.0"
22
+ "@murumets-ee/core": "0.95.0",
23
+ "@murumets-ee/entity": "0.95.0",
24
+ "@murumets-ee/db": "0.95.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "tsdown": "^0.22.2",