@aglyn/tenant-data-admin 1.0.0-beta.220 → 1.0.0-beta.222

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 (114) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-keys.js +3 -3
  3. package/src/lib/server/api-keys.js.map +1 -1
  4. package/src/lib/server/api-v1-resources.d.ts +44 -3
  5. package/src/lib/server/api-v1-resources.js +82 -5
  6. package/src/lib/server/api-v1-resources.js.map +1 -1
  7. package/src/lib/server/collection-live-pages.d.ts +2 -2
  8. package/src/lib/server/collection-live-pages.js +1 -1
  9. package/src/lib/server/collection-live-pages.js.map +1 -1
  10. package/src/lib/server/email-delivery-log.d.ts +17 -73
  11. package/src/lib/server/email-delivery-log.js +44 -222
  12. package/src/lib/server/email-delivery-log.js.map +1 -1
  13. package/src/lib/server/email-flow-gate.js +27 -38
  14. package/src/lib/server/email-flow-gate.js.map +1 -1
  15. package/src/lib/server/erase-person.d.ts +34 -54
  16. package/src/lib/server/erase-person.js +44 -239
  17. package/src/lib/server/erase-person.js.map +1 -1
  18. package/src/lib/server/host-visitor-records.d.ts +12 -4
  19. package/src/lib/server/host-visitor-records.js +20 -10
  20. package/src/lib/server/host-visitor-records.js.map +1 -1
  21. package/src/lib/server/hosted-page-review.d.ts +7 -0
  22. package/src/lib/server/hosted-page-review.js +144 -20
  23. package/src/lib/server/hosted-page-review.js.map +1 -1
  24. package/src/lib/server/leaving-notice.d.ts +98 -0
  25. package/src/lib/server/leaving-notice.js +250 -0
  26. package/src/lib/server/leaving-notice.js.map +1 -0
  27. package/src/lib/server/link-reputation-review.d.ts +58 -0
  28. package/src/lib/server/link-reputation-review.js +185 -0
  29. package/src/lib/server/link-reputation-review.js.map +1 -0
  30. package/src/lib/server/live-page-drops.d.ts +67 -0
  31. package/src/lib/server/live-page-drops.js +57 -0
  32. package/src/lib/server/live-page-drops.js.map +1 -0
  33. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  34. package/src/lib/server/media-bandwidth-cap.js +180 -0
  35. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  36. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  37. package/src/lib/server/media-cdn-icon.js +140 -0
  38. package/src/lib/server/media-cdn-icon.js.map +1 -0
  39. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  40. package/src/lib/server/media-delivery-regeneration.js +216 -0
  41. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  42. package/src/lib/server/media-delivery.d.ts +2 -0
  43. package/src/lib/server/media-delivery.js +2 -1
  44. package/src/lib/server/media-delivery.js.map +1 -1
  45. package/src/lib/server/media-serve-count.d.ts +50 -0
  46. package/src/lib/server/media-serve-count.js +62 -0
  47. package/src/lib/server/media-serve-count.js.map +1 -0
  48. package/src/lib/server/media-signing.d.ts +13 -2
  49. package/src/lib/server/media-signing.js +12 -8
  50. package/src/lib/server/media-signing.js.map +1 -1
  51. package/src/lib/server/media-tombstone.js +7 -5
  52. package/src/lib/server/media-tombstone.js.map +1 -1
  53. package/src/lib/server/media-variants.d.ts +151 -20
  54. package/src/lib/server/media-variants.js +204 -27
  55. package/src/lib/server/media-variants.js.map +1 -1
  56. package/src/lib/server/org-age.d.ts +15 -6
  57. package/src/lib/server/org-age.js +20 -8
  58. package/src/lib/server/org-age.js.map +1 -1
  59. package/src/lib/server/organizations.d.ts +102 -2
  60. package/src/lib/server/organizations.js +388 -58
  61. package/src/lib/server/organizations.js.map +1 -1
  62. package/src/lib/server/outbound-send-review.js +24 -6
  63. package/src/lib/server/outbound-send-review.js.map +1 -1
  64. package/src/lib/server/page-security-hold.d.ts +93 -0
  65. package/src/lib/server/page-security-hold.js +180 -0
  66. package/src/lib/server/page-security-hold.js.map +1 -0
  67. package/src/lib/server/paged-sweeps.d.ts +51 -0
  68. package/src/lib/server/paged-sweeps.js +79 -0
  69. package/src/lib/server/paged-sweeps.js.map +1 -0
  70. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  71. package/src/lib/server/platform-marketing-consent.js +27 -11
  72. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  73. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  74. package/src/lib/server/serve-media-cdn.js +424 -70
  75. package/src/lib/server/serve-media-cdn.js.map +1 -1
  76. package/src/lib/server/site-icon-render.d.ts +68 -0
  77. package/src/lib/server/site-icon-render.js +157 -0
  78. package/src/lib/server/site-icon-render.js.map +1 -0
  79. package/src/lib/server/staff-seat.d.ts +50 -0
  80. package/src/lib/server/staff-seat.js +118 -0
  81. package/src/lib/server/staff-seat.js.map +1 -0
  82. package/src/lib/server/transfer-export.d.ts +46 -0
  83. package/src/lib/server/transfer-export.js +221 -0
  84. package/src/lib/server/transfer-export.js.map +1 -0
  85. package/src/lib/server/transfer-jobs.d.ts +385 -0
  86. package/src/lib/server/transfer-jobs.js +2368 -0
  87. package/src/lib/server/transfer-jobs.js.map +1 -0
  88. package/src/lib/server/transfer-packages.d.ts +127 -0
  89. package/src/lib/server/transfer-packages.js +1036 -0
  90. package/src/lib/server/transfer-packages.js.map +1 -0
  91. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  92. package/src/lib/server/upgrade-proposal.js +312 -0
  93. package/src/lib/server/upgrade-proposal.js.map +1 -0
  94. package/src/lib/server/upsert-contact.d.ts +17 -8
  95. package/src/lib/server/upsert-contact.js +82 -16
  96. package/src/lib/server/upsert-contact.js.map +1 -1
  97. package/src/lib/server/web-risk.d.ts +139 -0
  98. package/src/lib/server/web-risk.js +521 -0
  99. package/src/lib/server/web-risk.js.map +1 -0
  100. package/src/tenancy.d.ts +3 -1
  101. package/src/tenancy.js +5 -1
  102. package/src/tenancy.js.map +1 -1
  103. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  104. package/src/lib/server/campaign-attribution-store.js +0 -95
  105. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  106. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  107. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  108. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  109. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  110. package/src/lib/server/dataset-live-pages.js +0 -421
  111. package/src/lib/server/dataset-live-pages.js.map +0 -1
  112. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  113. package/src/lib/server/email-revenue-attribution.js +0 -321
  114. package/src/lib/server/email-revenue-attribution.js.map +0 -1
@@ -14,8 +14,120 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { MEDIA_POSTER_OBJECT_SUFFIX, mediaPosterObjectPath, readImageDimensions } from "@aglyn/aglyn/server";
18
- import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
17
+ */ import { readImageDimensions } from "@aglyn/aglyn/server";
18
+ // By path: the media-ref grammar is a leaf, and the routes that spread this
19
+ // module's helpers into a document reach it the same way.
20
+ import { MEDIA_CDN_VARIANT_WIDTHS, MEDIA_DELIVERY_MAX_EDGE, MEDIA_POSTER_OBJECT_SUFFIX, MEDIA_VARIANT_ENCODER_VERSION, mediaDisplayObjectPath, mediaPosterObjectPath, mediaRenditionObjectPath, parseMediaRenditions } from "@aglyn/aglyn/app-utils/media-ref";
21
+ /**
22
+ * The WebP encoder settings for every `?w=` variant (AGL-3486).
23
+ *
24
+ * Quality 72 is where a photo stops showing a difference to the eye at the
25
+ * size it is painted; 80, the setting since AGL-175, spent 15-25% more bytes
26
+ * on detail nobody sees. Effort 5 of 6: measured on the four images the issue
27
+ * names, effort 6 saved another 1-2% for twice the encode time, and the
28
+ * encode runs inside an upload request or after a CDN response.
29
+ *
30
+ * Changing either value changes the bytes every asset should hold, so it
31
+ * bumps `MEDIA_VARIANT_ENCODER_VERSION` — that is what reaches the existing
32
+ * corpus.
33
+ */ export const MEDIA_VARIANT_WEBP_OPTIONS = Object.freeze({
34
+ quality: 72,
35
+ effort: 5
36
+ });
37
+ /**
38
+ * The quality the display copy is re-encoded at (AGL-3486). Higher than a
39
+ * variant's because the display copy is what a link preview, an email client
40
+ * and a CSS background receive at full size — it stands in for the original.
41
+ */ export const MEDIA_DISPLAY_QUALITY = 82;
42
+ /**
43
+ * Formats that get a display copy. JPEG, PNG and WebP are the formats a
44
+ * camera, a screenshot tool and a design export produce, and the ones `sharp`
45
+ * re-encodes losslessly enough to stand in for the original. Not GIF, whose
46
+ * animation the encoder would flatten, and not SVG or ICO, which have no
47
+ * variants at all.
48
+ */ export const MEDIA_DISPLAY_TYPES = new Set([
49
+ 'image/jpeg',
50
+ 'image/jpg',
51
+ 'image/png',
52
+ 'image/webp'
53
+ ]);
54
+ /**
55
+ * Encode one `?w=` variant: auto-oriented, downscaled, metadata stripped.
56
+ *
57
+ * `rotate()` with no angle applies the EXIF orientation, which a WebP cannot
58
+ * carry — before AGL-3486 a portrait phone photo's variants came out lying on
59
+ * their side. `sharp` writes no EXIF, XMP or IPTC unless asked to keep it, so
60
+ * a photo's GPS position never reaches a variant.
61
+ */ export async function encodeMediaVariant(sharp, source, width) {
62
+ return sharp(source).rotate().resize({
63
+ width,
64
+ withoutEnlargement: true
65
+ }).webp(MEDIA_VARIANT_WEBP_OPTIONS).toBuffer();
66
+ }
67
+ /**
68
+ * The display copy of an image, or null when the original can be served as
69
+ * it is (AGL-3486).
70
+ *
71
+ * The bare URL is what a link preview, an email client, a CSS background and
72
+ * every pasted Copy URL fetch, and before this it served the upload byte for
73
+ * byte: a 6 MB, 6000-pixel phone photo with the GPS position it was taken at.
74
+ * A display copy is made when the original has any of three properties:
75
+ *
76
+ * - **larger than {@link MEDIA_DELIVERY_MAX_EDGE} on its long edge** —
77
+ * downscaled to fit, since nothing on a page paints more;
78
+ * - **EXIF, XMP or IPTC metadata** — stripped. This is the privacy half, and
79
+ * it is kept even when the re-encode is not smaller: a location is not
80
+ * something to trade for bytes;
81
+ * - **an EXIF orientation** — applied, so the pixels are upright without it.
82
+ *
83
+ * Same format as the original, so nothing that could read the original loses
84
+ * the ability to read the copy. Null for an animated image, whose frames the
85
+ * encoder would drop, and for a format outside {@link MEDIA_DISPLAY_TYPES}.
86
+ */ export async function encodeMediaDisplay(sharp, source, contentType) {
87
+ var _metadata_pages, _metadata_width, _metadata_height, _metadata_orientation;
88
+ if (!MEDIA_DISPLAY_TYPES.has(contentType)) return null;
89
+ const metadata = await sharp(source).metadata();
90
+ if (((_metadata_pages = metadata.pages) != null ? _metadata_pages : 1) > 1) return null;
91
+ const width = (_metadata_width = metadata.width) != null ? _metadata_width : 0;
92
+ const height = (_metadata_height = metadata.height) != null ? _metadata_height : 0;
93
+ const oversize = Math.max(width, height) > MEDIA_DELIVERY_MAX_EDGE;
94
+ const oriented = ((_metadata_orientation = metadata.orientation) != null ? _metadata_orientation : 1) > 1;
95
+ const carriesMetadata = Boolean(metadata.exif || metadata.xmp || metadata.iptc);
96
+ if (!oversize && !oriented && !carriesMetadata) return null;
97
+ let pipeline = sharp(source).rotate();
98
+ if (oversize) {
99
+ pipeline = pipeline.resize({
100
+ width: MEDIA_DELIVERY_MAX_EDGE,
101
+ height: MEDIA_DELIVERY_MAX_EDGE,
102
+ fit: 'inside',
103
+ withoutEnlargement: true
104
+ });
105
+ }
106
+ pipeline = contentType === 'image/png' ? pipeline.png({
107
+ compressionLevel: 9,
108
+ // A palette PNG re-encoded as truecolor can triple in size.
109
+ palette: Boolean(metadata.isPalette)
110
+ }) : contentType === 'image/webp' ? pipeline.webp(_extends({}, MEDIA_VARIANT_WEBP_OPTIONS, {
111
+ quality: MEDIA_DISPLAY_QUALITY
112
+ })) : pipeline.jpeg({
113
+ quality: MEDIA_DISPLAY_QUALITY,
114
+ mozjpeg: true
115
+ });
116
+ const { data, info } = await pipeline.toBuffer({
117
+ resolveWithObject: true
118
+ });
119
+ // Only the privacy reason justifies a copy that is not smaller.
120
+ if (!carriesMetadata && data.length >= source.length) return null;
121
+ return {
122
+ bytes: data,
123
+ copy: {
124
+ contentType,
125
+ width: info.width,
126
+ height: info.height,
127
+ sizeBytes: data.length
128
+ }
129
+ };
130
+ }
19
131
  /**
20
132
  * Formats a WebP copy at the SOURCE'S OWN width beats.
21
133
  *
@@ -49,9 +161,10 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
49
161
  * outcome, and it has to read as one or the fault counter measures the favicon
50
162
  * instead of a fault (AGL-3121).
51
163
  *
52
- * Exported because the backfill script has to skip exactly this set. It kept a
53
- * second copy of the SVG half, which is how ICO came to be missing from one
54
- * and not the other.
164
+ * Exported because lazy regeneration (`media-delivery-regeneration.ts`) has to
165
+ * skip exactly this set. The backfill script it replaced kept a second copy of
166
+ * the SVG half, which is how ICO came to be missing from one and not the
167
+ * other.
55
168
  */ export const MEDIA_TYPES_WITHOUT_VARIANTS = new Set([
56
169
  'image/svg+xml',
57
170
  'image/x-icon',
@@ -143,42 +256,110 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
143
256
  return text.slice(0, 300);
144
257
  }
145
258
  /**
146
- * Generate the WebP variants for one asset and report the outcome.
259
+ * Generate the delivery copies for one asset and report the outcome: its
260
+ * WebP variants, and with `display: true` its display copy (AGL-3486).
147
261
  *
148
262
  * `saveVariant` is a callback rather than a `Bucket` so this stays free of a
149
263
  * storage dependency and so a spec can assert on the BYTES it produces —
150
264
  * which is the only assertion that distinguishes a working variant from the
151
265
  * original served back under a `?w=` that selects nothing.
266
+ *
267
+ * ## A variant is never larger than what it replaces
268
+ *
269
+ * A width the asset has no variant for is answered with the display copy, or
270
+ * the original when there is none — so a variant is only worth storing when
271
+ * it is SMALLER than that answer. Measured on a customer's 1280px JPEGs before
272
+ * AGL-3486: the source-width WebP at `?w=1280` was 384 KB against a 305 KB
273
+ * original, so the variant made every retina visit 26% heavier. A width whose
274
+ * encode comes out no smaller is now skipped and the smaller bytes serve it.
275
+ *
276
+ * The display copy is made FIRST for that reason: it is what the comparison
277
+ * is against.
152
278
  */ export async function generateMediaVariants(options) {
153
279
  const widths = mediaVariantWidthsFor(options);
154
- if (!widths.length) return {
280
+ const wantsDisplay = options.display === true && MEDIA_DISPLAY_TYPES.has(options.contentType);
281
+ if (!widths.length && !wantsDisplay) return {
155
282
  variants: []
156
283
  };
157
284
  const variants = [];
285
+ let display;
158
286
  try {
159
287
  const sharp = await loadSharp();
288
+ let fallbackBytes = options.buffer.length;
289
+ if (wantsDisplay) {
290
+ const made = await encodeMediaDisplay(sharp, options.buffer, options.contentType);
291
+ if (made) {
292
+ await options.saveVariant(mediaDisplayObjectPath(options.objectPath), made.bytes, options.contentType);
293
+ display = made.copy;
294
+ fallbackBytes = made.bytes.length;
295
+ }
296
+ }
160
297
  for (const width of widths){
161
- const webp = await sharp(options.buffer).resize({
162
- width,
163
- withoutEnlargement: true
164
- }).webp({
165
- quality: 80
166
- }).toBuffer();
167
- await options.saveVariant(`${options.objectPath}__w${width}.webp`, webp);
298
+ const webp = await encodeMediaVariant(sharp, options.buffer, width);
299
+ if (webp.length >= fallbackBytes) continue;
300
+ await options.saveVariant(`${options.objectPath}__w${width}.webp`, webp, 'image/webp');
168
301
  variants.push(width);
169
302
  }
170
303
  } catch (error) {
171
304
  // Still never fails the upload — but no longer only whispers.
172
305
  console.error('media variant generation failed', options.objectPath, error);
173
- return {
174
- variants,
306
+ return _extends({
307
+ variants
308
+ }, display ? {
309
+ display
310
+ } : {}, {
175
311
  error: describe(error)
176
- };
312
+ });
177
313
  }
178
- return {
314
+ return _extends({
179
315
  variants
316
+ }, display ? {
317
+ display
318
+ } : {});
319
+ }
320
+ /**
321
+ * The media document fields a generation outcome writes (AGL-3486), shared by
322
+ * every route that generates so none of them can forget the encoder
323
+ * generation — the field lazy regeneration keys on.
324
+ *
325
+ * `display` is written as `null` rather than left out when there is none, so
326
+ * a replace whose new bytes need no display copy clears the previous one's
327
+ * record instead of pointing at an object it just deleted. The caller decides
328
+ * `variantsError`, because the routes record it with a counter beside it.
329
+ */ export function mediaVariantDocFields(outcome) {
330
+ var _outcome_display;
331
+ return {
332
+ variants: outcome.variants,
333
+ variantEncoderVersion: MEDIA_VARIANT_ENCODER_VERSION,
334
+ display: (_outcome_display = outcome.display) != null ? _outcome_display : null
180
335
  };
181
336
  }
337
+ /**
338
+ * Every derived object a media document names, given its original's path
339
+ * (AGL-3486): the `?w=` variants, the display copy, a video's poster and the
340
+ * poster's widths, and its renditions.
341
+ *
342
+ * The one list every path that moves, replaces or deletes an asset reads, so
343
+ * a new kind of derived object is added in one place. Before it, each of the
344
+ * three kept its own: a replace dropped all four kinds, while a folder move
345
+ * and a delete knew only the variants — so a moved film lost its poster and
346
+ * renditions to the old prefix, and a deleted one left them in the bucket.
347
+ */ export function mediaDerivedObjectPaths(objectPath, /** The media document's fields, or its snapshot. */ document) {
348
+ const read = (field)=>'get' in document && typeof document.get === 'function' ? document.get(field) : document[field];
349
+ const widths = (value)=>Array.isArray(value) ? value.filter((width)=>Number.isInteger(width) && width > 0) : [];
350
+ const poster = read('poster');
351
+ return [
352
+ ...widths(read('variants')).map((width)=>`${objectPath}__w${width}.webp`),
353
+ ...read('display') ? [
354
+ mediaDisplayObjectPath(objectPath)
355
+ ] : [],
356
+ ...poster ? [
357
+ mediaPosterObjectPath(objectPath)
358
+ ] : [],
359
+ ...widths(poster == null ? void 0 : poster.variants).map((width)=>`${objectPath}${MEDIA_POSTER_OBJECT_SUFFIX}__w${width}.webp`),
360
+ ...parseMediaRenditions(read('videoRenditions')).map((rendition)=>mediaRenditionObjectPath(objectPath, rendition))
361
+ ];
362
+ }
182
363
  /**
183
364
  * The largest object this will pull back OUT of storage in order to generate.
184
365
  *
@@ -224,7 +405,8 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
224
405
  */ export async function generateStoredMediaVariants(options) {
225
406
  var _options_maxSourceBytes;
226
407
  const widths = mediaVariantWidthsFor(options);
227
- if (!widths.length) return {
408
+ const wantsDisplay = options.display === true && MEDIA_DISPLAY_TYPES.has(options.contentType);
409
+ if (!widths.length && !wantsDisplay) return {
228
410
  variants: []
229
411
  };
230
412
  const maxSourceBytes = (_options_maxSourceBytes = options.maxSourceBytes) != null ? _options_maxSourceBytes : MEDIA_VARIANT_SOURCE_MAX_BYTES;
@@ -288,12 +470,7 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
288
470
  channels: 3
289
471
  }
290
472
  }).png().toBuffer();
291
- const resized = await sharp(source).resize({
292
- width: 320,
293
- withoutEnlargement: true
294
- }).webp({
295
- quality: 80
296
- }).toBuffer();
473
+ const resized = await encodeMediaVariant(sharp, source, 320);
297
474
  // `RIFF....WEBP` — a WebP, not the PNG handed back.
298
475
  const isWebp = resized.length > 12 && resized.toString('ascii', 0, 4) === 'RIFF' && resized.toString('ascii', 8, 12) === 'WEBP';
299
476
  if (!isWebp) return {
@@ -333,7 +510,7 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
333
510
  * the largest realistic player box is the 1920 the variant widths already
334
511
  * top out at.
335
512
  */ export const MEDIA_POSTER_MAX_WIDTH = 1920;
336
- /** WebP quality for the poster. Higher than a variant's 80 would not show. */ const POSTER_QUALITY = 72;
513
+ /** WebP quality for the poster — the variants' own since AGL-3486. */ const POSTER_QUALITY = MEDIA_VARIANT_WEBP_OPTIONS.quality;
337
514
  /**
338
515
  * Turn a client-supplied still into the asset's poster (AGL-2742).
339
516
  *
@@ -397,7 +574,7 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
397
574
  };
398
575
  }
399
576
  try {
400
- await options.saveVariant(posterPath, webp);
577
+ await options.saveVariant(posterPath, webp, 'image/webp');
401
578
  } catch (error) {
402
579
  console.error('media poster save failed', posterPath, error);
403
580
  return {
@@ -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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"}
@@ -15,13 +15,22 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  /**
18
- * How old a workspace is, in days, from whatever its `createdAt` turns out to
19
- * be.
18
+ * When a workspace was created, in epoch ms, from whatever its `createdAt`
19
+ * turns out to be — or `null` when it cannot be read.
20
20
  *
21
21
  * Firestore hands back a `Timestamp`, a restore or a fixture can leave a
22
- * number, and an org written before the field existed has nothing. All three
23
- * are handled and the last one answers `null`, which
24
- * `claimOrgEmailSendDay` reads as graduated — the only safe direction,
25
- * for the reason stated there.
22
+ * number, and an org written before the field existed has nothing. A
23
+ * `Timestamp` that has been through JSON — the render cache stores plain
24
+ * values, so the tenant's copy of an org arrives this way — is a bare
25
+ * `{ _seconds, _nanoseconds }` with no methods left, and is read like the
26
+ * live one rather than as missing (AGL-3452).
27
+ */
28
+ export declare function orgCreatedMs(createdAt: unknown): number | null;
29
+ /**
30
+ * How old a workspace is, in days, from whatever its `createdAt` turns out to
31
+ * be (see {@link orgCreatedMs}).
32
+ *
33
+ * An unreadable date answers `null`, which `claimOrgEmailSendDay` reads as
34
+ * graduated — the only safe direction, for the reason stated there.
26
35
  */
27
36
  export declare function orgAgeDays(createdAt: unknown, now?: number): number | null;
@@ -17,28 +17,40 @@
17
17
  // the real day arithmetic here.
18
18
  import { daysBetween } from "@aglyn/shared-util-email/sender-reputation";
19
19
  /**
20
- * How old a workspace is, in days, from whatever its `createdAt` turns out to
21
- * be.
20
+ * When a workspace was created, in epoch ms, from whatever its `createdAt`
21
+ * turns out to be — or `null` when it cannot be read.
22
22
  *
23
23
  * Firestore hands back a `Timestamp`, a restore or a fixture can leave a
24
- * number, and an org written before the field existed has nothing. All three
25
- * are handled and the last one answers `null`, which
26
- * `claimOrgEmailSendDay` reads as graduated — the only safe direction,
27
- * for the reason stated there.
28
- */ export function orgAgeDays(createdAt, now = Date.now()) {
24
+ * number, and an org written before the field existed has nothing. A
25
+ * `Timestamp` that has been through JSON — the render cache stores plain
26
+ * values, so the tenant's copy of an org arrives this way — is a bare
27
+ * `{ _seconds, _nanoseconds }` with no methods left, and is read like the
28
+ * live one rather than as missing (AGL-3452).
29
+ */ export function orgCreatedMs(createdAt) {
29
30
  const raw = createdAt;
30
31
  let createdMs = 0;
31
32
  if (typeof raw === 'number') createdMs = raw;
32
33
  else if (typeof raw === 'string') createdMs = Date.parse(raw);
33
34
  else if (typeof (raw == null ? void 0 : raw.toMillis) === 'function') createdMs = raw.toMillis();
34
35
  else if (typeof (raw == null ? void 0 : raw.seconds) === 'number') createdMs = raw.seconds * 1000;
36
+ else if (typeof (raw == null ? void 0 : raw._seconds) === 'number') createdMs = raw._seconds * 1000;
35
37
  /*
36
38
  * The one check, and it has to answer `null` rather than 0. A creation date
37
39
  * that could not be read is an org whose record predates the field, which
38
40
  * is an EXISTING customer; `0` would say "created today" and ramp every
39
41
  * paying tenant on the platform down to the first step.
40
42
  */ if (!Number.isFinite(createdMs) || createdMs <= 0) return null;
41
- return daysBetween(createdMs, now);
43
+ return createdMs;
44
+ }
45
+ /**
46
+ * How old a workspace is, in days, from whatever its `createdAt` turns out to
47
+ * be (see {@link orgCreatedMs}).
48
+ *
49
+ * An unreadable date answers `null`, which `claimOrgEmailSendDay` reads as
50
+ * graduated — the only safe direction, for the reason stated there.
51
+ */ export function orgAgeDays(createdAt, now = Date.now()) {
52
+ const createdMs = orgCreatedMs(createdAt);
53
+ return createdMs === null ? null : daysBetween(createdMs, now);
42
54
  }
43
55
 
44
56
  //# sourceMappingURL=org-age.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/org-age.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// The leaf, not the barrel: a spec that substitutes the barrel must still get\n// the real day arithmetic here.\nimport { daysBetween } from '@aglyn/shared-util-email/sender-reputation'\n\n/**\n * How old a workspace is, in days, from whatever its `createdAt` turns out to\n * be.\n *\n * Firestore hands back a `Timestamp`, a restore or a fixture can leave a\n * number, and an org written before the field existed has nothing. All three\n * are handled and the last one answers `null`, which\n * `claimOrgEmailSendDay` reads as graduated — the only safe direction,\n * for the reason stated there.\n */\nexport function orgAgeDays(\n createdAt: unknown,\n now: number = Date.now(),\n): number | null {\n const raw = createdAt as\n | { toMillis?: () => number; seconds?: number }\n | number\n | string\n | null\n | undefined\n let createdMs = 0\n if (typeof raw === 'number') createdMs = raw\n else if (typeof raw === 'string') createdMs = Date.parse(raw)\n else if (typeof raw?.toMillis === 'function') createdMs = raw.toMillis()\n else if (typeof raw?.seconds === 'number') createdMs = raw.seconds * 1000\n /*\n * The one check, and it has to answer `null` rather than 0. A creation date\n * that could not be read is an org whose record predates the field, which\n * is an EXISTING customer; `0` would say \"created today\" and ramp every\n * paying tenant on the platform down to the first step.\n */\n if (!Number.isFinite(createdMs) || createdMs <= 0) return null\n return daysBetween(createdMs, now)\n}\n"],"names":["daysBetween","orgAgeDays","createdAt","now","Date","raw","createdMs","parse","toMillis","seconds","Number","isFinite"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,gCAAgC;AAChC,SAASA,WAAW,QAAQ,6CAA4C;AAExE;;;;;;;;;CASC,GACD,OAAO,SAASC,WACdC,SAAkB,EAClBC,MAAcC,KAAKD,GAAG,EAAE;IAExB,MAAME,MAAMH;IAMZ,IAAII,YAAY;IAChB,IAAI,OAAOD,QAAQ,UAAUC,YAAYD;SACpC,IAAI,OAAOA,QAAQ,UAAUC,YAAYF,KAAKG,KAAK,CAACF;SACpD,IAAI,QAAOA,uBAAAA,IAAKG,QAAQ,MAAK,YAAYF,YAAYD,IAAIG,QAAQ;SACjE,IAAI,QAAOH,uBAAAA,IAAKI,OAAO,MAAK,UAAUH,YAAYD,IAAII,OAAO,GAAG;IACrE;;;;;GAKC,GACD,IAAI,CAACC,OAAOC,QAAQ,CAACL,cAAcA,aAAa,GAAG,OAAO;IAC1D,OAAON,YAAYM,WAAWH;AAChC"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/org-age.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// The leaf, not the barrel: a spec that substitutes the barrel must still get\n// the real day arithmetic here.\nimport { daysBetween } from '@aglyn/shared-util-email/sender-reputation'\n\n/**\n * When a workspace was created, in epoch ms, from whatever its `createdAt`\n * turns out to be — or `null` when it cannot be read.\n *\n * Firestore hands back a `Timestamp`, a restore or a fixture can leave a\n * number, and an org written before the field existed has nothing. A\n * `Timestamp` that has been through JSON — the render cache stores plain\n * values, so the tenant's copy of an org arrives this way — is a bare\n * `{ _seconds, _nanoseconds }` with no methods left, and is read like the\n * live one rather than as missing (AGL-3452).\n */\nexport function orgCreatedMs(createdAt: unknown): number | null {\n const raw = createdAt as\n | {\n toMillis?: () => number\n seconds?: number\n _seconds?: number\n }\n | number\n | string\n | null\n | undefined\n let createdMs = 0\n if (typeof raw === 'number') createdMs = raw\n else if (typeof raw === 'string') createdMs = Date.parse(raw)\n else if (typeof raw?.toMillis === 'function') createdMs = raw.toMillis()\n else if (typeof raw?.seconds === 'number') createdMs = raw.seconds * 1000\n else if (typeof raw?._seconds === 'number') createdMs = raw._seconds * 1000\n /*\n * The one check, and it has to answer `null` rather than 0. A creation date\n * that could not be read is an org whose record predates the field, which\n * is an EXISTING customer; `0` would say \"created today\" and ramp every\n * paying tenant on the platform down to the first step.\n */\n if (!Number.isFinite(createdMs) || createdMs <= 0) return null\n return createdMs\n}\n\n/**\n * How old a workspace is, in days, from whatever its `createdAt` turns out to\n * be (see {@link orgCreatedMs}).\n *\n * An unreadable date answers `null`, which `claimOrgEmailSendDay` reads as\n * graduated — the only safe direction, for the reason stated there.\n */\nexport function orgAgeDays(\n createdAt: unknown,\n now: number = Date.now(),\n): number | null {\n const createdMs = orgCreatedMs(createdAt)\n return createdMs === null ? null : daysBetween(createdMs, now)\n}\n"],"names":["daysBetween","orgCreatedMs","createdAt","raw","createdMs","Date","parse","toMillis","seconds","_seconds","Number","isFinite","orgAgeDays","now"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,gCAAgC;AAChC,SAASA,WAAW,QAAQ,6CAA4C;AAExE;;;;;;;;;;CAUC,GACD,OAAO,SAASC,aAAaC,SAAkB;IAC7C,MAAMC,MAAMD;IAUZ,IAAIE,YAAY;IAChB,IAAI,OAAOD,QAAQ,UAAUC,YAAYD;SACpC,IAAI,OAAOA,QAAQ,UAAUC,YAAYC,KAAKC,KAAK,CAACH;SACpD,IAAI,QAAOA,uBAAAA,IAAKI,QAAQ,MAAK,YAAYH,YAAYD,IAAII,QAAQ;SACjE,IAAI,QAAOJ,uBAAAA,IAAKK,OAAO,MAAK,UAAUJ,YAAYD,IAAIK,OAAO,GAAG;SAChE,IAAI,QAAOL,uBAAAA,IAAKM,QAAQ,MAAK,UAAUL,YAAYD,IAAIM,QAAQ,GAAG;IACvE;;;;;GAKC,GACD,IAAI,CAACC,OAAOC,QAAQ,CAACP,cAAcA,aAAa,GAAG,OAAO;IAC1D,OAAOA;AACT;AAEA;;;;;;CAMC,GACD,OAAO,SAASQ,WACdV,SAAkB,EAClBW,MAAcR,KAAKQ,GAAG,EAAE;IAExB,MAAMT,YAAYH,aAAaC;IAC/B,OAAOE,cAAc,OAAO,OAAOJ,YAAYI,WAAWS;AAC5D"}