@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.223
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/ga4-measurement-protocol.js +41 -28
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +25 -4
- package/src/lib/server/host-visitor-records.js +39 -11
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +52 -36
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/link-reputation-review.d.ts +6 -2
- package/src/lib/server/link-reputation-review.js +61 -20
- package/src/lib/server/link-reputation-review.js.map +1 -1
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- package/src/lib/server/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +11 -7
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +49 -9
- package/src/lib/server/payment-fraud-signal.js +192 -48
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/render-monitor.d.ts +166 -0
- package/src/lib/server/render-monitor.js +412 -0
- package/src/lib/server/render-monitor.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +7 -0
- package/src/lib/server/risk-notice.js +6 -0
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +50 -9
- package/src/lib/server/web-risk.js +290 -155
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +3 -3
- package/src/tenancy.js +5 -3
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/crm-email-activity.d.ts +0 -118
- package/src/lib/server/crm-email-activity.js +0 -148
- package/src/lib/server/crm-email-activity.js.map +0 -1
- package/src/lib/server/crm-next-activity.d.ts +0 -74
- package/src/lib/server/crm-next-activity.js +0 -194
- package/src/lib/server/crm-next-activity.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- package/src/lib/server/email-revenue-attribution.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-variants.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 {\n MEDIA_POSTER_OBJECT_SUFFIX,\n mediaPosterObjectPath,\n readImageDimensions,\n} from '@aglyn/aglyn/server'\n\nimport { MEDIA_CDN_VARIANT_WIDTHS } from './serve-media-cdn'\n\n/**\n * WebP variant generation for a media asset (AGL-175), and the record of\n * whether it worked (AGL-1468).\n *\n * ## Why this is a library function and not two copies of a `try`\n *\n * `/api/media/upload` and `/api/media/replace` each carried the same eleven\n * lines, wrapped in a `catch` whose comment read *\"Variants are an\n * optimization — never fail the upload for them.\"* That judgement is right and\n * it survives here. What did not survive is its consequence: the catch wrote\n * `console.error` and nothing else, so a **total** failure and a **healthy**\n * asset produced byte-identical documents — `variants: []` either way.\n *\n * Measured 2026-08-13 on production: **1 of 180 media documents has a\n * non-empty `variants` array.** The last successful generation was\n * 2026-07-19; every image uploaded since has an empty one. Nothing surfaced\n * that for three weeks, because the only trace was a serverless log line\n * whose retention is about an hour.\n *\n * So the contract of this function is not \"generate variants\". It is\n * **generate variants and say what happened**, and the two failure classes\n * are kept apart on purpose:\n *\n * - `variants: []` with **no** `error` — nothing was ELIGIBLE. A type in\n * {@link MEDIA_TYPES_WITHOUT_VARIANTS}, a non-image, or a source already\n * narrower than every target width. This is the correct, common,\n * uninteresting outcome and must never look like a fault or the fault signal\n * is worthless.\n * - `variants: []` with an `error` — generation was attempted and did not\n * complete. That string goes onto the media document and bumps a counter,\n * which is what makes \"how many assets failed?\" a query instead of an\n * archaeology project.\n *\n * A partial run reports both: the widths that landed AND the error, because\n * the widths that landed are real files that the CDN route can serve.\n */\n\n/** The shape of `sharp`'s default export, narrowed to what is used here. */\ntype SharpFactory = (input: Buffer) => {\n resize(options: { width: number; withoutEnlargement: boolean }): {\n webp(options: { quality: number }): { toBuffer(): Promise<Buffer> }\n }\n}\n\nexport interface MediaVariantOutcome {\n /** Widths actually written. Safe to store — every entry is a real object. */\n variants: number[]\n /**\n * Present ONLY when generation was attempted and did not complete.\n * `undefined` means nothing went wrong, which includes \"nothing to do\".\n */\n error?: string\n}\n\n/**\n * Formats a WebP copy at the SOURCE'S OWN width beats.\n *\n * A renderer's srcSet offers every width in `MEDIA_CDN_VARIANT_WIDTHS`, and\n * `serveMediaCdn` answers a width an asset has no variant for with the\n * original. For a JPEG or PNG no wider than a candidate, that original is what\n * every request at or above its width downloads — the `1280w` a phone asks\n * for, the `1920w` a desktop asks for. Measured on aglyn.com's hero poster, a\n * 1920x1080 JPEG: 166,756 B served for `?w=1920`, where a WebP of the same\n * 1920 pixels at the generator's quality is 40,598 B.\n *\n * So these formats also get the widths AT and ABOVE their own, each encoded at\n * the source width (`withoutEnlargement`): a re-encode, never an upscale, and\n * the same pixels the original answer served. Not GIF, whose animation a WebP\n * frame would drop; not WebP or AVIF, which a re-encode could make larger.\n */\nconst SOURCE_WIDTH_WEBP_TYPES: ReadonlySet<string> = new Set([\n 'image/jpeg',\n 'image/jpg',\n 'image/png',\n])\n\n/**\n * `image/*` types that produce no variants, for two different reasons.\n *\n * SVG is a vector: there is nothing to downscale, and rasterizing it would be\n * a different asset rather than a variant of this one.\n *\n * ICO carries no `sharp` decoder at all, so attempting it raises an\n * unsupported-format error from `toBuffer` — an ERROR on the media document,\n * which is the signal reserved for generation that was attempted and broke. An\n * icon having no variants is neither attempted nor broken; it is the ordinary\n * outcome, and it has to read as one or the fault counter measures the favicon\n * instead of a fault (AGL-3121).\n *\n * Exported because the backfill script has to skip exactly this set. It kept a\n * second copy of the SVG half, which is how ICO came to be missing from one\n * and not the other.\n */\nexport const MEDIA_TYPES_WITHOUT_VARIANTS: ReadonlySet<string> = new Set([\n 'image/svg+xml',\n 'image/x-icon',\n 'image/vnd.microsoft.icon',\n])\n\n/**\n * Which widths this source should produce.\n *\n * Split out and exported because it is the predicate that decides whether an\n * empty result is NORMAL, and both the routes and the health probe have to\n * agree on it. Inlined, the skip rule was a `continue` buried in the loop that\n * a reader had to reconstruct in order to know whether `[]` was a bug.\n *\n * Widths narrower than the source always; for {@link SOURCE_WIDTH_WEBP_TYPES}\n * the rest as well, provided the source is wider than the narrowest width — a\n * source smaller than every candidate is an icon, and it is served as it is.\n */\nexport function mediaVariantWidthsFor(options: {\n contentType: string\n /** Source pixel width, when it could be read from the header. */\n sourceWidth?: number | null\n}): number[] {\n if (!options.contentType.startsWith('image/')) return []\n if (MEDIA_TYPES_WITHOUT_VARIANTS.has(options.contentType)) return []\n const sourceWidth = options.sourceWidth ?? 0\n // No known width means generate and let `withoutEnlargement` decide, which\n // is the same call the loop made before: an unreadable header must not\n // silently opt an asset out of the CDN.\n if (!sourceWidth) return [...MEDIA_CDN_VARIANT_WIDTHS]\n const narrower = MEDIA_CDN_VARIANT_WIDTHS.filter((width) => width < sourceWidth)\n if (!narrower.length || !SOURCE_WIDTH_WEBP_TYPES.has(options.contentType)) {\n return narrower\n }\n return [...MEDIA_CDN_VARIANT_WIDTHS]\n}\n\n/**\n * Resolve `sharp`'s callable through either module shape.\n *\n * `(await import('sharp')).default` is correct under Node ESM and under a\n * bundler that synthesises the CJS namespace. It is `undefined` under one that\n * hands back `module.exports` directly — and `undefined(buffer)` is a\n * `TypeError` raised INSIDE the same `try` that catches a genuine load\n * failure, so from outside the two are the same event: no variants, no\n * message, a 200. This normalises the shape and, when neither shape yields a\n * function, throws something that NAMES the problem instead of a bare\n * `is not a function` from three frames away.\n */\nexport async function loadSharp(): Promise<SharpFactory> {\n const imported: unknown = await import('sharp')\n const candidate =\n typeof imported === 'function'\n ? imported\n : (imported as { default?: unknown } | null)?.default\n if (typeof candidate !== 'function') {\n throw new Error(\n `sharp did not resolve to a function (module was ${typeof imported}, ` +\n `default was ${typeof candidate})`,\n )\n }\n return candidate as SharpFactory\n}\n\n/**\n * A short, PUBLISHABLE name for why the encoder is unavailable (AGL-1471).\n *\n * The first thing the probe said on production was `sharp-unavailable`, and\n * that answered less than it looked like it did: it is the fallback for \"the\n * error carried no `code`\", and BOTH interesting failures land there. `sharp`\n * catches every `require` of its prebuilt binaries and rethrows one composed\n * `Error` with no `code` at all; `loadSharp` throws a plain `Error` too when\n * the module resolves to something that is not callable. One means the native\n * library did not load, the other means the bundler handed back the wrong\n * shape, and they have nothing in common except the remedy being different.\n *\n * Matching on `sharp`'s own help text is the only signal available — the\n * distinction is not in a field, only in the prose. It is matched loosely and\n * it degrades to the old fallback, so a reworded upstream message costs a\n * detail rather than the answer.\n *\n * The MESSAGE still never leaves the process. A native loader failure names\n * every path it tried, the health endpoint is public, and the point of a code\n * is that it is a fixed vocabulary the caller can branch on.\n */\nexport function classifyLoadFailure(error: unknown): string {\n const code = (error as { code?: string })?.code\n if (code) return String(code)\n const message = error instanceof Error ? error.message : ''\n if (/Could not load the \"?sharp\"? module/i.test(message)) {\n return 'sharp-native-missing'\n }\n if (/did not resolve to a function/i.test(message)) {\n return 'sharp-not-a-function'\n }\n return 'sharp-unavailable'\n}\n\n/** Error text kept short — it is stored on a document, not in a log. */\nfunction describe(error: unknown): string {\n const text =\n error instanceof Error\n ? `${error.name}: ${error.message}`\n : String(error ?? 'unknown')\n return text.slice(0, 300)\n}\n\n/**\n * Generate the WebP variants for one asset and report the outcome.\n *\n * `saveVariant` is a callback rather than a `Bucket` so this stays free of a\n * storage dependency and so a spec can assert on the BYTES it produces —\n * which is the only assertion that distinguishes a working variant from the\n * original served back under a `?w=` that selects nothing.\n */\nexport async function generateMediaVariants(options: {\n buffer: Buffer\n contentType: string\n sourceWidth?: number | null\n /** Object path of the ORIGINAL; variants are `${objectPath}__w{n}.webp`. */\n objectPath: string\n saveVariant: (path: string, webp: Buffer) => Promise<void>\n}): Promise<MediaVariantOutcome> {\n const widths = mediaVariantWidthsFor(options)\n if (!widths.length) return { variants: [] }\n\n const variants: number[] = []\n try {\n const sharp = await loadSharp()\n for (const width of widths) {\n const webp = await sharp(options.buffer)\n .resize({ width, withoutEnlargement: true })\n .webp({ quality: 80 })\n .toBuffer()\n await options.saveVariant(`${options.objectPath}__w${width}.webp`, webp)\n variants.push(width)\n }\n } catch (error) {\n // Still never fails the upload — but no longer only whispers.\n console.error('media variant generation failed', options.objectPath, error)\n return { variants, error: describe(error) }\n }\n return { variants }\n}\n\n/**\n * The largest object this will pull back OUT of storage in order to generate.\n *\n * Deliberately equal to `IMAGE_MAX_BYTES`, the console's per-type ceiling for\n * an image (`apps/console/utils/media-upload-limits.ts`), rather than derived\n * from it: this library cannot import that file, and the two numbers answer\n * different questions. That one asks \"may this be uploaded at all\"; this one\n * asks \"may a serverless function afford to fetch it back and decode it\". They\n * agree today, which is the point — every image the DAM accepts gets variants.\n * If the upload ceiling is ever raised for a format where 15 MB is small (a\n * camera RAW, a TIFF), the download stops there instead of silently following\n * it into a timeout, and says so.\n */\nexport const MEDIA_VARIANT_SOURCE_MAX_BYTES = 15 * 1024 * 1024\n\n/**\n * Generate variants for an object whose bytes are in STORAGE, not in hand\n * (AGL-1476).\n *\n * `/api/media/upload` has the buffer — the client base64'd it into the request\n * body. `/api/media/upload-url` never sees it: the browser PUTs straight to GCS\n * with a signed URL, and finalize is handed a path. Since AGL-1465 gave images\n * a signed-path ceiling, every image between 3 MB and 15 MB takes that route,\n * so the assets that most need a 320px WebP became precisely the ones that got\n * none. Measured on production 2026-08-13: a 6,606,921 B PNG landed with\n * `variants` **absent**, and `?w=320` served back all 6,606,921 B of\n * `image/png`; a 2,168,376 B PNG on the base64 route landed with\n * `variants: [320, 640]` and answered `?w=320` in 31,566 B of `image/webp`.\n *\n * So the bytes have to come back. `readSource` is a callback, for the same\n * reason `saveVariant` is one: this stays free of a storage dependency, and a\n * spec can hand it a real PNG and count what comes out.\n *\n * **The read is the last thing that happens, not the first.** Widths are\n * decided from the header dimensions the caller already has, so a source\n * narrower than every target width — and every non-image — costs nothing at\n * all. That ordering is what keeps this from pulling a 200 MB video back out\n * of the bucket to discover it has no variants.\n *\n * The size refusal is reported as an ERROR rather than as an empty success,\n * and the distinction is AGL-1468's: `variants: []` with no error means\n * nothing was ELIGIBLE, and an image the platform accepted and then declined\n * to optimize is not that. It is eligible work that did not happen, so it\n * belongs on the document and in `variantFailures` where it can be counted.\n */\nexport async function generateStoredMediaVariants(options: {\n contentType: string\n /** The object's REAL size, as storage reports it. */\n sizeBytes: number\n sourceWidth?: number | null\n objectPath: string\n /** Fetches the whole object. Only ever called when there is work to do. */\n readSource: () => Promise<Buffer>\n saveVariant: (path: string, webp: Buffer) => Promise<void>\n maxSourceBytes?: number\n}): Promise<MediaVariantOutcome> {\n const widths = mediaVariantWidthsFor(options)\n if (!widths.length) return { variants: [] }\n\n const maxSourceBytes = options.maxSourceBytes ?? MEDIA_VARIANT_SOURCE_MAX_BYTES\n if (options.sizeBytes > maxSourceBytes) {\n return {\n variants: [],\n error: `source too large to fetch for variants (${options.sizeBytes} > ${maxSourceBytes} bytes)`,\n }\n }\n\n let buffer: Buffer\n try {\n buffer = await options.readSource()\n } catch (error) {\n // A download failure is a genuine fault on an eligible asset, and it is\n // NOT the same event as `sharp` being unavailable — so it is described\n // here rather than left to be inferred from a message three frames away.\n console.error('media variant source download failed', options.objectPath, error)\n return { variants: [], error: `source download failed — ${describe(error)}` }\n }\n\n return generateMediaVariants({ ...options, buffer })\n}\n\n/**\n * Can this deployment produce a variant at all?\n *\n * Synthesises a small image in memory, downscales it, and asserts the result\n * is both a WebP and SMALLER than the source. No Storage, no Firestore, no\n * upload — so it can run from a health endpoint on a schedule instead of\n * waiting for a customer to notice their thumbnails are full-size originals.\n *\n * The size comparison is the point. A resize that silently returns its input\n * is exactly the failure this issue is about: `?w=640` answering 200 with the\n * original is indistinguishable from success unless something counts bytes.\n */\nexport async function probeMediaVariantSupport(): Promise<{\n ok: boolean\n code?: string\n}> {\n try {\n const sharp = await loadSharp()\n // A smooth gradient, not a flat fill and not noise. A flat fill\n // compresses to a handful of bytes at every width, so the size\n // comparison below would pass on a resize that did nothing; noise\n // re-expands under lossy WebP, so it would FAIL on a perfectly healthy\n // encoder. Real images sit between the two, and so does this.\n const width = 640\n const height = 128\n const pixels = Buffer.alloc(width * height * 3)\n for (let y = 0; y < height; y += 1) {\n for (let x = 0; x < width; x += 1) {\n const index = (y * width + x) * 3\n pixels[index] = Math.round((x * 255) / width)\n pixels[index + 1] = Math.round((y * 255) / height)\n pixels[index + 2] = Math.round((((x + y) % 300) * 255) / 300)\n }\n }\n const source = await (\n sharp as unknown as (\n input: Buffer,\n options: unknown,\n ) => { png(): { toBuffer(): Promise<Buffer> } }\n )(pixels, { raw: { width, height, channels: 3 } })\n .png()\n .toBuffer()\n const resized = await sharp(source)\n .resize({ width: 320, withoutEnlargement: true })\n .webp({ quality: 80 })\n .toBuffer()\n // `RIFF....WEBP` — a WebP, not the PNG handed back.\n const isWebp =\n resized.length > 12 &&\n resized.toString('ascii', 0, 4) === 'RIFF' &&\n resized.toString('ascii', 8, 12) === 'WEBP'\n if (!isWebp) return { ok: false, code: 'not-webp' }\n if (resized.length >= source.length) return { ok: false, code: 'not-smaller' }\n return { ok: true }\n } catch (error) {\n // A CODE, never the message: the health endpoint is public and an error\n // from a native module can carry filesystem paths.\n return { ok: false, code: classifyLoadFailure(error) }\n }\n}\n\n/**\n * The largest poster this will accept from a client (AGL-2742).\n *\n * The browser encodes the still it captured as WebP at quality ~0.72, and a\n * 1920-wide frame of real video lands between 40 KB and 250 KB. 4 MB is\n * therefore ~16x the worst realistic case and is not a quality knob: it is\n * the point past which the bytes stopped being a poster. It has to sit well\n * under the platform's 4.5 MB request-body wall, because the poster rides\n * base64 in the finalize call and a ceiling that admits a body the platform\n * will reject turns a refusal we can explain into a 413 we cannot.\n */\nexport const MEDIA_POSTER_MAX_BYTES = 4 * 1024 * 1024\n\n/**\n * The widest poster stored. Above this a still is being kept at a resolution\n * no `<video poster>` paints — the element scales it to the player box, and\n * the largest realistic player box is the 1920 the variant widths already\n * top out at.\n */\nexport const MEDIA_POSTER_MAX_WIDTH = 1920\n\n/** WebP quality for the poster. Higher than a variant's 80 would not show. */\nconst POSTER_QUALITY = 72\n\nexport interface MediaPosterOutcome {\n /** Set only when a poster object was actually written. */\n poster?: { width: number; height: number; variants: number[] }\n /**\n * Present ONLY when a poster was offered and could not be produced —\n * the same two-failure-class contract {@link MediaVariantOutcome} keeps.\n * An upload with no poster offered returns an empty object, not an error.\n */\n error?: string\n}\n\n/**\n * Turn a client-supplied still into the asset's poster (AGL-2742).\n *\n * ## The re-encode is a security control, not a compression pass\n *\n * These bytes come from a browser, over the wire, and land in the shared\n * media bucket under an org's own prefix, where `serveMediaCdn` will stream\n * them `inline` from the console's origin and from every tenant site's. That\n * is precisely the shape of AGL-1474, where an uploaded `image/svg+xml`\n * turned out to be a scripted document served from `app.aglyn.com`.\n *\n * Nothing here trusts the declared type, the magic bytes, or the extension.\n * The buffer is decoded by `sharp` and **re-encoded** to WebP, so what gets\n * stored is bytes this process produced. A polyglot, an SVG, an HTML file\n * with a PNG header — each either fails to decode (reported, no object\n * written) or becomes a real raster WebP with whatever it was smuggling\n * discarded. That is a stronger guarantee than any sniffing gate, and it is\n * why the poster is allowed to be client-generated at all.\n *\n * ## Why it reuses `generateMediaVariants` rather than looping itself\n *\n * A poster IS an image, and the width set a poster should offer is the width\n * set every other image offers — the srcSet the renderer already knows how\n * to write. Handing the generator `{objectPath}__poster` as its object path\n * makes it emit `{objectPath}__poster__w{n}.webp` with no knowledge that\n * video exists, and inherits its skip rule, its partial-run reporting and\n * its refusal to fail an upload. A private loop here would be a second set\n * of widths to keep in step, which is the defect AGL-175's widths list was\n * moved into `@aglyn/aglyn` to prevent.\n *\n * The dimensions returned are read back from the ENCODED bytes rather than\n * taken from the caller. The browser's report is what sized the capture, and\n * the resize below may have changed it; a document that records what was\n * asked for instead of what was written is how an `aspect-ratio` drifts off\n * by the cap.\n */\nexport async function generateMediaPoster(options: {\n /** Raw bytes as received. Never assumed to be an image of any kind. */\n buffer: Buffer\n /** Object path of the ORIGINAL video, without the poster suffix. */\n objectPath: string\n saveVariant: (path: string, bytes: Buffer) => Promise<void>\n maxWidth?: number\n}): Promise<MediaPosterOutcome> {\n const posterPath = mediaPosterObjectPath(options.objectPath)\n let webp: Buffer\n try {\n const sharp = await loadSharp()\n webp = await sharp(options.buffer)\n .resize({\n width: options.maxWidth ?? MEDIA_POSTER_MAX_WIDTH,\n withoutEnlargement: true,\n })\n .webp({ quality: POSTER_QUALITY })\n .toBuffer()\n } catch (error) {\n console.error('media poster encode failed', options.objectPath, error)\n return { error: `poster encode failed — ${describe(error)}` }\n }\n\n // Read the WRITTEN bytes. `readImageDimensions` parses the WebP chunk\n // headers this encoder just emitted, so this is a measurement of the\n // stored object and not a restatement of the request.\n const dimensions = readImageDimensions(new Uint8Array(webp))\n if (!dimensions) {\n return { error: 'poster encoded but its dimensions were unreadable' }\n }\n\n try {\n await options.saveVariant(posterPath, webp)\n } catch (error) {\n console.error('media poster save failed', posterPath, error)\n return { error: `poster save failed — ${describe(error)}` }\n }\n\n // Widths for the poster, generated from the poster's own bytes. A failure\n // past this point still leaves a usable full-size poster on the document,\n // which is the whole reason the object above is written first.\n const outcome = await generateMediaVariants({\n buffer: webp,\n contentType: 'image/webp',\n sourceWidth: dimensions.width,\n objectPath: `${options.objectPath}${MEDIA_POSTER_OBJECT_SUFFIX}`,\n saveVariant: options.saveVariant,\n })\n\n return {\n poster: {\n width: dimensions.width,\n height: dimensions.height,\n variants: outcome.variants,\n },\n ...(outcome.error ? { error: outcome.error } : {}),\n }\n}\n"],"names":["MEDIA_POSTER_OBJECT_SUFFIX","mediaPosterObjectPath","readImageDimensions","MEDIA_CDN_VARIANT_WIDTHS","SOURCE_WIDTH_WEBP_TYPES","Set","MEDIA_TYPES_WITHOUT_VARIANTS","mediaVariantWidthsFor","options","contentType","startsWith","has","sourceWidth","narrower","filter","width","length","loadSharp","imported","candidate","default","Error","classifyLoadFailure","error","code","String","message","test","describe","text","name","slice","generateMediaVariants","widths","variants","sharp","webp","buffer","resize","withoutEnlargement","quality","toBuffer","saveVariant","objectPath","push","console","MEDIA_VARIANT_SOURCE_MAX_BYTES","generateStoredMediaVariants","maxSourceBytes","sizeBytes","readSource","probeMediaVariantSupport","height","pixels","Buffer","alloc","y","x","index","Math","round","source","raw","channels","png","resized","isWebp","toString","ok","MEDIA_POSTER_MAX_BYTES","MEDIA_POSTER_MAX_WIDTH","POSTER_QUALITY","generateMediaPoster","posterPath","maxWidth","dimensions","Uint8Array","outcome","poster"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-variants.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 { readImageDimensions } from '@aglyn/aglyn/server'\n// By path: the media-ref grammar is a leaf, and the routes that spread this\n// module's helpers into a document reach it the same way.\nimport {\n MEDIA_CDN_VARIANT_WIDTHS,\n MEDIA_DELIVERY_MAX_EDGE,\n MEDIA_POSTER_OBJECT_SUFFIX,\n MEDIA_VARIANT_ENCODER_VERSION,\n mediaDisplayObjectPath,\n mediaPosterObjectPath,\n mediaRenditionObjectPath,\n parseMediaRenditions,\n} from '@aglyn/aglyn/app-utils/media-ref'\nimport type { Sharp, SharpOptions } from 'sharp'\n\n/**\n * WebP variant generation for a media asset (AGL-175), and the record of\n * whether it worked (AGL-1468).\n *\n * ## Why this is a library function and not two copies of a `try`\n *\n * `/api/media/upload` and `/api/media/replace` each carried the same eleven\n * lines, wrapped in a `catch` whose comment read *\"Variants are an\n * optimization — never fail the upload for them.\"* That judgement is right and\n * it survives here. What did not survive is its consequence: the catch wrote\n * `console.error` and nothing else, so a **total** failure and a **healthy**\n * asset produced byte-identical documents — `variants: []` either way.\n *\n * Measured 2026-08-13 on production: **1 of 180 media documents has a\n * non-empty `variants` array.** The last successful generation was\n * 2026-07-19; every image uploaded since has an empty one. Nothing surfaced\n * that for three weeks, because the only trace was a serverless log line\n * whose retention is about an hour.\n *\n * So the contract of this function is not \"generate variants\". It is\n * **generate variants and say what happened**, and the two failure classes\n * are kept apart on purpose:\n *\n * - `variants: []` with **no** `error` — nothing was ELIGIBLE. A type in\n * {@link MEDIA_TYPES_WITHOUT_VARIANTS}, a non-image, or a source already\n * narrower than every target width. This is the correct, common,\n * uninteresting outcome and must never look like a fault or the fault signal\n * is worthless.\n * - `variants: []` with an `error` — generation was attempted and did not\n * complete. That string goes onto the media document and bumps a counter,\n * which is what makes \"how many assets failed?\" a query instead of an\n * archaeology project.\n *\n * A partial run reports both: the widths that landed AND the error, because\n * the widths that landed are real files that the CDN route can serve.\n */\n\n/** The shape of `sharp`'s default export. */\ntype SharpFactory = (input?: Buffer, options?: SharpOptions) => Sharp\n\n/**\n * What a media document records about its delivery copies' display half\n * (AGL-3486). Present only when a display object was written; its absence on\n * a document at the current encoder generation means the original is clean\n * enough to serve as it is.\n */\nexport interface MediaDisplayCopy {\n /** Always the original's own type — the display copy never changes format. */\n contentType: string\n width: number\n height: number\n sizeBytes: number\n}\n\nexport interface MediaVariantOutcome {\n /** Widths actually written. Safe to store — every entry is a real object. */\n variants: number[]\n /**\n * The display copy written for this asset, when one was needed and made.\n * Only ever produced when the caller asked for one (`display: true`).\n */\n display?: MediaDisplayCopy\n /**\n * Present ONLY when generation was attempted and did not complete.\n * `undefined` means nothing went wrong, which includes \"nothing to do\".\n */\n error?: string\n}\n\n/**\n * Writes one derived object. `contentType` is the type the bytes ARE: WebP\n * for a variant, the original's own type for a display copy.\n */\nexport type SaveMediaDerivedObject = (\n path: string,\n bytes: Buffer,\n contentType: string,\n) => Promise<void>\n\n/**\n * The WebP encoder settings for every `?w=` variant (AGL-3486).\n *\n * Quality 72 is where a photo stops showing a difference to the eye at the\n * size it is painted; 80, the setting since AGL-175, spent 15-25% more bytes\n * on detail nobody sees. Effort 5 of 6: measured on the four images the issue\n * names, effort 6 saved another 1-2% for twice the encode time, and the\n * encode runs inside an upload request or after a CDN response.\n *\n * Changing either value changes the bytes every asset should hold, so it\n * bumps `MEDIA_VARIANT_ENCODER_VERSION` — that is what reaches the existing\n * corpus.\n */\nexport const MEDIA_VARIANT_WEBP_OPTIONS = Object.freeze({\n quality: 72,\n effort: 5,\n})\n\n/**\n * The quality the display copy is re-encoded at (AGL-3486). Higher than a\n * variant's because the display copy is what a link preview, an email client\n * and a CSS background receive at full size — it stands in for the original.\n */\nexport const MEDIA_DISPLAY_QUALITY = 82\n\n/**\n * Formats that get a display copy. JPEG, PNG and WebP are the formats a\n * camera, a screenshot tool and a design export produce, and the ones `sharp`\n * re-encodes losslessly enough to stand in for the original. Not GIF, whose\n * animation the encoder would flatten, and not SVG or ICO, which have no\n * variants at all.\n */\nexport const MEDIA_DISPLAY_TYPES: ReadonlySet<string> = new Set([\n 'image/jpeg',\n 'image/jpg',\n 'image/png',\n 'image/webp',\n])\n\n/**\n * Encode one `?w=` variant: auto-oriented, downscaled, metadata stripped.\n *\n * `rotate()` with no angle applies the EXIF orientation, which a WebP cannot\n * carry — before AGL-3486 a portrait phone photo's variants came out lying on\n * their side. `sharp` writes no EXIF, XMP or IPTC unless asked to keep it, so\n * a photo's GPS position never reaches a variant.\n */\nexport async function encodeMediaVariant(\n sharp: SharpFactory,\n source: Buffer,\n width: number,\n): Promise<Buffer> {\n return sharp(source)\n .rotate()\n .resize({ width, withoutEnlargement: true })\n .webp(MEDIA_VARIANT_WEBP_OPTIONS)\n .toBuffer()\n}\n\n/**\n * The display copy of an image, or null when the original can be served as\n * it is (AGL-3486).\n *\n * The bare URL is what a link preview, an email client, a CSS background and\n * every pasted Copy URL fetch, and before this it served the upload byte for\n * byte: a 6 MB, 6000-pixel phone photo with the GPS position it was taken at.\n * A display copy is made when the original has any of three properties:\n *\n * - **larger than {@link MEDIA_DELIVERY_MAX_EDGE} on its long edge** —\n * downscaled to fit, since nothing on a page paints more;\n * - **EXIF, XMP or IPTC metadata** — stripped. This is the privacy half, and\n * it is kept even when the re-encode is not smaller: a location is not\n * something to trade for bytes;\n * - **an EXIF orientation** — applied, so the pixels are upright without it.\n *\n * Same format as the original, so nothing that could read the original loses\n * the ability to read the copy. Null for an animated image, whose frames the\n * encoder would drop, and for a format outside {@link MEDIA_DISPLAY_TYPES}.\n */\nexport async function encodeMediaDisplay(\n sharp: SharpFactory,\n source: Buffer,\n contentType: string,\n): Promise<{ bytes: Buffer; copy: MediaDisplayCopy } | null> {\n if (!MEDIA_DISPLAY_TYPES.has(contentType)) return null\n const metadata = await sharp(source).metadata()\n if ((metadata.pages ?? 1) > 1) return null\n const width = metadata.width ?? 0\n const height = metadata.height ?? 0\n const oversize = Math.max(width, height) > MEDIA_DELIVERY_MAX_EDGE\n const oriented = (metadata.orientation ?? 1) > 1\n const carriesMetadata = Boolean(metadata.exif || metadata.xmp || metadata.iptc)\n if (!oversize && !oriented && !carriesMetadata) return null\n\n let pipeline = sharp(source).rotate()\n if (oversize) {\n pipeline = pipeline.resize({\n width: MEDIA_DELIVERY_MAX_EDGE,\n height: MEDIA_DELIVERY_MAX_EDGE,\n fit: 'inside',\n withoutEnlargement: true,\n })\n }\n pipeline =\n contentType === 'image/png'\n ? pipeline.png({\n compressionLevel: 9,\n // A palette PNG re-encoded as truecolor can triple in size.\n palette: Boolean((metadata as { isPalette?: boolean }).isPalette),\n })\n : contentType === 'image/webp'\n ? pipeline.webp({ ...MEDIA_VARIANT_WEBP_OPTIONS, quality: MEDIA_DISPLAY_QUALITY })\n : pipeline.jpeg({ quality: MEDIA_DISPLAY_QUALITY, mozjpeg: true })\n const { data, info } = await pipeline.toBuffer({ resolveWithObject: true })\n // Only the privacy reason justifies a copy that is not smaller.\n if (!carriesMetadata && data.length >= source.length) return null\n return {\n bytes: data,\n copy: {\n contentType,\n width: info.width,\n height: info.height,\n sizeBytes: data.length,\n },\n }\n}\n\n/**\n * Formats a WebP copy at the SOURCE'S OWN width beats.\n *\n * A renderer's srcSet offers every width in `MEDIA_CDN_VARIANT_WIDTHS`, and\n * `serveMediaCdn` answers a width an asset has no variant for with the\n * original. For a JPEG or PNG no wider than a candidate, that original is what\n * every request at or above its width downloads — the `1280w` a phone asks\n * for, the `1920w` a desktop asks for. Measured on aglyn.com's hero poster, a\n * 1920x1080 JPEG: 166,756 B served for `?w=1920`, where a WebP of the same\n * 1920 pixels at the generator's quality is 40,598 B.\n *\n * So these formats also get the widths AT and ABOVE their own, each encoded at\n * the source width (`withoutEnlargement`): a re-encode, never an upscale, and\n * the same pixels the original answer served. Not GIF, whose animation a WebP\n * frame would drop; not WebP or AVIF, which a re-encode could make larger.\n */\nconst SOURCE_WIDTH_WEBP_TYPES: ReadonlySet<string> = new Set([\n 'image/jpeg',\n 'image/jpg',\n 'image/png',\n])\n\n/**\n * `image/*` types that produce no variants, for two different reasons.\n *\n * SVG is a vector: there is nothing to downscale, and rasterizing it would be\n * a different asset rather than a variant of this one.\n *\n * ICO carries no `sharp` decoder at all, so attempting it raises an\n * unsupported-format error from `toBuffer` — an ERROR on the media document,\n * which is the signal reserved for generation that was attempted and broke. An\n * icon having no variants is neither attempted nor broken; it is the ordinary\n * outcome, and it has to read as one or the fault counter measures the favicon\n * instead of a fault (AGL-3121).\n *\n * Exported because lazy regeneration (`media-delivery-regeneration.ts`) has to\n * skip exactly this set. The backfill script it replaced kept a second copy of\n * the SVG half, which is how ICO came to be missing from one and not the\n * other.\n */\nexport const MEDIA_TYPES_WITHOUT_VARIANTS: ReadonlySet<string> = new Set([\n 'image/svg+xml',\n 'image/x-icon',\n 'image/vnd.microsoft.icon',\n])\n\n/**\n * Which widths this source should produce.\n *\n * Split out and exported because it is the predicate that decides whether an\n * empty result is NORMAL, and both the routes and the health probe have to\n * agree on it. Inlined, the skip rule was a `continue` buried in the loop that\n * a reader had to reconstruct in order to know whether `[]` was a bug.\n *\n * Widths narrower than the source always; for {@link SOURCE_WIDTH_WEBP_TYPES}\n * the rest as well, provided the source is wider than the narrowest width — a\n * source smaller than every candidate is an icon, and it is served as it is.\n */\nexport function mediaVariantWidthsFor(options: {\n contentType: string\n /** Source pixel width, when it could be read from the header. */\n sourceWidth?: number | null\n}): number[] {\n if (!options.contentType.startsWith('image/')) return []\n if (MEDIA_TYPES_WITHOUT_VARIANTS.has(options.contentType)) return []\n const sourceWidth = options.sourceWidth ?? 0\n // No known width means generate and let `withoutEnlargement` decide, which\n // is the same call the loop made before: an unreadable header must not\n // silently opt an asset out of the CDN.\n if (!sourceWidth) return [...MEDIA_CDN_VARIANT_WIDTHS]\n const narrower = MEDIA_CDN_VARIANT_WIDTHS.filter((width) => width < sourceWidth)\n if (!narrower.length || !SOURCE_WIDTH_WEBP_TYPES.has(options.contentType)) {\n return narrower\n }\n return [...MEDIA_CDN_VARIANT_WIDTHS]\n}\n\n/**\n * Resolve `sharp`'s callable through either module shape.\n *\n * `(await import('sharp')).default` is correct under Node ESM and under a\n * bundler that synthesises the CJS namespace. It is `undefined` under one that\n * hands back `module.exports` directly — and `undefined(buffer)` is a\n * `TypeError` raised INSIDE the same `try` that catches a genuine load\n * failure, so from outside the two are the same event: no variants, no\n * message, a 200. This normalises the shape and, when neither shape yields a\n * function, throws something that NAMES the problem instead of a bare\n * `is not a function` from three frames away.\n */\nexport async function loadSharp(): Promise<SharpFactory> {\n const imported: unknown = await import('sharp')\n const candidate =\n typeof imported === 'function'\n ? imported\n : (imported as { default?: unknown } | null)?.default\n if (typeof candidate !== 'function') {\n throw new Error(\n `sharp did not resolve to a function (module was ${typeof imported}, ` +\n `default was ${typeof candidate})`,\n )\n }\n return candidate as SharpFactory\n}\n\n/**\n * A short, PUBLISHABLE name for why the encoder is unavailable (AGL-1471).\n *\n * The first thing the probe said on production was `sharp-unavailable`, and\n * that answered less than it looked like it did: it is the fallback for \"the\n * error carried no `code`\", and BOTH interesting failures land there. `sharp`\n * catches every `require` of its prebuilt binaries and rethrows one composed\n * `Error` with no `code` at all; `loadSharp` throws a plain `Error` too when\n * the module resolves to something that is not callable. One means the native\n * library did not load, the other means the bundler handed back the wrong\n * shape, and they have nothing in common except the remedy being different.\n *\n * Matching on `sharp`'s own help text is the only signal available — the\n * distinction is not in a field, only in the prose. It is matched loosely and\n * it degrades to the old fallback, so a reworded upstream message costs a\n * detail rather than the answer.\n *\n * The MESSAGE still never leaves the process. A native loader failure names\n * every path it tried, the health endpoint is public, and the point of a code\n * is that it is a fixed vocabulary the caller can branch on.\n */\nexport function classifyLoadFailure(error: unknown): string {\n const code = (error as { code?: string })?.code\n if (code) return String(code)\n const message = error instanceof Error ? error.message : ''\n if (/Could not load the \"?sharp\"? module/i.test(message)) {\n return 'sharp-native-missing'\n }\n if (/did not resolve to a function/i.test(message)) {\n return 'sharp-not-a-function'\n }\n return 'sharp-unavailable'\n}\n\n/** Error text kept short — it is stored on a document, not in a log. */\nfunction describe(error: unknown): string {\n const text =\n error instanceof Error\n ? `${error.name}: ${error.message}`\n : String(error ?? 'unknown')\n return text.slice(0, 300)\n}\n\n/**\n * Generate the delivery copies for one asset and report the outcome: its\n * WebP variants, and with `display: true` its display copy (AGL-3486).\n *\n * `saveVariant` is a callback rather than a `Bucket` so this stays free of a\n * storage dependency and so a spec can assert on the BYTES it produces —\n * which is the only assertion that distinguishes a working variant from the\n * original served back under a `?w=` that selects nothing.\n *\n * ## A variant is never larger than what it replaces\n *\n * A width the asset has no variant for is answered with the display copy, or\n * the original when there is none — so a variant is only worth storing when\n * it is SMALLER than that answer. Measured on a customer's 1280px JPEGs before\n * AGL-3486: the source-width WebP at `?w=1280` was 384 KB against a 305 KB\n * original, so the variant made every retina visit 26% heavier. A width whose\n * encode comes out no smaller is now skipped and the smaller bytes serve it.\n *\n * The display copy is made FIRST for that reason: it is what the comparison\n * is against.\n */\nexport async function generateMediaVariants(options: {\n buffer: Buffer\n contentType: string\n sourceWidth?: number | null\n /** Object path of the ORIGINAL; variants are `${objectPath}__w{n}.webp`. */\n objectPath: string\n saveVariant: SaveMediaDerivedObject\n /** Also make the display copy. Asked for by an asset, never by a poster. */\n display?: boolean\n}): Promise<MediaVariantOutcome> {\n const widths = mediaVariantWidthsFor(options)\n const wantsDisplay =\n options.display === true && MEDIA_DISPLAY_TYPES.has(options.contentType)\n if (!widths.length && !wantsDisplay) return { variants: [] }\n\n const variants: number[] = []\n let display: MediaDisplayCopy | undefined\n try {\n const sharp = await loadSharp()\n let fallbackBytes = options.buffer.length\n if (wantsDisplay) {\n const made = await encodeMediaDisplay(sharp, options.buffer, options.contentType)\n if (made) {\n await options.saveVariant(\n mediaDisplayObjectPath(options.objectPath),\n made.bytes,\n options.contentType,\n )\n display = made.copy\n fallbackBytes = made.bytes.length\n }\n }\n for (const width of widths) {\n const webp = await encodeMediaVariant(sharp, options.buffer, width)\n if (webp.length >= fallbackBytes) continue\n await options.saveVariant(\n `${options.objectPath}__w${width}.webp`,\n webp,\n 'image/webp',\n )\n variants.push(width)\n }\n } catch (error) {\n // Still never fails the upload — but no longer only whispers.\n console.error('media variant generation failed', options.objectPath, error)\n return { variants, ...(display ? { display } : {}), error: describe(error) }\n }\n return { variants, ...(display ? { display } : {}) }\n}\n\n/**\n * The media document fields a generation outcome writes (AGL-3486), shared by\n * every route that generates so none of them can forget the encoder\n * generation — the field lazy regeneration keys on.\n *\n * `display` is written as `null` rather than left out when there is none, so\n * a replace whose new bytes need no display copy clears the previous one's\n * record instead of pointing at an object it just deleted. The caller decides\n * `variantsError`, because the routes record it with a counter beside it.\n */\nexport function mediaVariantDocFields(\n outcome: Pick<MediaVariantOutcome, 'variants' | 'display'>,\n): {\n variants: number[]\n variantEncoderVersion: number\n display: MediaDisplayCopy | null\n} {\n return {\n variants: outcome.variants,\n variantEncoderVersion: MEDIA_VARIANT_ENCODER_VERSION,\n display: outcome.display ?? null,\n }\n}\n\n/**\n * Every derived object a media document names, given its original's path\n * (AGL-3486): the `?w=` variants, the display copy, a video's poster and the\n * poster's widths, and its renditions.\n *\n * The one list every path that moves, replaces or deletes an asset reads, so\n * a new kind of derived object is added in one place. Before it, each of the\n * three kept its own: a replace dropped all four kinds, while a folder move\n * and a delete knew only the variants — so a moved film lost its poster and\n * renditions to the old prefix, and a deleted one left them in the bucket.\n */\nexport function mediaDerivedObjectPaths(\n objectPath: string,\n /** The media document's fields, or its snapshot. */\n document:\n | {\n variants?: unknown\n display?: unknown\n poster?: unknown\n videoRenditions?: unknown\n }\n | { get(field: string): unknown },\n): string[] {\n const read = (field: 'variants' | 'display' | 'poster' | 'videoRenditions') =>\n 'get' in document && typeof document.get === 'function'\n ? document.get(field)\n : (document as Record<string, unknown>)[field]\n const widths = (value: unknown): number[] =>\n Array.isArray(value)\n ? value.filter((width): width is number => Number.isInteger(width) && width > 0)\n : []\n const poster = read('poster') as { variants?: unknown } | null | undefined\n return [\n ...widths(read('variants')).map((width) => `${objectPath}__w${width}.webp`),\n ...(read('display') ? [mediaDisplayObjectPath(objectPath)] : []),\n ...(poster ? [mediaPosterObjectPath(objectPath)] : []),\n ...widths(poster?.variants).map(\n (width) => `${objectPath}${MEDIA_POSTER_OBJECT_SUFFIX}__w${width}.webp`,\n ),\n ...parseMediaRenditions(read('videoRenditions')).map((rendition) =>\n mediaRenditionObjectPath(objectPath, rendition),\n ),\n ]\n}\n\n/**\n * The largest object this will pull back OUT of storage in order to generate.\n *\n * Deliberately equal to `IMAGE_MAX_BYTES`, the console's per-type ceiling for\n * an image (`apps/console/utils/media-upload-limits.ts`), rather than derived\n * from it: this library cannot import that file, and the two numbers answer\n * different questions. That one asks \"may this be uploaded at all\"; this one\n * asks \"may a serverless function afford to fetch it back and decode it\". They\n * agree today, which is the point — every image the DAM accepts gets variants.\n * If the upload ceiling is ever raised for a format where 15 MB is small (a\n * camera RAW, a TIFF), the download stops there instead of silently following\n * it into a timeout, and says so.\n */\nexport const MEDIA_VARIANT_SOURCE_MAX_BYTES = 15 * 1024 * 1024\n\n/**\n * Generate variants for an object whose bytes are in STORAGE, not in hand\n * (AGL-1476).\n *\n * `/api/media/upload` has the buffer — the client base64'd it into the request\n * body. `/api/media/upload-url` never sees it: the browser PUTs straight to GCS\n * with a signed URL, and finalize is handed a path. Since AGL-1465 gave images\n * a signed-path ceiling, every image between 3 MB and 15 MB takes that route,\n * so the assets that most need a 320px WebP became precisely the ones that got\n * none. Measured on production 2026-08-13: a 6,606,921 B PNG landed with\n * `variants` **absent**, and `?w=320` served back all 6,606,921 B of\n * `image/png`; a 2,168,376 B PNG on the base64 route landed with\n * `variants: [320, 640]` and answered `?w=320` in 31,566 B of `image/webp`.\n *\n * So the bytes have to come back. `readSource` is a callback, for the same\n * reason `saveVariant` is one: this stays free of a storage dependency, and a\n * spec can hand it a real PNG and count what comes out.\n *\n * **The read is the last thing that happens, not the first.** Widths are\n * decided from the header dimensions the caller already has, so a source\n * narrower than every target width — and every non-image — costs nothing at\n * all. That ordering is what keeps this from pulling a 200 MB video back out\n * of the bucket to discover it has no variants.\n *\n * The size refusal is reported as an ERROR rather than as an empty success,\n * and the distinction is AGL-1468's: `variants: []` with no error means\n * nothing was ELIGIBLE, and an image the platform accepted and then declined\n * to optimize is not that. It is eligible work that did not happen, so it\n * belongs on the document and in `variantFailures` where it can be counted.\n */\nexport async function generateStoredMediaVariants(options: {\n contentType: string\n /** The object's REAL size, as storage reports it. */\n sizeBytes: number\n sourceWidth?: number | null\n objectPath: string\n /** Fetches the whole object. Only ever called when there is work to do. */\n readSource: () => Promise<Buffer>\n saveVariant: SaveMediaDerivedObject\n maxSourceBytes?: number\n /** Also make the display copy (see {@link generateMediaVariants}). */\n display?: boolean\n}): Promise<MediaVariantOutcome> {\n const widths = mediaVariantWidthsFor(options)\n const wantsDisplay =\n options.display === true && MEDIA_DISPLAY_TYPES.has(options.contentType)\n if (!widths.length && !wantsDisplay) return { variants: [] }\n\n const maxSourceBytes = options.maxSourceBytes ?? MEDIA_VARIANT_SOURCE_MAX_BYTES\n if (options.sizeBytes > maxSourceBytes) {\n return {\n variants: [],\n error: `source too large to fetch for variants (${options.sizeBytes} > ${maxSourceBytes} bytes)`,\n }\n }\n\n let buffer: Buffer\n try {\n buffer = await options.readSource()\n } catch (error) {\n // A download failure is a genuine fault on an eligible asset, and it is\n // NOT the same event as `sharp` being unavailable — so it is described\n // here rather than left to be inferred from a message three frames away.\n console.error('media variant source download failed', options.objectPath, error)\n return { variants: [], error: `source download failed — ${describe(error)}` }\n }\n\n return generateMediaVariants({ ...options, buffer })\n}\n\n/**\n * Can this deployment produce a variant at all?\n *\n * Synthesises a small image in memory, downscales it, and asserts the result\n * is both a WebP and SMALLER than the source. No Storage, no Firestore, no\n * upload — so it can run from a health endpoint on a schedule instead of\n * waiting for a customer to notice their thumbnails are full-size originals.\n *\n * The size comparison is the point. A resize that silently returns its input\n * is exactly the failure this issue is about: `?w=640` answering 200 with the\n * original is indistinguishable from success unless something counts bytes.\n */\nexport async function probeMediaVariantSupport(): Promise<{\n ok: boolean\n code?: string\n}> {\n try {\n const sharp = await loadSharp()\n // A smooth gradient, not a flat fill and not noise. A flat fill\n // compresses to a handful of bytes at every width, so the size\n // comparison below would pass on a resize that did nothing; noise\n // re-expands under lossy WebP, so it would FAIL on a perfectly healthy\n // encoder. Real images sit between the two, and so does this.\n const width = 640\n const height = 128\n const pixels = Buffer.alloc(width * height * 3)\n for (let y = 0; y < height; y += 1) {\n for (let x = 0; x < width; x += 1) {\n const index = (y * width + x) * 3\n pixels[index] = Math.round((x * 255) / width)\n pixels[index + 1] = Math.round((y * 255) / height)\n pixels[index + 2] = Math.round((((x + y) % 300) * 255) / 300)\n }\n }\n const source = await sharp(pixels, { raw: { width, height, channels: 3 } })\n .png()\n .toBuffer()\n const resized = await encodeMediaVariant(sharp, source, 320)\n // `RIFF....WEBP` — a WebP, not the PNG handed back.\n const isWebp =\n resized.length > 12 &&\n resized.toString('ascii', 0, 4) === 'RIFF' &&\n resized.toString('ascii', 8, 12) === 'WEBP'\n if (!isWebp) return { ok: false, code: 'not-webp' }\n if (resized.length >= source.length) return { ok: false, code: 'not-smaller' }\n return { ok: true }\n } catch (error) {\n // A CODE, never the message: the health endpoint is public and an error\n // from a native module can carry filesystem paths.\n return { ok: false, code: classifyLoadFailure(error) }\n }\n}\n\n/**\n * The largest poster this will accept from a client (AGL-2742).\n *\n * The browser encodes the still it captured as WebP at quality ~0.72, and a\n * 1920-wide frame of real video lands between 40 KB and 250 KB. 4 MB is\n * therefore ~16x the worst realistic case and is not a quality knob: it is\n * the point past which the bytes stopped being a poster. It has to sit well\n * under the platform's 4.5 MB request-body wall, because the poster rides\n * base64 in the finalize call and a ceiling that admits a body the platform\n * will reject turns a refusal we can explain into a 413 we cannot.\n */\nexport const MEDIA_POSTER_MAX_BYTES = 4 * 1024 * 1024\n\n/**\n * The widest poster stored. Above this a still is being kept at a resolution\n * no `<video poster>` paints — the element scales it to the player box, and\n * the largest realistic player box is the 1920 the variant widths already\n * top out at.\n */\nexport const MEDIA_POSTER_MAX_WIDTH = 1920\n\n/** WebP quality for the poster — the variants' own since AGL-3486. */\nconst POSTER_QUALITY = MEDIA_VARIANT_WEBP_OPTIONS.quality\n\nexport interface MediaPosterOutcome {\n /** Set only when a poster object was actually written. */\n poster?: { width: number; height: number; variants: number[] }\n /**\n * Present ONLY when a poster was offered and could not be produced —\n * the same two-failure-class contract {@link MediaVariantOutcome} keeps.\n * An upload with no poster offered returns an empty object, not an error.\n */\n error?: string\n}\n\n/**\n * Turn a client-supplied still into the asset's poster (AGL-2742).\n *\n * ## The re-encode is a security control, not a compression pass\n *\n * These bytes come from a browser, over the wire, and land in the shared\n * media bucket under an org's own prefix, where `serveMediaCdn` will stream\n * them `inline` from the console's origin and from every tenant site's. That\n * is precisely the shape of AGL-1474, where an uploaded `image/svg+xml`\n * turned out to be a scripted document served from `app.aglyn.com`.\n *\n * Nothing here trusts the declared type, the magic bytes, or the extension.\n * The buffer is decoded by `sharp` and **re-encoded** to WebP, so what gets\n * stored is bytes this process produced. A polyglot, an SVG, an HTML file\n * with a PNG header — each either fails to decode (reported, no object\n * written) or becomes a real raster WebP with whatever it was smuggling\n * discarded. That is a stronger guarantee than any sniffing gate, and it is\n * why the poster is allowed to be client-generated at all.\n *\n * ## Why it reuses `generateMediaVariants` rather than looping itself\n *\n * A poster IS an image, and the width set a poster should offer is the width\n * set every other image offers — the srcSet the renderer already knows how\n * to write. Handing the generator `{objectPath}__poster` as its object path\n * makes it emit `{objectPath}__poster__w{n}.webp` with no knowledge that\n * video exists, and inherits its skip rule, its partial-run reporting and\n * its refusal to fail an upload. A private loop here would be a second set\n * of widths to keep in step, which is the defect AGL-175's widths list was\n * moved into `@aglyn/aglyn` to prevent.\n *\n * The dimensions returned are read back from the ENCODED bytes rather than\n * taken from the caller. The browser's report is what sized the capture, and\n * the resize below may have changed it; a document that records what was\n * asked for instead of what was written is how an `aspect-ratio` drifts off\n * by the cap.\n */\nexport async function generateMediaPoster(options: {\n /** Raw bytes as received. Never assumed to be an image of any kind. */\n buffer: Buffer\n /** Object path of the ORIGINAL video, without the poster suffix. */\n objectPath: string\n saveVariant: SaveMediaDerivedObject\n maxWidth?: number\n}): Promise<MediaPosterOutcome> {\n const posterPath = mediaPosterObjectPath(options.objectPath)\n let webp: Buffer\n try {\n const sharp = await loadSharp()\n webp = await sharp(options.buffer)\n .resize({\n width: options.maxWidth ?? MEDIA_POSTER_MAX_WIDTH,\n withoutEnlargement: true,\n })\n .webp({ quality: POSTER_QUALITY })\n .toBuffer()\n } catch (error) {\n console.error('media poster encode failed', options.objectPath, error)\n return { error: `poster encode failed — ${describe(error)}` }\n }\n\n // Read the WRITTEN bytes. `readImageDimensions` parses the WebP chunk\n // headers this encoder just emitted, so this is a measurement of the\n // stored object and not a restatement of the request.\n const dimensions = readImageDimensions(new Uint8Array(webp))\n if (!dimensions) {\n return { error: 'poster encoded but its dimensions were unreadable' }\n }\n\n try {\n await options.saveVariant(posterPath, webp, 'image/webp')\n } catch (error) {\n console.error('media poster save failed', posterPath, error)\n return { error: `poster save failed — ${describe(error)}` }\n }\n\n // Widths for the poster, generated from the poster's own bytes. A failure\n // past this point still leaves a usable full-size poster on the document,\n // which is the whole reason the object above is written first.\n const outcome = await generateMediaVariants({\n buffer: webp,\n contentType: 'image/webp',\n sourceWidth: dimensions.width,\n objectPath: `${options.objectPath}${MEDIA_POSTER_OBJECT_SUFFIX}`,\n saveVariant: options.saveVariant,\n })\n\n return {\n poster: {\n width: dimensions.width,\n height: dimensions.height,\n variants: outcome.variants,\n },\n ...(outcome.error ? { error: outcome.error } : {}),\n }\n}\n"],"names":["readImageDimensions","MEDIA_CDN_VARIANT_WIDTHS","MEDIA_DELIVERY_MAX_EDGE","MEDIA_POSTER_OBJECT_SUFFIX","MEDIA_VARIANT_ENCODER_VERSION","mediaDisplayObjectPath","mediaPosterObjectPath","mediaRenditionObjectPath","parseMediaRenditions","MEDIA_VARIANT_WEBP_OPTIONS","Object","freeze","quality","effort","MEDIA_DISPLAY_QUALITY","MEDIA_DISPLAY_TYPES","Set","encodeMediaVariant","sharp","source","width","rotate","resize","withoutEnlargement","webp","toBuffer","encodeMediaDisplay","contentType","metadata","has","pages","height","oversize","Math","max","oriented","orientation","carriesMetadata","Boolean","exif","xmp","iptc","pipeline","fit","png","compressionLevel","palette","isPalette","jpeg","mozjpeg","data","info","resolveWithObject","length","bytes","copy","sizeBytes","SOURCE_WIDTH_WEBP_TYPES","MEDIA_TYPES_WITHOUT_VARIANTS","mediaVariantWidthsFor","options","startsWith","sourceWidth","narrower","filter","loadSharp","imported","candidate","default","Error","classifyLoadFailure","error","code","String","message","test","describe","text","name","slice","generateMediaVariants","widths","wantsDisplay","display","variants","fallbackBytes","buffer","made","saveVariant","objectPath","push","console","mediaVariantDocFields","outcome","variantEncoderVersion","mediaDerivedObjectPaths","document","read","field","get","value","Array","isArray","Number","isInteger","poster","map","rendition","MEDIA_VARIANT_SOURCE_MAX_BYTES","generateStoredMediaVariants","maxSourceBytes","readSource","probeMediaVariantSupport","pixels","Buffer","alloc","y","x","index","round","raw","channels","resized","isWebp","toString","ok","MEDIA_POSTER_MAX_BYTES","MEDIA_POSTER_MAX_WIDTH","POSTER_QUALITY","generateMediaPoster","posterPath","maxWidth","dimensions","Uint8Array"],"mappings":"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"}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* and the projections the security rules authorize against are never
|
|
21
21
|
* client-writable (docs/MULTI_TENANT_FIRESTORE.md §8).
|
|
22
22
|
*/
|
|
23
|
-
import { type ConsentGroup, type AglynOrganization, type AglynOrgBilling, type AglynOrgMember, type HostAccessRole, type OrgPermission, type OrgRole } from '@aglyn/aglyn/server';
|
|
23
|
+
import { type ConsentGroup, type AglynOrganization, type AglynOrgBilling, type AglynOrgMember, type HostAccessRole, type OrgPermission, type OrgRole, type OwnerHandoffPreviousOwner } from '@aglyn/aglyn/server';
|
|
24
24
|
import type { PluginActivityTargetType } from '@aglyn/aglyn/plugin-manager/plugin-activity-actions';
|
|
25
25
|
import type { HostActivityActor } from '@aglyn/aglyn/app-utils/activity-presenter';
|
|
26
26
|
export declare class OrgSlugTakenError extends Error {
|
|
@@ -102,6 +102,13 @@ export interface CreateOrganizationOptions {
|
|
|
102
102
|
* nothing else.
|
|
103
103
|
*/
|
|
104
104
|
bypassFreeWorkspaceCap?: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Whether the creator holds the `staff` claim, when the caller already
|
|
107
|
+
* knows (AGL-3466). Absent, it is looked up. A staff creator's owner row is
|
|
108
|
+
* stamped `staffSeat: true`, so a workspace staff build for a prospect
|
|
109
|
+
* leaves the prospect's one Free seat free for the prospect.
|
|
110
|
+
*/
|
|
111
|
+
ownerStaffSeat?: boolean;
|
|
105
112
|
}
|
|
106
113
|
/**
|
|
107
114
|
* Creates an org in one transaction: slug reservation (uniqueness), org
|
|
@@ -435,7 +442,7 @@ options?: {
|
|
|
435
442
|
}): Promise<void>;
|
|
436
443
|
/** What a host activity entry points at. Mirrors `HostActivityTarget`. */
|
|
437
444
|
export interface HostActivityTarget {
|
|
438
|
-
type: 'host' | 'screen' | 'layout' | 'theme' | 'media' | 'content' | 'variable' | 'function' | 'workflow' | 'member' | 'component' | 'template' | 'contact' | 'company' | 'deal' | 'lead';
|
|
445
|
+
type: 'host' | 'screen' | 'layout' | 'theme' | 'media' | 'content' | 'variable' | 'function' | 'workflow' | 'member' | 'component' | 'template' | 'contact' | 'company' | 'deal' | 'lead' | PluginActivityTargetType;
|
|
439
446
|
id?: string;
|
|
440
447
|
name?: string;
|
|
441
448
|
versionId?: string;
|
|
@@ -514,9 +521,11 @@ export declare function collaboratorSeatRefusal(options: {
|
|
|
514
521
|
orgId: string;
|
|
515
522
|
org: Partial<AglynOrgBilling>;
|
|
516
523
|
hostIds: string[];
|
|
524
|
+
/** `staffSeat` admits platform staff without a seat (AGL-3466). */
|
|
517
525
|
self?: {
|
|
518
526
|
uid?: string | null;
|
|
519
527
|
email?: string | null;
|
|
528
|
+
staffSeat?: boolean;
|
|
520
529
|
};
|
|
521
530
|
}): Promise<Response | null>;
|
|
522
531
|
/**
|
|
@@ -566,9 +575,11 @@ export declare function managerSeatRefusal(options: {
|
|
|
566
575
|
orgId: string;
|
|
567
576
|
org: Partial<AglynOrgBilling>;
|
|
568
577
|
becomesManager: boolean;
|
|
578
|
+
/** `staffSeat` admits platform staff without a seat (AGL-3466). */
|
|
569
579
|
self?: {
|
|
570
580
|
uid?: string | null;
|
|
571
581
|
email?: string | null;
|
|
582
|
+
staffSeat?: boolean;
|
|
572
583
|
};
|
|
573
584
|
}): Promise<Response | null>;
|
|
574
585
|
export interface UpsertOrgMemberOptions {
|
|
@@ -755,6 +766,95 @@ export declare function backfillMemberIdentityEverywhere(uid: string, identity:
|
|
|
755
766
|
* sequence to keep it that way.
|
|
756
767
|
*/
|
|
757
768
|
export declare function transferOrgOwnership(orgId: string, fromUid: string, toUid: string): Promise<void>;
|
|
769
|
+
/**
|
|
770
|
+
* An owner handoff refused because it would need a team seat the plan does
|
|
771
|
+
* not have (AGL-3466).
|
|
772
|
+
*
|
|
773
|
+
* The loophole guard. A handoff reserves no seat, because the owner seat
|
|
774
|
+
* MOVES — but a `stay` handoff keeps the outgoing owner as an admin, so the
|
|
775
|
+
* workspace ends with one more manager than it started with. On Free that is
|
|
776
|
+
* a second manager on a one-seat plan, and without this check any owner could
|
|
777
|
+
* mint free managers by handing the workspace back and forth. The refusal
|
|
778
|
+
* names the way out: the outgoing owner leaving instead.
|
|
779
|
+
*
|
|
780
|
+
* Separate from {@link ManagerSeatLimitError} because the remedy is
|
|
781
|
+
* different, and a client that branches on `code` must be able to tell.
|
|
782
|
+
*/
|
|
783
|
+
export declare class OwnerHandoffSeatError extends Error {
|
|
784
|
+
readonly limit: number;
|
|
785
|
+
readonly upgradeRequired: boolean;
|
|
786
|
+
readonly previousOwner: OwnerHandoffPreviousOwner;
|
|
787
|
+
constructor(previousOwner: OwnerHandoffPreviousOwner, quota: {
|
|
788
|
+
limit: number;
|
|
789
|
+
upgradeRequired: boolean;
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* An owner handoff that can no longer be accepted as asked (AGL-3466): the
|
|
794
|
+
* invite was answered or withdrawn in the meantime, is not a handoff, is
|
|
795
|
+
* addressed to somebody else, or the accepter already owns the workspace.
|
|
796
|
+
*/
|
|
797
|
+
export declare class OwnerHandoffError extends Error {
|
|
798
|
+
readonly reason: 'not-pending' | 'not-handoff' | 'not-addressed' | 'already-owner';
|
|
799
|
+
constructor(reason: OwnerHandoffError['reason']);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Turn a handoff refusal into the response the invite route returns, or
|
|
803
|
+
* null when the error is something else and must keep propagating.
|
|
804
|
+
*/
|
|
805
|
+
export declare function ownerHandoffRefusalResponse(error: unknown): Response | null;
|
|
806
|
+
/**
|
|
807
|
+
* The handoff seat check, asked when the invitation is SENT (AGL-3466), so
|
|
808
|
+
* nobody is mailed a handoff that will be refused when they click it.
|
|
809
|
+
*
|
|
810
|
+
* Not the enforcement — `acceptOwnerHandoff` asks again inside its
|
|
811
|
+
* transaction, against the account that actually accepts. Here the invitee
|
|
812
|
+
* is only an address, so their current seat is read off the roster row that
|
|
813
|
+
* carries it.
|
|
814
|
+
*
|
|
815
|
+
* @returns the 403 to send, or null to proceed.
|
|
816
|
+
*/
|
|
817
|
+
export declare function ownerHandoffSeatRefusal(options: {
|
|
818
|
+
orgId: string;
|
|
819
|
+
email: string;
|
|
820
|
+
previousOwner: OwnerHandoffPreviousOwner;
|
|
821
|
+
inviteeStaff: boolean;
|
|
822
|
+
}): Promise<Response | null>;
|
|
823
|
+
export interface AcceptOwnerHandoffOptions {
|
|
824
|
+
orgId: string;
|
|
825
|
+
inviteId: string;
|
|
826
|
+
/** The account accepting, which becomes the owner. */
|
|
827
|
+
uid: string;
|
|
828
|
+
/** The accepter's primary address, mirrored onto their roster row. */
|
|
829
|
+
email: string;
|
|
830
|
+
/**
|
|
831
|
+
* Every CONFIRMED address on the accepting account (AGL-2486). The invite
|
|
832
|
+
* must be addressed to one of them, re-checked inside the transaction.
|
|
833
|
+
*/
|
|
834
|
+
emails: readonly string[];
|
|
835
|
+
displayName?: string;
|
|
836
|
+
photoURL?: string;
|
|
837
|
+
}
|
|
838
|
+
export interface AcceptOwnerHandoffResult {
|
|
839
|
+
previousOwnerUid: string;
|
|
840
|
+
previousOwner: OwnerHandoffPreviousOwner;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Accept an owner handoff (AGL-3466): the invitee becomes the owner, the
|
|
844
|
+
* outgoing owner stays on as an admin or leaves, and the invite is marked
|
|
845
|
+
* accepted — all in ONE transaction, with the seat check inside it.
|
|
846
|
+
*
|
|
847
|
+
* The move itself is {@link writeOwnershipMove}, the same writes a transfer
|
|
848
|
+
* makes. What this adds is what an invitation needs that a transfer does not:
|
|
849
|
+
* the incoming owner may not be on the roster yet, so their row is created
|
|
850
|
+
* with the identity a join would give it; and the outgoing owner may leave.
|
|
851
|
+
*
|
|
852
|
+
* The SSO lockout check is the CALLER's, for the reason
|
|
853
|
+
* `assessOwnershipTransferLockout` gives: it cannot live in this library
|
|
854
|
+
* without an import cycle, and `sso-transfer-lockout-wiring.spec.ts` pins
|
|
855
|
+
* that every caller of this asks it first.
|
|
856
|
+
*/
|
|
857
|
+
export declare function acceptOwnerHandoff(options: AcceptOwnerHandoffOptions): Promise<AcceptOwnerHandoffResult>;
|
|
758
858
|
/**
|
|
759
859
|
* Grants (or updates) per-host access for a uid without disturbing an
|
|
760
860
|
* existing membership's org role or allHosts flag (AGL-238: the host user
|