@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) 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/ga4-measurement-protocol.js +41 -28
  19. package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
  20. package/src/lib/server/host-visitor-records.d.ts +25 -4
  21. package/src/lib/server/host-visitor-records.js +39 -11
  22. package/src/lib/server/host-visitor-records.js.map +1 -1
  23. package/src/lib/server/hosted-page-review.js +52 -36
  24. package/src/lib/server/hosted-page-review.js.map +1 -1
  25. package/src/lib/server/link-reputation-review.d.ts +6 -2
  26. package/src/lib/server/link-reputation-review.js +61 -20
  27. package/src/lib/server/link-reputation-review.js.map +1 -1
  28. package/src/lib/server/live-page-drops.d.ts +67 -0
  29. package/src/lib/server/live-page-drops.js +57 -0
  30. package/src/lib/server/live-page-drops.js.map +1 -0
  31. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  32. package/src/lib/server/media-bandwidth-cap.js +180 -0
  33. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  34. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  35. package/src/lib/server/media-cdn-icon.js +140 -0
  36. package/src/lib/server/media-cdn-icon.js.map +1 -0
  37. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  38. package/src/lib/server/media-delivery-regeneration.js +216 -0
  39. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  40. package/src/lib/server/media-delivery.d.ts +2 -0
  41. package/src/lib/server/media-delivery.js +2 -1
  42. package/src/lib/server/media-delivery.js.map +1 -1
  43. package/src/lib/server/media-serve-count.d.ts +50 -0
  44. package/src/lib/server/media-serve-count.js +62 -0
  45. package/src/lib/server/media-serve-count.js.map +1 -0
  46. package/src/lib/server/media-signing.d.ts +13 -2
  47. package/src/lib/server/media-signing.js +12 -8
  48. package/src/lib/server/media-signing.js.map +1 -1
  49. package/src/lib/server/media-tombstone.js +7 -5
  50. package/src/lib/server/media-tombstone.js.map +1 -1
  51. package/src/lib/server/media-variants.d.ts +151 -20
  52. package/src/lib/server/media-variants.js +204 -27
  53. package/src/lib/server/media-variants.js.map +1 -1
  54. package/src/lib/server/organizations.d.ts +102 -2
  55. package/src/lib/server/organizations.js +388 -58
  56. package/src/lib/server/organizations.js.map +1 -1
  57. package/src/lib/server/outbound-send-review.js +11 -7
  58. package/src/lib/server/outbound-send-review.js.map +1 -1
  59. package/src/lib/server/paged-sweeps.d.ts +51 -0
  60. package/src/lib/server/paged-sweeps.js +79 -0
  61. package/src/lib/server/paged-sweeps.js.map +1 -0
  62. package/src/lib/server/payment-fraud-signal.d.ts +49 -9
  63. package/src/lib/server/payment-fraud-signal.js +192 -48
  64. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  65. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  66. package/src/lib/server/platform-marketing-consent.js +27 -11
  67. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  68. package/src/lib/server/render-monitor.d.ts +166 -0
  69. package/src/lib/server/render-monitor.js +412 -0
  70. package/src/lib/server/render-monitor.js.map +1 -0
  71. package/src/lib/server/risk-notice.d.ts +7 -0
  72. package/src/lib/server/risk-notice.js +6 -0
  73. package/src/lib/server/risk-notice.js.map +1 -1
  74. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  75. package/src/lib/server/serve-media-cdn.js +424 -70
  76. package/src/lib/server/serve-media-cdn.js.map +1 -1
  77. package/src/lib/server/site-icon-render.d.ts +68 -0
  78. package/src/lib/server/site-icon-render.js +157 -0
  79. package/src/lib/server/site-icon-render.js.map +1 -0
  80. package/src/lib/server/staff-seat.d.ts +50 -0
  81. package/src/lib/server/staff-seat.js +118 -0
  82. package/src/lib/server/staff-seat.js.map +1 -0
  83. package/src/lib/server/transfer-export.d.ts +46 -0
  84. package/src/lib/server/transfer-export.js +221 -0
  85. package/src/lib/server/transfer-export.js.map +1 -0
  86. package/src/lib/server/transfer-jobs.d.ts +385 -0
  87. package/src/lib/server/transfer-jobs.js +2368 -0
  88. package/src/lib/server/transfer-jobs.js.map +1 -0
  89. package/src/lib/server/transfer-packages.d.ts +127 -0
  90. package/src/lib/server/transfer-packages.js +1036 -0
  91. package/src/lib/server/transfer-packages.js.map +1 -0
  92. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  93. package/src/lib/server/upgrade-proposal.js +312 -0
  94. package/src/lib/server/upgrade-proposal.js.map +1 -0
  95. package/src/lib/server/upsert-contact.d.ts +17 -8
  96. package/src/lib/server/upsert-contact.js +82 -16
  97. package/src/lib/server/upsert-contact.js.map +1 -1
  98. package/src/lib/server/web-risk.d.ts +50 -9
  99. package/src/lib/server/web-risk.js +290 -155
  100. package/src/lib/server/web-risk.js.map +1 -1
  101. package/src/tenancy.d.ts +3 -3
  102. package/src/tenancy.js +5 -3
  103. package/src/tenancy.js.map +1 -1
  104. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  105. package/src/lib/server/campaign-attribution-store.js +0 -95
  106. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  107. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  108. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  109. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  110. package/src/lib/server/crm-email-activity.d.ts +0 -118
  111. package/src/lib/server/crm-email-activity.js +0 -148
  112. package/src/lib/server/crm-email-activity.js.map +0 -1
  113. package/src/lib/server/crm-next-activity.d.ts +0 -74
  114. package/src/lib/server/crm-next-activity.js +0 -194
  115. package/src/lib/server/crm-next-activity.js.map +0 -1
  116. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  117. package/src/lib/server/dataset-live-pages.js +0 -421
  118. package/src/lib/server/dataset-live-pages.js.map +0 -1
  119. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  120. package/src/lib/server/email-revenue-attribution.js +0 -321
  121. package/src/lib/server/email-revenue-attribution.js.map +0 -1
@@ -15,18 +15,23 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { pipeline } from "node:stream/promises";
18
- import { isLockdownActive, MEDIA_CDN_POSTER_PARAM, MEDIA_CDN_RENDITION_AUTO, MEDIA_CDN_RENDITION_PARAM, MEDIA_CDN_ROUTE, MEDIA_POSTER_OBJECT_SUFFIX, isSecurityClassLockdownReason, mediaPosterObjectPath, mediaRenditionObjectPath, normalizeHostLockdown, normalizeOrgLockdown, parseMediaRenditions } from "@aglyn/aglyn/server";
18
+ import { BANDWIDTH_CAP_RETRY_AFTER_SECONDS, bandwidthCapApplies, bandwidthCapEngaged, bandwidthCeilingDegradesHost, bandwidthCeilingMonthKey, isLockdownActive, MEDIA_BANDWIDTH_DAY_FIELD, MEDIA_CDN_POSTER_PARAM, MEDIA_CDN_RENDITION_AUTO, MEDIA_CDN_RENDITION_PARAM, MEDIA_CDN_ROUTE, MEDIA_CDN_VERSION_PARAM, MEDIA_POSTER_OBJECT_SUFFIX, MEDIA_VARIANT_ENCODER_VERSION, MEDIA_VARIANT_ENCODER_VERSION_FIELD, isSecurityClassLockdownReason, mediaDisplayObjectPath, mediaPosterObjectPath, mediaVariantEncoderVersionOf, mediaRenditionObjectPath, normalizeHostLockdown, normalizeOrgLockdown, parseMediaCdnVersionToken, parseMediaRenditions } from "@aglyn/aglyn/server";
19
19
  // By path, and out of every barrel: see the module note in `media-cdn-scope`.
20
20
  import { MEDIA_CDN_SEGMENT, mediaCdnScopeRefusal, parseMediaCdnScope } from "@aglyn/aglyn/app-utils/media-cdn-scope";
21
21
  import { mediaDeliveryProvider } from "@aglyn/aglyn/plugin-manager/media-delivery-provider";
22
+ import { parseSiteIconSpec, SITE_ICON_BACKGROUND_PARAM, SITE_ICON_PARAM, SITE_ICON_VERSION_PARAM } from "@aglyn/aglyn/app-utils/site-icon-set";
22
23
  import { analyticsDayExpiresAt } from "./analytics-retention.js";
23
24
  import { firebaseAdmin } from "./firebase-admin.js";
25
+ import { recordMediaServe } from "./media-serve-count.js";
24
26
  import { getPlatformLockdown } from "./lockdown.js";
27
+ import { engageMediaBandwidthCap, mediaBandwidthEvaluationDue } from "./media-bandwidth-cap.js";
25
28
  import { mediaCdnRateLimitRefusal } from "./media-cdn-rate-limit.js";
26
29
  import { mediaDeliveryOrgIdFor, mediaDeliveryRedirect } from "./media-delivery.js";
30
+ import { mediaDeliveryCopiesStale, regenerateMediaDeliveryCopies } from "./media-delivery-regeneration.js";
27
31
  import { getMediaQuarantine } from "./media-quarantine.js";
28
32
  import { verifyMediaAccess } from "./media-signing.js";
29
33
  import { mediaStoragePathInScope } from "./media-storage-path.js";
34
+ import { serveMediaCdnIcon } from "./media-cdn-icon.js";
30
35
  /**
31
36
  * Variant widths generated at upload (AGL-175).
32
37
  *
@@ -67,13 +72,19 @@ const SEGMENT = MEDIA_CDN_SEGMENT;
67
72
  // redirect must name the SAME representation the caller asked for, or a
68
73
  // stale content pin on a poster URL lands the browser on the master video
69
74
  // (AGL-2743).
75
+ // `icon`, `bg` and `v` for the same reason: a site icon (AGL-3484) behind a
76
+ // stale content pin must stay the icon, not become the full-size original.
70
77
  for (const key of [
71
78
  'w',
72
79
  'poster',
73
80
  'r',
74
81
  'download',
82
+ SITE_ICON_PARAM,
83
+ SITE_ICON_BACKGROUND_PARAM,
84
+ SITE_ICON_VERSION_PARAM,
75
85
  'exp',
76
- 'sig'
86
+ 'sig',
87
+ 'aud'
77
88
  ]){
78
89
  const raw = query[key];
79
90
  const value = Array.isArray(raw) ? raw[0] : raw;
@@ -141,6 +152,59 @@ const SEGMENT = MEDIA_CDN_SEGMENT;
141
152
  * the cap and was never edge-cached — the mangling reproduced on a 186 KB
142
153
  * asset precisely because small ones are the ones that get cached.
143
154
  */ export const MEDIA_CDN_STABLE_EDGE_BYPASS_CACHE_CONTROL = 'private, max-age=60';
155
+ /**
156
+ * The VERSIONED stable URL's policy (AGL-3485): `?v={contentHash}.{encoder}`
157
+ * on the stable path, answered while the token names the asset's current
158
+ * bytes and current variants.
159
+ *
160
+ * The browser keeps it for a year, `immutable`, which is the whole point: the
161
+ * 60-second window above made every repeat view of every published image a
162
+ * conditional request, and Lighthouse reports that policy against each one.
163
+ * It is safe for the reason the content-hashed form never was in a page
164
+ * (AGL-2798): the page names this URL only while the token is current, the
165
+ * composition reads the token off the media document on every render, and a
166
+ * replace or a regeneration therefore moves the page to a new URL instead of
167
+ * leaving it on a pinned copy of the old bytes.
168
+ *
169
+ * The EDGE keeps the stable URL's hour and its revalidation, deliberately not
170
+ * a year: the edge is the copy a takedown or a lockdown has to outrun for
171
+ * every new visitor (`media-takedown-reach.ts`), and an hour is the bound the
172
+ * product states for it. Other visitors still get the edge's copy within that
173
+ * hour, which is what `s-maxage` is for. Images only — every other type keeps
174
+ * the stable policy, for the AGL-1515 reasons above.
175
+ */ export const MEDIA_CDN_VERSIONED_CACHE_CONTROL = 'public, max-age=31536000, s-maxage=3600, stale-while-revalidate=86400, immutable';
176
+ /**
177
+ * Whether a versioned request may be answered with
178
+ * {@link MEDIA_CDN_VERSIONED_CACHE_CONTROL} (AGL-3485).
179
+ *
180
+ * Only when everything the token promises is true of what is being served:
181
+ *
182
+ * - the token's content hash is the document's CURRENT one. A stale token is
183
+ * served the current bytes under the stable URL's short policy — a stale
184
+ * page shows the new picture, and nothing pins it;
185
+ * - the representation is the original or a `?w=` variant. A poster or a
186
+ * rendition keeps its own policy;
187
+ * - for a `?w=` request, the variant itself is what is served. A width the
188
+ * asset has no variant for falls back to another representation, and a
189
+ * fallback is not something to keep for a year: the variant may be
190
+ * generated later;
191
+ * - and the asset's delivery copies were made by the encoder generation the
192
+ * token names, which is the one this code runs. The media document records
193
+ * which generation made them (`MEDIA_VARIANT_ENCODER_VERSION_FIELD`, absent
194
+ * meaning the first). A page rendered after the encoder moved on names the
195
+ * new generation before the asset has been regenerated; answering that
196
+ * request with the old encode for a year would hide the regeneration behind
197
+ * the very URL that was minted to reach it. Asked of every request, not
198
+ * only `?w=`, because the encoder makes what the bare URL serves too.
199
+ */ export function mediaCdnVersionIsCurrent(options) {
200
+ const { token, currentHash } = options;
201
+ if (!token || !currentHash || token.contentHash !== currentHash) return false;
202
+ if (options.otherRepresentation) return false;
203
+ if (options.widthRequested && !options.variantServed) return false;
204
+ const recorded = options.documentEncoderVersion;
205
+ const generation = typeof recorded === 'number' && Number.isInteger(recorded) && recorded > 0 ? recorded : 1;
206
+ return token.encoderVersion === MEDIA_VARIANT_ENCODER_VERSION && generation === token.encoderVersion;
207
+ }
144
208
  /** The immutable content-hashed URL's policy for edge-cacheable (image) types. */ export const MEDIA_CDN_IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
145
209
  /**
146
210
  * The immutable URL's policy for edge-bypassing types (AGL-1515): the
@@ -308,16 +372,22 @@ const SEGMENT = MEDIA_CDN_SEGMENT;
308
372
  *
309
373
  * That lookup is one BatchGetDocuments at org scope, and two at host scope
310
374
  * — `hosts` and `hostIndex` batched together, then the owning org, whose id
311
- * is what `hostIndex` returns and so cannot join the batch. Every one of
312
- * them is projected to {@link SUSPENSION_FIELDS}: the verdict needs three
313
- * fields, and the host document is the largest in the product. Measured
314
- * against production, projecting the three reads and batching two of them
315
- * took the host branch from 3 round trips and 2,964 B to 2 and 34 B.
375
+ * is what `hostIndex` returns and so cannot join the batch. The host and
376
+ * index reads are projected to {@link SUSPENSION_FIELDS} plus the two fields
377
+ * the bandwidth verdict needs off them: the host document is the largest in
378
+ * the product. Measured against production, projecting the three reads and
379
+ * batching two of them took the host branch from 3 round trips and 2,964 B to
380
+ * 2 and 34 B. The ORG read is whole since AGL-3474, because the bandwidth
381
+ * verdict asks the org's plan, and the plan is resolved from more fields than
382
+ * a projection could promise to keep in step with — a mask missing one would
383
+ * read a paying org as Free and pause its video. Still one read per scope
384
+ * per TTL.
316
385
  *
317
386
  * **Staleness bound, stated rather than hidden:** a warm origin refuses
318
387
  * within ≤15s of the org-doc write (the platform panic number). What the
319
388
  * origin cannot reach: browsers hold the stable URL up to 60s
320
- * (`max-age=60`); Vercel's edge holds image responses up to `s-maxage=3600`
389
+ * (`max-age=60`), and a versioned image URL a published page names for a
390
+ * year in a browser that already fetched it (AGL-3485); Vercel's edge holds image responses up to `s-maxage=3600`
321
391
  * (+ one stale serve while revalidating) — so an already-edge-cached image
322
392
  * URL can serve up to ~1h into a lock; non-image types are `private`
323
393
  * (AGL-1515) and never edge-held. The immutable content-hashed form is
@@ -338,6 +408,12 @@ const SEGMENT = MEDIA_CDN_SEGMENT;
338
408
  * stops only new origin fetches (AGL-1615). Bytes already cached anywhere
339
409
  * remain out of reach of both.
340
410
  */ const MEDIA_CDN_LOCK_TTL_MS = 15000;
411
+ /** The verdict when nothing could be read: serve, the lockdown core's posture. */ const OPEN_SCOPE_VERDICT = {
412
+ locked: false,
413
+ bandwidthPaused: false,
414
+ capApplies: false,
415
+ orgId: null
416
+ };
341
417
  const lockCache = new Map();
342
418
  const lockPending = new Map();
343
419
  /**
@@ -369,38 +445,65 @@ const lockPending = new Map();
369
445
  'suspendedUntilMs'
370
446
  ];
371
447
  /** Which host owns the asset's scope — the only field read off `hostIndex`. */ const HOST_INDEX_ORG_FIELD = 'orgId';
448
+ /** The site's abuse-ceiling stamp (AGL-2155), read beside its lockdown. */ const HOST_BANDWIDTH_CEILING_FIELD = 'bandwidthCeiling';
372
449
  /** The `suspended*` field family off a snapshot, for the normalizers. */ const suspensionCarrier = (snapshot)=>Object.fromEntries(SUSPENSION_FIELDS.map((field)=>[
373
450
  field,
374
451
  snapshot.get(field)
375
452
  ]));
376
- /** TTL-cached: does any lockdown covering `scope` stop delivery? */ async function mediaCdnScopeLocked(scope) {
453
+ /**
454
+ * The verdict an org document gives: its lockdown and, when it is not locked,
455
+ * whether its bandwidth stops video and files. `host` is the site's projected
456
+ * document for a host-library scope, whose abuse ceiling (AGL-2155) pauses the
457
+ * site's own media as it pauses the site's pages; an org library belongs to
458
+ * no site, so only the org-wide cap reaches it.
459
+ */ function orgScopeVerdict(org, orgId, host, nowMs) {
460
+ var _org_data;
461
+ if (lockdownStopsMediaDelivery(normalizeOrgLockdown(suspensionCarrier(org)), nowMs)) {
462
+ return _extends({}, OPEN_SCOPE_VERDICT, {
463
+ locked: true,
464
+ orgId
465
+ });
466
+ }
467
+ const billing = (_org_data = org.data()) != null ? _org_data : null;
468
+ const now = new Date(nowMs);
469
+ return {
470
+ locked: false,
471
+ bandwidthPaused: bandwidthCapEngaged(billing, now) || host !== null && bandwidthCeilingDegradesHost({
472
+ [HOST_BANDWIDTH_CEILING_FIELD]: host.get(HOST_BANDWIDTH_CEILING_FIELD)
473
+ }, bandwidthCeilingMonthKey(now), billing),
474
+ // A missing org document resolves as Free, but there is no org to total
475
+ // or to stamp, so nothing is evaluated for it.
476
+ capApplies: billing !== null && bandwidthCapApplies(billing),
477
+ orgId
478
+ };
479
+ }
480
+ /** TTL-cached: what the documents covering `scope` say about serving it. */ async function mediaCdnScopeVerdict(scope) {
377
481
  const key = `${scope.isOrg ? 'org' : 'host'}:${scope.scopeId}`;
378
482
  const cached = lockCache.get(key);
379
483
  if (cached && Date.now() - cached.at < MEDIA_CDN_LOCK_TTL_MS) {
380
- return cached.blocked;
484
+ return cached.verdict;
381
485
  }
382
486
  let pending = lockPending.get(key);
383
487
  if (!pending) {
384
488
  pending = (async ()=>{
385
- let blocked;
489
+ let verdict = OPEN_SCOPE_VERDICT;
386
490
  try {
387
491
  const nowMs = Date.now();
388
492
  // Platform first: cached, and a platform security lock is the panic
389
493
  // button — asset delivery is part of what it stops.
390
- blocked = lockdownStopsMediaDelivery(await getPlatformLockdown(), nowMs);
391
- const firestore = firebaseAdmin.app().firestore();
392
- if (!blocked && scope.isOrg) {
494
+ if (lockdownStopsMediaDelivery(await getPlatformLockdown(), nowMs)) {
495
+ verdict = _extends({}, OPEN_SCOPE_VERDICT, {
496
+ locked: true
497
+ });
498
+ } else if (scope.isOrg) {
499
+ const firestore = firebaseAdmin.app().firestore();
393
500
  // Org forms (`org:{orgId}` and `org:{orgId}:{hostId}`): the org
394
501
  // doc governs. The context host's own lock is not consulted — a
395
502
  // suspended HOST's pages 503 already, and which sites may USE an
396
503
  // org asset is `visibleTo`'s question, not the lock's.
397
- const [org] = await firestore.getAll(firestore.collection('orgs').doc(scope.scopeId), {
398
- fieldMask: [
399
- ...SUSPENSION_FIELDS
400
- ]
401
- });
402
- blocked = lockdownStopsMediaDelivery(normalizeOrgLockdown(suspensionCarrier(org)), nowMs);
403
- } else if (!blocked) {
504
+ const [org] = await firestore.getAll(firestore.collection('orgs').doc(scope.scopeId));
505
+ verdict = orgScopeVerdict(org, scope.scopeId, null, nowMs);
506
+ } else {
404
507
  // Host-library form: the host's own lock, and the OWNING org's —
405
508
  // an org lock never stamps host docs (AGL-1506), so a host-only
406
509
  // read would silently miss the very lock this issue is about.
@@ -410,33 +513,35 @@ const lockPending = new Map();
410
513
  // BatchGetDocuments round trips where the batch is one. The org
411
514
  // read below cannot join them — its id is what `hostIndex`
412
515
  // returns — so two is the floor for this branch, not three.
516
+ const firestore = firebaseAdmin.app().firestore();
413
517
  const [host, hostIndex] = await firestore.getAll(firestore.collection('hosts').doc(scope.scopeId), firestore.collection('hostIndex').doc(scope.scopeId), {
414
518
  fieldMask: [
415
519
  ...SUSPENSION_FIELDS,
416
- HOST_INDEX_ORG_FIELD
520
+ HOST_INDEX_ORG_FIELD,
521
+ HOST_BANDWIDTH_CEILING_FIELD
417
522
  ]
418
523
  });
419
- blocked = lockdownStopsMediaDelivery(normalizeHostLockdown(suspensionCarrier(host)), nowMs);
420
524
  const orgId = hostIndex.get(HOST_INDEX_ORG_FIELD);
421
- if (!blocked && typeof orgId === 'string' && orgId) {
422
- const [org] = await firestore.getAll(firestore.collection('orgs').doc(orgId), {
423
- fieldMask: [
424
- ...SUSPENSION_FIELDS
425
- ]
525
+ if (lockdownStopsMediaDelivery(normalizeHostLockdown(suspensionCarrier(host)), nowMs)) {
526
+ verdict = _extends({}, OPEN_SCOPE_VERDICT, {
527
+ locked: true
426
528
  });
427
- blocked = lockdownStopsMediaDelivery(normalizeOrgLockdown(suspensionCarrier(org)), nowMs);
529
+ } else if (typeof orgId === 'string' && orgId) {
530
+ const [org] = await firestore.getAll(firestore.collection('orgs').doc(orgId));
531
+ verdict = orgScopeVerdict(org, orgId, host, nowMs);
428
532
  }
429
533
  }
430
534
  } catch (unused) {
431
535
  // Fail open — the lockdown core's posture (see lockdown.ts): an
432
- // unreachable Firestore is an outage, not a lockdown.
433
- blocked = false;
536
+ // unreachable Firestore is an outage, not a lockdown, and it is not a
537
+ // spent band either.
538
+ verdict = OPEN_SCOPE_VERDICT;
434
539
  }
435
540
  lockCache.set(key, {
436
541
  at: Date.now(),
437
- blocked
542
+ verdict
438
543
  });
439
- return blocked;
544
+ return verdict;
440
545
  })().finally(()=>{
441
546
  lockPending.delete(key);
442
547
  });
@@ -444,6 +549,9 @@ const lockPending = new Map();
444
549
  }
445
550
  return pending;
446
551
  }
552
+ /** TTL-cached: does any lockdown covering `scope` stop delivery? */ async function mediaCdnScopeLocked(scope) {
553
+ return (await mediaCdnScopeVerdict(scope)).locked;
554
+ }
447
555
  /**
448
556
  * THE delivery-policy seam: "may this asset be served at all?" — one
449
557
  * question, one function, consulted once per request before any caching
@@ -493,6 +601,39 @@ const lockPending = new Map();
493
601
  const normalized = String(raw != null ? raw : '').toLowerCase();
494
602
  return normalized === '1' || normalized === 'true';
495
603
  }
604
+ /**
605
+ * The display copy a media document records (AGL-3486), or null when it has
606
+ * none. Only the type is needed to serve it — the object path is derived — and
607
+ * a record whose type is not an image is treated as no record: the display
608
+ * copy is always an image, and serving its object under another type would be
609
+ * a document field choosing a response's `Content-Type`.
610
+ */ export function mediaCdnDisplayCopy(value) {
611
+ if (!value || typeof value !== 'object') return null;
612
+ const contentType = value.contentType;
613
+ if (typeof contentType !== 'string' || !/^image\/[a-z0-9.+-]+$/.test(contentType)) {
614
+ return null;
615
+ }
616
+ return {
617
+ contentType
618
+ };
619
+ }
620
+ /**
621
+ * Runs `task` once the response has been sent, through Next's `after()`.
622
+ * Required when first asked for rather than imported, for the reason
623
+ * `capture-email-check.ts` gives; false where there is no request to run
624
+ * after — a spec, a script — and the task is then not run at all.
625
+ */ function scheduleAfterResponse(task) {
626
+ try {
627
+ const loaded = require('next/server');
628
+ if (typeof (loaded == null ? void 0 : loaded.after) !== 'function') return false;
629
+ loaded.after(()=>task().catch((error)=>{
630
+ console.error('[media-cdn] after-response task failed', error);
631
+ }));
632
+ return true;
633
+ } catch (unused) {
634
+ return false;
635
+ }
636
+ }
496
637
  /**
497
638
  * Whether `?poster=1` was asked for (AGL-2743).
498
639
  *
@@ -702,6 +843,20 @@ export function parseMediaCdnRange(header, size) {
702
843
  }
703
844
  res.setHeader('Cache-Control', 'no-store');
704
845
  }
846
+ /**
847
+ * Note `bytes` of counted media against the scope's org on this instance, and
848
+ * when that makes it due, total the org's month and engage the Free cap
849
+ * (AGL-3474; see `media-bandwidth-cap.ts`).
850
+ *
851
+ * Only for an org whose plan the cap stops and that is not already paused,
852
+ * both read off the cached scope verdict, so a paying org's delivery reads
853
+ * nothing for it. Never rejects.
854
+ */ async function mediaCdnBandwidthEvaluation(scope, bytes) {
855
+ const verdict = await mediaCdnScopeVerdict(scope);
856
+ if (!verdict.capApplies || verdict.bandwidthPaused || !verdict.orgId) return;
857
+ if (!mediaBandwidthEvaluationDue(verdict.orgId, bytes)) return;
858
+ await engageMediaBandwidthCap(verdict.orgId);
859
+ }
705
860
  /**
706
861
  * CDN media delivery (AGL-175 / AGL-829). Two URL shapes resolve the same
707
862
  * asset by `mediaId`, so delivery never depends on the object's storage
@@ -905,7 +1060,8 @@ export function parseMediaCdnRange(header, size) {
905
1060
  var _req_query_exp, _req_query_sig;
906
1061
  const signed = verifyMediaAccess(scopeSegment, mediaId, {
907
1062
  exp: Number((_req_query_exp = req.query['exp']) != null ? _req_query_exp : 0),
908
- sig: String((_req_query_sig = req.query['sig']) != null ? _req_query_sig : '')
1063
+ sig: String((_req_query_sig = req.query['sig']) != null ? _req_query_sig : ''),
1064
+ aud: req.query['aud'] === 'team' ? 'team' : undefined
909
1065
  });
910
1066
  setCacheControl('private, no-store');
911
1067
  if (!signed) {
@@ -915,6 +1071,19 @@ export function parseMediaCdnRange(header, size) {
915
1071
  return;
916
1072
  }
917
1073
  }
1074
+ /*
1075
+ * WHAT COUNTS TOWARD THE BANDWIDTH BAND (AGL-3474).
1076
+ *
1077
+ * Every type the edge never holds, because each such response leaves from
1078
+ * origin. A public image is the exception: it is inside the page weight
1079
+ * the band already measures, and the edge serves most of it. A PRIVATE
1080
+ * image is not — it cannot be placed on a page, and `no-store` sends every
1081
+ * request to origin — so it counts like a file. The one serve that never
1082
+ * counts is the workspace previewing its own private library in the
1083
+ * console: its signature names the team (`aud=team`, inside the HMAC, so
1084
+ * a visitor's link cannot claim it).
1085
+ */ const teamPreview = isPrivate && req.query['aud'] === 'team';
1086
+ const countsTowardBand = (type)=>!teamPreview && (isPrivate || !mediaCdnEdgeCacheable(type));
918
1087
  /**
919
1088
  * A stale hash on the immutable form REDIRECTS to the stable URL
920
1089
  * (AGL-2685). It used to 404.
@@ -953,6 +1122,44 @@ export function parseMediaCdnRange(header, size) {
953
1122
  res.status(302).end();
954
1123
  return;
955
1124
  }
1125
+ /*
1126
+ * A site icon (AGL-3484): one size of the favicon, touch icon or manifest
1127
+ * set, drawn from this asset's bytes. Past every gate above and ahead of
1128
+ * every other representation, which it excludes — see `media-cdn-icon.ts`.
1129
+ */ const iconSpec = parseSiteIconSpec(req.query[SITE_ICON_PARAM]);
1130
+ if (iconSpec) {
1131
+ const iconBase = `${isOrg ? 'orgs' : 'hosts'}/${scopeId}`;
1132
+ const iconFile = firebaseAdmin.app().storage().bucket(process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']).file(mediaStoragePathInScope({
1133
+ storagePath: snapshot.get('storagePath'),
1134
+ base: iconBase,
1135
+ mediaId
1136
+ }));
1137
+ await serveMediaCdnIcon({
1138
+ req,
1139
+ res,
1140
+ spec: iconSpec,
1141
+ source: {
1142
+ contentType: snapshot.get('contentType'),
1143
+ contentHash: currentHash,
1144
+ stablePath: `${MEDIA_CDN_ROUTE}/${scopeSegment}/${mediaId}`,
1145
+ file: iconFile
1146
+ },
1147
+ setCacheControl,
1148
+ cacheControl: {
1149
+ stable: MEDIA_CDN_STABLE_CACHE_CONTROL,
1150
+ immutable: MEDIA_CDN_IMMUTABLE_CACHE_CONTROL
1151
+ },
1152
+ rateLimit: ()=>{
1153
+ var _req_socket;
1154
+ return mediaCdnRateLimitRefusal({
1155
+ headers: req.headers,
1156
+ remoteAddress: (_req_socket = req.socket) == null ? void 0 : _req_socket.remoteAddress,
1157
+ rateClass: 'image'
1158
+ });
1159
+ }
1160
+ });
1161
+ return;
1162
+ }
956
1163
  const width = Number((_req_query_w = req.query['w']) != null ? _req_query_w : 0);
957
1164
  const variants = (_snapshot_get2 = snapshot.get('variants')) != null ? _snapshot_get2 : [];
958
1165
  /*
@@ -1031,6 +1238,31 @@ export function parseMediaCdnRange(header, size) {
1031
1238
  res.setHeader('Vary', 'Accept');
1032
1239
  }
1033
1240
  const useVariant = !usePoster && !rendition && Boolean(width) && variants.includes(width);
1241
+ // Read only here, past every gate — a refusal above returns before the
1242
+ // parameter is ever looked at, so `?download=1` can never be the reason
1243
+ // a response happens (AGL-1411).
1244
+ const download = wantsMediaDownload(req.query['download']);
1245
+ /*
1246
+ * THE DISPLAY COPY (AGL-3486).
1247
+ *
1248
+ * An image whose original is larger than the delivery edge, carries EXIF,
1249
+ * XMP or IPTC, or leans on an EXIF orientation has a display copy: the
1250
+ * same format, downscaled, upright and stripped. It answers every INLINE
1251
+ * request that is not a variant — the bare URL, and a `?w=` the asset has
1252
+ * no variant for, which used to fall back to the original. That is the
1253
+ * request a link preview, an email client and a CSS background make, and
1254
+ * it used to hand them a 6 MB photo with the GPS position it was taken at.
1255
+ *
1256
+ * `?download=1` still serves the ORIGINAL, byte for byte: Download file is
1257
+ * a promise the library makes, and it is the one request that asks for the
1258
+ * file rather than for a picture of it.
1259
+ */ const display = mediaCdnDisplayCopy(snapshot.get('display'));
1260
+ const useDisplay = !usePoster && !rendition && !useVariant && !download && display !== null;
1261
+ // The generation that made the variant or display copy, in its validator:
1262
+ // a regeneration changes the bytes behind the same URL, and a browser
1263
+ // revalidating the old encode must be sent the new one, not a 304.
1264
+ const encoderVersion = mediaVariantEncoderVersionOf(snapshot.get(MEDIA_VARIANT_ENCODER_VERSION_FIELD));
1265
+ const encoderTag = encoderVersion > 1 ? `-e${encoderVersion}` : '';
1034
1266
  /**
1035
1267
  * The ETag's representation tag, and the cache key's conscience.
1036
1268
  *
@@ -1040,11 +1272,7 @@ export function parseMediaCdnRange(header, size) {
1040
1272
  * revalidates. Between an image variant and its original that is a wrong
1041
1273
  * size; between a poster and its video it is a 40 KB still standing in
1042
1274
  * for a 40 MB film, or the reverse.
1043
- */ const representation = usePoster ? `-poster${usePosterVariant ? `-w${width}` : ''}` : rendition ? `-r${rendition.key}` : useVariant ? `-w${width}` : '';
1044
- // Read only here, past every gate — a refusal above returns before the
1045
- // parameter is ever looked at, so `?download=1` can never be the reason
1046
- // a response happens (AGL-1411).
1047
- const download = wantsMediaDownload(req.query['download']);
1275
+ */ const representation = usePoster ? `-poster${usePosterVariant ? `-w${width}` : ''}` : rendition ? `-r${rendition.key}` : useVariant ? `-w${width}${encoderTag}` : useDisplay ? `-d${encoderTag}` : '';
1048
1276
  // Stable URL: revalidate against an ETag so a replaced asset is picked
1049
1277
  // up (a conditional GET returns 304 while the content is unchanged).
1050
1278
  //
@@ -1061,7 +1289,20 @@ export function parseMediaCdnRange(header, size) {
1061
1289
  // type (a variant serve is always `image/webp`) because the 304 exit
1062
1290
  // below runs before the Storage metadata read; re-derived from the
1063
1291
  // authoritative served type once metadata is in hand.
1064
- const stableCacheControlFor = (type)=>mediaCdnEdgeCacheable(type) ? MEDIA_CDN_STABLE_CACHE_CONTROL : MEDIA_CDN_STABLE_EDGE_BYPASS_CACHE_CONTROL;
1292
+ //
1293
+ // A VERSIONED request (AGL-3485) whose token is current is the one image
1294
+ // response a browser may keep for a year; see
1295
+ // `MEDIA_CDN_VERSIONED_CACHE_CONTROL`. The hashed path form keeps its own
1296
+ // policy below, so a `?v=` on it is inert.
1297
+ const versionCurrent = !hashed && mediaCdnVersionIsCurrent({
1298
+ token: parseMediaCdnVersionToken(req.query[MEDIA_CDN_VERSION_PARAM]),
1299
+ currentHash,
1300
+ widthRequested: Boolean(width),
1301
+ variantServed: useVariant,
1302
+ otherRepresentation: usePoster || Boolean(rendition),
1303
+ documentEncoderVersion: snapshot.get(MEDIA_VARIANT_ENCODER_VERSION_FIELD)
1304
+ });
1305
+ const stableCacheControlFor = (type)=>mediaCdnEdgeCacheable(type) ? versionCurrent ? MEDIA_CDN_VERSIONED_CACHE_CONTROL : MEDIA_CDN_STABLE_CACHE_CONTROL : MEDIA_CDN_STABLE_EDGE_BYPASS_CACHE_CONTROL;
1065
1306
  // A poster is `image/webp` and therefore edge-cacheable through the rule
1066
1307
  // that already exists — which is the whole delivery argument for it
1067
1308
  // (AGL-2743). `mediaCdnEdgeCacheable` is untouched: nothing here relaxes
@@ -1071,7 +1312,36 @@ export function parseMediaCdnRange(header, size) {
1071
1312
  // exactly the size class Vercel's 10 MB cap admits, so edge-caching one
1072
1313
  // would maximize exposure to the very hybrid AGL-1515 recorded rather
1073
1314
  // than avoid it.
1074
- const docServedType = usePoster || useVariant ? 'image/webp' : rendition ? rendition.contentType : snapshot.get('contentType');
1315
+ const docServedType = usePoster || useVariant ? 'image/webp' : rendition ? rendition.contentType : useDisplay ? display.contentType : snapshot.get('contentType');
1316
+ /*
1317
+ * THE BANDWIDTH BAND (AGL-3474).
1318
+ *
1319
+ * Video and files count against the org's bandwidth allowance (see
1320
+ * `media-bandwidth.ts`), so they stop where the band stops: when the Free
1321
+ * cap is engaged for the owning org, or the site's abuse ceiling degrades
1322
+ * it — the same two predicates that pause the site's pages, read off the
1323
+ * scope documents the lockdown verdict above already holds. No read is
1324
+ * added.
1325
+ *
1326
+ * Public images keep serving. They are not counted here, a paused site
1327
+ * serves no page that asks for one, and the ones the edge holds would
1328
+ * keep serving from it whatever this said. A private image counts, so it
1329
+ * stops with the band like a file; the console's own preview does not.
1330
+ *
1331
+ * Before the provider redirect, which would otherwise hand a paused org's
1332
+ * film to the provider, and before the 304, which would renew a browser's
1333
+ * copy for another minute. `503` with `Retry-After` and `no-store`, the
1334
+ * shape the paused site's pages answer with: the pause lifts on an
1335
+ * upgrade or at the month's end, and nothing may keep the refusal past
1336
+ * either.
1337
+ */ if (countsTowardBand(docServedType) && (await mediaCdnScopeVerdict(scope)).bandwidthPaused) {
1338
+ res.setHeader('Cache-Control', 'no-store');
1339
+ res.setHeader('Retry-After', String(BANDWIDTH_CAP_RETRY_AFTER_SECONDS));
1340
+ res.status(503).json({
1341
+ error: 'Over the monthly traffic limit'
1342
+ });
1343
+ return;
1344
+ }
1075
1345
  /*
1076
1346
  * VIDEO FROM THE DELIVERY PROVIDER (AGL-2824).
1077
1347
  *
@@ -1093,7 +1363,7 @@ export function parseMediaCdnRange(header, size) {
1093
1363
  * private asset's URL expires no later than the signature that reached
1094
1364
  * it. It is not counted against the caller's delivery budget, which
1095
1365
  * guards the bytes this route sends; a redirect sends none.
1096
- */ const deliveryProvider = usePoster || useVariant || download ? null : mediaDeliveryProvider('deliver');
1366
+ */ const deliveryProvider = usePoster || useVariant || useDisplay || download ? null : mediaDeliveryProvider('deliver');
1097
1367
  if (deliveryProvider) {
1098
1368
  var _scope_contextHostId;
1099
1369
  const delivery = await mediaDeliveryRedirect(_extends({
@@ -1124,26 +1394,72 @@ export function parseMediaCdnRange(header, size) {
1124
1394
  res.setHeader('Cache-Control', 'private, no-store');
1125
1395
  res.setHeader('Location', delivery.location);
1126
1396
  // A play starts here, so it is counted here. `redirects` says how many
1127
- // of the serves left through the provider; the bytes are the
1128
- // provider's to measure, because none leave from this route.
1397
+ // of the serves left through the provider; the per-asset `bytes` stay
1398
+ // the provider's to measure, because none leave from this route.
1399
+ //
1400
+ // The bandwidth band is not the provider's (AGL-3474): moving a film
1401
+ // off origin must not move it off the meter. A player sends every
1402
+ // range of a sitting to the URL it was redirected to, so this request
1403
+ // is the only one of the sitting that reaches us, and it is counted
1404
+ // at the copy's full size — the most one sitting sends, whatever range
1405
+ // a player opens with.
1406
+ let evaluation = null;
1407
+ let recorded = null;
1129
1408
  if (req.method === 'GET') {
1130
- const day = new Date().toISOString().slice(0, 10);
1131
- void firestore.collection(isOrg ? 'orgs' : 'hosts').doc(scopeId).collection('analytics').doc(day).set({
1132
- expiresAt: analyticsDayExpiresAt(day),
1133
- media: {
1134
- [mediaId]: {
1135
- serves: firebaseAdmin.firestore.FieldValue.increment(1),
1136
- redirects: firebaseAdmin.firestore.FieldValue.increment(1)
1137
- }
1138
- }
1139
- }, {
1140
- merge: true
1141
- }).catch(()=>undefined);
1409
+ const bandwidthBytes = teamPreview ? 0 : delivery.sizeBytes;
1410
+ recorded = recordMediaServe({
1411
+ firestore,
1412
+ collection: isOrg ? 'orgs' : 'hosts',
1413
+ scopeId,
1414
+ mediaId,
1415
+ bandwidthBytes,
1416
+ redirect: true
1417
+ });
1418
+ evaluation = mediaCdnBandwidthEvaluation(scope, bandwidthBytes);
1142
1419
  }
1143
1420
  res.status(302).end();
1421
+ // Awaited after the response, as the evaluation is, so the function
1422
+ // lives until the count is written rather than being frozen with it
1423
+ // in flight.
1424
+ await Promise.all([
1425
+ recorded,
1426
+ evaluation
1427
+ ]);
1144
1428
  return;
1145
1429
  }
1146
1430
  }
1431
+ /*
1432
+ * LAZY REGENERATION (AGL-3486) — see `media-delivery-regeneration.ts`.
1433
+ *
1434
+ * An asset whose delivery copies an older encoder made is answered from
1435
+ * what exists, and regenerated once the response has gone. Scheduled
1436
+ * before the 304 exit, because a browser revalidating its copy is a view
1437
+ * of the asset as much as a download is.
1438
+ */ if (req.method === 'GET' && mediaDeliveryCopiesStale({
1439
+ contentType: snapshot.get('contentType'),
1440
+ cdnPath: snapshot.get('cdnPath'),
1441
+ sizeBytes: snapshot.get('sizeBytes'),
1442
+ width: snapshot.get('width'),
1443
+ [MEDIA_VARIANT_ENCODER_VERSION_FIELD]: snapshot.get(MEDIA_VARIANT_ENCODER_VERSION_FIELD),
1444
+ variantRegeneration: snapshot.get('variantRegeneration')
1445
+ })) {
1446
+ scheduleAfterResponse(async ()=>{
1447
+ const regenerationPath = mediaStoragePathInScope({
1448
+ storagePath: snapshot.get('storagePath'),
1449
+ base: `${isOrg ? 'orgs' : 'hosts'}/${scopeId}`,
1450
+ mediaId,
1451
+ onRefused: ()=>undefined
1452
+ });
1453
+ const outcome = await regenerateMediaDeliveryCopies({
1454
+ docRef: snapshot.ref,
1455
+ bucket: firebaseAdmin.app().storage().bucket(process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']),
1456
+ basePath: regenerationPath
1457
+ });
1458
+ if (outcome === 'failed') {
1459
+ console.error('[media-cdn] delivery copies could not be regenerated', scopeSegment, mediaId);
1460
+ }
1461
+ });
1462
+ }
1147
1463
  if (!hashed) {
1148
1464
  setCacheControl(stableCacheControlFor(docServedType));
1149
1465
  if (etag) res.setHeader('ETag', etag);
@@ -1181,8 +1497,8 @@ export function parseMediaCdnRange(header, size) {
1181
1497
  // and `parseMediaRendition` refuses a key or extension that is not
1182
1498
  // `[a-z0-9-]`. So no representation can address an object the master
1183
1499
  // could not.
1184
- const objectPath = usePoster ? usePosterVariant ? `${basePath}${MEDIA_POSTER_OBJECT_SUFFIX}__w${width}.webp` : mediaPosterObjectPath(basePath) : rendition ? mediaRenditionObjectPath(basePath, rendition) : useVariant ? `${basePath}__w${width}.webp` : basePath;
1185
- const file = bucket.file(objectPath);
1500
+ const objectPath = usePoster ? usePosterVariant ? `${basePath}${MEDIA_POSTER_OBJECT_SUFFIX}__w${width}.webp` : mediaPosterObjectPath(basePath) : rendition ? mediaRenditionObjectPath(basePath, rendition) : useVariant ? `${basePath}__w${width}.webp` : useDisplay ? mediaDisplayObjectPath(basePath) : basePath;
1501
+ let file = bucket.file(objectPath);
1186
1502
  // The caller's count (AGL-2812), asked here and nowhere earlier: past every
1187
1503
  // gate and the 304 exit, where the Storage reads begin. It starts before the
1188
1504
  // metadata read and is awaited after it, so the two run together. It never
@@ -1192,9 +1508,20 @@ export function parseMediaCdnRange(header, size) {
1192
1508
  remoteAddress: (_req_socket = req.socket) == null ? void 0 : _req_socket.remoteAddress,
1193
1509
  rateClass: mediaCdnEdgeCacheable(docServedType) ? 'image' : 'non-image'
1194
1510
  }) : null;
1195
- const [metadata] = await file.getMetadata().catch(()=>[
1511
+ let [metadata] = await file.getMetadata().catch(()=>[
1196
1512
  null
1197
1513
  ]);
1514
+ // A display copy the document names but Storage does not hold degrades
1515
+ // to the original — what this URL served before display copies existed —
1516
+ // rather than to a 404 on a live page.
1517
+ let servesDisplay = useDisplay;
1518
+ if (!metadata && useDisplay) {
1519
+ servesDisplay = false;
1520
+ file = bucket.file(basePath);
1521
+ [metadata] = await file.getMetadata().catch(()=>[
1522
+ null
1523
+ ]);
1524
+ }
1198
1525
  if (!metadata) {
1199
1526
  res.status(404).json({
1200
1527
  error: 'Not found'
@@ -1214,7 +1541,7 @@ export function parseMediaCdnRange(header, size) {
1214
1541
  });
1215
1542
  return;
1216
1543
  }
1217
- const servedType = usePoster || useVariant ? 'image/webp' : rendition ? rendition.contentType : String((_ref3 = (_metadata_contentType = metadata.contentType) != null ? _metadata_contentType : snapshot.get('contentType')) != null ? _ref3 : 'application/octet-stream');
1544
+ const servedType = usePoster || useVariant ? 'image/webp' : rendition ? rendition.contentType : servesDisplay && display ? display.contentType : String((_ref3 = (_metadata_contentType = metadata.contentType) != null ? _metadata_contentType : snapshot.get('contentType')) != null ? _ref3 : 'application/octet-stream');
1218
1545
  res.setHeader('Content-Type', servedType);
1219
1546
  // AGL-1474: an SVG (or anything else a browser treats as a document) gets
1220
1547
  // the sandboxing policy. Everything else keeps the base one set above —
@@ -1308,20 +1635,29 @@ export function parseMediaCdnRange(header, size) {
1308
1635
  return;
1309
1636
  }
1310
1637
  // Delivery volume (AGL-176): per-asset serves/bytes on the AGL-82
1311
- // analytics day-doc, fire-and-forget. Only cache MISSES reach this
1638
+ // analytics day-doc, awaited after the body rather than ahead of it.
1639
+ // Only cache MISSES reach this
1312
1640
  // code — edge-cached responses aren't counted, so these are origin
1313
- // serves, not user-facing totals (billing accuracy is AGL-41's job).
1641
+ // serves, not user-facing totals.
1314
1642
  // Hot-doc note: a single day-doc caps at ~1 write/sec sustained;
1315
1643
  // acceptable at current traffic, shard or sample if an asset gets hot.
1316
1644
  //
1317
1645
  // Written before the first byte, so `bytes` is what this request asked
1318
1646
  // the origin to send. A client that abandons the stream receives less;
1319
1647
  // the figure is the ceiling on what left, not a count of what arrived.
1648
+ //
1649
+ // The same bytes count against the org's bandwidth band when
1650
+ // `countsTowardBand` says so (above). In this write, so counting costs no
1651
+ // write of its own.
1652
+ const countedBytes = countsTowardBand(servedType) ? servedBytes : 0;
1320
1653
  const day = new Date().toISOString().slice(0, 10);
1321
- void firestore.collection(isOrg ? 'orgs' : 'hosts').doc(scopeId).collection('analytics').doc(day).set({
1654
+ const recorded = firestore.collection(isOrg ? 'orgs' : 'hosts').doc(scopeId).collection('analytics').doc(day).set(_extends({
1322
1655
  // Retention (AGL-1844): every writer of a day doc stamps the
1323
1656
  // day-anchored expiry the TTL policy sweeps on.
1324
- expiresAt: analyticsDayExpiresAt(day),
1657
+ expiresAt: analyticsDayExpiresAt(day)
1658
+ }, countedBytes > 0 ? {
1659
+ [MEDIA_BANDWIDTH_DAY_FIELD]: firebaseAdmin.firestore.FieldValue.increment(countedBytes)
1660
+ } : {}, {
1325
1661
  media: {
1326
1662
  [mediaId]: {
1327
1663
  serves: firebaseAdmin.firestore.FieldValue.increment(1),
@@ -1332,9 +1668,13 @@ export function parseMediaCdnRange(header, size) {
1332
1668
  bytes: firebaseAdmin.firestore.FieldValue.increment(servedBytes)
1333
1669
  }
1334
1670
  }
1335
- }, {
1671
+ }), {
1336
1672
  merge: true
1337
- }).catch(()=>undefined);
1673
+ }).catch((error)=>logUncountedServe(scopeSegment, mediaId, error));
1674
+ // Totalled beside the stream rather than ahead of it, and awaited — with
1675
+ // the count above — once the stream ends, so the function lives until
1676
+ // both are done.
1677
+ const evaluation = countedBytes > 0 ? mediaCdnBandwidthEvaluation(scope, countedBytes) : null;
1338
1678
  if (partial) res.status(206);
1339
1679
  // `start`/`end` are inclusive in `createReadStream`, matching the parsed
1340
1680
  // range — GCS is asked for exactly the requested bytes and nothing is
@@ -1346,10 +1686,17 @@ export function parseMediaCdnRange(header, size) {
1346
1686
  // rather than `pipe` because streaming needs teardown in both directions,
1347
1687
  // which `pipe` does not give — a failed Storage read must fail the
1348
1688
  // response, and a client that stops reading must stop the Storage read.
1349
- await pipeline(file.createReadStream(partial ? {
1350
- start: range.start,
1351
- end: range.end
1352
- } : {}), res);
1689
+ try {
1690
+ await pipeline(file.createReadStream(partial ? {
1691
+ start: range.start,
1692
+ end: range.end
1693
+ } : {}), res);
1694
+ } finally{
1695
+ await Promise.all([
1696
+ recorded,
1697
+ evaluation
1698
+ ]);
1699
+ }
1353
1700
  } catch (error) {
1354
1701
  if (mediaCdnClientWentAway(error, res)) return;
1355
1702
  console.error('serveMediaCdn failed', scopeSegment, mediaId, error);
@@ -1366,5 +1713,12 @@ export function parseMediaCdnRange(header, size) {
1366
1713
  }
1367
1714
  }
1368
1715
  }
1716
+ /**
1717
+ * A serve the day doc did not take. The response has already gone, so the
1718
+ * failure is the log's to report rather than the visitor's — but reported,
1719
+ * because a dropped count is bandwidth the band and the invoice never see.
1720
+ */ function logUncountedServe(scopeSegment, mediaId, error) {
1721
+ console.error('[media-cdn] serve not counted', scopeSegment, mediaId, error);
1722
+ }
1369
1723
 
1370
1724
  //# sourceMappingURL=serve-media-cdn.js.map