@aglyn/aglyn 1.0.0-beta.235 → 1.0.0-beta.237

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/docs-help-sections.generated.js +6 -3
  3. package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
  4. package/src/lib/app-utils/docs-help.generated.d.ts +2 -2
  5. package/src/lib/app-utils/docs-help.generated.js +5 -0
  6. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  7. package/src/lib/app-utils/docs-index.generated.js +119 -14
  8. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  9. package/src/lib/app-utils/element-ui.d.ts +21 -2
  10. package/src/lib/app-utils/element-ui.js +29 -2
  11. package/src/lib/app-utils/element-ui.js.map +1 -1
  12. package/src/lib/app-utils/foreign-dom-guard.d.ts +17 -0
  13. package/src/lib/app-utils/foreign-dom-guard.js +66 -0
  14. package/src/lib/app-utils/foreign-dom-guard.js.map +1 -0
  15. package/src/lib/app-utils/media-filter.js +5 -1
  16. package/src/lib/app-utils/media-filter.js.map +1 -1
  17. package/src/lib/app-utils/media-metadata.d.ts +3 -2
  18. package/src/lib/app-utils/media-metadata.js +4 -0
  19. package/src/lib/app-utils/media-metadata.js.map +1 -1
  20. package/src/lib/app-utils/media-picker-context.d.ts +5 -1
  21. package/src/lib/app-utils/media-picker-context.js +9 -0
  22. package/src/lib/app-utils/media-picker-context.js.map +1 -1
  23. package/src/lib/app-utils/plugin-api-rate-limit.js +3 -1
  24. package/src/lib/app-utils/plugin-api-rate-limit.js.map +1 -1
  25. package/src/lib/app-utils/release-flags.js +6 -6
  26. package/src/lib/app-utils/release-flags.js.map +1 -1
  27. package/src/lib/app-utils/upload-inspection.js +88 -0
  28. package/src/lib/app-utils/upload-inspection.js.map +1 -1
  29. package/src/lib/foundation/definitions/components.types.d.ts +6 -0
  30. package/src/lib/foundation/definitions/components.types.js.map +1 -1
  31. package/src/lib/foundation/definitions/organization.types.d.ts +7 -0
  32. package/src/lib/foundation/definitions/organization.types.js.map +1 -1
  33. package/src/lib/plugin-manager/first-party-plugins.generated.js +18 -0
  34. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  35. package/src/lib/plugin-manager/stock-photo-provider.d.ts +5 -0
  36. package/src/lib/plugin-manager/stock-photo-provider.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-metadata.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Asset metadata helpers (AGL-173): tag normalization shared by the\n * console editor and any future API validation, plus a dependency-free\n * image header parser so the upload route can stamp dimensions without\n * pulling in an image library.\n */\n\nimport { MEDIA_ALT_MAX_LENGTH } from './media-alt'\nimport { embeddedMetadataIsCurrent, mediaEmbeddedSearchText } from './media-embedded-fields'\nimport { NAME_TOKEN_LIMIT, nameSearchKey, nameSearchToken, nameSearchTokens } from './name-search'\n\nexport * from './media-alt'\n\n/**\n * The family a stored file belongs to, as the media library's Type filter\n * names it (AGL-3327). Stored on the document as `kind`, because a Firestore\n * query can ask for a value and cannot ask for a prefix beside a list's own\n * sort: `kind == 'image'` sits on any query the library builds, where a\n * `contentType` range would take the one range field the query has.\n *\n * Every type media ingress accepts that is not a picture, a film or a PDF is\n * a document — ZIP, Word, Excel, PowerPoint, CSV, text, Markdown, JSON — and\n * so is anything older than that allowlist.\n */\nexport type MediaKind = 'image' | 'video' | 'pdf' | 'document'\n\nexport const MEDIA_KINDS: readonly MediaKind[] = ['image', 'video', 'pdf', 'document']\n\nexport function mediaKindOf(contentType: unknown): MediaKind {\n const type = String(contentType ?? '').trim().toLowerCase()\n if (type.startsWith('image/')) return 'image'\n if (type.startsWith('video/')) return 'video'\n if (type === 'application/pdf') return 'pdf'\n return 'document'\n}\n\n/** Which way a picture or a film is longer, as the Orientation filter asks. */\nexport type MediaOrientation = 'landscape' | 'portrait' | 'square'\n\nexport const MEDIA_ORIENTATIONS: readonly MediaOrientation[] = [\n 'landscape',\n 'portrait',\n 'square',\n]\n\nconst positive = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null\n\n/**\n * The orientation of a stored file (AGL-3327), from the pixel size it\n * carries: a picture's `width` and `height`, or a film's `video.width` and\n * `video.height` (a video's own `width`/`height` are never written — see\n * `videoMediaProps`). Null when neither pair is whole, which a query for any\n * orientation then leaves out rather than guesses at.\n */\nexport function mediaOrientationOf(media: {\n width?: unknown\n height?: unknown\n video?: unknown\n}): MediaOrientation | null {\n const video =\n media.video && typeof media.video === 'object'\n ? (media.video as { width?: unknown; height?: unknown })\n : null\n let width = positive(media.width)\n let height = positive(media.height)\n if (width === null || height === null) {\n width = positive(video?.width)\n height = positive(video?.height)\n }\n if (width === null || height === null) return null\n if (width === height) return 'square'\n return width > height ? 'landscape' : 'portrait'\n}\n\n/**\n * A file name as words (AGL-3327): every run of characters that is not a\n * letter or a digit becomes a space, so `hero-banner_2x.png` is the four\n * words a person would search it by. The shared name search splits on\n * spaces only, which suits a person's name and misses nearly every file's.\n */\nexport function mediaNameWords(fileName: unknown): string {\n return String(fileName ?? '')\n .replace(/[^\\p{L}\\p{N}]+/gu, ' ')\n .trim()\n}\n\n/**\n * The one token a library search asks for (AGL-3327): the first word of what\n * was typed, folded the way `mediaNameWords` folds a stored name, lower-cased\n * and capped as `nameSearchToken` caps it. A query holds one array filter, so\n * a search is one word.\n */\nexport function mediaSearchToken(query: unknown): string {\n return nameSearchToken(mediaNameWords(query))\n}\n\n/** The derived fields every writer of a media document stores beside it. */\nexport interface MediaFilterKeys {\n kind: MediaKind\n /**\n * `nameSearchKey` of the file name: the Name sort, and the \"starts with\"\n * range a search is served by where the scope clause holds the query's one\n * array filter.\n */\n nameLower: string\n /**\n * Word-prefix tokens of the file name's words, then of the details read\n * from inside the file (AGL-3339), for search — at most\n * `NAME_TOKEN_LIMIT`, the name's first.\n */\n nameTokens: string[]\n /** Whether alt text is written — \"missing\" is a value a query can find. */\n hasAlt: boolean\n /** Which way the file is longer, or null when its size is not known. */\n orientation: MediaOrientation | null\n}\n\n/** What `mediaFilterKeys` reads off a media document. */\nexport interface MediaFilterSource {\n fileName?: unknown\n contentType?: unknown\n alt?: unknown\n width?: unknown\n height?: unknown\n video?: unknown\n /** The details read from inside the file (AGL-3339), searched after its name. */\n embeddedMetadata?: unknown\n /** The bytes' digest, which says whether `embeddedMetadata` describes them. */\n contentSha256?: unknown\n}\n\n/**\n * What a library search finds a file by (AGL-3339): every word prefix of its\n * name, then of the details read from inside it — a title, keywords, who\n * made it, where — inside the one `NAME_TOKEN_LIMIT` a document keeps, so a\n * long caption never crowds out the name. Details that describe other bytes\n * than the file's (`embeddedMetadataIsCurrent`) add nothing.\n */\nexport function mediaNameTokens(\n fileName: unknown,\n embeddedMetadata?: unknown,\n contentSha256?: unknown,\n): string[] {\n const tokens = new Set(nameSearchTokens(mediaNameWords(fileName)))\n if (!embeddedMetadataIsCurrent(embeddedMetadata, contentSha256)) return [...tokens]\n for (const token of nameSearchTokens(mediaNameWords(mediaEmbeddedSearchText(embeddedMetadata)))) {\n if (tokens.size >= NAME_TOKEN_LIMIT) break\n tokens.add(token)\n }\n return [...tokens]\n}\n\n/**\n * What the media library filters, sorts and searches by, derived from the\n * fields a document already carries (AGL-3327). Every writer — the upload\n * routes, replace, the REST API, restore, the Details drawer — spreads this\n * beside the fields it writes, derived from the document as it will stand\n * after the write, so a query finds every file by it. The backfill\n * (`tools/scripts/backfill-media-filter-keys.mjs`) stamps the documents\n * written before it existed, through `tools/scripts/lib/media-filter-keys.mjs`;\n * both are held to `media-filter-keys.fixtures.json` beside it.\n */\nexport function mediaFilterKeys(media: MediaFilterSource): MediaFilterKeys {\n const fileName = String(media.fileName ?? '')\n return {\n kind: mediaKindOf(media.contentType),\n nameLower: nameSearchKey(fileName),\n nameTokens: mediaNameTokens(fileName, media.embeddedMetadata, media.contentSha256),\n hasAlt: String(media.alt ?? '').trim().length > 0,\n orientation: mediaOrientationOf(media),\n }\n}\n\nexport const MEDIA_TAG_MAX_COUNT = 20\nexport const MEDIA_TAG_MAX_LENGTH = 40\n\n/**\n * Trim, lowercase, dedupe, and cap tags. Accepts a comma-separated string\n * or an array; empty and oversized entries are dropped.\n */\nexport function normalizeMediaTags(input: string | string[]): string[] {\n const raw = Array.isArray(input) ? input : String(input ?? '').split(',')\n const seen = new Set<string>()\n const tags: string[] = []\n for (const entry of raw) {\n const tag = String(entry ?? '').trim().toLowerCase()\n if (!tag || tag.length > MEDIA_TAG_MAX_LENGTH || seen.has(tag)) continue\n seen.add(tag)\n tags.push(tag)\n if (tags.length >= MEDIA_TAG_MAX_COUNT) break\n }\n return tags\n}\n\n/**\n * What a placement should store for `alt` once the asset's own alt text is\n * taken into account (AGL-1896) — the DAM asset is the DEFAULT, the\n * placement keeps the override.\n *\n * `AglynHostMedia.alt` has existed since AGL-173 and the library drawer has\n * always been able to set it; what never existed is anybody READING it. Every\n * placement surface asked the author to type alt again from scratch, so the\n * same logo on eight pages needed its alt typed eight times and in practice\n * shipped blank — on a customer's published site.\n *\n * ONE function, called at pick time by every surface, rather than a rule\n * re-derived per surface. The three refusals are the whole contract:\n *\n * * **`decorative` wins outright.** It is the field AGL-1305 added to record\n * \"screen readers should skip this\", and `image.tsx` already forces\n * `alt=\"\"` over any alt text when it is on. Inheriting into a node that has\n * declared itself decorative would put text on a node whose renderer\n * discards it — invisible, and misleading to the next author who opens the\n * panel.\n * * **A non-blank placement alt wins.** That is the per-placement override,\n * and clobbering it is the one failure that would make this feature worse\n * than not having it: the author's sentence about THIS placement is better\n * than the asset's generic one by construction.\n * * **A blank asset alt yields nothing.** Never a fabricated default. The\n * file name is not alt text (\"IMG_4021.jpg\" announced to a screen reader is\n * worse than silence), and nothing here has seen the image. Returning\n * `undefined` is what lets callers omit the key entirely rather than\n * writing `alt: ''`, which on a besigner node is itself an authored value.\n *\n * A blank placement alt DOES inherit, deliberately. Presets ship `alt: ''`\n * (see `card.tsx`), so requiring an absent key would have skipped the single\n * commonest authoring path — dropping a preset and pointing its image at a\n * library asset — and left the issue open for the case it was filed about.\n *\n * @returns the alt to store, or `undefined` when the caller should write\n * nothing at all.\n */\nexport function inheritedMediaAlt(options: {\n /** The alt already on the placement — a node prop, a config field. */\n placementAlt?: unknown\n /** The placement's explicit \"skip me\" intent, when it has one. */\n decorative?: unknown\n /** The chosen DAM asset's stored alt text. */\n assetAlt?: unknown\n}): string | undefined {\n const { placementAlt, decorative, assetAlt } = options ?? {}\n if (decorative === true) return undefined\n if (typeof placementAlt === 'string' && placementAlt.trim()) return undefined\n const inherited = typeof assetAlt === 'string' ? assetAlt.trim() : ''\n if (!inherited) return undefined\n // Capped at the same length the library drawer saves through, so an alt\n // that reaches a placement is one the DAM would also have stored.\n return inherited.slice(0, MEDIA_ALT_MAX_LENGTH)\n}\n\n/**\n * Component ids whose renderer reads intrinsic pixel dimensions off the node.\n *\n * Component ids are persisted in screen documents and never renamed, which is\n * what makes matching on them safe. The list is the gate rather than a\n * decoration: a prop written onto an element that does not destructure it\n * reaches `...rest` and is spread onto the DOM, so an unlisted element would\n * gain an invalid `intrinsicwidth` attribute in its published HTML.\n *\n * ⛔ `video` is NOT here, and its absence is a decision rather than an\n * oversight (AGL-2749). A video needs the pair at least as badly as an image\n * does — `preload=\"none\"` means its metadata never arrives until someone\n * presses play, so the element is zero-height for the whole life of the page\n * without it — but it cannot get it from `width`/`height`. Those are read\n * from the bytes by the server and are absent on every video, deliberately:\n * AGL-2742 kept the client-measured triple in its own `video` record so a\n * reader can tell a server measurement from a browser's report.\n * {@link videoMediaProps} is where a video gets its pair.\n */\nconst INTRINSIC_SIZE_COMPONENT_IDS = new Set(['image'])\n\n/**\n * The intrinsic `width`/`height` to copy onto a node when an author picks a\n * library asset for it (AGL-2486).\n *\n * ## Why the copy happens at pick time, and why it is not the last word\n *\n * The pair rides on the node like every other prop, so the editor canvas, and\n * any page whose asset cannot be read, still reserve a box. It is not the last\n * word. A replace rewrites the asset's `width`/`height` and cannot reach the\n * nodes that copied them, so the tenant composition reads every placed\n * image's document, in one projected batch per page, and lays its current\n * pair over this one (`media-asset-facts.ts`, AGL-2833). What is written here\n * is what a page shows when that read cannot answer.\n *\n * ## Why it matters\n *\n * `image.tsx` lays images out with `width: 100%; height: auto`, under which an\n * `<img>` has NO height until its bytes decode. Without an intrinsic pair the\n * browser has no ratio to reserve a box from, so every image on the page\n * shifts the content below it as it lands.\n *\n * ## The rules\n *\n * * **Both or neither.** A browser derives an aspect-ratio only from the\n * pair; a lone `width` is read as a real dimension and reserves a box of\n * the wrong shape, which is worse than reserving none.\n * * **Only for renderers that read them**, see\n * `INTRINSIC_SIZE_COMPONENT_IDS` — otherwise the prop lands on the DOM.\n * * **Finite and positive.** Upload capture is best-effort, so a media\n * document may carry `0`, a partial capture, or nothing at all; `width=\"0\"`\n * collapses the element.\n * * **`{}` when anything is unknown**, never `{ intrinsicWidth: undefined }`.\n * Callers spread the result into a props object that `updateNodeProps`\n * REPLACES wholesale, so a key present with an undefined value would strip\n * a pair a previous pick had correctly stored.\n *\n * @returns the props to spread, or `{}` when the caller should write nothing.\n */\nexport function intrinsicMediaSize(options: {\n /** The element being written to — its persisted component id. */\n componentId?: unknown\n /** The attribute the picker was opened for; only `src` carries an image. */\n propName?: unknown\n /** The chosen asset's stored pixel width. */\n assetWidth?: unknown\n /** The chosen asset's stored pixel height. */\n assetHeight?: unknown\n}): { intrinsicWidth?: number; intrinsicHeight?: number } {\n const { componentId, propName, assetWidth, assetHeight } = options ?? {}\n if (propName !== 'src') return {}\n if (!INTRINSIC_SIZE_COMPONENT_IDS.has(String(componentId ?? ''))) return {}\n const usable = (value: unknown): value is number =>\n typeof value === 'number' && Number.isFinite(value) && value > 0\n if (!usable(assetWidth) || !usable(assetHeight)) return {}\n return { intrinsicWidth: assetWidth, intrinsicHeight: assetHeight }\n}\n\n/** The one element that reads the props below off its node. */\nconst VIDEO_COMPONENT_ID = 'video'\n\n/**\n * The video-only companions to {@link intrinsicMediaSize}, copied onto the\n * node when an author picks a video asset (AGL-2741, rewritten against the\n * real document shape in AGL-2749).\n *\n * Same route as the image dimensions, and like theirs not the last word. A\n * replace rewrites the asset's `video` and `poster` records and cannot reach\n * the nodes that copied them, so the tenant composition reads each placed\n * film's document, in the same batch as the images', and lays its current\n * records over these (`media-asset-facts.ts`, AGL-2807). What is written here\n * is what a page shows when that read cannot answer.\n *\n * ## Why a video does not simply use {@link intrinsicMediaSize}\n *\n * A video's pixel dimensions are NOT in `media.width`/`media.height`. Those\n * are read from the bytes by the server and carry a stronger claim than a\n * browser's report, so AGL-2742 kept the client-measured triple in its own\n * `video` record rather than widening them — a reader that folded the two\n * together would lose the ability to tell which it had. A video therefore\n * reaches its intrinsic pair through here, and `intrinsicMediaSize` finds\n * nothing to copy for one.\n *\n * ## The three rules\n *\n * * **Only the `video` element, only its `src`.** A `durationSeconds` spread\n * onto an element that does not destructure it becomes an invalid attribute\n * in the published HTML.\n * * **The duration is stored in SECONDS**, because the author-facing field is\n * in seconds and a person types 63, not 63000. `videoDurationIso8601` takes\n * milliseconds, and the structured-data builder is the one place that\n * converts back.\n * * **`{}` when unknown**, never a key with an `undefined` value: callers\n * spread this into a props object `updateNodeProps` REPLACES wholesale, so\n * a present-but-undefined key strips what an earlier pick stored.\n *\n * `posterFromSource` is a FLAG rather than a url, deliberately. The generated\n * poster is `?poster=1` on the video's own reference, which\n * {@link mediaPosterSrc} builds without reading anything — and that url is\n * explicitly not a promise the poster exists. This flag IS the promise: it is\n * written only when the document records one, which is what lets\n * `videoPosterSrc` offer the derived url to an `<img>` and to a\n * `thumbnailUrl`, where a 404 would be a broken image and a rich result\n * pointing at nothing.\n *\n * An author's own poster is never touched here. It wins at render time\n * instead (`videoPosterSrc`), so re-picking the SOURCE cannot quietly replace\n * a frame somebody chose.\n *\n * @returns the props to spread, or `{}` when the caller should write nothing.\n */\nexport function videoMediaProps(options: {\n /** The element being written to — its persisted component id. */\n componentId?: unknown\n /** The attribute the picker was opened for; only `src` carries a video. */\n propName?: unknown\n /**\n * The chosen asset's `video` record, as {@link normalizeVideoMetadata}\n * bounds it — `durationMs`, `width` and `height`, all three or none.\n */\n assetVideo?: unknown\n /** The chosen asset's generated `poster` record, when it has one. */\n assetPoster?: unknown\n}): {\n durationSeconds?: number\n intrinsicWidth?: number\n intrinsicHeight?: number\n posterFromSource?: boolean\n} {\n const { componentId, propName, assetVideo, assetPoster } = options ?? {}\n if (propName !== 'src') return {}\n if (String(componentId ?? '') !== VIDEO_COMPONENT_ID) return {}\n const patch: {\n durationSeconds?: number\n intrinsicWidth?: number\n intrinsicHeight?: number\n posterFromSource?: boolean\n } = {}\n // Through the DAM's own validator rather than a second reading of the same\n // three numbers: it is all-or-nothing by design, and re-deriving that rule\n // here is how two files come to disagree about what a partial record means.\n const video = normalizeVideoMetadata(assetVideo)\n if (video) {\n // Never rounds to zero: a sub-second clip is still a clip, and a stored\n // `0` reads as \"no duration\" to everything downstream.\n patch.durationSeconds = Math.max(1, Math.round(video.durationMs / 1000))\n patch.intrinsicWidth = video.width\n patch.intrinsicHeight = video.height\n }\n // A poster RECORD, not a poster url — see the note above. Its mere presence\n // on the document is the whole fact being carried.\n if (assetPoster && typeof assetPoster === 'object') {\n patch.posterFromSource = true\n }\n return patch\n}\n\nexport interface ImageDimensions {\n width: number\n height: number\n}\n\nconst readU32BE = (bytes: Uint8Array, offset: number) =>\n (bytes[offset] << 24) |\n (bytes[offset + 1] << 16) |\n (bytes[offset + 2] << 8) |\n bytes[offset + 3]\n\nconst readU16BE = (bytes: Uint8Array, offset: number) =>\n (bytes[offset] << 8) | bytes[offset + 1]\n\nconst readU16LE = (bytes: Uint8Array, offset: number) =>\n bytes[offset] | (bytes[offset + 1] << 8)\n\n/**\n * Reads pixel dimensions from PNG, JPEG, GIF, and WebP headers. Returns\n * null for anything unrecognized or truncated — callers treat dimensions\n * as best-effort metadata, never a gate.\n */\nexport function readImageDimensions(\n bytes: Uint8Array,\n): ImageDimensions | null {\n if (bytes.length < 24) return null\n\n // PNG: 8-byte signature, IHDR width/height at offsets 16/20.\n if (\n bytes[0] === 0x89 &&\n bytes[1] === 0x50 &&\n bytes[2] === 0x4e &&\n bytes[3] === 0x47\n ) {\n const width = readU32BE(bytes, 16)\n const height = readU32BE(bytes, 20)\n return width > 0 && height > 0 ? { width, height } : null\n }\n\n // GIF87a/GIF89a: little-endian dimensions at offsets 6/8.\n if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {\n const width = readU16LE(bytes, 6)\n const height = readU16LE(bytes, 8)\n return width > 0 && height > 0 ? { width, height } : null\n }\n\n // JPEG: scan segments for a SOFn marker (C0–CF except C4/C8/CC).\n if (bytes[0] === 0xff && bytes[1] === 0xd8) {\n let offset = 2\n while (offset + 9 < bytes.length) {\n if (bytes[offset] !== 0xff) {\n offset += 1\n continue\n }\n const marker = bytes[offset + 1]\n if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {\n offset += 2\n continue\n }\n const length = readU16BE(bytes, offset + 2)\n if (\n marker >= 0xc0 &&\n marker <= 0xcf &&\n marker !== 0xc4 &&\n marker !== 0xc8 &&\n marker !== 0xcc\n ) {\n const height = readU16BE(bytes, offset + 5)\n const width = readU16BE(bytes, offset + 7)\n return width > 0 && height > 0 ? { width, height } : null\n }\n if (length < 2) return null\n offset += 2 + length\n }\n return null\n }\n\n // WebP: RIFF....WEBP then VP8/VP8L/VP8X chunk.\n if (\n bytes[0] === 0x52 &&\n bytes[1] === 0x49 &&\n bytes[2] === 0x46 &&\n bytes[3] === 0x46 &&\n bytes[8] === 0x57 &&\n bytes[9] === 0x45 &&\n bytes[10] === 0x42 &&\n bytes[11] === 0x50\n ) {\n const chunk = String.fromCharCode(\n bytes[12],\n bytes[13],\n bytes[14],\n bytes[15],\n )\n if (chunk === 'VP8X' && bytes.length >= 30) {\n const width = 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16))\n const height = 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16))\n return { width, height }\n }\n if (chunk === 'VP8 ' && bytes.length >= 30) {\n const width = readU16LE(bytes, 26) & 0x3fff\n const height = readU16LE(bytes, 28) & 0x3fff\n return width > 0 && height > 0 ? { width, height } : null\n }\n if (chunk === 'VP8L' && bytes.length >= 25) {\n const bits =\n bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24)\n const width = (bits & 0x3fff) + 1\n const height = ((bits >> 14) & 0x3fff) + 1\n return { width, height }\n }\n }\n\n return null\n}\n\n/**\n * What the platform knows about a VIDEO asset (AGL-2742).\n *\n * ## Why this is not `width`/`height` on the document beside an image's\n *\n * An image's dimensions are read from its own header by\n * {@link readImageDimensions}, server-side, from bytes the platform holds.\n * None of that is available for video: the dimensions live in a `moov` atom\n * that an MP4 is free to place at the END of the file, so reading them\n * server-side means fetching up to 200 MB back out of Storage to answer a\n * question worth twelve bytes — the exact download `generateStoredMediaVariants`\n * is written to avoid. So these numbers arrive from the BROWSER, which had\n * the file in hand and a decoder already loaded, and they are consequently\n * client data: every field is bounded here before it reaches a document.\n *\n * They live under their own key rather than widening `width`/`height`\n * because the provenance differs and a reader should be able to tell. A\n * `width` on a media document means \"measured from the bytes\"; a\n * `video.width` means \"reported by the uploader's browser\". Folding the two\n * together would make the weaker claim indistinguishable from the stronger\n * one on every asset in the library.\n */\nexport interface MediaVideoMetadata {\n /** Duration in whole milliseconds. Always finite and positive. */\n durationMs: number\n /** Coded frame width in pixels, after any display-aspect correction. */\n width: number\n height: number\n /**\n * A short label for what produced the file, when the browser offered one\n * (`video/mp4; codecs=\"avc1.640028\"` collapses to `avc1.640028`). Absent\n * far more often than present — no browser API reports the codec of a\n * local file, so this is only ever filled from a `MediaCapabilities` probe\n * or a container sniff, and nothing depends on it.\n */\n codec?: string\n}\n\n/**\n * The upper bound on a stored duration: 24 hours.\n *\n * Not a policy about what may be uploaded — the DAM's ceiling is 200 MB of\n * bytes and says nothing about running time. This is the bound past which a\n * number stops being a duration and starts being a bug, and the specific\n * bug it exists for is real: `HTMLMediaElement.duration` is `Infinity` for a\n * stream and for some WebM files until the element has been seeked to the\n * end. `Infinity` fails the finite test below before it reaches this\n * constant, but a browser that reports a plausible-looking 10^12 instead\n * would otherwise write a document claiming a 31-year film.\n */\nexport const MEDIA_VIDEO_MAX_DURATION_MS = 24 * 60 * 60 * 1000\n\n/** The largest coded dimension accepted, matching the 8K ceiling encoders use. */\nexport const MEDIA_VIDEO_MAX_DIMENSION = 16384\n\n/**\n * Bound a browser's report into something safe to store, or refuse it whole.\n *\n * All-or-nothing on purpose. A partial record — a duration with no\n * dimensions — is worse than none: `VideoObject` JSON-LD would emit a\n * `duration` and omit `width`, and a renderer sizing its container from\n * `video.height` would find the key present and the value absent. The three\n * numbers are produced by one `loadedmetadata` event and are meaningful only\n * together, so they are accepted or rejected together.\n *\n * `codec` is the exception and is dropped rather than refused, for the same\n * reason `formatMediaRef` drops a malformed content pin: it is a label\n * nothing branches on, and losing the whole record over it would trade a\n * working poster and a correct aspect ratio for a cosmetic string.\n */\nexport function normalizeVideoMetadata(\n input: unknown,\n): MediaVideoMetadata | null {\n if (!input || typeof input !== 'object') return null\n const source = input as Record<string, unknown>\n const durationMs = Math.round(Number(source['durationMs']))\n const width = Math.round(Number(source['width']))\n const height = Math.round(Number(source['height']))\n if (!Number.isFinite(durationMs) || durationMs <= 0) return null\n if (durationMs > MEDIA_VIDEO_MAX_DURATION_MS) return null\n if (!Number.isFinite(width) || width <= 0 || width > MEDIA_VIDEO_MAX_DIMENSION)\n return null\n if (\n !Number.isFinite(height) ||\n height <= 0 ||\n height > MEDIA_VIDEO_MAX_DIMENSION\n )\n return null\n const rawCodec = source['codec']\n const codec =\n typeof rawCodec === 'string' && /^[\\w.,\\- ]{1,64}$/.test(rawCodec.trim())\n ? rawCodec.trim()\n : undefined\n return codec ? { durationMs, width, height, codec } : { durationMs, width, height }\n}\n\n/**\n * The `duration` a schema.org `VideoObject` wants: an ISO 8601 duration.\n *\n * Whole seconds, and never a fractional component. Google's structured-data\n * documentation accepts `PT1M30S` and treats sub-second precision as noise,\n * and a `PT1M30.437S` in a rich result is a number nobody asked for. A\n * duration under one second rounds UP to `PT1S` rather than to `PT0S`, which\n * would read as \"no duration\" to a consumer that tests for truthiness.\n */\nexport function videoDurationIso8601(\n durationMs: number | undefined | null,\n): string | undefined {\n const total = Math.max(1, Math.round(Number(durationMs) / 1000))\n if (!Number.isFinite(total)) return undefined\n if (!durationMs || Number(durationMs) <= 0) return undefined\n const hours = Math.floor(total / 3600)\n const minutes = Math.floor((total % 3600) / 60)\n const seconds = total % 60\n const parts = [\n hours ? `${hours}H` : '',\n minutes ? `${minutes}M` : '',\n seconds ? `${seconds}S` : '',\n ].join('')\n return `PT${parts || '0S'}`\n}\n"],"names":["MEDIA_ALT_MAX_LENGTH","embeddedMetadataIsCurrent","mediaEmbeddedSearchText","NAME_TOKEN_LIMIT","nameSearchKey","nameSearchToken","nameSearchTokens","MEDIA_KINDS","mediaKindOf","contentType","type","String","trim","toLowerCase","startsWith","MEDIA_ORIENTATIONS","positive","value","Number","isFinite","mediaOrientationOf","media","video","width","height","mediaNameWords","fileName","replace","mediaSearchToken","query","mediaNameTokens","embeddedMetadata","contentSha256","tokens","Set","token","size","add","mediaFilterKeys","kind","nameLower","nameTokens","hasAlt","alt","length","orientation","MEDIA_TAG_MAX_COUNT","MEDIA_TAG_MAX_LENGTH","normalizeMediaTags","input","raw","Array","isArray","split","seen","tags","entry","tag","has","push","inheritedMediaAlt","options","placementAlt","decorative","assetAlt","undefined","inherited","slice","INTRINSIC_SIZE_COMPONENT_IDS","intrinsicMediaSize","componentId","propName","assetWidth","assetHeight","usable","intrinsicWidth","intrinsicHeight","VIDEO_COMPONENT_ID","videoMediaProps","assetVideo","assetPoster","patch","normalizeVideoMetadata","durationSeconds","Math","max","round","durationMs","posterFromSource","readU32BE","bytes","offset","readU16BE","readU16LE","readImageDimensions","marker","chunk","fromCharCode","bits","MEDIA_VIDEO_MAX_DURATION_MS","MEDIA_VIDEO_MAX_DIMENSION","source","rawCodec","codec","test","videoDurationIso8601","total","hours","floor","minutes","seconds","parts","join"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-metadata.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Asset metadata helpers (AGL-173): tag normalization shared by the\n * console editor and any future API validation, plus a dependency-free\n * image header parser so the upload route can stamp dimensions without\n * pulling in an image library.\n */\n\nimport { MEDIA_ALT_MAX_LENGTH } from './media-alt'\nimport { embeddedMetadataIsCurrent, mediaEmbeddedSearchText } from './media-embedded-fields'\nimport { NAME_TOKEN_LIMIT, nameSearchKey, nameSearchToken, nameSearchTokens } from './name-search'\n\nexport * from './media-alt'\n\n/**\n * The family a stored file belongs to, as the media library's Type filter\n * names it (AGL-3327). Stored on the document as `kind`, because a Firestore\n * query can ask for a value and cannot ask for a prefix beside a list's own\n * sort: `kind == 'image'` sits on any query the library builds, where a\n * `contentType` range would take the one range field the query has.\n *\n * Every type media ingress accepts that is not a picture, a film, a track\n * (AGL-3716) or a PDF is\n * a document — ZIP, Word, Excel, PowerPoint, CSV, text, Markdown, JSON — and\n * so is anything older than that allowlist.\n */\nexport type MediaKind = 'image' | 'video' | 'audio' | 'pdf' | 'document'\n\nexport const MEDIA_KINDS: readonly MediaKind[] = ['image', 'video', 'audio', 'pdf', 'document']\n\nexport function mediaKindOf(contentType: unknown): MediaKind {\n const type = String(contentType ?? '').trim().toLowerCase()\n if (type.startsWith('image/')) return 'image'\n if (type.startsWith('video/')) return 'video'\n // A track (AGL-3716). No audio was accepted before the Music player, so no\n // stored document needs re-stamping for this family.\n if (type.startsWith('audio/')) return 'audio'\n if (type === 'application/pdf') return 'pdf'\n return 'document'\n}\n\n/** Which way a picture or a film is longer, as the Orientation filter asks. */\nexport type MediaOrientation = 'landscape' | 'portrait' | 'square'\n\nexport const MEDIA_ORIENTATIONS: readonly MediaOrientation[] = [\n 'landscape',\n 'portrait',\n 'square',\n]\n\nconst positive = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null\n\n/**\n * The orientation of a stored file (AGL-3327), from the pixel size it\n * carries: a picture's `width` and `height`, or a film's `video.width` and\n * `video.height` (a video's own `width`/`height` are never written — see\n * `videoMediaProps`). Null when neither pair is whole, which a query for any\n * orientation then leaves out rather than guesses at.\n */\nexport function mediaOrientationOf(media: {\n width?: unknown\n height?: unknown\n video?: unknown\n}): MediaOrientation | null {\n const video =\n media.video && typeof media.video === 'object'\n ? (media.video as { width?: unknown; height?: unknown })\n : null\n let width = positive(media.width)\n let height = positive(media.height)\n if (width === null || height === null) {\n width = positive(video?.width)\n height = positive(video?.height)\n }\n if (width === null || height === null) return null\n if (width === height) return 'square'\n return width > height ? 'landscape' : 'portrait'\n}\n\n/**\n * A file name as words (AGL-3327): every run of characters that is not a\n * letter or a digit becomes a space, so `hero-banner_2x.png` is the four\n * words a person would search it by. The shared name search splits on\n * spaces only, which suits a person's name and misses nearly every file's.\n */\nexport function mediaNameWords(fileName: unknown): string {\n return String(fileName ?? '')\n .replace(/[^\\p{L}\\p{N}]+/gu, ' ')\n .trim()\n}\n\n/**\n * The one token a library search asks for (AGL-3327): the first word of what\n * was typed, folded the way `mediaNameWords` folds a stored name, lower-cased\n * and capped as `nameSearchToken` caps it. A query holds one array filter, so\n * a search is one word.\n */\nexport function mediaSearchToken(query: unknown): string {\n return nameSearchToken(mediaNameWords(query))\n}\n\n/** The derived fields every writer of a media document stores beside it. */\nexport interface MediaFilterKeys {\n kind: MediaKind\n /**\n * `nameSearchKey` of the file name: the Name sort, and the \"starts with\"\n * range a search is served by where the scope clause holds the query's one\n * array filter.\n */\n nameLower: string\n /**\n * Word-prefix tokens of the file name's words, then of the details read\n * from inside the file (AGL-3339), for search — at most\n * `NAME_TOKEN_LIMIT`, the name's first.\n */\n nameTokens: string[]\n /** Whether alt text is written — \"missing\" is a value a query can find. */\n hasAlt: boolean\n /** Which way the file is longer, or null when its size is not known. */\n orientation: MediaOrientation | null\n}\n\n/** What `mediaFilterKeys` reads off a media document. */\nexport interface MediaFilterSource {\n fileName?: unknown\n contentType?: unknown\n alt?: unknown\n width?: unknown\n height?: unknown\n video?: unknown\n /** The details read from inside the file (AGL-3339), searched after its name. */\n embeddedMetadata?: unknown\n /** The bytes' digest, which says whether `embeddedMetadata` describes them. */\n contentSha256?: unknown\n}\n\n/**\n * What a library search finds a file by (AGL-3339): every word prefix of its\n * name, then of the details read from inside it — a title, keywords, who\n * made it, where — inside the one `NAME_TOKEN_LIMIT` a document keeps, so a\n * long caption never crowds out the name. Details that describe other bytes\n * than the file's (`embeddedMetadataIsCurrent`) add nothing.\n */\nexport function mediaNameTokens(\n fileName: unknown,\n embeddedMetadata?: unknown,\n contentSha256?: unknown,\n): string[] {\n const tokens = new Set(nameSearchTokens(mediaNameWords(fileName)))\n if (!embeddedMetadataIsCurrent(embeddedMetadata, contentSha256)) return [...tokens]\n for (const token of nameSearchTokens(mediaNameWords(mediaEmbeddedSearchText(embeddedMetadata)))) {\n if (tokens.size >= NAME_TOKEN_LIMIT) break\n tokens.add(token)\n }\n return [...tokens]\n}\n\n/**\n * What the media library filters, sorts and searches by, derived from the\n * fields a document already carries (AGL-3327). Every writer — the upload\n * routes, replace, the REST API, restore, the Details drawer — spreads this\n * beside the fields it writes, derived from the document as it will stand\n * after the write, so a query finds every file by it. The backfill\n * (`tools/scripts/backfill-media-filter-keys.mjs`) stamps the documents\n * written before it existed, through `tools/scripts/lib/media-filter-keys.mjs`;\n * both are held to `media-filter-keys.fixtures.json` beside it.\n */\nexport function mediaFilterKeys(media: MediaFilterSource): MediaFilterKeys {\n const fileName = String(media.fileName ?? '')\n return {\n kind: mediaKindOf(media.contentType),\n nameLower: nameSearchKey(fileName),\n nameTokens: mediaNameTokens(fileName, media.embeddedMetadata, media.contentSha256),\n hasAlt: String(media.alt ?? '').trim().length > 0,\n orientation: mediaOrientationOf(media),\n }\n}\n\nexport const MEDIA_TAG_MAX_COUNT = 20\nexport const MEDIA_TAG_MAX_LENGTH = 40\n\n/**\n * Trim, lowercase, dedupe, and cap tags. Accepts a comma-separated string\n * or an array; empty and oversized entries are dropped.\n */\nexport function normalizeMediaTags(input: string | string[]): string[] {\n const raw = Array.isArray(input) ? input : String(input ?? '').split(',')\n const seen = new Set<string>()\n const tags: string[] = []\n for (const entry of raw) {\n const tag = String(entry ?? '').trim().toLowerCase()\n if (!tag || tag.length > MEDIA_TAG_MAX_LENGTH || seen.has(tag)) continue\n seen.add(tag)\n tags.push(tag)\n if (tags.length >= MEDIA_TAG_MAX_COUNT) break\n }\n return tags\n}\n\n/**\n * What a placement should store for `alt` once the asset's own alt text is\n * taken into account (AGL-1896) — the DAM asset is the DEFAULT, the\n * placement keeps the override.\n *\n * `AglynHostMedia.alt` has existed since AGL-173 and the library drawer has\n * always been able to set it; what never existed is anybody READING it. Every\n * placement surface asked the author to type alt again from scratch, so the\n * same logo on eight pages needed its alt typed eight times and in practice\n * shipped blank — on a customer's published site.\n *\n * ONE function, called at pick time by every surface, rather than a rule\n * re-derived per surface. The three refusals are the whole contract:\n *\n * * **`decorative` wins outright.** It is the field AGL-1305 added to record\n * \"screen readers should skip this\", and `image.tsx` already forces\n * `alt=\"\"` over any alt text when it is on. Inheriting into a node that has\n * declared itself decorative would put text on a node whose renderer\n * discards it — invisible, and misleading to the next author who opens the\n * panel.\n * * **A non-blank placement alt wins.** That is the per-placement override,\n * and clobbering it is the one failure that would make this feature worse\n * than not having it: the author's sentence about THIS placement is better\n * than the asset's generic one by construction.\n * * **A blank asset alt yields nothing.** Never a fabricated default. The\n * file name is not alt text (\"IMG_4021.jpg\" announced to a screen reader is\n * worse than silence), and nothing here has seen the image. Returning\n * `undefined` is what lets callers omit the key entirely rather than\n * writing `alt: ''`, which on a besigner node is itself an authored value.\n *\n * A blank placement alt DOES inherit, deliberately. Presets ship `alt: ''`\n * (see `card.tsx`), so requiring an absent key would have skipped the single\n * commonest authoring path — dropping a preset and pointing its image at a\n * library asset — and left the issue open for the case it was filed about.\n *\n * @returns the alt to store, or `undefined` when the caller should write\n * nothing at all.\n */\nexport function inheritedMediaAlt(options: {\n /** The alt already on the placement — a node prop, a config field. */\n placementAlt?: unknown\n /** The placement's explicit \"skip me\" intent, when it has one. */\n decorative?: unknown\n /** The chosen DAM asset's stored alt text. */\n assetAlt?: unknown\n}): string | undefined {\n const { placementAlt, decorative, assetAlt } = options ?? {}\n if (decorative === true) return undefined\n if (typeof placementAlt === 'string' && placementAlt.trim()) return undefined\n const inherited = typeof assetAlt === 'string' ? assetAlt.trim() : ''\n if (!inherited) return undefined\n // Capped at the same length the library drawer saves through, so an alt\n // that reaches a placement is one the DAM would also have stored.\n return inherited.slice(0, MEDIA_ALT_MAX_LENGTH)\n}\n\n/**\n * Component ids whose renderer reads intrinsic pixel dimensions off the node.\n *\n * Component ids are persisted in screen documents and never renamed, which is\n * what makes matching on them safe. The list is the gate rather than a\n * decoration: a prop written onto an element that does not destructure it\n * reaches `...rest` and is spread onto the DOM, so an unlisted element would\n * gain an invalid `intrinsicwidth` attribute in its published HTML.\n *\n * ⛔ `video` is NOT here, and its absence is a decision rather than an\n * oversight (AGL-2749). A video needs the pair at least as badly as an image\n * does — `preload=\"none\"` means its metadata never arrives until someone\n * presses play, so the element is zero-height for the whole life of the page\n * without it — but it cannot get it from `width`/`height`. Those are read\n * from the bytes by the server and are absent on every video, deliberately:\n * AGL-2742 kept the client-measured triple in its own `video` record so a\n * reader can tell a server measurement from a browser's report.\n * {@link videoMediaProps} is where a video gets its pair.\n */\nconst INTRINSIC_SIZE_COMPONENT_IDS = new Set(['image'])\n\n/**\n * The intrinsic `width`/`height` to copy onto a node when an author picks a\n * library asset for it (AGL-2486).\n *\n * ## Why the copy happens at pick time, and why it is not the last word\n *\n * The pair rides on the node like every other prop, so the editor canvas, and\n * any page whose asset cannot be read, still reserve a box. It is not the last\n * word. A replace rewrites the asset's `width`/`height` and cannot reach the\n * nodes that copied them, so the tenant composition reads every placed\n * image's document, in one projected batch per page, and lays its current\n * pair over this one (`media-asset-facts.ts`, AGL-2833). What is written here\n * is what a page shows when that read cannot answer.\n *\n * ## Why it matters\n *\n * `image.tsx` lays images out with `width: 100%; height: auto`, under which an\n * `<img>` has NO height until its bytes decode. Without an intrinsic pair the\n * browser has no ratio to reserve a box from, so every image on the page\n * shifts the content below it as it lands.\n *\n * ## The rules\n *\n * * **Both or neither.** A browser derives an aspect-ratio only from the\n * pair; a lone `width` is read as a real dimension and reserves a box of\n * the wrong shape, which is worse than reserving none.\n * * **Only for renderers that read them**, see\n * `INTRINSIC_SIZE_COMPONENT_IDS` — otherwise the prop lands on the DOM.\n * * **Finite and positive.** Upload capture is best-effort, so a media\n * document may carry `0`, a partial capture, or nothing at all; `width=\"0\"`\n * collapses the element.\n * * **`{}` when anything is unknown**, never `{ intrinsicWidth: undefined }`.\n * Callers spread the result into a props object that `updateNodeProps`\n * REPLACES wholesale, so a key present with an undefined value would strip\n * a pair a previous pick had correctly stored.\n *\n * @returns the props to spread, or `{}` when the caller should write nothing.\n */\nexport function intrinsicMediaSize(options: {\n /** The element being written to — its persisted component id. */\n componentId?: unknown\n /** The attribute the picker was opened for; only `src` carries an image. */\n propName?: unknown\n /** The chosen asset's stored pixel width. */\n assetWidth?: unknown\n /** The chosen asset's stored pixel height. */\n assetHeight?: unknown\n}): { intrinsicWidth?: number; intrinsicHeight?: number } {\n const { componentId, propName, assetWidth, assetHeight } = options ?? {}\n if (propName !== 'src') return {}\n if (!INTRINSIC_SIZE_COMPONENT_IDS.has(String(componentId ?? ''))) return {}\n const usable = (value: unknown): value is number =>\n typeof value === 'number' && Number.isFinite(value) && value > 0\n if (!usable(assetWidth) || !usable(assetHeight)) return {}\n return { intrinsicWidth: assetWidth, intrinsicHeight: assetHeight }\n}\n\n/** The one element that reads the props below off its node. */\nconst VIDEO_COMPONENT_ID = 'video'\n\n/**\n * The video-only companions to {@link intrinsicMediaSize}, copied onto the\n * node when an author picks a video asset (AGL-2741, rewritten against the\n * real document shape in AGL-2749).\n *\n * Same route as the image dimensions, and like theirs not the last word. A\n * replace rewrites the asset's `video` and `poster` records and cannot reach\n * the nodes that copied them, so the tenant composition reads each placed\n * film's document, in the same batch as the images', and lays its current\n * records over these (`media-asset-facts.ts`, AGL-2807). What is written here\n * is what a page shows when that read cannot answer.\n *\n * ## Why a video does not simply use {@link intrinsicMediaSize}\n *\n * A video's pixel dimensions are NOT in `media.width`/`media.height`. Those\n * are read from the bytes by the server and carry a stronger claim than a\n * browser's report, so AGL-2742 kept the client-measured triple in its own\n * `video` record rather than widening them — a reader that folded the two\n * together would lose the ability to tell which it had. A video therefore\n * reaches its intrinsic pair through here, and `intrinsicMediaSize` finds\n * nothing to copy for one.\n *\n * ## The three rules\n *\n * * **Only the `video` element, only its `src`.** A `durationSeconds` spread\n * onto an element that does not destructure it becomes an invalid attribute\n * in the published HTML.\n * * **The duration is stored in SECONDS**, because the author-facing field is\n * in seconds and a person types 63, not 63000. `videoDurationIso8601` takes\n * milliseconds, and the structured-data builder is the one place that\n * converts back.\n * * **`{}` when unknown**, never a key with an `undefined` value: callers\n * spread this into a props object `updateNodeProps` REPLACES wholesale, so\n * a present-but-undefined key strips what an earlier pick stored.\n *\n * `posterFromSource` is a FLAG rather than a url, deliberately. The generated\n * poster is `?poster=1` on the video's own reference, which\n * {@link mediaPosterSrc} builds without reading anything — and that url is\n * explicitly not a promise the poster exists. This flag IS the promise: it is\n * written only when the document records one, which is what lets\n * `videoPosterSrc` offer the derived url to an `<img>` and to a\n * `thumbnailUrl`, where a 404 would be a broken image and a rich result\n * pointing at nothing.\n *\n * An author's own poster is never touched here. It wins at render time\n * instead (`videoPosterSrc`), so re-picking the SOURCE cannot quietly replace\n * a frame somebody chose.\n *\n * @returns the props to spread, or `{}` when the caller should write nothing.\n */\nexport function videoMediaProps(options: {\n /** The element being written to — its persisted component id. */\n componentId?: unknown\n /** The attribute the picker was opened for; only `src` carries a video. */\n propName?: unknown\n /**\n * The chosen asset's `video` record, as {@link normalizeVideoMetadata}\n * bounds it — `durationMs`, `width` and `height`, all three or none.\n */\n assetVideo?: unknown\n /** The chosen asset's generated `poster` record, when it has one. */\n assetPoster?: unknown\n}): {\n durationSeconds?: number\n intrinsicWidth?: number\n intrinsicHeight?: number\n posterFromSource?: boolean\n} {\n const { componentId, propName, assetVideo, assetPoster } = options ?? {}\n if (propName !== 'src') return {}\n if (String(componentId ?? '') !== VIDEO_COMPONENT_ID) return {}\n const patch: {\n durationSeconds?: number\n intrinsicWidth?: number\n intrinsicHeight?: number\n posterFromSource?: boolean\n } = {}\n // Through the DAM's own validator rather than a second reading of the same\n // three numbers: it is all-or-nothing by design, and re-deriving that rule\n // here is how two files come to disagree about what a partial record means.\n const video = normalizeVideoMetadata(assetVideo)\n if (video) {\n // Never rounds to zero: a sub-second clip is still a clip, and a stored\n // `0` reads as \"no duration\" to everything downstream.\n patch.durationSeconds = Math.max(1, Math.round(video.durationMs / 1000))\n patch.intrinsicWidth = video.width\n patch.intrinsicHeight = video.height\n }\n // A poster RECORD, not a poster url — see the note above. Its mere presence\n // on the document is the whole fact being carried.\n if (assetPoster && typeof assetPoster === 'object') {\n patch.posterFromSource = true\n }\n return patch\n}\n\nexport interface ImageDimensions {\n width: number\n height: number\n}\n\nconst readU32BE = (bytes: Uint8Array, offset: number) =>\n (bytes[offset] << 24) |\n (bytes[offset + 1] << 16) |\n (bytes[offset + 2] << 8) |\n bytes[offset + 3]\n\nconst readU16BE = (bytes: Uint8Array, offset: number) =>\n (bytes[offset] << 8) | bytes[offset + 1]\n\nconst readU16LE = (bytes: Uint8Array, offset: number) =>\n bytes[offset] | (bytes[offset + 1] << 8)\n\n/**\n * Reads pixel dimensions from PNG, JPEG, GIF, and WebP headers. Returns\n * null for anything unrecognized or truncated — callers treat dimensions\n * as best-effort metadata, never a gate.\n */\nexport function readImageDimensions(\n bytes: Uint8Array,\n): ImageDimensions | null {\n if (bytes.length < 24) return null\n\n // PNG: 8-byte signature, IHDR width/height at offsets 16/20.\n if (\n bytes[0] === 0x89 &&\n bytes[1] === 0x50 &&\n bytes[2] === 0x4e &&\n bytes[3] === 0x47\n ) {\n const width = readU32BE(bytes, 16)\n const height = readU32BE(bytes, 20)\n return width > 0 && height > 0 ? { width, height } : null\n }\n\n // GIF87a/GIF89a: little-endian dimensions at offsets 6/8.\n if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {\n const width = readU16LE(bytes, 6)\n const height = readU16LE(bytes, 8)\n return width > 0 && height > 0 ? { width, height } : null\n }\n\n // JPEG: scan segments for a SOFn marker (C0–CF except C4/C8/CC).\n if (bytes[0] === 0xff && bytes[1] === 0xd8) {\n let offset = 2\n while (offset + 9 < bytes.length) {\n if (bytes[offset] !== 0xff) {\n offset += 1\n continue\n }\n const marker = bytes[offset + 1]\n if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {\n offset += 2\n continue\n }\n const length = readU16BE(bytes, offset + 2)\n if (\n marker >= 0xc0 &&\n marker <= 0xcf &&\n marker !== 0xc4 &&\n marker !== 0xc8 &&\n marker !== 0xcc\n ) {\n const height = readU16BE(bytes, offset + 5)\n const width = readU16BE(bytes, offset + 7)\n return width > 0 && height > 0 ? { width, height } : null\n }\n if (length < 2) return null\n offset += 2 + length\n }\n return null\n }\n\n // WebP: RIFF....WEBP then VP8/VP8L/VP8X chunk.\n if (\n bytes[0] === 0x52 &&\n bytes[1] === 0x49 &&\n bytes[2] === 0x46 &&\n bytes[3] === 0x46 &&\n bytes[8] === 0x57 &&\n bytes[9] === 0x45 &&\n bytes[10] === 0x42 &&\n bytes[11] === 0x50\n ) {\n const chunk = String.fromCharCode(\n bytes[12],\n bytes[13],\n bytes[14],\n bytes[15],\n )\n if (chunk === 'VP8X' && bytes.length >= 30) {\n const width = 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16))\n const height = 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16))\n return { width, height }\n }\n if (chunk === 'VP8 ' && bytes.length >= 30) {\n const width = readU16LE(bytes, 26) & 0x3fff\n const height = readU16LE(bytes, 28) & 0x3fff\n return width > 0 && height > 0 ? { width, height } : null\n }\n if (chunk === 'VP8L' && bytes.length >= 25) {\n const bits =\n bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24)\n const width = (bits & 0x3fff) + 1\n const height = ((bits >> 14) & 0x3fff) + 1\n return { width, height }\n }\n }\n\n return null\n}\n\n/**\n * What the platform knows about a VIDEO asset (AGL-2742).\n *\n * ## Why this is not `width`/`height` on the document beside an image's\n *\n * An image's dimensions are read from its own header by\n * {@link readImageDimensions}, server-side, from bytes the platform holds.\n * None of that is available for video: the dimensions live in a `moov` atom\n * that an MP4 is free to place at the END of the file, so reading them\n * server-side means fetching up to 200 MB back out of Storage to answer a\n * question worth twelve bytes — the exact download `generateStoredMediaVariants`\n * is written to avoid. So these numbers arrive from the BROWSER, which had\n * the file in hand and a decoder already loaded, and they are consequently\n * client data: every field is bounded here before it reaches a document.\n *\n * They live under their own key rather than widening `width`/`height`\n * because the provenance differs and a reader should be able to tell. A\n * `width` on a media document means \"measured from the bytes\"; a\n * `video.width` means \"reported by the uploader's browser\". Folding the two\n * together would make the weaker claim indistinguishable from the stronger\n * one on every asset in the library.\n */\nexport interface MediaVideoMetadata {\n /** Duration in whole milliseconds. Always finite and positive. */\n durationMs: number\n /** Coded frame width in pixels, after any display-aspect correction. */\n width: number\n height: number\n /**\n * A short label for what produced the file, when the browser offered one\n * (`video/mp4; codecs=\"avc1.640028\"` collapses to `avc1.640028`). Absent\n * far more often than present — no browser API reports the codec of a\n * local file, so this is only ever filled from a `MediaCapabilities` probe\n * or a container sniff, and nothing depends on it.\n */\n codec?: string\n}\n\n/**\n * The upper bound on a stored duration: 24 hours.\n *\n * Not a policy about what may be uploaded — the DAM's ceiling is 200 MB of\n * bytes and says nothing about running time. This is the bound past which a\n * number stops being a duration and starts being a bug, and the specific\n * bug it exists for is real: `HTMLMediaElement.duration` is `Infinity` for a\n * stream and for some WebM files until the element has been seeked to the\n * end. `Infinity` fails the finite test below before it reaches this\n * constant, but a browser that reports a plausible-looking 10^12 instead\n * would otherwise write a document claiming a 31-year film.\n */\nexport const MEDIA_VIDEO_MAX_DURATION_MS = 24 * 60 * 60 * 1000\n\n/** The largest coded dimension accepted, matching the 8K ceiling encoders use. */\nexport const MEDIA_VIDEO_MAX_DIMENSION = 16384\n\n/**\n * Bound a browser's report into something safe to store, or refuse it whole.\n *\n * All-or-nothing on purpose. A partial record — a duration with no\n * dimensions — is worse than none: `VideoObject` JSON-LD would emit a\n * `duration` and omit `width`, and a renderer sizing its container from\n * `video.height` would find the key present and the value absent. The three\n * numbers are produced by one `loadedmetadata` event and are meaningful only\n * together, so they are accepted or rejected together.\n *\n * `codec` is the exception and is dropped rather than refused, for the same\n * reason `formatMediaRef` drops a malformed content pin: it is a label\n * nothing branches on, and losing the whole record over it would trade a\n * working poster and a correct aspect ratio for a cosmetic string.\n */\nexport function normalizeVideoMetadata(\n input: unknown,\n): MediaVideoMetadata | null {\n if (!input || typeof input !== 'object') return null\n const source = input as Record<string, unknown>\n const durationMs = Math.round(Number(source['durationMs']))\n const width = Math.round(Number(source['width']))\n const height = Math.round(Number(source['height']))\n if (!Number.isFinite(durationMs) || durationMs <= 0) return null\n if (durationMs > MEDIA_VIDEO_MAX_DURATION_MS) return null\n if (!Number.isFinite(width) || width <= 0 || width > MEDIA_VIDEO_MAX_DIMENSION)\n return null\n if (\n !Number.isFinite(height) ||\n height <= 0 ||\n height > MEDIA_VIDEO_MAX_DIMENSION\n )\n return null\n const rawCodec = source['codec']\n const codec =\n typeof rawCodec === 'string' && /^[\\w.,\\- ]{1,64}$/.test(rawCodec.trim())\n ? rawCodec.trim()\n : undefined\n return codec ? { durationMs, width, height, codec } : { durationMs, width, height }\n}\n\n/**\n * The `duration` a schema.org `VideoObject` wants: an ISO 8601 duration.\n *\n * Whole seconds, and never a fractional component. Google's structured-data\n * documentation accepts `PT1M30S` and treats sub-second precision as noise,\n * and a `PT1M30.437S` in a rich result is a number nobody asked for. A\n * duration under one second rounds UP to `PT1S` rather than to `PT0S`, which\n * would read as \"no duration\" to a consumer that tests for truthiness.\n */\nexport function videoDurationIso8601(\n durationMs: number | undefined | null,\n): string | undefined {\n const total = Math.max(1, Math.round(Number(durationMs) / 1000))\n if (!Number.isFinite(total)) return undefined\n if (!durationMs || Number(durationMs) <= 0) return undefined\n const hours = Math.floor(total / 3600)\n const minutes = Math.floor((total % 3600) / 60)\n const seconds = total % 60\n const parts = [\n hours ? `${hours}H` : '',\n minutes ? `${minutes}M` : '',\n seconds ? `${seconds}S` : '',\n ].join('')\n return `PT${parts || '0S'}`\n}\n"],"names":["MEDIA_ALT_MAX_LENGTH","embeddedMetadataIsCurrent","mediaEmbeddedSearchText","NAME_TOKEN_LIMIT","nameSearchKey","nameSearchToken","nameSearchTokens","MEDIA_KINDS","mediaKindOf","contentType","type","String","trim","toLowerCase","startsWith","MEDIA_ORIENTATIONS","positive","value","Number","isFinite","mediaOrientationOf","media","video","width","height","mediaNameWords","fileName","replace","mediaSearchToken","query","mediaNameTokens","embeddedMetadata","contentSha256","tokens","Set","token","size","add","mediaFilterKeys","kind","nameLower","nameTokens","hasAlt","alt","length","orientation","MEDIA_TAG_MAX_COUNT","MEDIA_TAG_MAX_LENGTH","normalizeMediaTags","input","raw","Array","isArray","split","seen","tags","entry","tag","has","push","inheritedMediaAlt","options","placementAlt","decorative","assetAlt","undefined","inherited","slice","INTRINSIC_SIZE_COMPONENT_IDS","intrinsicMediaSize","componentId","propName","assetWidth","assetHeight","usable","intrinsicWidth","intrinsicHeight","VIDEO_COMPONENT_ID","videoMediaProps","assetVideo","assetPoster","patch","normalizeVideoMetadata","durationSeconds","Math","max","round","durationMs","posterFromSource","readU32BE","bytes","offset","readU16BE","readU16LE","readImageDimensions","marker","chunk","fromCharCode","bits","MEDIA_VIDEO_MAX_DURATION_MS","MEDIA_VIDEO_MAX_DIMENSION","source","rawCodec","codec","test","videoDurationIso8601","total","hours","floor","minutes","seconds","parts","join"],"mappings":"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"}
@@ -58,7 +58,11 @@ export interface PickedMedia {
58
58
  * two cannot disagree about what counts as a video. `image` and `video` are
59
59
  * whole families; `pdf` is PDF alone, as the filter offers it.
60
60
  */
61
- export type MediaPickerKind = 'image' | 'video' | 'pdf';
61
+ export type MediaPickerKind = 'image' | 'video' | 'pdf' | 'audio';
62
+ /** Every kind a picker can be narrowed to (`audio` since AGL-3716). */
63
+ export declare const MEDIA_PICKER_KINDS: readonly MediaPickerKind[];
64
+ /** Whether a value names a picker kind — for a schema's `mediaKind`. */
65
+ export declare function isMediaPickerKind(value: unknown): value is MediaPickerKind;
62
66
  /** How a caller wants the picker to behave. */
63
67
  export interface PickMediaOptions {
64
68
  /**
@@ -17,6 +17,15 @@
17
17
  // @aglyn/aglyn without a 'use client' banner so both the console app and
18
18
  // relocated feature plugins share one context module.
19
19
  import { createContext, useContext } from "react";
20
+ /** Every kind a picker can be narrowed to (`audio` since AGL-3716). */ export const MEDIA_PICKER_KINDS = [
21
+ 'image',
22
+ 'video',
23
+ 'pdf',
24
+ 'audio'
25
+ ];
26
+ /** Whether a value names a picker kind — for a schema's `mediaKind`. */ export function isMediaPickerKind(value) {
27
+ return typeof value === 'string' && MEDIA_PICKER_KINDS.includes(value);
28
+ }
20
29
  export const MediaPickerContext = createContext({});
21
30
  MediaPickerContext.displayName = 'MediaPickerContext';
22
31
  /** Hook form of {@link MediaPickerContext}. */ export function useMediaPicker() {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-picker-context.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n// Same placement rationale as entity-picker-context.ts: lives in\n// @aglyn/aglyn without a 'use client' banner so both the console app and\n// relocated feature plugins share one context module.\nimport { createContext, useContext } from 'react'\n\nexport interface PickedMedia {\n /**\n * The chosen asset's URL — use for image `src` props.\n *\n * Already resolved by the provider, which prefers the media-id-keyed CDN\n * path over the raw storage download URL (AGL-1215): the raw form names\n * the object's current location and dies on a folder move.\n */\n url: string\n /** Original file name, when the source exposes it (e.g. digital files). */\n fileName?: string\n /** MIME type, when known. */\n contentType?: string\n /**\n * The asset's own alt text, as authored in the media library (AGL-1896).\n *\n * Carried so a placement can DEFAULT from the asset instead of asking the\n * author to retype it — the same logo on eight pages had its alt typed\n * eight times, and in practice shipped blank on published customer sites.\n * Absent for every asset whose alt has never been filled in; there is no\n * fallback to {@link fileName}, deliberately. A file name is not a\n * description, and \"IMG_4021.jpg\" read aloud by a screen reader is worse\n * than the silence it replaces.\n *\n * Consumers must not read this directly into a stored field. Pass it to\n * `inheritedMediaAlt` with whatever the placement already holds, so the\n * per-placement override keeps winning.\n */\n alt?: string\n /**\n * The chosen asset's media DOCUMENT id (AGL-2662).\n *\n * For the callers that store a reference rather than a placement: a CRM\n * record's attachments are ids, so a file moved between folders keeps its\n * attachment and a private asset is still served through the signed CDN\n * door. Absent for a source that has no document behind it.\n */\n mediaId?: string\n /**\n * The CDN scope the id resolves under — `org:{orgId}` for the shared\n * library, a host id for a site's own. Carried beside {@link mediaId}\n * because an id alone cannot be turned back into a URL.\n */\n mediaScope?: string\n /**\n * Whether the chosen asset is PRIVATE (AGL-2814): fetchable only through a\n * signed, expiring link, so {@link url} holds its media reference rather\n * than an address a browser could load. Only a caller that opened the\n * picker with `allowPrivate` ever receives one.\n */\n private?: boolean\n}\n\n/**\n * The one kind of file a picker can be narrowed to (AGL-2953).\n *\n * These are the values of the media library's own Type filter, not a second\n * vocabulary: a narrowed picker is the library with that filter fixed, so the\n * two cannot disagree about what counts as a video. `image` and `video` are\n * whole families; `pdf` is PDF alone, as the filter offers it.\n */\nexport type MediaPickerKind = 'image' | 'video' | 'pdf'\n\n/** How a caller wants the picker to behave. */\nexport interface PickMediaOptions {\n /**\n * Accept a PRIVATE asset instead of refusing it (AGL-2814).\n *\n * For a caller that delivers the file through a signed link it mints per\n * request — a product's members video — and never places it on a page,\n * which a private asset cannot be.\n */\n allowPrivate?: boolean\n /**\n * List and upload only this kind of file (AGL-2953), for a field that can\n * hold nothing else — a video source offers no images or PDFs to choose\n * and takes no image as an upload. Absent, the picker offers every file\n * the library holds.\n */\n kind?: MediaPickerKind\n}\n\n/**\n * Lets a relocated plugin console page open the console's media browser\n * without importing it. The console app provides `pickMedia` (it owns the\n * media library, which is coupled to the org/session context); plugin\n * components call it and receive the chosen asset — or `null` if cancelled.\n * Absent (undefined `pickMedia`) when no provider is mounted, so callers\n * fall back to a plain URL input.\n */\nexport interface MediaPickerContextValue {\n pickMedia?: (options?: PickMediaOptions) => Promise<PickedMedia | null>\n}\n\nexport const MediaPickerContext = createContext<MediaPickerContextValue>({})\nMediaPickerContext.displayName = 'MediaPickerContext'\n\n/** Hook form of {@link MediaPickerContext}. */\nexport function useMediaPicker(): MediaPickerContextValue {\n return useContext(MediaPickerContext)\n}\n"],"names":["createContext","useContext","MediaPickerContext","displayName","useMediaPicker"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD,iEAAiE;AACjE,yEAAyE;AACzE,sDAAsD;AACtD,SAASA,aAAa,EAAEC,UAAU,QAAQ,QAAO;AAgGjD,OAAO,MAAMC,qBAAqBF,cAAuC,CAAC,GAAE;AAC5EE,mBAAmBC,WAAW,GAAG;AAEjC,6CAA6C,GAC7C,OAAO,SAASC;IACd,OAAOH,WAAWC;AACpB"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-picker-context.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n// Same placement rationale as entity-picker-context.ts: lives in\n// @aglyn/aglyn without a 'use client' banner so both the console app and\n// relocated feature plugins share one context module.\nimport { createContext, useContext } from 'react'\n\nexport interface PickedMedia {\n /**\n * The chosen asset's URL — use for image `src` props.\n *\n * Already resolved by the provider, which prefers the media-id-keyed CDN\n * path over the raw storage download URL (AGL-1215): the raw form names\n * the object's current location and dies on a folder move.\n */\n url: string\n /** Original file name, when the source exposes it (e.g. digital files). */\n fileName?: string\n /** MIME type, when known. */\n contentType?: string\n /**\n * The asset's own alt text, as authored in the media library (AGL-1896).\n *\n * Carried so a placement can DEFAULT from the asset instead of asking the\n * author to retype it — the same logo on eight pages had its alt typed\n * eight times, and in practice shipped blank on published customer sites.\n * Absent for every asset whose alt has never been filled in; there is no\n * fallback to {@link fileName}, deliberately. A file name is not a\n * description, and \"IMG_4021.jpg\" read aloud by a screen reader is worse\n * than the silence it replaces.\n *\n * Consumers must not read this directly into a stored field. Pass it to\n * `inheritedMediaAlt` with whatever the placement already holds, so the\n * per-placement override keeps winning.\n */\n alt?: string\n /**\n * The chosen asset's media DOCUMENT id (AGL-2662).\n *\n * For the callers that store a reference rather than a placement: a CRM\n * record's attachments are ids, so a file moved between folders keeps its\n * attachment and a private asset is still served through the signed CDN\n * door. Absent for a source that has no document behind it.\n */\n mediaId?: string\n /**\n * The CDN scope the id resolves under — `org:{orgId}` for the shared\n * library, a host id for a site's own. Carried beside {@link mediaId}\n * because an id alone cannot be turned back into a URL.\n */\n mediaScope?: string\n /**\n * Whether the chosen asset is PRIVATE (AGL-2814): fetchable only through a\n * signed, expiring link, so {@link url} holds its media reference rather\n * than an address a browser could load. Only a caller that opened the\n * picker with `allowPrivate` ever receives one.\n */\n private?: boolean\n}\n\n/**\n * The one kind of file a picker can be narrowed to (AGL-2953).\n *\n * These are the values of the media library's own Type filter, not a second\n * vocabulary: a narrowed picker is the library with that filter fixed, so the\n * two cannot disagree about what counts as a video. `image` and `video` are\n * whole families; `pdf` is PDF alone, as the filter offers it.\n */\nexport type MediaPickerKind = 'image' | 'video' | 'pdf' | 'audio'\n\n/** Every kind a picker can be narrowed to (`audio` since AGL-3716). */\nexport const MEDIA_PICKER_KINDS: readonly MediaPickerKind[] = [\n 'image',\n 'video',\n 'pdf',\n 'audio',\n]\n\n/** Whether a value names a picker kind — for a schema's `mediaKind`. */\nexport function isMediaPickerKind(value: unknown): value is MediaPickerKind {\n return (\n typeof value === 'string' &&\n (MEDIA_PICKER_KINDS as readonly string[]).includes(value)\n )\n}\n\n/** How a caller wants the picker to behave. */\nexport interface PickMediaOptions {\n /**\n * Accept a PRIVATE asset instead of refusing it (AGL-2814).\n *\n * For a caller that delivers the file through a signed link it mints per\n * request — a product's members video — and never places it on a page,\n * which a private asset cannot be.\n */\n allowPrivate?: boolean\n /**\n * List and upload only this kind of file (AGL-2953), for a field that can\n * hold nothing else — a video source offers no images or PDFs to choose\n * and takes no image as an upload. Absent, the picker offers every file\n * the library holds.\n */\n kind?: MediaPickerKind\n}\n\n/**\n * Lets a relocated plugin console page open the console's media browser\n * without importing it. The console app provides `pickMedia` (it owns the\n * media library, which is coupled to the org/session context); plugin\n * components call it and receive the chosen asset — or `null` if cancelled.\n * Absent (undefined `pickMedia`) when no provider is mounted, so callers\n * fall back to a plain URL input.\n */\nexport interface MediaPickerContextValue {\n pickMedia?: (options?: PickMediaOptions) => Promise<PickedMedia | null>\n}\n\nexport const MediaPickerContext = createContext<MediaPickerContextValue>({})\nMediaPickerContext.displayName = 'MediaPickerContext'\n\n/** Hook form of {@link MediaPickerContext}. */\nexport function useMediaPicker(): MediaPickerContextValue {\n return useContext(MediaPickerContext)\n}\n"],"names":["createContext","useContext","MEDIA_PICKER_KINDS","isMediaPickerKind","value","includes","MediaPickerContext","displayName","useMediaPicker"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD,iEAAiE;AACjE,yEAAyE;AACzE,sDAAsD;AACtD,SAASA,aAAa,EAAEC,UAAU,QAAQ,QAAO;AAiEjD,qEAAqE,GACrE,OAAO,MAAMC,qBAAiD;IAC5D;IACA;IACA;IACA;CACD,CAAA;AAED,sEAAsE,GACtE,OAAO,SAASC,kBAAkBC,KAAc;IAC9C,OACE,OAAOA,UAAU,YACjB,AAACF,mBAAyCG,QAAQ,CAACD;AAEvD;AAiCA,OAAO,MAAME,qBAAqBN,cAAuC,CAAC,GAAE;AAC5EM,mBAAmBC,WAAW,GAAG;AAEjC,6CAA6C,GAC7C,OAAO,SAASC;IACd,OAAOP,WAAWK;AACpB"}
@@ -127,7 +127,9 @@
127
127
  * `svix-signature` HEADER. Header presence is not a credential — the
128
128
  * dispatcher cannot verify signatures it does not own, so exempting on the
129
129
  * header would let any caller skip the limiter by attaching a garbage one to
130
- * a cart POST.
130
+ * a cart POST. (The console limiter does exempt the cron secret, but only
131
+ * once it has compared the VALUE with `CRON_SECRET` — a credential it owns —
132
+ * in `consoleApiRateLimitRefusal`.)
131
133
  *
132
134
  * Nor is `commerce/supplier-update`, whose HMAC token makes it a machine
133
135
  * caller but whose volume is set by a supplier's own system rather than by a
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/plugin-api-rate-limit.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { isPluginMachineRoute } from './api-plugins'\n\n/**\n * Policy for the tenant plugin-API dispatcher's visitor-write rate limit\n * (AGL-1770). Pure but for one read of the route registry, for a route its\n * plugin registered as a machine's, so it is unit-testable without a route\n * harness — the durable half lives in `@aglyn/tenant-data-admin` beside\n * `visitorWriteRefusal`, exactly like the lockdown split.\n *\n * ## What this bounds\n *\n * `apps/tenant/app/api/[...pluginApi]/route.ts` dispatches every plugin's\n * visitor-facing handler and, before this, applied no rate limit of any kind.\n * The gates it did have each refuse something narrower: per-site enablement is\n * skipped by an unresolvable `hostId`, the release gate only checks that the\n * plugin is on, and `visitorWriteRefusal` refuses only a paused or suspended\n * site. None of them bounds volume from a working, enabled site — which is\n * every live merchant.\n *\n * AGL-1769 closed the *shape* of the unauthenticated cart write (the caller\n * chose the document path) and was explicit that it did not bound the\n * *quantity*: \"id control never multiplied volume; it bought shape\". This is\n * the other half. The cost of the unbounded half lands in the merchant's own\n * Firestore — document count and storage on a tenant who did nothing wrong.\n */\n\n/**\n * 120 writes per minute per (site, client IP).\n *\n * Deliberately generous, and the reasoning matters more than the number: a\n * limit that trips on genuine use is worse than no limit, because it gets\n * raised until it means nothing. Measured against what the storefront actually\n * emits:\n *\n * - `cart.tsx:214` puts the line-quantity `TextField`'s `onChange` straight\n * onto a POST with no debounce, so *typing* `100` into a quantity box is\n * three cart writes. Editing a few lines is a genuine ten-to-twenty-write\n * burst in a couple of seconds.\n * - `experiments/track` is an A/B beacon: one exposure write per experiment\n * per page view, so a visitor clicking through pages at ~1/s with two live\n * experiments sustains ~2 writes/s legitimately.\n * - Every visitor write on a site shares ONE budget (see the key below), so\n * these add rather than each getting their own headroom.\n *\n * What a tighter number would actually buy is small. At 120/min a single\n * source can still mint ~172k documents a day against one site; at 30/min it\n * is ~43k. Neither is \"safe\" in absolute terms — what the cap buys is turning\n * a one-line flood script from *millions* per day into something that needs a\n * distributed botnet to stay interesting, and turning an incident from\n * instantaneous into detectable. That property is bought at 120 as well as at\n * 30, and 30 would refuse real shoppers.\n *\n * 120/60s is also `DEFAULT_RATE_LIMIT` / `DEFAULT_RATE_WINDOW_MS` — the same\n * pair the public REST API enforces — so this adds no bespoke number.\n */\nexport const VISITOR_WRITE_RATE_LIMIT = 120\n\n/**\n * Fixed 60s window, matching every other limiter in the codebase rather than\n * improving on it. A fixed window admits up to 2× the cap across a boundary;\n * consistency with the shared bucket ids, the degradation markers and\n * `/api/health/rate-limits` is worth more here than closing that.\n */\nexport const VISITOR_WRITE_RATE_WINDOW_MS = 60_000\n\n/**\n * Machine-caller paths on EITHER plugin-API surface: neither dispatcher rate\n * limits these.\n *\n * The membership rule, so this list is decidable rather than a taste call: the\n * caller proves a shared secret, a webhook signature or a console session that\n * a visitor has no way to obtain, and its request volume is set by campaign\n * size or cron schedule rather than by human behaviour. A 120/min cap does not\n * merely inconvenience these — Resend delivering open/click webhooks for one\n * 50k-recipient campaign arrives from a handful of IPs for a single host and\n * would be shredded by any shopper-sized ceiling.\n *\n * ### Why a path list is right here when the dispatcher argues against them\n *\n * The lockdown gate two lines below is keyed by METHOD precisely to avoid a\n * path list, \"for the same reason `useSiteFetch` draws the Preview boundary\n * that way: a per-path list means thirteen handlers each remembering and the\n * fourteenth silently not.\"\n *\n * That argument turns on POLARITY, and this list has the opposite one. The\n * lockdown list would have enumerated what to *protect*, so a forgotten entry\n * is silently unprotected — the failure you never find. This enumerates what\n * to *exempt*, so the default is limited: a new visitor endpoint added\n * tomorrow is covered by doing nothing, and a forgotten machine endpoint\n * fails loudly and recoverably as 429s with `Retry-After` on a caller that\n * retries. Safe default, noisy mistake.\n *\n * Each entry, with the credential that makes it a machine caller:\n *\n * - `email/events` — Resend's webhook; `verifySvix` over `svix-id`/\n * `svix-timestamp`/`svix-signature` against `RESEND_WEBHOOK_SECRET`.\n * - `campaigns/send` — a Firebase ID token with a host member role, or\n * `CRON_SECRET` for the scheduled-send path.\n * - `bookings/reminders` — `x-cron-secret` against `CRON_SECRET`; refuses\n * outright when it is unset.\n * - `hooks/…` — the merchant's per-hook secret in `x-aglyn-secret`, compared\n * with `timingSafeEqual`. It already carries its own 30/min per-hook\n * limiter, so exempting it here removes nothing.\n *\n * The four below are registered on the CONSOLE surface only, and each is a\n * chunked cron sweep rather than a single beat — `sweepConsoleCron` follows\n * `nextCursor` up to `CRON_SWEEP_MAX_CHUNKS` (50) POSTs per route per run,\n * from one Cloud Functions address, and the key below carries no path. Three\n * such routes on one beat is 150 requests in a minute against a single\n * bucket, so this exemption is load-bearing and not decorative:\n *\n * - `campaigns/process-scheduled` — `x-cron-secret` against `CRON_SECRET`;\n * refuses outright when it is unset.\n * - `lists/materialize` — the same header, the same refusal.\n * - `commerce/process-abandoned` — the same header, the same refusal.\n * - `commerce/process-restock` — the same header, the same refusal.\n *\n * Note what is deliberately NOT exempt: presence of an `authorization` or\n * `svix-signature` HEADER. Header presence is not a credential — the\n * dispatcher cannot verify signatures it does not own, so exempting on the\n * header would let any caller skip the limiter by attaching a garbage one to\n * a cart POST.\n *\n * Nor is `commerce/supplier-update`, whose HMAC token makes it a machine\n * caller but whose volume is set by a supplier's own system rather than by a\n * schedule. It fails the second half of the membership rule, and the polarity\n * above says which way to resolve a borderline case: leave it limited, where\n * a mistake is a recoverable 429.\n */\nconst MACHINE_API_PATHS: ReadonlySet<string> = new Set([\n 'email/events',\n 'campaigns/send',\n 'bookings/reminders',\n 'campaigns/process-scheduled',\n 'lists/materialize',\n 'commerce/process-abandoned',\n 'commerce/process-restock',\n])\n\n/** Prefixes whose whole subtree is a machine surface (`hooks/{host}/{hook}`). */\nconst MACHINE_API_PREFIXES: readonly string[] = ['hooks/']\n\n/** Leading/trailing slashes stripped, matching `normalizeApiPath`. */\nfunction normalize(path: string): string {\n return String(path ?? '').replace(/^\\/+|\\/+$/g, '')\n}\n\n/**\n * Is this dispatcher path a credentialed machine surface (exempt), rather\n * than a visitor one (limited)? Unknown paths are visitor by default.\n *\n * A path is a machine's when it is on the list above, or when the plugin\n * that registered it declared it one (`machine: true`, AGL-3080) — the same\n * polarity as the list: a route is exempt only by saying so, and one that\n * forgot is limited, which fails loudly as a 429 its scheduler retries.\n */\nexport function isMachinePluginApiPath(path: string): boolean {\n const normalized = normalize(path)\n if (MACHINE_API_PATHS.has(normalized)) return true\n if (MACHINE_API_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return true\n return isPluginMachineRoute(normalized)\n}\n\n/**\n * The limiter key: one bucket per (site, client IP).\n *\n * ## Why compound, and why NOT a second limiter in either direction\n *\n * **Per-site alone is rejected.** A single attacker would consume the whole\n * merchant's allowance and every real shopper would then be refused — an\n * attacker-triggered denial of service against the victim, cheaper to mount\n * than the flood it replaces and costing the merchant *sales* rather than\n * storage. That is strictly worse than the growth it guards.\n *\n * **Per-IP alone is rejected.** Trivially distributed, and it would let one\n * merchant's traffic spend another's budget.\n *\n * **Compound is what \"per-host and per-IP together\" should mean here.** Each\n * source is bounded on each site, and no source can affect another's budget,\n * so the ceiling a site is exposed to is (distinct source IPs) × 120/min —\n * the same shape legitimate traffic has. What it does not stop is a genuine\n * botnet, and that is stated rather than papered over: bounding *that* needs\n * an edge WAF, not an application limiter, and every application-level scheme\n * that would catch it (a site-wide ceiling) reintroduces the self-DoS above.\n *\n * **A second, platform-wide per-IP limiter was considered and dropped.** It\n * would bound one source sweeping many merchants, but costs a second\n * Firestore transaction on every storefront write — tripling the ops on a\n * legitimate add-to-cart — to bound an attack that the compound key already\n * prices at one full budget per merchant. One transaction per visitor write\n * is the established cost here (`forms/submit`, `protection/unlock`); two\n * would be novel and is not paid for by what it buys.\n *\n * The path is **not** in the key, on purpose: one shared budget per (site,\n * IP) across cart, reviews, newsletter, bookings and beacons. Keying per path\n * would let a caller multiply its allowance by cycling endpoints, which is\n * free to do and defeats the point.\n *\n * `hostId` is frequently `''` — the dispatcher only resolves one from the\n * query or a JSON body, and a handler that self-gates may supply none. It is\n * kept in the key rather than skipped: AGL-1769 named \"an unresolvable\n * `hostId` skips the gate\" as an existing hole, and a limiter that a caller\n * turns off by declining to name a site is not a limiter. Host-less writes\n * simply share one bucket per IP.\n */\nexport function visitorWriteRateLimitKey(hostId: string, ip: string): string {\n return `pluginwrite:${hostId || '-'}:${ip || 'unknown'}`\n}\n\n/**\n * Policy for the CONSOLE plugin-API dispatcher's write rate limit.\n *\n * `apps/console/app/api/[...pluginApi]/route.ts` dispatches every plugin's\n * console-facing handler — the marketplace installs and publishes, `ai/assist`,\n * gift cards, POS orders, refunds, the email list previews and the suppression\n * writes — and it carries no limiter of any kind. Its gates each refuse\n * something narrower: per-site enablement needs a resolvable `hostId`, the\n * release flag only asks whether the plugin is on, and lockdown only refuses\n * during an incident. None of them bounds volume from an ordinary signed-in\n * operator, which is every request the surface serves.\n *\n * The exposure is not the same one the visitor limiter closes. These handlers\n * are authenticated, so nobody is minting documents anonymously; what they do\n * instead is spend REAL money per call — a contact scan with a read budget, a\n * bundle download and verify, a model call to a paid vendor — on a surface\n * where a stuck retry loop in one console tab is indistinguishable from abuse.\n */\n\n/**\n * 120 requests per minute per console subject.\n *\n * `DEFAULT_RATE_LIMIT` / `DEFAULT_RATE_WINDOW_MS` again, and the same pair the\n * visitor limiter and the public REST API use, so this introduces no bespoke\n * number to reason about. It is far above a person pressing buttons: the\n * costly routes on this surface are one-per-click, and the console's own\n * page-load traffic reaches Firestore through the client SDK rather than\n * through this dispatcher.\n *\n * Deliberately not tighter even though each call here costs more than a cart\n * write. A limit that trips on genuine use gets raised until it means nothing,\n * and the per-call cost is the wrong lever for that anyway — an entitlement or\n * a quota bounds spend per org over a month, where this bounds a runaway loop\n * over a minute. Two different jobs; this one only has to make the loop stop.\n */\nexport const CONSOLE_API_RATE_LIMIT = 120\n\n/** Fixed 60s window, matching every other limiter in the codebase. */\nexport const CONSOLE_API_RATE_WINDOW_MS = 60_000\n\n/**\n * The limiter key: one bucket per authenticated subject.\n *\n * ## Why this is NOT the visitor key\n *\n * `visitorWriteRateLimitKey` is compound because its surface is\n * unauthenticated, and both halves of that argument are answers to anonymity.\n * Per-site alone was rejected there because one attacker would spend a whole\n * merchant's allowance and refuse real shoppers — an attacker-triggered denial\n * of service against the victim. On the console the subject is a verified\n * `uid`, so a caller can only ever refuse ITSELF: the self-DoS the compound key\n * exists to prevent cannot be aimed at anyone else, and the isolation it buys\n * is already bought by the identity.\n *\n * `hostId` is therefore left OUT rather than added, which is the opposite of\n * the visitor key and for the reason the visitor key leaves the PATH out: an\n * operator with fifty sites would otherwise hold fifty budgets and could\n * multiply its allowance by cycling a field it controls. One person is one\n * budget across every site they can reach.\n *\n * ## The unauthenticated fallback\n *\n * A caller whose bearer token did not decode — a plugin key, a POS device, an\n * anonymous probe — has no `uid`, and its client address stands in. That\n * bucket is shared by every such caller behind one address, which is the\n * conservative direction: an unidentified caller on an authenticated surface\n * should not get a private allowance for declining to identify itself. Keeping\n * it counted at all is the point. A limiter a caller switches off by sending\n * no credential is not a limiter, and this surface's whole exposure is what\n * runs BEFORE a handler decides who is asking.\n */\nexport function consoleApiRateLimitKey(subject: string): string {\n return `consoleapi:${subject || 'unknown'}`\n}\n"],"names":["isPluginMachineRoute","VISITOR_WRITE_RATE_LIMIT","VISITOR_WRITE_RATE_WINDOW_MS","MACHINE_API_PATHS","Set","MACHINE_API_PREFIXES","normalize","path","String","replace","isMachinePluginApiPath","normalized","has","some","prefix","startsWith","visitorWriteRateLimitKey","hostId","ip","CONSOLE_API_RATE_LIMIT","CONSOLE_API_RATE_WINDOW_MS","consoleApiRateLimitKey","subject"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,mBAAe;AAEpD;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMC,2BAA2B,IAAG;AAE3C;;;;;CAKC,GACD,OAAO,MAAMC,+BAA+B,MAAM;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+DC,GACD,MAAMC,oBAAyC,IAAIC,IAAI;IACrD;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,+EAA+E,GAC/E,MAAMC,uBAA0C;IAAC;CAAS;AAE1D,oEAAoE,GACpE,SAASC,UAAUC,IAAY;IAC7B,OAAOC,OAAOD,eAAAA,OAAQ,IAAIE,OAAO,CAAC,cAAc;AAClD;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,uBAAuBH,IAAY;IACjD,MAAMI,aAAaL,UAAUC;IAC7B,IAAIJ,kBAAkBS,GAAG,CAACD,aAAa,OAAO;IAC9C,IAAIN,qBAAqBQ,IAAI,CAAC,CAACC,SAAWH,WAAWI,UAAU,CAACD,UAAU,OAAO;IACjF,OAAOd,qBAAqBW;AAC9B;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCC,GACD,OAAO,SAASK,yBAAyBC,MAAc,EAAEC,EAAU;IACjE,OAAO,CAAC,YAAY,EAAED,UAAU,IAAI,CAAC,EAAEC,MAAM,WAAW;AAC1D;AAEA;;;;;;;;;;;;;;;;;CAiBC,GAED;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,yBAAyB,IAAG;AAEzC,oEAAoE,GACpE,OAAO,MAAMC,6BAA6B,MAAM;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASC,uBAAuBC,OAAe;IACpD,OAAO,CAAC,WAAW,EAAEA,WAAW,WAAW;AAC7C"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/plugin-api-rate-limit.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { isPluginMachineRoute } from './api-plugins'\n\n/**\n * Policy for the tenant plugin-API dispatcher's visitor-write rate limit\n * (AGL-1770). Pure but for one read of the route registry, for a route its\n * plugin registered as a machine's, so it is unit-testable without a route\n * harness — the durable half lives in `@aglyn/tenant-data-admin` beside\n * `visitorWriteRefusal`, exactly like the lockdown split.\n *\n * ## What this bounds\n *\n * `apps/tenant/app/api/[...pluginApi]/route.ts` dispatches every plugin's\n * visitor-facing handler and, before this, applied no rate limit of any kind.\n * The gates it did have each refuse something narrower: per-site enablement is\n * skipped by an unresolvable `hostId`, the release gate only checks that the\n * plugin is on, and `visitorWriteRefusal` refuses only a paused or suspended\n * site. None of them bounds volume from a working, enabled site — which is\n * every live merchant.\n *\n * AGL-1769 closed the *shape* of the unauthenticated cart write (the caller\n * chose the document path) and was explicit that it did not bound the\n * *quantity*: \"id control never multiplied volume; it bought shape\". This is\n * the other half. The cost of the unbounded half lands in the merchant's own\n * Firestore — document count and storage on a tenant who did nothing wrong.\n */\n\n/**\n * 120 writes per minute per (site, client IP).\n *\n * Deliberately generous, and the reasoning matters more than the number: a\n * limit that trips on genuine use is worse than no limit, because it gets\n * raised until it means nothing. Measured against what the storefront actually\n * emits:\n *\n * - `cart.tsx:214` puts the line-quantity `TextField`'s `onChange` straight\n * onto a POST with no debounce, so *typing* `100` into a quantity box is\n * three cart writes. Editing a few lines is a genuine ten-to-twenty-write\n * burst in a couple of seconds.\n * - `experiments/track` is an A/B beacon: one exposure write per experiment\n * per page view, so a visitor clicking through pages at ~1/s with two live\n * experiments sustains ~2 writes/s legitimately.\n * - Every visitor write on a site shares ONE budget (see the key below), so\n * these add rather than each getting their own headroom.\n *\n * What a tighter number would actually buy is small. At 120/min a single\n * source can still mint ~172k documents a day against one site; at 30/min it\n * is ~43k. Neither is \"safe\" in absolute terms — what the cap buys is turning\n * a one-line flood script from *millions* per day into something that needs a\n * distributed botnet to stay interesting, and turning an incident from\n * instantaneous into detectable. That property is bought at 120 as well as at\n * 30, and 30 would refuse real shoppers.\n *\n * 120/60s is also `DEFAULT_RATE_LIMIT` / `DEFAULT_RATE_WINDOW_MS` — the same\n * pair the public REST API enforces — so this adds no bespoke number.\n */\nexport const VISITOR_WRITE_RATE_LIMIT = 120\n\n/**\n * Fixed 60s window, matching every other limiter in the codebase rather than\n * improving on it. A fixed window admits up to 2× the cap across a boundary;\n * consistency with the shared bucket ids, the degradation markers and\n * `/api/health/rate-limits` is worth more here than closing that.\n */\nexport const VISITOR_WRITE_RATE_WINDOW_MS = 60_000\n\n/**\n * Machine-caller paths on EITHER plugin-API surface: neither dispatcher rate\n * limits these.\n *\n * The membership rule, so this list is decidable rather than a taste call: the\n * caller proves a shared secret, a webhook signature or a console session that\n * a visitor has no way to obtain, and its request volume is set by campaign\n * size or cron schedule rather than by human behaviour. A 120/min cap does not\n * merely inconvenience these — Resend delivering open/click webhooks for one\n * 50k-recipient campaign arrives from a handful of IPs for a single host and\n * would be shredded by any shopper-sized ceiling.\n *\n * ### Why a path list is right here when the dispatcher argues against them\n *\n * The lockdown gate two lines below is keyed by METHOD precisely to avoid a\n * path list, \"for the same reason `useSiteFetch` draws the Preview boundary\n * that way: a per-path list means thirteen handlers each remembering and the\n * fourteenth silently not.\"\n *\n * That argument turns on POLARITY, and this list has the opposite one. The\n * lockdown list would have enumerated what to *protect*, so a forgotten entry\n * is silently unprotected — the failure you never find. This enumerates what\n * to *exempt*, so the default is limited: a new visitor endpoint added\n * tomorrow is covered by doing nothing, and a forgotten machine endpoint\n * fails loudly and recoverably as 429s with `Retry-After` on a caller that\n * retries. Safe default, noisy mistake.\n *\n * Each entry, with the credential that makes it a machine caller:\n *\n * - `email/events` — Resend's webhook; `verifySvix` over `svix-id`/\n * `svix-timestamp`/`svix-signature` against `RESEND_WEBHOOK_SECRET`.\n * - `campaigns/send` — a Firebase ID token with a host member role, or\n * `CRON_SECRET` for the scheduled-send path.\n * - `bookings/reminders` — `x-cron-secret` against `CRON_SECRET`; refuses\n * outright when it is unset.\n * - `hooks/…` — the merchant's per-hook secret in `x-aglyn-secret`, compared\n * with `timingSafeEqual`. It already carries its own 30/min per-hook\n * limiter, so exempting it here removes nothing.\n *\n * The four below are registered on the CONSOLE surface only, and each is a\n * chunked cron sweep rather than a single beat — `sweepConsoleCron` follows\n * `nextCursor` up to `CRON_SWEEP_MAX_CHUNKS` (50) POSTs per route per run,\n * from one Cloud Functions address, and the key below carries no path. Three\n * such routes on one beat is 150 requests in a minute against a single\n * bucket, so this exemption is load-bearing and not decorative:\n *\n * - `campaigns/process-scheduled` — `x-cron-secret` against `CRON_SECRET`;\n * refuses outright when it is unset.\n * - `lists/materialize` — the same header, the same refusal.\n * - `commerce/process-abandoned` — the same header, the same refusal.\n * - `commerce/process-restock` — the same header, the same refusal.\n *\n * Note what is deliberately NOT exempt: presence of an `authorization` or\n * `svix-signature` HEADER. Header presence is not a credential — the\n * dispatcher cannot verify signatures it does not own, so exempting on the\n * header would let any caller skip the limiter by attaching a garbage one to\n * a cart POST. (The console limiter does exempt the cron secret, but only\n * once it has compared the VALUE with `CRON_SECRET` — a credential it owns —\n * in `consoleApiRateLimitRefusal`.)\n *\n * Nor is `commerce/supplier-update`, whose HMAC token makes it a machine\n * caller but whose volume is set by a supplier's own system rather than by a\n * schedule. It fails the second half of the membership rule, and the polarity\n * above says which way to resolve a borderline case: leave it limited, where\n * a mistake is a recoverable 429.\n */\nconst MACHINE_API_PATHS: ReadonlySet<string> = new Set([\n 'email/events',\n 'campaigns/send',\n 'bookings/reminders',\n 'campaigns/process-scheduled',\n 'lists/materialize',\n 'commerce/process-abandoned',\n 'commerce/process-restock',\n])\n\n/** Prefixes whose whole subtree is a machine surface (`hooks/{host}/{hook}`). */\nconst MACHINE_API_PREFIXES: readonly string[] = ['hooks/']\n\n/** Leading/trailing slashes stripped, matching `normalizeApiPath`. */\nfunction normalize(path: string): string {\n return String(path ?? '').replace(/^\\/+|\\/+$/g, '')\n}\n\n/**\n * Is this dispatcher path a credentialed machine surface (exempt), rather\n * than a visitor one (limited)? Unknown paths are visitor by default.\n *\n * A path is a machine's when it is on the list above, or when the plugin\n * that registered it declared it one (`machine: true`, AGL-3080) — the same\n * polarity as the list: a route is exempt only by saying so, and one that\n * forgot is limited, which fails loudly as a 429 its scheduler retries.\n */\nexport function isMachinePluginApiPath(path: string): boolean {\n const normalized = normalize(path)\n if (MACHINE_API_PATHS.has(normalized)) return true\n if (MACHINE_API_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return true\n return isPluginMachineRoute(normalized)\n}\n\n/**\n * The limiter key: one bucket per (site, client IP).\n *\n * ## Why compound, and why NOT a second limiter in either direction\n *\n * **Per-site alone is rejected.** A single attacker would consume the whole\n * merchant's allowance and every real shopper would then be refused — an\n * attacker-triggered denial of service against the victim, cheaper to mount\n * than the flood it replaces and costing the merchant *sales* rather than\n * storage. That is strictly worse than the growth it guards.\n *\n * **Per-IP alone is rejected.** Trivially distributed, and it would let one\n * merchant's traffic spend another's budget.\n *\n * **Compound is what \"per-host and per-IP together\" should mean here.** Each\n * source is bounded on each site, and no source can affect another's budget,\n * so the ceiling a site is exposed to is (distinct source IPs) × 120/min —\n * the same shape legitimate traffic has. What it does not stop is a genuine\n * botnet, and that is stated rather than papered over: bounding *that* needs\n * an edge WAF, not an application limiter, and every application-level scheme\n * that would catch it (a site-wide ceiling) reintroduces the self-DoS above.\n *\n * **A second, platform-wide per-IP limiter was considered and dropped.** It\n * would bound one source sweeping many merchants, but costs a second\n * Firestore transaction on every storefront write — tripling the ops on a\n * legitimate add-to-cart — to bound an attack that the compound key already\n * prices at one full budget per merchant. One transaction per visitor write\n * is the established cost here (`forms/submit`, `protection/unlock`); two\n * would be novel and is not paid for by what it buys.\n *\n * The path is **not** in the key, on purpose: one shared budget per (site,\n * IP) across cart, reviews, newsletter, bookings and beacons. Keying per path\n * would let a caller multiply its allowance by cycling endpoints, which is\n * free to do and defeats the point.\n *\n * `hostId` is frequently `''` — the dispatcher only resolves one from the\n * query or a JSON body, and a handler that self-gates may supply none. It is\n * kept in the key rather than skipped: AGL-1769 named \"an unresolvable\n * `hostId` skips the gate\" as an existing hole, and a limiter that a caller\n * turns off by declining to name a site is not a limiter. Host-less writes\n * simply share one bucket per IP.\n */\nexport function visitorWriteRateLimitKey(hostId: string, ip: string): string {\n return `pluginwrite:${hostId || '-'}:${ip || 'unknown'}`\n}\n\n/**\n * Policy for the CONSOLE plugin-API dispatcher's write rate limit.\n *\n * `apps/console/app/api/[...pluginApi]/route.ts` dispatches every plugin's\n * console-facing handler — the marketplace installs and publishes, `ai/assist`,\n * gift cards, POS orders, refunds, the email list previews and the suppression\n * writes — and it carries no limiter of any kind. Its gates each refuse\n * something narrower: per-site enablement needs a resolvable `hostId`, the\n * release flag only asks whether the plugin is on, and lockdown only refuses\n * during an incident. None of them bounds volume from an ordinary signed-in\n * operator, which is every request the surface serves.\n *\n * The exposure is not the same one the visitor limiter closes. These handlers\n * are authenticated, so nobody is minting documents anonymously; what they do\n * instead is spend REAL money per call — a contact scan with a read budget, a\n * bundle download and verify, a model call to a paid vendor — on a surface\n * where a stuck retry loop in one console tab is indistinguishable from abuse.\n */\n\n/**\n * 120 requests per minute per console subject.\n *\n * `DEFAULT_RATE_LIMIT` / `DEFAULT_RATE_WINDOW_MS` again, and the same pair the\n * visitor limiter and the public REST API use, so this introduces no bespoke\n * number to reason about. It is far above a person pressing buttons: the\n * costly routes on this surface are one-per-click, and the console's own\n * page-load traffic reaches Firestore through the client SDK rather than\n * through this dispatcher.\n *\n * Deliberately not tighter even though each call here costs more than a cart\n * write. A limit that trips on genuine use gets raised until it means nothing,\n * and the per-call cost is the wrong lever for that anyway — an entitlement or\n * a quota bounds spend per org over a month, where this bounds a runaway loop\n * over a minute. Two different jobs; this one only has to make the loop stop.\n */\nexport const CONSOLE_API_RATE_LIMIT = 120\n\n/** Fixed 60s window, matching every other limiter in the codebase. */\nexport const CONSOLE_API_RATE_WINDOW_MS = 60_000\n\n/**\n * The limiter key: one bucket per authenticated subject.\n *\n * ## Why this is NOT the visitor key\n *\n * `visitorWriteRateLimitKey` is compound because its surface is\n * unauthenticated, and both halves of that argument are answers to anonymity.\n * Per-site alone was rejected there because one attacker would spend a whole\n * merchant's allowance and refuse real shoppers — an attacker-triggered denial\n * of service against the victim. On the console the subject is a verified\n * `uid`, so a caller can only ever refuse ITSELF: the self-DoS the compound key\n * exists to prevent cannot be aimed at anyone else, and the isolation it buys\n * is already bought by the identity.\n *\n * `hostId` is therefore left OUT rather than added, which is the opposite of\n * the visitor key and for the reason the visitor key leaves the PATH out: an\n * operator with fifty sites would otherwise hold fifty budgets and could\n * multiply its allowance by cycling a field it controls. One person is one\n * budget across every site they can reach.\n *\n * ## The unauthenticated fallback\n *\n * A caller whose bearer token did not decode — a plugin key, a POS device, an\n * anonymous probe — has no `uid`, and its client address stands in. That\n * bucket is shared by every such caller behind one address, which is the\n * conservative direction: an unidentified caller on an authenticated surface\n * should not get a private allowance for declining to identify itself. Keeping\n * it counted at all is the point. A limiter a caller switches off by sending\n * no credential is not a limiter, and this surface's whole exposure is what\n * runs BEFORE a handler decides who is asking.\n */\nexport function consoleApiRateLimitKey(subject: string): string {\n return `consoleapi:${subject || 'unknown'}`\n}\n"],"names":["isPluginMachineRoute","VISITOR_WRITE_RATE_LIMIT","VISITOR_WRITE_RATE_WINDOW_MS","MACHINE_API_PATHS","Set","MACHINE_API_PREFIXES","normalize","path","String","replace","isMachinePluginApiPath","normalized","has","some","prefix","startsWith","visitorWriteRateLimitKey","hostId","ip","CONSOLE_API_RATE_LIMIT","CONSOLE_API_RATE_WINDOW_MS","consoleApiRateLimitKey","subject"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,mBAAe;AAEpD;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMC,2BAA2B,IAAG;AAE3C;;;;;CAKC,GACD,OAAO,MAAMC,+BAA+B,MAAM;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiEC,GACD,MAAMC,oBAAyC,IAAIC,IAAI;IACrD;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,+EAA+E,GAC/E,MAAMC,uBAA0C;IAAC;CAAS;AAE1D,oEAAoE,GACpE,SAASC,UAAUC,IAAY;IAC7B,OAAOC,OAAOD,eAAAA,OAAQ,IAAIE,OAAO,CAAC,cAAc;AAClD;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,uBAAuBH,IAAY;IACjD,MAAMI,aAAaL,UAAUC;IAC7B,IAAIJ,kBAAkBS,GAAG,CAACD,aAAa,OAAO;IAC9C,IAAIN,qBAAqBQ,IAAI,CAAC,CAACC,SAAWH,WAAWI,UAAU,CAACD,UAAU,OAAO;IACjF,OAAOd,qBAAqBW;AAC9B;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCC,GACD,OAAO,SAASK,yBAAyBC,MAAc,EAAEC,EAAU;IACjE,OAAO,CAAC,YAAY,EAAED,UAAU,IAAI,CAAC,EAAEC,MAAM,WAAW;AAC1D;AAEA;;;;;;;;;;;;;;;;;CAiBC,GAED;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,yBAAyB,IAAG;AAEzC,oEAAoE,GACpE,OAAO,MAAMC,6BAA6B,MAAM;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASC,uBAAuBC,OAAe;IACpD,OAAO,CAAC,WAAW,EAAEA,WAAW,WAAW;AAC7C"}
@@ -64,8 +64,8 @@ import { PLUGIN_RELEASE_FLAGS } from "./plugin-release-flags.generated.js";
64
64
  {
65
65
  key: 'release_assist',
66
66
  label: `${PLATFORM_BRAND_NAME} Assist`,
67
- description: 'The in-console AI chat helper on every page (AGL-1860): docs-' + 'grounded answers with deep links, page-context guidance on Pro+. ' + 'OFF by default, and blocked on TWO published legal artifacts ' + '(AGL-1909), neither of which is a repo file — both are live besigner ' + 'pages, so publication is what satisfies them: (1) the privacy-policy ' + 'disclosure for stored Q&A, because the data loop records every ' + 'exchange org-scoped under orgs/{orgId}/assistExchanges; and (2) the ' + 'Anthropic row on /legal/subprocessors, which was deliberately ' + 'REMOVED on 2026-08-13 (subprocessorsV2a-20260813) on the premise ' + 'that no production key existed — so the page is not merely ' + 'incomplete without it, it is affirmatively wrong. NOTE the real ' + 'trigger is ANTHROPIC_API_KEY, not this flag: /api/ai/assist (the ' + 'besigner copy assistant, AGL-89/130/169) carries no release flag at ' + 'all and sends customer site content to Anthropic on the key plus a ' + 'Pro entitlement alone. Setting the key in production therefore makes ' + 'Anthropic a subprocessor whether or not this flag is ever flipped.',
68
- defaultEnabled: false
67
+ description: 'The in-console AI chat helper on every page (AGL-1860): docs-' + 'grounded answers with deep links, page-context guidance on Pro+. ' + 'ON in production, and gated on TWO published legal artifacts ' + '(AGL-1909), neither of which is a repo file — both are live besigner ' + 'pages, so publication is what satisfies them: (1) the privacy-policy ' + 'disclosure for stored Q&A, because the data loop records every ' + 'exchange org-scoped under orgs/{orgId}/assistExchanges; and (2) the ' + 'Anthropic row on /legal/subprocessors, which was deliberately ' + 'REMOVED on 2026-08-13 (subprocessorsV2a-20260813) on the premise ' + 'that no production key existed — so the page is not merely ' + 'incomplete without it, it is affirmatively wrong. NOTE the real ' + 'trigger is ANTHROPIC_API_KEY, not this flag: /api/ai/assist (the ' + 'besigner copy assistant, AGL-89/130/169) carries no release flag at ' + 'all and sends customer site content to Anthropic on the key plus a ' + 'Pro entitlement alone. Setting the key in production therefore makes ' + 'Anthropic a subprocessor whether or not this flag is ever flipped.',
68
+ defaultEnabled: true
69
69
  },
70
70
  // Ingress only, and with no staff preview on the server: a video a staff
71
71
  // session uploads into a customer's library serves on that customer's pages
@@ -101,8 +101,8 @@ import { PLUGIN_RELEASE_FLAGS } from "./plugin-release-flags.generated.js";
101
101
  {
102
102
  key: 'release_ai_generative',
103
103
  label: `${PLATFORM_BRAND_NAME} AI generation`,
104
- description: 'Generative building and automation behind the AI add-on ' + '(AGL-2903): sections, pages and workflows written by a model from ' + 'a brief, on the shared Anthropic runtime. OFF by default and staff ' + 'preview only; every generative route answers 404 while it is off. ' + 'Turning it on sends customer briefs and site content to Anthropic ' + 'on the same ANTHROPIC_API_KEY and under the same subprocessor ' + 'disclosure as Assist (AGL-1909).',
105
- defaultEnabled: false
104
+ description: 'Generative building and automation behind the AI add-on ' + '(AGL-2903): sections, pages and workflows written by a model from ' + 'a brief, on the shared Anthropic runtime. ON for every workspace ' + 'since 2026-09-29; every generative route answers 404 while it is ' + 'off. On, it sends customer briefs and site content to Anthropic ' + 'on the same ANTHROPIC_API_KEY and under the same subprocessor ' + 'disclosure as Assist (AGL-1909).',
105
+ defaultEnabled: true
106
106
  },
107
107
  // What CRM assistance hands the model provider, not whether it runs:
108
108
  // `release_ai_generative` opens the doors, this widens what a record sends
@@ -112,8 +112,8 @@ import { PLUGIN_RELEASE_FLAGS } from "./plugin-release-flags.generated.js";
112
112
  {
113
113
  key: 'release_crm_assist_whole_record',
114
114
  label: 'CRM assistance: whole record',
115
- description: 'What AI assistance in the CRM (a record summary, next step or ' + 'email draft) sends the model provider (AGL-3520). OFF: the ' + 'disclosed fields, with addresses and numbers in typed text ' + 'replaced. ON: the whole record, contact details and custom fields ' + 'included. Turn on only after Privacy Policy section 2 and the ' + 'Subprocessors row are republished; see ' + 'docs/drafts/agl-3520-crm-assistance-whole-record-disclosure.md.',
116
- defaultEnabled: false
115
+ description: 'What AI assistance in the CRM (a record summary, next step or ' + 'email draft) sends the model provider (AGL-3520). OFF: the ' + 'disclosed fields, with addresses and numbers in typed text ' + 'replaced. ON: the whole record, contact details and custom fields ' + 'included. ON since 2026-10-05, after Privacy Policy section 2 and ' + 'the Subprocessors row were republished; see ' + 'docs/drafts/agl-3520-crm-assistance-whole-record-disclosure.md.',
116
+ defaultEnabled: true
117
117
  }
118
118
  ];
119
119
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/release-flags.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Release flags (AGL-227): platform-level \"is this feature launched\"\n * gating backed by Firebase Remote Config. A separate axis from plan\n * entitlements (`plan-entitlements.ts`) — entitlements ask whether the\n * tenant's PLAN includes a feature, release flags ask whether the feature\n * is released to the public at all (or partially, via percentage rollout).\n * Staff bypass release flags everywhere but see a flagged warning.\n */\n\n/** Remote Config parameter keys for release-gated features. */\nimport type { OrgPlan } from '../foundation'\nimport { PLAN_LABELS, SELF_SERVE_PLANS } from './plan-entitlements'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\nimport {\n PLUGIN_RELEASE_FLAGS,\n type PluginReleaseFlagKey,\n} from './plugin-release-flags.generated'\n\n/**\n * Every release flag: the ones plugins declare (compiled from\n * plugins.config.json, AGL-3080) and the platform's own below.\n */\nexport type ReleaseFlagKey =\n | PluginReleaseFlagKey\n | 'release_addon_store'\n | 'release_native_checkout'\n | 'release_edit_bar'\n | 'release_assist'\n | 'release_video_uploads'\n | 'release_video_delivery'\n | 'release_ai_generative'\n | 'release_crm_assist_whole_record'\n\nexport interface ReleaseFlagDefinition {\n key: ReleaseFlagKey\n label: string\n description: string\n /**\n * Fallback verdict when Remote Config is unreachable (offline, blocked,\n * first paint before activate). MUST match the seeded value in\n * cloud/firebase-remoteconfig.template.json so environments without a\n * published template behave like the template intends.\n */\n defaultEnabled: boolean\n /** Host dashboard tab id (host-nav-tabs.ts) this flag hides, if any. */\n navTabId?: string\n}\n\n/** A release flag a plugin declares, as the generator compiles it. */\nexport type PluginReleaseFlagDefinition = ReleaseFlagDefinition & {\n key: PluginReleaseFlagKey\n}\n\n/**\n * The platform's own release flags: features no plugin owns.\n *\n * Every first-party plugin is release-flagged too (AGL-422) — the flag feeds\n * the plugin LOADER (console, published sites, API dispatch), not just nav\n * visibility, so staff can kill-switch a whole plugin platform-wide from the\n * Feature Flags page. Those flags are the plugins' own: each is defined by\n * its catalog row's `releaseFlagDefinition` in plugins.config.json and\n * compiled into `PLUGIN_RELEASE_FLAGS`, so adding a plugin edits nothing\n * here.\n */\nconst PLATFORM_RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n {\n key: 'release_addon_store',\n label: 'Add-on store',\n description:\n 'Self-serve add-on purchases on the Billing page: seats, datasets, ' +\n 'extra sites, POS registers, Event Calendar (AGL-524..531).',\n defaultEnabled: true,\n },\n // Released to every workspace 2026-10-06 (AGL-3606). Storefront-only since\n // the console's plan checkout stopped rendering Stripe Checkout at all. Still\n // the surface's kill switch, globally or per org: off, both storefront\n // checkout routes return the hosted redirect again.\n {\n key: 'release_native_checkout',\n label: 'In-page checkout',\n description:\n 'Storefront shoppers pay on the merchant\\u2019s own site: the Payment ' +\n 'Element, email, shipping address and method, and a live total with ' +\n 'tax open in place under the Buy or Checkout button, styled with the ' +\n 'site theme, instead of a redirect to checkout.stripe.com (AGL-1944). ' +\n 'Released to every workspace 2026-10-06 (AGL-3606). The webhook is ' +\n 'still the only thing that fulfils. Also gated on ' +\n 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, so a deployment without one keeps ' +\n 'the redirect; turning this off returns every storefront to the redirect.',\n defaultEnabled: true,\n },\n // Released to every site 2026-09-16 (AGL-3041). Still the whole surface's\n // kill switch, globally or for one org by override: off, the tenant slot\n // renders nothing, both token mints refuse, and `/api/edit-context` turns\n // away every token already issued.\n {\n key: 'release_edit_bar',\n label: 'Site admin bar',\n description:\n 'Admin bar on published sites: a signed-in editor jumps from a live ' +\n 'page straight into the Besigner to edit it. Released ' +\n '2026-09-16 (AGL-3041) for every site; turning it off hides the bar ' +\n 'and revokes every outstanding edit token at the verify site.',\n defaultEnabled: true,\n },\n {\n key: 'release_assist',\n label: `${PLATFORM_BRAND_NAME} Assist`,\n description:\n 'The in-console AI chat helper on every page (AGL-1860): docs-' +\n 'grounded answers with deep links, page-context guidance on Pro+. ' +\n 'OFF by default, and blocked on TWO published legal artifacts ' +\n '(AGL-1909), neither of which is a repo file — both are live besigner ' +\n 'pages, so publication is what satisfies them: (1) the privacy-policy ' +\n 'disclosure for stored Q&A, because the data loop records every ' +\n 'exchange org-scoped under orgs/{orgId}/assistExchanges; and (2) the ' +\n 'Anthropic row on /legal/subprocessors, which was deliberately ' +\n 'REMOVED on 2026-08-13 (subprocessorsV2a-20260813) on the premise ' +\n 'that no production key existed — so the page is not merely ' +\n 'incomplete without it, it is affirmatively wrong. NOTE the real ' +\n 'trigger is ANTHROPIC_API_KEY, not this flag: /api/ai/assist (the ' +\n 'besigner copy assistant, AGL-89/130/169) carries no release flag at ' +\n 'all and sends customer site content to Anthropic on the key plus a ' +\n 'Pro entitlement alone. Setting the key in production therefore makes ' +\n 'Anthropic a subprocessor whether or not this flag is ever flipped.',\n defaultEnabled: false,\n },\n // Ingress only, and with no staff preview on the server: a video a staff\n // session uploads into a customer's library serves on that customer's pages\n // like any other, so a grant goes to the org through its override.\n // `apps/console/utils/server/video-uploads.ts` holds the gate.\n {\n key: 'release_video_uploads',\n label: 'Video uploads',\n description:\n 'New video in the media library, on every upload path (AGL-2830). ' +\n 'Off: refused with 403 video_uploads_paused, as DAM video delivery is ' +\n 'not yet metered or bounded per org (AGL-2810, AGL-2812). Images, ' +\n 'documents and stored videos are unaffected.',\n defaultEnabled: false,\n },\n // Delivery, not ingress: this flag decides whether a video already in the\n // library is COPIED to the configured delivery provider and served from it\n // through a short-lived signed redirect (AGL-2824). Per org, with no staff\n // preview on the server — a copy is customer data at the provider, so a\n // grant goes to the org through its override. Off, or with no provider\n // configured, every video serves from this platform exactly as before.\n // `media-delivery.ts` in `tenant-data-admin` holds the gate.\n {\n key: 'release_video_delivery',\n label: 'Video delivery',\n description:\n 'Serves library video from a configured delivery provider through ' +\n 'short-lived signed redirects, and copies each video to it (AGL-2824). ' +\n 'OFF by default: video serves from this platform as before. Also ' +\n \"needs the provider's settings.\",\n defaultEnabled: false,\n },\n // The generative doors share one runtime with Assist (AGL-2903) but not\n // its flag: `release_assist` answers questions, this builds things, and\n // the two are sold, metered and disclosed separately. The server-side\n // gate lives in `aiGateLadder` (404 when off, staff preview).\n // Labeled for what it switches — the generative doors — and not with the\n // add-on's own name, which is sold and live whether or not the doors are\n // released: the docs name the add-on, and a flag wearing the same words\n // reads as documented when it is not.\n {\n key: 'release_ai_generative',\n label: `${PLATFORM_BRAND_NAME} AI generation`,\n description:\n 'Generative building and automation behind the AI add-on ' +\n '(AGL-2903): sections, pages and workflows written by a model from ' +\n 'a brief, on the shared Anthropic runtime. OFF by default and staff ' +\n 'preview only; every generative route answers 404 while it is off. ' +\n 'Turning it on sends customer briefs and site content to Anthropic ' +\n 'on the same ANTHROPIC_API_KEY and under the same subprocessor ' +\n 'disclosure as Assist (AGL-1909).',\n defaultEnabled: false,\n },\n // What CRM assistance hands the model provider, not whether it runs:\n // `release_ai_generative` opens the doors, this widens what a record sends\n // through them. Per org, with no staff preview — the data is the customer's,\n // and a staff session reading a customer's record must send what the\n // published pages promise. The CRM's record-facts readers hold the gate.\n {\n key: 'release_crm_assist_whole_record',\n label: 'CRM assistance: whole record',\n description:\n 'What AI assistance in the CRM (a record summary, next step or ' +\n 'email draft) sends the model provider (AGL-3520). OFF: the ' +\n 'disclosed fields, with addresses and numbers in typed text ' +\n 'replaced. ON: the whole record, contact details and custom fields ' +\n 'included. Turn on only after Privacy Policy section 2 and the ' +\n 'Subprocessors row are republished; see ' +\n 'docs/drafts/agl-3520-crm-assistance-whole-record-disclosure.md.',\n defaultEnabled: false,\n },\n]\n\n/**\n * The registry: one entry per gated feature, the plugins' first. Adding a\n * platform flag = add it above, seed it in the Remote Config template, and\n * (optionally) wrap the page in `<FeatureGate>` — the staff admin editor and\n * nav filtering pick it up from this list.\n */\nexport const RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n ...PLUGIN_RELEASE_FLAGS,\n ...PLATFORM_RELEASE_FLAGS,\n]\n\nexport const RELEASE_FLAG_KEYS = RELEASE_FLAGS.map(\n (definition) => definition.key,\n) as readonly ReleaseFlagKey[]\n\nexport function isReleaseFlagKey(value: string): value is ReleaseFlagKey {\n return (RELEASE_FLAG_KEYS as readonly string[]).includes(value)\n}\n\nexport function getReleaseFlagDefinition(\n key: ReleaseFlagKey,\n): ReleaseFlagDefinition {\n const definition = RELEASE_FLAGS.find((entry) => entry.key === key)\n if (!definition) throw new Error(`Unknown release flag: ${key}`)\n return definition\n}\n\n/**\n * The JSON payload stored in each Remote Config parameter. `enabled: true`\n * turns the feature on for everyone; `enabled: false` with a positive\n * `rolloutPercent` enables it for that percentage of subjects (stable\n * per-tenant bucketing, GrowthBook-style); `plans` narrows either of those\n * to a set of tiers (AGL-2486).\n */\nexport interface ReleaseFlagValue {\n enabled: boolean\n /** 0–100; only consulted while `enabled` is false. */\n rolloutPercent?: number\n /**\n * Plan/tier targeting (AGL-2486). The tiers this flag is being rolled out\n * to, or ABSENT for \"every tier\".\n *\n * ABSENT AND EMPTY BOTH MEAN EVERY TIER, and that is load-bearing: every\n * flag stored before this field existed parses to `undefined` here, and an\n * operator who unticks every box has plainly not asked for a flag that\n * reaches nobody. Reading an empty list as \"no tiers\" would dark-launch\n * nothing while the console still said the flag was on — the inverted\n * reading of this field is the whole hazard, so it is closed in the parser\n * rather than left to each call site.\n */\n plans?: OrgPlan[]\n /** Free-form staff note (\"waiting on AGL-199\", owner, etc.). */\n note?: string\n}\n\nconst clampPercent = (value: unknown): number => {\n const percent = typeof value === 'number' && Number.isFinite(value) ? value : 0\n return Math.min(100, Math.max(0, Math.round(percent)))\n}\n\n/**\n * Every plan a flag may target, cheapest first — `SELF_SERVE_PLANS` then\n * `enterprise`, the same ladder `planGrantingFeature` walks and the same\n * order the plan grid renders. Derived, never re-typed: pricing v3 inserted\n * `scale` mid-ladder and added `agency`, and a second hand-written tier list\n * here would have missed both.\n */\nexport const RELEASE_FLAG_PLAN_LADDER: readonly OrgPlan[] = [\n ...SELF_SERVE_PLANS,\n 'enterprise' as OrgPlan,\n]\n\nconst isOrgPlan = (value: unknown): value is OrgPlan =>\n typeof value === 'string' &&\n Object.prototype.hasOwnProperty.call(PLAN_LABELS, value)\n\n/**\n * The tiers on the ladder at or above `plan` — what the console's \"Pro and\n * above\" shortcut expands to before it is STORED as an explicit list.\n *\n * Deliberately a UI convenience and not a stored \"minimum plan\" mode: a\n * stored threshold would silently re-aim every live flag the day a tier is\n * inserted into the middle of the ladder (pricing v3 did exactly that with\n * `scale`). An explicit list means the audience of a published flag only\n * ever changes because a human changed it.\n */\nexport function releaseFlagPlansAtOrAbove(plan: OrgPlan): OrgPlan[] {\n const index = RELEASE_FLAG_PLAN_LADDER.indexOf(plan)\n return index < 0 ? [] : RELEASE_FLAG_PLAN_LADDER.slice(index)\n}\n\n/**\n * Sanitises whatever is stored at `plans`, in ladder order.\n *\n * Returns `undefined` — not `[]` — for \"no targeting declared\", so the\n * absence survives a round trip through the staff editor and cannot be\n * written back as a list that means something else. Unknown tier names are\n * dropped for the same reason `parseOrgReleaseFlagOverrides` drops unknown\n * keys: a renamed or retired tier must never gate anything. A list that\n * names ONLY unknown tiers collapses to `undefined` (every tier) rather than\n * to an empty list, because a typo must inherit, never silently target.\n */\nexport function parseReleaseFlagPlans(raw: unknown): OrgPlan[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const named = new Set(raw.filter(isOrgPlan))\n if (named.size === 0) return undefined\n return RELEASE_FLAG_PLAN_LADDER.filter((plan) => named.has(plan))\n}\n\n/**\n * Parses a Remote Config parameter string into a `ReleaseFlagValue`.\n * Tolerant by design — the template is hand-editable in the Firebase\n * console, so plain \"true\"/\"false\" strings and malformed JSON must not\n * crash gating: anything unreadable falls back to the registry default.\n */\nexport function parseReleaseFlagValue(\n raw: string | null | undefined,\n fallbackEnabled: boolean,\n): ReleaseFlagValue {\n const text = raw?.trim()\n if (!text) return { enabled: fallbackEnabled }\n if (text === 'true') return { enabled: true }\n if (text === 'false') return { enabled: false }\n try {\n const parsed = JSON.parse(text)\n if (typeof parsed === 'boolean') return { enabled: parsed }\n if (parsed && typeof parsed === 'object') {\n const plans = parseReleaseFlagPlans(parsed.plans)\n return {\n enabled: parsed.enabled === true,\n rolloutPercent: clampPercent(parsed.rolloutPercent),\n // Spread, so a flag stored before AGL-2486 has NO `plans` key at all\n // rather than an explicit `undefined`. `JSON.stringify` drops both,\n // but the two are not the same to `'plans' in value`, which is what\n // the staff PUT uses to tell \"the operator cleared the targeting\"\n // apart from \"this client never sent the field\".\n ...(plans ? { plans } : {}),\n note: typeof parsed.note === 'string' ? parsed.note : undefined,\n }\n }\n } catch {\n // fall through to the registry default\n }\n return { enabled: fallbackEnabled }\n}\n\n/**\n * The longest parameter description Remote Config will publish (AGL-3048).\n *\n * One description past it refuses the WHOLE publish with\n * `DESCRIPTION_EXCEEDS_MAXIMUM_SIZE`, which the staff flags route answered as\n * a 500. Registry descriptions are written for the staff flags page, and\n * several run far past the limit, so what the page shows and what a\n * parameter carries cannot always be the same text.\n *\n * Counted in UTF-8 BYTES. Remote Config states the limit in characters\n * without saying how it counts them, and a UTF-8 byte count is never smaller\n * than the code-point or UTF-16 count, so text within 256 bytes is within the\n * limit on every reading. The cost is a few characters of headroom on text\n * with typographic punctuation.\n */\nexport const REMOTE_CONFIG_DESCRIPTION_MAX_BYTES = 256\n\nfunction utf8ByteLength(text: string): number {\n let bytes = 0\n for (const character of text) {\n const codePoint = character.codePointAt(0) ?? 0\n bytes +=\n codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4\n }\n return bytes\n}\n\n/** Whether Remote Config accepts `text` as a parameter description. */\nexport function fitsRemoteConfigDescription(text: string): boolean {\n return utf8ByteLength(text) <= REMOTE_CONFIG_DESCRIPTION_MAX_BYTES\n}\n\nconst DESCRIPTION_ELLIPSIS = '…'\n\n/**\n * `text` cut to a length Remote Config accepts, at a word boundary, with an\n * ellipsis marking the cut. Text that already fits comes back unchanged.\n *\n * The cut takes whole code points, so a multi-byte character is never split.\n * It backs up to the last space when it lands inside a word (only a single\n * word longer than the whole budget is cut hard), and punctuation left\n * dangling before the ellipsis is dropped.\n */\nexport function clampRemoteConfigDescription(text: string): string {\n if (fitsRemoteConfigDescription(text)) return text\n const budget =\n REMOTE_CONFIG_DESCRIPTION_MAX_BYTES - utf8ByteLength(DESCRIPTION_ELLIPSIS)\n let kept = ''\n let bytes = 0\n for (const character of text) {\n const size = utf8ByteLength(character)\n if (bytes + size > budget) break\n kept += character\n bytes += size\n }\n const next = text.charAt(kept.length)\n if (next && !/\\s/.test(next)) {\n const lastSpace = kept.search(/\\s\\S*$/)\n if (lastSpace > 0) kept = kept.slice(0, lastSpace)\n }\n return `${kept.replace(/[\\s.,;:(–—-]+$/, '')}${DESCRIPTION_ELLIPSIS}`\n}\n\n/**\n * The description to publish with a release flag's Remote Config parameter\n * (AGL-3048). Always one Remote Config accepts:\n *\n * 1. The registry description, when it fits, so the flags page and the\n * parameter say the same thing.\n * 2. Otherwise the LIVE parameter's description, when it has one that fits.\n * Remote Config has already accepted exactly that text, and someone chose\n * it for the Firebase console. The first 250 characters of a longer\n * paragraph would drop whatever the paragraph says last: for\n * `release_assist`, the `/legal/subprocessors` precondition that its\n * AGL-1909 suite wants in front of whoever reads the flag there. The full\n * registry text stays on the staff flags page either way.\n * 3. Otherwise, as on a first publish with nothing live to keep, the\n * registry description clamped at a word boundary.\n */\nexport function releaseFlagParameterDescription(\n registryDescription: string,\n liveDescription: string | null | undefined,\n): string {\n if (fitsRemoteConfigDescription(registryDescription)) {\n return registryDescription\n }\n if (\n typeof liveDescription === 'string' &&\n liveDescription.trim().length > 0 &&\n fitsRemoteConfigDescription(liveDescription)\n ) {\n return liveDescription\n }\n return clampRemoteConfigDescription(registryDescription)\n}\n\n/**\n * FNV-1a 32-bit hash → 0–99 bucket. Deterministic so a subject keeps the\n * same rollout verdict across sessions and surfaces, and seeded with the\n * flag key so a subject doesn't land in the same bucket for every flag.\n */\nexport function releaseFlagBucket(flagKey: string, subjectId: string): number {\n let hash = 0x811c9dc5\n const seed = `${flagKey}:${subjectId}`\n for (let index = 0; index < seed.length; index += 1) {\n hash ^= seed.charCodeAt(index)\n hash = Math.imul(hash, 0x01000193)\n }\n return (hash >>> 0) % 100\n}\n\n/**\n * The gating verdict for one subject.\n *\n * `subjectId` is the ORG ID, or nothing. A rollout is a cohort of\n * workspaces, so a whole workspace has to land on one side of the\n * percentage: every surface that asks about an org must ask with the same\n * string, or they answer differently about the same customer.\n *\n * This docstring used to sanction falling back to the uid, and the server\n * gates fell back to a hostId. Since a hostId, a uid and an orgId hash to\n * three different buckets, a mid-rollout flag could be on in the console\n * and off on the published site for one workspace — stable per subject, so\n * it never flickered, it just stayed wrong (AGL-1656).\n *\n * An empty subject only passes fully-enabled flags. That is the deliberate\n * cost: a request with no resolvable org never joins a partial rollout,\n * which is the conservative answer rather than a confidently wrong one.\n */\nexport function isReleaseFlagOn(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n /**\n * The subject org's plan, when the caller knows it (AGL-2486). Optional so\n * every pre-existing call site compiles and answers unchanged: a flag that\n * declares no tier targeting never reads this.\n */\n plan?: OrgPlan | null,\n): boolean {\n if (!releaseFlagTargetsPlan(value, plan)) return false\n if (value.enabled) return true\n const percent = clampPercent(value.rolloutPercent)\n if (percent <= 0 || !subjectId) return false\n if (percent >= 100) return true\n return releaseFlagBucket(flagKey, subjectId) < percent\n}\n\n/**\n * Does this flag's tier targeting admit `plan`? (AGL-2486)\n *\n * THE SEMANTICS, because an operator who cannot predict the audience will\n * not stage a rollout at all:\n *\n * - The tier list is a FILTER, applied before and independently of the\n * percentage. The percentage then picks a cohort WITHIN the admitted\n * tiers. \"Pro and above at 50%\" is therefore both \"half of the Pro+\n * workspaces\" and \"the global 50% cohort, restricted to Pro+\" — the two\n * readings name the same set, and they do so because\n * {@link releaseFlagBucket} hashes `flagKey:orgId` and NOTHING ELSE.\n * - Which is also why the bucket is stable. Adding, removing or reordering\n * tiers cannot reshuffle the bucket, so an org already inside a 50%\n * rollout keeps the feature when an unrelated tier joins the list. Had\n * the plan been mixed into the hash — the obvious way to write this — a\n * tier edit would have re-drawn the cohort under every customer already\n * in it, and a plan change would have re-rolled the dice for one.\n * - The filter binds the FULLY-ENABLED path too, not just the rollout.\n * \"On, Enterprise + Agency\" is how a launch to the top of the ladder is\n * expressed, and it is what was actually asked for. Untargeted flags are\n * unaffected: no list means every tier, so `enabled` still means everyone.\n * - An UNKNOWN plan fails a declared list. Same conservatism as a missing\n * subject on a percentage rollout: a caller that cannot say which\n * workspace it is asking about gets the safe answer rather than a\n * confidently wrong one. A per-org staff override still wins over all of\n * it — see {@link isReleaseFlagOnForOrg} — so a targeted flag can still be\n * handed to one org off-ladder.\n */\nexport function releaseFlagTargetsPlan(\n value: ReleaseFlagValue,\n plan: OrgPlan | null | undefined,\n): boolean {\n const plans = value.plans\n // `!plans` would be the idiom here, but `strictNullChecks` is off repo-wide\n // and an empty array is truthy — both \"absent\" and \"empty\" have to be named\n // for the every-tier reading to actually hold.\n if (plans == null || plans.length === 0) return true\n if (!plan) return false\n return plans.includes(plan)\n}\n\n/**\n * Per-org release-flag overrides (AGL-1635), stored on the org doc at\n * `releaseFlags`. A present key is a staff DECISION about one organization\n * and wins over both the Remote Config value and the rollout bucket; an\n * absent key inherits.\n *\n * A separate field from `entitlements.features` on purpose: those ask\n * whether the org's PLAN includes a feature, these ask whether a\n * not-yet-released feature is switched on for this one customer. Folding\n * them together would make \"granted by the deal\" and \"previewing an\n * unreleased build\" indistinguishable at the point a support question is\n * asked.\n *\n * Why the org doc and not a Remote Config condition: RC conditions are\n * template-global and would need one published condition per org, with a\n * template publish (a manual, separate deploy) for every grant.\n */\nexport type OrgReleaseFlagOverrides = Partial<Record<ReleaseFlagKey, boolean>>\n\n/**\n * Sanitises whatever is actually stored at `org.releaseFlags`.\n *\n * Tolerant for the same reason `parseReleaseFlagValue` is: this map is\n * hand-editable in the Firebase console and survives registry renames, so a\n * retired flag key or a non-boolean must be dropped rather than allowed to\n * gate anything. Unknown keys are discarded — a stale key can never grant a\n * flag that no longer exists, and a typo silently inherits instead of\n * silently forcing.\n */\nexport function parseOrgReleaseFlagOverrides(\n raw: unknown,\n): OrgReleaseFlagOverrides {\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}\n const overrides: OrgReleaseFlagOverrides = {}\n for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {\n if (typeof value === 'boolean' && isReleaseFlagKey(key)) {\n overrides[key] = value\n }\n }\n return overrides\n}\n\n/**\n * The gating verdict with a per-org override applied on top of\n * `isReleaseFlagOn`.\n *\n * The override is checked FIRST and short-circuits, so a forced-off flag\n * stays off for an org even while the flag is globally enabled — the\n * per-org kill switch is half the point, not just the per-org grant. Every\n * release-flag gate resolves through here so the console, the tenant\n * runtime and the API dispatchers cannot disagree about one org.\n */\nexport function isReleaseFlagOnForOrg(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n overrides: OrgReleaseFlagOverrides | null | undefined,\n /**\n * The org's plan, for tier targeting (AGL-2486). Checked only AFTER the\n * override: a staff grant is a decision about one named customer and\n * outranks the tier filter exactly as it already outranks the rollout\n * bucket. That ordering is what lets a Free org preview a\n * Business-targeted flag without widening the flag for every Free org.\n *\n * REQUIRED, and `null` has to be typed out. It was optional for one\n * release and that cost a silent revenue bug: `report-usage` held the org\n * document, dropped the argument, and every `plans`-declaring flag read\n * OFF — so the contacts overage went uninvoiced for exactly the orgs\n * entitled to reach the feature, with nothing thrown and nothing logged.\n * An unknown tier still refuses, which is the correct conservatism; what\n * cannot be allowed is a caller MANUFACTURING an unknown out of a tier it\n * already has in hand. Spelling `null` makes that a statement rather than\n * an omission, and makes the next call site that forgets fail to compile\n * instead of failing to bill. `getOrgReleaseFlagTargeting` returns the\n * plan beside the overrides from ONE document read precisely so that\n * passing it costs nothing.\n */\n plan: OrgPlan | null,\n): boolean {\n const override = overrides?.[flagKey]\n if (typeof override === 'boolean') return override\n return isReleaseFlagOn(flagKey, value, subjectId, plan)\n}\n"],"names":["PLAN_LABELS","SELF_SERVE_PLANS","PLATFORM_BRAND_NAME","PLUGIN_RELEASE_FLAGS","PLATFORM_RELEASE_FLAGS","key","label","description","defaultEnabled","RELEASE_FLAGS","RELEASE_FLAG_KEYS","map","definition","isReleaseFlagKey","value","includes","getReleaseFlagDefinition","find","entry","Error","clampPercent","percent","Number","isFinite","Math","min","max","round","RELEASE_FLAG_PLAN_LADDER","isOrgPlan","Object","prototype","hasOwnProperty","call","releaseFlagPlansAtOrAbove","plan","index","indexOf","slice","parseReleaseFlagPlans","raw","Array","isArray","undefined","named","Set","filter","size","has","parseReleaseFlagValue","fallbackEnabled","text","trim","enabled","parsed","JSON","parse","plans","rolloutPercent","note","REMOTE_CONFIG_DESCRIPTION_MAX_BYTES","utf8ByteLength","bytes","character","codePoint","codePointAt","fitsRemoteConfigDescription","DESCRIPTION_ELLIPSIS","clampRemoteConfigDescription","budget","kept","next","charAt","length","test","lastSpace","search","replace","releaseFlagParameterDescription","registryDescription","liveDescription","releaseFlagBucket","flagKey","subjectId","hash","seed","charCodeAt","imul","isReleaseFlagOn","releaseFlagTargetsPlan","parseOrgReleaseFlagOverrides","overrides","entries","isReleaseFlagOnForOrg","override"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;CAOC,GAED,6DAA6D;AAE7D,SAASA,WAAW,EAAEC,gBAAgB,QAAQ,yBAAqB;AACnE,SAASC,mBAAmB,QAAQ,sBAAkB;AACtD,SACEC,oBAAoB,QAEf,sCAAkC;AAqCzC;;;;;;;;;;CAUC,GACD,MAAMC,yBAA2D;IAC/D;QACEC,KAAK;QACLC,OAAO;QACPC,aACE,uEACA;QACFC,gBAAgB;IAClB;IACA,2EAA2E;IAC3E,8EAA8E;IAC9E,uEAAuE;IACvE,oDAAoD;IACpD;QACEH,KAAK;QACLC,OAAO;QACPC,aACE,0EACA,wEACA,yEACA,0EACA,uEACA,sDACA,2EACA;QACFC,gBAAgB;IAClB;IACA,0EAA0E;IAC1E,yEAAyE;IACzE,0EAA0E;IAC1E,mCAAmC;IACnC;QACEH,KAAK;QACLC,OAAO;QACPC,aACE,wEACA,0DACA,wEACA;QACFC,gBAAgB;IAClB;IACA;QACEH,KAAK;QACLC,OAAO,GAAGJ,oBAAoB,OAAO,CAAC;QACtCK,aACE,kEACA,sEACA,kEACA,0EACA,0EACA,oEACA,yEACA,mEACA,sEACA,gEACA,qEACA,sEACA,yEACA,wEACA,0EACA;QACFC,gBAAgB;IAClB;IACA,yEAAyE;IACzE,4EAA4E;IAC5E,mEAAmE;IACnE,+DAA+D;IAC/D;QACEH,KAAK;QACLC,OAAO;QACPC,aACE,sEACA,0EACA,sEACA;QACFC,gBAAgB;IAClB;IACA,0EAA0E;IAC1E,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,uEAAuE;IACvE,uEAAuE;IACvE,6DAA6D;IAC7D;QACEH,KAAK;QACLC,OAAO;QACPC,aACE,sEACA,2EACA,qEACA;QACFC,gBAAgB;IAClB;IACA,wEAAwE;IACxE,wEAAwE;IACxE,sEAAsE;IACtE,8DAA8D;IAC9D,yEAAyE;IACzE,yEAAyE;IACzE,wEAAwE;IACxE,sCAAsC;IACtC;QACEH,KAAK;QACLC,OAAO,GAAGJ,oBAAoB,cAAc,CAAC;QAC7CK,aACE,6DACA,uEACA,wEACA,uEACA,uEACA,mEACA;QACFC,gBAAgB;IAClB;IACA,qEAAqE;IACrE,2EAA2E;IAC3E,6EAA6E;IAC7E,qEAAqE;IACrE,yEAAyE;IACzE;QACEH,KAAK;QACLC,OAAO;QACPC,aACE,mEACA,gEACA,gEACA,uEACA,mEACA,4CACA;QACFC,gBAAgB;IAClB;CACD;AAED;;;;;CAKC,GACD,OAAO,MAAMC,gBAAkD;OAC1DN;OACAC;CACJ,CAAA;AAED,OAAO,MAAMM,oBAAoBD,cAAcE,GAAG,CAChD,CAACC,aAAeA,WAAWP,GAAG,EACF;AAE9B,OAAO,SAASQ,iBAAiBC,KAAa;IAC5C,OAAO,AAACJ,kBAAwCK,QAAQ,CAACD;AAC3D;AAEA,OAAO,SAASE,yBACdX,GAAmB;IAEnB,MAAMO,aAAaH,cAAcQ,IAAI,CAAC,CAACC,QAAUA,MAAMb,GAAG,KAAKA;IAC/D,IAAI,CAACO,YAAY,MAAM,IAAIO,MAAM,CAAC,sBAAsB,EAAEd,KAAK;IAC/D,OAAOO;AACT;AA8BA,MAAMQ,eAAe,CAACN;IACpB,MAAMO,UAAU,OAAOP,UAAU,YAAYQ,OAAOC,QAAQ,CAACT,SAASA,QAAQ;IAC9E,OAAOU,KAAKC,GAAG,CAAC,KAAKD,KAAKE,GAAG,CAAC,GAAGF,KAAKG,KAAK,CAACN;AAC9C;AAEA;;;;;;CAMC,GACD,OAAO,MAAMO,2BAA+C;OACvD3B;IACH;CACD,CAAA;AAED,MAAM4B,YAAY,CAACf,QACjB,OAAOA,UAAU,YACjBgB,OAAOC,SAAS,CAACC,cAAc,CAACC,IAAI,CAACjC,aAAac;AAEpD;;;;;;;;;CASC,GACD,OAAO,SAASoB,0BAA0BC,IAAa;IACrD,MAAMC,QAAQR,yBAAyBS,OAAO,CAACF;IAC/C,OAAOC,QAAQ,IAAI,EAAE,GAAGR,yBAAyBU,KAAK,CAACF;AACzD;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASG,sBAAsBC,GAAY;IAChD,IAAI,CAACC,MAAMC,OAAO,CAACF,MAAM,OAAOG;IAChC,MAAMC,QAAQ,IAAIC,IAAIL,IAAIM,MAAM,CAACjB;IACjC,IAAIe,MAAMG,IAAI,KAAK,GAAG,OAAOJ;IAC7B,OAAOf,yBAAyBkB,MAAM,CAAC,CAACX,OAASS,MAAMI,GAAG,CAACb;AAC7D;AAEA;;;;;CAKC,GACD,OAAO,SAASc,sBACdT,GAA8B,EAC9BU,eAAwB;IAExB,MAAMC,OAAOX,uBAAAA,IAAKY,IAAI;IACtB,IAAI,CAACD,MAAM,OAAO;QAAEE,SAASH;IAAgB;IAC7C,IAAIC,SAAS,QAAQ,OAAO;QAAEE,SAAS;IAAK;IAC5C,IAAIF,SAAS,SAAS,OAAO;QAAEE,SAAS;IAAM;IAC9C,IAAI;QACF,MAAMC,SAASC,KAAKC,KAAK,CAACL;QAC1B,IAAI,OAAOG,WAAW,WAAW,OAAO;YAAED,SAASC;QAAO;QAC1D,IAAIA,UAAU,OAAOA,WAAW,UAAU;YACxC,MAAMG,QAAQlB,sBAAsBe,OAAOG,KAAK;YAChD,OAAO;gBACLJ,SAASC,OAAOD,OAAO,KAAK;gBAC5BK,gBAAgBtC,aAAakC,OAAOI,cAAc;eAM9CD,QAAQ;gBAAEA;YAAM,IAAI,CAAC;gBACzBE,MAAM,OAAOL,OAAOK,IAAI,KAAK,WAAWL,OAAOK,IAAI,GAAGhB;;QAE1D;IACF,EAAE,eAAM;IACN,uCAAuC;IACzC;IACA,OAAO;QAAEU,SAASH;IAAgB;AACpC;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMU,sCAAsC,IAAG;AAEtD,SAASC,eAAeV,IAAY;IAClC,IAAIW,QAAQ;IACZ,KAAK,MAAMC,aAAaZ,KAAM;YACVY;QAAlB,MAAMC,aAAYD,yBAAAA,UAAUE,WAAW,CAAC,cAAtBF,yBAA4B;QAC9CD,SACEE,YAAY,OAAO,IAAIA,YAAY,QAAQ,IAAIA,YAAY,UAAU,IAAI;IAC7E;IACA,OAAOF;AACT;AAEA,qEAAqE,GACrE,OAAO,SAASI,4BAA4Bf,IAAY;IACtD,OAAOU,eAAeV,SAASS;AACjC;AAEA,MAAMO,uBAAuB;AAE7B;;;;;;;;CAQC,GACD,OAAO,SAASC,6BAA6BjB,IAAY;IACvD,IAAIe,4BAA4Bf,OAAO,OAAOA;IAC9C,MAAMkB,SACJT,sCAAsCC,eAAeM;IACvD,IAAIG,OAAO;IACX,IAAIR,QAAQ;IACZ,KAAK,MAAMC,aAAaZ,KAAM;QAC5B,MAAMJ,OAAOc,eAAeE;QAC5B,IAAID,QAAQf,OAAOsB,QAAQ;QAC3BC,QAAQP;QACRD,SAASf;IACX;IACA,MAAMwB,OAAOpB,KAAKqB,MAAM,CAACF,KAAKG,MAAM;IACpC,IAAIF,QAAQ,CAAC,KAAKG,IAAI,CAACH,OAAO;QAC5B,MAAMI,YAAYL,KAAKM,MAAM,CAAC;QAC9B,IAAID,YAAY,GAAGL,OAAOA,KAAKhC,KAAK,CAAC,GAAGqC;IAC1C;IACA,OAAO,GAAGL,KAAKO,OAAO,CAAC,kBAAkB,MAAMV,sBAAsB;AACvE;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASW,gCACdC,mBAA2B,EAC3BC,eAA0C;IAE1C,IAAId,4BAA4Ba,sBAAsB;QACpD,OAAOA;IACT;IACA,IACE,OAAOC,oBAAoB,YAC3BA,gBAAgB5B,IAAI,GAAGqB,MAAM,GAAG,KAChCP,4BAA4Bc,kBAC5B;QACA,OAAOA;IACT;IACA,OAAOZ,6BAA6BW;AACtC;AAEA;;;;CAIC,GACD,OAAO,SAASE,kBAAkBC,OAAe,EAAEC,SAAiB;IAClE,IAAIC,OAAO;IACX,MAAMC,OAAO,GAAGH,QAAQ,CAAC,EAAEC,WAAW;IACtC,IAAK,IAAI/C,QAAQ,GAAGA,QAAQiD,KAAKZ,MAAM,EAAErC,SAAS,EAAG;QACnDgD,QAAQC,KAAKC,UAAU,CAAClD;QACxBgD,OAAO5D,KAAK+D,IAAI,CAACH,MAAM;IACzB;IACA,OAAO,AAACA,CAAAA,SAAS,CAAA,IAAK;AACxB;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASI,gBACdN,OAAuB,EACvBpE,KAAuB,EACvBqE,SAAoC,EACpC;;;;GAIC,GACDhD,IAAqB;IAErB,IAAI,CAACsD,uBAAuB3E,OAAOqB,OAAO,OAAO;IACjD,IAAIrB,MAAMuC,OAAO,EAAE,OAAO;IAC1B,MAAMhC,UAAUD,aAAaN,MAAM4C,cAAc;IACjD,IAAIrC,WAAW,KAAK,CAAC8D,WAAW,OAAO;IACvC,IAAI9D,WAAW,KAAK,OAAO;IAC3B,OAAO4D,kBAAkBC,SAASC,aAAa9D;AACjD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,SAASoE,uBACd3E,KAAuB,EACvBqB,IAAgC;IAEhC,MAAMsB,QAAQ3C,MAAM2C,KAAK;IACzB,4EAA4E;IAC5E,4EAA4E;IAC5E,+CAA+C;IAC/C,IAAIA,SAAS,QAAQA,MAAMgB,MAAM,KAAK,GAAG,OAAO;IAChD,IAAI,CAACtC,MAAM,OAAO;IAClB,OAAOsB,MAAM1C,QAAQ,CAACoB;AACxB;AAqBA;;;;;;;;;CASC,GACD,OAAO,SAASuD,6BACdlD,GAAY;IAEZ,IAAI,CAACA,OAAO,OAAOA,QAAQ,YAAYC,MAAMC,OAAO,CAACF,MAAM,OAAO,CAAC;IACnE,MAAMmD,YAAqC,CAAC;IAC5C,KAAK,MAAM,CAACtF,KAAKS,MAAM,IAAIgB,OAAO8D,OAAO,CAACpD,KAAiC;QACzE,IAAI,OAAO1B,UAAU,aAAaD,iBAAiBR,MAAM;YACvDsF,SAAS,CAACtF,IAAI,GAAGS;QACnB;IACF;IACA,OAAO6E;AACT;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASE,sBACdX,OAAuB,EACvBpE,KAAuB,EACvBqE,SAAoC,EACpCQ,SAAqD,EACrD;;;;;;;;;;;;;;;;;;;GAmBC,GACDxD,IAAoB;IAEpB,MAAM2D,WAAWH,6BAAAA,SAAW,CAACT,QAAQ;IACrC,IAAI,OAAOY,aAAa,WAAW,OAAOA;IAC1C,OAAON,gBAAgBN,SAASpE,OAAOqE,WAAWhD;AACpD"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/release-flags.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Release flags (AGL-227): platform-level \"is this feature launched\"\n * gating backed by Firebase Remote Config. A separate axis from plan\n * entitlements (`plan-entitlements.ts`) — entitlements ask whether the\n * tenant's PLAN includes a feature, release flags ask whether the feature\n * is released to the public at all (or partially, via percentage rollout).\n * Staff bypass release flags everywhere but see a flagged warning.\n */\n\n/** Remote Config parameter keys for release-gated features. */\nimport type { OrgPlan } from '../foundation'\nimport { PLAN_LABELS, SELF_SERVE_PLANS } from './plan-entitlements'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\nimport {\n PLUGIN_RELEASE_FLAGS,\n type PluginReleaseFlagKey,\n} from './plugin-release-flags.generated'\n\n/**\n * Every release flag: the ones plugins declare (compiled from\n * plugins.config.json, AGL-3080) and the platform's own below.\n */\nexport type ReleaseFlagKey =\n | PluginReleaseFlagKey\n | 'release_addon_store'\n | 'release_native_checkout'\n | 'release_edit_bar'\n | 'release_assist'\n | 'release_video_uploads'\n | 'release_video_delivery'\n | 'release_ai_generative'\n | 'release_crm_assist_whole_record'\n\nexport interface ReleaseFlagDefinition {\n key: ReleaseFlagKey\n label: string\n description: string\n /**\n * Fallback verdict when Remote Config is unreachable (offline, blocked,\n * first paint before activate). MUST match the seeded value in\n * cloud/firebase-remoteconfig.template.json so environments without a\n * published template behave like the template intends.\n */\n defaultEnabled: boolean\n /** Host dashboard tab id (host-nav-tabs.ts) this flag hides, if any. */\n navTabId?: string\n}\n\n/** A release flag a plugin declares, as the generator compiles it. */\nexport type PluginReleaseFlagDefinition = ReleaseFlagDefinition & {\n key: PluginReleaseFlagKey\n}\n\n/**\n * The platform's own release flags: features no plugin owns.\n *\n * Every first-party plugin is release-flagged too (AGL-422) — the flag feeds\n * the plugin LOADER (console, published sites, API dispatch), not just nav\n * visibility, so staff can kill-switch a whole plugin platform-wide from the\n * Feature Flags page. Those flags are the plugins' own: each is defined by\n * its catalog row's `releaseFlagDefinition` in plugins.config.json and\n * compiled into `PLUGIN_RELEASE_FLAGS`, so adding a plugin edits nothing\n * here.\n */\nconst PLATFORM_RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n {\n key: 'release_addon_store',\n label: 'Add-on store',\n description:\n 'Self-serve add-on purchases on the Billing page: seats, datasets, ' +\n 'extra sites, POS registers, Event Calendar (AGL-524..531).',\n defaultEnabled: true,\n },\n // Released to every workspace 2026-10-06 (AGL-3606). Storefront-only since\n // the console's plan checkout stopped rendering Stripe Checkout at all. Still\n // the surface's kill switch, globally or per org: off, both storefront\n // checkout routes return the hosted redirect again.\n {\n key: 'release_native_checkout',\n label: 'In-page checkout',\n description:\n 'Storefront shoppers pay on the merchant\\u2019s own site: the Payment ' +\n 'Element, email, shipping address and method, and a live total with ' +\n 'tax open in place under the Buy or Checkout button, styled with the ' +\n 'site theme, instead of a redirect to checkout.stripe.com (AGL-1944). ' +\n 'Released to every workspace 2026-10-06 (AGL-3606). The webhook is ' +\n 'still the only thing that fulfils. Also gated on ' +\n 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, so a deployment without one keeps ' +\n 'the redirect; turning this off returns every storefront to the redirect.',\n defaultEnabled: true,\n },\n // Released to every site 2026-09-16 (AGL-3041). Still the whole surface's\n // kill switch, globally or for one org by override: off, the tenant slot\n // renders nothing, both token mints refuse, and `/api/edit-context` turns\n // away every token already issued.\n {\n key: 'release_edit_bar',\n label: 'Site admin bar',\n description:\n 'Admin bar on published sites: a signed-in editor jumps from a live ' +\n 'page straight into the Besigner to edit it. Released ' +\n '2026-09-16 (AGL-3041) for every site; turning it off hides the bar ' +\n 'and revokes every outstanding edit token at the verify site.',\n defaultEnabled: true,\n },\n {\n key: 'release_assist',\n label: `${PLATFORM_BRAND_NAME} Assist`,\n description:\n 'The in-console AI chat helper on every page (AGL-1860): docs-' +\n 'grounded answers with deep links, page-context guidance on Pro+. ' +\n 'ON in production, and gated on TWO published legal artifacts ' +\n '(AGL-1909), neither of which is a repo file — both are live besigner ' +\n 'pages, so publication is what satisfies them: (1) the privacy-policy ' +\n 'disclosure for stored Q&A, because the data loop records every ' +\n 'exchange org-scoped under orgs/{orgId}/assistExchanges; and (2) the ' +\n 'Anthropic row on /legal/subprocessors, which was deliberately ' +\n 'REMOVED on 2026-08-13 (subprocessorsV2a-20260813) on the premise ' +\n 'that no production key existed — so the page is not merely ' +\n 'incomplete without it, it is affirmatively wrong. NOTE the real ' +\n 'trigger is ANTHROPIC_API_KEY, not this flag: /api/ai/assist (the ' +\n 'besigner copy assistant, AGL-89/130/169) carries no release flag at ' +\n 'all and sends customer site content to Anthropic on the key plus a ' +\n 'Pro entitlement alone. Setting the key in production therefore makes ' +\n 'Anthropic a subprocessor whether or not this flag is ever flipped.',\n defaultEnabled: true,\n },\n // Ingress only, and with no staff preview on the server: a video a staff\n // session uploads into a customer's library serves on that customer's pages\n // like any other, so a grant goes to the org through its override.\n // `apps/console/utils/server/video-uploads.ts` holds the gate.\n {\n key: 'release_video_uploads',\n label: 'Video uploads',\n description:\n 'New video in the media library, on every upload path (AGL-2830). ' +\n 'Off: refused with 403 video_uploads_paused, as DAM video delivery is ' +\n 'not yet metered or bounded per org (AGL-2810, AGL-2812). Images, ' +\n 'documents and stored videos are unaffected.',\n defaultEnabled: false,\n },\n // Delivery, not ingress: this flag decides whether a video already in the\n // library is COPIED to the configured delivery provider and served from it\n // through a short-lived signed redirect (AGL-2824). Per org, with no staff\n // preview on the server — a copy is customer data at the provider, so a\n // grant goes to the org through its override. Off, or with no provider\n // configured, every video serves from this platform exactly as before.\n // `media-delivery.ts` in `tenant-data-admin` holds the gate.\n {\n key: 'release_video_delivery',\n label: 'Video delivery',\n description:\n 'Serves library video from a configured delivery provider through ' +\n 'short-lived signed redirects, and copies each video to it (AGL-2824). ' +\n 'OFF by default: video serves from this platform as before. Also ' +\n \"needs the provider's settings.\",\n defaultEnabled: false,\n },\n // The generative doors share one runtime with Assist (AGL-2903) but not\n // its flag: `release_assist` answers questions, this builds things, and\n // the two are sold, metered and disclosed separately. The server-side\n // gate lives in `aiGateLadder` (404 when off, staff preview).\n // Labeled for what it switches — the generative doors — and not with the\n // add-on's own name, which is sold and live whether or not the doors are\n // released: the docs name the add-on, and a flag wearing the same words\n // reads as documented when it is not.\n {\n key: 'release_ai_generative',\n label: `${PLATFORM_BRAND_NAME} AI generation`,\n description:\n 'Generative building and automation behind the AI add-on ' +\n '(AGL-2903): sections, pages and workflows written by a model from ' +\n 'a brief, on the shared Anthropic runtime. ON for every workspace ' +\n 'since 2026-09-29; every generative route answers 404 while it is ' +\n 'off. On, it sends customer briefs and site content to Anthropic ' +\n 'on the same ANTHROPIC_API_KEY and under the same subprocessor ' +\n 'disclosure as Assist (AGL-1909).',\n defaultEnabled: true,\n },\n // What CRM assistance hands the model provider, not whether it runs:\n // `release_ai_generative` opens the doors, this widens what a record sends\n // through them. Per org, with no staff preview — the data is the customer's,\n // and a staff session reading a customer's record must send what the\n // published pages promise. The CRM's record-facts readers hold the gate.\n {\n key: 'release_crm_assist_whole_record',\n label: 'CRM assistance: whole record',\n description:\n 'What AI assistance in the CRM (a record summary, next step or ' +\n 'email draft) sends the model provider (AGL-3520). OFF: the ' +\n 'disclosed fields, with addresses and numbers in typed text ' +\n 'replaced. ON: the whole record, contact details and custom fields ' +\n 'included. ON since 2026-10-05, after Privacy Policy section 2 and ' +\n 'the Subprocessors row were republished; see ' +\n 'docs/drafts/agl-3520-crm-assistance-whole-record-disclosure.md.',\n defaultEnabled: true,\n },\n]\n\n/**\n * The registry: one entry per gated feature, the plugins' first. Adding a\n * platform flag = add it above, seed it in the Remote Config template, and\n * (optionally) wrap the page in `<FeatureGate>` — the staff admin editor and\n * nav filtering pick it up from this list.\n */\nexport const RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n ...PLUGIN_RELEASE_FLAGS,\n ...PLATFORM_RELEASE_FLAGS,\n]\n\nexport const RELEASE_FLAG_KEYS = RELEASE_FLAGS.map(\n (definition) => definition.key,\n) as readonly ReleaseFlagKey[]\n\nexport function isReleaseFlagKey(value: string): value is ReleaseFlagKey {\n return (RELEASE_FLAG_KEYS as readonly string[]).includes(value)\n}\n\nexport function getReleaseFlagDefinition(\n key: ReleaseFlagKey,\n): ReleaseFlagDefinition {\n const definition = RELEASE_FLAGS.find((entry) => entry.key === key)\n if (!definition) throw new Error(`Unknown release flag: ${key}`)\n return definition\n}\n\n/**\n * The JSON payload stored in each Remote Config parameter. `enabled: true`\n * turns the feature on for everyone; `enabled: false` with a positive\n * `rolloutPercent` enables it for that percentage of subjects (stable\n * per-tenant bucketing, GrowthBook-style); `plans` narrows either of those\n * to a set of tiers (AGL-2486).\n */\nexport interface ReleaseFlagValue {\n enabled: boolean\n /** 0–100; only consulted while `enabled` is false. */\n rolloutPercent?: number\n /**\n * Plan/tier targeting (AGL-2486). The tiers this flag is being rolled out\n * to, or ABSENT for \"every tier\".\n *\n * ABSENT AND EMPTY BOTH MEAN EVERY TIER, and that is load-bearing: every\n * flag stored before this field existed parses to `undefined` here, and an\n * operator who unticks every box has plainly not asked for a flag that\n * reaches nobody. Reading an empty list as \"no tiers\" would dark-launch\n * nothing while the console still said the flag was on — the inverted\n * reading of this field is the whole hazard, so it is closed in the parser\n * rather than left to each call site.\n */\n plans?: OrgPlan[]\n /** Free-form staff note (\"waiting on AGL-199\", owner, etc.). */\n note?: string\n}\n\nconst clampPercent = (value: unknown): number => {\n const percent = typeof value === 'number' && Number.isFinite(value) ? value : 0\n return Math.min(100, Math.max(0, Math.round(percent)))\n}\n\n/**\n * Every plan a flag may target, cheapest first — `SELF_SERVE_PLANS` then\n * `enterprise`, the same ladder `planGrantingFeature` walks and the same\n * order the plan grid renders. Derived, never re-typed: pricing v3 inserted\n * `scale` mid-ladder and added `agency`, and a second hand-written tier list\n * here would have missed both.\n */\nexport const RELEASE_FLAG_PLAN_LADDER: readonly OrgPlan[] = [\n ...SELF_SERVE_PLANS,\n 'enterprise' as OrgPlan,\n]\n\nconst isOrgPlan = (value: unknown): value is OrgPlan =>\n typeof value === 'string' &&\n Object.prototype.hasOwnProperty.call(PLAN_LABELS, value)\n\n/**\n * The tiers on the ladder at or above `plan` — what the console's \"Pro and\n * above\" shortcut expands to before it is STORED as an explicit list.\n *\n * Deliberately a UI convenience and not a stored \"minimum plan\" mode: a\n * stored threshold would silently re-aim every live flag the day a tier is\n * inserted into the middle of the ladder (pricing v3 did exactly that with\n * `scale`). An explicit list means the audience of a published flag only\n * ever changes because a human changed it.\n */\nexport function releaseFlagPlansAtOrAbove(plan: OrgPlan): OrgPlan[] {\n const index = RELEASE_FLAG_PLAN_LADDER.indexOf(plan)\n return index < 0 ? [] : RELEASE_FLAG_PLAN_LADDER.slice(index)\n}\n\n/**\n * Sanitises whatever is stored at `plans`, in ladder order.\n *\n * Returns `undefined` — not `[]` — for \"no targeting declared\", so the\n * absence survives a round trip through the staff editor and cannot be\n * written back as a list that means something else. Unknown tier names are\n * dropped for the same reason `parseOrgReleaseFlagOverrides` drops unknown\n * keys: a renamed or retired tier must never gate anything. A list that\n * names ONLY unknown tiers collapses to `undefined` (every tier) rather than\n * to an empty list, because a typo must inherit, never silently target.\n */\nexport function parseReleaseFlagPlans(raw: unknown): OrgPlan[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const named = new Set(raw.filter(isOrgPlan))\n if (named.size === 0) return undefined\n return RELEASE_FLAG_PLAN_LADDER.filter((plan) => named.has(plan))\n}\n\n/**\n * Parses a Remote Config parameter string into a `ReleaseFlagValue`.\n * Tolerant by design — the template is hand-editable in the Firebase\n * console, so plain \"true\"/\"false\" strings and malformed JSON must not\n * crash gating: anything unreadable falls back to the registry default.\n */\nexport function parseReleaseFlagValue(\n raw: string | null | undefined,\n fallbackEnabled: boolean,\n): ReleaseFlagValue {\n const text = raw?.trim()\n if (!text) return { enabled: fallbackEnabled }\n if (text === 'true') return { enabled: true }\n if (text === 'false') return { enabled: false }\n try {\n const parsed = JSON.parse(text)\n if (typeof parsed === 'boolean') return { enabled: parsed }\n if (parsed && typeof parsed === 'object') {\n const plans = parseReleaseFlagPlans(parsed.plans)\n return {\n enabled: parsed.enabled === true,\n rolloutPercent: clampPercent(parsed.rolloutPercent),\n // Spread, so a flag stored before AGL-2486 has NO `plans` key at all\n // rather than an explicit `undefined`. `JSON.stringify` drops both,\n // but the two are not the same to `'plans' in value`, which is what\n // the staff PUT uses to tell \"the operator cleared the targeting\"\n // apart from \"this client never sent the field\".\n ...(plans ? { plans } : {}),\n note: typeof parsed.note === 'string' ? parsed.note : undefined,\n }\n }\n } catch {\n // fall through to the registry default\n }\n return { enabled: fallbackEnabled }\n}\n\n/**\n * The longest parameter description Remote Config will publish (AGL-3048).\n *\n * One description past it refuses the WHOLE publish with\n * `DESCRIPTION_EXCEEDS_MAXIMUM_SIZE`, which the staff flags route answered as\n * a 500. Registry descriptions are written for the staff flags page, and\n * several run far past the limit, so what the page shows and what a\n * parameter carries cannot always be the same text.\n *\n * Counted in UTF-8 BYTES. Remote Config states the limit in characters\n * without saying how it counts them, and a UTF-8 byte count is never smaller\n * than the code-point or UTF-16 count, so text within 256 bytes is within the\n * limit on every reading. The cost is a few characters of headroom on text\n * with typographic punctuation.\n */\nexport const REMOTE_CONFIG_DESCRIPTION_MAX_BYTES = 256\n\nfunction utf8ByteLength(text: string): number {\n let bytes = 0\n for (const character of text) {\n const codePoint = character.codePointAt(0) ?? 0\n bytes +=\n codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4\n }\n return bytes\n}\n\n/** Whether Remote Config accepts `text` as a parameter description. */\nexport function fitsRemoteConfigDescription(text: string): boolean {\n return utf8ByteLength(text) <= REMOTE_CONFIG_DESCRIPTION_MAX_BYTES\n}\n\nconst DESCRIPTION_ELLIPSIS = '…'\n\n/**\n * `text` cut to a length Remote Config accepts, at a word boundary, with an\n * ellipsis marking the cut. Text that already fits comes back unchanged.\n *\n * The cut takes whole code points, so a multi-byte character is never split.\n * It backs up to the last space when it lands inside a word (only a single\n * word longer than the whole budget is cut hard), and punctuation left\n * dangling before the ellipsis is dropped.\n */\nexport function clampRemoteConfigDescription(text: string): string {\n if (fitsRemoteConfigDescription(text)) return text\n const budget =\n REMOTE_CONFIG_DESCRIPTION_MAX_BYTES - utf8ByteLength(DESCRIPTION_ELLIPSIS)\n let kept = ''\n let bytes = 0\n for (const character of text) {\n const size = utf8ByteLength(character)\n if (bytes + size > budget) break\n kept += character\n bytes += size\n }\n const next = text.charAt(kept.length)\n if (next && !/\\s/.test(next)) {\n const lastSpace = kept.search(/\\s\\S*$/)\n if (lastSpace > 0) kept = kept.slice(0, lastSpace)\n }\n return `${kept.replace(/[\\s.,;:(–—-]+$/, '')}${DESCRIPTION_ELLIPSIS}`\n}\n\n/**\n * The description to publish with a release flag's Remote Config parameter\n * (AGL-3048). Always one Remote Config accepts:\n *\n * 1. The registry description, when it fits, so the flags page and the\n * parameter say the same thing.\n * 2. Otherwise the LIVE parameter's description, when it has one that fits.\n * Remote Config has already accepted exactly that text, and someone chose\n * it for the Firebase console. The first 250 characters of a longer\n * paragraph would drop whatever the paragraph says last: for\n * `release_assist`, the `/legal/subprocessors` precondition that its\n * AGL-1909 suite wants in front of whoever reads the flag there. The full\n * registry text stays on the staff flags page either way.\n * 3. Otherwise, as on a first publish with nothing live to keep, the\n * registry description clamped at a word boundary.\n */\nexport function releaseFlagParameterDescription(\n registryDescription: string,\n liveDescription: string | null | undefined,\n): string {\n if (fitsRemoteConfigDescription(registryDescription)) {\n return registryDescription\n }\n if (\n typeof liveDescription === 'string' &&\n liveDescription.trim().length > 0 &&\n fitsRemoteConfigDescription(liveDescription)\n ) {\n return liveDescription\n }\n return clampRemoteConfigDescription(registryDescription)\n}\n\n/**\n * FNV-1a 32-bit hash → 0–99 bucket. Deterministic so a subject keeps the\n * same rollout verdict across sessions and surfaces, and seeded with the\n * flag key so a subject doesn't land in the same bucket for every flag.\n */\nexport function releaseFlagBucket(flagKey: string, subjectId: string): number {\n let hash = 0x811c9dc5\n const seed = `${flagKey}:${subjectId}`\n for (let index = 0; index < seed.length; index += 1) {\n hash ^= seed.charCodeAt(index)\n hash = Math.imul(hash, 0x01000193)\n }\n return (hash >>> 0) % 100\n}\n\n/**\n * The gating verdict for one subject.\n *\n * `subjectId` is the ORG ID, or nothing. A rollout is a cohort of\n * workspaces, so a whole workspace has to land on one side of the\n * percentage: every surface that asks about an org must ask with the same\n * string, or they answer differently about the same customer.\n *\n * This docstring used to sanction falling back to the uid, and the server\n * gates fell back to a hostId. Since a hostId, a uid and an orgId hash to\n * three different buckets, a mid-rollout flag could be on in the console\n * and off on the published site for one workspace — stable per subject, so\n * it never flickered, it just stayed wrong (AGL-1656).\n *\n * An empty subject only passes fully-enabled flags. That is the deliberate\n * cost: a request with no resolvable org never joins a partial rollout,\n * which is the conservative answer rather than a confidently wrong one.\n */\nexport function isReleaseFlagOn(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n /**\n * The subject org's plan, when the caller knows it (AGL-2486). Optional so\n * every pre-existing call site compiles and answers unchanged: a flag that\n * declares no tier targeting never reads this.\n */\n plan?: OrgPlan | null,\n): boolean {\n if (!releaseFlagTargetsPlan(value, plan)) return false\n if (value.enabled) return true\n const percent = clampPercent(value.rolloutPercent)\n if (percent <= 0 || !subjectId) return false\n if (percent >= 100) return true\n return releaseFlagBucket(flagKey, subjectId) < percent\n}\n\n/**\n * Does this flag's tier targeting admit `plan`? (AGL-2486)\n *\n * THE SEMANTICS, because an operator who cannot predict the audience will\n * not stage a rollout at all:\n *\n * - The tier list is a FILTER, applied before and independently of the\n * percentage. The percentage then picks a cohort WITHIN the admitted\n * tiers. \"Pro and above at 50%\" is therefore both \"half of the Pro+\n * workspaces\" and \"the global 50% cohort, restricted to Pro+\" — the two\n * readings name the same set, and they do so because\n * {@link releaseFlagBucket} hashes `flagKey:orgId` and NOTHING ELSE.\n * - Which is also why the bucket is stable. Adding, removing or reordering\n * tiers cannot reshuffle the bucket, so an org already inside a 50%\n * rollout keeps the feature when an unrelated tier joins the list. Had\n * the plan been mixed into the hash — the obvious way to write this — a\n * tier edit would have re-drawn the cohort under every customer already\n * in it, and a plan change would have re-rolled the dice for one.\n * - The filter binds the FULLY-ENABLED path too, not just the rollout.\n * \"On, Enterprise + Agency\" is how a launch to the top of the ladder is\n * expressed, and it is what was actually asked for. Untargeted flags are\n * unaffected: no list means every tier, so `enabled` still means everyone.\n * - An UNKNOWN plan fails a declared list. Same conservatism as a missing\n * subject on a percentage rollout: a caller that cannot say which\n * workspace it is asking about gets the safe answer rather than a\n * confidently wrong one. A per-org staff override still wins over all of\n * it — see {@link isReleaseFlagOnForOrg} — so a targeted flag can still be\n * handed to one org off-ladder.\n */\nexport function releaseFlagTargetsPlan(\n value: ReleaseFlagValue,\n plan: OrgPlan | null | undefined,\n): boolean {\n const plans = value.plans\n // `!plans` would be the idiom here, but `strictNullChecks` is off repo-wide\n // and an empty array is truthy — both \"absent\" and \"empty\" have to be named\n // for the every-tier reading to actually hold.\n if (plans == null || plans.length === 0) return true\n if (!plan) return false\n return plans.includes(plan)\n}\n\n/**\n * Per-org release-flag overrides (AGL-1635), stored on the org doc at\n * `releaseFlags`. A present key is a staff DECISION about one organization\n * and wins over both the Remote Config value and the rollout bucket; an\n * absent key inherits.\n *\n * A separate field from `entitlements.features` on purpose: those ask\n * whether the org's PLAN includes a feature, these ask whether a\n * not-yet-released feature is switched on for this one customer. Folding\n * them together would make \"granted by the deal\" and \"previewing an\n * unreleased build\" indistinguishable at the point a support question is\n * asked.\n *\n * Why the org doc and not a Remote Config condition: RC conditions are\n * template-global and would need one published condition per org, with a\n * template publish (a manual, separate deploy) for every grant.\n */\nexport type OrgReleaseFlagOverrides = Partial<Record<ReleaseFlagKey, boolean>>\n\n/**\n * Sanitises whatever is actually stored at `org.releaseFlags`.\n *\n * Tolerant for the same reason `parseReleaseFlagValue` is: this map is\n * hand-editable in the Firebase console and survives registry renames, so a\n * retired flag key or a non-boolean must be dropped rather than allowed to\n * gate anything. Unknown keys are discarded — a stale key can never grant a\n * flag that no longer exists, and a typo silently inherits instead of\n * silently forcing.\n */\nexport function parseOrgReleaseFlagOverrides(\n raw: unknown,\n): OrgReleaseFlagOverrides {\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}\n const overrides: OrgReleaseFlagOverrides = {}\n for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {\n if (typeof value === 'boolean' && isReleaseFlagKey(key)) {\n overrides[key] = value\n }\n }\n return overrides\n}\n\n/**\n * The gating verdict with a per-org override applied on top of\n * `isReleaseFlagOn`.\n *\n * The override is checked FIRST and short-circuits, so a forced-off flag\n * stays off for an org even while the flag is globally enabled — the\n * per-org kill switch is half the point, not just the per-org grant. Every\n * release-flag gate resolves through here so the console, the tenant\n * runtime and the API dispatchers cannot disagree about one org.\n */\nexport function isReleaseFlagOnForOrg(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n overrides: OrgReleaseFlagOverrides | null | undefined,\n /**\n * The org's plan, for tier targeting (AGL-2486). Checked only AFTER the\n * override: a staff grant is a decision about one named customer and\n * outranks the tier filter exactly as it already outranks the rollout\n * bucket. That ordering is what lets a Free org preview a\n * Business-targeted flag without widening the flag for every Free org.\n *\n * REQUIRED, and `null` has to be typed out. It was optional for one\n * release and that cost a silent revenue bug: `report-usage` held the org\n * document, dropped the argument, and every `plans`-declaring flag read\n * OFF — so the contacts overage went uninvoiced for exactly the orgs\n * entitled to reach the feature, with nothing thrown and nothing logged.\n * An unknown tier still refuses, which is the correct conservatism; what\n * cannot be allowed is a caller MANUFACTURING an unknown out of a tier it\n * already has in hand. Spelling `null` makes that a statement rather than\n * an omission, and makes the next call site that forgets fail to compile\n * instead of failing to bill. `getOrgReleaseFlagTargeting` returns the\n * plan beside the overrides from ONE document read precisely so that\n * passing it costs nothing.\n */\n plan: OrgPlan | null,\n): boolean {\n const override = overrides?.[flagKey]\n if (typeof override === 'boolean') return override\n return isReleaseFlagOn(flagKey, value, subjectId, plan)\n}\n"],"names":["PLAN_LABELS","SELF_SERVE_PLANS","PLATFORM_BRAND_NAME","PLUGIN_RELEASE_FLAGS","PLATFORM_RELEASE_FLAGS","key","label","description","defaultEnabled","RELEASE_FLAGS","RELEASE_FLAG_KEYS","map","definition","isReleaseFlagKey","value","includes","getReleaseFlagDefinition","find","entry","Error","clampPercent","percent","Number","isFinite","Math","min","max","round","RELEASE_FLAG_PLAN_LADDER","isOrgPlan","Object","prototype","hasOwnProperty","call","releaseFlagPlansAtOrAbove","plan","index","indexOf","slice","parseReleaseFlagPlans","raw","Array","isArray","undefined","named","Set","filter","size","has","parseReleaseFlagValue","fallbackEnabled","text","trim","enabled","parsed","JSON","parse","plans","rolloutPercent","note","REMOTE_CONFIG_DESCRIPTION_MAX_BYTES","utf8ByteLength","bytes","character","codePoint","codePointAt","fitsRemoteConfigDescription","DESCRIPTION_ELLIPSIS","clampRemoteConfigDescription","budget","kept","next","charAt","length","test","lastSpace","search","replace","releaseFlagParameterDescription","registryDescription","liveDescription","releaseFlagBucket","flagKey","subjectId","hash","seed","charCodeAt","imul","isReleaseFlagOn","releaseFlagTargetsPlan","parseOrgReleaseFlagOverrides","overrides","entries","isReleaseFlagOnForOrg","override"],"mappings":"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"}