@aglyn/tenant-data-admin 1.0.0-beta.143

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 (374) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/jest.integration.config.d.ts +39 -0
  4. package/jest.integration.setup.d.ts +17 -0
  5. package/package.json +51 -0
  6. package/src/index.d.ts +17 -0
  7. package/src/index.js +18 -0
  8. package/src/index.js.map +1 -0
  9. package/src/lib/render-cache.d.ts +184 -0
  10. package/src/lib/render-cache.js +198 -0
  11. package/src/lib/render-cache.js.map +1 -0
  12. package/src/lib/server/account-addresses.d.ts +160 -0
  13. package/src/lib/server/account-addresses.js +311 -0
  14. package/src/lib/server/account-addresses.js.map +1 -0
  15. package/src/lib/server/account-emails.d.ts +219 -0
  16. package/src/lib/server/account-emails.js +640 -0
  17. package/src/lib/server/account-emails.js.map +1 -0
  18. package/src/lib/server/admin-audit.d.ts +180 -0
  19. package/src/lib/server/admin-audit.js +233 -0
  20. package/src/lib/server/admin-audit.js.map +1 -0
  21. package/src/lib/server/analytics-retention.d.ts +63 -0
  22. package/src/lib/server/analytics-retention.js +66 -0
  23. package/src/lib/server/analytics-retention.js.map +1 -0
  24. package/src/lib/server/api-http.d.ts +159 -0
  25. package/src/lib/server/api-http.js +230 -0
  26. package/src/lib/server/api-http.js.map +1 -0
  27. package/src/lib/server/api-keys.d.ts +117 -0
  28. package/src/lib/server/api-keys.js +298 -0
  29. package/src/lib/server/api-keys.js.map +1 -0
  30. package/src/lib/server/auth-handoff.d.ts +165 -0
  31. package/src/lib/server/auth-handoff.js +482 -0
  32. package/src/lib/server/auth-handoff.js.map +1 -0
  33. package/src/lib/server/auth-pools.d.ts +246 -0
  34. package/src/lib/server/auth-pools.js +551 -0
  35. package/src/lib/server/auth-pools.js.map +1 -0
  36. package/src/lib/server/billing-addons.d.ts +171 -0
  37. package/src/lib/server/billing-addons.js +277 -0
  38. package/src/lib/server/billing-addons.js.map +1 -0
  39. package/src/lib/server/campaign-attribution-store.d.ts +55 -0
  40. package/src/lib/server/campaign-attribution-store.js +95 -0
  41. package/src/lib/server/campaign-attribution-store.js.map +1 -0
  42. package/src/lib/server/campaign-conversion-attribution.d.ts +205 -0
  43. package/src/lib/server/campaign-conversion-attribution.js +279 -0
  44. package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
  45. package/src/lib/server/client-error-report.d.ts +301 -0
  46. package/src/lib/server/client-error-report.js +755 -0
  47. package/src/lib/server/client-error-report.js.map +1 -0
  48. package/src/lib/server/connect-account-status.d.ts +74 -0
  49. package/src/lib/server/connect-account-status.js +94 -0
  50. package/src/lib/server/connect-account-status.js.map +1 -0
  51. package/src/lib/server/connect-payout-failure.d.ts +103 -0
  52. package/src/lib/server/connect-payout-failure.js +127 -0
  53. package/src/lib/server/connect-payout-failure.js.map +1 -0
  54. package/src/lib/server/console-api-rate-limit.d.ts +81 -0
  55. package/src/lib/server/console-api-rate-limit.js +111 -0
  56. package/src/lib/server/console-api-rate-limit.js.map +1 -0
  57. package/src/lib/server/console-domains.d.ts +326 -0
  58. package/src/lib/server/console-domains.js +779 -0
  59. package/src/lib/server/console-domains.js.map +1 -0
  60. package/src/lib/server/consume-once.d.ts +91 -0
  61. package/src/lib/server/consume-once.js +96 -0
  62. package/src/lib/server/consume-once.js.map +1 -0
  63. package/src/lib/server/contact-company-link.d.ts +85 -0
  64. package/src/lib/server/contact-company-link.js +105 -0
  65. package/src/lib/server/contact-company-link.js.map +1 -0
  66. package/src/lib/server/contact-email-engagement.d.ts +32 -0
  67. package/src/lib/server/contact-email-engagement.js +145 -0
  68. package/src/lib/server/contact-email-engagement.js.map +1 -0
  69. package/src/lib/server/contact-email-index.d.ts +56 -0
  70. package/src/lib/server/contact-email-index.js +158 -0
  71. package/src/lib/server/contact-email-index.js.map +1 -0
  72. package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
  73. package/src/lib/server/contact-lifecycle-floor.js +88 -0
  74. package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
  75. package/src/lib/server/contact-merge.d.ts +55 -0
  76. package/src/lib/server/contact-merge.js +279 -0
  77. package/src/lib/server/contact-merge.js.map +1 -0
  78. package/src/lib/server/contact-suppression.d.ts +153 -0
  79. package/src/lib/server/contact-suppression.js +259 -0
  80. package/src/lib/server/contact-suppression.js.map +1 -0
  81. package/src/lib/server/crm-booking-activity.d.ts +86 -0
  82. package/src/lib/server/crm-booking-activity.js +251 -0
  83. package/src/lib/server/crm-booking-activity.js.map +1 -0
  84. package/src/lib/server/crm-email-activity.d.ts +112 -0
  85. package/src/lib/server/crm-email-activity.js +143 -0
  86. package/src/lib/server/crm-email-activity.js.map +1 -0
  87. package/src/lib/server/crm-inbound-email.d.ts +145 -0
  88. package/src/lib/server/crm-inbound-email.js +287 -0
  89. package/src/lib/server/crm-inbound-email.js.map +1 -0
  90. package/src/lib/server/crm-next-activity.d.ts +74 -0
  91. package/src/lib/server/crm-next-activity.js +194 -0
  92. package/src/lib/server/crm-next-activity.js.map +1 -0
  93. package/src/lib/server/crm-records.d.ts +144 -0
  94. package/src/lib/server/crm-records.js +218 -0
  95. package/src/lib/server/crm-records.js.map +1 -0
  96. package/src/lib/server/csp-aggregate.d.ts +62 -0
  97. package/src/lib/server/csp-aggregate.js +276 -0
  98. package/src/lib/server/csp-aggregate.js.map +1 -0
  99. package/src/lib/server/data-storage-gate.d.ts +66 -0
  100. package/src/lib/server/data-storage-gate.js +48 -0
  101. package/src/lib/server/data-storage-gate.js.map +1 -0
  102. package/src/lib/server/dataset-live-pages.d.ts +179 -0
  103. package/src/lib/server/dataset-live-pages.js +421 -0
  104. package/src/lib/server/dataset-live-pages.js.map +1 -0
  105. package/src/lib/server/dns-probe.d.ts +53 -0
  106. package/src/lib/server/dns-probe.js +184 -0
  107. package/src/lib/server/dns-probe.js.map +1 -0
  108. package/src/lib/server/document-id.d.ts +29 -0
  109. package/src/lib/server/document-id.js +100 -0
  110. package/src/lib/server/document-id.js.map +1 -0
  111. package/src/lib/server/domain-provider-vercel.d.ts +33 -0
  112. package/src/lib/server/domain-provider-vercel.js +298 -0
  113. package/src/lib/server/domain-provider-vercel.js.map +1 -0
  114. package/src/lib/server/domain-provider-webhook.d.ts +73 -0
  115. package/src/lib/server/domain-provider-webhook.js +223 -0
  116. package/src/lib/server/domain-provider-webhook.js.map +1 -0
  117. package/src/lib/server/domain-provider-wildcard.d.ts +54 -0
  118. package/src/lib/server/domain-provider-wildcard.js +163 -0
  119. package/src/lib/server/domain-provider-wildcard.js.map +1 -0
  120. package/src/lib/server/domain-provider.d.ts +197 -0
  121. package/src/lib/server/domain-provider.js +221 -0
  122. package/src/lib/server/domain-provider.js.map +1 -0
  123. package/src/lib/server/duplicate-activity.d.ts +54 -0
  124. package/src/lib/server/duplicate-activity.js +67 -0
  125. package/src/lib/server/duplicate-activity.js.map +1 -0
  126. package/src/lib/server/duplicate-resource.d.ts +55 -0
  127. package/src/lib/server/duplicate-resource.js +474 -0
  128. package/src/lib/server/duplicate-resource.js.map +1 -0
  129. package/src/lib/server/dynamic-list-materialize.d.ts +153 -0
  130. package/src/lib/server/dynamic-list-materialize.js +581 -0
  131. package/src/lib/server/dynamic-list-materialize.js.map +1 -0
  132. package/src/lib/server/edit-access-authz.d.ts +56 -0
  133. package/src/lib/server/edit-access-authz.js +107 -0
  134. package/src/lib/server/edit-access-authz.js.map +1 -0
  135. package/src/lib/server/edit-access-token.d.ts +72 -0
  136. package/src/lib/server/edit-access-token.js +116 -0
  137. package/src/lib/server/edit-access-token.js.map +1 -0
  138. package/src/lib/server/edit-hint-token.d.ts +83 -0
  139. package/src/lib/server/edit-hint-token.js +119 -0
  140. package/src/lib/server/edit-hint-token.js.map +1 -0
  141. package/src/lib/server/email-delivery-log.d.ts +509 -0
  142. package/src/lib/server/email-delivery-log.js +1043 -0
  143. package/src/lib/server/email-delivery-log.js.map +1 -0
  144. package/src/lib/server/email-flow-gate.d.ts +80 -0
  145. package/src/lib/server/email-flow-gate.js +153 -0
  146. package/src/lib/server/email-flow-gate.js.map +1 -0
  147. package/src/lib/server/email-marketing-gate.d.ts +333 -0
  148. package/src/lib/server/email-marketing-gate.js +639 -0
  149. package/src/lib/server/email-marketing-gate.js.map +1 -0
  150. package/src/lib/server/email-metering.d.ts +293 -0
  151. package/src/lib/server/email-metering.js +359 -0
  152. package/src/lib/server/email-metering.js.map +1 -0
  153. package/src/lib/server/email-revenue-attribution.d.ts +185 -0
  154. package/src/lib/server/email-revenue-attribution.js +300 -0
  155. package/src/lib/server/email-revenue-attribution.js.map +1 -0
  156. package/src/lib/server/email-send-rate.d.ts +232 -0
  157. package/src/lib/server/email-send-rate.js +417 -0
  158. package/src/lib/server/email-send-rate.js.map +1 -0
  159. package/src/lib/server/email-sender-reputation.d.ts +317 -0
  160. package/src/lib/server/email-sender-reputation.js +464 -0
  161. package/src/lib/server/email-sender-reputation.js.map +1 -0
  162. package/src/lib/server/email-suppression.d.ts +490 -0
  163. package/src/lib/server/email-suppression.js +648 -0
  164. package/src/lib/server/email-suppression.js.map +1 -0
  165. package/src/lib/server/email-topic-confirmation.d.ts +150 -0
  166. package/src/lib/server/email-topic-confirmation.js +272 -0
  167. package/src/lib/server/email-topic-confirmation.js.map +1 -0
  168. package/src/lib/server/email-unsubscribe-link.d.ts +174 -0
  169. package/src/lib/server/email-unsubscribe-link.js +236 -0
  170. package/src/lib/server/email-unsubscribe-link.js.map +1 -0
  171. package/src/lib/server/erase-person.d.ts +116 -0
  172. package/src/lib/server/erase-person.js +272 -0
  173. package/src/lib/server/erase-person.js.map +1 -0
  174. package/src/lib/server/erase.d.ts +458 -0
  175. package/src/lib/server/erase.js +1506 -0
  176. package/src/lib/server/erase.js.map +1 -0
  177. package/src/lib/server/firebase-admin.d.ts +71 -0
  178. package/src/lib/server/firebase-admin.js +343 -0
  179. package/src/lib/server/firebase-admin.js.map +1 -0
  180. package/src/lib/server/form-dataset-binding-token.d.ts +35 -0
  181. package/src/lib/server/form-dataset-binding-token.js +107 -0
  182. package/src/lib/server/form-dataset-binding-token.js.map +1 -0
  183. package/src/lib/server/free-workspace-cap.d.ts +201 -0
  184. package/src/lib/server/free-workspace-cap.js +404 -0
  185. package/src/lib/server/free-workspace-cap.js.map +1 -0
  186. package/src/lib/server/ga4-measurement-protocol.d.ts +256 -0
  187. package/src/lib/server/ga4-measurement-protocol.js +558 -0
  188. package/src/lib/server/ga4-measurement-protocol.js.map +1 -0
  189. package/src/lib/server/host-email-tokens.d.ts +47 -0
  190. package/src/lib/server/host-email-tokens.js +72 -0
  191. package/src/lib/server/host-email-tokens.js.map +1 -0
  192. package/src/lib/server/host-memberships.d.ts +54 -0
  193. package/src/lib/server/host-memberships.js +194 -0
  194. package/src/lib/server/host-memberships.js.map +1 -0
  195. package/src/lib/server/host-sending-domain.d.ts +292 -0
  196. package/src/lib/server/host-sending-domain.js +451 -0
  197. package/src/lib/server/host-sending-domain.js.map +1 -0
  198. package/src/lib/server/host-visitor-records.d.ts +150 -0
  199. package/src/lib/server/host-visitor-records.js +262 -0
  200. package/src/lib/server/host-visitor-records.js.map +1 -0
  201. package/src/lib/server/id-token-refusal.d.ts +28 -0
  202. package/src/lib/server/id-token-refusal.js +111 -0
  203. package/src/lib/server/id-token-refusal.js.map +1 -0
  204. package/src/lib/server/legal-acceptance.d.ts +214 -0
  205. package/src/lib/server/legal-acceptance.js +305 -0
  206. package/src/lib/server/legal-acceptance.js.map +1 -0
  207. package/src/lib/server/list-members.d.ts +211 -0
  208. package/src/lib/server/list-members.js +176 -0
  209. package/src/lib/server/list-members.js.map +1 -0
  210. package/src/lib/server/live-page-usage.d.ts +202 -0
  211. package/src/lib/server/live-page-usage.js +300 -0
  212. package/src/lib/server/live-page-usage.js.map +1 -0
  213. package/src/lib/server/lockdown.d.ts +313 -0
  214. package/src/lib/server/lockdown.js +643 -0
  215. package/src/lib/server/lockdown.js.map +1 -0
  216. package/src/lib/server/media-cdn-rate-limit.d.ts +188 -0
  217. package/src/lib/server/media-cdn-rate-limit.js +204 -0
  218. package/src/lib/server/media-cdn-rate-limit.js.map +1 -0
  219. package/src/lib/server/media-delivery.d.ts +267 -0
  220. package/src/lib/server/media-delivery.js +485 -0
  221. package/src/lib/server/media-delivery.js.map +1 -0
  222. package/src/lib/server/media-download-tokens.d.ts +161 -0
  223. package/src/lib/server/media-download-tokens.js +386 -0
  224. package/src/lib/server/media-download-tokens.js.map +1 -0
  225. package/src/lib/server/media-quarantine.d.ts +127 -0
  226. package/src/lib/server/media-quarantine.js +170 -0
  227. package/src/lib/server/media-quarantine.js.map +1 -0
  228. package/src/lib/server/media-signing.d.ts +126 -0
  229. package/src/lib/server/media-signing.js +159 -0
  230. package/src/lib/server/media-signing.js.map +1 -0
  231. package/src/lib/server/media-storage-path.d.ts +96 -0
  232. package/src/lib/server/media-storage-path.js +111 -0
  233. package/src/lib/server/media-storage-path.js.map +1 -0
  234. package/src/lib/server/media-strong-digest.d.ts +85 -0
  235. package/src/lib/server/media-strong-digest.js +190 -0
  236. package/src/lib/server/media-strong-digest.js.map +1 -0
  237. package/src/lib/server/media-tombstone.d.ts +276 -0
  238. package/src/lib/server/media-tombstone.js +421 -0
  239. package/src/lib/server/media-tombstone.js.map +1 -0
  240. package/src/lib/server/media-variants.d.ts +309 -0
  241. package/src/lib/server/media-variants.js +428 -0
  242. package/src/lib/server/media-variants.js.map +1 -0
  243. package/src/lib/server/member-email-aliases.d.ts +180 -0
  244. package/src/lib/server/member-email-aliases.js +420 -0
  245. package/src/lib/server/member-email-aliases.js.map +1 -0
  246. package/src/lib/server/member-photo.d.ts +88 -0
  247. package/src/lib/server/member-photo.js +185 -0
  248. package/src/lib/server/member-photo.js.map +1 -0
  249. package/src/lib/server/membership-recover-throttle.d.ts +211 -0
  250. package/src/lib/server/membership-recover-throttle.js +231 -0
  251. package/src/lib/server/membership-recover-throttle.js.map +1 -0
  252. package/src/lib/server/notifications.d.ts +55 -0
  253. package/src/lib/server/notifications.js +134 -0
  254. package/src/lib/server/notifications.js.map +1 -0
  255. package/src/lib/server/org-billing.d.ts +97 -0
  256. package/src/lib/server/org-billing.js +236 -0
  257. package/src/lib/server/org-billing.js.map +1 -0
  258. package/src/lib/server/organizations.d.ts +837 -0
  259. package/src/lib/server/organizations.js +1814 -0
  260. package/src/lib/server/organizations.js.map +1 -0
  261. package/src/lib/server/password-reset-throttle.d.ts +84 -0
  262. package/src/lib/server/password-reset-throttle.js +102 -0
  263. package/src/lib/server/password-reset-throttle.js.map +1 -0
  264. package/src/lib/server/personal-data-export.d.ts +241 -0
  265. package/src/lib/server/personal-data-export.js +858 -0
  266. package/src/lib/server/personal-data-export.js.map +1 -0
  267. package/src/lib/server/platform-domain-names.d.ts +62 -0
  268. package/src/lib/server/platform-domain-names.js +197 -0
  269. package/src/lib/server/platform-domain-names.js.map +1 -0
  270. package/src/lib/server/platform-marketing-consent.d.ts +133 -0
  271. package/src/lib/server/platform-marketing-consent.js +209 -0
  272. package/src/lib/server/platform-marketing-consent.js.map +1 -0
  273. package/src/lib/server/rate-limit-store.d.ts +702 -0
  274. package/src/lib/server/rate-limit-store.js +1220 -0
  275. package/src/lib/server/rate-limit-store.js.map +1 -0
  276. package/src/lib/server/realm-plugins.d.ts +67 -0
  277. package/src/lib/server/realm-plugins.js +250 -0
  278. package/src/lib/server/realm-plugins.js.map +1 -0
  279. package/src/lib/server/recaptcha-allowlist.d.ts +90 -0
  280. package/src/lib/server/recaptcha-allowlist.js +409 -0
  281. package/src/lib/server/recaptcha-allowlist.js.map +1 -0
  282. package/src/lib/server/release-flags.d.ts +105 -0
  283. package/src/lib/server/release-flags.js +183 -0
  284. package/src/lib/server/release-flags.js.map +1 -0
  285. package/src/lib/server/replay-erasures.d.ts +126 -0
  286. package/src/lib/server/replay-erasures.js +217 -0
  287. package/src/lib/server/replay-erasures.js.map +1 -0
  288. package/src/lib/server/resolve-people.d.ts +66 -0
  289. package/src/lib/server/resolve-people.js +112 -0
  290. package/src/lib/server/resolve-people.js.map +1 -0
  291. package/src/lib/server/safe-equal.d.ts +45 -0
  292. package/src/lib/server/safe-equal.js +58 -0
  293. package/src/lib/server/safe-equal.js.map +1 -0
  294. package/src/lib/server/sending-domain-debt.d.ts +127 -0
  295. package/src/lib/server/sending-domain-debt.js +268 -0
  296. package/src/lib/server/sending-domain-debt.js.map +1 -0
  297. package/src/lib/server/sending-domain-recheck.d.ts +151 -0
  298. package/src/lib/server/sending-domain-recheck.js +253 -0
  299. package/src/lib/server/sending-domain-recheck.js.map +1 -0
  300. package/src/lib/server/sending-domains.d.ts +364 -0
  301. package/src/lib/server/sending-domains.js +683 -0
  302. package/src/lib/server/sending-domains.js.map +1 -0
  303. package/src/lib/server/serve-media-cdn.d.ts +433 -0
  304. package/src/lib/server/serve-media-cdn.js +1370 -0
  305. package/src/lib/server/serve-media-cdn.js.map +1 -0
  306. package/src/lib/server/serve-plugin-fetch.d.ts +61 -0
  307. package/src/lib/server/serve-plugin-fetch.js +294 -0
  308. package/src/lib/server/serve-plugin-fetch.js.map +1 -0
  309. package/src/lib/server/sms-keywords.d.ts +63 -0
  310. package/src/lib/server/sms-keywords.js +146 -0
  311. package/src/lib/server/sms-keywords.js.map +1 -0
  312. package/src/lib/server/sso-break-glass-owners.d.ts +91 -0
  313. package/src/lib/server/sso-break-glass-owners.js +227 -0
  314. package/src/lib/server/sso-break-glass-owners.js.map +1 -0
  315. package/src/lib/server/sso-domain-policy.d.ts +140 -0
  316. package/src/lib/server/sso-domain-policy.js +239 -0
  317. package/src/lib/server/sso-domain-policy.js.map +1 -0
  318. package/src/lib/server/sso-drift-logic.d.ts +106 -0
  319. package/src/lib/server/sso-drift-logic.js +95 -0
  320. package/src/lib/server/sso-drift-logic.js.map +1 -0
  321. package/src/lib/server/sso-enforcement.d.ts +204 -0
  322. package/src/lib/server/sso-enforcement.js +309 -0
  323. package/src/lib/server/sso-enforcement.js.map +1 -0
  324. package/src/lib/server/sso-provisioning.d.ts +320 -0
  325. package/src/lib/server/sso-provisioning.js +715 -0
  326. package/src/lib/server/sso-provisioning.js.map +1 -0
  327. package/src/lib/server/staff-alert-email.d.ts +41 -0
  328. package/src/lib/server/staff-alert-email.js +63 -0
  329. package/src/lib/server/staff-alert-email.js.map +1 -0
  330. package/src/lib/server/stripe-account-mode.d.ts +156 -0
  331. package/src/lib/server/stripe-account-mode.js +162 -0
  332. package/src/lib/server/stripe-account-mode.js.map +1 -0
  333. package/src/lib/server/tenant-write-lockdown.d.ts +131 -0
  334. package/src/lib/server/tenant-write-lockdown.js +176 -0
  335. package/src/lib/server/tenant-write-lockdown.js.map +1 -0
  336. package/src/lib/server/test-firestore.d.ts +45 -0
  337. package/src/lib/server/test-firestore.js +124 -0
  338. package/src/lib/server/test-firestore.js.map +1 -0
  339. package/src/lib/server/token-revocation.d.ts +140 -0
  340. package/src/lib/server/token-revocation.js +255 -0
  341. package/src/lib/server/token-revocation.js.map +1 -0
  342. package/src/lib/server/update-existing.d.ts +32 -0
  343. package/src/lib/server/update-existing.js +73 -0
  344. package/src/lib/server/update-existing.js.map +1 -0
  345. package/src/lib/server/upload-cors-reconcile.d.ts +118 -0
  346. package/src/lib/server/upload-cors-reconcile.js +234 -0
  347. package/src/lib/server/upload-cors-reconcile.js.map +1 -0
  348. package/src/lib/server/upsert-contact.d.ts +262 -0
  349. package/src/lib/server/upsert-contact.js +546 -0
  350. package/src/lib/server/upsert-contact.js.map +1 -0
  351. package/src/lib/server/usage-invoice.d.ts +203 -0
  352. package/src/lib/server/usage-invoice.js +397 -0
  353. package/src/lib/server/usage-invoice.js.map +1 -0
  354. package/src/lib/server/user-profiles.d.ts +151 -0
  355. package/src/lib/server/user-profiles.js +364 -0
  356. package/src/lib/server/user-profiles.js.map +1 -0
  357. package/src/lib/server/vercel-drain-signature.d.ts +30 -0
  358. package/src/lib/server/vercel-drain-signature.js +61 -0
  359. package/src/lib/server/vercel-drain-signature.js.map +1 -0
  360. package/src/lib/server/vercel-log-drain.d.ts +259 -0
  361. package/src/lib/server/vercel-log-drain.js +479 -0
  362. package/src/lib/server/vercel-log-drain.js.map +1 -0
  363. package/src/lib/server/verify-email-cooldown.d.ts +102 -0
  364. package/src/lib/server/verify-email-cooldown.js +114 -0
  365. package/src/lib/server/verify-email-cooldown.js.map +1 -0
  366. package/src/lib/server/visitor-write-rate-limit.d.ts +87 -0
  367. package/src/lib/server/visitor-write-rate-limit.js +117 -0
  368. package/src/lib/server/visitor-write-rate-limit.js.map +1 -0
  369. package/src/lib/server/workspace-domains.d.ts +156 -0
  370. package/src/lib/server/workspace-domains.js +271 -0
  371. package/src/lib/server/workspace-domains.js.map +1 -0
  372. package/src/tenancy.d.ts +115 -0
  373. package/src/tenancy.js +142 -0
  374. package/src/tenancy.js.map +1 -0
@@ -0,0 +1,1370 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
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, mediaPosterObjectPath, mediaRenditionObjectPath, normalizeHostLockdown, normalizeOrgLockdown, parseMediaRenditions } from "@aglyn/aglyn/server";
19
+ // By path, and out of every barrel: see the module note in `media-cdn-scope`.
20
+ import { MEDIA_CDN_SEGMENT, mediaCdnScopeRefusal, parseMediaCdnScope } from "@aglyn/aglyn/app-utils/media-cdn-scope";
21
+ import { mediaDeliveryProvider } from "@aglyn/aglyn/plugin-manager/media-delivery-provider";
22
+ import { analyticsDayExpiresAt } from "./analytics-retention.js";
23
+ import { firebaseAdmin } from "./firebase-admin.js";
24
+ import { getPlatformLockdown } from "./lockdown.js";
25
+ import { mediaCdnRateLimitRefusal } from "./media-cdn-rate-limit.js";
26
+ import { mediaDeliveryOrgIdFor, mediaDeliveryRedirect } from "./media-delivery.js";
27
+ import { getMediaQuarantine } from "./media-quarantine.js";
28
+ import { verifyMediaAccess } from "./media-signing.js";
29
+ import { mediaStoragePathInScope } from "./media-storage-path.js";
30
+ /**
31
+ * Variant widths generated at upload (AGL-175).
32
+ *
33
+ * Re-exported, not defined: the list now lives in `@aglyn/aglyn`'s media-ref
34
+ * so the RENDERER can reach it too. `image.tsx` carried its own
35
+ * `[320, 640, 1280]` literal, so a width added here never reached the srcSet.
36
+ */ export { MEDIA_CDN_VARIANT_WIDTHS } from "@aglyn/aglyn/server";
37
+ /**
38
+ * The scope grammar and the refusal this handler enforces (AGL-1043).
39
+ *
40
+ * Re-exported, not defined: the pure rule lives in `@aglyn/aglyn`'s
41
+ * `media-cdn-scope`, so the readers that must reach this handler's verdict —
42
+ * the composition's video facts and the besigner canvas — call the functions
43
+ * it calls.
44
+ */ export { mediaCdnAllows, mediaCdnScopeRefusal, parseMediaCdnScope } from "@aglyn/aglyn/app-utils/media-cdn-scope";
45
+ const SEGMENT = MEDIA_CDN_SEGMENT;
46
+ /**
47
+ * The query string to carry onto the stable URL when a stale content pin
48
+ * redirects (AGL-2685).
49
+ *
50
+ * REBUILT from the four parameters this handler reads, never forwarded whole.
51
+ * Two reasons, and the second is the one that matters:
52
+ *
53
+ * 1. Everything else is inert here — it would only split the CDN cache key
54
+ * for a response that is identical either way.
55
+ * 2. `Location` is a header, and the value would otherwise be caller-supplied
56
+ * text. Node rejects a CR or LF in a header value rather than splitting
57
+ * the response, so this is a second lock on a door that is already shut —
58
+ * which is the right number of locks for a header built from user input.
59
+ *
60
+ * `exp` and `sig` ride along because a private asset's signature covers
61
+ * (scope, mediaId, exp) and never the hash, so it is still valid at the
62
+ * target. Dropping them would turn a redirect into a 404 for exactly the
63
+ * assets whose delivery is most sensitive.
64
+ */ export function mediaCdnForwardedQuery(query) {
65
+ const params = new URLSearchParams();
66
+ // `poster` and `r` join the set for the reason the others are in it: the
67
+ // redirect must name the SAME representation the caller asked for, or a
68
+ // stale content pin on a poster URL lands the browser on the master video
69
+ // (AGL-2743).
70
+ for (const key of [
71
+ 'w',
72
+ 'poster',
73
+ 'r',
74
+ 'download',
75
+ 'exp',
76
+ 'sig'
77
+ ]){
78
+ const raw = query[key];
79
+ const value = Array.isArray(raw) ? raw[0] : raw;
80
+ if (value !== undefined && value !== '') params.set(key, String(value));
81
+ }
82
+ const encoded = params.toString();
83
+ return encoded ? `?${encoded}` : '';
84
+ }
85
+ /**
86
+ * The stable (non-content-hashed) URL's caching contract.
87
+ *
88
+ * This was `public, max-age=3600, stale-while-revalidate=86400`, and that
89
+ * header contradicted the promise the stable URL exists to make. Measured on
90
+ * production 2026-08-12: Vercel's edge DOES cache this route on a bare
91
+ * `max-age` (`x-vercel-cache: MISS` then `HIT`, `age` climbing) — so the
92
+ * saving was never in question. What a browser `max-age` breaks is
93
+ * *propagation*. `max-age=3600` reaches the client, so a browser holding a
94
+ * replaced asset will not send a conditional request for a full hour: the
95
+ * ETag below never gets a chance to answer, and nothing on our side can bust
96
+ * a browser cache. Every other cacheable route in the repo already uses
97
+ * `s-maxage` for exactly this reason — see `seo-origin.spec.ts`, "never a
98
+ * browser `max-age`, which nothing could bust".
99
+ *
100
+ * So: a short browser `max-age` that still collapses the burst of repeat
101
+ * requests within one page view (an image referenced by four `srcSet`
102
+ * candidates, a tile rendered in a grid and again in a drawer), and the full
103
+ * hour moved to `s-maxage` where it belongs. Worst stale read is now 60 s of
104
+ * replaced bytes in one browser rather than an hour, at the cost of one
105
+ * conditional request per image per minute — answered by the EDGE from its
106
+ * own copy, so it adds no Storage read and no Firestore read.
107
+ *
108
+ * The immutable content-hashed form is untouched: its URL changes with its
109
+ * bytes, so it can and should be pinned in the browser for a year.
110
+ *
111
+ * Since AGL-1515 this policy applies to IMAGE responses only — see
112
+ * {@link mediaCdnEdgeCacheable}.
113
+ */ export const MEDIA_CDN_STABLE_CACHE_CONTROL = 'public, max-age=60, s-maxage=3600, stale-while-revalidate=86400';
114
+ /**
115
+ * The stable URL's policy for types the edge must never hold (AGL-1515):
116
+ * the same 60-second browser window and the same ETag/304 contract, with
117
+ * `private` in place of `public, s-maxage` so no shared cache stores a
118
+ * full body.
119
+ *
120
+ * Why the edge must never hold one: Vercel's edge, holding a cached
121
+ * full-body 200, answers a `Range` request FROM that entry as a
122
+ * spec-violating hybrid — status 200, a `Content-Range` header, and only
123
+ * the requested slice as the body (`x-vercel-cache: HIT`; reproduced twice
124
+ * on production, 2026-08-13). A video player seeking into such an asset
125
+ * adopts a 100-byte slice as the complete file: silent playback corruption.
126
+ *
127
+ * The S4 shape below this constant was built on Vercel's documented
128
+ * cacheable-response criteria ("Request doesn't contain Range header"),
129
+ * read as "ranged requests bypass the edge". Production falsified that
130
+ * reading: the criteria govern what the edge STORES, not what it SERVES. A
131
+ * ranged request is still matched against the URL-keyed entry a previous
132
+ * plain GET left behind, and the edge's slicing layer rewrites the body
133
+ * and adds `Content-Range` without rewriting the stored 200 status.
134
+ *
135
+ * `private` is the lever because it is in the same documented criteria
136
+ * list as an absolute storage preventer ("Response doesn't contain the
137
+ * `private` … directives"), where `Vary: Range` is undocumented on
138
+ * Vercel, discouraged by RFC 9110, and untestable anywhere but a
139
+ * production deploy. What it costs: edge caching for non-image assets
140
+ * under Vercel's 10 MB cacheable-size cap. Real video mostly sits ABOVE
141
+ * the cap and was never edge-cached — the mangling reproduced on a 186 KB
142
+ * asset precisely because small ones are the ones that get cached.
143
+ */ export const MEDIA_CDN_STABLE_EDGE_BYPASS_CACHE_CONTROL = 'private, max-age=60';
144
+ /** 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
+ /**
146
+ * The immutable URL's policy for edge-bypassing types (AGL-1515): the
147
+ * browser keeps its year — the URL still changes with the bytes — while
148
+ * the edge holds nothing it could mangle.
149
+ */ export const MEDIA_CDN_IMMUTABLE_EDGE_BYPASS_CACHE_CONTROL = 'private, max-age=31536000, immutable';
150
+ /**
151
+ * Whether a response of this content type may be edge-cached (AGL-1515).
152
+ *
153
+ * The split is "types no realistic client ranges into": browsers fetch
154
+ * images with plain GETs (`<img>`, `srcSet`, save-as — all of them), while
155
+ * every consumer that seeks or resumes — `<video>`/`<audio>` players, PDF
156
+ * viewers, download managers — operates on the non-image types. Images stay
157
+ * on the shared edge policy because they are the DAM grid's hot path (4.3 KB
158
+ * WebP tiles at volume), and pushing them to origin to fix video would trade
159
+ * a real regression for a theoretical one.
160
+ *
161
+ * Accepted residual, recorded here on purpose: a hand-built `Range` request
162
+ * against an edge-cached IMAGE can still be answered with the hybrid (that
163
+ * is exactly the AGL-1515 favicon repro). No browser or player issues one,
164
+ * and the alternative — `private` on images too — costs the hot path an
165
+ * edge hit rate it measurably has.
166
+ *
167
+ * Unknown or absent types return false: correctness over cache.
168
+ */ export function mediaCdnEdgeCacheable(contentType) {
169
+ return String(contentType != null ? contentType : '').split(';')[0].trim().toLowerCase().startsWith('image/');
170
+ }
171
+ /**
172
+ * The CDN's OWN Content-Security-Policy (AGL-1474).
173
+ *
174
+ * An uploaded `image/svg+xml` is a document, not a picture. It passes the
175
+ * `image/*` allowlist, it is stored under whatever type the client declared,
176
+ * and this handler serves it `inline` from the console's own origin — and
177
+ * from every tenant site's, since the same handler mounts in both apps. So
178
+ * `<script>alert(document.domain)</script>` inside an uploaded logo executed
179
+ * on `app.aglyn.com` the moment the asset URL was opened top-level, for
180
+ * anyone with editor rights.
181
+ *
182
+ * Nothing already in place stopped it. `X-Content-Type-Options: nosniff`
183
+ * blocks HTML *mislabelled* as an image; it says nothing about a file
184
+ * honestly labelled `image/svg+xml`, which browsers render as a scripted
185
+ * document. And there was no CSP on this response **at all**: since AGL-523
186
+ * the policy is built per-response in each app's middleware, and both
187
+ * middlewares' matchers exclude `api` (`apps/console/middleware.ts`,
188
+ * `apps/tenant/middleware.ts`), so a directly-navigated CDN URL never passed
189
+ * through the code that sets one.
190
+ *
191
+ * That is precisely why the header is set HERE, on the response, rather than
192
+ * added to a matcher: a route-level header cannot be lost to a matcher edit,
193
+ * a rewrite, or a new mount of `serveMediaCdn` in a third app. It is also
194
+ * what makes this the containment rather than the remediation — it covers
195
+ * every asset already in the bucket, including the SVGs uploaded before the
196
+ * sanitizer existed, without rewriting a byte.
197
+ *
198
+ * **It does not touch `<img src>`, which is how logos and marks are used
199
+ * across this product.** A browser loading an SVG as an image neither runs
200
+ * its script nor applies the response's CSP — CSP governs documents and
201
+ * workers. The policy only becomes live in the case that is the vector: the
202
+ * asset opened as a top-level document (or framed via `<object>`/`<iframe>`).
203
+ */ export const MEDIA_CDN_BASE_CSP = "default-src 'none'; script-src 'none'; object-src 'none'; " + "base-uri 'none'; form-action 'none'";
204
+ /**
205
+ * The policy for a type a browser will treat as an ACTIVE DOCUMENT. Adds
206
+ * `sandbox` — an opaque origin, so even a hypothetical execution has no
207
+ * `document.domain`, no cookies and no storage to reach for.
208
+ *
209
+ * `style-src 'unsafe-inline'` and the two `data:` allowances are not
210
+ * concessions to script: with `script-src 'none'` and `sandbox` in force,
211
+ * nothing in CSS or a data URI can execute. They exist so that opening a
212
+ * logo's URL directly still shows the logo — `default-src 'none'` alone
213
+ * would blank an SVG's own `<style>` block and its embedded raster fills,
214
+ * which is a visible regression on legitimate assets and buys no safety.
215
+ */ export const MEDIA_CDN_ACTIVE_DOCUMENT_CSP = `${MEDIA_CDN_BASE_CSP}; style-src 'unsafe-inline'; img-src data:; ` + 'font-src data:; sandbox';
216
+ /**
217
+ * Types a browser parses as a document rather than rendering as an image.
218
+ *
219
+ * Keying off the SERVED content type is sound only because this response also
220
+ * carries `nosniff`: the browser is bound to the label we send, so an SVG
221
+ * uploaded under a `image/png` label is decoded as a PNG and is inert. Every
222
+ * type here except SVG is refused by the upload allowlist today; they are
223
+ * listed anyway because the allowlist has moved before (AGL-1465) and because
224
+ * a legacy fourth upload route accepted arbitrary types for its whole life
225
+ * before AGL-1485 deleted it.
226
+ */ const MEDIA_CDN_ACTIVE_DOCUMENT_TYPES = new Set([
227
+ 'image/svg+xml',
228
+ 'image/svg',
229
+ 'text/html',
230
+ 'application/xhtml+xml',
231
+ 'application/xml',
232
+ 'text/xml',
233
+ 'text/xsl',
234
+ 'application/xslt+xml',
235
+ 'application/mathml+xml'
236
+ ]);
237
+ /**
238
+ * Types this CDN will serve `inline`.
239
+ *
240
+ * `?download=1` has always been able to force `attachment`, but it is the
241
+ * REQUESTER's flag — the safe disposition cannot depend on a caller asking
242
+ * for it. Anything not on this list is served as a download whatever the
243
+ * query says, so opening its URL top-level saves a file instead of handing
244
+ * the type to a renderer on the customer's own domain.
245
+ *
246
+ * The list is what a browser has a real inline use for on this route:
247
+ * pictures, media a player streams, and PDFs, which people expect to open in
248
+ * a tab. Everything else — the office and text formats, JSON, ZIP, and any
249
+ * legacy `text/html` still sitting in the bucket from before the allowlist
250
+ * narrowed — has no inline use case here and every one of them is a document
251
+ * a browser would happily parse.
252
+ *
253
+ * SVG stays INLINE deliberately. AGL-1474 chose the sandboxing CSP over a
254
+ * forced download precisely so that opening a logo's URL still shows the
255
+ * logo, and that policy already denies it script, an origin, and everything
256
+ * it could reach for. Turning it into a download now would take that
257
+ * decision back without adding to it.
258
+ */ function servesInline(contentType) {
259
+ const type = String(contentType != null ? contentType : '').split(';')[0].trim().toLowerCase();
260
+ return type.startsWith('image/') || type.startsWith('video/') || type.startsWith('audio/') || type === 'application/pdf';
261
+ }
262
+ /** The `Content-Security-Policy` for a response serving `contentType`. */ export function mediaCdnContentSecurityPolicy(contentType) {
263
+ const type = String(contentType != null ? contentType : '').split(';')[0].trim().toLowerCase();
264
+ return MEDIA_CDN_ACTIVE_DOCUMENT_TYPES.has(type) ? MEDIA_CDN_ACTIVE_DOCUMENT_CSP : MEDIA_CDN_BASE_CSP;
265
+ }
266
+ /**
267
+ * Lockdown on the delivery path (AGL-1520).
268
+ *
269
+ * A security-locked org's SITE stops within seconds (the tenant middleware
270
+ * 503s, AGL-1501) — but until this gate existed its public media kept
271
+ * serving worldwide: to hot-links, to third-party embeds, and to the
272
+ * infected asset's own URL, which for an "infected host" security lock is
273
+ * the exact thing the lock was pressed to stop.
274
+ *
275
+ * **Which reasons stop delivery — decided per reason, not blanket:**
276
+ *
277
+ * - `security` — REFUSE. The point of the lock: the org's content, media
278
+ * included, must stop serving.
279
+ * - `manual` — REFUSE. A staff suspension with no reason code (every
280
+ * pre-lockdown `suspendedAt` normalizes to `manual`) is "we turned this
281
+ * org off"; content continuing to serve would make the kill partial.
282
+ * - `maintenance` — SERVE. The maintenance notice surface may itself
283
+ * reference org assets (a logo on the notice page), the window is
284
+ * temporary and non-adversarial, and blanking every image buys no safety.
285
+ * - `billing` — SERVE. The AGL-1506 principle: billing-locked orgs keep the
286
+ * surfaces that let them come back (members can still reach billing to
287
+ * pay). The site already 503s, so whether a hot-linked image serves is
288
+ * nearly moot — and refusing would punish e.g. an email-signature logo
289
+ * for a payment lapse. Serving is the cheap, reversible answer.
290
+ *
291
+ * Scopes are checked INDIVIDUALLY against that matrix rather than through
292
+ * `resolveLockdown`, on purpose: the resolver answers "which notice does a
293
+ * visitor see" and returns the WIDEST active scope — so a platform
294
+ * `maintenance` window would mask a concurrent org `security` lock and the
295
+ * infected asset would keep serving. Delivery has no notice to pick; the
296
+ * question is only "does ANY active lockdown demand these bytes stop".
297
+ */ export function lockdownStopsMediaDelivery(state, nowMs) {
298
+ if (!state || !isLockdownActive(state, nowMs)) return false;
299
+ return state.reason === 'security' || state.reason === 'manual';
300
+ }
301
+ /**
302
+ * **Read cost (AGL-1302):** the verdict inputs are TTL-cached in-process
303
+ * per CDN scope — one lookup per scope per {@link MEDIA_CDN_LOCK_TTL_MS},
304
+ * not per asset. A DAM grid firing dozens of requests coalesces into one;
305
+ * the platform doc rides `getPlatformLockdown`'s existing 15s cache. Same
306
+ * fail-open posture as the verdict core: an unreachable Firestore is an
307
+ * outage, not a lockdown, and must not blank every customer image.
308
+ *
309
+ * That lookup is one BatchGetDocuments at org scope, and two at host scope
310
+ * — `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.
316
+ *
317
+ * **Staleness bound, stated rather than hidden:** a warm origin refuses
318
+ * within ≤15s of the org-doc write (the platform panic number). What the
319
+ * 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`
321
+ * (+ one stale serve while revalidating) — so an already-edge-cached image
322
+ * URL can serve up to ~1h into a lock; non-image types are `private`
323
+ * (AGL-1515) and never edge-held. The immutable content-hashed form is
324
+ * browser-pinned for a year in clients that already fetched it — there is
325
+ * no per-asset purge API, so that copy is out of reach by design; NEW
326
+ * fetchers of the same URL refuse at the next origin miss. Compare the
327
+ * AGL-1501 drill: host pages flip ≤10s; assets lag minutes-to-an-hour at
328
+ * the caching tiers, which is the accepted trade for a near-zero read cost
329
+ * on the hottest unauthenticated path.
330
+ *
331
+ * **Out of this control's reach entirely:** free-tier raw
332
+ * `firebasestorage.googleapis.com` URLs — no code of ours runs there.
333
+ * Stated on AGL-1520, and now answered elsewhere rather than here: a
334
+ * `security` lock rotates those objects' download tokens
335
+ * (`media-download-tokens.ts`, AGL-1526), which kills the URL at Google's
336
+ * edge. That is a SIBLING control, not an extension of this one — it is
337
+ * `security`-only, it is irreversible for embeds, and like this gate it
338
+ * stops only new origin fetches (AGL-1615). Bytes already cached anywhere
339
+ * remain out of reach of both.
340
+ */ const MEDIA_CDN_LOCK_TTL_MS = 15000;
341
+ const lockCache = new Map();
342
+ const lockPending = new Map();
343
+ /**
344
+ * Drop the per-scope lock cache. Tests need it between cases; production
345
+ * convergence is the TTL — the lock is written by the console app and served
346
+ * by the tenant app, different processes an in-process invalidation can
347
+ * never reach.
348
+ */ export function invalidateMediaCdnLockCache() {
349
+ lockCache.clear();
350
+ lockPending.clear();
351
+ }
352
+ /**
353
+ * Every field the delivery verdict reads off a host or org document, and
354
+ * the projection sent to Firestore — ONE list, because the carrier below is
355
+ * built from it. A mask that omitted a field the carrier reads would leave
356
+ * that field `undefined` and silently soften the lock: drop
357
+ * `suspendedReasonCode` and every lock normalizes to `manual`, which
358
+ * REFUSES delivery for locks that should serve; drop `suspendedAt` and no
359
+ * lock is ever seen at all. Neither failure can be introduced here without
360
+ * changing the one array both sides use.
361
+ *
362
+ * `suspendedMode` and `suspendedEnforcement` are deliberately absent:
363
+ * {@link lockdownStopsMediaDelivery} decides on `reason` and the active
364
+ * window alone, so a read-only or takedown-class lock stops these bytes on
365
+ * exactly the same terms as a full one.
366
+ */ const SUSPENSION_FIELDS = [
367
+ 'suspendedAt',
368
+ 'suspendedReasonCode',
369
+ 'suspendedUntilMs'
370
+ ];
371
+ /** Which host owns the asset's scope — the only field read off `hostIndex`. */ const HOST_INDEX_ORG_FIELD = 'orgId';
372
+ /** The `suspended*` field family off a snapshot, for the normalizers. */ const suspensionCarrier = (snapshot)=>Object.fromEntries(SUSPENSION_FIELDS.map((field)=>[
373
+ field,
374
+ snapshot.get(field)
375
+ ]));
376
+ /** TTL-cached: does any lockdown covering `scope` stop delivery? */ async function mediaCdnScopeLocked(scope) {
377
+ const key = `${scope.isOrg ? 'org' : 'host'}:${scope.scopeId}`;
378
+ const cached = lockCache.get(key);
379
+ if (cached && Date.now() - cached.at < MEDIA_CDN_LOCK_TTL_MS) {
380
+ return cached.blocked;
381
+ }
382
+ let pending = lockPending.get(key);
383
+ if (!pending) {
384
+ pending = (async ()=>{
385
+ let blocked;
386
+ try {
387
+ const nowMs = Date.now();
388
+ // Platform first: cached, and a platform security lock is the panic
389
+ // 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) {
393
+ // Org forms (`org:{orgId}` and `org:{orgId}:{hostId}`): the org
394
+ // doc governs. The context host's own lock is not consulted — a
395
+ // suspended HOST's pages 503 already, and which sites may USE an
396
+ // 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) {
404
+ // Host-library form: the host's own lock, and the OWNING org's —
405
+ // an org lock never stamps host docs (AGL-1506), so a host-only
406
+ // read would silently miss the very lock this issue is about.
407
+ //
408
+ // One `getAll`, not two parallel gets: `DocumentReference.get()`
409
+ // is `getAll([ref])`, so a `Promise.all` of two of them is two
410
+ // BatchGetDocuments round trips where the batch is one. The org
411
+ // read below cannot join them — its id is what `hostIndex`
412
+ // returns — so two is the floor for this branch, not three.
413
+ const [host, hostIndex] = await firestore.getAll(firestore.collection('hosts').doc(scope.scopeId), firestore.collection('hostIndex').doc(scope.scopeId), {
414
+ fieldMask: [
415
+ ...SUSPENSION_FIELDS,
416
+ HOST_INDEX_ORG_FIELD
417
+ ]
418
+ });
419
+ blocked = lockdownStopsMediaDelivery(normalizeHostLockdown(suspensionCarrier(host)), nowMs);
420
+ 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
+ ]
426
+ });
427
+ blocked = lockdownStopsMediaDelivery(normalizeOrgLockdown(suspensionCarrier(org)), nowMs);
428
+ }
429
+ }
430
+ } catch (unused) {
431
+ // Fail open — the lockdown core's posture (see lockdown.ts): an
432
+ // unreachable Firestore is an outage, not a lockdown.
433
+ blocked = false;
434
+ }
435
+ lockCache.set(key, {
436
+ at: Date.now(),
437
+ blocked
438
+ });
439
+ return blocked;
440
+ })().finally(()=>{
441
+ lockPending.delete(key);
442
+ });
443
+ lockPending.set(key, pending);
444
+ }
445
+ return pending;
446
+ }
447
+ /**
448
+ * THE delivery-policy seam: "may this asset be served at all?" — one
449
+ * question, one function, consulted once per request before any caching
450
+ * exit. Distinct from `mediaCdnAllows` (which site may use it under this
451
+ * URL) and from the signature check (may the public fetch it): this asks
452
+ * whether the platform is willing to serve the bytes to ANYONE.
453
+ *
454
+ * Two independent reasons to refuse, both answered here so the handler
455
+ * keeps ONE refusal path:
456
+ *
457
+ * - `'locked'` — a lockdown covering this SCOPE (AGL-1520): the whole org
458
+ * or host is off.
459
+ * - `'quarantined'` — this ASSET is on the deny list (AGL-1512): one
460
+ * infected, abusive or DMCA-noticed file is off while everything else in
461
+ * the same workspace keeps serving. That proportionality is the point —
462
+ * a single bad object should not cost a customer their whole site.
463
+ *
464
+ * Order is scope-then-asset because the scope verdict is the cheaper cached
465
+ * one and the wider fact; the caller does not distinguish them on the wire
466
+ * (both are the same neutral 410), so the order is a cost decision only.
467
+ *
468
+ * Read cost, since this sits on the hottest unauthenticated path: the scope
469
+ * verdict is one read per SCOPE per 15s, and the quarantine deny list is
470
+ * one read per PROCESS per 15s for every asset in existence — it is a
471
+ * single document. Fifty DAM tiles pay two reads between them, not a
472
+ * hundred.
473
+ */ export async function mediaCdnServeBlock(scope, asset) {
474
+ if (await mediaCdnScopeLocked(scope)) return 'locked';
475
+ const quarantined = await getMediaQuarantine({
476
+ contentSha256: asset.contentSha256,
477
+ contentHash: asset.contentHash,
478
+ scopeSegment: asset.scopeSegment,
479
+ mediaId: asset.mediaId
480
+ });
481
+ return quarantined ? 'quarantined' : null;
482
+ }
483
+ /**
484
+ * Whether `?download=1` was asked for (AGL-1411). Strictly opt-in: absent,
485
+ * `0`, `false` or junk all keep the historical `inline`, because the default
486
+ * is what every `<img src>` in every published site relies on.
487
+ *
488
+ * Only `1` and `true` count, rather than "any truthy-looking string". The
489
+ * query string is part of the CDN cache key, so every accepted spelling is a
490
+ * separate edge entry for identical bytes; two is enough.
491
+ */ export function wantsMediaDownload(value) {
492
+ const raw = Array.isArray(value) ? value[0] : value;
493
+ const normalized = String(raw != null ? raw : '').toLowerCase();
494
+ return normalized === '1' || normalized === 'true';
495
+ }
496
+ /**
497
+ * Whether `?poster=1` was asked for (AGL-2743).
498
+ *
499
+ * Deliberately the same two-spelling rule as {@link wantsMediaDownload},
500
+ * and for the same reason rather than out of symmetry: the query string is
501
+ * part of the CDN cache key, so every accepted spelling of "yes" is another
502
+ * edge entry holding identical bytes. A poster is the response most likely
503
+ * to be requested at volume from a grid, which makes cache-key discipline
504
+ * worth more here than anywhere else on the route.
505
+ */ export function mediaCdnWantsPoster(value) {
506
+ return wantsMediaDownload(value);
507
+ }
508
+ /**
509
+ * The container every browser has decoded for a decade, and the encoding the
510
+ * producer always emits (`720p`, H.264 High in MP4). WebM/VP9 is opt-in there
511
+ * and is the only entry a client has to earn.
512
+ */ const MEDIA_CDN_BASELINE_RENDITION_TYPE = 'video/mp4';
513
+ /**
514
+ * What a client said about media types, split into the two answers that
515
+ * matter: types it NAMED, and types it explicitly refused with `q=0`.
516
+ *
517
+ * Wildcards are deliberately dropped rather than expanded. `video/*` and
518
+ * `*\/*` are a client declining to answer the question, and the browsers that
519
+ * matter mostly decline: Chrome and Safari send `*\/*` for a media element's
520
+ * request and only Firefox enumerates containers. Reading `*\/*` as "every
521
+ * container decodes here" is how a Safari 13 gets handed a WebM.
522
+ */ function parseAcceptedTypes(header) {
523
+ const raw = Array.isArray(header) ? header[0] : header;
524
+ const named = new Set();
525
+ const refused = new Set();
526
+ for (const part of String(raw != null ? raw : '').split(',')){
527
+ const [type, ...params] = part.split(';');
528
+ const name = type.trim().toLowerCase();
529
+ if (!name || name.includes('*')) continue;
530
+ // `q=0` is a refusal in RFC 9110, so it removes an entry rather than
531
+ // selecting it — the one case where naming a type argues against it.
532
+ const isRefusal = params.some((param)=>/^\s*q\s*=/i.test(param) && Number(param.split('=')[1]) === 0);
533
+ if (isRefusal) refused.add(name);
534
+ else named.add(name);
535
+ }
536
+ return {
537
+ named,
538
+ refused
539
+ };
540
+ }
541
+ /**
542
+ * Which rendition `?r=auto` resolves to, or undefined for "serve the master"
543
+ * (AGL-2753).
544
+ *
545
+ * ## Why the CDN chooses at all
546
+ *
547
+ * Because it is the only participant holding the media document when the
548
+ * bytes are asked for. A page cannot name a rendition — the encodings are
549
+ * produced out of band, after the placement, and the page is ISR-cached HTML
550
+ * that neither regenerates when one lands nor varies on a visitor's `Accept`.
551
+ * The composition does read a placed film's document for its length and
552
+ * shape (AGL-2807), but that answer is frozen into the cached page. The
553
+ * alternatives were a node backfill that rewrites published screens nobody
554
+ * edited, or naming encodings in that same frozen page. This costs neither:
555
+ * the document is already in hand on this request.
556
+ *
557
+ * ## Why the stored order is not simply obeyed
558
+ *
559
+ * The producer writes most-efficient-codec first, which is the right order for
560
+ * `<source>` elements — there the BROWSER picks, using what it actually knows
561
+ * about its own decoders. A single negotiated URL has no such luxury, so
562
+ * taking the head of the list would hand a WebM to anything that could not
563
+ * decode one and turn a saving into a black player.
564
+ *
565
+ * So an entry is selected on an EXPLICIT type match, and everything else falls
566
+ * to the MP4 baseline the producer always emits. A client that names
567
+ * `video/webm` gets the 30%-smaller file; a client that says `*\/*` gets the
568
+ * universally decodable one; a client that refused every candidate gets the
569
+ * master. Every outcome is a video that plays.
570
+ *
571
+ * ## Why negotiating here is safe under a cache
572
+ *
573
+ * A rendition response is `video/*`, so `mediaCdnEdgeCacheable` is false and
574
+ * it is served `private` (AGL-1515) — no shared cache stores it, which is the
575
+ * invariant `serve-media-cdn.video.spec.ts` pins. The only cache that can hold
576
+ * one is the requesting browser's own, and a browser's `Accept` for media does
577
+ * not change between the first request and the Range requests that follow it.
578
+ * That matters more than it looks: negotiation on a RANGEABLE resource that
579
+ * could flip mid-playback would stitch byte ranges from two different files
580
+ * together, which is the AGL-1515 corruption reached by a new road.
581
+ */ export function selectAutoRendition(renditions, acceptHeader) {
582
+ var _renditions_find;
583
+ if (renditions.length === 0) return undefined;
584
+ const { named, refused } = parseAcceptedTypes(acceptHeader);
585
+ const typeOf = (entry)=>entry.contentType.toLowerCase();
586
+ return (_renditions_find = renditions.find((entry)=>named.has(typeOf(entry)))) != null ? _renditions_find : renditions.find((entry)=>typeOf(entry) === MEDIA_CDN_BASELINE_RENDITION_TYPE && !refused.has(MEDIA_CDN_BASELINE_RENDITION_TYPE));
587
+ }
588
+ /** Anything that cannot appear literally in an HTTP field-value. */ const NON_ASCII_FIELD_VALUE = /[^\x20-\x7e]/g;
589
+ /** ...and what additionally cannot appear inside a quoted-string. */ const NOT_QUOTED_STRING_SAFE = /["\\]/g;
590
+ /**
591
+ * The download name for an asset: the stored `fileName`, reduced to its
592
+ * basename, falling back to the media id (the URL is id-keyed and
593
+ * extensionless, so without a name a "save as" lands as a nameless blob).
594
+ */ export function mediaDownloadName(fileName, mediaId) {
595
+ var _raw_split_pop;
596
+ const raw = String(fileName != null ? fileName : '').trim();
597
+ // A stored name can carry a path from whatever uploaded it; only the last
598
+ // segment is a filename, and `..` must never reach a client's save dialog.
599
+ const base = (_raw_split_pop = raw.split(/[\\/]/).pop()) != null ? _raw_split_pop : '';
600
+ // Cap by CODE POINT so a truncation can't split a surrogate pair into a
601
+ // lone half, which is exactly the kind of thing that later throws in a
602
+ // header encoder.
603
+ return Array.from(base).slice(0, 200).join('') || mediaId;
604
+ }
605
+ /**
606
+ * A `Content-Disposition` value for `name` (AGL-1411).
607
+ *
608
+ * A filename is attacker-adjacent data — it is whatever the uploader typed —
609
+ * and it is being pasted into a header, so a quote or a CRLF in it is a
610
+ * header-injection vector. The old code stripped `["\\\r\n]` and left
611
+ * everything else, which is safe against injection but silently mangles the
612
+ * name and passes non-ASCII through raw: a byte above 0x7e in a header is
613
+ * either mojibake at the client or, on a stricter encoder than ours, a
614
+ * throw. So both forms of RFC 6266 §4.1 are emitted:
615
+ *
616
+ * - `filename="…"` — ASCII only, every unrepresentable character replaced
617
+ * (not dropped) so the extension and the shape of the name survive. This
618
+ * is the fallback, and it always exists.
619
+ * - `filename*=UTF-8''…` — RFC 8187 percent-encoding of the real name, added
620
+ * only when the ASCII form actually lost something. Every current browser
621
+ * prefers it, so a non-ASCII name arrives intact.
622
+ */ export function mediaContentDisposition(name, options) {
623
+ const type = options.download ? 'attachment' : 'inline';
624
+ const ascii = name.replace(NON_ASCII_FIELD_VALUE, '_').replace(NOT_QUOTED_STRING_SAFE, '_');
625
+ const header = `${type}; filename="${ascii}"`;
626
+ if (ascii === name) return header;
627
+ // `encodeURIComponent` leaves `!'()*` alone, and `'`, `(`, `)` and `*` are
628
+ // not `attr-char` (RFC 8187 §3.2.1) — a bare `'` in particular would be
629
+ // read as the charset/language delimiter. Encode them too.
630
+ const encoded = encodeURIComponent(name).replace(/['()*]/g, (character)=>`%${character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`);
631
+ return `${header}; filename*=UTF-8''${encoded}`;
632
+ }
633
+ export function parseMediaCdnRange(header, size) {
634
+ if (typeof header !== 'string') return null;
635
+ const unit = /^bytes=(.*)$/i.exec(header.trim());
636
+ if (!unit) return null;
637
+ const specs = unit[1].split(',');
638
+ if (specs.length !== 1) return null;
639
+ const spec = /^(\d*)-(\d*)$/.exec(specs[0].trim());
640
+ if (!spec) return null;
641
+ const [, startRaw, endRaw] = spec;
642
+ if (!startRaw && !endRaw) return null;
643
+ if (!startRaw) {
644
+ // Suffix form `bytes=-N`: the LAST N bytes. `-0` names zero bytes and a
645
+ // 200 would over-answer it, so it is the one suffix that 416s.
646
+ const suffix = Number(endRaw);
647
+ if (!Number.isSafeInteger(suffix)) return null;
648
+ if (suffix === 0 || size === 0) return 'unsatisfiable';
649
+ return {
650
+ start: Math.max(0, size - suffix),
651
+ end: size - 1
652
+ };
653
+ }
654
+ const start = Number(startRaw);
655
+ if (!Number.isSafeInteger(start)) return null;
656
+ const end = endRaw ? Number(endRaw) : size - 1;
657
+ if (!Number.isSafeInteger(end)) return null;
658
+ // `bytes=5-2` is a syntax error, not an unsatisfiable range — ignore it.
659
+ if (endRaw && end < start) return null;
660
+ if (start >= size) return 'unsatisfiable';
661
+ return {
662
+ start,
663
+ end: Math.min(end, size - 1)
664
+ };
665
+ }
666
+ /**
667
+ * Whether a delivery ended early only because its client stopped reading
668
+ * (AGL-2810).
669
+ *
670
+ * A `<video>` element abandons its opening request once it has buffered
671
+ * enough, and abandons another on every seek, so this is the ordinary way a
672
+ * streamed response ends early — and it is not a delivery failure, so it is
673
+ * neither logged nor answered.
674
+ *
675
+ * `pipeline` reports it as a premature close, and reports a Storage read that
676
+ * closed early the same way. The response tells them apart: a client that
677
+ * canceled destroyed it without an error, while a failed read makes
678
+ * `pipeline` destroy it WITH the read's error.
679
+ */ function mediaCdnClientWentAway(error, res) {
680
+ return (error == null ? void 0 : error.code) === 'ERR_STREAM_PREMATURE_CLOSE' && !res.errored;
681
+ }
682
+ /**
683
+ * The headers that describe the file, removed from an answer that will not
684
+ * carry it.
685
+ *
686
+ * A failure found after the representation was chosen would otherwise go out
687
+ * under the file's own headers: a `Content-Length` of the whole object on a
688
+ * short JSON body leaves the client waiting for bytes that never come, a
689
+ * `video/mp4` type misdescribes the body outright, and the validator would
690
+ * attach the file's identity to an error. `no-store` because the answer is
691
+ * about this request, not the URL.
692
+ */ function clearMediaCdnRepresentation(res) {
693
+ for (const header of [
694
+ 'Content-Type',
695
+ 'Content-Length',
696
+ 'Content-Range',
697
+ 'Content-Disposition',
698
+ 'Accept-Ranges',
699
+ 'ETag'
700
+ ]){
701
+ res.removeHeader(header);
702
+ }
703
+ res.setHeader('Cache-Control', 'no-store');
704
+ }
705
+ /**
706
+ * CDN media delivery (AGL-175 / AGL-829). Two URL shapes resolve the same
707
+ * asset by `mediaId`, so delivery never depends on the object's storage
708
+ * location (folder moves don't change the URL):
709
+ *
710
+ * - **Stable** `/api/media/cdn/[scope]/[mediaId]` — always serves the
711
+ * asset's CURRENT bytes, so it survives a **replace** too. Revalidated
712
+ * with an ETag (the content hash) + `stale-while-revalidate`, so a
713
+ * replaced asset propagates without ever breaking references. This is
714
+ * the URL the console hands out.
715
+ * - **Immutable** `/api/media/cdn/[scope]/[mediaId]/[contentHash]` —
716
+ * year-long `immutable` cache; bytes are served only while the hash
717
+ * matches the current content, and a stale hash 302s to the stable URL
718
+ * (AGL-2685). No renderer names this form (AGL-2798): an edge or a browser
719
+ * holding an immutable response never asks again, so a replace could not
720
+ * reach a page that did. It is answered for the URLs already handed out.
721
+ *
722
+ * `?w=[width]` selects a generated WebP variant. `?download=1` swaps the
723
+ * `Content-Disposition` from `inline` to `attachment` (AGL-1411) — the press
724
+ * kit hands out links that must SAVE rather than open a tab. Both are read
725
+ * only after every access gate, so neither can widen what is served; both
726
+ * are part of the CDN cache key, so neither variant can poison the other.
727
+ * The same handler mounts in both the tenant and console apps; raw storage
728
+ * URLs already embedded in screens keep working unchanged.
729
+ *
730
+ * Single byte-range requests are honored with a 206 (AGL-1442 S4) — see
731
+ * {@link parseMediaCdnRange} and the block below `Accept-Ranges` for the
732
+ * exact semantics. This is the capability whose absence kept video on raw
733
+ * `firebasestorage.googleapis.com` URLs (S8): a `<video>` seek is a Range
734
+ * request, and a server that ignores it forces the player to re-download
735
+ * the whole file.
736
+ *
737
+ * The body streams (AGL-2810): each chunk leaves as Storage produces it, so no
738
+ * file is held whole in function memory. A GET that will read the file is
739
+ * first counted against its caller (AGL-2812); see
740
+ * {@link mediaCdnRateLimitRefusal}.
741
+ */ export async function serveMediaCdn(req, res) {
742
+ var _ref, _ref1;
743
+ // AGL-1474, set FIRST so no exit from this handler can be reached without
744
+ // it — including the refusals, the 304 and the 500. Upgraded below to the
745
+ // sandboxing form once the served content type is known. `nosniff` is
746
+ // repeated here rather than left to the app config's `/(.*)` header: the
747
+ // policy above keys off the declared type, so this response has to carry
748
+ // the header that makes the declared type binding, on its own.
749
+ res.setHeader('Content-Security-Policy', MEDIA_CDN_BASE_CSP);
750
+ res.setHeader('X-Content-Type-Options', 'nosniff');
751
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
752
+ res.setHeader('Allow', 'GET, HEAD');
753
+ res.status(405).end();
754
+ return;
755
+ }
756
+ const path = Array.isArray(req.query['path']) ? req.query['path'] : [];
757
+ const [scopeSegment, mediaId, hash] = path.map((value)=>String(value != null ? value : ''));
758
+ const scope = parseMediaCdnScope(scopeSegment);
759
+ const isOrg = (_ref = scope == null ? void 0 : scope.isOrg) != null ? _ref : false;
760
+ const scopeId = (_ref1 = scope == null ? void 0 : scope.scopeId) != null ? _ref1 : '';
761
+ // 3 segments = the immutable content-hashed URL; 2 = the stable URL.
762
+ const hashed = path.length === 3;
763
+ if (!scope || path.length !== 2 && path.length !== 3 || !SEGMENT.test(mediaId) || hashed && !SEGMENT.test(hash)) {
764
+ res.status(400).json({
765
+ error: 'Bad media path'
766
+ });
767
+ return;
768
+ }
769
+ try {
770
+ var _snapshot_get, _snapshot_get1, _req_query_w, _snapshot_get2, _ref2, _ref3, _metadata_contentType, _metadata_size, _metadata_size1;
771
+ var _req_socket;
772
+ const firestore = firebaseAdmin.app().firestore();
773
+ const snapshot = await firestore.collection(isOrg ? 'orgs' : 'hosts').doc(scopeId).collection('media').doc(mediaId).get();
774
+ const currentHash = String((_snapshot_get = snapshot.get('contentHash')) != null ? _snapshot_get : '');
775
+ if (!snapshot.exists || snapshot.get('deletedAt')) {
776
+ res.setHeader('Cache-Control', 'public, max-age=60');
777
+ res.status(404).json({
778
+ error: 'Not found'
779
+ });
780
+ return;
781
+ }
782
+ // Delivery gate — scope lockdown (AGL-1520) and asset quarantine
783
+ // (AGL-1512). See mediaCdnServeBlock for the reason matrix, the
784
+ // read-cost accounting and the staleness bound. Three shape decisions,
785
+ // argued, and all three apply to BOTH refusal kinds:
786
+ //
787
+ // **Before the ETag/304 exit**, necessarily: a browser revalidating its
788
+ // 60s copy during a lock or a quarantine must be refused, not handed a
789
+ // 304 that renews the copy for another minute.
790
+ //
791
+ // **410, not 423, and identical for both kinds.** The 423 body is the
792
+ // AGL-1506 discipline for authenticated API consumers who deserve
793
+ // "suspended: billing"; this response answers anonymous `<img>` fetches
794
+ // nobody parses, and a 423 would confirm to any prober that a lock
795
+ // exists — which for a security lock is information. It is information
796
+ // for a quarantine too, and of a worse kind: the existence of a DMCA
797
+ // notice or a malware finding on a specific file is not something an
798
+ // anonymous fetcher has standing to learn, and telling quarantine apart
799
+ // from lockdown on the wire would leak which one it was. So the owning
800
+ // org learns the reason in the console (`mediaQuarantineNotice`), and
801
+ // the CDN says only `410 Gone` — neutral, indistinguishable in kind
802
+ // from a deleted asset, and a hint to well-behaved consumers to drop
803
+ // the link. (404 would also be neutral, but 410's "permanently gone"
804
+ // semantics discourage retry loops from embedders.)
805
+ //
806
+ // **`no-store`, absolutely** (the AGL-1515 lesson): the refusal is a
807
+ // function of a TTL'd verdict, not of the URL — a cached refusal would
808
+ // weld the asset's URL identity to "gone" past the unlock, and edge
809
+ // entries have no per-asset purge. The 60s negative window the 404s use
810
+ // is not acceptable here either. For quarantine that argument is
811
+ // strictly stronger than for lockdown: REVERSIBILITY is the whole
812
+ // reason quarantine exists instead of deletion, so a cached 410 that
813
+ // outlived the lift would defeat the feature's only advantage over the
814
+ // irreversible option. The refusal is also nearly free to re-serve —
815
+ // the verdict is one Firestore read per process per 15s for the entire
816
+ // deny list, and the body is a few bytes with no Storage read — so
817
+ // there is no saving to weigh against it. CSP + nosniff are already on
818
+ // the response (set first, before any exit).
819
+ const blockedBy = await mediaCdnServeBlock(scope, {
820
+ // The strong digest is read for the quarantine key ONLY. It is
821
+ // deliberately not the ETag and not the immutable URL segment
822
+ // (AGL-1614): changing either of those would change live URLs and
823
+ // cache validators for every existing asset, and this change must be
824
+ // additive to the point of being invisible on the wire.
825
+ contentSha256: String((_snapshot_get1 = snapshot.get('contentSha256')) != null ? _snapshot_get1 : ''),
826
+ contentHash: currentHash,
827
+ scopeSegment,
828
+ mediaId
829
+ });
830
+ if (blockedBy) {
831
+ res.setHeader('Cache-Control', 'no-store');
832
+ res.status(410).json({
833
+ error: 'Gone'
834
+ });
835
+ return;
836
+ }
837
+ // Scope check (AGL-1043). The bare `org:` form serves ORG-WIDE assets
838
+ // only; a restricted asset must be requested through the form that
839
+ // names the site using it, which is when AGL-1045's confirmation already
840
+ // warns which pages are affected.
841
+ //
842
+ // An asset carrying no `visibleTo` is refused here rather than treated as
843
+ // org-wide: absent is "nobody has said who may see this", not "everybody"
844
+ // (`app-utils/scope-tokens`). The scope backfill stamps such documents and
845
+ // the weekly drift detector reports the ones it has not reached, so the
846
+ // repair is a stamp rather than a wider read.
847
+ //
848
+ // 404 rather than 403: whether a restricted asset exists is itself
849
+ // something the caller has no standing to learn. Short max-age so
850
+ // re-widening a scope propagates quickly instead of being pinned by a
851
+ // long-lived negative cache entry.
852
+ const refusal = mediaCdnScopeRefusal(scope, snapshot.get('visibleTo'));
853
+ if (refusal) {
854
+ /**
855
+ * A `restricted` refusal is the gate WORKING and is deliberately
856
+ * silent: it is the ordinary consequence of an internal asset's URL
857
+ * being shared outside the site it belongs to, it can be provoked by
858
+ * anyone who can guess a scope segment, and a log line per anonymous
859
+ * request would make this route amplify traffic into the drain.
860
+ *
861
+ * The other two cannot be provoked from outside — they are properties
862
+ * of the document, not of the request — and they are the ones nothing
863
+ * else says out loud. An asset undeliverable under every URL form
864
+ * shows up as an image that stopped rendering on a live site, and
865
+ * until the weekly drift detector runs the only trace it leaves is a
866
+ * 404 indistinguishable from a deleted asset. Naming it here is what
867
+ * makes the next occurrence readable from the logs.
868
+ */ if (refusal !== 'restricted') {
869
+ console.error('[media-cdn] asset has no site it can be served to', JSON.stringify({
870
+ scopeId,
871
+ mediaId,
872
+ refusal
873
+ }));
874
+ }
875
+ res.setHeader('Cache-Control', 'public, max-age=60');
876
+ res.status(404).json({
877
+ error: 'Not found'
878
+ });
879
+ return;
880
+ }
881
+ /**
882
+ * Private assets (AGL-1051) leave the public model entirely: no
883
+ * `cdnPath`, and no bytes without an unexpired signature. This is a
884
+ * different question from the scope check above — that one asks which
885
+ * SITE may use the asset, this one asks whether the public may fetch
886
+ * it at all.
887
+ *
888
+ * `no-store`, not a short max-age. Every other response here is safe to
889
+ * cache because it is the same answer for everyone, but a signed
890
+ * request is per-caller and time-boxed: letting a shared cache keep
891
+ * either the denial OR the bytes would outlive the signature and hand
892
+ * the asset to the next requester on the same URL.
893
+ *
894
+ * Which is why the header goes through `setCacheControl` from here on
895
+ * rather than `res.setHeader` — the private case has to win at EVERY
896
+ * exit, and remembering that at each of six call sites is precisely
897
+ * the kind of thing that gets missed when a seventh is added. It
898
+ * already was: the stale-hash 404 below used to overwrite `no-store`
899
+ * with `public, max-age=60`.
900
+ */ const isPrivate = snapshot.get('private') === true;
901
+ const setCacheControl = (value)=>{
902
+ res.setHeader('Cache-Control', isPrivate ? 'private, no-store' : value);
903
+ };
904
+ if (isPrivate) {
905
+ var _req_query_exp, _req_query_sig;
906
+ const signed = verifyMediaAccess(scopeSegment, mediaId, {
907
+ 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 : '')
909
+ });
910
+ setCacheControl('private, no-store');
911
+ if (!signed) {
912
+ res.status(404).json({
913
+ error: 'Not found'
914
+ });
915
+ return;
916
+ }
917
+ }
918
+ /**
919
+ * A stale hash on the immutable form REDIRECTS to the stable URL
920
+ * (AGL-2685). It used to 404.
921
+ *
922
+ * The invariant the 404 protected is intact and is the reason this is a
923
+ * redirect rather than a body: **no version of this URL ever serves bytes
924
+ * that are not the ones its hash names.** A stale one now serves a
925
+ * pointer to the URL that is allowed to change instead of a dead end.
926
+ *
927
+ * What the 404 cost was every copy of this URL breaking on the first
928
+ * **replace** of its asset. With a redirect, a stale URL that reaches
929
+ * this handler costs one extra hop and self-heals. It does NOT make the
930
+ * form safe to publish: an edge or a browser already holding the
931
+ * immutable response answers from its copy and never reaches this line,
932
+ * which is why no renderer names this form (AGL-2798).
933
+ *
934
+ * **302, not 301.** A hash can become current again — a replace reverted
935
+ * to the previous bytes restores it — and a permanent redirect is exactly
936
+ * the thing browsers refuse to forget.
937
+ *
938
+ * **The stable URL's own cache policy**, not a shorter one. The redirect
939
+ * is as re-checkable as the resource it points at, and it is the same
940
+ * answer for every caller, so there is no reason for the edge to hold it
941
+ * less well than it holds the target. `setCacheControl` still forces
942
+ * `private, no-store` for a signed asset.
943
+ *
944
+ * The query string is REBUILT from the parameters this handler reads
945
+ * rather than forwarded: `?w=`, `?download=` and the signature pair are
946
+ * the whole set, they are already validated by the code below, and
947
+ * echoing arbitrary caller input into a `Location` header is a habit
948
+ * worth not having. Dropping an unknown parameter costs a cache-key
949
+ * split and nothing else.
950
+ */ if (hashed && currentHash !== hash) {
951
+ setCacheControl(MEDIA_CDN_STABLE_CACHE_CONTROL);
952
+ res.setHeader('Location', `${MEDIA_CDN_ROUTE}/${scopeSegment}/${mediaId}${mediaCdnForwardedQuery(req.query)}`);
953
+ res.status(302).end();
954
+ return;
955
+ }
956
+ const width = Number((_req_query_w = req.query['w']) != null ? _req_query_w : 0);
957
+ const variants = (_snapshot_get2 = snapshot.get('variants')) != null ? _snapshot_get2 : [];
958
+ /*
959
+ * WHICH REPRESENTATION OF THIS ASSET (AGL-2743).
960
+ *
961
+ * Three derived forms now sit behind one media id — the image variants
962
+ * `?w=` has selected since AGL-175, a video's poster still, and its
963
+ * encoded renditions. They are resolved here, together, because they are
964
+ * mutually exclusive and because every one of them has to reach the ETag,
965
+ * the cache tier and the object path in agreement. A second lookup later
966
+ * in the handler is how a poster ends up cached under the master's
967
+ * validator.
968
+ *
969
+ * Precedence is poster, then rendition, then width, and it is total: a
970
+ * caller sending `?poster=1&r=720p` gets the poster rather than an error,
971
+ * because the parameters are a REQUEST for a representation and the only
972
+ * useful answer to an incoherent one is the safest coherent one.
973
+ *
974
+ * ## Falling back to the master is right for a WIDTH and wrong for a POSTER
975
+ *
976
+ * `?w=` on an asset with no such variant serves the original, and that has
977
+ * always been safe because both answers are the same KIND of thing: an
978
+ * image, larger than asked for. A rendition inherits it for the same
979
+ * reason — an unknown `?r=` serves the master, which is the same video in
980
+ * more bytes.
981
+ *
982
+ * `?poster=1` cannot. The caller asked for a still and the master is a
983
+ * film, so the fallback would answer a `<video poster>` with 60 MB of
984
+ * `video/mp4` — a request for 40 KB satisfied with the entire asset,
985
+ * which is the precise cost this feature exists to remove, delivered by
986
+ * the feature itself. So a poster that does not exist is a **404**, and
987
+ * that is the better answer in the browser too: a `<video>` whose
988
+ * `poster` 404s behaves exactly like a `<video>` with no `poster`, which
989
+ * is what every video did before AGL-2742. It also lets a renderer emit
990
+ * the attribute without first reading the document — the property the
991
+ * fallback was supposed to buy, bought honestly.
992
+ */ const poster = snapshot.get('poster');
993
+ const posterVariants = (_ref2 = poster == null ? void 0 : poster.variants) != null ? _ref2 : [];
994
+ const wantsPoster = mediaCdnWantsPoster(req.query[MEDIA_CDN_POSTER_PARAM]);
995
+ if (wantsPoster && !poster) {
996
+ // See above: a still is not a film, so this is the one representation
997
+ // that must refuse rather than degrade. `no-store` because the answer
998
+ // is about a DOCUMENT field that a poster backfill can change at any
999
+ // time, and a cached 404 would outlive the fix.
1000
+ setCacheControl('private, no-store');
1001
+ res.status(404).json({
1002
+ error: 'No poster'
1003
+ });
1004
+ return;
1005
+ }
1006
+ const usePoster = wantsPoster;
1007
+ const usePosterVariant = usePoster && Boolean(width) && posterVariants.includes(width);
1008
+ const renditionParam = req.query[MEDIA_CDN_RENDITION_PARAM];
1009
+ const renditionKey = String(Array.isArray(renditionParam) ? renditionParam[0] : renditionParam != null ? renditionParam : '');
1010
+ /*
1011
+ * `?r=auto` is the form a PAGE emits (AGL-2753), because a page cannot
1012
+ * know which encodings an asset has — they are produced out of band, long
1013
+ * after the placement. `selectAutoRendition` answers from the document
1014
+ * already read above, so serving the encodings costs no extra read
1015
+ * anywhere and a rendition made today reaches a video placed last month.
1016
+ *
1017
+ * An explicit key stays exact and stays supported: the producer's report
1018
+ * and the specs address one encoding by name, and a debugging request for
1019
+ * `?r=720p-vp9` should get that file or the master, never a negotiation.
1020
+ */ const negotiated = !usePoster && renditionKey === MEDIA_CDN_RENDITION_AUTO;
1021
+ const availableRenditions = usePoster ? [] : parseMediaRenditions(snapshot.get('videoRenditions'));
1022
+ const rendition = usePoster ? undefined : negotiated ? selectAutoRendition(availableRenditions, req.headers['accept']) : availableRenditions.find((entry)=>entry.key === renditionKey);
1023
+ if (negotiated) {
1024
+ // Declared for correctness rather than as the thing keeping this safe.
1025
+ // What keeps it safe is `private` (AGL-1515): a video response is never
1026
+ // held by a shared cache, so there is no entry for a second client's
1027
+ // `Accept` to be answered from. This tells the BROWSER's own cache — and
1028
+ // any intermediary that ignored `private` — that the URL alone does not
1029
+ // identify the bytes. Set before the 304 exit below, because a validator
1030
+ // handed out without it would be reused across representations.
1031
+ res.setHeader('Vary', 'Accept');
1032
+ }
1033
+ const useVariant = !usePoster && !rendition && Boolean(width) && variants.includes(width);
1034
+ /**
1035
+ * The ETag's representation tag, and the cache key's conscience.
1036
+ *
1037
+ * A validator is not scoped to a URL in practice — the reasoning the
1038
+ * `-dl` suffix below was added for — so two representations of one asset
1039
+ * that share a validator can be swapped for each other by any cache that
1040
+ * revalidates. Between an image variant and its original that is a wrong
1041
+ * size; between a poster and its video it is a 40 KB still standing in
1042
+ * 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']);
1048
+ // Stable URL: revalidate against an ETag so a replaced asset is picked
1049
+ // up (a conditional GET returns 304 while the content is unchanged).
1050
+ //
1051
+ // The disposition is part of the ETag for the same reason the variant
1052
+ // width is. The query string is already part of the CDN cache key, so
1053
+ // the two URLs are separate edge entries — but a validator is not scoped
1054
+ // to a URL in practice: a client holding the inline ETag that revalidates
1055
+ // the download URL would be answered 304 and reuse its stored INLINE
1056
+ // headers, and the file would open in a tab anyway. Same bytes, different
1057
+ // representation, so: different validator.
1058
+ const etag = currentHash ? `"${currentHash}${representation}${download ? '-dl' : ''}"` : null;
1059
+ // AGL-1515: which cache tier may hold this response is a function of the
1060
+ // served type — see mediaCdnEdgeCacheable. Decided here from the DOC's
1061
+ // type (a variant serve is always `image/webp`) because the 304 exit
1062
+ // below runs before the Storage metadata read; re-derived from the
1063
+ // 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;
1065
+ // A poster is `image/webp` and therefore edge-cacheable through the rule
1066
+ // that already exists — which is the whole delivery argument for it
1067
+ // (AGL-2743). `mediaCdnEdgeCacheable` is untouched: nothing here relaxes
1068
+ // AGL-1515, because a poster genuinely IS an image and no player ranges
1069
+ // into a `<video poster>`. A RENDITION reports its own `video/*` type and
1070
+ // stays origin-only, deliberately — a small fixed-size rendition is
1071
+ // exactly the size class Vercel's 10 MB cap admits, so edge-caching one
1072
+ // would maximize exposure to the very hybrid AGL-1515 recorded rather
1073
+ // than avoid it.
1074
+ const docServedType = usePoster || useVariant ? 'image/webp' : rendition ? rendition.contentType : snapshot.get('contentType');
1075
+ /*
1076
+ * VIDEO FROM THE DELIVERY PROVIDER (AGL-2824).
1077
+ *
1078
+ * Every gate above has run: the scope, lockdown and quarantine, the
1079
+ * private signature and the stale-pin redirect. With the representation
1080
+ * chosen, a video whose current copy sits at the configured delivery
1081
+ * provider is answered with a `302` to a short-lived signed URL there, and
1082
+ * the provider serves the bytes and their ranges. `mediaDeliveryRedirect`
1083
+ * says null for everything else — an image, a poster, no copy of this
1084
+ * representation made from the current bytes, the release flag off for
1085
+ * the owning org — and the request carries on below exactly as before.
1086
+ *
1087
+ * With no provider configured to deliver, nothing here runs: the check is
1088
+ * synchronous and reads nothing.
1089
+ *
1090
+ * A download stays here, because its disposition is this route's to set.
1091
+ * The redirect is decided before the ETag and the 304: it names a URL
1092
+ * that expires, so it carries no validator and `private, no-store`, and a
1093
+ * private asset's URL expires no later than the signature that reached
1094
+ * it. It is not counted against the caller's delivery budget, which
1095
+ * guards the bytes this route sends; a redirect sends none.
1096
+ */ const deliveryProvider = usePoster || useVariant || download ? null : mediaDeliveryProvider('deliver');
1097
+ if (deliveryProvider) {
1098
+ var _scope_contextHostId;
1099
+ const delivery = await mediaDeliveryRedirect(_extends({
1100
+ provider: deliveryProvider,
1101
+ asset: {
1102
+ collection: isOrg ? 'orgs' : 'hosts',
1103
+ scopeId,
1104
+ mediaId
1105
+ },
1106
+ document: snapshot,
1107
+ rendition,
1108
+ servedType: docServedType,
1109
+ claims: {
1110
+ scope: scopeSegment,
1111
+ mediaId,
1112
+ hostId: isOrg ? (_scope_contextHostId = scope.contextHostId) != null ? _scope_contextHostId : null : scopeId
1113
+ },
1114
+ orgId: ()=>mediaDeliveryOrgIdFor(isOrg ? 'orgs' : 'hosts', scopeId)
1115
+ }, isPrivate ? {
1116
+ notAfterMs: Number(req.query['exp'])
1117
+ } : {})).catch((error)=>{
1118
+ // The provider is an alternative to the path below, never a reason
1119
+ // for this request to fail.
1120
+ console.error('[media-cdn] delivery redirect failed', scopeSegment, mediaId, error);
1121
+ return null;
1122
+ });
1123
+ if (delivery) {
1124
+ res.setHeader('Cache-Control', 'private, no-store');
1125
+ res.setHeader('Location', delivery.location);
1126
+ // 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.
1129
+ 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);
1142
+ }
1143
+ res.status(302).end();
1144
+ return;
1145
+ }
1146
+ }
1147
+ if (!hashed) {
1148
+ setCacheControl(stableCacheControlFor(docServedType));
1149
+ if (etag) res.setHeader('ETag', etag);
1150
+ if (etag && req.headers['if-none-match'] === etag) {
1151
+ res.status(304).end();
1152
+ return;
1153
+ }
1154
+ }
1155
+ const bucket = firebaseAdmin.app().storage().bucket(process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']);
1156
+ // Real folders: the doc records its object path; legacy assets fall
1157
+ // back to the flat layout.
1158
+ //
1159
+ // The recorded path is CLIENT DATA and this is an ADMIN-SDK bucket read,
1160
+ // which the Storage rules do not constrain — so it is honoured only when
1161
+ // it is genuinely inside this scope's media prefix (AGL-1881). Without
1162
+ // that check a host or org editor could point their own media document at
1163
+ // any key in the shared bucket and have this route stream it to an
1164
+ // anonymous caller behind a `s-maxage=3600` edge-cached URL, including the
1165
+ // fixed `adminAudit-archive/` and `erasures/` retention prefixes.
1166
+ const basePath = mediaStoragePathInScope({
1167
+ storagePath: snapshot.get('storagePath'),
1168
+ base: `${isOrg ? 'orgs' : 'hosts'}/${scopeId}`,
1169
+ mediaId,
1170
+ onRefused: (candidate)=>{
1171
+ console.error('[media-cdn] out-of-scope storagePath refused', JSON.stringify({
1172
+ scopeId,
1173
+ mediaId,
1174
+ candidate
1175
+ }));
1176
+ }
1177
+ });
1178
+ // Every derived path is built from `basePath`, which `mediaStoragePathInScope`
1179
+ // has already proved is inside this scope's prefix (AGL-1881), plus a
1180
+ // suffix whose grammar admits no separator: the widths are numbers,
1181
+ // and `parseMediaRendition` refuses a key or extension that is not
1182
+ // `[a-z0-9-]`. So no representation can address an object the master
1183
+ // 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);
1186
+ // The caller's count (AGL-2812), asked here and nowhere earlier: past every
1187
+ // gate and the 304 exit, where the Storage reads begin. It starts before the
1188
+ // metadata read and is awaited after it, so the two run together. It never
1189
+ // rejects, and answers `null` whenever it could not count.
1190
+ const callerCount = req.method === 'GET' ? mediaCdnRateLimitRefusal({
1191
+ headers: req.headers,
1192
+ remoteAddress: (_req_socket = req.socket) == null ? void 0 : _req_socket.remoteAddress,
1193
+ rateClass: mediaCdnEdgeCacheable(docServedType) ? 'image' : 'non-image'
1194
+ }) : null;
1195
+ const [metadata] = await file.getMetadata().catch(()=>[
1196
+ null
1197
+ ]);
1198
+ if (!metadata) {
1199
+ res.status(404).json({
1200
+ error: 'Not found'
1201
+ });
1202
+ return;
1203
+ }
1204
+ const refused = await callerCount;
1205
+ if (refused) {
1206
+ // The file's validator and cache policy are already set. A refusal is
1207
+ // about this caller rather than the URL, so no cache may keep it: an
1208
+ // edge holding a 429 would refuse every visitor to that image for an
1209
+ // hour.
1210
+ clearMediaCdnRepresentation(res);
1211
+ res.setHeader('Retry-After', String(refused.retryAfterSeconds));
1212
+ res.status(429).json({
1213
+ error: 'Too many requests'
1214
+ });
1215
+ return;
1216
+ }
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');
1218
+ res.setHeader('Content-Type', servedType);
1219
+ // AGL-1474: an SVG (or anything else a browser treats as a document) gets
1220
+ // the sandboxing policy. Everything else keeps the base one set above —
1221
+ // `sandbox` is withheld from raster and PDF responses on purpose, since it
1222
+ // would change how a directly-opened PDF is handled for no security gain.
1223
+ res.setHeader('Content-Security-Policy', mediaCdnContentSecurityPolicy(servedType));
1224
+ // Range support (AGL-1442 S4) — this is what lets video ride this route
1225
+ // at all: a `<video>` seek is a Range request, and before S4 the raw
1226
+ // storage URL was the only server that would answer one.
1227
+ //
1228
+ // `Accept-Ranges` is advertised unconditionally because it is now TRUE
1229
+ // unconditionally — the handler honors a single byte-range for every
1230
+ // asset class. Limiting it to "types that benefit" would only teach a
1231
+ // PDF viewer or a download manager not to ask for something we would
1232
+ // happily serve, and the header is not part of any cache key.
1233
+ //
1234
+ // Caching shape (AGL-1515): S4 read Vercel's cacheable-response
1235
+ // criteria ("Request doesn't contain Range header") as "a cached full
1236
+ // body and a function-served partial can never cross". Production
1237
+ // falsified that: the criteria govern what the edge STORES, not what it
1238
+ // SERVES, and the edge answers a ranged request from an existing cached
1239
+ // full-body 200 as a 200 + Content-Range + sliced-body hybrid. The fix
1240
+ // is upstream of this line — every type a ranged consumer actually uses
1241
+ // is served `private` (see mediaCdnEdgeCacheable), so the entry the
1242
+ // edge would mangle never exists and every ranged request reaches this
1243
+ // function for a real 206. In the browser the 206 rides the same
1244
+ // Cache-Control as its URL class: a 206 under a STRONG validator is
1245
+ // exactly what lets a player reuse the segments it already fetched.
1246
+ res.setHeader('Accept-Ranges', 'bytes');
1247
+ // The authoritative tier decision, now that the served type is known
1248
+ // from Storage metadata rather than inferred from the doc. Overwrites
1249
+ // the header set before the 304 exit iff the two disagree — and the
1250
+ // BODY-serving response is the one the edge could store, so this one
1251
+ // must win.
1252
+ if (!hashed) setCacheControl(stableCacheControlFor(servedType));
1253
+ const size = Number((_metadata_size = metadata.size) != null ? _metadata_size : NaN);
1254
+ // Range applies to GET only (RFC 9110 §14.2); a HEAD answers the full
1255
+ // representation's metadata. And with no known size there is no honest
1256
+ // `Content-Range` to write, so the header is ignored and the full body
1257
+ // served — the degradation every unrecognised shape shares.
1258
+ const parsed = req.method === 'GET' && Number.isFinite(size) && size >= 0 ? parseMediaCdnRange(req.headers['range'], size) : null;
1259
+ // `If-Range`: the client is saying "this range is against version X; if
1260
+ // you hold anything else, send me the whole file". Honoring the range on
1261
+ // a mismatch is how a stale player splices two versions of a video into
1262
+ // one stream — so anything but an exact match with the CURRENT strong
1263
+ // validator (including a date form, and including the no-hash legacy
1264
+ // shape that has no validator at all) collapses to a full 200.
1265
+ const ifRange = req.headers['if-range'];
1266
+ const range = parsed !== null && typeof ifRange === 'string' && ifRange !== etag ? null : parsed;
1267
+ if (range === 'unsatisfiable') {
1268
+ // The refusal is a function of the REQUEST header, not the URL, and
1269
+ // shared caches key on the URL — no cache may keep it.
1270
+ setCacheControl('private, no-store');
1271
+ res.setHeader('Content-Range', `bytes */${size}`);
1272
+ res.status(416).end();
1273
+ return;
1274
+ }
1275
+ const partial = range !== null && typeof range === 'object';
1276
+ const servedBytes = partial ? range.end - range.start + 1 : Number((_metadata_size1 = metadata.size) != null ? _metadata_size1 : 0);
1277
+ if (partial) {
1278
+ res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${size}`);
1279
+ res.setHeader('Content-Length', String(servedBytes));
1280
+ } else if (metadata.size) {
1281
+ res.setHeader('Content-Length', String(metadata.size));
1282
+ }
1283
+ // Give downloads a real filename+extension (AGL-834): the URL is
1284
+ // mediaId-keyed and extensionless, so without this a "save image" lands
1285
+ // as a name-less blob. `?download=1` makes it an actual download rather
1286
+ // than a tab (AGL-1411).
1287
+ res.setHeader('Content-Disposition', mediaContentDisposition(mediaDownloadName(snapshot.get('fileName'), mediaId), // `?download=1` can force a download; nothing can force an INLINE
1288
+ // render of a type that has no inline use here.
1289
+ {
1290
+ download: download || !servesInline(servedType)
1291
+ }));
1292
+ // `immutable` for a year is the strongest possible caching, and on a
1293
+ // private asset it would be a permanent public copy of a file whose
1294
+ // whole point is that it expires (AGL-1051) — `setCacheControl` holds
1295
+ // that line. The year stays browser-only for non-image types
1296
+ // (AGL-1515): the URL still changes with the bytes, but the edge must
1297
+ // hold no full body it could slice.
1298
+ if (hashed) {
1299
+ setCacheControl(mediaCdnEdgeCacheable(servedType) ? MEDIA_CDN_IMMUTABLE_CACHE_CONTROL : MEDIA_CDN_IMMUTABLE_EDGE_BYPASS_CACHE_CONTROL);
1300
+ }
1301
+ // HEAD returns here, before the delivery counter below (AGL-2811). It
1302
+ // carries a GET's headers and no body, and `servedBytes` is the whole
1303
+ // object for any request without a range. `serves` and `bytes` count
1304
+ // representations that left, which is also why the 304 and 416 exits
1305
+ // above return before this point.
1306
+ if (req.method === 'HEAD') {
1307
+ res.status(200).end();
1308
+ return;
1309
+ }
1310
+ // Delivery volume (AGL-176): per-asset serves/bytes on the AGL-82
1311
+ // analytics day-doc, fire-and-forget. Only cache MISSES reach this
1312
+ // code — edge-cached responses aren't counted, so these are origin
1313
+ // serves, not user-facing totals (billing accuracy is AGL-41's job).
1314
+ // Hot-doc note: a single day-doc caps at ~1 write/sec sustained;
1315
+ // acceptable at current traffic, shard or sample if an asset gets hot.
1316
+ //
1317
+ // Written before the first byte, so `bytes` is what this request asked
1318
+ // the origin to send. A client that abandons the stream receives less;
1319
+ // the figure is the ceiling on what left, not a count of what arrived.
1320
+ const day = new Date().toISOString().slice(0, 10);
1321
+ void firestore.collection(isOrg ? 'orgs' : 'hosts').doc(scopeId).collection('analytics').doc(day).set({
1322
+ // Retention (AGL-1844): every writer of a day doc stamps the
1323
+ // day-anchored expiry the TTL policy sweeps on.
1324
+ expiresAt: analyticsDayExpiresAt(day),
1325
+ media: {
1326
+ [mediaId]: {
1327
+ serves: firebaseAdmin.firestore.FieldValue.increment(1),
1328
+ // The bytes that actually leave, not the object's size — a
1329
+ // seek in a 200 MB video is a few MB of egress, and counting
1330
+ // the full object per range would overstate delivery by the
1331
+ // number of seeks (AGL-1442 S4).
1332
+ bytes: firebaseAdmin.firestore.FieldValue.increment(servedBytes)
1333
+ }
1334
+ }
1335
+ }, {
1336
+ merge: true
1337
+ }).catch(()=>undefined);
1338
+ if (partial) res.status(206);
1339
+ // `start`/`end` are inclusive in `createReadStream`, matching the parsed
1340
+ // range — GCS is asked for exactly the requested bytes and nothing is
1341
+ // over-read from Storage on a partial serve.
1342
+ //
1343
+ // The response streams (AGL-2810): each chunk leaves as Storage produces
1344
+ // it, and the next is read only once the client has taken the last, so a
1345
+ // file is never held in function memory whatever its size. `pipeline`
1346
+ // rather than `pipe` because streaming needs teardown in both directions,
1347
+ // which `pipe` does not give — a failed Storage read must fail the
1348
+ // 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);
1353
+ } catch (error) {
1354
+ if (mediaCdnClientWentAway(error, res)) return;
1355
+ console.error('serveMediaCdn failed', scopeSegment, mediaId, error);
1356
+ if (!res.headersSent) {
1357
+ clearMediaCdnRepresentation(res);
1358
+ res.status(500).json({
1359
+ error: 'Delivery failed'
1360
+ });
1361
+ } else {
1362
+ // The status line has already left, so the body is the only place the
1363
+ // failure can surface. A destroyed response is a broken transfer to the
1364
+ // client; ending it would pass a truncated file off as a whole one.
1365
+ res.destroy(error instanceof Error ? error : new Error(String(error)));
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ //# sourceMappingURL=serve-media-cdn.js.map