@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,1220 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { deploymentRegion } from "@aglyn/aglyn/app-utils/deployment-shape";
18
+ import { createHash } from "crypto";
19
+ // The atomic counter primitive for the AGL-1921 server-error markers below.
20
+ // Commutative server-side, so instances converging on one minute's document
21
+ // neither contend nor retry — see `flushServerErrors`.
22
+ import { FieldValue } from "firebase-admin/firestore";
23
+ import { firebaseAdmin } from "./firebase-admin.js";
24
+ import { checkRateLimit, DEFAULT_RATE_LIMIT, DEFAULT_RATE_WINDOW_MS } from "./api-http.js";
25
+ /**
26
+ * Durable, cross-instance rate limiting (AGL-794).
27
+ *
28
+ * Every limiter in the codebase was a per-instance `Map`, each carrying its
29
+ * own "best-effort, serverless instances are ephemeral" caveat. On Vercel that
30
+ * is close to no limit at all: the counter resets on every cold start and each
31
+ * concurrent instance keeps its own, so the effective cap is roughly
32
+ * `limit × instances` and an attacker can widen it just by going wider. That
33
+ * is tolerable for blunting accidental bursts; it is not a brute-force
34
+ * defense.
35
+ *
36
+ * This backs the same fixed-window shape with a Firestore counter, so the cap
37
+ * is global. `api-http.ts` deliberately stays pure — it has no imports and is
38
+ * unit-tested directly — so the storage-backed variant lives here instead of
39
+ * being bolted onto it.
40
+ *
41
+ * **Cost is the reason this isn't the default everywhere.** Each call is a
42
+ * transaction (one read + one write). That is the right trade for a password
43
+ * unlock attempt; it is the wrong trade for an analytics beacon, which can
44
+ * fire on every page view. Use `checkRateLimit` for volume, this for
45
+ * consequence.
46
+ */ /** Collection holding one document per (key, window). Server-writes only. */ export const RATE_LIMIT_COLLECTION = 'rateLimits';
47
+ /**
48
+ * Document-id prefix for degradation markers (AGL-1679).
49
+ *
50
+ * `degraded: true` used to exist only in a `console.error`, which means a
51
+ * Firestore blip silently dropped every durable limiter — auth, password
52
+ * reset, and now the public REST API's per-key quota — back to a per-instance
53
+ * cap for as long as it lasted, and nobody found out. Fail-soft is only a
54
+ * defensible choice if someone can tell that it fired.
55
+ *
56
+ * This is deliberately not an alerting stack. It is the cheapest thing that
57
+ * makes a degraded window answerable after the fact: when an episode ends,
58
+ * the instance writes one summary document into the SAME collection, so it
59
+ * inherits the deny-all rule and the `expiresAt` TTL policy that already
60
+ * exist rather than needing a new collection, a rules deploy and a second TTL
61
+ * policy. Ids are minute-bucketed so concurrent instances converge on a
62
+ * handful of documents:
63
+ *
64
+ * ```
65
+ * rateLimits/degraded_1755100800000
66
+ * ```
67
+ *
68
+ * Written on RECOVERY, never during the outage — the store is unreachable
69
+ * exactly when the episode is happening, so a marker written then would be
70
+ * the one write guaranteed to fail.
71
+ */ export const DEGRADATION_DOC_PREFIX = 'degraded_';
72
+ /** Marker id granularity, and the log re-notice interval for a long episode. */ const DEGRADATION_BUCKET_MS = 60000;
73
+ /** How long a marker survives the TTL sweep — long enough to look back. */ const DEGRADATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
74
+ /**
75
+ * The episode currently in progress on THIS instance, or null when healthy.
76
+ * Module-scoped on purpose: it is a per-instance observation being reported,
77
+ * and there is nowhere durable to keep it while the durable store is down.
78
+ */ let episode = null;
79
+ /** Short, stable error code for the marker and the log line. */ function failureCode(error) {
80
+ var _ref;
81
+ const code = error == null ? void 0 : error.code;
82
+ if (typeof code === 'string' || typeof code === 'number') return String(code);
83
+ return String((_ref = error == null ? void 0 : error.message) != null ? _ref : 'unknown').slice(0, 120);
84
+ }
85
+ /**
86
+ * Record one fallback. Logs at the start of an episode and at most once a
87
+ * minute after that — the public REST API now calls this path on every
88
+ * request, so a per-call `console.error` during a Firestore outage would be a
89
+ * log flood that buries the signal it is meant to be.
90
+ */ function noteDegradation(nowMs, error) {
91
+ const bucket = Math.floor(nowMs / DEGRADATION_BUCKET_MS);
92
+ if (!episode) {
93
+ episode = {
94
+ count: 1,
95
+ firstAtMs: nowMs,
96
+ lastAtMs: nowMs,
97
+ code: failureCode(error),
98
+ lastLoggedBucket: bucket
99
+ };
100
+ console.error('[rate-limit] degraded: durable store unavailable, falling back to the per-instance cap', {
101
+ code: episode.code,
102
+ atMs: nowMs
103
+ });
104
+ return;
105
+ }
106
+ episode.count += 1;
107
+ episode.lastAtMs = nowMs;
108
+ episode.code = failureCode(error);
109
+ if (bucket > episode.lastLoggedBucket) {
110
+ episode.lastLoggedBucket = bucket;
111
+ console.error('[rate-limit] degraded: still falling back', {
112
+ code: episode.code,
113
+ count: episode.count,
114
+ sinceMs: episode.firstAtMs
115
+ });
116
+ }
117
+ }
118
+ /**
119
+ * An episode ended: close it out and leave a durable record of the window.
120
+ *
121
+ * Fire-and-forget and best-effort — this is a diagnostic, and failing a
122
+ * customer's request because the postmortem breadcrumb could not be written
123
+ * would be a worse bug than the one it documents. The transaction shape
124
+ * matches the counter above so a marker merges cleanly when several instances
125
+ * recover into the same minute.
126
+ */ function flushDegradation(firestore, nowMs) {
127
+ const ended = episode;
128
+ if (!ended) return;
129
+ // Cleared BEFORE the await so a concurrent recovery cannot flush it twice.
130
+ episode = null;
131
+ console.error('[rate-limit] recovered from degraded window', {
132
+ count: ended.count,
133
+ firstAtMs: ended.firstAtMs,
134
+ lastAtMs: ended.lastAtMs,
135
+ code: ended.code
136
+ });
137
+ const bucketStart = Math.floor(ended.firstAtMs / DEGRADATION_BUCKET_MS) * DEGRADATION_BUCKET_MS;
138
+ const ref = firestore.collection(RATE_LIMIT_COLLECTION).doc(`${DEGRADATION_DOC_PREFIX}${bucketStart}`);
139
+ void firestore.runTransaction(async (tx)=>{
140
+ var _ref, _ref1;
141
+ const snapshot = await tx.get(ref);
142
+ const priorCalls = (_ref = snapshot.exists ? snapshot.get('calls') : 0) != null ? _ref : 0;
143
+ const priorEpisodes = (_ref1 = snapshot.exists ? snapshot.get('episodes') : 0) != null ? _ref1 : 0;
144
+ const priorFirst = snapshot.exists ? snapshot.get('firstAtMs') : undefined;
145
+ const priorLast = snapshot.exists ? snapshot.get('lastAtMs') : undefined;
146
+ tx.set(ref, {
147
+ calls: priorCalls + ended.count,
148
+ episodes: priorEpisodes + 1,
149
+ firstAtMs: Math.min(priorFirst != null ? priorFirst : ended.firstAtMs, ended.firstAtMs),
150
+ lastAtMs: Math.max(priorLast != null ? priorLast : ended.lastAtMs, ended.lastAtMs),
151
+ code: ended.code,
152
+ region: deploymentRegion(),
153
+ // Same TTL field the counters use, so the policy already configured
154
+ // on this collection sweeps these too — just far later.
155
+ expiresAt: new Date(nowMs + DEGRADATION_RETENTION_MS)
156
+ }, {
157
+ merge: true
158
+ });
159
+ }).catch(()=>undefined);
160
+ }
161
+ /**
162
+ * The degradation episode in progress on this instance, or `null`. Exposed
163
+ * for tests and for any future health surface; callers must not treat it as a
164
+ * global view — it only ever describes the instance that answers.
165
+ */ export function currentRateLimitDegradation() {
166
+ return episode;
167
+ }
168
+ /** Test seam: forget any in-progress episode. */ export function resetRateLimitDegradationForTests() {
169
+ episode = null;
170
+ }
171
+ /**
172
+ * Document-id prefix for signup-refusal markers (AGL-1907).
173
+ *
174
+ * `/api/orgs/create` has been rate-limited since AGL-1534 (3/h per uid, 10/h
175
+ * per IP) and AGL-1536 watches org-creation VOLUME — but volume only counts
176
+ * the signups that SUCCEEDED. A scripted farm that trips the limiter is
177
+ * therefore invisible in exactly the moment it is being contained: the 429s
178
+ * are the attack's signature and nothing recorded them. Before Sep 1 that is
179
+ * the difference between "the limiter held" and "we have no idea whether it
180
+ * was ever tested".
181
+ *
182
+ * Written into the SAME `rateLimits` collection as the counters and the
183
+ * AGL-1679 degradation markers, for the same reason that one gave: it inherits
184
+ * the deny-all security rule and the `expiresAt` TTL policy that already
185
+ * exist, instead of needing a new collection, a rules deploy and a second TTL
186
+ * policy. Minute-bucketed so concurrent instances converge:
187
+ *
188
+ * ```
189
+ * rateLimits/signupRefused_1755100800000
190
+ * ```
191
+ *
192
+ * **The timestamp field is `refusedAtMs`, deliberately not `lastAtMs`.** The
193
+ * AGL-1693 rate-limiter health probe queries this collection with
194
+ * `where('lastAtMs', '>=', cutoff).orderBy('lastAtMs','desc').limit(N)`. A
195
+ * refusal marker carrying `lastAtMs` would be picked up by that range and,
196
+ * under a flood, could fill the limit and push the real degradation markers
197
+ * out of the result — silently blinding a sibling alarm. A distinct field name
198
+ * keeps the two queries disjoint at the index level rather than relying on the
199
+ * id-prefix filter that runs after the read.
200
+ */ export const SIGNUP_REFUSAL_DOC_PREFIX = 'signupRefused_';
201
+ /** Marker id granularity. Matches the degradation markers. */ const SIGNUP_REFUSAL_BUCKET_MS = 60000;
202
+ /**
203
+ * How long a refusal marker survives the TTL sweep. Shorter than the 30 days
204
+ * degradation markers get: these can be written once a minute under sustained
205
+ * pressure, and the question they answer ("was there a wave, and when") is
206
+ * asked in the days after launch, not the month after.
207
+ */ const SIGNUP_REFUSAL_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
208
+ /**
209
+ * Record one refused org-creation attempt.
210
+ *
211
+ * Fire-and-forget and best-effort, like `flushDegradation`: refusing the
212
+ * request is the control, and failing a 429 because its breadcrumb could not
213
+ * be written would be strictly worse than not having the breadcrumb. Callers
214
+ * must not await this on the response path.
215
+ *
216
+ * **On the write-per-refusal cost.** A refused request is free to the attacker
217
+ * and now costs one transaction — the usual amplification objection. It does
218
+ * not apply here in kind, only in degree: `/api/orgs/create` already spends
219
+ * TWO `consumeRateLimit` transactions on every hit including the refused ones
220
+ * (AGL-1534 counts over-limit attempts by design), so this is a third write on
221
+ * a path that was already three, not a new class of cost. If it ever shows up
222
+ * on the bill the fix is in-process coalescing like `episode` above, not
223
+ * dropping the signal.
224
+ *
225
+ * Nothing identifying is stored. The counts are per-reason only; the uid and
226
+ * IP that were refused stay in the (hashed) limiter keys, which this never
227
+ * reads.
228
+ */ export function recordSignupRefusal(reason, options) {
229
+ var _ref;
230
+ const nowMs = (_ref = options == null ? void 0 : options.now) != null ? _ref : Date.now();
231
+ const bucketStart = Math.floor(nowMs / SIGNUP_REFUSAL_BUCKET_MS) * SIGNUP_REFUSAL_BUCKET_MS;
232
+ let firestore;
233
+ try {
234
+ var _ref1;
235
+ firestore = (_ref1 = options == null ? void 0 : options.firestore) != null ? _ref1 : firebaseAdmin.app().firestore();
236
+ } catch (unused) {
237
+ // No Admin app (a unit test, a misconfigured instance). The refusal still
238
+ // happened and the caller still refuses; only the breadcrumb is lost.
239
+ return;
240
+ }
241
+ const ref = firestore.collection(RATE_LIMIT_COLLECTION).doc(`${SIGNUP_REFUSAL_DOC_PREFIX}${bucketStart}`);
242
+ void firestore.runTransaction(async (tx)=>{
243
+ var _ref, _ref1, _ref2, _ref3;
244
+ const snapshot = await tx.get(ref);
245
+ const priorTotal = (_ref = snapshot.exists ? snapshot.get('refusals') : 0) != null ? _ref : 0;
246
+ const priorByReason = (_ref1 = snapshot.exists ? snapshot.get('byReason') : undefined) != null ? _ref1 : {};
247
+ const priorFirst = snapshot.exists ? snapshot.get('firstRefusedAtMs') : undefined;
248
+ tx.set(ref, {
249
+ refusals: priorTotal + 1,
250
+ byReason: _extends({}, priorByReason, {
251
+ [reason]: ((_ref2 = priorByReason == null ? void 0 : priorByReason[reason]) != null ? _ref2 : 0) + 1
252
+ }),
253
+ firstRefusedAtMs: Math.min(priorFirst != null ? priorFirst : nowMs, nowMs),
254
+ // NOT `lastAtMs` — see the prefix doc above.
255
+ refusedAtMs: Math.max((_ref3 = snapshot.exists ? snapshot.get('refusedAtMs') : 0) != null ? _ref3 : 0, nowMs),
256
+ expiresAt: new Date(nowMs + SIGNUP_REFUSAL_RETENTION_MS)
257
+ }, {
258
+ merge: true
259
+ });
260
+ }).catch(()=>undefined);
261
+ }
262
+ /**
263
+ * Document-id prefix for server-error markers (AGL-1921).
264
+ *
265
+ * ## Why a Firestore marker when the errors already go to Cloud Logging
266
+ *
267
+ * `reportServerError` forwards every `onRequestError` to a `server-errors` log
268
+ * in `aglyn-main`, which is the right sink for triage — Error Reporting groups
269
+ * them and a log-match policy can page on them. It is not a sink anything in
270
+ * this repo can READ. Measured 2026-08-24 against the production credential:
271
+ * `POST logging.googleapis.com/v2/entries:list` for that log answers
272
+ * **403 `Permission denied for all log views`**, because the firebase-admin
273
+ * service account can create log entries and cannot list them. So the only
274
+ * reader the log has is a GCP alert policy that does not exist yet, and
275
+ * creating one is the click, not a thing that ships with a commit.
276
+ *
277
+ * That is the AGL-2486 shape exactly — a detector written into a place nothing
278
+ * watches. This marker is the second copy of the count, in a store we can
279
+ * already read, so `/api/health/server-errors` can grade it and the readers
280
+ * that already exist (the 15-minute GitHub uptime probe, the external keyword
281
+ * monitors, `docs.aglyn.com/status`) become the listeners on day one.
282
+ *
283
+ * Written into the SAME `rateLimits` collection as the counters, the AGL-1679
284
+ * degradation markers and the AGL-1907 refusal markers, for the reason those
285
+ * two gave: it inherits the deny-all security rule and the `expiresAt` TTL
286
+ * policy that already exist, instead of needing a new collection, a rules
287
+ * deploy and a second TTL policy — i.e. instead of needing a console action
288
+ * nobody can take from code. Minute bucketed so concurrent instances
289
+ * converge:
290
+ *
291
+ * ```
292
+ * rateLimits/serverError_1755100800000
293
+ * ```
294
+ *
295
+ * **The timestamp field is `erroredAtMs`**, deliberately neither `lastAtMs`
296
+ * (AGL-1679's) nor `refusedAtMs` (AGL-1907's), for the reason spelled out on
297
+ * `SIGNUP_REFUSAL_DOC_PREFIX`: each health probe range-queries its own field,
298
+ * and a shared field would let one signal's flood fill another's read limit
299
+ * and silently blind it. Three signals, three disjoint indexes.
300
+ */ export const SERVER_ERROR_DOC_PREFIX = 'serverError_';
301
+ /** Marker id granularity. Matches both sibling marker kinds. */ const SERVER_ERROR_BUCKET_MS = 60000;
302
+ /**
303
+ * How long a server-error marker survives the TTL sweep. Seven days, matching
304
+ * the refusal markers rather than the degradation markers' thirty: these can
305
+ * be written every few seconds under a spike, and the question they answer
306
+ * ("was there a spike, when, and on which deployment") is asked in the days
307
+ * after an incident, not the month after.
308
+ */ const SERVER_ERROR_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
309
+ /**
310
+ * Minimum spacing between marker writes from ONE instance (AGL-1921).
311
+ *
312
+ * The failure being watched for is a SPIKE, and a spike is exactly when an
313
+ * unbounded recorder turns one incident into a billing incident — the same
314
+ * argument that gave `reportServerError` its per-minute Logging budget. A
315
+ * write per error would also be a write per error onto a SINGLE document,
316
+ * which is where Firestore's per-document write ceiling lives and where
317
+ * AGL-2404's contention storm came from.
318
+ *
319
+ * So errors coalesce in process and land at most once every five seconds per
320
+ * instance — twelve writes a minute at the very worst, against a threshold
321
+ * measured in single digits over half an hour. The count is never rounded
322
+ * down: what is pending is added to the next flush, and a bucket rollover
323
+ * flushes what it was holding before the new bucket starts.
324
+ *
325
+ * **The first error of a window is written IMMEDIATELY** (`lastFlushAtMs`
326
+ * starts at 0), so a lone error is durable within a second and the alarm
327
+ * never waits on a second one to become visible.
328
+ */ const SERVER_ERROR_FLUSH_INTERVAL_MS = 5000;
329
+ /**
330
+ * The errors this instance is holding, or null when it is holding none.
331
+ * Module-scoped for the same reason `episode` above is: it is a per-instance
332
+ * observation on its way to a durable place.
333
+ */ let serverErrors = null;
334
+ /**
335
+ * Write what the accumulator is holding, and forget it.
336
+ *
337
+ * Fire-and-forget and best-effort, like `flushDegradation`: this runs while a
338
+ * request is already failing, and a monitoring breadcrumb must never be the
339
+ * reason a second thing breaks.
340
+ *
341
+ * `FieldValue.increment` with a merge, rather than the read-modify-write
342
+ * transaction the two sibling markers use: increments are commutative
343
+ * server-side, so several instances converging on one minute's document
344
+ * neither contend nor retry — which is the AGL-2416 lesson applied to a path
345
+ * that only ever runs during an incident.
346
+ */ function flushServerErrors(firestore, nowMs) {
347
+ const held = serverErrors;
348
+ if (!held || held.pending <= 0) return;
349
+ // Zeroed BEFORE the await so a concurrent flush cannot write them twice.
350
+ const errors = held.pending;
351
+ const byService = held.pendingByService;
352
+ const erroredAtMs = held.lastErrorAtMs;
353
+ const bucketStart = held.bucketStart;
354
+ held.pending = 0;
355
+ held.pendingByService = {};
356
+ held.lastFlushAtMs = nowMs;
357
+ const ref = firestore.collection(RATE_LIMIT_COLLECTION).doc(`${SERVER_ERROR_DOC_PREFIX}${bucketStart}`);
358
+ const byServiceIncrements = {};
359
+ for (const [service, count] of Object.entries(byService)){
360
+ byServiceIncrements[service] = FieldValue.increment(count);
361
+ }
362
+ void Promise.resolve(ref.set({
363
+ errors: FieldValue.increment(errors),
364
+ byService: byServiceIncrements,
365
+ // NOT `lastAtMs`, NOT `refusedAtMs` — see the prefix doc above.
366
+ erroredAtMs,
367
+ expiresAt: new Date(nowMs + SERVER_ERROR_RETENTION_MS)
368
+ }, {
369
+ merge: true
370
+ })).catch(()=>undefined);
371
+ }
372
+ /**
373
+ * Record one uncaught server-side error, for `/api/health/server-errors`.
374
+ *
375
+ * Called from `reportServerError`, which is called from each app's
376
+ * `onRequestError` hook — so this counts exactly what that hook can see: an
377
+ * uncaught throw in a render or a route handler. It does NOT see an error that
378
+ * kills the process first, a platform-level 5xx (function timeout, OOM,
379
+ * cold-start 502), or anything thrown in the edge runtime. Those need the
380
+ * Vercel log drain; `docs/UPTIME_AND_SLA.md` carries the list.
381
+ *
382
+ * Fire-and-forget and never throws. Nothing identifying is stored — a count
383
+ * and which deployment produced it. The message, the stack and the route
384
+ * pattern stay in the Logging entry, which is not public; this marker is read
385
+ * by an endpoint that is.
386
+ */ export function recordServerError(service, options) {
387
+ var _ref, _serverErrors_pendingByService_service;
388
+ const nowMs = (_ref = options == null ? void 0 : options.now) != null ? _ref : Date.now();
389
+ const bucketStart = Math.floor(nowMs / SERVER_ERROR_BUCKET_MS) * SERVER_ERROR_BUCKET_MS;
390
+ let firestore;
391
+ try {
392
+ var _ref1;
393
+ firestore = (_ref1 = options == null ? void 0 : options.firestore) != null ? _ref1 : firebaseAdmin.app().firestore();
394
+ } catch (unused) {
395
+ // No Admin app (a unit test, a misconfigured instance). The error still
396
+ // happened and the caller still reports it to Logging; only the count is
397
+ // lost — and a deployment that cannot reach Firestore at all is already
398
+ // red on `/api/health`'s own `firestore` check.
399
+ return;
400
+ }
401
+ // A rollover flushes what the OLD bucket was holding before anything is
402
+ // added to the new one, so a minute's count is never smeared into the next.
403
+ if (serverErrors && serverErrors.bucketStart !== bucketStart) {
404
+ flushServerErrors(firestore, nowMs);
405
+ serverErrors = null;
406
+ }
407
+ if (!serverErrors) {
408
+ serverErrors = {
409
+ bucketStart,
410
+ pending: 0,
411
+ pendingByService: {},
412
+ // 0, so the first error of a bucket is written immediately.
413
+ lastFlushAtMs: 0,
414
+ lastErrorAtMs: nowMs
415
+ };
416
+ }
417
+ serverErrors.pending += 1;
418
+ serverErrors.pendingByService[service] = ((_serverErrors_pendingByService_service = serverErrors.pendingByService[service]) != null ? _serverErrors_pendingByService_service : 0) + 1;
419
+ serverErrors.lastErrorAtMs = Math.max(serverErrors.lastErrorAtMs, nowMs);
420
+ if (nowMs - serverErrors.lastFlushAtMs >= SERVER_ERROR_FLUSH_INTERVAL_MS) {
421
+ flushServerErrors(firestore, nowMs);
422
+ }
423
+ }
424
+ /**
425
+ * Errors this instance is holding but has not written yet. Exposed for tests
426
+ * and for anyone reasoning about the coalescing window; callers must not treat
427
+ * it as a global view — it only ever describes the instance that answers.
428
+ */ export function pendingServerErrors() {
429
+ var _ref;
430
+ return (_ref = serverErrors == null ? void 0 : serverErrors.pending) != null ? _ref : 0;
431
+ }
432
+ /** Test seam: forget anything held. */ export function resetServerErrorsForTests() {
433
+ serverErrors = null;
434
+ }
435
+ /**
436
+ * Document-id prefix for the beacon's last-landed marker (AGL-2713).
437
+ *
438
+ * ## Why this one is not bucketed, and not queried
439
+ *
440
+ * Its four siblings above count OCCURRENCES over a window, so each one wants
441
+ * a stream of minute-bucketed documents and a range query over its own
442
+ * timestamp field. This answers a single question — *when did a heartbeat
443
+ * last reach Cloud Logging from this deployment* — so it is one document per
444
+ * service, read by id:
445
+ *
446
+ * ```
447
+ * rateLimits/beaconHeartbeat_console-web
448
+ * rateLimits/beaconHeartbeat_tenant-web
449
+ * ```
450
+ *
451
+ * A point read needs no index at all, which matters more than the cost: an
452
+ * index this repository cannot deploy without a token is an index that is not
453
+ * there when the failure is. Same collection as the siblings, for the reason
454
+ * they each gave — it inherits the deny-all rule and the `expiresAt` TTL
455
+ * policy that already exist, instead of needing a rules deploy nobody can
456
+ * make from code.
457
+ *
458
+ * **The timestamp field is `heartbeatAtMs`**, deliberately none of `lastAtMs`
459
+ * (AGL-1679), `refusedAtMs` (AGL-1907), `erroredAtMs` (AGL-1921) or
460
+ * `servedAtMs` (AGL-2583). This one is not range-queried, so the isolation
461
+ * argument the others make is not what earns it a field of its own — but a
462
+ * document carrying a sibling's field would enter that sibling's window and be
463
+ * counted as an episode it is not.
464
+ *
465
+ * ⚠️ **Evidence of a LANDING, never of a failure.** Nothing writes here when
466
+ * a heartbeat misses. So the worst a corrupt, stale or unreadable marker can
467
+ * do is make `/api/health/error-beacon` red sooner, and a deployment whose
468
+ * credential has never once worked has no marker at all and reds on its first
469
+ * probe. That asymmetry is what lets the door tolerate a miss without becoming
470
+ * a door that cannot go red.
471
+ */ export const BEACON_HEARTBEAT_DOC_PREFIX = 'beaconHeartbeat_';
472
+ /**
473
+ * How long a last-landed marker survives the TTL sweep.
474
+ *
475
+ * Seven days, matching the refusal and server-error markers. It is refreshed
476
+ * on every successful probe, so the only way it ages out is a deployment that
477
+ * stopped answering entirely — by which time the door has been red for days
478
+ * and the marker's disappearance changes nothing.
479
+ */ const BEACON_HEARTBEAT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
480
+ /** How long the failure path waits for the marker before giving up on it. */ const BEACON_HEARTBEAT_READ_BUDGET_MS = 2000;
481
+ /**
482
+ * Record that a heartbeat reached Cloud Logging (AGL-2713).
483
+ *
484
+ * Fire-and-forget and never throws, like `flushServerErrors`: this runs
485
+ * inside a monitoring probe, and a breadcrumb must never be the reason the
486
+ * probe it is attached to fails. A write that does not land simply means the
487
+ * next miss is graded without it, which is the safe direction.
488
+ *
489
+ * The caller is memoized per instance on a five-minute TTL, so the write rate
490
+ * is one per instance per five minutes however hard the public endpoint is
491
+ * hit — orders of magnitude under the per-document ceiling that produced
492
+ * AGL-2404's contention storm.
493
+ */ export function recordBeaconHeartbeat(service, options) {
494
+ var _ref;
495
+ const nowMs = (_ref = options == null ? void 0 : options.now) != null ? _ref : Date.now();
496
+ let firestore;
497
+ try {
498
+ var _ref1;
499
+ firestore = (_ref1 = options == null ? void 0 : options.firestore) != null ? _ref1 : firebaseAdmin.app().firestore();
500
+ } catch (unused) {
501
+ // No Admin app. The heartbeat still landed and the probe still reports
502
+ // green off the write itself; only the evidence for forgiving a FUTURE
503
+ // miss is lost, and a miss with no evidence reds.
504
+ return;
505
+ }
506
+ void Promise.resolve(firestore.collection(RATE_LIMIT_COLLECTION).doc(`${BEACON_HEARTBEAT_DOC_PREFIX}${service}`).set({
507
+ // NOT `lastAtMs`, NOT `refusedAtMs`, NOT `erroredAtMs`, NOT
508
+ // `servedAtMs` — see the prefix doc above.
509
+ heartbeatAtMs: nowMs,
510
+ service,
511
+ expiresAt: new Date(nowMs + BEACON_HEARTBEAT_RETENTION_MS)
512
+ }, {
513
+ merge: true
514
+ })).catch(()=>undefined);
515
+ }
516
+ /**
517
+ * When a heartbeat last landed for this deployment, or null (AGL-2713).
518
+ *
519
+ * Null covers three cases that must all grade the same way: no marker has
520
+ * ever been written, the document holds nothing usable, and the store could
521
+ * not be read inside its budget. Every one of them means *there is no proof a
522
+ * heartbeat landed recently*, and the caller forgives a miss only on proof.
523
+ * Folding them is therefore not a shortcut — distinguishing them could only
524
+ * ever be used to forgive something unproven.
525
+ *
526
+ * The budget is the reason this is not a bare `get()`. It runs on the failure
527
+ * path, where the credential is already misbehaving, and a Firestore client
528
+ * whose own token will not mint retries with backoff for far longer than a
529
+ * health endpoint may take to answer.
530
+ */ export async function readBeaconHeartbeat(service, options) {
531
+ try {
532
+ var _ref, _ref1;
533
+ const firestore = (_ref = options == null ? void 0 : options.firestore) != null ? _ref : firebaseAdmin.app().firestore();
534
+ const snapshot = await withBudget(firestore.collection(RATE_LIMIT_COLLECTION).doc(`${BEACON_HEARTBEAT_DOC_PREFIX}${service}`).get(), (_ref1 = options == null ? void 0 : options.budgetMs) != null ? _ref1 : BEACON_HEARTBEAT_READ_BUDGET_MS);
535
+ const landedAtMs = snapshot.get('heartbeatAtMs');
536
+ return typeof landedAtMs === 'number' && Number.isFinite(landedAtMs) ? landedAtMs : null;
537
+ } catch (unused) {
538
+ return null;
539
+ }
540
+ }
541
+ /**
542
+ * Document-id prefix for signup-page serve markers (AGL-2583).
543
+ *
544
+ * ## Why traffic is worth storing at all
545
+ *
546
+ * `/api/health/signups` counts orgs created in the trailing hour and goes red
547
+ * when there are TOO MANY. Zero — the reading that means nobody on earth can
548
+ * sign up — is its healthiest possible score, and that is how AGL-2581 refused
549
+ * every account creation for three days with the signup monitor green
550
+ * throughout. The missing half is a DENOMINATOR: zero accounts is an outage
551
+ * only if people were trying, and a quiet night otherwise.
552
+ *
553
+ * These markers are that denominator. Every render of the signup page fetches
554
+ * `/api/lockdown-status?feature=signups` to decide whether to show the paused
555
+ * notice, so that route is the one server touch a real arrival always makes,
556
+ * and counting it needs no new endpoint, no client beacon and no analytics
557
+ * vendor in the alerting path.
558
+ *
559
+ * Written into the SAME `rateLimits` collection as the counters and the three
560
+ * sibling marker kinds, for the reason they each gave: it inherits the
561
+ * deny-all security rule and the `expiresAt` TTL policy that already exist,
562
+ * instead of needing a new collection, a rules deploy and a second TTL policy.
563
+ * Minute bucketed so concurrent instances converge:
564
+ *
565
+ * ```
566
+ * rateLimits/signupServed_1755100800000
567
+ * ```
568
+ *
569
+ * **The timestamp field is `servedAtMs`**, deliberately none of `lastAtMs`
570
+ * (AGL-1679), `refusedAtMs` (AGL-1907) or `erroredAtMs` (AGL-1921), for the
571
+ * reason spelled out on `SIGNUP_REFUSAL_DOC_PREFIX`: each health probe
572
+ * range-queries its own field, and a shared field would let one signal's flood
573
+ * fill another's read limit and silently blind it. Four signals, four disjoint
574
+ * indexes.
575
+ */ export const SIGNUP_SERVED_DOC_PREFIX = 'signupServed_';
576
+ /** Marker id granularity. Matches every sibling marker kind. */ const SIGNUP_SERVED_BUCKET_MS = 60000;
577
+ /**
578
+ * How long a serve marker survives the TTL sweep. Seven days, matching the
579
+ * refusal and server-error markers: the question these answer ("were people
580
+ * arriving while nothing was being created") is asked during an incident and
581
+ * in the days after it, never a month later.
582
+ */ const SIGNUP_SERVED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
583
+ /**
584
+ * Minimum spacing between marker writes from ONE instance.
585
+ *
586
+ * The same argument `SERVER_ERROR_FLUSH_INTERVAL_MS` makes, for the same
587
+ * reason: this counts a PUBLIC page's traffic, so an unbounded recorder turns
588
+ * a marketing campaign into a write-per-visitor on a single hot document —
589
+ * which is where Firestore's per-document ceiling lives and where AGL-2404's
590
+ * contention storm came from. Serves coalesce in process and land at most once
591
+ * every five seconds per instance.
592
+ *
593
+ * **The first serve of a bucket is written IMMEDIATELY** (`lastFlushAtMs`
594
+ * starts at 0), so the denominator is never behind the numerator: an hour that
595
+ * saw traffic says so within a second of the first arrival.
596
+ */ const SIGNUP_SERVED_FLUSH_INTERVAL_MS = 5000;
597
+ /**
598
+ * The serves this instance is holding, or null when it is holding none.
599
+ * Module-scoped for the same reason its siblings are: a per-instance
600
+ * observation on its way to a durable place.
601
+ */ let signupServes = null;
602
+ /**
603
+ * Write what the accumulator is holding, and forget it.
604
+ *
605
+ * Fire-and-forget and best-effort: this runs on the signup page's own
606
+ * critical path, and a monitoring breadcrumb must never be the reason the
607
+ * page that sells the product gets slower or fails.
608
+ *
609
+ * `FieldValue.increment` with a merge, like the server-error marker: server
610
+ * side increments are commutative, so instances converging on one minute's
611
+ * document neither contend nor retry.
612
+ */ function flushSignupServes(firestore, nowMs) {
613
+ const held = signupServes;
614
+ if (!held || held.pending <= 0) return;
615
+ // Zeroed BEFORE the write so a concurrent flush cannot count them twice.
616
+ const serves = held.pending;
617
+ const servedAtMs = held.lastServedAtMs;
618
+ const bucketStart = held.bucketStart;
619
+ held.pending = 0;
620
+ held.lastFlushAtMs = nowMs;
621
+ try {
622
+ const ref = firestore.collection(RATE_LIMIT_COLLECTION).doc(`${SIGNUP_SERVED_DOC_PREFIX}${bucketStart}`);
623
+ void Promise.resolve(ref.set({
624
+ serves: FieldValue.increment(serves),
625
+ // NOT `lastAtMs`, NOT `refusedAtMs`, NOT `erroredAtMs` — see the
626
+ // prefix doc above.
627
+ servedAtMs,
628
+ expiresAt: new Date(nowMs + SIGNUP_SERVED_RETENTION_MS)
629
+ }, {
630
+ merge: true
631
+ })).catch(()=>undefined);
632
+ } catch (unused) {
633
+ // A Firestore handle that throws SYNCHRONOUSLY — a half-initialized admin
634
+ // app, a stub in a test — must not escape onto the signup page's request.
635
+ // The serve is lost from the denominator, which can only make the drought
636
+ // alarm quieter, never louder.
637
+ }
638
+ }
639
+ /**
640
+ * Record one serving of the signup page, for `/api/health/signup-volume`.
641
+ *
642
+ * Nothing identifying is stored — a count and a timestamp. No IP, no user
643
+ * agent, no referrer, no campaign: this marker is read by an endpoint that is
644
+ * public, and the drought verdict needs a number, not an audience.
645
+ *
646
+ * Never throws, and callers must not await it.
647
+ */ export function recordSignupServed(options) {
648
+ var _ref;
649
+ const nowMs = (_ref = options == null ? void 0 : options.now) != null ? _ref : Date.now();
650
+ const bucketStart = Math.floor(nowMs / SIGNUP_SERVED_BUCKET_MS) * SIGNUP_SERVED_BUCKET_MS;
651
+ let firestore;
652
+ try {
653
+ var _ref1;
654
+ firestore = (_ref1 = options == null ? void 0 : options.firestore) != null ? _ref1 : firebaseAdmin.app().firestore();
655
+ } catch (unused) {
656
+ // No Admin app (a unit test, a misconfigured instance). The page still
657
+ // serves; only the denominator loses a tick, and a deployment that cannot
658
+ // reach Firestore at all is already red on `/api/health`.
659
+ return;
660
+ }
661
+ // A rollover flushes what the OLD bucket was holding before anything is
662
+ // added to the new one, so a minute's count is never smeared into the next.
663
+ if (signupServes && signupServes.bucketStart !== bucketStart) {
664
+ flushSignupServes(firestore, nowMs);
665
+ signupServes = null;
666
+ }
667
+ if (!signupServes) {
668
+ signupServes = {
669
+ bucketStart,
670
+ pending: 0,
671
+ // 0, so the first serve of a bucket is written immediately.
672
+ lastFlushAtMs: 0,
673
+ lastServedAtMs: nowMs
674
+ };
675
+ }
676
+ signupServes.pending += 1;
677
+ signupServes.lastServedAtMs = Math.max(signupServes.lastServedAtMs, nowMs);
678
+ if (nowMs - signupServes.lastFlushAtMs >= SIGNUP_SERVED_FLUSH_INTERVAL_MS) {
679
+ flushSignupServes(firestore, nowMs);
680
+ }
681
+ }
682
+ /**
683
+ * Serves this instance is holding but has not written yet. Exposed for tests
684
+ * and for anyone reasoning about the coalescing window; it only ever describes
685
+ * the instance that answers.
686
+ */ export function pendingSignupServes() {
687
+ var _ref;
688
+ return (_ref = signupServes == null ? void 0 : signupServes.pending) != null ? _ref : 0;
689
+ }
690
+ /** Test seam: forget anything held. */ export function resetSignupServesForTests() {
691
+ signupServes = null;
692
+ }
693
+ /**
694
+ * Document-id prefix for org-creation ATTEMPT markers (AGL-2714).
695
+ *
696
+ * ## Why serves were the wrong denominator
697
+ *
698
+ * The drought verdict beside this one asks "did traffic arrive and no account
699
+ * come out", and it took its denominator from `signupServed_` — a hit on
700
+ * `/api/lockdown-status?feature=signups`, which the signup page fetches on
701
+ * every render. That counts LOOKERS. Crawlers, link previews, a person
702
+ * reading the plan names and closing the tab, and the diagnostic page load
703
+ * somebody makes while investigating the alert itself all land in it.
704
+ *
705
+ * At this platform's conversion rate an hour with a handful of arrivals and
706
+ * no account is the ordinary case, so the check could not tell a door that
707
+ * does not open from a quiet afternoon — and it paged as though the service
708
+ * were down. No threshold fixes that; the quantity is wrong.
709
+ *
710
+ * An ATTEMPT is somebody who authenticated, filled the form and asked for an
711
+ * org. If those arrive and no org appears, something is broken, and the
712
+ * question has an answer that does not depend on how many people were merely
713
+ * browsing.
714
+ *
715
+ * ## Why this is stronger than the refusal marker beside it
716
+ *
717
+ * `signupRefusal_` counts requests this route TURNED AWAY, which is a
718
+ * deliberate outcome the code chose. An attempt marker is written before any
719
+ * outcome is known, so the pair spans what refusals cannot see on their own:
720
+ * a request that 500s, hangs, or dies in the platform between the limiter and
721
+ * the write leaves an attempt and no org, and no refusal at all.
722
+ *
723
+ * **The field is `attemptedAtMs`**, deliberately none of `lastAtMs`
724
+ * (AGL-1679), `refusedAtMs` (AGL-1907), `erroredAtMs` (AGL-1921) or
725
+ * `servedAtMs` (AGL-2583) — same rule the four before it follow: each health
726
+ * probe range-queries its own field, and a shared field would let one
727
+ * signal's flood fill another's read limit and silently blind it.
728
+ */ export const SIGNUP_ATTEMPT_DOC_PREFIX = 'signupAttempted_';
729
+ /** Marker id granularity. Matches every sibling marker kind. */ const SIGNUP_ATTEMPT_BUCKET_MS = 60000;
730
+ /**
731
+ * How long an attempt marker survives the TTL sweep. Seven days, matching the
732
+ * serve and refusal markers it is read beside — the same incident asks all
733
+ * three, and a denominator that outlived its numerator would answer nothing.
734
+ */ const SIGNUP_ATTEMPT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
735
+ /**
736
+ * Record one attempt to create an org, for `/api/health/signup-volume`.
737
+ *
738
+ * Written per attempt with no in-process coalescing, unlike the serve marker.
739
+ * The serve marker guards a PUBLIC page whose traffic is unbounded; this one
740
+ * sits behind an authenticated route the limiter already caps at 3 per uid and
741
+ * 10 per IP an hour, so the write rate is bounded by the limiter rather than
742
+ * by the internet. It is also the third write on a path that already spends
743
+ * two `consumeRateLimit` transactions per hit, which is the same accounting
744
+ * `recordSignupRefusal` sets out.
745
+ *
746
+ * `FieldValue.increment` with a merge rather than a transaction: increments
747
+ * are commutative server-side, so instances converging on one minute's
748
+ * document neither contend nor retry.
749
+ *
750
+ * Nothing identifying is stored — a count and a timestamp. This marker is read
751
+ * by an endpoint that is public, and the drought verdict needs a number, not
752
+ * an audience.
753
+ *
754
+ * Never throws, and callers must not await it: creating the org is the
755
+ * control, and a monitoring breadcrumb must never be the reason a signup gets
756
+ * slower or fails.
757
+ */ export function recordSignupAttempt(options) {
758
+ var _ref;
759
+ const nowMs = (_ref = options == null ? void 0 : options.now) != null ? _ref : Date.now();
760
+ const bucketStart = Math.floor(nowMs / SIGNUP_ATTEMPT_BUCKET_MS) * SIGNUP_ATTEMPT_BUCKET_MS;
761
+ let firestore;
762
+ try {
763
+ var _ref1;
764
+ firestore = (_ref1 = options == null ? void 0 : options.firestore) != null ? _ref1 : firebaseAdmin.app().firestore();
765
+ } catch (unused) {
766
+ // No Admin app (a unit test, a misconfigured instance). The signup still
767
+ // proceeds; only the denominator loses a tick. Losing an attempt can only
768
+ // make the drought alarm quieter, never louder — the opposite bias to the
769
+ // one that would matter here.
770
+ return;
771
+ }
772
+ try {
773
+ const ref = firestore.collection(RATE_LIMIT_COLLECTION).doc(`${SIGNUP_ATTEMPT_DOC_PREFIX}${bucketStart}`);
774
+ void Promise.resolve(ref.set({
775
+ attempts: FieldValue.increment(1),
776
+ // NOT `lastAtMs`, NOT `refusedAtMs`, NOT `erroredAtMs`, NOT
777
+ // `servedAtMs` — see the prefix doc above.
778
+ attemptedAtMs: nowMs,
779
+ expiresAt: new Date(nowMs + SIGNUP_ATTEMPT_RETENTION_MS)
780
+ }, {
781
+ merge: true
782
+ })).catch(()=>undefined);
783
+ } catch (unused) {
784
+ // A Firestore handle that throws SYNCHRONOUSLY — a half-initialized admin
785
+ // app, a stub in a test — must not escape onto the signup request.
786
+ }
787
+ }
788
+ /**
789
+ * Wall-clock budget for one durable counter round trip (AGL-2404).
790
+ *
791
+ * Without a bound this function could not fail at all — it could only hang.
792
+ * The counter used to be a read-modify-write `runTransaction` on a single hot
793
+ * document: it contended, retried with backoff, and on a contended key
794
+ * routinely outran the platform's function ceiling, so the request died as a
795
+ * **504 with no body and no `Retry-After`** instead of the cheap 429 the
796
+ * limiter exists to produce. Measured against production on 2026-08-19 at
797
+ * `/api/protection/unlock`: sequential traffic refused cleanly at the 10th
798
+ * attempt, while **two** concurrent requests on one key were already enough
799
+ * to produce a 504 at ~10.3 s — the account-default function ceiling, since
800
+ * that route declares no `maxDuration`.
801
+ *
802
+ * AGL-2416 removed the read-modify-write (see {@link consumeRateLimit}), so
803
+ * the retry storm this bound was catching should no longer occur. The bound
804
+ * STAYS: it is what makes the function answerable at all, and a store that is
805
+ * slow for any other reason — a hot document at Firestore's own single-doc
806
+ * write ceiling, a network stall — must still produce a decision rather than
807
+ * a held function.
808
+ *
809
+ * 2.5 s is chosen to sit far below the smallest ceiling any caller runs at
810
+ * (10 s at the Vercel account default) while leaving room for the two round
811
+ * trips below, which measured ~0.6 s end to end on the same endpoint.
812
+ */ export const RATE_LIMIT_TRANSACTION_BUDGET_MS = 2500;
813
+ /**
814
+ * Thrown when the durable counter could not reach a decision inside
815
+ * {@link RATE_LIMIT_TRANSACTION_BUDGET_MS}. Distinct from a store *error* on
816
+ * purpose — see the classification note on {@link consumeRateLimit}.
817
+ */ export class RateLimitContentionError extends Error {
818
+ constructor(message = 'rate-limit counter contended'){
819
+ super(message);
820
+ this.name = 'RateLimitContentionError';
821
+ }
822
+ }
823
+ /**
824
+ * gRPC status codes that mean "the store is up, this document is hot":
825
+ * `ABORTED` (10) is what Firestore returns when a transaction loses its
826
+ * optimistic race too many times, and `DEADLINE_EXCEEDED` (4) is the same
827
+ * condition observed as a timeout. Neither is an outage, so neither may take
828
+ * the fail-soft path — degrading on contention would let a caller widen its
829
+ * own cap just by going concurrent, which is the exact defect AGL-794 closed.
830
+ */ const CONTENTION_CODES = new Set([
831
+ 4,
832
+ 10
833
+ ]);
834
+ function isContention(error) {
835
+ if (error instanceof RateLimitContentionError) return true;
836
+ const code = error == null ? void 0 : error.code;
837
+ return typeof code === 'number' && CONTENTION_CODES.has(code);
838
+ }
839
+ /**
840
+ * Resolve `work`, or reject with {@link RateLimitContentionError} once
841
+ * `budgetMs` elapses.
842
+ *
843
+ * The losing promise is explicitly silenced: an abandoned transaction that
844
+ * rejects later would otherwise surface as an unhandled rejection and, on a
845
+ * strict runtime, take the instance down — turning a contention fix into an
846
+ * availability bug.
847
+ */ async function withBudget(work, budgetMs) {
848
+ let timer;
849
+ work.catch(()=>undefined);
850
+ try {
851
+ return await Promise.race([
852
+ work,
853
+ new Promise((_, reject)=>{
854
+ timer = setTimeout(()=>reject(new RateLimitContentionError()), budgetMs);
855
+ })
856
+ ]);
857
+ } finally{
858
+ if (timer) clearTimeout(timer);
859
+ }
860
+ }
861
+ /**
862
+ * Add one to `ref`'s counter and report the total (AGL-2416).
863
+ *
864
+ * Two round trips, in this order and no other. The write MUST land before the
865
+ * read: reading first and writing second is a read-modify-write again, only
866
+ * without the transaction that made it safe, which would under-count under
867
+ * concurrency and let a caller widen its own cap by racing itself.
868
+ *
869
+ * `merge: true` creates the document on the first request of a window —
870
+ * `increment` on a missing field starts from zero — so no separate create is
871
+ * needed, and `windowStartMs`/`expiresAt` are re-stamped idempotently on every
872
+ * write rather than only on the first.
873
+ *
874
+ * A read-back that carries no usable count means the store accepted a write
875
+ * and then failed to show it. That is a malfunction, not contention, so it
876
+ * throws a plain error and takes the fail-SOFT path — the same posture as any
877
+ * other store failure. Returning `0` instead would read as "no requests yet"
878
+ * and admit every caller: fail-open, from the one branch that exists because
879
+ * the store is misbehaving.
880
+ */ async function countOne(ref, windowStartMs, resetMs, windowMs) {
881
+ var _snapshot_get;
882
+ await ref.set({
883
+ count: firebaseAdmin.firestore.FieldValue.increment(1),
884
+ windowStartMs,
885
+ // For a Firestore TTL policy on `expiresAt` — without one these
886
+ // documents accumulate forever. See docs/RATE_LIMITING.md.
887
+ expiresAt: new Date(resetMs + windowMs)
888
+ }, {
889
+ merge: true
890
+ });
891
+ const snapshot = await ref.get();
892
+ const observed = Number(snapshot == null ? void 0 : (_snapshot_get = snapshot.get) == null ? void 0 : _snapshot_get.call(snapshot, 'count'));
893
+ if (!Number.isFinite(observed) || observed < 1) {
894
+ throw new Error('rate-limit counter read-back returned no count');
895
+ }
896
+ return observed;
897
+ }
898
+ /**
899
+ * Document id for a (key, window) pair.
900
+ *
901
+ * The key is hashed rather than embedded: callers key on client IPs, and an
902
+ * IP is personal data that would otherwise sit in plaintext document ids
903
+ * (which also show up in any index export). Hashing additionally makes the id
904
+ * safe — Firestore ids may not contain `/`, which IPv6-mapped and
905
+ * path-derived keys can. Truncated to 32 hex chars: collisions would merely
906
+ * merge two callers into one bucket, and 128 bits is far past needing that.
907
+ */ function bucketId(key, windowStartMs) {
908
+ const hash = createHash('sha256').update(key).digest('hex').slice(0, 32);
909
+ return `${hash}_${windowStartMs}`;
910
+ }
911
+ /**
912
+ * Counts one request against `key`'s fixed window, globally.
913
+ *
914
+ * Fails *soft, not open*: if Firestore is unreachable the in-memory limiter
915
+ * answers and the result is flagged `degraded`. Failing fully open would let
916
+ * an attacker disable brute-force protection by inducing a storage error;
917
+ * failing fully closed would lock legitimate visitors out of a site because of
918
+ * an unrelated Firestore blip. Degrading to the per-instance cap keeps some
919
+ * protection and keeps the site usable, and says which happened.
920
+ *
921
+ * ## Two failures, two postures (AGL-2404)
922
+ *
923
+ * "Firestore is unreachable" and "this one document is contended" are not the
924
+ * same event and must not get the same answer:
925
+ *
926
+ * - **The store is down** (fast error — `UNAVAILABLE`, a missing app, a
927
+ * credential failure). Fail SOFT, exactly as before: degrade to the
928
+ * per-instance cap and flag `degraded`. A real visitor must not lose access
929
+ * to a customer's site over an unrelated blip.
930
+ * - **The key is contended** (`ABORTED`, `DEADLINE_EXCEEDED`, or the budget
931
+ * above elapsing). Fail CLOSED: refuse with `contended: true` and let the
932
+ * caller answer 429 with its usual `Retry-After`. Degrading here would be a
933
+ * partial bypass — concurrent requests on ONE key are cheap to generate,
934
+ * and if they dropped the cap to a per-instance count then going wide would
935
+ * widen the cap, which is precisely the property AGL-794 removed.
936
+ *
937
+ * Refusing on contention is never worse than the 504 it replaces. A 504 is
938
+ * also a failed request, but it costs a full function timeout, carries no
939
+ * `Retry-After` for a well-behaved client to back off on, and invites an
940
+ * immediate retry that deepens the contention. Contention on a single key
941
+ * also means, at nearly every call site, either abuse or a client
942
+ * double-submitting: the keys are per (host, screen, IP), per uid, or per API
943
+ * key, so a legitimate visitor essentially never races themselves.
944
+ *
945
+ * ## The counter is an atomic increment, not a transaction (AGL-2416)
946
+ *
947
+ * It used to be `runTransaction(read count, write count + 1)`. That is
948
+ * optimistic concurrency on ONE document, so two writers in flight at once
949
+ * already race: measured in production on 2026-08-19, **two** concurrent
950
+ * requests on a single fresh key were enough to make a transaction lose,
951
+ * retry, and blow past its budget. Two is ordinary client behaviour — a
952
+ * double-submit, a mobile retry, two visitors behind one NAT — and the
953
+ * documented 120/min per API key is not even reachable without concurrency,
954
+ * so an integration built to the published budget met this by design.
955
+ *
956
+ * `set({count: increment(1)}, {merge: true})` is applied by the server with
957
+ * no read set to conflict over, so concurrent writers do not abort each
958
+ * other. The decision then needs the value, which costs a read back — the
959
+ * same two round trips a transaction spent, and the same billing: one
960
+ * document read plus one document write.
961
+ *
962
+ * **The trade is exactness for headroom, and it is deliberately biased.** The
963
+ * read-back can observe increments from writers that landed after ours, so
964
+ * under `C` concurrent requests a caller may see a count up to `C - 1` higher
965
+ * than its own position. The consequences, precisely:
966
+ *
967
+ * - It can only ever refuse EARLY, never late. The count is monotonic within
968
+ * a window and always includes this request's own increment, so an admitted
969
+ * request is one where at most `limit` increments had landed. The cap can
970
+ * never be exceeded, and going concurrent can never widen it.
971
+ * - "Exactly `limit` admitted" becomes "**at most** `limit` admitted". At the
972
+ * window's edge, `C` simultaneous requests can all read the same post-burst
973
+ * total and all be refused, so as few as `limit - C + 1` are admitted where
974
+ * `limit` would have been. Bounded by the concurrency, confined to one
975
+ * window, and in the customer-favourable direction for a *security* limiter
976
+ * — which is what all nine of this store's call sites are.
977
+ * - A fixed-window limiter already admits up to `2 × limit` across a window
978
+ * boundary, so exactness was never a property this shape had end to end.
979
+ * - **A call abandoned on its budget may already have counted.** The write
980
+ * lands before the read-back, so a caller that gives up during the read-back
981
+ * still spent its increment. The transaction it replaced committed nothing
982
+ * when it timed out — which is exactly how AGL-2416 could observe "10
983
+ * admitted" after a burst of 504s — so this is a genuine change, not a
984
+ * restatement. It errs in the same safe direction: the counter over-counts,
985
+ * so the cap still cannot be exceeded, and a caller can never buy budget by
986
+ * timing out.
987
+ *
988
+ * The failure classification above is unchanged and still load-bearing: the
989
+ * budget can still elapse on a genuinely slow store, and when it does the
990
+ * answer is still a refusal rather than a degradation.
991
+ */ export async function consumeRateLimit(key, options) {
992
+ var _ref, _ref1, _ref2;
993
+ const limit = (_ref = options == null ? void 0 : options.limit) != null ? _ref : DEFAULT_RATE_LIMIT;
994
+ const windowMs = (_ref1 = options == null ? void 0 : options.windowMs) != null ? _ref1 : DEFAULT_RATE_WINDOW_MS;
995
+ const now = (_ref2 = options == null ? void 0 : options.now) != null ? _ref2 : Date.now();
996
+ const windowStartMs = Math.floor(now / windowMs) * windowMs;
997
+ const resetMs = windowStartMs + windowMs;
998
+ try {
999
+ var _ref3, _ref4;
1000
+ const firestore = (_ref3 = options == null ? void 0 : options.firestore) != null ? _ref3 : firebaseAdmin.app().firestore();
1001
+ const ref = firestore.collection(RATE_LIMIT_COLLECTION).doc(bucketId(key, windowStartMs));
1002
+ // Explicit type argument: `firestore` is `any`, so the helper's promise
1003
+ // infers as `any` and would widen the counter to `unknown`.
1004
+ const count = await withBudget(countOne(ref, windowStartMs, resetMs, windowMs), (_ref4 = options == null ? void 0 : options.budgetMs) != null ? _ref4 : RATE_LIMIT_TRANSACTION_BUDGET_MS);
1005
+ // The store answered, so any episode this instance was in is over. The
1006
+ // marker is written here rather than in the `catch` because the store is
1007
+ // unreachable exactly while the episode is happening (AGL-1679).
1008
+ if (episode) flushDegradation(firestore, now);
1009
+ return {
1010
+ allowed: count <= limit,
1011
+ limit,
1012
+ remaining: Math.max(0, limit - count),
1013
+ resetMs,
1014
+ degraded: false,
1015
+ contended: false
1016
+ };
1017
+ } catch (error) {
1018
+ if (isContention(error)) {
1019
+ // NOT `noteDegradation`: nothing degraded. The durable counter is still
1020
+ // the only authority and it still holds — this caller simply did not get
1021
+ // a turn. Recording it as a degradation would put contention markers in
1022
+ // front of `/api/health/rate-limits`, whose whole question is "did the
1023
+ // durable store stop answering", and answer it wrongly.
1024
+ //
1025
+ // `resetMs` stays the true end of the window so `X-RateLimit-Reset`
1026
+ // remains honest, which also makes the caller's `Retry-After` a bounded
1027
+ // back-off to the window boundary — the one client behaviour that
1028
+ // actually relieves the contention.
1029
+ return {
1030
+ allowed: false,
1031
+ limit,
1032
+ remaining: 0,
1033
+ resetMs,
1034
+ degraded: false,
1035
+ contended: true
1036
+ };
1037
+ }
1038
+ noteDegradation(now, error);
1039
+ return _extends({}, checkRateLimit(key, {
1040
+ limit,
1041
+ windowMs,
1042
+ now
1043
+ }), {
1044
+ degraded: true,
1045
+ contended: false
1046
+ });
1047
+ }
1048
+ }
1049
+ export default consumeRateLimit;
1050
+ /**
1051
+ * Document id for the signup canary's verdict (AGL-2715).
1052
+ *
1053
+ * One document, not a bucketed series: this records the LAST walk, and a
1054
+ * history of walks answers a question nobody asks during an incident. The
1055
+ * walk that matters is the most recent one.
1056
+ *
1057
+ * Written by the scheduled canary, read by `/api/health/journeys`. The split
1058
+ * is the same one the beacon heartbeat makes and for a stronger reason: the
1059
+ * canary CREATES AN ACCOUNT AND AN ORG, and the door that reports it is
1060
+ * public and unauthenticated. A probe that walked the signup itself would
1061
+ * hand anyone with `curl` an org factory.
1062
+ *
1063
+ * ⚠️ NOT in the `rateLimits` TTL's usual seven-day shape. There is exactly one
1064
+ * of these and it is overwritten on every run, so it costs one document
1065
+ * forever; `expiresAt` is still stamped, far enough out that a canary which
1066
+ * stopped running expires its own evidence rather than leaving a stale pass
1067
+ * lying around to be misread. The health verdict's own staleness window is
1068
+ * two hours, so expiry is a backstop, not the mechanism.
1069
+ */ export const SIGNUP_CANARY_DOC_ID = 'signupCanary_production';
1070
+ /** How long a recorded walk survives the sweep. */ const SIGNUP_CANARY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
1071
+ /** Budget for the single-document read on the health path. */ const SIGNUP_CANARY_READ_BUDGET_MS = 2000;
1072
+ /**
1073
+ * Record the outcome of one signup walk.
1074
+ *
1075
+ * AWAITED by its caller, unlike every other recorder in this file. Those are
1076
+ * breadcrumbs on a request path where the user's work is the control and the
1077
+ * marker must never delay it. This one IS the work: a canary whose verdict
1078
+ * did not land has not reported, and the door it feeds would rather red on a
1079
+ * missing marker than believe a walk nobody wrote down.
1080
+ *
1081
+ * `set` without merge, deliberately: this document is a snapshot of the last
1082
+ * walk, and merging would let a field from a previous run survive into a
1083
+ * verdict it was not part of — a `failedStep` outliving the failure that
1084
+ * produced it, read beside a fresh `ok: true`.
1085
+ */ export async function recordSignupCanaryWalk(verdict, options) {
1086
+ var _ref, _ref1, _verdict_failedStep;
1087
+ const nowMs = (_ref = options == null ? void 0 : options.now) != null ? _ref : Date.now();
1088
+ const firestore = (_ref1 = options == null ? void 0 : options.firestore) != null ? _ref1 : firebaseAdmin.app().firestore();
1089
+ await firestore.collection(RATE_LIMIT_COLLECTION).doc(SIGNUP_CANARY_DOC_ID).set({
1090
+ walkedAtMs: nowMs,
1091
+ ok: verdict.ok === true,
1092
+ failedStep: (_verdict_failedStep = verdict.failedStep) != null ? _verdict_failedStep : null,
1093
+ elapsedMs: Number(verdict.elapsedMs) || 0,
1094
+ reapedCleanly: verdict.reapedCleanly === true,
1095
+ expiresAt: new Date(nowMs + SIGNUP_CANARY_RETENTION_MS)
1096
+ });
1097
+ }
1098
+ /**
1099
+ * The last recorded walk, or null (AGL-2715).
1100
+ *
1101
+ * Null covers the same three cases `readBeaconHeartbeat` folds, for the same
1102
+ * reason: no marker has ever been written, the document holds nothing usable,
1103
+ * and the store would not answer inside its budget. Each one means *nothing
1104
+ * here has demonstrated that a stranger can sign up*, and the verdict grades
1105
+ * all three as `canary-unavailable`. Distinguishing them could only ever be
1106
+ * used to forgive something unproven.
1107
+ */ export async function readSignupCanaryWalk(options) {
1108
+ try {
1109
+ var _ref, _ref1, _snapshot_get;
1110
+ const firestore = (_ref = options == null ? void 0 : options.firestore) != null ? _ref : firebaseAdmin.app().firestore();
1111
+ const snapshot = await withBudget(firestore.collection(RATE_LIMIT_COLLECTION).doc(SIGNUP_CANARY_DOC_ID).get(), (_ref1 = options == null ? void 0 : options.budgetMs) != null ? _ref1 : SIGNUP_CANARY_READ_BUDGET_MS);
1112
+ if (!(snapshot == null ? void 0 : snapshot.exists)) return null;
1113
+ const walkedAtMs = snapshot.get('walkedAtMs');
1114
+ if (typeof walkedAtMs !== 'number' || !Number.isFinite(walkedAtMs)) return null;
1115
+ return {
1116
+ walkedAtMs,
1117
+ ok: snapshot.get('ok') === true,
1118
+ failedStep: (_snapshot_get = snapshot.get('failedStep')) != null ? _snapshot_get : null,
1119
+ elapsedMs: snapshot.get('elapsedMs'),
1120
+ reapedCleanly: snapshot.get('reapedCleanly')
1121
+ };
1122
+ } catch (unused) {
1123
+ return null;
1124
+ }
1125
+ }
1126
+ /**
1127
+ * Document id for the App Check attestation sample (AGL-2715).
1128
+ *
1129
+ * The rate is published by Google to Cloud Monitoring, which a public health
1130
+ * endpoint has no business calling: it needs a second credential, it is slow,
1131
+ * and its quota is not something a route anyone can curl should be able to
1132
+ * spend. So a scheduled job samples it and this holds the answer, exactly as
1133
+ * the signup canary's own verdict is held.
1134
+ *
1135
+ * One document, overwritten each run — the last reading is the only one that
1136
+ * answers "is attestation working now".
1137
+ */ /**
1138
+ * Where the metered-traffic sampler leaves its reading (AGL-2720).
1139
+ *
1140
+ * One document, like every sibling here. The sampler owns the judgement about
1141
+ * whether the total moved; this file only carries it.
1142
+ */ export const EDGE_ADMISSION_DOC_ID = 'edgeAdmission_production';
1143
+ /** Budget for the single-document read on the health path. */ const EDGE_ADMISSION_READ_BUDGET_MS = 2000;
1144
+ /**
1145
+ * Read the last metered-traffic sample.
1146
+ *
1147
+ * Deliberately has NO max-age rejection, which is the one place this differs
1148
+ * from {@link readAppCheckAttestation}. The quantity graded is already an age
1149
+ * — how long since traffic moved — so discarding an old marker would turn the
1150
+ * loudest possible reading into `edge-admission-unavailable` and lose which
1151
+ * of the two things broke.
1152
+ */ export async function readEdgeAdmission(options) {
1153
+ try {
1154
+ var _ref, _ref1;
1155
+ const firestore = (_ref = options == null ? void 0 : options.firestore) != null ? _ref : firebaseAdmin.app().firestore();
1156
+ const snapshot = await withBudget(firestore.collection(RATE_LIMIT_COLLECTION).doc(EDGE_ADMISSION_DOC_ID).get(), (_ref1 = options == null ? void 0 : options.budgetMs) != null ? _ref1 : EDGE_ADMISSION_READ_BUDGET_MS);
1157
+ if (!(snapshot == null ? void 0 : snapshot.exists)) return null;
1158
+ const advancedAtMs = snapshot.get('advancedAtMs');
1159
+ if (typeof advancedAtMs !== 'number') return null;
1160
+ return {
1161
+ sampledAtMs: snapshot.get('sampledAtMs'),
1162
+ advancedAtMs,
1163
+ day: snapshot.get('day'),
1164
+ total: snapshot.get('total')
1165
+ };
1166
+ } catch (unused) {
1167
+ return null;
1168
+ }
1169
+ }
1170
+ export const APP_CHECK_ATTESTATION_DOC_ID = 'appCheckAttestation_production';
1171
+ /** How long a sample survives the sweep. */ const APP_CHECK_ATTESTATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
1172
+ /** Budget for the single-document read on the health path. */ const APP_CHECK_ATTESTATION_READ_BUDGET_MS = 2000;
1173
+ /**
1174
+ * Record one sampling of the App Check verification counts.
1175
+ *
1176
+ * Awaited by its caller, like the canary's verdict and unlike the breadcrumbs
1177
+ * in this file: the sample IS the work, and a door would rather report a
1178
+ * missing reading than believe one nobody wrote down.
1179
+ */ export async function recordAppCheckAttestation(samples, options) {
1180
+ var _ref, _ref1;
1181
+ const nowMs = (_ref = options == null ? void 0 : options.now) != null ? _ref : Date.now();
1182
+ const firestore = (_ref1 = options == null ? void 0 : options.firestore) != null ? _ref1 : firebaseAdmin.app().firestore();
1183
+ await firestore.collection(RATE_LIMIT_COLLECTION).doc(APP_CHECK_ATTESTATION_DOC_ID).set({
1184
+ sampledAtMs: nowMs,
1185
+ samples,
1186
+ expiresAt: new Date(nowMs + APP_CHECK_ATTESTATION_RETENTION_MS)
1187
+ });
1188
+ }
1189
+ /**
1190
+ * The last attestation sample, or null (AGL-2715).
1191
+ *
1192
+ * Null folds the three cases its siblings fold — never written, unusable, and
1193
+ * the store would not answer — because each means *nothing here has shown that
1194
+ * attestation is working*, and the verdict grades all three as
1195
+ * `attestation-unavailable`.
1196
+ *
1197
+ * A sample older than the window it describes is also null. A stale reading is
1198
+ * not a reading: reporting a six-hour-old rate as current is how a check comes
1199
+ * to say attestation is fine long after it stopped being.
1200
+ */ export async function readAppCheckAttestation(options) {
1201
+ try {
1202
+ var _ref, _ref1, _ref2, _ref3;
1203
+ const firestore = (_ref = options == null ? void 0 : options.firestore) != null ? _ref : firebaseAdmin.app().firestore();
1204
+ const snapshot = await withBudget(firestore.collection(RATE_LIMIT_COLLECTION).doc(APP_CHECK_ATTESTATION_DOC_ID).get(), (_ref1 = options == null ? void 0 : options.budgetMs) != null ? _ref1 : APP_CHECK_ATTESTATION_READ_BUDGET_MS);
1205
+ if (!(snapshot == null ? void 0 : snapshot.exists)) return null;
1206
+ const sampledAtMs = snapshot.get('sampledAtMs');
1207
+ if (typeof sampledAtMs !== 'number') return null;
1208
+ const nowMs = (_ref2 = options == null ? void 0 : options.now) != null ? _ref2 : Date.now();
1209
+ // Twice the window it covers: one missed sampling is tolerated, two are
1210
+ // not, and the reading still describes a period that overlaps now.
1211
+ const maxAgeMs = (_ref3 = options == null ? void 0 : options.maxAgeMs) != null ? _ref3 : 12 * 60 * 60 * 1000;
1212
+ if (nowMs - sampledAtMs > maxAgeMs) return null;
1213
+ const samples = snapshot.get('samples');
1214
+ return Array.isArray(samples) ? samples : null;
1215
+ } catch (unused) {
1216
+ return null;
1217
+ }
1218
+ }
1219
+
1220
+ //# sourceMappingURL=rate-limit-store.js.map