@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,150 @@
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 durable half of the visitor-record ceilings (AGL-1529) — the lead
19
+ * writer that enforces `LEADS_MAX_PER_HOST`, and the bookkeeping that makes a
20
+ * trip of EITHER ceiling something a site's owner can see.
21
+ *
22
+ * The policy is pure and lives in `@aglyn/aglyn/server`
23
+ * (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same
24
+ * split as `visitorWriteRateLimitRefusal`, and for the same reason.
25
+ */
26
+ import { type VisitorRecordKind } from '@aglyn/aglyn/server';
27
+ import { type ResolvedCampaignTouch } from './campaign-conversion-attribution';
28
+ /**
29
+ * Make a tripped ceiling OBSERVABLE rather than a silent drop — the * standing rule that a control which is not visible in the console does not
30
+ * count as shipped.
31
+ *
32
+ * Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape
33
+ * (`apps/tenant/app/api/forms/submit/route.ts`, AGL-1655):
34
+ *
35
+ * - A durable per-month refusal count at
36
+ * `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded
37
+ * from every client write in `cloud/firebase-firestore.rules` (AGL-1367),
38
+ * so the record cannot be edited away by the site it describes, and host
39
+ * admins can already READ it — which is what lets the inbox console page
40
+ * render it with no rules change.
41
+ * - One in-app notification to the site's managers, on the FIRST refusal of
42
+ * the month only. A notification per refused bot request would be the
43
+ * flood again, delivered.
44
+ *
45
+ * The counter is month-keyed even though the CEILING is a total, because the
46
+ * two facts are different: the ceiling is "how many records exist" and this is
47
+ * "how many were turned away recently". Only the second is worth a notice.
48
+ *
49
+ * Best-effort throughout: bookkeeping that failed must never turn a contained
50
+ * refusal into a 500, because a 500 is an invitation to retry.
51
+ */
52
+ export declare function recordVisitorRecordCeilingTrip(options: {
53
+ hostRef: FirebaseFirestore.DocumentReference;
54
+ hostId: string;
55
+ kind: VisitorRecordKind;
56
+ ceiling: number;
57
+ monthKey?: string;
58
+ }): Promise<void>;
59
+ /** What a caller wants stored on the lead. `createdAt` is stamped here. */
60
+ export interface HostLeadInput {
61
+ email: string;
62
+ /** The name the person typed, when they typed one (AGL-2303). */
63
+ name?: string;
64
+ /** `signup`, `booking`, `form:{formId}` — the surface that produced it. */
65
+ source: string;
66
+ /**
67
+ * Explicit marketing opt-in, with a consent timestamp — the same shape
68
+ * `upsertHostContact` already carries (AGL-301). Omitted or `false` writes
69
+ * nothing: a lead is a side effect of an action the visitor DID take
70
+ * (sign up, book), which is not by itself consent to be emailed
71
+ * marketing, so this is only set when the caller captured an explicit
72
+ * checkbox.
73
+ */
74
+ marketingConsent?: boolean;
75
+ }
76
+ /**
77
+ * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by
78
+ * `LEADS_MAX_PER_HOST` (AGL-1529).
79
+ *
80
+ * ## One person is one document
81
+ *
82
+ * `docs/specs/reusable-forms.md` §4b. This used to be `tx.create(ref.doc())`
83
+ * — an auto-id per capture event — so one returning customer who signed up
84
+ * and booked twice was three "leads". The Members & leads tab presented a
85
+ * list of events as a list of people, and the only thing holding two rows for
86
+ * one person together was string equality on the address at render time.
87
+ *
88
+ * The events are not lost, they are just no longer the record: `sources`
89
+ * carries every surface that produced a capture, `submissionCount` counts
90
+ * them, and `firstSeenAtMs`/`lastSeenAtMs` bracket them. The submissions, the
91
+ * bookings and the member document are still there and still one row each.
92
+ *
93
+ * The id is {@link personKey} — `sha256(normalizeContactEmail(email))`, the
94
+ * SAME derivation a list membership's `memberKey` uses, imported rather than
95
+ * restated. Two specs named this function and the rule both stated is that
96
+ * whichever ships second imports the first's helper: a second copy is how
97
+ * `emailSuppressionKey` and `suppressionId` came to disagree.
98
+ *
99
+ * ## Why every lead writer goes through here
100
+ *
101
+ * There are three lead writes in the repo — the sign-up handler and the two
102
+ * bookings paths — and all three were `hostRef.collection('leads').add(…)`
103
+ * with a `.catch()` on the end. A cap enforced at two of three call sites is
104
+ * not a cap, and the fourth writer somebody adds next month would not have
105
+ * one either. One function is the only shape that survives that.
106
+ *
107
+ * ## The count is evaluated INSIDE the transaction that writes
108
+ *
109
+ * A create-time quota can be laundered by WHEN it is evaluated, not by the
110
+ * counting rule (AGL-2231/2265/2266). Read-then-decide-then-`add()` lets N
111
+ * concurrent visitors each read the same pre-count, each find room, and each
112
+ * land — and nothing re-counts afterwards, so the extra rows are permanent.
113
+ * `Transaction.get(AggregateQuery)` serialises the count against a concurrent
114
+ * create into the same collection: the loser retries, re-reads the higher
115
+ * count, and is refused. The count is of LIVE documents, so triaging leads in
116
+ * the inbox frees the slots.
117
+ *
118
+ * ## Refusing a lead never fails the visitor's action
119
+ *
120
+ * A lead is a SIDE EFFECT — of a sign-up, or of a booking. The visitor did
121
+ * not ask for it and cannot see it, so refusing one must not refuse the thing
122
+ * they did ask for. This returns a boolean and never throws; the trip is
123
+ * recorded and the caller carries on. That is the honest split, and it is why
124
+ * `SITE_MEMBERS_MAX_PER_HOST` (which governs an action a visitor DID take) is
125
+ * enforced with a 429 in the sign-up handler and this is not.
126
+ */
127
+ export declare function addHostLead(options: {
128
+ hostRef: FirebaseFirestore.DocumentReference;
129
+ hostId: string;
130
+ lead: HostLeadInput;
131
+ /**
132
+ * The ceiling to compare against. Defaults to `LEADS_MAX_PER_HOST` and is
133
+ * passed by NOTHING in production — it exists so the suite can re-drive the
134
+ * same count against a ceiling one higher and require the write to succeed.
135
+ * A refusal that survives its own ceiling being raised was never that
136
+ * ceiling's refusal, and there is exactly ONE comparison below, so knifing
137
+ * it cannot be absorbed by a fallback branch.
138
+ */
139
+ ceiling?: number;
140
+ /**
141
+ * The campaign this person came from, already resolved by the door.
142
+ *
143
+ * Resolved rather than raw, and passed rather than looked up, because one
144
+ * visitor action reaches several writers: a form submission that creates a
145
+ * submission, a contact AND a lead must pay for the touch lookup once. A
146
+ * door that hands none — every order path, every import — attributes
147
+ * nothing, which is how a lead that no campaign caused stays uncredited.
148
+ */
149
+ touch?: ResolvedCampaignTouch | null;
150
+ }): Promise<boolean>;
@@ -0,0 +1,262 @@
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 durable half of the visitor-record ceilings (AGL-1529) — the lead
19
+ * writer that enforces `LEADS_MAX_PER_HOST`, and the bookkeeping that makes a
20
+ * trip of EITHER ceiling something a site's owner can see.
21
+ *
22
+ * The policy is pure and lives in `@aglyn/aglyn/server`
23
+ * (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same
24
+ * split as `visitorWriteRateLimitRefusal`, and for the same reason.
25
+ */ import { CAPTURED_BY_HOST_FIELD, checkVisitorRecordCeiling, LEADS_MAX_PER_HOST, marketingConsentFieldsForGroup, personKey, readMarketingBasis, soloConsentGroup, submissionMonthKey, visitorRecordRefusedCounterId } from "@aglyn/aglyn/server";
26
+ import { FieldValue } from "firebase-admin/firestore";
27
+ import { attributeCampaignConversion } from "./campaign-conversion-attribution.js";
28
+ import { notifyHostManagers } from "./notifications.js";
29
+ /**
30
+ * Make a tripped ceiling OBSERVABLE rather than a silent drop — the * standing rule that a control which is not visible in the console does not
31
+ * count as shipped.
32
+ *
33
+ * Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape
34
+ * (`apps/tenant/app/api/forms/submit/route.ts`, AGL-1655):
35
+ *
36
+ * - A durable per-month refusal count at
37
+ * `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded
38
+ * from every client write in `cloud/firebase-firestore.rules` (AGL-1367),
39
+ * so the record cannot be edited away by the site it describes, and host
40
+ * admins can already READ it — which is what lets the inbox console page
41
+ * render it with no rules change.
42
+ * - One in-app notification to the site's managers, on the FIRST refusal of
43
+ * the month only. A notification per refused bot request would be the
44
+ * flood again, delivered.
45
+ *
46
+ * The counter is month-keyed even though the CEILING is a total, because the
47
+ * two facts are different: the ceiling is "how many records exist" and this is
48
+ * "how many were turned away recently". Only the second is worth a notice.
49
+ *
50
+ * Best-effort throughout: bookkeeping that failed must never turn a contained
51
+ * refusal into a 500, because a 500 is an invitation to retry.
52
+ */ export async function recordVisitorRecordCeilingTrip(options) {
53
+ var _options_monthKey;
54
+ const { hostRef, hostId, kind, ceiling } = options;
55
+ const monthKey = (_options_monthKey = options.monthKey) != null ? _options_monthKey : submissionMonthKey();
56
+ try {
57
+ var _refusedSnapshot_get;
58
+ const refusedRef = hostRef.collection('counters').doc(visitorRecordRefusedCounterId(kind));
59
+ const refusedSnapshot = await refusedRef.get();
60
+ const alreadyRefused = Number((_refusedSnapshot_get = refusedSnapshot.get(monthKey)) != null ? _refusedSnapshot_get : 0);
61
+ await refusedRef.set({
62
+ [monthKey]: FieldValue.increment(1),
63
+ // Explicit values only — Firestore rejects `undefined`.
64
+ ceiling,
65
+ lastRefusedAtMs: Date.now()
66
+ }, {
67
+ merge: true
68
+ });
69
+ if (alreadyRefused === 0) {
70
+ const leads = kind === 'leads';
71
+ await notifyHostManagers(hostId, {
72
+ type: 'system.visitorRecordsPaused',
73
+ title: leads ? 'Lead capture paused — this site is at the platform limit' : 'Sign-ups paused — this site is at the platform limit',
74
+ body: `This site holds ${ceiling.toLocaleString()} ` + `${leads ? 'leads' : 'member accounts'}, which is the platform ` + `safety limit, so further ${leads ? 'leads' : 'sign-ups'} are ` + 'being refused. This is not part of your plan — every plan ' + `includes unlimited ${leads ? 'leads' : 'member accounts'}. ` + `Remove some ${leads ? 'leads' : 'members'}, or contact support ` + 'if this is real traffic.',
75
+ link: `/${hostId}/inbox`
76
+ });
77
+ }
78
+ } catch (error) {
79
+ console.error('visitor record ceiling bookkeeping failed', error);
80
+ }
81
+ }
82
+ /**
83
+ * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by
84
+ * `LEADS_MAX_PER_HOST` (AGL-1529).
85
+ *
86
+ * ## One person is one document
87
+ *
88
+ * `docs/specs/reusable-forms.md` §4b. This used to be `tx.create(ref.doc())`
89
+ * — an auto-id per capture event — so one returning customer who signed up
90
+ * and booked twice was three "leads". The Members & leads tab presented a
91
+ * list of events as a list of people, and the only thing holding two rows for
92
+ * one person together was string equality on the address at render time.
93
+ *
94
+ * The events are not lost, they are just no longer the record: `sources`
95
+ * carries every surface that produced a capture, `submissionCount` counts
96
+ * them, and `firstSeenAtMs`/`lastSeenAtMs` bracket them. The submissions, the
97
+ * bookings and the member document are still there and still one row each.
98
+ *
99
+ * The id is {@link personKey} — `sha256(normalizeContactEmail(email))`, the
100
+ * SAME derivation a list membership's `memberKey` uses, imported rather than
101
+ * restated. Two specs named this function and the rule both stated is that
102
+ * whichever ships second imports the first's helper: a second copy is how
103
+ * `emailSuppressionKey` and `suppressionId` came to disagree.
104
+ *
105
+ * ## Why every lead writer goes through here
106
+ *
107
+ * There are three lead writes in the repo — the sign-up handler and the two
108
+ * bookings paths — and all three were `hostRef.collection('leads').add(…)`
109
+ * with a `.catch()` on the end. A cap enforced at two of three call sites is
110
+ * not a cap, and the fourth writer somebody adds next month would not have
111
+ * one either. One function is the only shape that survives that.
112
+ *
113
+ * ## The count is evaluated INSIDE the transaction that writes
114
+ *
115
+ * A create-time quota can be laundered by WHEN it is evaluated, not by the
116
+ * counting rule (AGL-2231/2265/2266). Read-then-decide-then-`add()` lets N
117
+ * concurrent visitors each read the same pre-count, each find room, and each
118
+ * land — and nothing re-counts afterwards, so the extra rows are permanent.
119
+ * `Transaction.get(AggregateQuery)` serialises the count against a concurrent
120
+ * create into the same collection: the loser retries, re-reads the higher
121
+ * count, and is refused. The count is of LIVE documents, so triaging leads in
122
+ * the inbox frees the slots.
123
+ *
124
+ * ## Refusing a lead never fails the visitor's action
125
+ *
126
+ * A lead is a SIDE EFFECT — of a sign-up, or of a booking. The visitor did
127
+ * not ask for it and cannot see it, so refusing one must not refuse the thing
128
+ * they did ask for. This returns a boolean and never throws; the trip is
129
+ * recorded and the caller carries on. That is the honest split, and it is why
130
+ * `SITE_MEMBERS_MAX_PER_HOST` (which governs an action a visitor DID take) is
131
+ * enforced with a 429 in the sign-up handler and this is not.
132
+ */ export async function addHostLead(options) {
133
+ var _options_ceiling;
134
+ const { hostRef, hostId, lead } = options;
135
+ const maxPerHost = (_options_ceiling = options.ceiling) != null ? _options_ceiling : LEADS_MAX_PER_HOST;
136
+ try {
137
+ const leadsRef = hostRef.collection('leads');
138
+ const firestore = hostRef.firestore;
139
+ /*
140
+ * `null` for anything that is not a usable address — a lead captured
141
+ * against a malformed one keeps an auto-id and stays its own row. Keying
142
+ * several unusable addresses under one guessed id would merge two
143
+ * different people, which is worse than two rows for one.
144
+ */ const key = personKey(lead.email);
145
+ const leadRef = key ? leadsRef.doc(key) : leadsRef.doc();
146
+ const now = Date.now();
147
+ const seen = _extends({
148
+ // `arrayUnion`, so a person who books twice has `['booking']` and one
149
+ // who signed up and then submitted a form has both. Bounded by the
150
+ // number of surfaces, not by the number of captures.
151
+ sources: FieldValue.arrayUnion(lead.source),
152
+ lastSeenAtMs: now,
153
+ submissionCount: FieldValue.increment(1)
154
+ }, lead.name ? {
155
+ name: lead.name
156
+ } : {});
157
+ let created = false;
158
+ const refused = await firestore.runTransaction(async (tx)=>{
159
+ var _existing_data;
160
+ // Reset per attempt: a contended transaction re-runs its body, and a
161
+ // flag left standing from an aborted attempt would credit a campaign
162
+ // with a person who turned out to exist already.
163
+ created = false;
164
+ // ALL READS BEFORE THE WRITE, which Firestore requires.
165
+ const existing = await tx.get(leadRef);
166
+ /*
167
+ * ⛔ THE CEILING GATES A NEW PERSON, NEVER AN EXISTING ONE.
168
+ *
169
+ * A returning visitor's capture is an UPDATE — it does not grow the
170
+ * collection, so refusing it buys no capacity and costs the customer
171
+ * the source and the timestamp they would have learned. That is the
172
+ * enforcement-at-use shape the capacity rule exists to forbid: a limit
173
+ * must refuse the addition, never a person already recorded or the
174
+ * data attached to them.
175
+ *
176
+ * It also means the count is only paid on a genuinely new person,
177
+ * which is the case that can move it.
178
+ */ if (!existing.exists) {
179
+ const used = (await tx.get(leadsRef.count())).data().count;
180
+ if (checkVisitorRecordCeiling(used, maxPerHost).exceeded) return true;
181
+ }
182
+ /*
183
+ * Consent is carried forward and never cleared.
184
+ *
185
+ * A basis is written only when this capture carried an explicit
186
+ * opt-in, so a later booking by someone who did not tick the box
187
+ * leaves an earlier grant standing — absent-or-granted, the shape
188
+ * every other writer uses. The TIMESTAMP is carried over rather than
189
+ * restamped, for the reason given at the read below.
190
+ */ /*
191
+ * The EARLIEST grant is the one that happened, so a later capture
192
+ * carrying the same checkbox keeps the original date rather than
193
+ * restamping when this person opted in. Read back through the shared
194
+ * reader so "already consented" means the same thing here as it does
195
+ * at send time.
196
+ */ /*
197
+ * THE GROUP OF ONE, and deliberately so on this silo.
198
+ *
199
+ * `hosts/{hostId}/leads` is private by path — no sibling site can sweep
200
+ * it, declared group or not — so pooling a lead's basis would record a
201
+ * disclosure that reaches nothing. The contact written by the same
202
+ * capture door IS org-shared and IS pooled, which is where a declared
203
+ * group's disclosure is honored.
204
+ */ const group = soloConsentGroup(hostId);
205
+ const prior = readMarketingBasis((_existing_data = existing.data()) != null ? _existing_data : null, group);
206
+ const consentAtMs = prior.basis === 'granted' && prior.basisAtMs !== null ? prior.basisAtMs : now;
207
+ created = !existing.exists;
208
+ tx.set(leadRef, _extends({
209
+ email: lead.email
210
+ }, seen, existing.exists ? {} : {
211
+ firstSeenAtMs: now,
212
+ createdAt: FieldValue.serverTimestamp(),
213
+ [CAPTURED_BY_HOST_FIELD]: [
214
+ hostId
215
+ ]
216
+ }, lead.marketingConsent ? marketingConsentFieldsForGroup(group, consentAtMs) : {}), {
217
+ merge: true
218
+ });
219
+ return false;
220
+ });
221
+ if (refused) {
222
+ await recordVisitorRecordCeilingTrip({
223
+ hostRef,
224
+ hostId,
225
+ kind: 'leads',
226
+ ceiling: maxPerHost
227
+ });
228
+ return false;
229
+ }
230
+ /*
231
+ * ATTRIBUTED ON CREATION ONLY.
232
+ *
233
+ * A returning visitor's capture is an update — the campaign did not
234
+ * produce a lead, it produced another visit by a person the site already
235
+ * held — and crediting it would let whichever campaign ran most recently
236
+ * re-earn every lead on the list. `created` is set inside the transaction
237
+ * that decides it, so the attribution and the write agree about whether
238
+ * this person is new.
239
+ *
240
+ * Awaited rather than fired off: `addHostLead` already returns only after
241
+ * its own write, and a caller that `void`s it (every one of them) is
242
+ * unaffected. Never throws, so a failure here cannot cost the lead.
243
+ */ if (created && options.touch) {
244
+ await attributeCampaignConversion({
245
+ hostId,
246
+ kind: 'lead',
247
+ refId: leadRef.id,
248
+ touch: options.touch,
249
+ convertedAtMs: now
250
+ });
251
+ }
252
+ return true;
253
+ } catch (error) {
254
+ // Same posture the three original call sites had (`.catch(() => undefined)`
255
+ // / `.catch(console.error)`): a lead that failed to store must not fail
256
+ // the sign-up or the booking that produced it.
257
+ console.error('lead write failed', error);
258
+ return false;
259
+ }
260
+ }
261
+
262
+ //# sourceMappingURL=host-visitor-records.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-visitor-records.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 durable half of the visitor-record ceilings (AGL-1529) — the lead\n * writer that enforces `LEADS_MAX_PER_HOST`, and the bookkeeping that makes a\n * trip of EITHER ceiling something a site's owner can see.\n *\n * The policy is pure and lives in `@aglyn/aglyn/server`\n * (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same\n * split as `visitorWriteRateLimitRefusal`, and for the same reason.\n */\n\nimport {\n CAPTURED_BY_HOST_FIELD,\n checkVisitorRecordCeiling,\n LEADS_MAX_PER_HOST,\n marketingConsentFieldsForGroup,\n personKey,\n readMarketingBasis,\n soloConsentGroup,\n submissionMonthKey,\n visitorRecordRefusedCounterId,\n type VisitorRecordKind,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n attributeCampaignConversion,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { notifyHostManagers } from './notifications'\n\n/**\n * Make a tripped ceiling OBSERVABLE rather than a silent drop — the * standing rule that a control which is not visible in the console does not\n * count as shipped.\n *\n * Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape\n * (`apps/tenant/app/api/forms/submit/route.ts`, AGL-1655):\n *\n * - A durable per-month refusal count at\n * `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded\n * from every client write in `cloud/firebase-firestore.rules` (AGL-1367),\n * so the record cannot be edited away by the site it describes, and host\n * admins can already READ it — which is what lets the inbox console page\n * render it with no rules change.\n * - One in-app notification to the site's managers, on the FIRST refusal of\n * the month only. A notification per refused bot request would be the\n * flood again, delivered.\n *\n * The counter is month-keyed even though the CEILING is a total, because the\n * two facts are different: the ceiling is \"how many records exist\" and this is\n * \"how many were turned away recently\". Only the second is worth a notice.\n *\n * Best-effort throughout: bookkeeping that failed must never turn a contained\n * refusal into a 500, because a 500 is an invitation to retry.\n */\nexport async function recordVisitorRecordCeilingTrip(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n kind: VisitorRecordKind\n ceiling: number\n monthKey?: string\n}): Promise<void> {\n const { hostRef, hostId, kind, ceiling } = options\n const monthKey = options.monthKey ?? submissionMonthKey()\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc(visitorRecordRefusedCounterId(kind))\n const refusedSnapshot = await refusedRef.get()\n const alreadyRefused = Number(refusedSnapshot.get(monthKey) ?? 0)\n await refusedRef.set(\n {\n [monthKey]: FieldValue.increment(1),\n // Explicit values only — Firestore rejects `undefined`.\n ceiling,\n lastRefusedAtMs: Date.now(),\n },\n { merge: true },\n )\n if (alreadyRefused === 0) {\n const leads = kind === 'leads'\n await notifyHostManagers(hostId, {\n type: 'system.visitorRecordsPaused',\n title: leads\n ? 'Lead capture paused — this site is at the platform limit'\n : 'Sign-ups paused — this site is at the platform limit',\n body:\n `This site holds ${ceiling.toLocaleString()} ` +\n `${leads ? 'leads' : 'member accounts'}, which is the platform ` +\n `safety limit, so further ${leads ? 'leads' : 'sign-ups'} are ` +\n 'being refused. This is not part of your plan — every plan ' +\n `includes unlimited ${leads ? 'leads' : 'member accounts'}. ` +\n `Remove some ${leads ? 'leads' : 'members'}, or contact support ` +\n 'if this is real traffic.',\n link: `/${hostId}/inbox`,\n })\n }\n } catch (error) {\n console.error('visitor record ceiling bookkeeping failed', error)\n }\n}\n\n/** What a caller wants stored on the lead. `createdAt` is stamped here. */\nexport interface HostLeadInput {\n email: string\n /** The name the person typed, when they typed one (AGL-2303). */\n name?: string\n /** `signup`, `booking`, `form:{formId}` — the surface that produced it. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` already carries (AGL-301). Omitted or `false` writes\n * nothing: a lead is a side effect of an action the visitor DID take\n * (sign up, book), which is not by itself consent to be emailed\n * marketing, so this is only set when the caller captured an explicit\n * checkbox.\n */\n marketingConsent?: boolean\n}\n\n/**\n * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by\n * `LEADS_MAX_PER_HOST` (AGL-1529).\n *\n * ## One person is one document\n *\n * `docs/specs/reusable-forms.md` §4b. This used to be `tx.create(ref.doc())`\n * — an auto-id per capture event — so one returning customer who signed up\n * and booked twice was three \"leads\". The Members & leads tab presented a\n * list of events as a list of people, and the only thing holding two rows for\n * one person together was string equality on the address at render time.\n *\n * The events are not lost, they are just no longer the record: `sources`\n * carries every surface that produced a capture, `submissionCount` counts\n * them, and `firstSeenAtMs`/`lastSeenAtMs` bracket them. The submissions, the\n * bookings and the member document are still there and still one row each.\n *\n * The id is {@link personKey} — `sha256(normalizeContactEmail(email))`, the\n * SAME derivation a list membership's `memberKey` uses, imported rather than\n * restated. Two specs named this function and the rule both stated is that\n * whichever ships second imports the first's helper: a second copy is how\n * `emailSuppressionKey` and `suppressionId` came to disagree.\n *\n * ## Why every lead writer goes through here\n *\n * There are three lead writes in the repo — the sign-up handler and the two\n * bookings paths — and all three were `hostRef.collection('leads').add(…)`\n * with a `.catch()` on the end. A cap enforced at two of three call sites is\n * not a cap, and the fourth writer somebody adds next month would not have\n * one either. One function is the only shape that survives that.\n *\n * ## The count is evaluated INSIDE the transaction that writes\n *\n * A create-time quota can be laundered by WHEN it is evaluated, not by the\n * counting rule (AGL-2231/2265/2266). Read-then-decide-then-`add()` lets N\n * concurrent visitors each read the same pre-count, each find room, and each\n * land — and nothing re-counts afterwards, so the extra rows are permanent.\n * `Transaction.get(AggregateQuery)` serialises the count against a concurrent\n * create into the same collection: the loser retries, re-reads the higher\n * count, and is refused. The count is of LIVE documents, so triaging leads in\n * the inbox frees the slots.\n *\n * ## Refusing a lead never fails the visitor's action\n *\n * A lead is a SIDE EFFECT — of a sign-up, or of a booking. The visitor did\n * not ask for it and cannot see it, so refusing one must not refuse the thing\n * they did ask for. This returns a boolean and never throws; the trip is\n * recorded and the caller carries on. That is the honest split, and it is why\n * `SITE_MEMBERS_MAX_PER_HOST` (which governs an action a visitor DID take) is\n * enforced with a 429 in the sign-up handler and this is not.\n */\nexport async function addHostLead(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n lead: HostLeadInput\n /**\n * The ceiling to compare against. Defaults to `LEADS_MAX_PER_HOST` and is\n * passed by NOTHING in production — it exists so the suite can re-drive the\n * same count against a ceiling one higher and require the write to succeed.\n * A refusal that survives its own ceiling being raised was never that\n * ceiling's refusal, and there is exactly ONE comparison below, so knifing\n * it cannot be absorbed by a fallback branch.\n */\n ceiling?: number\n /**\n * The campaign this person came from, already resolved by the door.\n *\n * Resolved rather than raw, and passed rather than looked up, because one\n * visitor action reaches several writers: a form submission that creates a\n * submission, a contact AND a lead must pay for the touch lookup once. A\n * door that hands none — every order path, every import — attributes\n * nothing, which is how a lead that no campaign caused stays uncredited.\n */\n touch?: ResolvedCampaignTouch | null\n}): Promise<boolean> {\n const { hostRef, hostId, lead } = options\n const maxPerHost = options.ceiling ?? LEADS_MAX_PER_HOST\n try {\n const leadsRef = hostRef.collection('leads')\n const firestore = hostRef.firestore\n /*\n * `null` for anything that is not a usable address — a lead captured\n * against a malformed one keeps an auto-id and stays its own row. Keying\n * several unusable addresses under one guessed id would merge two\n * different people, which is worse than two rows for one.\n */\n const key = personKey(lead.email)\n const leadRef = key ? leadsRef.doc(key) : leadsRef.doc()\n const now = Date.now()\n const seen = {\n // `arrayUnion`, so a person who books twice has `['booking']` and one\n // who signed up and then submitted a form has both. Bounded by the\n // number of surfaces, not by the number of captures.\n sources: FieldValue.arrayUnion(lead.source),\n lastSeenAtMs: now,\n submissionCount: FieldValue.increment(1),\n ...(lead.name ? { name: lead.name } : {}),\n }\n let created = false\n const refused = await firestore.runTransaction(async (tx) => {\n // Reset per attempt: a contended transaction re-runs its body, and a\n // flag left standing from an aborted attempt would credit a campaign\n // with a person who turned out to exist already.\n created = false\n // ALL READS BEFORE THE WRITE, which Firestore requires.\n const existing = await tx.get(leadRef)\n /*\n * ⛔ THE CEILING GATES A NEW PERSON, NEVER AN EXISTING ONE.\n *\n * A returning visitor's capture is an UPDATE — it does not grow the\n * collection, so refusing it buys no capacity and costs the customer\n * the source and the timestamp they would have learned. That is the\n * enforcement-at-use shape the capacity rule exists to forbid: a limit\n * must refuse the addition, never a person already recorded or the\n * data attached to them.\n *\n * It also means the count is only paid on a genuinely new person,\n * which is the case that can move it.\n */\n if (!existing.exists) {\n const used = (await tx.get(leadsRef.count())).data().count\n if (checkVisitorRecordCeiling(used, maxPerHost).exceeded) return true\n }\n /*\n * Consent is carried forward and never cleared.\n *\n * A basis is written only when this capture carried an explicit\n * opt-in, so a later booking by someone who did not tick the box\n * leaves an earlier grant standing — absent-or-granted, the shape\n * every other writer uses. The TIMESTAMP is carried over rather than\n * restamped, for the reason given at the read below.\n */\n /*\n * The EARLIEST grant is the one that happened, so a later capture\n * carrying the same checkbox keeps the original date rather than\n * restamping when this person opted in. Read back through the shared\n * reader so \"already consented\" means the same thing here as it does\n * at send time.\n */\n /*\n * THE GROUP OF ONE, and deliberately so on this silo.\n *\n * `hosts/{hostId}/leads` is private by path — no sibling site can sweep\n * it, declared group or not — so pooling a lead's basis would record a\n * disclosure that reaches nothing. The contact written by the same\n * capture door IS org-shared and IS pooled, which is where a declared\n * group's disclosure is honored.\n */\n const group = soloConsentGroup(hostId)\n const prior = readMarketingBasis(existing.data() ?? null, group)\n const consentAtMs =\n prior.basis === 'granted' && prior.basisAtMs !== null\n ? prior.basisAtMs\n : now\n created = !existing.exists\n tx.set(\n leadRef,\n {\n email: lead.email,\n ...seen,\n ...(existing.exists\n ? {}\n : {\n firstSeenAtMs: now,\n createdAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [hostId],\n }),\n /*\n * Recorded under THIS host even though the collection already sits\n * under it.\n *\n * A lead cannot be swept by another site — `hosts/{hostId}/leads`\n * is private by path — so the host key adds no enforcement here.\n * It is written anyway because {@link readMarketingBasis} is one\n * function over four silos, and a silo whose basis lived somewhere\n * else would need the reader to know which collection it was\n * handed. A reader that has to be told the shape is a reader that\n * can be told the wrong one.\n */\n ...(lead.marketingConsent\n ? marketingConsentFieldsForGroup(group, consentAtMs)\n : {}),\n },\n { merge: true },\n )\n return false\n })\n if (refused) {\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'leads',\n ceiling: maxPerHost,\n })\n return false\n }\n /*\n * ATTRIBUTED ON CREATION ONLY.\n *\n * A returning visitor's capture is an update — the campaign did not\n * produce a lead, it produced another visit by a person the site already\n * held — and crediting it would let whichever campaign ran most recently\n * re-earn every lead on the list. `created` is set inside the transaction\n * that decides it, so the attribution and the write agree about whether\n * this person is new.\n *\n * Awaited rather than fired off: `addHostLead` already returns only after\n * its own write, and a caller that `void`s it (every one of them) is\n * unaffected. Never throws, so a failure here cannot cost the lead.\n */\n if (created && options.touch) {\n await attributeCampaignConversion({\n hostId,\n kind: 'lead',\n refId: leadRef.id,\n touch: options.touch,\n convertedAtMs: now,\n })\n }\n return true\n } catch (error) {\n // Same posture the three original call sites had (`.catch(() => undefined)`\n // / `.catch(console.error)`): a lead that failed to store must not fail\n // the sign-up or the booking that produced it.\n console.error('lead write failed', error)\n return false\n }\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","marketingConsentFieldsForGroup","personKey","readMarketingBasis","soloConsentGroup","submissionMonthKey","visitorRecordRefusedCounterId","FieldValue","attributeCampaignConversion","notifyHostManagers","recordVisitorRecordCeilingTrip","options","hostRef","hostId","kind","ceiling","monthKey","refusedSnapshot","refusedRef","collection","doc","get","alreadyRefused","Number","set","increment","lastRefusedAtMs","Date","now","merge","leads","type","title","body","toLocaleString","link","error","console","addHostLead","lead","maxPerHost","leadsRef","firestore","key","email","leadRef","seen","sources","arrayUnion","source","lastSeenAtMs","submissionCount","name","created","refused","runTransaction","tx","existing","exists","used","count","data","exceeded","group","prior","consentAtMs","basis","basisAtMs","firstSeenAtMs","createdAt","serverTimestamp","marketingConsent","touch","refId","id","convertedAtMs"],"mappings":"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"}
@@ -0,0 +1,28 @@
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
+ * Whether a throw from verifying an ID token (or a session cookie) says the
19
+ * credential was refused. `true` answers 401; `false` is a failure to check
20
+ * and must keep the caller's 5xx.
21
+ */
22
+ export declare function isRefusedIdToken(error: unknown): boolean;
23
+ /**
24
+ * The 401 a refused credential earns, or `null` for anything else so the
25
+ * caller's own 500 stands (AGL-1993). Byte-identical to the body a missing
26
+ * Authorization header already gets, and it never says WHICH code matched.
27
+ */
28
+ export declare function invalidIdTokenResponse(error: unknown): Response | null;
@@ -0,0 +1,111 @@
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
+ * "THIS TOKEN IS NOT VALID" vs "SOMETHING BROKE WHILE CHECKING IT"
18
+ * (AGL-1993, AGL-2852).
19
+ *
20
+ * `verifyIdToken` throws for both. A handler that answers every throw with a
21
+ * 401 tells the caller their credential is bad during an outage that has
22
+ * nothing to do with it, and nothing pages; one that answers every throw with
23
+ * a 500 records an expired tab as Aglyn failing. This says which one a throw
24
+ * is, so a refusal can answer 401 and anything else keeps a 5xx.
25
+ *
26
+ * ## The direction of the default, which is the whole design
27
+ *
28
+ * Unrecognized → `false` → the caller's 5xx. NOT 401. A wrong 500 makes
29
+ * noise; a wrong 401 hides an outage. Only codes POSITIVELY known to mean the
30
+ * credential is bad answer `true`, and `strictNullChecks` is off repo-wide, so
31
+ * an error with no `code` folds to falsy and lands on the `false` default.
32
+ *
33
+ * ## `auth/argument-error` is NOT purely a client fault
34
+ *
35
+ * firebase-admin maps a bad signature, a malformed JWT, a wrong audience or
36
+ * issuer and an unknown `kid` to `auth/argument-error` — and falls through to
37
+ * the same code for `KEY_FETCH_ERROR`, its own Google public-key endpoint
38
+ * being unreachable (`lib/utils/jwt.js` in firebase-admin 14.2.0 builds that
39
+ * message as `'Error fetching public keys for Google certs: …'`). By code
40
+ * alone a certificate outage is a forged token, so the message is read too.
41
+ *
42
+ * ## Why a module of its own
43
+ *
44
+ * Specs replace `@aglyn/tenant-data-admin` with hand-built `jest.mock`
45
+ * factories — 178 in the console alone — and a factory that does not list a
46
+ * symbol makes it `undefined` rather than failing loudly. This file is its
47
+ * own entry point, `@aglyn/tenant-data-admin/server/id-token-refusal`, which
48
+ * no factory replaces, so the console's `invalidIdTokenResponse` and every
49
+ * plugin handler that verifies a token classify through the same real code.
50
+ * It imports nothing, and must keep importing nothing, for the same reason.
51
+ *
52
+ * ## What callers must NOT do with this
53
+ *
54
+ * Say which code matched. A refusal's body is the one a missing
55
+ * Authorization header already gets; telling "expired" from "revoked" from
56
+ * "no such user" answers questions about accounts for anyone who can send a
57
+ * request.
58
+ *==========================================*/ /**
59
+ * Codes that mean the CREDENTIAL is bad. Anything absent from this set is an
60
+ * infrastructure failure and keeps its 5xx.
61
+ */ const INVALID_CREDENTIAL_CODES = new Set([
62
+ // The JWT itself did not check out: bad signature, malformed, wrong
63
+ // audience or issuer, unknown `kid`, absent or oversized `sub`.
64
+ 'auth/argument-error',
65
+ 'auth/id-token-expired',
66
+ 'auth/session-cookie-expired',
67
+ // Revoked or locked out. `assertIdTokenNotRevoked` (AGL-1881) raises these
68
+ // two with codes matching the SDK's, so both arms agree.
69
+ 'auth/id-token-revoked',
70
+ 'auth/session-cookie-revoked',
71
+ 'auth/user-disabled',
72
+ // The account behind a well-formed token is gone. Fail-closed, and it is a
73
+ // statement about the credential, not about our health.
74
+ 'auth/user-not-found',
75
+ // A token minted in a different GCIP tenant than the pool verifying it.
76
+ 'auth/mismatching-tenant-id'
77
+ ]);
78
+ /**
79
+ * firebase-admin reports its own public-key fetch failing as
80
+ * `auth/argument-error`. That is an OUTAGE, not a bad token.
81
+ */ const KEY_FETCH_FAILURE = /error fetching public keys/i;
82
+ /**
83
+ * Whether a throw from verifying an ID token (or a session cookie) says the
84
+ * credential was refused. `true` answers 401; `false` is a failure to check
85
+ * and must keep the caller's 5xx.
86
+ */ export function isRefusedIdToken(error) {
87
+ const code = error == null ? void 0 : error.code;
88
+ if (typeof code !== 'string') return false;
89
+ if (!INVALID_CREDENTIAL_CODES.has(code)) return false;
90
+ if (code === 'auth/argument-error') {
91
+ const message = error.message;
92
+ if (typeof message === 'string' && KEY_FETCH_FAILURE.test(message)) {
93
+ return false;
94
+ }
95
+ }
96
+ return true;
97
+ }
98
+ /**
99
+ * The 401 a refused credential earns, or `null` for anything else so the
100
+ * caller's own 500 stands (AGL-1993). Byte-identical to the body a missing
101
+ * Authorization header already gets, and it never says WHICH code matched.
102
+ */ export function invalidIdTokenResponse(error) {
103
+ if (!isRefusedIdToken(error)) return null;
104
+ return Response.json({
105
+ error: 'Unauthenticated'
106
+ }, {
107
+ status: 401
108
+ });
109
+ }
110
+
111
+ //# sourceMappingURL=id-token-refusal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/id-token-refusal.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 * \"THIS TOKEN IS NOT VALID\" vs \"SOMETHING BROKE WHILE CHECKING IT\"\n * (AGL-1993, AGL-2852).\n *\n * `verifyIdToken` throws for both. A handler that answers every throw with a\n * 401 tells the caller their credential is bad during an outage that has\n * nothing to do with it, and nothing pages; one that answers every throw with\n * a 500 records an expired tab as Aglyn failing. This says which one a throw\n * is, so a refusal can answer 401 and anything else keeps a 5xx.\n *\n * ## The direction of the default, which is the whole design\n *\n * Unrecognized → `false` → the caller's 5xx. NOT 401. A wrong 500 makes\n * noise; a wrong 401 hides an outage. Only codes POSITIVELY known to mean the\n * credential is bad answer `true`, and `strictNullChecks` is off repo-wide, so\n * an error with no `code` folds to falsy and lands on the `false` default.\n *\n * ## `auth/argument-error` is NOT purely a client fault\n *\n * firebase-admin maps a bad signature, a malformed JWT, a wrong audience or\n * issuer and an unknown `kid` to `auth/argument-error` — and falls through to\n * the same code for `KEY_FETCH_ERROR`, its own Google public-key endpoint\n * being unreachable (`lib/utils/jwt.js` in firebase-admin 14.2.0 builds that\n * message as `'Error fetching public keys for Google certs: …'`). By code\n * alone a certificate outage is a forged token, so the message is read too.\n *\n * ## Why a module of its own\n *\n * Specs replace `@aglyn/tenant-data-admin` with hand-built `jest.mock`\n * factories — 178 in the console alone — and a factory that does not list a\n * symbol makes it `undefined` rather than failing loudly. This file is its\n * own entry point, `@aglyn/tenant-data-admin/server/id-token-refusal`, which\n * no factory replaces, so the console's `invalidIdTokenResponse` and every\n * plugin handler that verifies a token classify through the same real code.\n * It imports nothing, and must keep importing nothing, for the same reason.\n *\n * ## What callers must NOT do with this\n *\n * Say which code matched. A refusal's body is the one a missing\n * Authorization header already gets; telling \"expired\" from \"revoked\" from\n * \"no such user\" answers questions about accounts for anyone who can send a\n * request.\n *==========================================*/\n\n/**\n * Codes that mean the CREDENTIAL is bad. Anything absent from this set is an\n * infrastructure failure and keeps its 5xx.\n */\nconst INVALID_CREDENTIAL_CODES: ReadonlySet<string> = new Set([\n // The JWT itself did not check out: bad signature, malformed, wrong\n // audience or issuer, unknown `kid`, absent or oversized `sub`.\n 'auth/argument-error',\n 'auth/id-token-expired',\n 'auth/session-cookie-expired',\n // Revoked or locked out. `assertIdTokenNotRevoked` (AGL-1881) raises these\n // two with codes matching the SDK's, so both arms agree.\n 'auth/id-token-revoked',\n 'auth/session-cookie-revoked',\n 'auth/user-disabled',\n // The account behind a well-formed token is gone. Fail-closed, and it is a\n // statement about the credential, not about our health.\n 'auth/user-not-found',\n // A token minted in a different GCIP tenant than the pool verifying it.\n 'auth/mismatching-tenant-id',\n])\n\n/**\n * firebase-admin reports its own public-key fetch failing as\n * `auth/argument-error`. That is an OUTAGE, not a bad token.\n */\nconst KEY_FETCH_FAILURE = /error fetching public keys/i\n\n/**\n * Whether a throw from verifying an ID token (or a session cookie) says the\n * credential was refused. `true` answers 401; `false` is a failure to check\n * and must keep the caller's 5xx.\n */\nexport function isRefusedIdToken(error: unknown): boolean {\n const code = (error as { code?: unknown } | null | undefined)?.code\n if (typeof code !== 'string') return false\n if (!INVALID_CREDENTIAL_CODES.has(code)) return false\n if (code === 'auth/argument-error') {\n const message = (error as { message?: unknown }).message\n if (typeof message === 'string' && KEY_FETCH_FAILURE.test(message)) {\n return false\n }\n }\n return true\n}\n\n/**\n * The 401 a refused credential earns, or `null` for anything else so the\n * caller's own 500 stands (AGL-1993). Byte-identical to the body a missing\n * Authorization header already gets, and it never says WHICH code matched.\n */\nexport function invalidIdTokenResponse(error: unknown): Response | null {\n if (!isRefusedIdToken(error)) return null\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n}\n"],"names":["INVALID_CREDENTIAL_CODES","Set","KEY_FETCH_FAILURE","isRefusedIdToken","error","code","has","message","test","invalidIdTokenResponse","Response","json","status"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA0C4C,GAE5C;;;CAGC,GACD,MAAMA,2BAAgD,IAAIC,IAAI;IAC5D,oEAAoE;IACpE,gEAAgE;IAChE;IACA;IACA;IACA,2EAA2E;IAC3E,yDAAyD;IACzD;IACA;IACA;IACA,2EAA2E;IAC3E,wDAAwD;IACxD;IACA,wEAAwE;IACxE;CACD;AAED;;;CAGC,GACD,MAAMC,oBAAoB;AAE1B;;;;CAIC,GACD,OAAO,SAASC,iBAAiBC,KAAc;IAC7C,MAAMC,OAAQD,yBAAD,AAACA,MAAiDC,IAAI;IACnE,IAAI,OAAOA,SAAS,UAAU,OAAO;IACrC,IAAI,CAACL,yBAAyBM,GAAG,CAACD,OAAO,OAAO;IAChD,IAAIA,SAAS,uBAAuB;QAClC,MAAME,UAAU,AAACH,MAAgCG,OAAO;QACxD,IAAI,OAAOA,YAAY,YAAYL,kBAAkBM,IAAI,CAACD,UAAU;YAClE,OAAO;QACT;IACF;IACA,OAAO;AACT;AAEA;;;;CAIC,GACD,OAAO,SAASE,uBAAuBL,KAAc;IACnD,IAAI,CAACD,iBAAiBC,QAAQ,OAAO;IACrC,OAAOM,SAASC,IAAI,CAAC;QAAEP,OAAO;IAAkB,GAAG;QAAEQ,QAAQ;IAAI;AACnE"}