@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 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sending-domains.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * CUSTOM SENDING DOMAINS — durable half.\n *\n * The record, the DNS lookups and the status transitions. The decisions all\n * live in `@aglyn/shared-util-email`'s `sending-domain.ts`, which is pure;\n * this module is the I/O around them, and it holds no policy of its own.\n *\n * ## Per-org record, per-host selection\n *\n * The record is at `orgs/{orgId}/sendingDomains/{domain}`, alongside\n * `orgs/{orgId}/ssoDomains/{domain}` and for the same reason: proving control\n * of a zone is a property of the ORG that proved it, and an agency running\n * four sites on `client.com` should publish the DKIM record once rather than\n * four times.\n *\n * Which identity a given site SENDS on is a separate per-host field,\n * `hosts/{hostId}.sendingDomain`. That split is what answers the two halves of\n * the question at once: per-org verification keeps the DNS chore down, and a\n * per-host selection is what an agency's client actually wants, because the\n * `From:` their recipients see belongs to the site, not to the agency.\n *\n * ## Reads are cheap on purpose\n *\n * `resolveHostSendingIdentity` is on the path of a campaign send, so it reads\n * the host document the caller already has and at most ONE org subcollection\n * document. It never scans, never lists, and never touches DNS — the standing\n * rule against unrequested reads on a hot path applies here more than most,\n * because a campaign resolves an identity once for thousands of messages.\n *\n * ## Why the provider call is not in here\n *\n * Issuing a DKIM key means creating a domain at the mail provider, which needs\n * a credential that can create things — a different one from the send-only\n * `RESEND_API_KEY`, which is exactly why `email-health.ts` can use the domains\n * endpoint as a read-only credential probe.\n *\n * That credential belongs to the console alone, so the driver that reads it\n * lives in the console app — `apps/console/utils/server/` — and not here.\n * **This library is imported by the tenant runtime**, which\n * serves untrusted published sites; a module it can import is a module whose\n * environment read is one bug away from being reachable from a site request.\n * A file in `apps/console` is not importable from the tenant app at all —\n * there is no path mapping to it and nx's module boundaries forbid app→app —\n * so the isolation is structural rather than a convention.\n *\n * What stays here is the seam: {@link recordIssuedSendingDomain} takes what a\n * provider returned, and {@link recordSendingDomainIssueFailure} takes what it\n * refused. `requestSendingDomain` still stops at `requested`, and a domain\n * stopped there refuses sends, which is the correct behavior for a domain that\n * has no signing key.\n */\n\nimport {\n assessDmarc,\n assessSendingRecords,\n isPlatformSendingDomain,\n isSharedSendingDomain,\n normalizeLocalPart,\n normalizeSendingDomain,\n resolveSendingIdentity,\n safeProviderDetail,\n SENDING_SUBDOMAIN,\n SENDING_TRACKING_SUBDOMAIN,\n sendingDnsRecords,\n sendingDomainRequiredRecords,\n sendingRecordKey,\n sharedTenantSendingFrom,\n validateSendingDomain,\n type DmarcAssessment,\n type SendingDnsRecord,\n type SendingDomainRecord,\n type SendingDomainSelection,\n type SendingIdentityPurpose,\n type SendingIdentityVerdict,\n type SendingVerification,\n} from '@aglyn/shared-util-email'\nimport firebaseAdmin from './firebase-admin'\nimport { lookupCaa, lookupMx, lookupTxt } from './dns-probe'\nimport { getOrgForHost } from './organizations'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Subcollection under the owning org. */\nexport const SENDING_DOMAINS_COLLECTION = 'sendingDomains'\n\nconst domainRef = (orgId: string, domain: string) =>\n firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(SENDING_DOMAINS_COLLECTION)\n .doc(domain)\n\n/**\n * The DKIM selector issued to one org for one domain.\n *\n * Includes the org id so two orgs verifying the same name occupy different\n * record names. Without that they would share `resend._domainkey.<domain>`,\n * and whichever verified second would overwrite the first — or, worse, inherit\n * the first's verification without ever publishing anything.\n */\nexport function sendingDkimSelector(orgId: string): string {\n const safe = String(orgId ?? '')\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '')\n .slice(0, 24)\n return `aglyn${safe ? `-${safe}` : ''}`\n}\n\n/** Both keys always present, one always null — `strictNullChecks` is off. */\nexport interface SendingDomainResult {\n record: SendingDomainRecord | null\n error: string | null\n /** The HTTP status a route should answer with. */\n status: number\n}\n\n/**\n * One stored document as a record.\n *\n * Exported so the re-check sweep, which reaches these documents through a\n * collection-group query rather than by org and name, shapes them the same way\n * every other reader here does — a second reader with its own defaults is how\n * two callers come to disagree about what an absent field means.\n */\nexport function readSendingDomainRecord(\n snapshot: FirebaseFirestore.DocumentSnapshot,\n): SendingDomainRecord | null {\n if (!snapshot?.exists) return null\n const data = snapshot.data() ?? {}\n return {\n domain: String(data.domain ?? snapshot.id),\n status: data.status ?? 'requested',\n dkimSelector: String(data.dkimSelector ?? ''),\n dkimPublicKey: data.dkimPublicKey ?? null,\n returnPathHost: data.returnPathHost ?? null,\n providerDomainId: data.providerDomainId ?? null,\n trackingTarget: data.trackingTarget ?? null,\n createdAtMs: Number(data.createdAtMs) || null,\n verifiedAtMs: Number(data.verifiedAtMs) || null,\n lastCheckedAtMs: Number(data.lastCheckedAtMs) || null,\n lastIssueError: data.lastIssueError ?? null,\n lastIssueAtMs: Number(data.lastIssueAtMs) || null,\n lastMissing: Array.isArray(data.lastMissing) ? data.lastMissing : null,\n }\n}\n\n/**\n * Start (or re-read) a claim on a sending domain.\n *\n * Idempotent, and deliberately non-destructive on a re-request: an existing\n * record keeps its selector and its key, because reissuing either would\n * invalidate a record the customer may already have published and turn a\n * working setup into a mysterious failure. This mirrors `issueDomainClaim`.\n */\nexport async function requestSendingDomain(options: {\n orgId: string\n domain: string\n}): Promise<SendingDomainResult> {\n const { domain, error } = validateSendingDomain(options?.domain)\n if (!domain) return { record: null, error, status: 400 }\n if (!options?.orgId) {\n return { record: null, error: 'Missing organization', status: 400 }\n }\n\n const ref = domainRef(options.orgId, domain)\n const existing = await ref.get()\n if (existing.exists) {\n return { record: readSendingDomainRecord(existing), error: null, status: 200 }\n }\n\n const record: SendingDomainRecord = {\n domain,\n status: 'requested',\n dkimSelector: sendingDkimSelector(options.orgId),\n dkimPublicKey: null,\n returnPathHost: null,\n createdAtMs: Date.now(),\n }\n await ref.set(record, { merge: true })\n return { record, error: null, status: 201 }\n}\n\n/**\n * Record the key the mail provider issued, moving the domain to\n * `records-issued` so the customer has something to publish.\n *\n * Separated from {@link requestSendingDomain} because it needs a credential\n * that may not exist. Nothing here calls the provider: the caller supplies\n * what it was given, so a deployment whose key cannot create domains still has\n * a working path — an operator can complete this step by hand — and this\n * module never grows a dependency on a credential it cannot assume. The\n * console holds the credential and the driver that uses it; this library,\n * which the tenant runtime also imports, holds neither.\n *\n * ## `records-issued` is a promise that records exist\n *\n * The write is refused unless the resulting record actually yields a DKIM\n * record with a value. The alternative is the failure this whole feature is\n * arranged against: a status saying the customer has records to publish, next\n * to a records table with an empty DKIM row, which cannot ever verify and\n * reads to the customer as our bug.\n *\n * ## An issued key is never overwritten\n *\n * A second call carrying a DIFFERENT key is refused rather than applied. The\n * customer may already have published the first one, and replacing it turns a\n * finished setup into a domain that silently stops signing. Re-recording the\n * SAME key is a no-op and succeeds, so a retried request is safe.\n *\n * The SELECTOR, by contrast, comes from the provider when it supplies one:\n * the record a customer publishes has to be the name the provider will sign\n * under, and `sendingDkimSelector` only proposes it.\n */\nexport async function recordIssuedSendingDomain(options: {\n orgId: string\n domain: string\n dkimPublicKey: string\n /** The selector the provider issued, when it chose its own. */\n dkimSelector?: string\n returnPathHost?: string\n providerDomainId?: string\n /**\n * The provider's tracking host, when it issued one — see\n * `SendingDomainRecord.trackingTarget`. Absent leaves the domain sending\n * and unmeasured rather than unsent.\n */\n trackingTarget?: string | null\n}): Promise<SendingDomainResult> {\n const domain = normalizeSendingDomain(options?.domain)\n const key = String(options?.dkimPublicKey ?? '').trim()\n if (!domain) {\n return { record: null, error: 'Missing domain', status: 400 }\n }\n\n const ref = domainRef(options.orgId, domain)\n const snapshot = await ref.get()\n if (!snapshot.exists) {\n return { record: null, error: 'No claim on that domain', status: 404 }\n }\n\n const existing = readSendingDomainRecord(snapshot)\n const held = String(existing?.dkimPublicKey ?? '').trim()\n if (held && held !== key) {\n return {\n record: existing,\n error:\n 'That domain already has an issued signing key. Release the domain ' +\n 'and add it again to start over.',\n status: 409,\n }\n }\n\n const selector =\n String(options?.dkimSelector ?? '').trim() || existing?.dkimSelector || ''\n const returnPathHost = options.returnPathHost || existing?.returnPathHost\n\n /*\n * The only thing standing between a caller and `records-issued`, and it is\n * asked of the SAME function that prints the records for the customer and\n * that the verifier compares against — not a truthiness check on the key,\n * which would be this write's private opinion of what \"publishable\" means\n * and could agree with nothing else.\n */\n const issued = sendingDomainRequiredRecords({\n domain,\n dkimSelector: selector,\n dkimPublicKey: key,\n returnPathHost,\n })\n if (!issued.some((entry) => entry.purpose === 'dkim')) {\n return {\n record: existing,\n error: 'Refusing to issue records for a domain with no publishable DKIM record',\n status: 400,\n }\n }\n\n await ref.set(\n {\n status: 'records-issued',\n dkimPublicKey: key,\n ...(selector ? { dkimSelector: selector } : {}),\n ...(options.returnPathHost\n ? { returnPathHost: options.returnPathHost }\n : {}),\n ...(options.providerDomainId\n ? { providerDomainId: String(options.providerDomainId) }\n : {}),\n // Only when the provider issued one. Writing an empty value would put a\n // tracking CNAME with no target in front of a customer, which is the\n // blank-record shape `sendingDnsRecords` refuses everywhere else.\n ...(String(options.trackingTarget ?? '').trim()\n ? { trackingTarget: String(options.trackingTarget).trim() }\n : {}),\n // A previous failure is not part of the record once it succeeded.\n lastIssueError: firebaseAdmin.firestore.FieldValue.delete(),\n },\n { merge: true },\n )\n return { record: readSendingDomainRecord(await ref.get()), error: null, status: 200 }\n}\n\n/**\n * Record that the provider did not issue anything, WITHOUT moving the status.\n *\n * The point of a separate function is that there is no path from a provider\n * failure to `records-issued`. A `4xx` or `5xx` means no key exists, so the\n * domain stays `requested` — where it refuses sends, which is correct for a\n * domain that cannot sign — and carries a reason an admin can act on instead\n * of appearing to have finished.\n *\n * `detail` is a short code the caller built from a fixed vocabulary, never a\n * provider's response body. {@link safeProviderDetail} is the second line:\n * an `Authorization` header echoed back by a provider must not become a\n * Firestore document.\n */\nexport async function recordSendingDomainIssueFailure(options: {\n orgId: string\n domain: string\n detail: string\n}): Promise<void> {\n const domain = normalizeSendingDomain(options?.domain)\n if (!options?.orgId || !domain) return\n const ref = domainRef(options.orgId, domain)\n if (!(await ref.get()).exists) return\n await ref.set(\n {\n lastIssueError: safeProviderDetail(options.detail) || 'unknown',\n lastIssueAtMs: Date.now(),\n },\n { merge: true },\n )\n}\n\nexport interface SendingDomainView {\n record: SendingDomainRecord\n /** Exactly what the customer must publish. */\n records: SendingDnsRecord[]\n /** Their DMARC policy, read and never written. */\n dmarc: DmarcAssessment | null\n}\n\n/** One domain plus the records it needs, for a surface that shows both. */\nexport async function getSendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<SendingDomainView | null> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) return null\n const record = readSendingDomainRecord(await domainRef(orgId, domain).get())\n if (!record) return null\n return { record, records: sendingDnsRecords(record), dmarc: null }\n}\n\nexport async function listSendingDomains(\n orgId: string,\n): Promise<SendingDomainRecord[]> {\n if (!orgId) return []\n const snapshot = await firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(SENDING_DOMAINS_COLLECTION)\n .get()\n return snapshot.docs.map(readSendingDomainRecord).filter(Boolean)\n}\n\n/**\n * Read the customer's DMARC policy.\n *\n * Advisory in both directions: it never blocks verification and it is never\n * written. A domain under `p=reject` with our DKIM unpublished hard-fails\n * every message, and a customer deserves to be told that before they wonder\n * why their campaign vanished.\n */\nexport async function readDmarcPolicy(\n rawDomain: string,\n): Promise<DmarcAssessment | null> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!domain) return null\n const lookup = await lookupTxt(`_dmarc.${domain}`)\n // An unreachable lookup is not \"they have no policy\" — saying so would tell\n // a customer under p=reject that they have no protection.\n if (!lookup.answered) return null\n return assessDmarc(lookup.records)\n}\n\n/**\n * Start (or read) the hold that keeps a tracked domain's links alive.\n *\n * Stamped on FIRST sighting rather than computed from an orphan timestamp,\n * because there is no reliable one: a label reassignment leaves no record of\n * when it happened, and the reaper re-infers the orphan on every pass. The\n * stamp is the record of when the hold began, so a domain cannot be held\n * forever by a sweep that keeps starting the clock over.\n *\n * @returns the instant the domain may be released, or `null` when there is\n * nothing to stamp it on — a domain with no org record cannot be\n * held, and refusing to release it on the strength of a hold nobody\n * can see would strand a provider slot permanently.\n */\nexport async function holdTrackedSendingDomain(options: {\n orgId: string | null\n domain: string\n nowMs: number\n windowMs: number\n}): Promise<number | null> {\n const orgId = String(options?.orgId ?? '').trim()\n const domain = normalizeSendingDomain(options?.domain)\n if (!orgId || !domain || options.windowMs <= 0) return null\n\n const ref = domainRef(orgId, domain)\n const snapshot = await ref.get().catch(() => null)\n if (!snapshot?.exists) return null\n\n const held = Number(snapshot.get('trackingRetentionUntilMs')) || 0\n if (held > 0) return held\n\n const until = options.nowMs + options.windowMs\n await ref\n .set({ trackingRetentionUntilMs: until }, { merge: true })\n // A stamp we could not write is a hold we cannot prove later, so the\n // caller is told there is none rather than being asked to wait on a\n // deadline that will restart on the next pass.\n .catch(() => null)\n return until\n}\n\n/**\n * Whether this domain publishes a CAA policy that would REFUSE the tracking\n * host's certificate.\n *\n * The question decides whether a customer is shown a CAA record at all, and\n * asking it is the difference between guidance and a footgun. CAA restricts\n * which authorities may issue, and the lookup stops at the first name in the\n * tree publishing any record — so:\n *\n * - A domain publishing NOTHING needs nothing. Any authority may already\n * issue, and handing over a CAA record to paste would be the change that\n * STARTS restricting them, breaking whatever else renews on that name.\n * - A domain that publishes some, none of which name our authority, has to\n * add one ALONGSIDE what it has.\n * - A domain that already names our authority is done.\n *\n * Walks up from the tracking host exactly as a certificate authority does,\n * and stops where a real lookup stops: at the first name with any record.\n *\n * @returns `null` when DNS could not be reached, which is not \"they publish\n * none\" — telling a customer to add a CAA on the strength of an\n * unanswered lookup is how a working zone gets narrowed by mistake.\n */\nexport async function readTrackingCaaNeed(\n rawDomain: string,\n authority: string,\n): Promise<'not-needed' | 'must-add' | 'satisfied' | null> {\n const domain = normalizeSendingDomain(rawDomain)\n const ca = String(authority ?? '').trim().toLowerCase()\n if (!domain || !ca) return null\n\n /*\n * From the tracking host upward. Every label is asked because a CAA on a\n * closer name overrides a broader one — a customer who already scoped a\n * policy to `links.` must not be told to widen their root.\n */\n const labels = `${SENDING_TRACKING_SUBDOMAIN}.${domain}`.split('.')\n for (let index = 0; index < labels.length - 1; index += 1) {\n const name = labels.slice(index).join('.')\n const lookup = await lookupCaa(name)\n if (!lookup.answered) return null\n if (!lookup.records.length) continue\n // The first name with any record is where issuance is decided.\n const issuers = lookup.records\n .filter((entry) => / issue \"/.test(entry))\n .map((entry) => entry.toLowerCase())\n return issuers.some((entry) => entry.includes(`\"${ca}\"`))\n ? 'satisfied'\n : 'must-add'\n }\n return 'not-needed'\n}\n\n/**\n * Read the live DNS for one record and say what it establishes. Writes\n * nothing.\n *\n * Split out so the two callers that need this answer ask the SAME question of\n * the SAME resolvers: {@link verifySendingDomain}, which acts on it\n * immediately, and the unattended re-check sweep, which counts conclusive\n * failures before it acts. A sweep that assessed the records its own way would\n * be a second opinion on \"are these published\", and the customer would meet\n * whichever one happened to run last.\n *\n * The three lookups run together because they are independent and the caller\n * is waiting on the slowest either way.\n */\nexport async function probeSendingRecords(\n record: SendingDomainRecord,\n): Promise<SendingVerification> {\n const domain = normalizeSendingDomain(record?.domain ?? '')\n const sendHost = `${SENDING_SUBDOMAIN}.${domain}`\n const dkimHost = `${record.dkimSelector}._domainkey.${domain}`\n const [spf, dkim, mx] = await Promise.all([\n lookupTxt(sendHost),\n lookupTxt(dkimHost),\n lookupMx(sendHost),\n ])\n\n return assessSendingRecords(record, {\n spfTxt: spf.records,\n dkimTxt: dkim.records,\n mx: mx.records,\n // All three, not any: a record we could not read is not a record that is\n // absent, and one unreadable lookup is enough to make the whole answer\n // evidence of nothing.\n conclusive: spf.answered && dkim.answered && mx.answered,\n })\n}\n\n/**\n * Check the live DNS and move the record to `verified` or `failed`.\n *\n * Never throws: a sweep across every org must not stop at the first bad zone.\n *\n * An INCONCLUSIVE result writes nothing but the check time. A resolver outage\n * must not un-verify a working domain — which would silently stop that\n * tenant's mail — and must not fail a customer who is midway through\n * publishing either. This is the `hold` arm the SSO drift sweep uses, and the\n * reason `assessSendingRecords` has three outcomes rather than two.\n *\n * Acts on ONE conclusive answer, and that is right for this caller: an admin\n * pressed Verify and is watching the result, so the check is attended and its\n * outcome is immediately visible and immediately retryable. The unattended\n * sweep over already-verified domains is the caller that cannot say that, and\n * `sending-domain-recheck.ts` is where the extra evidence it needs is\n * gathered.\n */\nexport async function verifySendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<{\n record: SendingDomainRecord | null\n missing: string[]\n inconclusive: boolean\n error: string | null\n}> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) {\n return { record: null, missing: [], inconclusive: false, error: 'Invalid domain' }\n }\n\n const ref = domainRef(orgId, domain)\n const record = readSendingDomainRecord(await ref.get())\n if (!record) {\n return {\n record: null,\n missing: [],\n inconclusive: false,\n error: 'No claim on that domain — add it first.',\n }\n }\n\n const verdict = await probeSendingRecords(record)\n\n const now = Date.now()\n if (verdict.status === 'inconclusive') {\n await ref.set({ lastCheckedAtMs: now }, { merge: true })\n return { record, missing: [], inconclusive: true, error: null }\n }\n\n const verified = verdict.status === 'verified'\n await ref.set(\n {\n status: verified ? 'verified' : 'failed',\n lastCheckedAtMs: now,\n ...(verified\n ? {\n verifiedAtMs: now,\n lastMissing: firebaseAdmin.firestore.FieldValue.delete(),\n }\n : { lastMissing: verdict.missing }),\n },\n { merge: true },\n )\n\n return {\n record: readSendingDomainRecord(await ref.get()),\n missing: verdict.missing,\n inconclusive: false,\n error: null,\n }\n}\n\n/**\n * Drop a claim.\n *\n * The host selections pointing at it are NOT rewritten to the platform\n * identity. A site that was sending as its own domain and now has no verified\n * one must refuse, not quietly revert — silently moving a tenant's mail back\n * onto the shared domain is the exact fallback this feature exists to prevent,\n * and doing it during a delete would be no better than doing it during a send.\n */\nexport async function releaseSendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<void> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) return\n await domainRef(orgId, domain)\n .delete()\n .catch(() => undefined)\n}\n\n/*==========================================\n The read on the send path\n==========================================*/\n\n/**\n * The identity one host sends on, ready to hand to `sendEmail`.\n *\n * Two document reads at most, and none at all for a host that has no\n * selection. The host document is passed in rather than re-fetched because\n * every caller already holds it.\n *\n * ## This function cannot return an `aglyn.com` address, and that is the point\n *\n * Every caller here is resolving mail FOR A SITE — that is what \"host\" in the\n * name means — so every verdict it produces is tenant mail by construction.\n * `audience: 'tenant'` is therefore passed unconditionally and is not a\n * parameter: a caller that could choose would eventually choose wrong, and the\n * wrong choice puts a merchant's list quality onto the domain the platform's\n * own invoices and password resets leave on.\n *\n * A site that has selected NOTHING does not reach `USAGE_EMAIL_FROM` either.\n * All of its mail goes on the shared pool member `hostSharedFrom` assigns it,\n * marketing included. What keeps the pool usable is measurement rather than\n * prohibition: a pooled campaign is graded against the stricter reputation\n * thresholds, so the site earning complaints is the one that stops.\n */\nexport async function resolveHostSendingIdentity(options: {\n orgId: string | null | undefined\n /** `hosts/{hostId}.sendingDomain`, the site's selection. */\n selectedDomain: string | null | undefined\n /** `hosts/{hostId}.sendingLocalPart`, defaulted when unset. */\n selectedLocalPart?: string | null\n platformFrom?: string | null\n /**\n * The site, so an unselected host can be assigned a pool member.\n *\n * Optional because two of the three callers resolve an identity for a host\n * they are already holding and one — the console's identity view — is asking\n * about a specific site by id. A caller that omits it gets no shared\n * identity, which is the honest answer: the pool assignment is per site, and\n * \"some pool member\" is not a thing to send a receipt from.\n */\n hostId?: string | null\n /**\n * A pool member this host is pinned to, overriding the hash.\n *\n * `hosts/{hostId}.sendingPoolMember`. The QUARANTINE lever: a site whose\n * transactional mail is generating complaints can be moved onto a member set\n * aside for it, without disturbing any of the other sites that hash to the\n * member it was on. Ignored unless it names a real member of the current\n * pool, so a stale or hand-edited value degrades to the ordinary assignment\n * rather than to an address nothing signs for.\n */\n poolMember?: string | null\n /** See `SendingIdentityPurpose`. Defaults to transactional. */\n purpose?: SendingIdentityPurpose\n}): Promise<SendingIdentityVerdict> {\n const platformFrom =\n options?.platformFrom ?? process.env.USAGE_EMAIL_FROM ?? null\n const domain = normalizeSendingDomain(options?.selectedDomain ?? '')\n const purpose = options?.purpose\n const sharedFrom = hostSharedFrom(options?.hostId, options?.poolMember)\n\n if (!domain || !options?.orgId) {\n return resolveSendingIdentity({\n selection: null,\n platformFrom,\n sharedFrom,\n purpose,\n audience: 'tenant',\n })\n }\n\n const record = readSendingDomainRecord(await domainRef(options.orgId, domain).get())\n\n /*\n * Whose domain this is, decided from the NAME rather than from the record.\n *\n * The record is what a released domain no longer has, and a site pointed at\n * a subdomain of our own apex is still pointed at a subdomain of our own\n * apex whether or not one exists for it. Deriving this from the name keeps\n * the two possible failures apart: a missing record for OUR name is our\n * provisioning that did not finish, a missing record for THEIRS is a domain\n * they asked to send as and nothing has verified.\n */\n const platformIssued = isPlatformSendingDomain(domain)\n\n /*\n * A selection naming a CUSTOMER'S domain with no record refuses rather than\n * falling back. The record can be gone because it was released, or because\n * the selection was written against a different org — both mean the site is\n * configured to send as a domain nothing has verified, and neither is a\n * reason to send as somebody else.\n *\n * `platformIssued` rides on the selection either way, and the resolver reads\n * it only on the unverified branch: a platform subdomain that has not\n * finished provisioning drops to the pool for transactional mail instead,\n * because nothing about it is a merchant instruction to contradict.\n */\n const selection: SendingDomainSelection = record\n ? {\n domain: record.domain,\n status: record.status,\n localPart: normalizeLocalPart(options.selectedLocalPart ?? '') || 'hello',\n missing: record.lastMissing ?? [],\n platformIssued,\n }\n : { domain, status: 'failed', localPart: '', missing: [], platformIssued }\n\n /*\n * `sharedFrom` is passed even here, where a selection exists and will decide\n * the outcome. It is inert by construction — the selection branch of\n * `resolveSendingIdentity` never reads it — and passing it unconditionally\n * keeps the call shape identical between the two arms, so a future edit\n * cannot produce a path that had a pool available and did not offer it.\n *\n * It must NOT become a fallback for an unverified selection, and it is the\n * resolver's structure rather than this call site that guarantees that.\n */\n return resolveSendingIdentity({\n selection,\n platformFrom,\n sharedFrom,\n purpose,\n audience: 'tenant',\n })\n}\n\n/**\n * The pool address for one host: the pinned member if it names a real one, the\n * hash assignment otherwise.\n *\n * The override is validated against the LIVE pool rather than trusted, because\n * a stale value is the expected case — an operator shrinks the pool, or a\n * quarantine was lifted by deleting the member rather than the field — and an\n * address on a domain that no longer exists is a send that fails at the\n * provider instead of one that quietly goes back to normal.\n */\nfunction hostSharedFrom(\n hostId: string | null | undefined,\n poolMember: string | null | undefined,\n): string {\n const id = String(hostId ?? '').trim()\n if (!id) return ''\n\n const pinned = normalizeSendingDomain(String(poolMember ?? ''))\n if (pinned && isSharedSendingDomain(pinned)) {\n const shared = sharedTenantSendingFrom(id)\n const at = shared.lastIndexOf('@')\n return at > 0 ? `${shared.slice(0, at)}@${pinned}` : ''\n }\n\n return sharedTenantSendingFrom(id)\n}\n\n/**\n * The identity one site sends on, from a `hostId` alone.\n *\n * The door every tenant sender uses. {@link resolveHostSendingIdentity} needs\n * the org id and the host's two selection fields, and a survey of the tenant\n * send sites found that all of them hold a `hostId` while only three hold an\n * org id and half never read the host document at all. Asking nineteen call\n * sites to each assemble the same three values is the shape that produces a\n * twentieth which does not — and the cost of forgetting here is a message\n * leaving on the platform's own domain.\n *\n * ## The cache is per-call-graph, not per-process\n *\n * Passed a `cache`, repeated resolutions for one host cost one pair of reads.\n * A sweep that mails two hundred abandoned carts across a dozen sites, or a\n * webhook that sends a receipt and a seller notice for the same order, holds\n * one map for the run and pays for each site once.\n *\n * Deliberately NOT a module-level cache. Verification status is exactly the\n * thing that changes underneath a long-lived process — the re-check sweep\n * un-verifies a domain whose records have gone — and a stale entry would keep\n * a site sending on a domain that no longer authenticates. A caller that\n * declares a cache has also declared how long it may be trusted.\n */\nexport async function hostSendingIdentity(\n hostId: string | null | undefined,\n cache?: Map<string, SendingIdentityVerdict>,\n): Promise<SendingIdentityVerdict> {\n const id = String(hostId ?? '').trim()\n if (!id) {\n /*\n * No host is not \"the platform is speaking\", and it is not \"any pool\n * member will do\" either. It is a caller that does not know which site it\n * is sending for, and the honest answer to that is a refusal.\n *\n * No `sharedFrom` is passed, which is what makes that structural rather\n * than a rule: the pool is assigned FROM the host id, so a caller without\n * one has nothing to be assigned.\n */\n return resolveSendingIdentity({ selection: null, audience: 'tenant' })\n }\n\n const hit = cache?.get(id)\n if (hit) return hit\n\n const snapshot = await firestore()\n .collection('hosts')\n .doc(id)\n .get()\n .catch(() => null)\n const owner = await getOrgForHost(id).catch(() => null)\n\n const verdict = await resolveHostSendingIdentity({\n orgId: owner?.orgId ?? null,\n selectedDomain: snapshot?.get('sendingDomain') ?? '',\n selectedLocalPart: snapshot?.get('sendingLocalPart') ?? '',\n hostId: id,\n poolMember: snapshot?.get('sendingPoolMember') ?? '',\n })\n\n cache?.set(id, verdict)\n return verdict\n}\n\n/** The record keys a surface highlights as outstanding. */\nexport function outstandingSendingRecords(\n record: SendingDomainRecord,\n): SendingDnsRecord[] {\n const missing = new Set(record?.lastMissing ?? [])\n if (!missing.size) return []\n return sendingDnsRecords(record).filter((entry) =>\n missing.has(sendingRecordKey(entry)),\n )\n}\n"],"names":["assessDmarc","assessSendingRecords","isPlatformSendingDomain","isSharedSendingDomain","normalizeLocalPart","normalizeSendingDomain","resolveSendingIdentity","safeProviderDetail","SENDING_SUBDOMAIN","SENDING_TRACKING_SUBDOMAIN","sendingDnsRecords","sendingDomainRequiredRecords","sendingRecordKey","sharedTenantSendingFrom","validateSendingDomain","firebaseAdmin","lookupCaa","lookupMx","lookupTxt","getOrgForHost","firestore","app","SENDING_DOMAINS_COLLECTION","domainRef","orgId","domain","collection","doc","sendingDkimSelector","safe","String","toLowerCase","replace","slice","readSendingDomainRecord","snapshot","data","exists","id","status","dkimSelector","dkimPublicKey","returnPathHost","providerDomainId","trackingTarget","createdAtMs","Number","verifiedAtMs","lastCheckedAtMs","lastIssueError","lastIssueAtMs","lastMissing","Array","isArray","requestSendingDomain","options","error","record","ref","existing","get","Date","now","set","merge","recordIssuedSendingDomain","key","trim","held","selector","issued","some","entry","purpose","FieldValue","delete","recordSendingDomainIssueFailure","detail","getSendingDomain","rawDomain","records","dmarc","listSendingDomains","docs","map","filter","Boolean","readDmarcPolicy","lookup","answered","holdTrackedSendingDomain","windowMs","catch","until","nowMs","trackingRetentionUntilMs","readTrackingCaaNeed","authority","ca","labels","split","index","length","name","join","issuers","test","includes","probeSendingRecords","sendHost","dkimHost","spf","dkim","mx","Promise","all","spfTxt","dkimTxt","conclusive","verifySendingDomain","missing","inconclusive","verdict","verified","releaseSendingDomain","undefined","resolveHostSendingIdentity","platformFrom","process","env","USAGE_EMAIL_FROM","selectedDomain","sharedFrom","hostSharedFrom","hostId","poolMember","selection","audience","platformIssued","localPart","selectedLocalPart","pinned","shared","at","lastIndexOf","hostSendingIdentity","cache","hit","owner","outstandingSendingRecords","Set","size","has"],"mappings":"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"}
@@ -0,0 +1,433 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type LockdownState, type MediaVideoRendition } from '@aglyn/aglyn/server';
18
+ import { type MediaCdnScope } from '@aglyn/aglyn/app-utils/media-cdn-scope';
19
+ import type { NextApiRequest, NextApiResponse } from 'next';
20
+ /**
21
+ * Variant widths generated at upload (AGL-175).
22
+ *
23
+ * Re-exported, not defined: the list now lives in `@aglyn/aglyn`'s media-ref
24
+ * so the RENDERER can reach it too. `image.tsx` carried its own
25
+ * `[320, 640, 1280]` literal, so a width added here never reached the srcSet.
26
+ */
27
+ export { MEDIA_CDN_VARIANT_WIDTHS } from '@aglyn/aglyn/server';
28
+ /**
29
+ * The scope grammar and the refusal this handler enforces (AGL-1043).
30
+ *
31
+ * Re-exported, not defined: the pure rule lives in `@aglyn/aglyn`'s
32
+ * `media-cdn-scope`, so the readers that must reach this handler's verdict —
33
+ * the composition's video facts and the besigner canvas — call the functions
34
+ * it calls.
35
+ */
36
+ export { mediaCdnAllows, type MediaCdnScope, type MediaCdnScopeRefusal, mediaCdnScopeRefusal, parseMediaCdnScope, } from '@aglyn/aglyn/app-utils/media-cdn-scope';
37
+ /**
38
+ * The query string to carry onto the stable URL when a stale content pin
39
+ * redirects (AGL-2685).
40
+ *
41
+ * REBUILT from the four parameters this handler reads, never forwarded whole.
42
+ * Two reasons, and the second is the one that matters:
43
+ *
44
+ * 1. Everything else is inert here — it would only split the CDN cache key
45
+ * for a response that is identical either way.
46
+ * 2. `Location` is a header, and the value would otherwise be caller-supplied
47
+ * text. Node rejects a CR or LF in a header value rather than splitting
48
+ * the response, so this is a second lock on a door that is already shut —
49
+ * which is the right number of locks for a header built from user input.
50
+ *
51
+ * `exp` and `sig` ride along because a private asset's signature covers
52
+ * (scope, mediaId, exp) and never the hash, so it is still valid at the
53
+ * target. Dropping them would turn a redirect into a 404 for exactly the
54
+ * assets whose delivery is most sensitive.
55
+ */
56
+ export declare function mediaCdnForwardedQuery(query: NextApiRequest['query']): string;
57
+ /**
58
+ * The stable (non-content-hashed) URL's caching contract.
59
+ *
60
+ * This was `public, max-age=3600, stale-while-revalidate=86400`, and that
61
+ * header contradicted the promise the stable URL exists to make. Measured on
62
+ * production 2026-08-12: Vercel's edge DOES cache this route on a bare
63
+ * `max-age` (`x-vercel-cache: MISS` then `HIT`, `age` climbing) — so the
64
+ * saving was never in question. What a browser `max-age` breaks is
65
+ * *propagation*. `max-age=3600` reaches the client, so a browser holding a
66
+ * replaced asset will not send a conditional request for a full hour: the
67
+ * ETag below never gets a chance to answer, and nothing on our side can bust
68
+ * a browser cache. Every other cacheable route in the repo already uses
69
+ * `s-maxage` for exactly this reason — see `seo-origin.spec.ts`, "never a
70
+ * browser `max-age`, which nothing could bust".
71
+ *
72
+ * So: a short browser `max-age` that still collapses the burst of repeat
73
+ * requests within one page view (an image referenced by four `srcSet`
74
+ * candidates, a tile rendered in a grid and again in a drawer), and the full
75
+ * hour moved to `s-maxage` where it belongs. Worst stale read is now 60 s of
76
+ * replaced bytes in one browser rather than an hour, at the cost of one
77
+ * conditional request per image per minute — answered by the EDGE from its
78
+ * own copy, so it adds no Storage read and no Firestore read.
79
+ *
80
+ * The immutable content-hashed form is untouched: its URL changes with its
81
+ * bytes, so it can and should be pinned in the browser for a year.
82
+ *
83
+ * Since AGL-1515 this policy applies to IMAGE responses only — see
84
+ * {@link mediaCdnEdgeCacheable}.
85
+ */
86
+ export declare const MEDIA_CDN_STABLE_CACHE_CONTROL = "public, max-age=60, s-maxage=3600, stale-while-revalidate=86400";
87
+ /**
88
+ * The stable URL's policy for types the edge must never hold (AGL-1515):
89
+ * the same 60-second browser window and the same ETag/304 contract, with
90
+ * `private` in place of `public, s-maxage` so no shared cache stores a
91
+ * full body.
92
+ *
93
+ * Why the edge must never hold one: Vercel's edge, holding a cached
94
+ * full-body 200, answers a `Range` request FROM that entry as a
95
+ * spec-violating hybrid — status 200, a `Content-Range` header, and only
96
+ * the requested slice as the body (`x-vercel-cache: HIT`; reproduced twice
97
+ * on production, 2026-08-13). A video player seeking into such an asset
98
+ * adopts a 100-byte slice as the complete file: silent playback corruption.
99
+ *
100
+ * The S4 shape below this constant was built on Vercel's documented
101
+ * cacheable-response criteria ("Request doesn't contain Range header"),
102
+ * read as "ranged requests bypass the edge". Production falsified that
103
+ * reading: the criteria govern what the edge STORES, not what it SERVES. A
104
+ * ranged request is still matched against the URL-keyed entry a previous
105
+ * plain GET left behind, and the edge's slicing layer rewrites the body
106
+ * and adds `Content-Range` without rewriting the stored 200 status.
107
+ *
108
+ * `private` is the lever because it is in the same documented criteria
109
+ * list as an absolute storage preventer ("Response doesn't contain the
110
+ * `private` … directives"), where `Vary: Range` is undocumented on
111
+ * Vercel, discouraged by RFC 9110, and untestable anywhere but a
112
+ * production deploy. What it costs: edge caching for non-image assets
113
+ * under Vercel's 10 MB cacheable-size cap. Real video mostly sits ABOVE
114
+ * the cap and was never edge-cached — the mangling reproduced on a 186 KB
115
+ * asset precisely because small ones are the ones that get cached.
116
+ */
117
+ export declare const MEDIA_CDN_STABLE_EDGE_BYPASS_CACHE_CONTROL = "private, max-age=60";
118
+ /** The immutable content-hashed URL's policy for edge-cacheable (image) types. */
119
+ export declare const MEDIA_CDN_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
120
+ /**
121
+ * The immutable URL's policy for edge-bypassing types (AGL-1515): the
122
+ * browser keeps its year — the URL still changes with the bytes — while
123
+ * the edge holds nothing it could mangle.
124
+ */
125
+ export declare const MEDIA_CDN_IMMUTABLE_EDGE_BYPASS_CACHE_CONTROL = "private, max-age=31536000, immutable";
126
+ /**
127
+ * Whether a response of this content type may be edge-cached (AGL-1515).
128
+ *
129
+ * The split is "types no realistic client ranges into": browsers fetch
130
+ * images with plain GETs (`<img>`, `srcSet`, save-as — all of them), while
131
+ * every consumer that seeks or resumes — `<video>`/`<audio>` players, PDF
132
+ * viewers, download managers — operates on the non-image types. Images stay
133
+ * on the shared edge policy because they are the DAM grid's hot path (4.3 KB
134
+ * WebP tiles at volume), and pushing them to origin to fix video would trade
135
+ * a real regression for a theoretical one.
136
+ *
137
+ * Accepted residual, recorded here on purpose: a hand-built `Range` request
138
+ * against an edge-cached IMAGE can still be answered with the hybrid (that
139
+ * is exactly the AGL-1515 favicon repro). No browser or player issues one,
140
+ * and the alternative — `private` on images too — costs the hot path an
141
+ * edge hit rate it measurably has.
142
+ *
143
+ * Unknown or absent types return false: correctness over cache.
144
+ */
145
+ export declare function mediaCdnEdgeCacheable(contentType: unknown): boolean;
146
+ /**
147
+ * The CDN's OWN Content-Security-Policy (AGL-1474).
148
+ *
149
+ * An uploaded `image/svg+xml` is a document, not a picture. It passes the
150
+ * `image/*` allowlist, it is stored under whatever type the client declared,
151
+ * and this handler serves it `inline` from the console's own origin — and
152
+ * from every tenant site's, since the same handler mounts in both apps. So
153
+ * `<script>alert(document.domain)</script>` inside an uploaded logo executed
154
+ * on `app.aglyn.com` the moment the asset URL was opened top-level, for
155
+ * anyone with editor rights.
156
+ *
157
+ * Nothing already in place stopped it. `X-Content-Type-Options: nosniff`
158
+ * blocks HTML *mislabelled* as an image; it says nothing about a file
159
+ * honestly labelled `image/svg+xml`, which browsers render as a scripted
160
+ * document. And there was no CSP on this response **at all**: since AGL-523
161
+ * the policy is built per-response in each app's middleware, and both
162
+ * middlewares' matchers exclude `api` (`apps/console/middleware.ts`,
163
+ * `apps/tenant/middleware.ts`), so a directly-navigated CDN URL never passed
164
+ * through the code that sets one.
165
+ *
166
+ * That is precisely why the header is set HERE, on the response, rather than
167
+ * added to a matcher: a route-level header cannot be lost to a matcher edit,
168
+ * a rewrite, or a new mount of `serveMediaCdn` in a third app. It is also
169
+ * what makes this the containment rather than the remediation — it covers
170
+ * every asset already in the bucket, including the SVGs uploaded before the
171
+ * sanitizer existed, without rewriting a byte.
172
+ *
173
+ * **It does not touch `<img src>`, which is how logos and marks are used
174
+ * across this product.** A browser loading an SVG as an image neither runs
175
+ * its script nor applies the response's CSP — CSP governs documents and
176
+ * workers. The policy only becomes live in the case that is the vector: the
177
+ * asset opened as a top-level document (or framed via `<object>`/`<iframe>`).
178
+ */
179
+ export declare const MEDIA_CDN_BASE_CSP: string;
180
+ /**
181
+ * The policy for a type a browser will treat as an ACTIVE DOCUMENT. Adds
182
+ * `sandbox` — an opaque origin, so even a hypothetical execution has no
183
+ * `document.domain`, no cookies and no storage to reach for.
184
+ *
185
+ * `style-src 'unsafe-inline'` and the two `data:` allowances are not
186
+ * concessions to script: with `script-src 'none'` and `sandbox` in force,
187
+ * nothing in CSS or a data URI can execute. They exist so that opening a
188
+ * logo's URL directly still shows the logo — `default-src 'none'` alone
189
+ * would blank an SVG's own `<style>` block and its embedded raster fills,
190
+ * which is a visible regression on legitimate assets and buys no safety.
191
+ */
192
+ export declare const MEDIA_CDN_ACTIVE_DOCUMENT_CSP: string;
193
+ /** The `Content-Security-Policy` for a response serving `contentType`. */
194
+ export declare function mediaCdnContentSecurityPolicy(contentType: string): string;
195
+ /**
196
+ * Lockdown on the delivery path (AGL-1520).
197
+ *
198
+ * A security-locked org's SITE stops within seconds (the tenant middleware
199
+ * 503s, AGL-1501) — but until this gate existed its public media kept
200
+ * serving worldwide: to hot-links, to third-party embeds, and to the
201
+ * infected asset's own URL, which for an "infected host" security lock is
202
+ * the exact thing the lock was pressed to stop.
203
+ *
204
+ * **Which reasons stop delivery — decided per reason, not blanket:**
205
+ *
206
+ * - `security` — REFUSE. The point of the lock: the org's content, media
207
+ * included, must stop serving.
208
+ * - `manual` — REFUSE. A staff suspension with no reason code (every
209
+ * pre-lockdown `suspendedAt` normalizes to `manual`) is "we turned this
210
+ * org off"; content continuing to serve would make the kill partial.
211
+ * - `maintenance` — SERVE. The maintenance notice surface may itself
212
+ * reference org assets (a logo on the notice page), the window is
213
+ * temporary and non-adversarial, and blanking every image buys no safety.
214
+ * - `billing` — SERVE. The AGL-1506 principle: billing-locked orgs keep the
215
+ * surfaces that let them come back (members can still reach billing to
216
+ * pay). The site already 503s, so whether a hot-linked image serves is
217
+ * nearly moot — and refusing would punish e.g. an email-signature logo
218
+ * for a payment lapse. Serving is the cheap, reversible answer.
219
+ *
220
+ * Scopes are checked INDIVIDUALLY against that matrix rather than through
221
+ * `resolveLockdown`, on purpose: the resolver answers "which notice does a
222
+ * visitor see" and returns the WIDEST active scope — so a platform
223
+ * `maintenance` window would mask a concurrent org `security` lock and the
224
+ * infected asset would keep serving. Delivery has no notice to pick; the
225
+ * question is only "does ANY active lockdown demand these bytes stop".
226
+ */
227
+ export declare function lockdownStopsMediaDelivery(state: LockdownState | null | undefined, nowMs: number): boolean;
228
+ /**
229
+ * Drop the per-scope lock cache. Tests need it between cases; production
230
+ * convergence is the TTL — the lock is written by the console app and served
231
+ * by the tenant app, different processes an in-process invalidation can
232
+ * never reach.
233
+ */
234
+ export declare function invalidateMediaCdnLockCache(): void;
235
+ /**
236
+ * THE delivery-policy seam: "may this asset be served at all?" — one
237
+ * question, one function, consulted once per request before any caching
238
+ * exit. Distinct from `mediaCdnAllows` (which site may use it under this
239
+ * URL) and from the signature check (may the public fetch it): this asks
240
+ * whether the platform is willing to serve the bytes to ANYONE.
241
+ *
242
+ * Two independent reasons to refuse, both answered here so the handler
243
+ * keeps ONE refusal path:
244
+ *
245
+ * - `'locked'` — a lockdown covering this SCOPE (AGL-1520): the whole org
246
+ * or host is off.
247
+ * - `'quarantined'` — this ASSET is on the deny list (AGL-1512): one
248
+ * infected, abusive or DMCA-noticed file is off while everything else in
249
+ * the same workspace keeps serving. That proportionality is the point —
250
+ * a single bad object should not cost a customer their whole site.
251
+ *
252
+ * Order is scope-then-asset because the scope verdict is the cheaper cached
253
+ * one and the wider fact; the caller does not distinguish them on the wire
254
+ * (both are the same neutral 410), so the order is a cost decision only.
255
+ *
256
+ * Read cost, since this sits on the hottest unauthenticated path: the scope
257
+ * verdict is one read per SCOPE per 15s, and the quarantine deny list is
258
+ * one read per PROCESS per 15s for every asset in existence — it is a
259
+ * single document. Fifty DAM tiles pay two reads between them, not a
260
+ * hundred.
261
+ */
262
+ export declare function mediaCdnServeBlock(scope: MediaCdnScope, asset: {
263
+ /**
264
+ * The strong full-width digest (AGL-1614) when the document has one.
265
+ * Preferred over `contentHash` as a quarantine key — it is one
266
+ * algorithm at full width, where `contentHash` is a 64-bit truncation
267
+ * of either sha256 or md5 depending on the ingesting route.
268
+ */
269
+ contentSha256?: string;
270
+ contentHash?: string;
271
+ /**
272
+ * The raw URL scope segment and media id — the fallback quarantine key
273
+ * for the assets that carry no `contentHash` at all (legacy uploads,
274
+ * and composite objects GCS gives no md5 for). Without it the largest
275
+ * files in the product would be the ones a takedown could not touch.
276
+ */
277
+ scopeSegment?: string;
278
+ mediaId?: string;
279
+ }): Promise<'locked' | 'quarantined' | null>;
280
+ /**
281
+ * Whether `?download=1` was asked for (AGL-1411). Strictly opt-in: absent,
282
+ * `0`, `false` or junk all keep the historical `inline`, because the default
283
+ * is what every `<img src>` in every published site relies on.
284
+ *
285
+ * Only `1` and `true` count, rather than "any truthy-looking string". The
286
+ * query string is part of the CDN cache key, so every accepted spelling is a
287
+ * separate edge entry for identical bytes; two is enough.
288
+ */
289
+ export declare function wantsMediaDownload(value: unknown): boolean;
290
+ /**
291
+ * Whether `?poster=1` was asked for (AGL-2743).
292
+ *
293
+ * Deliberately the same two-spelling rule as {@link wantsMediaDownload},
294
+ * and for the same reason rather than out of symmetry: the query string is
295
+ * part of the CDN cache key, so every accepted spelling of "yes" is another
296
+ * edge entry holding identical bytes. A poster is the response most likely
297
+ * to be requested at volume from a grid, which makes cache-key discipline
298
+ * worth more here than anywhere else on the route.
299
+ */
300
+ export declare function mediaCdnWantsPoster(value: unknown): boolean;
301
+ /**
302
+ * Which rendition `?r=auto` resolves to, or undefined for "serve the master"
303
+ * (AGL-2753).
304
+ *
305
+ * ## Why the CDN chooses at all
306
+ *
307
+ * Because it is the only participant holding the media document when the
308
+ * bytes are asked for. A page cannot name a rendition — the encodings are
309
+ * produced out of band, after the placement, and the page is ISR-cached HTML
310
+ * that neither regenerates when one lands nor varies on a visitor's `Accept`.
311
+ * The composition does read a placed film's document for its length and
312
+ * shape (AGL-2807), but that answer is frozen into the cached page. The
313
+ * alternatives were a node backfill that rewrites published screens nobody
314
+ * edited, or naming encodings in that same frozen page. This costs neither:
315
+ * the document is already in hand on this request.
316
+ *
317
+ * ## Why the stored order is not simply obeyed
318
+ *
319
+ * The producer writes most-efficient-codec first, which is the right order for
320
+ * `<source>` elements — there the BROWSER picks, using what it actually knows
321
+ * about its own decoders. A single negotiated URL has no such luxury, so
322
+ * taking the head of the list would hand a WebM to anything that could not
323
+ * decode one and turn a saving into a black player.
324
+ *
325
+ * So an entry is selected on an EXPLICIT type match, and everything else falls
326
+ * to the MP4 baseline the producer always emits. A client that names
327
+ * `video/webm` gets the 30%-smaller file; a client that says `*\/*` gets the
328
+ * universally decodable one; a client that refused every candidate gets the
329
+ * master. Every outcome is a video that plays.
330
+ *
331
+ * ## Why negotiating here is safe under a cache
332
+ *
333
+ * A rendition response is `video/*`, so `mediaCdnEdgeCacheable` is false and
334
+ * it is served `private` (AGL-1515) — no shared cache stores it, which is the
335
+ * invariant `serve-media-cdn.video.spec.ts` pins. The only cache that can hold
336
+ * one is the requesting browser's own, and a browser's `Accept` for media does
337
+ * not change between the first request and the Range requests that follow it.
338
+ * That matters more than it looks: negotiation on a RANGEABLE resource that
339
+ * could flip mid-playback would stitch byte ranges from two different files
340
+ * together, which is the AGL-1515 corruption reached by a new road.
341
+ */
342
+ export declare function selectAutoRendition(renditions: readonly MediaVideoRendition[], acceptHeader: unknown): MediaVideoRendition | undefined;
343
+ /**
344
+ * The download name for an asset: the stored `fileName`, reduced to its
345
+ * basename, falling back to the media id (the URL is id-keyed and
346
+ * extensionless, so without a name a "save as" lands as a nameless blob).
347
+ */
348
+ export declare function mediaDownloadName(fileName: unknown, mediaId: string): string;
349
+ /**
350
+ * A `Content-Disposition` value for `name` (AGL-1411).
351
+ *
352
+ * A filename is attacker-adjacent data — it is whatever the uploader typed —
353
+ * and it is being pasted into a header, so a quote or a CRLF in it is a
354
+ * header-injection vector. The old code stripped `["\\\r\n]` and left
355
+ * everything else, which is safe against injection but silently mangles the
356
+ * name and passes non-ASCII through raw: a byte above 0x7e in a header is
357
+ * either mojibake at the client or, on a stricter encoder than ours, a
358
+ * throw. So both forms of RFC 6266 §4.1 are emitted:
359
+ *
360
+ * - `filename="…"` — ASCII only, every unrepresentable character replaced
361
+ * (not dropped) so the extension and the shape of the name survive. This
362
+ * is the fallback, and it always exists.
363
+ * - `filename*=UTF-8''…` — RFC 8187 percent-encoding of the real name, added
364
+ * only when the ASCII form actually lost something. Every current browser
365
+ * prefers it, so a non-ASCII name arrives intact.
366
+ */
367
+ export declare function mediaContentDisposition(name: string, options: {
368
+ download: boolean;
369
+ }): string;
370
+ /**
371
+ * A parsed `Range` request (AGL-1442 S4). Three answers, and the difference
372
+ * between the last two is the whole game:
373
+ *
374
+ * - `{ start, end }` — a single satisfiable byte range, both ends INCLUSIVE
375
+ * (matching `createReadStream`, which is where the numbers go).
376
+ * - `'unsatisfiable'` — syntactically a range, but nothing in it can be
377
+ * served (`start` past EOF, `-0`, an empty object). RFC 9110 §14.1.2 says
378
+ * this one earns a 416.
379
+ * - `null` — everything else: no header, another unit, a multi-range, or
380
+ * malformed syntax. RFC 9110 §14.2 lets a server ignore a Range header
381
+ * outright, and ignoring means a full 200 — which is both the safe answer
382
+ * and the pre-S4 behavior, so every shape this parser does not positively
383
+ * recognise degrades to exactly what shipped before it existed.
384
+ *
385
+ * Multi-range requests land in `null` DELIBERATELY. Serving them needs a
386
+ * `multipart/byteranges` body with generated boundaries; no browser sends
387
+ * them for media (a `<video>` seek is always one range), and a 416 would be
388
+ * wrong because a multi-range over a real file is satisfiable. A full 200
389
+ * is the RFC-sanctioned refusal that no client can misread.
390
+ */
391
+ export type MediaCdnRange = {
392
+ start: number;
393
+ end: number;
394
+ } | 'unsatisfiable' | null;
395
+ export declare function parseMediaCdnRange(header: unknown, size: number): MediaCdnRange;
396
+ /**
397
+ * CDN media delivery (AGL-175 / AGL-829). Two URL shapes resolve the same
398
+ * asset by `mediaId`, so delivery never depends on the object's storage
399
+ * location (folder moves don't change the URL):
400
+ *
401
+ * - **Stable** `/api/media/cdn/[scope]/[mediaId]` — always serves the
402
+ * asset's CURRENT bytes, so it survives a **replace** too. Revalidated
403
+ * with an ETag (the content hash) + `stale-while-revalidate`, so a
404
+ * replaced asset propagates without ever breaking references. This is
405
+ * the URL the console hands out.
406
+ * - **Immutable** `/api/media/cdn/[scope]/[mediaId]/[contentHash]` —
407
+ * year-long `immutable` cache; bytes are served only while the hash
408
+ * matches the current content, and a stale hash 302s to the stable URL
409
+ * (AGL-2685). No renderer names this form (AGL-2798): an edge or a browser
410
+ * holding an immutable response never asks again, so a replace could not
411
+ * reach a page that did. It is answered for the URLs already handed out.
412
+ *
413
+ * `?w=[width]` selects a generated WebP variant. `?download=1` swaps the
414
+ * `Content-Disposition` from `inline` to `attachment` (AGL-1411) — the press
415
+ * kit hands out links that must SAVE rather than open a tab. Both are read
416
+ * only after every access gate, so neither can widen what is served; both
417
+ * are part of the CDN cache key, so neither variant can poison the other.
418
+ * The same handler mounts in both the tenant and console apps; raw storage
419
+ * URLs already embedded in screens keep working unchanged.
420
+ *
421
+ * Single byte-range requests are honored with a 206 (AGL-1442 S4) — see
422
+ * {@link parseMediaCdnRange} and the block below `Accept-Ranges` for the
423
+ * exact semantics. This is the capability whose absence kept video on raw
424
+ * `firebasestorage.googleapis.com` URLs (S8): a `<video>` seek is a Range
425
+ * request, and a server that ignores it forces the player to re-download
426
+ * the whole file.
427
+ *
428
+ * The body streams (AGL-2810): each chunk leaves as Storage produces it, so no
429
+ * file is held whole in function memory. A GET that will read the file is
430
+ * first counted against its caller (AGL-2812); see
431
+ * {@link mediaCdnRateLimitRefusal}.
432
+ */
433
+ export declare function serveMediaCdn(req: NextApiRequest, res: NextApiResponse): Promise<void>;