@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/console-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 * Registration, DNS ownership proof and Vercel attachment for a custom\n * **console** domain — the \"verify + attach\" half of AGL-1099 (AGL-1373).\n *\n * ⚠️ **THIS LANDS DARK, AND MUST STAY DARK.** AGL-1099 says it outright:\n * *\"Do NOT ship 1099b alone: attaching a domain that then cannot authenticate\n * is worse than not attaching it, because it looks finished.\"* Nothing here is\n * reachable from a user-facing path, nothing calls `activateConsoleDomain`, and\n * no domain has been attached. One thing must still land first:\n *\n * - **1099e — the handoff itself.** A console domain shares no parent domain\n * with `aglyn.com`, so the `.aglyn.com` session cookie cannot reach it.\n *\n * ## 1099d is no longer one of them (AGL-1378, closed 2026-08-23)\n *\n * The 1099a proof-of-concept (`docs/design/agl-1099a-poc-findings.md`)\n * measured that App Check, *not* Firebase's authorized-domain list, is the\n * gate: an unattested `signInWithCustomToken` 401s with \"Firebase App Check\n * token is invalid\" **before** token validation. Same page, same app id, only\n * the origin differing — an allowlisted origin minted a token and reached\n * credential validation; a non-allowlisted one failed at\n * `appCheck/recaptcha-error`. That finding stands, and it is why the allowlist\n * entry is a hard **functional** prerequisite rather than the commercial\n * ceiling the design called it.\n *\n * What was wrong was the conclusion that the entry could only be added by\n * hand. That rested on probing `aglyn-main`, which is not the project the key\n * lives in; the key was auto-migrated into `recaptcha-migrated-…`, where the\n * Enterprise API is enabled, we hold owner, and `projects.keys.patch` on\n * `webSettings.allowedDomains` answers 200. `activateConsoleDomain` now writes\n * the serving name through `recaptcha-allowlist.ts` and refuses to mark the\n * claim `active` if that write does not land.\n *\n * ## Why this is not `/api/domains/attach|verify|detach`\n *\n * Those routes are shaped around `hosts/{hostId}`, a `cname` field and the\n * `customDomain` entitlement. A console domain is keyed on the **domain**,\n * owned by an **org**, and gated on `whiteLabel`. Re-pointing them at the\n * console project would mean parameterising the auth model, the entitlement,\n * the uniqueness query and the Firestore shape — at which point it is not\n * reuse. What *is* reused is `workspace-domains.ts`, which already talks to\n * `VERCEL_CONSOLE_PROJECT_ID`, already carries a deadline, and already never\n * throws (AGL-1353 D8).\n *\n * ## The uniqueness invariant, which is the whole security property\n *\n * `attachProjectDomain` tolerates Vercel's `domain_already_in_use` as success.\n * That is only safe while the Firestore claim indexes **every** name Vercel\n * holds for us. If a name reaches Vercel without a claim, a second org can\n * claim it here, read `already-exists` as health, and have its visitors served\n * — or redirected — to a stranger's console. That is AGL-743 exactly: there,\n * `host.cname` was written client-side *before* the uniqueness check, so losing\n * the race still left the loser holding the domain and `get-host.ts` resolved\n * the duplicate by Firestore document order.\n *\n * So: **`claimConsoleDomain` claims the primary and every twin in ONE\n * transaction, or none of them**, and `activateConsoleDomain` attaches exactly\n * the set that transaction claimed. Neither half is optional, and a twin\n * introduced later without going through `consoleDomainNames` reopens the hole.\n */\n\nimport { randomBytes } from 'crypto'\nimport { checkEntitlement } from '@aglyn/aglyn/server'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/server'\nimport firebaseAdmin from './firebase-admin'\nimport {\n challengeValue,\n recordsProveOwnership,\n resolveChallengeTxt,\n SSO_CHALLENGE_PREFIX,\n type DomainCheck,\n} from './sso-provisioning'\nimport {\n allowConsoleOrigin,\n allowlistSatisfied,\n reclaimConsoleOrigin,\n type RecaptchaAllowlistResult,\n} from './recaptcha-allowlist'\nimport {\n attachProjectDomain,\n detachProjectDomain,\n pendingUploadCorsRemedy,\n type WorkspaceDomainResult,\n} from './workspace-domains'\n// The blocklist and the shape check, shared with the site custom-domain path\n// so a name added to one is added to both (AGL-1430).\nimport {\n normalizePlatformDomain,\n validatePlatformDomain,\n} from './platform-domain-names'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Collection name, kept in one place so the rules file and this agree. */\nexport const CONSOLE_DOMAINS_COLLECTION = 'consoleDomains'\n\n/**\n * Trim, lowercase, drop a trailing root dot, and tolerate a pasted URL.\n *\n * The console-flavoured name for the shared normaliser, kept because callers\n * and the docs use it.\n */\nexport const normalizeConsoleDomain = normalizePlatformDomain\n\n/**\n * Shape, length and reserved-name check.\n *\n * The RULES moved to `platform-domain-names.ts` (AGL-1430) so the site\n * custom-domain path could stop being the only surface without them — it was\n * the one with the live hole. Only the wording stays here: these two strings\n * are what a customer reads in the console-domain wizard.\n *\n * `{ domain, error }` with both keys always present rather than a discriminated\n * union, matching `validateSsoDomain` — `strictNullChecks` is off repo-wide and\n * an `{ ok: true } | { ok: false }` union does not narrow reliably once it\n * crosses a library boundary.\n */\nexport function validateConsoleDomain(input: string): DomainCheck {\n return validatePlatformDomain(input, {\n invalid: 'Enter a valid domain, for example console.acme.com',\n reserved: 'That domain is reserved and cannot be used as a console domain',\n })\n}\n\n/**\n * Every name this claim covers: the primary, then its twins.\n *\n * **A twin is a name we attach to Vercel that is not the one the customer\n * asked for**, and it is the sharp edge of the whole file. Attaching\n * `acme.com` without also attaching `www.acme.com` leaves half the traffic on\n * the floor; attaching `www.acme.com` without *claiming* it leaves a name in\n * our Vercel project that no Firestore document accounts for — and then a\n * second org claims it, `attachProjectDomain` answers `already-exists`, and\n * that org's visitors are redirected to the first org's console. The name is\n * therefore derived here, claimed here, and attached from the same list.\n *\n * The apex test is deliberately narrow: exactly two labels. `acme.co.uk` is\n * also an apex and gets no twin, which costs a customer a `www` redirect they\n * can ask for by hand. Guessing wrong in the other direction — treating\n * `console.acme.com` as an apex and quietly reserving `www.console.acme.com` —\n * would reserve names on an org's behalf that it never asked for, and a\n * reservation is the thing that locks another org out.\n */\nexport function consoleDomainNames(domain: string): string[] {\n const primary = normalizeConsoleDomain(domain)\n if (!primary) return []\n const labels = primary.split('.')\n if (labels.length !== 2) return [primary]\n return [primary, `www.${primary}`]\n}\n\nexport type ConsoleDomainStatus = 'pending' | 'verified' | 'active' | 'suspended'\n\nexport interface ConsoleDomainClaimView {\n domain: string\n orgId: string\n status: ConsoleDomainStatus\n token: string\n /** Every name the claim reserves — primary first. */\n names: string[]\n /** Fully-qualified host the TXT record goes on. */\n recordHost: string\n /** Exact value the TXT record must carry. */\n recordValue: string\n verified: boolean\n lastRecords?: string[]\n}\n\n/** Both keys always present, one always null — see `validateConsoleDomain`. */\nexport interface ConsoleDomainResult {\n claim: ConsoleDomainClaimView | null\n error: string | null\n /** HTTP status a route should return. 200 whenever `claim` is set. */\n status: number\n}\n\nexport class ConsoleDomainTakenError extends Error {\n readonly domain: string\n constructor(domain: string) {\n super(`Console domain already claimed: ${domain}`)\n this.name = 'ConsoleDomainTakenError'\n this.domain = domain\n }\n}\n\nconst claimRef = (domain: string) =>\n firestore().collection(CONSOLE_DOMAINS_COLLECTION).doc(domain)\n\nfunction claimView(\n domain: string,\n data: Record<string, unknown>,\n): ConsoleDomainClaimView {\n const token = String(data?.txtToken ?? '')\n const names = Array.isArray(data?.names)\n ? (data.names as string[])\n : consoleDomainNames(domain)\n return {\n domain,\n orgId: String(data?.orgId ?? ''),\n status: (data?.status as ConsoleDomainStatus) ?? 'pending',\n token,\n names,\n recordHost: `${SSO_CHALLENGE_PREFIX}.${domain}`,\n recordValue: challengeValue(token),\n verified: data?.status === 'verified' || data?.status === 'active',\n lastRecords: (data?.lastRecords as string[]) ?? undefined,\n }\n}\n\n/**\n * The org's `whiteLabel` entitlement, read server-side.\n *\n * AGL-1354 removed `brandingProfile` from what a client may write, so a server\n * route is now the only writer of `customConsoleDomain` — which makes this the\n * only place the gate can live. A gate that is bypassable is not a gate, and\n * this one was, on any plan, straight from the client SDK, until that landed.\n */\nasync function orgIsEntitled(orgId: string): Promise<boolean> {\n const snapshot = await firestore().collection('orgs').doc(orgId).get()\n if (!snapshot.exists) return false\n return checkEntitlement(\n snapshot.data() as Partial<AglynOrgBilling>,\n 'whiteLabel',\n )\n}\n\n/**\n * Claim the primary and every twin in ONE transaction, or none of them.\n *\n * Modelled on the `orgSlugs` reservation in `organizations.ts`: read inside the\n * transaction, refuse if someone else holds it, write in the same transaction.\n * Every name is read with a single `getAll` so the whole set is covered by one\n * transaction's read-set — claiming a twin in a follow-up write is precisely\n * the AGL-743 hole this exists to close.\n *\n * Idempotent for the owning org, and it **keeps the existing token**: reissuing\n * would invalidate a TXT record the customer may already have published and\n * turn a working setup into a mysterious failure (the same reasoning as\n * `issueDomainClaim`).\n *\n * Throws `ConsoleDomainTakenError` when any name in the set belongs to another\n * org. Exported for the integration test, which needs to drive the transaction\n * under real contention.\n */\nexport async function claimConsoleDomain(\n orgId: string,\n domain: string,\n): Promise<ConsoleDomainClaimView> {\n const names = consoleDomainNames(domain)\n const primary = names[0]\n const db = firestore()\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n\n return db.runTransaction(async (tx) => {\n const refs = names.map((name) =>\n db.collection(CONSOLE_DOMAINS_COLLECTION).doc(name),\n )\n // ONE read covering the whole set. Firestore requires every read before\n // every write in a transaction, so this also fixes the ordering.\n const snapshots = await tx.getAll(...refs)\n for (const snapshot of snapshots) {\n if (snapshot.exists && snapshot.get('orgId') !== orgId) {\n throw new ConsoleDomainTakenError(snapshot.id)\n }\n }\n const existing = snapshots[0]\n const token = existing.exists\n ? String(existing.get('txtToken') ?? '')\n : randomBytes(24).toString('hex')\n const status: ConsoleDomainStatus = existing.exists\n ? ((existing.get('status') as ConsoleDomainStatus) ?? 'pending')\n : 'pending'\n\n snapshots.forEach((snapshot, index) => {\n tx.set(\n refs[index],\n {\n orgId,\n primaryHost: primary,\n role: index === 0 ? 'primary' : 'redirect',\n names,\n txtToken: token,\n status,\n vercelState: snapshot.get('vercelState') ?? 'pending',\n createdAt: snapshot.exists ? snapshot.get('createdAt') : now,\n updatedAt: now,\n },\n { merge: true },\n )\n })\n return claimView(primary, { orgId, status, txtToken: token, names })\n })\n}\n\n/**\n * Start (or re-read) a registration: entitlement, validation, then the claim.\n *\n * The order matters. An org without `whiteLabel` must not be able to learn\n * whether a domain is free, and must not leave a `pending` claim behind that\n * blocks the org that *is* entitled to it.\n */\nexport async function registerConsoleDomain(options: {\n orgId: string\n domain: string\n}): Promise<ConsoleDomainResult> {\n const orgId = String(options?.orgId ?? '')\n if (!orgId) return { claim: null, error: 'Unknown organization', status: 400 }\n if (!(await orgIsEntitled(orgId))) {\n return {\n claim: null,\n error: 'A custom console domain requires the Agency plan',\n status: 403,\n }\n }\n const { domain, error } = validateConsoleDomain(options?.domain)\n if (!domain) return { claim: null, error: error ?? 'Invalid domain', status: 400 }\n try {\n return { claim: await claimConsoleDomain(orgId, domain), error: null, status: 200 }\n } catch (thrown) {\n if (thrown instanceof ConsoleDomainTakenError) {\n return {\n claim: null,\n error: 'That domain is already connected to another organization',\n status: 409,\n }\n }\n throw thrown\n }\n}\n\n/**\n * Re-read DNS and record the verdict.\n *\n * Re-reads the claim first and refuses if the caller does not own it — the\n * `publishSsoDomains` discipline, where nothing reaches the collection sign-in\n * consults until a fresh lookup has seen the record. The comparison itself is\n * `recordsProveOwnership`, an exact match on a whole TXT record: a substring\n * test would accept `aglyn-domain-verification=<token>-and-more`, and a prefix\n * test would accept anything that merely starts the same way.\n */\nexport async function verifyConsoleDomain(options: {\n orgId: string\n domain: string\n}): Promise<ConsoleDomainResult> {\n const orgId = String(options?.orgId ?? '')\n const { domain, error } = validateConsoleDomain(options?.domain)\n if (!domain) return { claim: null, error: error ?? 'Invalid domain', status: 400 }\n\n const snapshot = await claimRef(domain).get()\n if (!snapshot.exists || snapshot.get('orgId') !== orgId) {\n return { claim: null, error: 'No claim on that domain', status: 404 }\n }\n if (!(await orgIsEntitled(orgId))) {\n return {\n claim: null,\n error: 'A custom console domain requires the Agency plan',\n status: 403,\n }\n }\n\n const token = String(snapshot.get('txtToken') ?? '')\n const records = await resolveChallengeTxt(domain)\n const proved = recordsProveOwnership(records, token)\n const status: ConsoleDomainStatus = proved\n ? snapshot.get('status') === 'active'\n ? 'active'\n : 'verified'\n : 'pending'\n await claimRef(domain).set(\n {\n status,\n lastRecords: records.slice(0, 10),\n verifiedAt: proved\n ? (snapshot.get('verifiedAt') ??\n firebaseAdmin.firestore.FieldValue.serverTimestamp())\n : null,\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n const view = claimView(domain, {\n ...snapshot.data(),\n status,\n txtToken: token,\n lastRecords: records,\n })\n return proved\n ? { claim: view, error: null, status: 200 }\n : {\n claim: view,\n error: `No matching TXT record at ${view.recordHost}`,\n status: 409,\n }\n}\n\nexport interface ConsoleDomainAttachment {\n domain: string\n results: WorkspaceDomainResult[]\n /** Vercel's verdict alone: every claimed name is on the console project. */\n attached: boolean\n /**\n * Attached **and** able to attest — the only thing a caller may treat as\n * \"the customer can use this domain\". `attached && !ready` is a real and\n * reachable state: Vercel serves the name, App Check refuses it.\n */\n ready: boolean\n /**\n * Non-null when a name this claim SERVES on cannot complete a large upload\n * (AGL-1452), carrying the exact ordered command that fixes it.\n *\n * The attach itself still succeeds — the console does serve on the name, and\n * refusing the domain over the upload path would be the worse outcome. But\n * the customer's video, PDF and ZIP uploads will fail behind a generic \"try\n * again\" snackbar until this is cleared, and the whole point of surfacing it\n * here is that it stops being something a person has to remember: GCS\n * matches CORS origins EXACTLY, so a new console origin needs its own entry\n * and nothing about the eventual symptom points at bucket configuration.\n *\n * `attachProjectDomain` tries to add the entry itself; this is what is left\n * when it could not — in practice, a runtime service account without\n * `storage.buckets.update`.\n */\n uploadCorsRemedy: string | null\n /**\n * The App Check reCAPTCHA allowlist write for the SERVING name (AGL-1378).\n *\n * Unlike `uploadCorsRemedy` this is **not** advisory. A console origin the\n * key has never heard of cannot solve reCAPTCHA, so `getToken` fails with\n * `appCheck/recaptcha-error` and Identity Platform refuses the first\n * `signInWithCustomToken` with `401 UNAUTHENTICATED` — before it looks at\n * the token. The domain would serve a console that renders and can never\n * sign anyone in, and the customer-visible symptom is \"Missing or\n * insufficient permissions\", indistinguishable from a rules verdict.\n *\n * So this participates in `attached`: a claim does not reach `active` unless\n * `allowlistSatisfied()` says the origin can attest.\n */\n allowlist: RecaptchaAllowlistResult\n}\n\n/**\n * Attach every claimed name to the console Vercel project.\n *\n * ⚠️ Not called from anywhere. It exists so the mechanism is complete and\n * tested; wiring it to a route is 1099c's job and must not happen before 1099d\n * (see the header).\n *\n * Refuses unless the claim is this org's **and** currently verified, and unless\n * the org still passes `whiteLabel`. The names come from the stored claim, not\n * from `consoleDomainNames`, so a change to the twin rule can never attach a\n * name the transaction did not reserve.\n *\n * Twins are registered as per-domain **redirects to the primary**, and the\n * redirect value is a **bare hostname** — `attachProjectDomain` enforces that,\n * because `https://${target}` is rejected by Vercel with a message that blames\n * the target for being absent rather than the format (AGL-1365).\n */\nexport async function activateConsoleDomain(options: {\n orgId: string\n domain: string\n}): Promise<ConsoleDomainResult & { attachment: ConsoleDomainAttachment | null }> {\n const orgId = String(options?.orgId ?? '')\n const { domain, error } = validateConsoleDomain(options?.domain)\n if (!domain) {\n return { claim: null, error: error ?? 'Invalid domain', status: 400, attachment: null }\n }\n const snapshot = await claimRef(domain).get()\n if (!snapshot.exists || snapshot.get('orgId') !== orgId) {\n return { claim: null, error: 'No claim on that domain', status: 404, attachment: null }\n }\n if (!(await orgIsEntitled(orgId))) {\n return {\n claim: null,\n error: 'A custom console domain requires the Agency plan',\n status: 403,\n attachment: null,\n }\n }\n const status = snapshot.get('status')\n if (status !== 'verified' && status !== 'active') {\n return {\n claim: null,\n error: 'Prove ownership of the domain before attaching it',\n status: 409,\n attachment: null,\n }\n }\n\n const names: string[] = Array.isArray(snapshot.get('names'))\n ? snapshot.get('names')\n : [domain]\n const primary = names[0]\n const results: WorkspaceDomainResult[] = []\n for (const [index, name] of names.entries()) {\n results.push(\n await attachProjectDomain(\n name,\n index === 0 ? {} : { redirectTo: primary },\n ),\n )\n }\n const attached = results.every(\n (result) => result.outcome === 'attached' || result.outcome === 'already-exists',\n )\n const uploadCorsRemedy = pendingUploadCorsRemedy(results)\n\n // AGL-1378. Vercel first, deliberately: allowlisting a name we do not\n // actually serve would hand that origin the right to use our App Check site\n // key for nothing in return. Only the primary — a twin is a 308 and never\n // executes the console.\n //\n // If this loses, the claim stays `verified`. A domain reported ready while\n // unlisted is the failure that costs a customer a day of \"permission\n // denied\" tickets against rules that are perfectly correct.\n const allowlist = attached\n ? await allowConsoleOrigin(primary)\n : ({ outcome: 'failed', domain: primary, detail: 'Vercel did not accept every name' } as const)\n const ready = attached && allowlistSatisfied(allowlist.outcome)\n\n await claimRef(domain).set(\n {\n status: ready ? 'active' : 'verified',\n vercelState: attached ? 'attached' : 'pending',\n appCheckState: allowlist.outcome,\n activatedAt: ready\n ? (snapshot.get('activatedAt') ??\n firebaseAdmin.firestore.FieldValue.serverTimestamp())\n : null,\n // Bumped on every activation so a session minted against a previous\n // attachment of the same name is not honoured (AGL-1353 D6).\n sessionEpoch: Date.now(),\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n const view = claimView(domain, {\n ...snapshot.data(),\n status: ready ? 'active' : 'verified',\n })\n return {\n claim: view,\n error: ready\n ? null\n : attached\n ? `The domain is attached but cannot pass App Check: ${allowlist.detail ?? allowlist.outcome}`\n : 'Vercel did not accept every name',\n status: ready ? 200 : 502,\n attachment: { domain: primary, results, attached, ready, uploadCorsRemedy, allowlist },\n }\n}\n\n/**\n * `sessionEpoch = now` across every name a claim covers.\n *\n * The revocation lever of AGL-1353 D6, factored out because D7 pulls it twice:\n * `suspendOnDowngrade` writes it alongside the suspension, and a release writes\n * it alone, before anything else happens. Epoch-ms via `Date.now()`, never\n * `serverTimestamp()` — the value is compared against a cookie's `iat` and an\n * `authorizedAt`, both plain numbers, and a sentinel that resolves later would\n * read as `NaN` at exactly the moment it is supposed to refuse.\n *\n * Never throws, for `suspendOnDowngrade`'s reason: this is defence in depth\n * behind the Vercel domain allowlist, and a caller that cannot persist the bump\n * still has work to do that is more important than the bump.\n */\nasync function bumpConsoleSessionEpoch(names: string[]): Promise<void> {\n if (!Array.isArray(names) || names.length === 0) return\n try {\n const batch = firestore().batch()\n for (const name of names) {\n batch.set(\n firestore().collection(CONSOLE_DOMAINS_COLLECTION).doc(name),\n {\n sessionEpoch: Date.now(),\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n await batch.commit()\n } catch (error) {\n console.error('[console-domains] session epoch bump failed', error)\n }\n}\n\n/**\n * Kill the sessions, detach every name, then drop the claim — in that order.\n *\n * **The epoch bump comes first, and that is D7's own ordering**: *\"`sessionEpoch\n * = now` first, then Vercel `DELETE`, then delete the doc. Order matters: kill\n * the sessions while we still control the host.\"* It was the one step of that\n * sentence this function never performed, while\n * {@link consoleSessionEpochRefuses} documented it as something release\n * already did.\n *\n * Two things went wrong without it, and neither is covered by the Vercel\n * detach:\n *\n * - **The in-flight window.** Vercel's `DELETE` and the allowlist reclaim are\n * network round trips. Across all of them the claim still reads `active`, so\n * `resolveConsoleDomain` says `servable` and `redeemConsoleHandoff` — which\n * sits on `/api/*`, outside the middleware matcher — happily cashes an\n * authorized handoff into a brand-new session on a domain we are in the\n * middle of giving up.\n * - **And that session then outlives the release entirely**, because the\n * document carrying the epoch is deleted a moment later and\n * `readConsoleSessionEpoch` reads a missing document as `0`, which every\n * caller treats as \"this control has nothing to say\" and FAILS OPEN. The\n * effect was that `suspendOnDowngrade` — the *lesser* action — revoked\n * sessions properly while an explicit release did not revoke them at all.\n *\n * Bumping first inverts that: an authorization that predates the bump is\n * refused `revoked` at redemption, so the window closes rather than merely\n * being short. What survives the release is still only Vercel's allowlist,\n * which is the boundary; this is the defence in depth behind it that D7 asked\n * for and the code had stopped providing.\n *\n * Vercel before the documents, though, for the reason the header gives: a claim\n * released while the name is still on the project is the unindexed-name hole,\n * opened deliberately. If a detach fails the claim is kept and the caller is\n * told, so the name stays reserved to the org that still holds it in Vercel —\n * and the epoch bump is exactly the right residue on that path too, since\n * sessions on a domain we just tried to release should not survive the attempt.\n */\nexport async function releaseConsoleDomain(options: {\n orgId: string\n domain: string\n}): Promise<{\n released: boolean\n results: WorkspaceDomainResult[]\n /** Present once the reclaim was attempted — absent when Vercel refused first. */\n allowlist?: RecaptchaAllowlistResult\n error: string | null\n}> {\n const orgId = String(options?.orgId ?? '')\n const { domain } = validateConsoleDomain(options?.domain)\n if (!domain) return { released: false, results: [], error: 'Invalid domain' }\n const snapshot = await claimRef(domain).get()\n if (!snapshot.exists || snapshot.get('orgId') !== orgId) {\n return { released: false, results: [], error: 'No claim on that domain' }\n }\n const names: string[] = Array.isArray(snapshot.get('names'))\n ? snapshot.get('names')\n : [domain]\n\n // D7, step one. Across EVERY name, so a twin cannot outlive its primary —\n // the same reasoning `suspendOnDowngrade` uses, and the same failure posture:\n // never throw. A bump that does not persist still leaves the detach below to\n // run, and refusing to release over a transient Firestore error would leave\n // the domain both attached AND serving, which is the worse of the two.\n await bumpConsoleSessionEpoch(names)\n\n const results: WorkspaceDomainResult[] = []\n for (const name of names) results.push(await detachProjectDomain(name))\n const clear = results.every(\n (result) =>\n result.outcome === 'detached' ||\n result.outcome === 'not-found' ||\n result.outcome === 'skipped',\n )\n if (!clear) {\n return {\n released: false,\n results,\n error: 'The domain is still attached to the project',\n }\n }\n\n // AGL-1378, the reclaim half. Same ordering rule as Vercel: give the\n // capability back before dropping the record of it. A claim deleted while\n // the name is still on the reCAPTCHA key leaves an origin we no longer\n // serve holding a permanent right to mint App Check tokens against our site\n // key, with nothing left in Firestore that says why it is there — and it\n // silently spends one of the 250 slots forever.\n const allowlist = await reclaimConsoleOrigin(names[0])\n if (!allowlistSatisfied(allowlist.outcome)) {\n return {\n released: false,\n results,\n allowlist,\n error: `The domain is off Vercel but still on the App Check allowlist: ${allowlist.detail ?? allowlist.outcome}`,\n }\n }\n\n const batch = firestore().batch()\n for (const name of names) {\n batch.delete(firestore().collection(CONSOLE_DOMAINS_COLLECTION).doc(name))\n }\n await batch.commit()\n return { released: true, results, allowlist, error: null }\n}\n\n/**\n * Drop a claim that was never attached, without touching Vercel.\n *\n * The counterpart to claiming from the branding form: an org that clears or\n * changes `customConsoleDomain` must not leave its old reservation standing,\n * or it locks another org out of a name nobody is using. Deliberately refuses\n * once the claim has proved ownership or reached Vercel — releasing *that*\n * needs `releaseConsoleDomain`, because a claim dropped while the name is\n * still on the project is the unindexed-name hole in the header.\n */\nexport async function releasePendingConsoleDomain(options: {\n orgId: string\n domain: string\n}): Promise<boolean> {\n const orgId = String(options?.orgId ?? '')\n const domain = normalizeConsoleDomain(options?.domain)\n if (!orgId || !domain) return false\n const snapshot = await claimRef(domain).get()\n if (!snapshot.exists || snapshot.get('orgId') !== orgId) return false\n if (snapshot.get('status') !== 'pending') return false\n if (snapshot.get('vercelState') === 'attached') return false\n const names: string[] = Array.isArray(snapshot.get('names'))\n ? snapshot.get('names')\n : [domain]\n const batch = firestore().batch()\n for (const name of names) {\n batch.delete(firestore().collection(CONSOLE_DOMAINS_COLLECTION).doc(name))\n }\n await batch.commit()\n return true\n}\n\n/**\n * Why a host may — or may not — be served the console (AGL-1099c).\n *\n * `reason` is not decoration. `unknown` and `degraded` both mean \"serve it,\n * this gate has no opinion\", and they mean it for opposite causes; every other\n * value means \"refuse\". Collapsing them into a boolean is how a lookup outage\n * would come to read the same as a revoked entitlement.\n */\nexport type ConsoleDomainReason =\n | 'active'\n | 'unknown'\n | 'no-org'\n | 'not-entitled'\n | 'not-active'\n | 'degraded'\n\nexport interface ConsoleDomainVerdict {\n /** A claim exists for this exact host. */\n known: boolean\n /** The console may be served here, pinned to `orgSlug`. */\n servable: boolean\n /**\n * Workspace slug of the owning org — where a refused visitor is sent, and\n * the org the path is rewritten under when servable. Never the `orgId`: a\n * verdict is read by an unauthenticated route.\n */\n orgSlug: string | null\n reason: ConsoleDomainReason\n /** The lookup could not be completed. Honour it, never cache it. */\n degraded: boolean\n}\n\nconst UNKNOWN_VERDICT: ConsoleDomainVerdict = {\n known: false,\n servable: false,\n orgSlug: null,\n reason: 'unknown',\n degraded: false,\n}\n\nconst DEGRADED_VERDICT: ConsoleDomainVerdict = {\n known: false,\n servable: false,\n orgSlug: null,\n reason: 'degraded',\n degraded: true,\n}\n\n/**\n * Stop serving a domain whose org no longer holds `whiteLabel`, and make it\n * stick.\n *\n * **This is the fail-closed half of the entitlement, and it is a write on a\n * read path on purpose.** Nothing in the codebase reacts to a plan change —\n * the Stripe webhook writes `plan` and says outright that entitlements resolve\n * at read time, which is sufficient for rendering and insufficient for a\n * hostname (AGL-1353 D7). Rather than add a fan-out to the webhook or a cron\n * that has to be believed, the first request to arrive after a downgrade\n * converts the read-time verdict into stored state: `status: 'suspended'` plus\n * a `sessionEpoch` bump, across **every** name the claim covers, so the twin\n * cannot outlive its primary.\n *\n * The epoch is what makes this more than cosmetic. A refusal that only lives\n * in a verdict leaves already-minted cookies valid; bumping the epoch is the\n * lever that invalidates them at our boundary (AGL-1353 D6).\n *\n * Deliberately NOT the inverse: re-upgrading does not re-activate. Activation\n * attaches names to Vercel and — until AGL-1378 resolves — depends on a manual\n * App Check allowlist entry, so it stays an explicit act through\n * `activateConsoleDomain`, never a side effect of a plan read.\n *\n * Never throws: a suspension that fails to persist still refuses this request,\n * and the next one tries again.\n */\nasync function suspendOnDowngrade(\n domain: string,\n snapshot: FirebaseFirestore.DocumentSnapshot,\n): Promise<void> {\n const status = snapshot.get('status')\n if (status === 'suspended') return\n const names: string[] = Array.isArray(snapshot.get('names'))\n ? snapshot.get('names')\n : [domain]\n try {\n const batch = firestore().batch()\n for (const name of names) {\n batch.set(\n firestore().collection(CONSOLE_DOMAINS_COLLECTION).doc(name),\n {\n status: 'suspended',\n suspendedReason: 'entitlement',\n sessionEpoch: Date.now(),\n suspendedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n await batch.commit()\n } catch (error) {\n console.error('[console-domains] suspend on downgrade failed', error)\n }\n}\n\n/**\n * Host → org, for the middleware gate and the session-route boundary.\n *\n * **One host pins exactly one org.** That is enforced by the document being\n * keyed on the host and claimed transactionally (see the header), not by\n * anything here — this function only ever reads the pin, and there is no input\n * by which a caller can ask for a different org.\n *\n * The three \"serve it anyway\" answers, and why each is right:\n *\n * - **`unknown`** — no claim on this host. localhost, preview deployments and\n * self-hosted installs all land here, and they must keep working. It is safe\n * because of the detach ordering `releaseConsoleDomain` enforces: Vercel\n * first, documents second, and the claim is kept if the detach fails. So a\n * name the console project still answers for always has a document, and a\n * name with no document cannot reach this deployment through the console\n * project at all (AGL-1430's correspondence — do not break it).\n * - **`degraded`** — Firestore could not be reached. Fails OPEN, matching the\n * workspace gate and for the reason AGL-1135 recorded: the Vercel domain\n * allowlist is the boundary, and a customer's console going dark because a\n * lookup timed out is worse than the residual exposure. Never cached.\n * - a claim that is `active` and entitled.\n *\n * Everything else refuses. In particular a `verified` claim is **not**\n * servable: until AGL-1378 clears, activation includes a manual App Check\n * allowlist entry, so \"ownership proved\" must never imply \"serve it\".\n */\nexport async function resolveConsoleDomain(\n host: string,\n): Promise<ConsoleDomainVerdict> {\n const domain = normalizeConsoleDomain(host)\n if (!domain) return UNKNOWN_VERDICT\n\n let snapshot: FirebaseFirestore.DocumentSnapshot\n try {\n snapshot = await claimRef(domain).get()\n } catch {\n return DEGRADED_VERDICT\n }\n if (!snapshot.exists) return UNKNOWN_VERDICT\n\n const orgId = String(snapshot.get('orgId') ?? '')\n if (!orgId) return { ...UNKNOWN_VERDICT, known: true, reason: 'no-org' }\n\n let orgSnapshot: FirebaseFirestore.DocumentSnapshot\n try {\n orgSnapshot = await firestore().collection('orgs').doc(orgId).get()\n } catch {\n return DEGRADED_VERDICT\n }\n // A claim pointing at an org that no longer exists refuses, but is NOT\n // suspended: `checkEntitlement(undefined)` resolves to the free plan, so\n // writing a suspension off a missing document would be recording an\n // entitlement verdict that was never actually read (the loading-default\n // class this repo keeps hitting). Refuse now, decide when there is data.\n if (!orgSnapshot.exists) {\n return { ...UNKNOWN_VERDICT, known: true, reason: 'no-org' }\n }\n\n const org = orgSnapshot.data() as Partial<AglynOrgBilling> & { slug?: string }\n const orgSlug = String(org?.slug ?? '') || null\n\n if (!checkEntitlement(org, 'whiteLabel')) {\n await suspendOnDowngrade(domain, snapshot)\n return { known: true, servable: false, orgSlug, reason: 'not-entitled', degraded: false }\n }\n if (snapshot.get('status') !== 'active') {\n return { known: true, servable: false, orgSlug, reason: 'not-active', degraded: false }\n }\n return { known: true, servable: true, orgSlug, reason: 'active', degraded: false }\n}\n\n/**\n * The org a console domain belongs to, or null.\n *\n * Read-only, and used today only to refuse a `customConsoleDomain` that another\n * org already holds. Routing reads `resolveConsoleDomain` instead, which asks\n * the entitlement question this one does not.\n */\nexport async function getConsoleDomainClaim(\n domain: string,\n): Promise<{ orgId: string; status: ConsoleDomainStatus } | null> {\n const normalized = normalizeConsoleDomain(domain)\n if (!normalized) return null\n const snapshot = await claimRef(normalized).get()\n if (!snapshot.exists) return null\n return {\n orgId: String(snapshot.get('orgId') ?? ''),\n status: (snapshot.get('status') as ConsoleDomainStatus) ?? 'pending',\n }\n}\n"],"names":["randomBytes","checkEntitlement","firebaseAdmin","challengeValue","recordsProveOwnership","resolveChallengeTxt","SSO_CHALLENGE_PREFIX","allowConsoleOrigin","allowlistSatisfied","reclaimConsoleOrigin","attachProjectDomain","detachProjectDomain","pendingUploadCorsRemedy","normalizePlatformDomain","validatePlatformDomain","firestore","app","CONSOLE_DOMAINS_COLLECTION","normalizeConsoleDomain","validateConsoleDomain","input","invalid","reserved","consoleDomainNames","domain","primary","labels","split","length","ConsoleDomainTakenError","Error","name","claimRef","collection","doc","claimView","data","token","String","txtToken","names","Array","isArray","orgId","status","recordHost","recordValue","verified","lastRecords","undefined","orgIsEntitled","snapshot","get","exists","claimConsoleDomain","db","now","FieldValue","serverTimestamp","runTransaction","tx","existing","refs","map","snapshots","getAll","id","toString","forEach","index","set","primaryHost","role","vercelState","createdAt","updatedAt","merge","registerConsoleDomain","options","claim","error","thrown","verifyConsoleDomain","records","proved","slice","verifiedAt","view","activateConsoleDomain","allowlist","attachment","results","entries","push","redirectTo","attached","every","result","outcome","uploadCorsRemedy","detail","ready","appCheckState","activatedAt","sessionEpoch","Date","bumpConsoleSessionEpoch","batch","commit","console","releaseConsoleDomain","released","clear","delete","releasePendingConsoleDomain","UNKNOWN_VERDICT","known","servable","orgSlug","reason","degraded","DEGRADED_VERDICT","suspendOnDowngrade","suspendedReason","suspendedAt","resolveConsoleDomain","host","orgSnapshot","org","slug","getConsoleDomainClaim","normalized"],"mappings":"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"}
@@ -0,0 +1,91 @@
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
+ /**
18
+ * Single use as a PROPERTY, not a policy (AGL-1353 D2/D8, extraction 1).
19
+ *
20
+ * Lifted from the WebAuthn challenge consume in
21
+ * `apps/console/app/api/_lib/passkeys.ts`, whose comment states the standard
22
+ * verbatim:
23
+ *
24
+ * > single use is not a policy here, it is a property: a second consume of the
25
+ * > same id finds nothing, whatever the outcome of the first
26
+ *
27
+ * ## Why the transaction must WRITE
28
+ *
29
+ * Firestore transactions are serializable with optimistic concurrency: two
30
+ * transactions that read AND write the same document cannot both commit. The
31
+ * loser retries, re-reads the consumed state, and fails cleanly — so exactly
32
+ * one caller ever sees a success. A read-only check followed by a write
33
+ * *outside* the transaction would not be detected as contention at all, and is
34
+ * the mistake to look for in review. That is why {@link consumeOnce} always
35
+ * mutates the document it read, on every accepting path, and why `validate`
36
+ * returns the new state rather than performing the write itself.
37
+ *
38
+ * ## Why not the rate limiter
39
+ *
40
+ * `consumeRateLimit(key, { limit: 1 })` looks like single use and is not.
41
+ * It **fails soft**: on a Firestore error it silently falls back to a
42
+ * per-instance in-memory limiter and reports `degraded: true`. A single-use
43
+ * guarantee that evaporates during a Firestore blip is a replayable
44
+ * credential. Volume control fails soft; uniqueness must fail closed.
45
+ */
46
+ /** What {@link consumeOnce} does with the document it read. */
47
+ export type ConsumeDecision<T> = {
48
+ /** Accept: commit `patch` (merged) — or delete — and return `value`. */
49
+ accept: true;
50
+ value: T;
51
+ patch?: Record<string, unknown>;
52
+ /** Delete the document instead of patching it. */
53
+ remove?: boolean;
54
+ } | {
55
+ accept: false;
56
+ reason: string;
57
+ /**
58
+ * Delete the document anyway.
59
+ *
60
+ * Off by default, and the default is the safer one: a refused consume
61
+ * that writes can destroy a record a concurrent legitimate consume is
62
+ * about to accept, which hands a denial of service to anyone who can
63
+ * guess an id. The cross-domain handoff relies on that default.
64
+ *
65
+ * The WebAuthn challenge consume opted in when it was migrated here
66
+ * (AGL-1902), because that is what it did before and an extraction must
67
+ * not change behaviour on its way out of the file it came from. Whether
68
+ * it SHOULD is a separate question from whether this refactor may
69
+ * decide it.
70
+ */
71
+ remove?: boolean;
72
+ };
73
+ export interface ConsumeOnceResult<T> {
74
+ ok: boolean;
75
+ value: T | null;
76
+ /** Why it was refused. `'absent'` when the document was already gone. */
77
+ reason: string | null;
78
+ }
79
+ /**
80
+ * Read a document and, in the same transaction, consume it.
81
+ *
82
+ * `validate` is called with the document's data and must decide. It runs
83
+ * INSIDE the transaction, so every check it makes — status, expiry, hashes,
84
+ * target host, liveness — is part of the same serializable unit as the write.
85
+ * Nothing it needs may be fetched afterwards.
86
+ *
87
+ * @param firestore - Admin SDK Firestore
88
+ * @param ref - the document to consume
89
+ * @param validate - decides, given the stored data
90
+ */
91
+ export declare function consumeOnce<T>(firestore: FirebaseFirestore.Firestore, ref: FirebaseFirestore.DocumentReference, validate: (data: Record<string, unknown>) => ConsumeDecision<T>): Promise<ConsumeOnceResult<T>>;
@@ -0,0 +1,96 @@
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
+ * Single use as a PROPERTY, not a policy (AGL-1353 D2/D8, extraction 1).
18
+ *
19
+ * Lifted from the WebAuthn challenge consume in
20
+ * `apps/console/app/api/_lib/passkeys.ts`, whose comment states the standard
21
+ * verbatim:
22
+ *
23
+ * > single use is not a policy here, it is a property: a second consume of the
24
+ * > same id finds nothing, whatever the outcome of the first
25
+ *
26
+ * ## Why the transaction must WRITE
27
+ *
28
+ * Firestore transactions are serializable with optimistic concurrency: two
29
+ * transactions that read AND write the same document cannot both commit. The
30
+ * loser retries, re-reads the consumed state, and fails cleanly — so exactly
31
+ * one caller ever sees a success. A read-only check followed by a write
32
+ * *outside* the transaction would not be detected as contention at all, and is
33
+ * the mistake to look for in review. That is why {@link consumeOnce} always
34
+ * mutates the document it read, on every accepting path, and why `validate`
35
+ * returns the new state rather than performing the write itself.
36
+ *
37
+ * ## Why not the rate limiter
38
+ *
39
+ * `consumeRateLimit(key, { limit: 1 })` looks like single use and is not.
40
+ * It **fails soft**: on a Firestore error it silently falls back to a
41
+ * per-instance in-memory limiter and reports `degraded: true`. A single-use
42
+ * guarantee that evaporates during a Firestore blip is a replayable
43
+ * credential. Volume control fails soft; uniqueness must fail closed.
44
+ */ /** What {@link consumeOnce} does with the document it read. */ /**
45
+ * Read a document and, in the same transaction, consume it.
46
+ *
47
+ * `validate` is called with the document's data and must decide. It runs
48
+ * INSIDE the transaction, so every check it makes — status, expiry, hashes,
49
+ * target host, liveness — is part of the same serializable unit as the write.
50
+ * Nothing it needs may be fetched afterwards.
51
+ *
52
+ * @param firestore - Admin SDK Firestore
53
+ * @param ref - the document to consume
54
+ * @param validate - decides, given the stored data
55
+ */ export async function consumeOnce(firestore, ref, validate) {
56
+ return firestore.runTransaction(async (tx)=>{
57
+ var _snapshot_data, _decision_patch, _decision_value;
58
+ const snapshot = await tx.get(ref);
59
+ if (!snapshot.exists) {
60
+ return {
61
+ ok: false,
62
+ value: null,
63
+ reason: 'absent'
64
+ };
65
+ }
66
+ // Read structurally rather than by narrowing. `strictNullChecks` is off
67
+ // repo-wide and discriminated-union narrowing on a boolean literal does
68
+ // not survive it — the same reason `security-alerts.ts` reads its send
69
+ // failures this way.
70
+ const decision = validate((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {});
71
+ if (!decision.accept) {
72
+ var _decision_reason;
73
+ // No write on a refusal unless the caller asks. A refused consume must
74
+ // not be able to destroy a record that a concurrent legitimate consume
75
+ // is about to accept — and the record's own expiry, not this call, is
76
+ // what eventually clears it.
77
+ if (decision.remove) tx.delete(ref);
78
+ return {
79
+ ok: false,
80
+ value: null,
81
+ reason: (_decision_reason = decision.reason) != null ? _decision_reason : 'refused'
82
+ };
83
+ }
84
+ if (decision.remove) tx.delete(ref);
85
+ else tx.set(ref, (_decision_patch = decision.patch) != null ? _decision_patch : {}, {
86
+ merge: true
87
+ });
88
+ return {
89
+ ok: true,
90
+ value: (_decision_value = decision.value) != null ? _decision_value : null,
91
+ reason: null
92
+ };
93
+ });
94
+ }
95
+
96
+ //# sourceMappingURL=consume-once.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/consume-once.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 * Single use as a PROPERTY, not a policy (AGL-1353 D2/D8, extraction 1).\n *\n * Lifted from the WebAuthn challenge consume in\n * `apps/console/app/api/_lib/passkeys.ts`, whose comment states the standard\n * verbatim:\n *\n * > single use is not a policy here, it is a property: a second consume of the\n * > same id finds nothing, whatever the outcome of the first\n *\n * ## Why the transaction must WRITE\n *\n * Firestore transactions are serializable with optimistic concurrency: two\n * transactions that read AND write the same document cannot both commit. The\n * loser retries, re-reads the consumed state, and fails cleanly — so exactly\n * one caller ever sees a success. A read-only check followed by a write\n * *outside* the transaction would not be detected as contention at all, and is\n * the mistake to look for in review. That is why {@link consumeOnce} always\n * mutates the document it read, on every accepting path, and why `validate`\n * returns the new state rather than performing the write itself.\n *\n * ## Why not the rate limiter\n *\n * `consumeRateLimit(key, { limit: 1 })` looks like single use and is not.\n * It **fails soft**: on a Firestore error it silently falls back to a\n * per-instance in-memory limiter and reports `degraded: true`. A single-use\n * guarantee that evaporates during a Firestore blip is a replayable\n * credential. Volume control fails soft; uniqueness must fail closed.\n */\n\n/** What {@link consumeOnce} does with the document it read. */\nexport type ConsumeDecision<T> =\n | {\n /** Accept: commit `patch` (merged) — or delete — and return `value`. */\n accept: true\n value: T\n patch?: Record<string, unknown>\n /** Delete the document instead of patching it. */\n remove?: boolean\n }\n | {\n accept: false\n reason: string\n /**\n * Delete the document anyway.\n *\n * Off by default, and the default is the safer one: a refused consume\n * that writes can destroy a record a concurrent legitimate consume is\n * about to accept, which hands a denial of service to anyone who can\n * guess an id. The cross-domain handoff relies on that default.\n *\n * The WebAuthn challenge consume opted in when it was migrated here\n * (AGL-1902), because that is what it did before and an extraction must\n * not change behaviour on its way out of the file it came from. Whether\n * it SHOULD is a separate question from whether this refactor may\n * decide it.\n */\n remove?: boolean\n }\n\nexport interface ConsumeOnceResult<T> {\n ok: boolean\n value: T | null\n /** Why it was refused. `'absent'` when the document was already gone. */\n reason: string | null\n}\n\n/**\n * Read a document and, in the same transaction, consume it.\n *\n * `validate` is called with the document's data and must decide. It runs\n * INSIDE the transaction, so every check it makes — status, expiry, hashes,\n * target host, liveness — is part of the same serializable unit as the write.\n * Nothing it needs may be fetched afterwards.\n *\n * @param firestore - Admin SDK Firestore\n * @param ref - the document to consume\n * @param validate - decides, given the stored data\n */\nexport async function consumeOnce<T>(\n firestore: FirebaseFirestore.Firestore,\n ref: FirebaseFirestore.DocumentReference,\n validate: (data: Record<string, unknown>) => ConsumeDecision<T>,\n): Promise<ConsumeOnceResult<T>> {\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) {\n return { ok: false, value: null, reason: 'absent' }\n }\n // Read structurally rather than by narrowing. `strictNullChecks` is off\n // repo-wide and discriminated-union narrowing on a boolean literal does\n // not survive it — the same reason `security-alerts.ts` reads its send\n // failures this way.\n const decision = validate(\n (snapshot.data() ?? {}) as Record<string, unknown>,\n ) as {\n accept: boolean\n value?: T\n patch?: Record<string, unknown>\n remove?: boolean\n reason?: string\n }\n if (!decision.accept) {\n // No write on a refusal unless the caller asks. A refused consume must\n // not be able to destroy a record that a concurrent legitimate consume\n // is about to accept — and the record's own expiry, not this call, is\n // what eventually clears it.\n if (decision.remove) tx.delete(ref)\n return { ok: false, value: null, reason: decision.reason ?? 'refused' }\n }\n if (decision.remove) tx.delete(ref)\n else tx.set(ref, decision.patch ?? {}, { merge: true })\n return { ok: true, value: decision.value ?? null, reason: null }\n })\n}\n"],"names":["consumeOnce","firestore","ref","validate","runTransaction","tx","snapshot","decision","get","exists","ok","value","reason","data","accept","remove","delete","set","patch","merge"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GAED,6DAA6D,GAqC7D;;;;;;;;;;;CAWC,GACD,OAAO,eAAeA,YACpBC,SAAsC,EACtCC,GAAwC,EACxCC,QAA+D;IAE/D,OAAOF,UAAUG,cAAc,CAAC,OAAOC;YAUlCC,gBAiBcC,iBACSA;QA3B1B,MAAMD,WAAW,MAAMD,GAAGG,GAAG,CAACN;QAC9B,IAAI,CAACI,SAASG,MAAM,EAAE;YACpB,OAAO;gBAAEC,IAAI;gBAAOC,OAAO;gBAAMC,QAAQ;YAAS;QACpD;QACA,wEAAwE;QACxE,wEAAwE;QACxE,uEAAuE;QACvE,qBAAqB;QACrB,MAAML,WAAWJ,UACdG,iBAAAA,SAASO,IAAI,cAAbP,iBAAmB,CAAC;QAQvB,IAAI,CAACC,SAASO,MAAM,EAAE;gBAMqBP;YALzC,uEAAuE;YACvE,uEAAuE;YACvE,sEAAsE;YACtE,6BAA6B;YAC7B,IAAIA,SAASQ,MAAM,EAAEV,GAAGW,MAAM,CAACd;YAC/B,OAAO;gBAAEQ,IAAI;gBAAOC,OAAO;gBAAMC,MAAM,GAAEL,mBAAAA,SAASK,MAAM,YAAfL,mBAAmB;YAAU;QACxE;QACA,IAAIA,SAASQ,MAAM,EAAEV,GAAGW,MAAM,CAACd;aAC1BG,GAAGY,GAAG,CAACf,MAAKK,kBAAAA,SAASW,KAAK,YAAdX,kBAAkB,CAAC,GAAG;YAAEY,OAAO;QAAK;QACrD,OAAO;YAAET,IAAI;YAAMC,KAAK,GAAEJ,kBAAAA,SAASI,KAAK,YAAdJ,kBAAkB;YAAMK,QAAQ;QAAK;IACjE;AACF"}
@@ -0,0 +1,85 @@
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
+ /**
18
+ * The contact–company link as the Admin SDK writes it (AGL-2613).
19
+ *
20
+ * The console's writers apply `planContactCompanyLink` with the client
21
+ * SDK's sentinels; the server doors — the capture that links on domain, the
22
+ * create route, a lead's conversion, the upsert carrying a door's company —
23
+ * apply the same plan with `FieldValue`'s. One planner, two sentinel sets,
24
+ * and this file is the second: nothing here decides whether an id leaves the
25
+ * mirror, it only spells the answer in Admin terms.
26
+ *
27
+ * Imported by module path rather than from the `@aglyn/aglyn/server` barrel,
28
+ * for the reason `upsert-contact.ts` gives: the door's specs substitute a
29
+ * fixture barrel, and a helper that reached the planner only through the
30
+ * barrel would find `undefined` there and take the door down with it.
31
+ */
32
+ import { type ContactCompanyLinkPlan } from '@aglyn/aglyn/app-utils/crm';
33
+ /**
34
+ * The plan's mirror change as the sentinel — or array — an Admin write takes.
35
+ * Both an `update()` and a merge-`set()` accept these at the top level of
36
+ * the document, which is where the mirror lives.
37
+ */
38
+ export declare function contactCompanyMirrorValue(plan: ContactCompanyLinkPlan): unknown | undefined;
39
+ /**
40
+ * The contact's patch for a plan, in `update()` shape: the holder's facet by
41
+ * DOTTED path — a nested object would replace every other holder's facet —
42
+ * and the mirror at the top of the document.
43
+ */
44
+ export declare function contactCompanyLinkFields(plan: ContactCompanyLinkPlan, groupId: string): Record<string, unknown>;
45
+ /** The company's patch: its contacts count moved by `delta`. */
46
+ export declare function companyContactsCountFields(delta: number): Record<string, unknown>;
47
+ /**
48
+ * Move each company's count the plan names, one `update()` each, without
49
+ * failing the caller.
50
+ *
51
+ * Separate writes rather than a batch, for the callers that cannot batch:
52
+ * the upsert has already written the contact by the time it knows the plan
53
+ * held, and a capture door must not fail because a company document was
54
+ * deleted between the link and the count. A count that could not move is
55
+ * logged and left; the company's own page takes the live aggregate, which
56
+ * is what corrects a stale figure.
57
+ */
58
+ export declare function settleCompanyContactsCounts(companiesRef: FirebaseFirestore.CollectionReference | null, plan: ContactCompanyLinkPlan | null): Promise<void>;
59
+ export interface WriteContactCompanyLinkOptions {
60
+ firestore: FirebaseFirestore.Firestore;
61
+ contactRef: FirebaseFirestore.DocumentReference;
62
+ /**
63
+ * The stored contact, BEFORE this write — what the planner reads the
64
+ * previous link and the mirror off. `null` for a row the caller has just
65
+ * created with no company on it.
66
+ */
67
+ contact: Record<string, unknown> | null | undefined;
68
+ companiesRef: FirebaseFirestore.CollectionReference;
69
+ groupId: string;
70
+ /** The company to link for this holder, or `null` to unlink. */
71
+ companyId: string | null;
72
+ }
73
+ /**
74
+ * Link a contact to a company FOR ONE HOLDER — or unlink, with `null` — as
75
+ * ONE commit: the facet and the mirror on the contact, and the count on
76
+ * each company the link moves. `null` when the document already said so.
77
+ *
78
+ * A batch rather than two writes because the count is a derived figure of
79
+ * the mirror: a mirror that changed while the count did not is a company
80
+ * list that says "3" over a page of four. `updatedAt` moves on the contact
81
+ * — a link is an edit of the person's record — and not on the company,
82
+ * whose list is ordered by its own edits and would otherwise reshuffle on
83
+ * every capture.
84
+ */
85
+ export declare function writeContactCompanyLink(options: WriteContactCompanyLinkOptions): Promise<ContactCompanyLinkPlan | null>;
@@ -0,0 +1,105 @@
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
+ */ /**
18
+ * The contact–company link as the Admin SDK writes it (AGL-2613).
19
+ *
20
+ * The console's writers apply `planContactCompanyLink` with the client
21
+ * SDK's sentinels; the server doors — the capture that links on domain, the
22
+ * create route, a lead's conversion, the upsert carrying a door's company —
23
+ * apply the same plan with `FieldValue`'s. One planner, two sentinel sets,
24
+ * and this file is the second: nothing here decides whether an id leaves the
25
+ * mirror, it only spells the answer in Admin terms.
26
+ *
27
+ * Imported by module path rather than from the `@aglyn/aglyn/server` barrel,
28
+ * for the reason `upsert-contact.ts` gives: the door's specs substitute a
29
+ * fixture barrel, and a helper that reached the planner only through the
30
+ * barrel would find `undefined` there and take the door down with it.
31
+ */ import { COMPANY_CONTACTS_COUNT_FIELD, CONTACT_COMPANY_IDS_FIELD, planContactCompanyLink, readContactCompanyLink } from "@aglyn/aglyn/app-utils/crm";
32
+ import { contactFacetPath } from "@aglyn/aglyn/app-utils/contacts";
33
+ import { FieldValue } from "firebase-admin/firestore";
34
+ /**
35
+ * The plan's mirror change as the sentinel — or array — an Admin write takes.
36
+ * Both an `update()` and a merge-`set()` accept these at the top level of
37
+ * the document, which is where the mirror lives.
38
+ */ export function contactCompanyMirrorValue(plan) {
39
+ const change = plan.mirror;
40
+ if (!change) return undefined;
41
+ if (change.op === 'union') return FieldValue.arrayUnion(change.companyId);
42
+ if (change.op === 'remove') return FieldValue.arrayRemove(change.companyId);
43
+ return change.companyIds;
44
+ }
45
+ /**
46
+ * The contact's patch for a plan, in `update()` shape: the holder's facet by
47
+ * DOTTED path — a nested object would replace every other holder's facet —
48
+ * and the mirror at the top of the document.
49
+ */ export function contactCompanyLinkFields(plan, groupId) {
50
+ var _plan_companyId;
51
+ const mirror = contactCompanyMirrorValue(plan);
52
+ return _extends({
53
+ [contactFacetPath(groupId, 'companyId')]: (_plan_companyId = plan.companyId) != null ? _plan_companyId : FieldValue.delete()
54
+ }, mirror !== undefined ? {
55
+ [CONTACT_COMPANY_IDS_FIELD]: mirror
56
+ } : {});
57
+ }
58
+ /** The company's patch: its contacts count moved by `delta`. */ export function companyContactsCountFields(delta) {
59
+ return {
60
+ [COMPANY_CONTACTS_COUNT_FIELD]: FieldValue.increment(delta)
61
+ };
62
+ }
63
+ /**
64
+ * Move each company's count the plan names, one `update()` each, without
65
+ * failing the caller.
66
+ *
67
+ * Separate writes rather than a batch, for the callers that cannot batch:
68
+ * the upsert has already written the contact by the time it knows the plan
69
+ * held, and a capture door must not fail because a company document was
70
+ * deleted between the link and the count. A count that could not move is
71
+ * logged and left; the company's own page takes the live aggregate, which
72
+ * is what corrects a stale figure.
73
+ */ export async function settleCompanyContactsCounts(companiesRef, plan) {
74
+ if (!companiesRef || !(plan == null ? void 0 : plan.counts.length)) return;
75
+ await Promise.all(plan.counts.map((count)=>companiesRef.doc(count.companyId).update(companyContactsCountFields(count.delta)).catch((error)=>{
76
+ console.error('contact company count could not move', count.companyId, count.delta, error);
77
+ })));
78
+ }
79
+ /**
80
+ * Link a contact to a company FOR ONE HOLDER — or unlink, with `null` — as
81
+ * ONE commit: the facet and the mirror on the contact, and the count on
82
+ * each company the link moves. `null` when the document already said so.
83
+ *
84
+ * A batch rather than two writes because the count is a derived figure of
85
+ * the mirror: a mirror that changed while the count did not is a company
86
+ * list that says "3" over a page of four. `updatedAt` moves on the contact
87
+ * — a link is an edit of the person's record — and not on the company,
88
+ * whose list is ordered by its own edits and would otherwise reshuffle on
89
+ * every capture.
90
+ */ export async function writeContactCompanyLink(options) {
91
+ const { firestore, contactRef, contact, companiesRef, groupId, companyId } = options;
92
+ const plan = planContactCompanyLink(readContactCompanyLink(contact, groupId), companyId);
93
+ if (!plan) return null;
94
+ const batch = firestore.batch();
95
+ batch.update(contactRef, _extends({}, contactCompanyLinkFields(plan, groupId), {
96
+ updatedAt: FieldValue.serverTimestamp()
97
+ }));
98
+ for (const count of plan.counts){
99
+ batch.update(companiesRef.doc(count.companyId), companyContactsCountFields(count.delta));
100
+ }
101
+ await batch.commit();
102
+ return plan;
103
+ }
104
+
105
+ //# sourceMappingURL=contact-company-link.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-company-link.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 * The contact–company link as the Admin SDK writes it (AGL-2613).\n *\n * The console's writers apply `planContactCompanyLink` with the client\n * SDK's sentinels; the server doors — the capture that links on domain, the\n * create route, a lead's conversion, the upsert carrying a door's company —\n * apply the same plan with `FieldValue`'s. One planner, two sentinel sets,\n * and this file is the second: nothing here decides whether an id leaves the\n * mirror, it only spells the answer in Admin terms.\n *\n * Imported by module path rather than from the `@aglyn/aglyn/server` barrel,\n * for the reason `upsert-contact.ts` gives: the door's specs substitute a\n * fixture barrel, and a helper that reached the planner only through the\n * barrel would find `undefined` there and take the door down with it.\n */\n\nimport {\n COMPANY_CONTACTS_COUNT_FIELD,\n CONTACT_COMPANY_IDS_FIELD,\n type ContactCompanyLinkPlan,\n planContactCompanyLink,\n readContactCompanyLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { contactFacetPath } from '@aglyn/aglyn/app-utils/contacts'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The plan's mirror change as the sentinel — or array — an Admin write takes.\n * Both an `update()` and a merge-`set()` accept these at the top level of\n * the document, which is where the mirror lives.\n */\nexport function contactCompanyMirrorValue(\n plan: ContactCompanyLinkPlan,\n): unknown | undefined {\n const change = plan.mirror\n if (!change) return undefined\n if (change.op === 'union') return FieldValue.arrayUnion(change.companyId)\n if (change.op === 'remove') return FieldValue.arrayRemove(change.companyId)\n return change.companyIds\n}\n\n/**\n * The contact's patch for a plan, in `update()` shape: the holder's facet by\n * DOTTED path — a nested object would replace every other holder's facet —\n * and the mirror at the top of the document.\n */\nexport function contactCompanyLinkFields(\n plan: ContactCompanyLinkPlan,\n groupId: string,\n): Record<string, unknown> {\n const mirror = contactCompanyMirrorValue(plan)\n return {\n [contactFacetPath(groupId, 'companyId')]: plan.companyId ?? FieldValue.delete(),\n ...(mirror !== undefined ? { [CONTACT_COMPANY_IDS_FIELD]: mirror } : {}),\n }\n}\n\n/** The company's patch: its contacts count moved by `delta`. */\nexport function companyContactsCountFields(delta: number): Record<string, unknown> {\n return { [COMPANY_CONTACTS_COUNT_FIELD]: FieldValue.increment(delta) }\n}\n\n/**\n * Move each company's count the plan names, one `update()` each, without\n * failing the caller.\n *\n * Separate writes rather than a batch, for the callers that cannot batch:\n * the upsert has already written the contact by the time it knows the plan\n * held, and a capture door must not fail because a company document was\n * deleted between the link and the count. A count that could not move is\n * logged and left; the company's own page takes the live aggregate, which\n * is what corrects a stale figure.\n */\nexport async function settleCompanyContactsCounts(\n companiesRef: FirebaseFirestore.CollectionReference | null,\n plan: ContactCompanyLinkPlan | null,\n): Promise<void> {\n if (!companiesRef || !plan?.counts.length) return\n await Promise.all(\n plan.counts.map((count) =>\n companiesRef\n .doc(count.companyId)\n .update(companyContactsCountFields(count.delta))\n .catch((error: unknown) => {\n console.error(\n 'contact company count could not move',\n count.companyId,\n count.delta,\n error,\n )\n }),\n ),\n )\n}\n\nexport interface WriteContactCompanyLinkOptions {\n firestore: FirebaseFirestore.Firestore\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * The stored contact, BEFORE this write — what the planner reads the\n * previous link and the mirror off. `null` for a row the caller has just\n * created with no company on it.\n */\n contact: Record<string, unknown> | null | undefined\n companiesRef: FirebaseFirestore.CollectionReference\n groupId: string\n /** The company to link for this holder, or `null` to unlink. */\n companyId: string | null\n}\n\n/**\n * Link a contact to a company FOR ONE HOLDER — or unlink, with `null` — as\n * ONE commit: the facet and the mirror on the contact, and the count on\n * each company the link moves. `null` when the document already said so.\n *\n * A batch rather than two writes because the count is a derived figure of\n * the mirror: a mirror that changed while the count did not is a company\n * list that says \"3\" over a page of four. `updatedAt` moves on the contact\n * — a link is an edit of the person's record — and not on the company,\n * whose list is ordered by its own edits and would otherwise reshuffle on\n * every capture.\n */\nexport async function writeContactCompanyLink(\n options: WriteContactCompanyLinkOptions,\n): Promise<ContactCompanyLinkPlan | null> {\n const { firestore, contactRef, contact, companiesRef, groupId, companyId } =\n options\n const plan = planContactCompanyLink(\n readContactCompanyLink(contact, groupId),\n companyId,\n )\n if (!plan) return null\n const batch = firestore.batch()\n batch.update(contactRef, {\n ...contactCompanyLinkFields(plan, groupId),\n updatedAt: FieldValue.serverTimestamp(),\n })\n for (const count of plan.counts) {\n batch.update(\n companiesRef.doc(count.companyId),\n companyContactsCountFields(count.delta),\n )\n }\n await batch.commit()\n return plan\n}\n"],"names":["COMPANY_CONTACTS_COUNT_FIELD","CONTACT_COMPANY_IDS_FIELD","planContactCompanyLink","readContactCompanyLink","contactFacetPath","FieldValue","contactCompanyMirrorValue","plan","change","mirror","undefined","op","arrayUnion","companyId","arrayRemove","companyIds","contactCompanyLinkFields","groupId","delete","companyContactsCountFields","delta","increment","settleCompanyContactsCounts","companiesRef","counts","length","Promise","all","map","count","doc","update","catch","error","console","writeContactCompanyLink","options","firestore","contactRef","contact","batch","updatedAt","serverTimestamp","commit"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAED,SACEA,4BAA4B,EAC5BC,yBAAyB,EAEzBC,sBAAsB,EACtBC,sBAAsB,QACjB,6BAA4B;AACnC,SAASC,gBAAgB,QAAQ,kCAAiC;AAClE,SAASC,UAAU,QAAQ,2BAA0B;AAErD;;;;CAIC,GACD,OAAO,SAASC,0BACdC,IAA4B;IAE5B,MAAMC,SAASD,KAAKE,MAAM;IAC1B,IAAI,CAACD,QAAQ,OAAOE;IACpB,IAAIF,OAAOG,EAAE,KAAK,SAAS,OAAON,WAAWO,UAAU,CAACJ,OAAOK,SAAS;IACxE,IAAIL,OAAOG,EAAE,KAAK,UAAU,OAAON,WAAWS,WAAW,CAACN,OAAOK,SAAS;IAC1E,OAAOL,OAAOO,UAAU;AAC1B;AAEA;;;;CAIC,GACD,OAAO,SAASC,yBACdT,IAA4B,EAC5BU,OAAe;QAI6BV;IAF5C,MAAME,SAASH,0BAA0BC;IACzC,OAAO;QACL,CAACH,iBAAiBa,SAAS,aAAa,GAAEV,kBAAAA,KAAKM,SAAS,YAAdN,kBAAkBF,WAAWa,MAAM;OACzET,WAAWC,YAAY;QAAE,CAACT,0BAA0B,EAAEQ;IAAO,IAAI,CAAC;AAE1E;AAEA,8DAA8D,GAC9D,OAAO,SAASU,2BAA2BC,KAAa;IACtD,OAAO;QAAE,CAACpB,6BAA6B,EAAEK,WAAWgB,SAAS,CAACD;IAAO;AACvE;AAEA;;;;;;;;;;CAUC,GACD,OAAO,eAAeE,4BACpBC,YAA0D,EAC1DhB,IAAmC;IAEnC,IAAI,CAACgB,gBAAgB,EAAChB,wBAAAA,KAAMiB,MAAM,CAACC,MAAM,GAAE;IAC3C,MAAMC,QAAQC,GAAG,CACfpB,KAAKiB,MAAM,CAACI,GAAG,CAAC,CAACC,QACfN,aACGO,GAAG,CAACD,MAAMhB,SAAS,EACnBkB,MAAM,CAACZ,2BAA2BU,MAAMT,KAAK,GAC7CY,KAAK,CAAC,CAACC;YACNC,QAAQD,KAAK,CACX,wCACAJ,MAAMhB,SAAS,EACfgB,MAAMT,KAAK,EACXa;QAEJ;AAGR;AAiBA;;;;;;;;;;;CAWC,GACD,OAAO,eAAeE,wBACpBC,OAAuC;IAEvC,MAAM,EAAEC,SAAS,EAAEC,UAAU,EAAEC,OAAO,EAAEhB,YAAY,EAAEN,OAAO,EAAEJ,SAAS,EAAE,GACxEuB;IACF,MAAM7B,OAAOL,uBACXC,uBAAuBoC,SAAStB,UAChCJ;IAEF,IAAI,CAACN,MAAM,OAAO;IAClB,MAAMiC,QAAQH,UAAUG,KAAK;IAC7BA,MAAMT,MAAM,CAACO,YAAY,aACpBtB,yBAAyBT,MAAMU;QAClCwB,WAAWpC,WAAWqC,eAAe;;IAEvC,KAAK,MAAMb,SAAStB,KAAKiB,MAAM,CAAE;QAC/BgB,MAAMT,MAAM,CACVR,aAAaO,GAAG,CAACD,MAAMhB,SAAS,GAChCM,2BAA2BU,MAAMT,KAAK;IAE1C;IACA,MAAMoB,MAAMG,MAAM;IAClB,OAAOpC;AACT"}
@@ -0,0 +1,32 @@
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 { EmailDeliveryEventOutcome } from './email-delivery-log';
18
+ /** The facet field the stamp lives under. */
19
+ export declare const CONTACT_EMAIL_ENGAGEMENT_FIELD = "lastEmailEngagementAtMs";
20
+ /**
21
+ * Stamps the sending site's contact facet for every person these outcomes
22
+ * say engaged for the first time with a message.
23
+ *
24
+ * @param hostId the site the campaign went out from — the `hostId` tag the
25
+ * send stamped, which is the only tenant identity a delivery event carries.
26
+ * @returns how many contact documents were written.
27
+ */
28
+ export declare function recordContactEmailEngagement(args: {
29
+ hostId: string;
30
+ outcomes: readonly EmailDeliveryEventOutcome[];
31
+ firestore?: any;
32
+ }): Promise<number>;