@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,1814 @@
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
+ * Server-side organization operations (AGL-233/234). Everything here is
19
+ * Admin-SDK-only by design: org creation, slug reservation, membership
20
+ * and the projections the security rules authorize against are never
21
+ * client-writable (docs/MULTI_TENANT_FIRESTORE.md §8).
22
+ */ import { consentGroupForHost, checkHostCollaboratorQuota, checkSeatQuota, countCollaboratorSeats, countManagerSeatsExcluding, createResourceUid, generateOrgSlug, projectMemberResolvedPermissions, resolveOrgPermissions, isOrgWideMember, isValidOrgSlug, hostPermissionKeys, orgPermissionLabel, pluginOrgPermissionKeys, projectHostMemberPermissions, projectHostMemberRoles, projectMemberScopeTokens, resolveCollaboratorHostPermissions, scopeTokensForHost } from "@aglyn/aglyn/server";
23
+ import { nameSearchKey, nameSearchReversed, nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
24
+ // LEAF MODULE, NOT THE BARREL (AGL-1289). This file is itself reachable
25
+ // through `@aglyn/aglyn/server`, and the verdict route proved this week that a
26
+ // constant pulled from that barrel inside the cycle typechecks and then
27
+ // resolves `undefined` at runtime.
28
+ import { ORG_BILLING_DOC_ID, ORG_BILLING_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/org-billing-doc";
29
+ import { MEMBER_EMAIL_ALIASES_COLLECTION } from "@aglyn/aglyn/app-utils/member-email-aliases";
30
+ import { FieldValue } from "firebase-admin/firestore";
31
+ import { cache } from "react";
32
+ import { findUserByUidAcrossPools } from "./auth-pools.js";
33
+ import firebaseAdmin from "./firebase-admin.js";
34
+ import { enforceFreeWorkspaceCapInTransaction, readFreeWorkspaceCapConfig } from "./free-workspace-cap.js";
35
+ import { deleteMemberHostProjections, syncHostProjectionForMembers, syncMemberHostProjections } from "./host-memberships.js";
36
+ import { updateExisting } from "./update-existing.js";
37
+ import { attachWorkspaceDomain } from "./workspace-domains.js";
38
+ const firestore = ()=>firebaseAdmin.app().firestore();
39
+ /** Firestore's hard cap on writes in one batched commit. */ const FIRESTORE_BATCH_LIMIT = 500;
40
+ export class OrgSlugTakenError extends Error {
41
+ constructor(slug){
42
+ super(`Org slug already reserved: ${slug}`);
43
+ this.name = 'OrgSlugTakenError';
44
+ }
45
+ }
46
+ /**
47
+ * ⛔ NOTHING WRITES A NEW `reservedUntil` ANY MORE — AND THE RULES THAT READ
48
+ * ONE STAY (AGL-2590).
49
+ *
50
+ * AGL-2585 gave a workspace created by an UNVERIFIED owner a held address
51
+ * rather than a granted one, because a signup could create a workspace before
52
+ * anything proved the email belonged to the person typing it. That is no
53
+ * longer possible: `/api/orgs/create` refuses an unverified caller outright,
54
+ * the sign-up form holds its typed name against the account instead, and the
55
+ * workspace is created on the first verified session. There is no path left
56
+ * that can produce an unproven address, so `createOrganization` writes the
57
+ * plain grant this collection has always held and the twenty-one-day
58
+ * reservation window is gone with the code that set it.
59
+ *
60
+ * The READ side below is deliberately kept, and it is not dead weight:
61
+ * production holds `orgSlugs` documents that WERE written with an expiry,
62
+ * before this. Deleting the lapse rules would silently promote every one of
63
+ * those squats to a permanent grant — the exact outcome AGL-2585 existed to
64
+ * end. They stay until `reap-unverified-orgs` has erased or promoted the last
65
+ * of them.
66
+ *
67
+ * Has a PENDING address reservation run out?
68
+ *
69
+ * A reservation with no `reservedUntil` is a GRANT and never lapses, which is
70
+ * what keeps every workspace made by a verified owner — and every one that
71
+ * predates the field — untouchable by this rule.
72
+ *
73
+ * A `reservedUntil` that is not a finite number never lapses either: a
74
+ * corrupt or half-written expiry is a reason to leave an address alone, not a
75
+ * reason to hand it to the next caller.
76
+ */ export function isSlugReservationLapsed(// The whole `orgSlugs/{slug}` document, not just the field being read: every
77
+ // caller has one in hand, and a parameter narrowed to `reservedUntil` alone
78
+ // makes the ordinary case — a grant, which carries `orgId` and no expiry —
79
+ // an excess-property error at the call site.
80
+ reservation, now = Date.now()) {
81
+ const until = reservation == null ? void 0 : reservation.reservedUntil;
82
+ if (typeof until !== 'number' || !Number.isFinite(until)) return false;
83
+ return until <= now;
84
+ }
85
+ /**
86
+ * Whether an `orgSlugs/{slug}` reservation may be (re)claimed (AGL-585):
87
+ * free when the doc is missing, when the claimant already owns it, or when
88
+ * it is a tombstone (`movedTo` set) — a renamed-away slug keeps redirecting
89
+ * old URLs only until someone wants it, it is never reserved forever.
90
+ * Claiming writes a full-replace `{ orgId }`, which ends the redirect —
91
+ * links to a reclaimed slug resolve to the new owner from then on.
92
+ *
93
+ * A LAPSED PENDING RESERVATION is claimable too (AGL-2585). The reservation
94
+ * an unverified signup takes is a hold, not a grant, and a hold that never
95
+ * expires is the squat this rule exists to end.
96
+ *
97
+ * ⚠️ Claimable here is NOT the whole answer for a lapsed reservation — see
98
+ * {@link lapsedReservationIsStillHeld}, which both call sites consult before
99
+ * they act on a `true` that came from the lapse branch. This function is pure
100
+ * and cannot ask whether the owner has verified since; treating its answer as
101
+ * final would let a customer who verified on day one lose their address on
102
+ * day twenty-one because a sweep was down.
103
+ */ export function isSlugReservationClaimable(reservation, claimingOrgId, now = Date.now()) {
104
+ if (!reservation) return true;
105
+ if (claimingOrgId !== null && reservation.orgId === claimingOrgId) return true;
106
+ if (reservation.movedTo) return true;
107
+ return isSlugReservationLapsed(reservation, now);
108
+ }
109
+ /**
110
+ * Is a LAPSED reservation nonetheless still its holder's? (AGL-2585)
111
+ *
112
+ * The lapse rule above is pure, and the fact it cannot see is the only one
113
+ * that matters here: whether the owner verified their address after the
114
+ * workspace was made. `reap-unverified-orgs` clears `reservedUntil` on its
115
+ * next pass when they have, but "on its next pass" is a promise about a
116
+ * scheduled job, and a scheduled job can stop. Between a verification and the
117
+ * promotion that records it, the pure rule would say this address is free.
118
+ *
119
+ * So the two paths that take a slug ask this before they take a lapsed one,
120
+ * and it answers from the auth record — the only source of truth for whether
121
+ * an address was ever confirmed.
122
+ *
123
+ * FAILS CLOSED, in every direction. A missing org, a missing owner, an auth
124
+ * lookup that throws: all of them return `true`, meaning the reservation
125
+ * stands and the claim is refused. Refusing to hand over an address costs the
126
+ * claimant one attempt at a name; granting one wrongly costs its holder the
127
+ * URL their customers use.
128
+ */ async function lapsedReservationIsStillHeld(reservation) {
129
+ const holderOrgId = typeof (reservation == null ? void 0 : reservation.orgId) === 'string' ? reservation.orgId : null;
130
+ if (!holderOrgId) return true;
131
+ try {
132
+ const holder = await firestore().collection('orgs').doc(holderOrgId).get();
133
+ if (!holder.exists) {
134
+ // The workspace is gone and only the reservation outlived it. Nothing
135
+ // is being taken from anyone.
136
+ return false;
137
+ }
138
+ const ownerUid = holder.get('ownerUid');
139
+ if (typeof ownerUid !== 'string' || !ownerUid) return true;
140
+ const found = await findUserByUidAcrossPools(ownerUid);
141
+ if (!found) return true;
142
+ return found.record.emailVerified === true;
143
+ } catch (error) {
144
+ console.error('[orgs] lapsed reservation check failed', error);
145
+ return true;
146
+ }
147
+ }
148
+ /**
149
+ * Creates an org in one transaction: slug reservation (uniqueness), org
150
+ * doc, owner membership, and the owner's reverse-index entry. Throws
151
+ * `OrgSlugTakenError` when the slug is reserved; slug validity is the
152
+ * caller's job (API routes return 400 with policy copy).
153
+ */ export async function createOrganization(options) {
154
+ const { name, slug, ownerUid, ownerEmail, ownerDisplayName } = options;
155
+ const db = firestore();
156
+ const orgId = createResourceUid();
157
+ // The free-workspace ceiling (AGL-2265). Read OUTSIDE the transaction —
158
+ // it is a platform setting on a 15s cache, not a document this creation
159
+ // races with, and putting it in the read set would make every workspace
160
+ // creation on the platform contend on one document. `ready` rides along so
161
+ // the verdict knows the difference between "staff set no limit" and "we
162
+ // could not read it", and never treats the second as the first.
163
+ const capConfig = options.bypassFreeWorkspaceCap ? null : await readFreeWorkspaceCapConfig();
164
+ await db.runTransaction(async (tx)=>{
165
+ const reservation = await tx.get(db.collection('orgSlugs').doc(slug));
166
+ const held = reservation.exists ? reservation.data() : undefined;
167
+ // Tombstones (renamed-away slugs) are claimable by new orgs (AGL-585),
168
+ // and so is a reservation left by an unverified signup made before
169
+ // AGL-2590 that has since run out — but only once the auth record agrees
170
+ // it was never confirmed.
171
+ if (!isSlugReservationClaimable(held, null) || isSlugReservationLapsed(held) && await lapsedReservationIsStillHeld(held)) {
172
+ throw new OrgSlugTakenError(slug);
173
+ }
174
+ // Last read, first write: the ceiling counts inside this transaction, so
175
+ // a retry recounts, and it writes the per-owner marker that makes two
176
+ // concurrent creates by one account contend. Throws
177
+ // `FreeWorkspaceCapError`, which the API routes turn into a 403 with the
178
+ // numbers in it.
179
+ if (capConfig) {
180
+ await enforceFreeWorkspaceCapInTransaction({
181
+ tx,
182
+ firestore: db,
183
+ uid: ownerUid,
184
+ config: capConfig
185
+ });
186
+ }
187
+ // The plain grant, always (AGL-2590): no caller can reach this with an
188
+ // unproven address any more. `orgSlugs` is world-readable — the console
189
+ // resolves a workspace subdomain client-side from it — so the id is all
190
+ // that goes in.
191
+ tx.set(db.collection('orgSlugs').doc(slug), {
192
+ orgId
193
+ });
194
+ /*
195
+ * THE BILLING DOCUMENT EXISTS FROM BIRTH (AGL-1152).
196
+ *
197
+ * `readOrgBilling` reads `orgs/{id}/billing/stripe` and falls back to the
198
+ * org doc when it is absent — and Firestore BILLS a read for a document
199
+ * that does not exist. An org created without one therefore pays a
200
+ * NOT_FOUND plus the fallback lookup on every read, forever, on the
201
+ * tenant's hot path behind a deliberately short TTL.
202
+ *
203
+ * Measured before this: 14,498 NOT_FOUND reads/day on production, 15% of
204
+ * all Firestore reads, from four orgs that had never had a document. The
205
+ * `--seed-empty` pass in `backfill-org-billing.mjs` repaired those; this is
206
+ * what stops the next org recreating the problem.
207
+ *
208
+ * EMPTY IS THE HONEST VALUE, not a placeholder: a new org has no Stripe
209
+ * relationship, and `readOrgBilling`'s fallback returned `{}` for exactly
210
+ * this case anyway. `writeOrgBilling` merge-sets, so the first real
211
+ * subscription composes with this rather than racing it.
212
+ */ tx.set(db.collection('orgs').doc(orgId).collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID), {});
213
+ tx.set(db.collection('orgs').doc(orgId), {
214
+ name,
215
+ /*
216
+ * The searchable form of `name`, written beside it (AGL-2501).
217
+ *
218
+ * Firestore cannot search a string it has not been given in search
219
+ * form: a prefix range needs the normalized key to ORDER by, and
220
+ * `name` carries case and stray whitespace. Without this the staff
221
+ * organization list can only filter the rows already on screen — ten
222
+ * of them — which stops being a search the moment there are more
223
+ * organizations than a page.
224
+ *
225
+ * Denormalized rather than computed at query time because there is no
226
+ * query-time in Firestore. Every writer of `name` owes this field; the
227
+ * rename in `/api/orgs/settings` is the other one.
228
+ */ nameLower: nameSearchKey(name),
229
+ // Word-prefix tokens, so the staff search can answer "contains a word
230
+ // starting with X" rather than only "starts with X" (AGL-2501).
231
+ nameTokens: nameSearchTokens(name),
232
+ // Reversed, so the list's "ends with" filter is a prefix range like
233
+ // every other string operator Firestore can answer.
234
+ nameReversed: nameSearchReversed(name),
235
+ slug,
236
+ ownerUid,
237
+ // Stamped once and never mutated — `transferOrgOwnership` moves
238
+ // `ownerUid` and deliberately leaves this alone (AGL-2265). It is what
239
+ // stops "hand the workspace to an alt account, create another, take it
240
+ // back" from being a way past the free-workspace ceiling.
241
+ createdByUid: ownerUid,
242
+ hosts: {},
243
+ createdAt: FieldValue.serverTimestamp(),
244
+ updatedAt: FieldValue.serverTimestamp()
245
+ });
246
+ tx.set(db.collection('orgs').doc(orgId).collection('members').doc(ownerUid), {
247
+ role: 'owner',
248
+ allHosts: true,
249
+ email: ownerEmail != null ? ownerEmail : null,
250
+ displayName: ownerDisplayName != null ? ownerDisplayName : null,
251
+ joinedAt: FieldValue.serverTimestamp(),
252
+ /*
253
+ * The rules projection, stamped AT CREATION (AGL-1038).
254
+ *
255
+ * Every other membership write reaches `syncOrgAuthProjections`,
256
+ * which recomputes this for the whole roster. This one does not —
257
+ * it is inside the creating transaction, and nothing runs after it
258
+ * — so a brand-new org's owner had no `scopeTokens` at all and the
259
+ * weekly scope-drift detector reported the org from the day it was
260
+ * made until some later membership change happened to heal it.
261
+ *
262
+ * Computed rather than written as a literal, so it cannot disagree
263
+ * with the projection every other path uses.
264
+ */ scopeTokens: projectMemberScopeTokens({
265
+ role: 'owner',
266
+ allHosts: true
267
+ }),
268
+ /*
269
+ * The permission projection, stamped here for the same reason and
270
+ * with the same consequence if it is missed.
271
+ *
272
+ * No custom role can exist in an org being created, so the resolver
273
+ * is handed an explicit null and returns the owner's role defaults —
274
+ * which is also what the rules fall back to for a member carrying no
275
+ * map, so a failure to stamp this is invisible rather than a lockout.
276
+ * It is written anyway: an unstamped owner is a row the drift check
277
+ * has to keep explaining.
278
+ */ resolvedPermissions: projectMemberResolvedPermissions({
279
+ role: 'owner',
280
+ allHosts: true
281
+ }, null)
282
+ });
283
+ tx.set(db.collection('users').doc(ownerUid).collection('orgs').doc(orgId), // The owner reaches every site by definition (AGL-1032).
284
+ {
285
+ role: 'owner',
286
+ orgName: name,
287
+ slug,
288
+ orgWide: true
289
+ });
290
+ });
291
+ // Make `{slug}.aglyn.com` resolve (AGL-1136). AGL-1135 removed the
292
+ // `*.aglyn.com` wildcard — it served a real sign-in page on every hostname
293
+ // under the domain — so a workspace subdomain now only works if the domain
294
+ // is attached to the project.
295
+ //
296
+ // AFTER the transaction, and AWAITED. It was `void`, on the reasoning that
297
+ // no workspace should fail to be created because a DNS API was slow — right
298
+ // requirement, wrong mechanism (AGL-1136). On a serverless runtime `void`
299
+ // does not mean "in the background", it means "may never run": the instance
300
+ // can be frozen the moment the response is flushed. Confirmed twice on this
301
+ // codebase already, on the Stripe org sync and the profile seed.
302
+ //
303
+ // Awaiting cannot fail org creation, and that property comes from the
304
+ // helper, not from the `void` — `attachWorkspaceDomain` swallows every
305
+ // error and returns an outcome rather than throwing. The cost is one HTTP
306
+ // round trip on an operation that already runs a Firestore transaction; the
307
+ // alternative was advertising a workspace URL that 404s.
308
+ //
309
+ // `erase.ts` already awaits the matching detach, which is what made the
310
+ // asymmetry worth looking at.
311
+ await attachWorkspaceDomain(slug);
312
+ // The first entry in the workspace's log (AGL-118). Creation is the one
313
+ // category the activity log never covered — it was assembled by adding
314
+ // calls at mutation points in the console UI, and the acts that bring a
315
+ // top-level object into existence happen out here, in provisioning code no
316
+ // UI mutation point ever reaches. The visible symptom was a customer whose
317
+ // page read as though they had never used the product, because their whole
318
+ // session had been creation.
319
+ await logOrgActivity(orgId, {
320
+ uid: ownerUid,
321
+ email: ownerEmail != null ? ownerEmail : null
322
+ }, 'Created the workspace', {
323
+ type: 'org',
324
+ id: orgId,
325
+ name
326
+ });
327
+ return orgId;
328
+ }
329
+ /**
330
+ * The signed-in user's membership in one org, or null. When `orgId` is
331
+ * omitted, resolves the user's first org from the reverse index (the
332
+ * single-org case every pre-org account lands in after backfill).
333
+ */ export async function resolveOrgMembership(uid, orgId) {
334
+ const db = firestore();
335
+ let resolved = orgId != null ? orgId : null;
336
+ if (!resolved) {
337
+ const mine = await db.collection('users').doc(uid).collection('orgs').limit(1).get();
338
+ resolved = mine.empty ? null : mine.docs[0].id;
339
+ }
340
+ if (!resolved) return null;
341
+ const memberSnapshot = await db.collection('orgs').doc(resolved).collection('members').doc(uid).get();
342
+ if (!memberSnapshot.exists) return null;
343
+ return {
344
+ orgId: resolved,
345
+ member: _extends({
346
+ $id: uid
347
+ }, memberSnapshot.data())
348
+ };
349
+ }
350
+ /**
351
+ * The user's org, creating a personal one on first need (signup flows and
352
+ * pre-backfill accounts): name from the display name or email local part,
353
+ * slug generated with numeric-suffix retries on collision.
354
+ */ export async function ensureOrgForUser(uid, profile = {}) {
355
+ var _profile_displayName, _profile_email_split_, _profile_email;
356
+ const existing = await resolveOrgMembership(uid);
357
+ if (existing) return existing;
358
+ const base = ((_profile_displayName = profile.displayName) == null ? void 0 : _profile_displayName.trim()) || ((_profile_email = profile.email) == null ? void 0 : (_profile_email_split_ = _profile_email.split('@')[0]) == null ? void 0 : _profile_email_split_.trim()) || 'workspace';
359
+ const name = base.slice(0, 80);
360
+ let slug = generateOrgSlug(name) || `org-${createResourceUid().slice(0, 8)}`;
361
+ for(let attempt = 0;; attempt += 1){
362
+ try {
363
+ var _profile_email1, _profile_displayName1;
364
+ const orgId = await createOrganization({
365
+ name,
366
+ slug,
367
+ ownerUid: uid,
368
+ ownerEmail: (_profile_email1 = profile.email) != null ? _profile_email1 : null,
369
+ ownerDisplayName: (_profile_displayName1 = profile.displayName) != null ? _profile_displayName1 : null
370
+ });
371
+ const created = await resolveOrgMembership(uid, orgId);
372
+ if (!created) throw new Error('Org membership missing after create');
373
+ // Marked so the caller can count the activation (AGL-2587).
374
+ return _extends({}, created, {
375
+ created: true
376
+ });
377
+ } catch (error) {
378
+ if (!(error instanceof OrgSlugTakenError) || attempt >= 4) throw error;
379
+ slug = `${slug.slice(0, 26)}-${attempt + 2}`;
380
+ if (!isValidOrgSlug(slug)) {
381
+ slug = `org-${createResourceUid().slice(0, 8)}`;
382
+ }
383
+ }
384
+ }
385
+ }
386
+ /**
387
+ * Changes an org's workspace slug (AGL-236): reserves the new slug and
388
+ * updates the org doc in one transaction, leaving the old reservation as
389
+ * a tombstone (`movedTo`) so existing workspace URLs keep resolving —
390
+ * the middleware redirects them. Reverse-index slugs fan out after.
391
+ * Throws `OrgSlugTakenError` only when another org ACTIVELY holds the new
392
+ * slug — tombstones are claimable (AGL-585). Slug validity/authorization
393
+ * are the API route's job.
394
+ */ export async function changeOrgSlug(orgId, newSlug) {
395
+ const db = firestore();
396
+ let previousSlug = null;
397
+ await db.runTransaction(async (tx)=>{
398
+ var _orgSnapshot_get;
399
+ const orgRef = db.collection('orgs').doc(orgId);
400
+ const orgSnapshot = await tx.get(orgRef);
401
+ if (!orgSnapshot.exists) throw new Error(`Unknown org: ${orgId}`);
402
+ previousSlug = (_orgSnapshot_get = orgSnapshot.get('slug')) != null ? _orgSnapshot_get : null;
403
+ if (previousSlug === newSlug) return;
404
+ const reservation = await tx.get(db.collection('orgSlugs').doc(newSlug));
405
+ const held = reservation.exists ? reservation.data() : undefined;
406
+ // Claimable when free, own (moving back), a tombstone another org renamed
407
+ // away from (AGL-585), or an unverified signup's reservation that ran out
408
+ // (AGL-2585) — abandoned slugs are never reserved forever. Only another
409
+ // org's ACTIVE slug blocks the change, and a lapsed reservation whose
410
+ // holder has since verified is still active, which the auth record decides.
411
+ if (!isSlugReservationClaimable(held, orgId) || (held == null ? void 0 : held.orgId) !== orgId && isSlugReservationLapsed(held) && await lapsedReservationIsStillHeld(held)) {
412
+ throw new OrgSlugTakenError(newSlug);
413
+ }
414
+ tx.set(db.collection('orgSlugs').doc(newSlug), {
415
+ orgId
416
+ });
417
+ tx.set(orgRef, {
418
+ slug: newSlug,
419
+ updatedAt: FieldValue.serverTimestamp()
420
+ }, {
421
+ merge: true
422
+ });
423
+ if (previousSlug) {
424
+ tx.set(db.collection('orgSlugs').doc(previousSlug), {
425
+ orgId,
426
+ movedTo: newSlug,
427
+ renamedAt: FieldValue.serverTimestamp()
428
+ });
429
+ }
430
+ });
431
+ // Attach the new subdomain, and deliberately KEEP the old one (AGL-1136).
432
+ // The previous slug's tombstone 308s to the new one, and a redirect can
433
+ // only run on a hostname that still resolves — detaching it here would
434
+ // break the very redirect the tombstone exists to serve.
435
+ // Awaited for the same reason as the create path above (AGL-1136): a
436
+ // `void` here is not a background task, it is a coin flip.
437
+ await attachWorkspaceDomain(newSlug);
438
+ // Reverse index carries the slug for the switcher display.
439
+ const members = await listOrgMembers(orgId);
440
+ const batch = db.batch();
441
+ for (const member of members){
442
+ batch.set(db.collection('users').doc(member.$id).collection('orgs').doc(orgId), {
443
+ slug: newSlug
444
+ }, {
445
+ merge: true
446
+ });
447
+ }
448
+ await batch.commit();
449
+ return {
450
+ previousSlug
451
+ };
452
+ }
453
+ /**
454
+ * Host → org resolution via the server-written `hostIndex` mirror.
455
+ *
456
+ * `React.cache`-deduped PER REQUEST (AGL-1302): one tenant render resolved
457
+ * this hop up to five times — org billing, datasets, plugin installs, realm
458
+ * installs and the publish-schedule executor each re-read the same
459
+ * `hostIndex/{hostId}` doc. Per-request memoization is zero-staleness by
460
+ * construction; outside a React render (route handlers, jest) `cache` is a
461
+ * pass-through, so nothing changes for the console's authz paths.
462
+ */ export const resolveOrgIdForHost = cache(async (hostId)=>{
463
+ var _snapshot_data;
464
+ const snapshot = await firestore().collection('hostIndex').doc(hostId).get();
465
+ const orgId = (_snapshot_data = snapshot.data()) == null ? void 0 : _snapshot_data['orgId'];
466
+ return typeof orgId === 'string' ? orgId : null;
467
+ });
468
+ /**
469
+ * The org doc itself — billing, plan, entitlements and suspension (the
470
+ * shape the legacy tenants/{uid} doc carried; orgs are the only billing
471
+ * source since AGL-238). Null when the doc is missing.
472
+ */ /**
473
+ * `React.cache`-deduped per request like {@link resolveOrgIdForHost}
474
+ * (AGL-1302). NOTE: within one render every caller receives the SAME object
475
+ * — treat it as read-only, as every current caller already does.
476
+ */ export const getOrgDoc = cache(async (orgId)=>{
477
+ const snapshot = await firestore().collection('orgs').doc(orgId).get();
478
+ return snapshot.exists ? _extends({
479
+ $id: snapshot.id
480
+ }, snapshot.data()) : null;
481
+ });
482
+ /**
483
+ * Billing/entitlement source for a host (AGL-238): the owning org's doc
484
+ * via the hostIndex mirror. Null for unindexed hosts — callers treat that
485
+ * as the pre-billing fail-open (every feature on), the same contract the
486
+ * legacy tenants/{uid} read had.
487
+ */ export async function getOrgForHost(hostId) {
488
+ const orgId = await resolveOrgIdForHost(hostId);
489
+ if (!orgId) return null;
490
+ const org = await getOrgDoc(orgId);
491
+ return org ? {
492
+ orgId,
493
+ org
494
+ } : null;
495
+ }
496
+ /**
497
+ * The raw host doc, `React.cache`-deduped per request like
498
+ * {@link resolveOrgIdForHost}. Null when missing. Added for AGL-1506 so a
499
+ * dispatcher that already pays this read for the plugin deny-list can also
500
+ * feed the host's `suspendedAt` family to the lockdown verdict without a
501
+ * second get. Same read-only contract as {@link getOrgDoc}.
502
+ */ export const getHostDocAdmin = cache(async (hostId)=>{
503
+ const snapshot = await firestore().collection('hosts').doc(hostId).get();
504
+ return snapshot.exists ? snapshot.data() : null;
505
+ });
506
+ /**
507
+ * The host's per-site plugin deny-list (AGL-1014), for API dispatch and any
508
+ * other server consumer of `resolveHostEnabledPlugins`. Rides
509
+ * {@link getHostDocAdmin}'s request-cached read. Fail-open to [] — an absent
510
+ * host doc or field means "nothing disabled here", never a lockout.
511
+ */ export const getHostDisabledPlugins = cache(async (hostId)=>{
512
+ var _this;
513
+ const disabled = (_this = await getHostDocAdmin(hostId)) == null ? void 0 : _this['disabledPlugins'];
514
+ return Array.isArray(disabled) ? disabled.map(String) : [];
515
+ });
516
+ /**
517
+ * Billing/entitlement source for a user without host context (account-
518
+ * level APIs): the explicit workspace org when given, else the first org
519
+ * from the reverse index. Null for accounts with no org yet.
520
+ */ export async function getOrgForUser(uid, orgId) {
521
+ const membership = await resolveOrgMembership(uid, orgId);
522
+ if (!membership) return null;
523
+ const org = await getOrgDoc(membership.orgId);
524
+ return org ? {
525
+ orgId: membership.orgId,
526
+ org,
527
+ member: membership.member
528
+ } : null;
529
+ }
530
+ export async function orgDataCollectionForHost(hostId, name) {
531
+ const orgId = await resolveOrgIdForHost(hostId);
532
+ if (!orgId) {
533
+ throw new Error(`Host ${hostId} has no org — cannot resolve ${name}`);
534
+ }
535
+ return firestore().collection('orgs').doc(orgId).collection(name);
536
+ }
537
+ /**
538
+ * Narrows an org-scoped collection to what ONE host may see (AGL-1039).
539
+ *
540
+ * The Admin SDK does not evaluate Firestore rules, so AGL-1041's
541
+ * `visibleTo.hasAny(...)` protects the console and nothing else — every
542
+ * server read has to filter for itself or a client site can render another
543
+ * client's data. Use this instead of the bare collection ref anywhere a
544
+ * request is being served in the context of a single host.
545
+ *
546
+ * Only the ORG path is filtered — but no longer because of the legacy
547
+ * `hosts/{hostId}/…` fallback, which AGL-1050 removed on both the server
548
+ * (above) and the client. What survives it is the reason stated at the
549
+ * check itself: callers may hand this helper a ref they built themselves,
550
+ * and a host-library ref must never be filtered, since its docs carry no
551
+ * `visibleTo` and the filter would match nothing and blank the site.
552
+ */ export function scopedToHost(ref, hostId) {
553
+ var _ref_parent_parent, _ref_parent;
554
+ // The org-path check is retained even though AGL-1050 removed the host
555
+ // fallback: this helper is also handed refs by callers that build their
556
+ // own paths, and a host-library ref must never be filtered — its docs
557
+ // carry no `visibleTo`, so the filter would match nothing.
558
+ const orgScoped = ((_ref_parent = ref.parent) == null ? void 0 : (_ref_parent_parent = _ref_parent.parent) == null ? void 0 : _ref_parent_parent.id) === 'orgs';
559
+ if (!orgScoped) return ref;
560
+ return ref.where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId));
561
+ }
562
+ /**
563
+ * `orgDataCollectionForHost` + `scopedToHost` in one call — the form every
564
+ * host-context read should use. Returns the collection ref too, for the
565
+ * writes and `doc()` lookups a Query cannot express.
566
+ */ export async function orgDataQueryForHost(hostId, name) {
567
+ const ref = await orgDataCollectionForHost(hostId, name);
568
+ return {
569
+ ref,
570
+ query: scopedToHost(ref, hostId)
571
+ };
572
+ }
573
+ /**
574
+ * Server-side permission check (AGL-243): the member's org-role defaults
575
+ * refined by their custom role doc (one read, only when assigned). API
576
+ * routes call this before privileged mutations.
577
+ */ /**
578
+ * The member's FULL granular permission set, custom role and per-member
579
+ * overrides applied (AGL-2350).
580
+ *
581
+ * `memberHasOrgPermission` below is the single-permission form and now
582
+ * delegates here, so the two cannot answer differently. Split out because
583
+ * `resolveOrgPermissions` in `libs/tenant/runtime` needs the whole set to
584
+ * project onto the legacy flag map that the marketplace install and publish
585
+ * gates read — it previously derived those flags from the built-in role tier
586
+ * alone, which silently ignored both refinements.
587
+ *
588
+ * One conditional read, only when a custom role is actually assigned. A
589
+ * dangling `roleId` resolves to `null` and falls back to the role defaults
590
+ * rather than denying, matching what the console hook does with the same
591
+ * dangling id — a deleted role must not lock a member out of surfaces their
592
+ * base role allows.
593
+ */ export async function resolveMemberOrgPermissions(orgId, member) {
594
+ let customRole = null;
595
+ if (member == null ? void 0 : member.roleId) {
596
+ const snapshot = await firestore().collection('orgs').doc(orgId).collection('roles').doc(member.roleId).get();
597
+ customRole = snapshot.exists ? snapshot.data() : null;
598
+ }
599
+ return resolveOrgPermissions(member, customRole);
600
+ }
601
+ export async function memberHasOrgPermission(orgId, member, permission) {
602
+ if (!member) return false;
603
+ return (await resolveMemberOrgPermissions(orgId, member))[permission];
604
+ }
605
+ /**
606
+ * May this member hold a catalog permission on this site (AGL-2927,
607
+ * AGL-2984)?
608
+ *
609
+ * The one resolver a door calls for a key a plugin declared with host-role
610
+ * defaults, so the two membership axes cannot be answered differently by two
611
+ * routes. An org-wide member is decided by the org catalog through
612
+ * `resolveMemberOrgPermissions` — custom role and overrides applied, one
613
+ * conditional read. A site collaborator is decided by the host role they hold
614
+ * on the site the request NAMES, refined by the per-site toggle on their
615
+ * member document; a collaborator whose request names no site is refused,
616
+ * because there is no host role to read a default from and omitting the site
617
+ * must not be a way around the toggle.
618
+ *
619
+ * `hostId` is whatever the body carried, trimmed by the caller or not — an
620
+ * empty string and `undefined` both mean "no site named".
621
+ */ export async function memberHasPermissionOnHost(orgId, hostId, member, permission) {
622
+ var _ref;
623
+ var _resolveCollaboratorHostPermissions;
624
+ if (!member) return false;
625
+ if (isOrgWideMember(member)) {
626
+ return (await resolveMemberOrgPermissions(orgId, member))[permission] === true;
627
+ }
628
+ const site = typeof hostId === 'string' ? hostId.trim() : '';
629
+ return (_ref = (_resolveCollaboratorHostPermissions = resolveCollaboratorHostPermissions(member, site)) == null ? void 0 : _resolveCollaboratorHostPermissions[permission]) != null ? _ref : false;
630
+ }
631
+ /**
632
+ * The 403 a door sends when `memberHasPermissionOnHost` says no: the same
633
+ * customer-safe shape the doors' other refusals use — one sentence, a
634
+ * `reason` a client can branch on — naming the permission by its catalog
635
+ * label so the reader can find it on the Team page, and who to ask.
636
+ */ export function permissionRefusal(permission) {
637
+ return Response.json({
638
+ error: `Your role does not include "${orgPermissionLabel(permission)}" — ask an organization admin`,
639
+ reason: 'permission',
640
+ permission
641
+ }, {
642
+ status: 403
643
+ });
644
+ }
645
+ /**
646
+ * An org-wide member's verdict for every key PLUGINS declared into the
647
+ * catalog, read fresh (AGL-2929, AGL-2984): what `memberHasPermissionOnHost`
648
+ * answers for them with no site named, as a whole map. `null` for a uid with
649
+ * no member document and for a site collaborator, whose per-site keys are
650
+ * decided per site by `setHostPermissions` and have no org-level verdict to
651
+ * compare. Read on either side of a membership write, it is how the members
652
+ * route tells which declared keys the write moved.
653
+ */ export async function resolveMemberPluginPermissionsOnOrg(orgId, uid) {
654
+ const snapshot = await firestore().collection('orgs').doc(orgId).collection('members').doc(uid).get();
655
+ if (!snapshot.exists) return null;
656
+ const member = _extends({
657
+ $id: uid
658
+ }, snapshot.data());
659
+ if (!isOrgWideMember(member)) return null;
660
+ const granted = await resolveMemberOrgPermissions(orgId, member);
661
+ return Object.fromEntries(pluginOrgPermissionKeys().map((key)=>[
662
+ key,
663
+ granted[key] === true
664
+ ]));
665
+ }
666
+ /**
667
+ * Set a collaborator's per-site toggles (AGL-2927, AGL-2984) and re-project.
668
+ *
669
+ * A merge on the nested map, so the member's other sites and every other
670
+ * field stay untouched; keys no plugin declared as per-site are dropped
671
+ * rather than stored, and a non-boolean is ignored rather than coerced.
672
+ * Re-projection is scoped to the one host whose `memberPermissions` changed.
673
+ *
674
+ * The verdict before the write comes back beside the one after it, because
675
+ * the caller records one activity row per key that moved (AGL-2929) and a
676
+ * toggle set to the value it already had is not a change.
677
+ */ export async function setHostPermissions(options) {
678
+ var _resolveCollaboratorHostPermissions;
679
+ const { orgId, uid, hostId } = options;
680
+ const keys = hostPermissionKeys();
681
+ const accepted = {};
682
+ for (const key of keys){
683
+ const value = options.permissions[key];
684
+ if (typeof value === 'boolean') accepted[key] = value;
685
+ }
686
+ const ref = firestore().collection('orgs').doc(orgId).collection('members').doc(uid);
687
+ const stored = _extends({
688
+ $id: uid
689
+ }, (await ref.get()).data());
690
+ const before = resolveCollaboratorHostPermissions(stored, hostId);
691
+ await ref.set({
692
+ hostPermissions: {
693
+ [hostId]: accepted
694
+ }
695
+ }, {
696
+ merge: true
697
+ });
698
+ await syncOrgAuthProjections(orgId, hostId);
699
+ const member = _extends({
700
+ $id: uid
701
+ }, (await ref.get()).data());
702
+ return {
703
+ before,
704
+ after: (_resolveCollaboratorHostPermissions = resolveCollaboratorHostPermissions(member, hostId)) != null ? _resolveCollaboratorHostPermissions : Object.fromEntries(keys.map((key)=>[
705
+ key,
706
+ false
707
+ ]))
708
+ };
709
+ }
710
+ export async function listOrgMembers(orgId) {
711
+ const snapshot = await firestore().collection('orgs').doc(orgId).collection('members').get();
712
+ return snapshot.docs.map((doc)=>_extends({
713
+ $id: doc.id
714
+ }, doc.data()));
715
+ }
716
+ /**
717
+ * The custom role documents this roster actually references, read once each.
718
+ *
719
+ * A roster of hundreds shares a handful of roles, so this is bounded by the
720
+ * number of DISTINCT `roleId`s and not by the member count.
721
+ *
722
+ * A role id that resolves to nothing is left ABSENT rather than recorded as
723
+ * an empty role. The two happen to reach the same verdict today —
724
+ * `resolveOrgPermissions` skips a key whose value is not a boolean, so an
725
+ * empty map changes nothing — but they are different claims, and only one of
726
+ * them is true: a dangling id means the lookup MISSED, not that a role
727
+ * granting nothing was found. Recording the miss honestly is what keeps the
728
+ * fallback correct if that resolver ever treats an empty map as a revocation,
729
+ * which is what its own type comment already says it does.
730
+ */ async function loadOrgCustomRoles(orgId, members) {
731
+ const roleIds = [
732
+ ...new Set(members.map((member)=>member.roleId).filter((roleId)=>typeof roleId === 'string' && !!roleId))
733
+ ];
734
+ const rolesRef = firestore().collection('orgs').doc(orgId).collection('roles');
735
+ const found = new Map();
736
+ await Promise.all(roleIds.map(async (roleId)=>{
737
+ const snapshot = await rolesRef.doc(roleId).get();
738
+ if (snapshot.exists) {
739
+ found.set(roleId, snapshot.data());
740
+ }
741
+ }));
742
+ return found;
743
+ }
744
+ /**
745
+ * How the projections are written: REPLACED, not merged (AGL-2985).
746
+ *
747
+ * `{ merge: true }` merges a map key by key and keeps every key the new map
748
+ * does not name, so a projection written that way could add a member and
749
+ * never take one away. A collaborator whose site access was revoked, or a
750
+ * member removed from the organization, drops out of the recomputed
751
+ * `memberRoles` — and kept their old key on the host document, which is the
752
+ * one thing the Firestore rules read to let a person edit and publish a
753
+ * site. `memberPermissions` kept their AI verdict the same way, and the
754
+ * plugin half of a member's `resolvedPermissions` would keep a withdrawn
755
+ * grant `true` (AGL-2974).
756
+ *
757
+ * `mergeFields` overwrites exactly the listed fields whole and leaves the
758
+ * rest of the document untouched, which is all the merge was for. Every
759
+ * listed map is recomputed from the complete roster on every write, so
760
+ * replacing it loses nothing a merge would have kept correctly.
761
+ */ const HOST_PROJECTION_WRITE = {
762
+ mergeFields: [
763
+ 'orgId',
764
+ 'memberRoles',
765
+ 'memberPermissions',
766
+ 'updatedAt'
767
+ ]
768
+ };
769
+ const MEMBER_PROJECTION_WRITE = {
770
+ mergeFields: [
771
+ 'scopeTokens',
772
+ 'resolvedPermissions'
773
+ ]
774
+ };
775
+ /**
776
+ * Recomputes the denormalized authorization projections after a membership
777
+ * change: `memberRoles` and `memberPermissions` on every host the org owns
778
+ * (or one host when given), and `scopeTokens` + `resolvedPermissions` on
779
+ * every member doc.
780
+ *
781
+ * The rules resolve a request from these reads — the host doc for host
782
+ * content (docs/MULTI_TENANT_FIRESTORE.md §5), the member doc for scoped
783
+ * org resources (AGL-1038) — so this is what makes a membership effective.
784
+ * They live here, in one writer called by every mutation below, because a
785
+ * grant path that updates one projection and forgets another silently over-
786
+ * or under-grants.
787
+ *
788
+ * Everything is recomputed for the whole roster rather than the changed
789
+ * member: the roster is already loaded for `memberRoles`, and a full pass
790
+ * self-heals rows that an earlier partial failure left stale.
791
+ *
792
+ * ## `resolvedPermissions`, and why the rules need it denormalized
793
+ *
794
+ * Security rules cannot resolve a custom role. `member.roleId` points at
795
+ * `orgs/{orgId}/roles/{roleId}`, and reproducing the three-layer precedence
796
+ * (per-member beats custom role beats role default) in CEL takes a second
797
+ * cross-document get() plus a correct handling of a dangling id — where a
798
+ * naive version over-denies and locks out paying customers. So the rules read
799
+ * the ANSWER instead of the inputs, which is the same trade `scopeTokens`
800
+ * already makes for a reason the rules language shares: it has no `.map()`
801
+ * either.
802
+ *
803
+ * The map is `projectMemberResolvedPermissions`: `resolveOrgPermissions`'
804
+ * own catalog verdict, plus the plugin-declared keys a custom role or an
805
+ * override set explicitly (AGL-2974), so the rules and every
806
+ * server route are reading one resolver's verdict rather than two
807
+ * implementations of it.
808
+ *
809
+ * ONE READ PER DISTINCT ROLE, not per member: an org assigns a handful of
810
+ * custom roles across a roster that can run to hundreds, and resolving each
811
+ * member independently would re-read the same few documents once each.
812
+ */ export async function syncOrgAuthProjections(orgId, hostId) {
813
+ var _ref;
814
+ var _data;
815
+ const db = firestore();
816
+ const orgRef = db.collection('orgs').doc(orgId);
817
+ const members = await listOrgMembers(orgId);
818
+ const customRoles = await loadOrgCustomRoles(orgId, members);
819
+ const hostIds = hostId ? [
820
+ hostId
821
+ ] : Object.keys((_ref = (_data = (await orgRef.get()).data()) == null ? void 0 : _data.hosts) != null ? _ref : {});
822
+ const writes = [
823
+ ...hostIds.map((id)=>[
824
+ db.collection('hosts').doc(id),
825
+ {
826
+ orgId,
827
+ memberRoles: projectHostMemberRoles(members, id),
828
+ // Each member's per-site permission verdicts ON this site
829
+ // (AGL-2927), beside the role they derive from, so a reader of
830
+ // the host document has both answers from the one get it
831
+ // already does.
832
+ memberPermissions: projectHostMemberPermissions(members, id, customRoles),
833
+ updatedAt: FieldValue.serverTimestamp()
834
+ },
835
+ HOST_PROJECTION_WRITE
836
+ ]),
837
+ ...members.map((member)=>{
838
+ var _customRoles_get;
839
+ return [
840
+ orgRef.collection('members').doc(member.$id),
841
+ {
842
+ scopeTokens: projectMemberScopeTokens(member),
843
+ resolvedPermissions: projectMemberResolvedPermissions(member, // `?? null`, never `?? undefined`: a member whose `roleId`
844
+ // points at a DELETED role must resolve to their role
845
+ // defaults, which is what the resolver does with an explicit
846
+ // null and what every server route already does with the same
847
+ // dangling id. Leaving it undefined would be the same value,
848
+ // but the null says the lookup happened and missed.
849
+ member.roleId ? (_customRoles_get = customRoles.get(member.roleId)) != null ? _customRoles_get : null : null)
850
+ },
851
+ MEMBER_PROJECTION_WRITE
852
+ ];
853
+ })
854
+ ];
855
+ // Hosts alone rarely approached the 500-write batch cap; hosts plus the
856
+ // whole roster can, so commit in chunks rather than throwing on big orgs.
857
+ for(let i = 0; i < writes.length; i += FIRESTORE_BATCH_LIMIT){
858
+ const batch = db.batch();
859
+ for (const [ref, data, options] of writes.slice(i, i + FIRESTORE_BATCH_LIMIT)){
860
+ batch.set(ref, data, options);
861
+ }
862
+ await batch.commit();
863
+ }
864
+ }
865
+ /**
866
+ * @deprecated Renamed to `syncOrgAuthProjections` (AGL-1038) now that it
867
+ * also writes member `scopeTokens`. Kept as an alias for out-of-tree
868
+ * callers; delete once none remain.
869
+ */ export const syncHostMemberRoles = syncOrgAuthProjections;
870
+ /**
871
+ * Org-level counterpart to the host activity log (AGL-118): fire-and-
872
+ * forget append to `orgs/{orgId}/activity` from the org API routes. Never
873
+ * throws — an audit miss must not break the mutation that triggered it.
874
+ * Admin-SDK-only, like the rest of this file; the rules deny client writes.
875
+ */ export async function logOrgActivity(orgId, /**
876
+ * `uid` is nullable because some org events HAVE no actor (AGL-118). Stripe
877
+ * cancels a subscription after a month of failed retries with nobody
878
+ * present, and the honest record of that says so. Naming the last person
879
+ * who touched billing instead would put a real name on an act nobody
880
+ * performed — and `actorId` is a filterable field, so the invented
881
+ * attribution would then show up under that person when somebody asks what
882
+ * they have done.
883
+ */ actor, action, target) {
884
+ var _actor_uid, _actor_email;
885
+ await firestore().collection('orgs').doc(orgId).collection('activity').add({
886
+ actorId: (_actor_uid = actor.uid) != null ? _actor_uid : null,
887
+ actorEmail: (_actor_email = actor.email) != null ? _actor_email : null,
888
+ action,
889
+ target: _extends({
890
+ type: target.type
891
+ }, target.id ? {
892
+ id: target.id
893
+ } : {}, target.name ? {
894
+ name: target.name
895
+ } : {}, target.versionId ? {
896
+ versionId: target.versionId
897
+ } : {}),
898
+ createdAt: FieldValue.serverTimestamp()
899
+ }).catch(()=>undefined);
900
+ }
901
+ /**
902
+ * Append to `hosts/{hostId}/activity` with the ADMIN SDK (AGL-118).
903
+ *
904
+ * The host log's twin of {@link logOrgActivity}, and the beginning of the
905
+ * migration off the browser. Every entry in this collection has been written
906
+ * by the client since the log existed, which makes it an audit trail its
907
+ * subject can decline to write: three template surfaces created screens,
908
+ * layouts and components while calling no logger at all, and nothing noticed
909
+ * for months because a log that is missing an entry looks exactly like a
910
+ * person who did nothing.
911
+ *
912
+ * A route that already authenticated the caller has the two things the client
913
+ * cannot be trusted for — a VERIFIED uid, and the certainty that the write it
914
+ * is recording actually happened, because it performed it. So an entry from
915
+ * here is worth more than the one it replaces, not merely more reliable.
916
+ *
917
+ * Never throws, for the reason the client logger never throws: an audit miss
918
+ * must not turn a successful create into a failed request. It is `await`ed
919
+ * rather than floated because a serverless response ending cancels in-flight
920
+ * work, which would make the drop the common case rather than the rare one.
921
+ */ export async function logHostActivity(hostId, actor, action, target) {
922
+ var _actor_email;
923
+ await firestore().collection('hosts').doc(hostId).collection('activity').add(_extends({
924
+ actorId: actor.uid,
925
+ actorEmail: (_actor_email = actor.email) != null ? _actor_email : null
926
+ }, actor.apiKeyName ? {
927
+ apiKeyName: actor.apiKeyName
928
+ } : {}, {
929
+ action,
930
+ target: _extends({
931
+ type: target.type
932
+ }, target.id ? {
933
+ id: target.id
934
+ } : {}, target.name ? {
935
+ name: target.name
936
+ } : {}, target.versionId ? {
937
+ versionId: target.versionId
938
+ } : {}),
939
+ createdAt: FieldValue.serverTimestamp()
940
+ })).catch(()=>undefined);
941
+ }
942
+ /**
943
+ * A collaborator seat refusal, raised from INSIDE the grant transaction
944
+ * (AGL-2068).
945
+ *
946
+ * An exception rather than a return value because it has to travel out of
947
+ * `upsertOrgMember` / `grantHostAccess`, whose contract is "make it so" and
948
+ * which four routes already call as a bare `await`. Returning a verdict would
949
+ * have let every existing call site ignore it silently, which is the shape of
950
+ * the bug being fixed.
951
+ */ export class CollaboratorSeatLimitError extends Error {
952
+ constructor(hostId, quota){
953
+ var _quota_retainedOverCap;
954
+ super(collaboratorSeatMessage(quota));
955
+ this.name = 'CollaboratorSeatLimitError';
956
+ this.hostId = hostId;
957
+ this.limit = quota.limit;
958
+ this.upgradeRequired = quota.upgradeRequired;
959
+ this.addonPriceUsd = quota.addonPriceUsd;
960
+ this.retainedOverCap = Math.max(0, (_quota_retainedOverCap = quota.retainedOverCap) != null ? _quota_retainedOverCap : 0);
961
+ }
962
+ }
963
+ /**
964
+ * The two refusal strings, verbatim from `/api/hosts/members` where they have
965
+ * always lived. Kept byte-identical on purpose: this is now the ONE place
966
+ * they are produced, and any client or spec matching "Collaborator limit
967
+ * reached" must keep matching.
968
+ */ function collaboratorSeatMessage(quota) {
969
+ return quota.upgradeRequired ? `Collaborator limit reached (${quota.limit}) — upgrade ` + 'your plan to add more collaborators' : `Collaborator seats full (${quota.limit}) — add seats for ` + `$${quota.addonPriceUsd}/mo each from Billing`;
970
+ }
971
+ /**
972
+ * Everyone who could be holding a collaborator seat in this org: the whole
973
+ * roster plus every un-accepted invite (AGL-2068).
974
+ *
975
+ * Both collections in full, rather than a `where('hostAccess.X','!=',null)`:
976
+ * the predicate that decides a seat is `isOrgWideMember`, which reads three
977
+ * fields and treats an ABSENT `allHosts` as org-wide. Firestore cannot
978
+ * express "field absent" in a filter, so a query-side count gets the legacy
979
+ * rows wrong in the direction that over-charges. These collections are
980
+ * bounded by the very caps being enforced, so reading them whole is cheap and
981
+ * — inside a transaction — is exactly the lock that serialises concurrent
982
+ * grants.
983
+ */ async function readSeatEntries(orgRef, read) {
984
+ const [members, invites] = await Promise.all([
985
+ read(orgRef.collection('members')),
986
+ read(orgRef.collection('invites').where('acceptedAt', '==', null))
987
+ ]);
988
+ return [
989
+ // The uid is the DOCUMENT ID on the roster and is not a field, so it has
990
+ // to be put back or every legacy row without a mirrored email identifies
991
+ // nobody and silently stops consuming its seat.
992
+ ...members.docs.map((doc)=>_extends({
993
+ uid: doc.id
994
+ }, doc.data())),
995
+ ...invites.docs.map((doc)=>doc.data())
996
+ ];
997
+ }
998
+ /**
999
+ * The hard cap itself, evaluated against the POST-state and inside the same
1000
+ * transaction that performs the grant (AGL-2068).
1001
+ *
1002
+ * A create-time quota that reads, decides, and then writes is not a cap —
1003
+ * this repo has now relearned that three times in one day (AGL-1390 laundering
1004
+ * a count, AGL-2057 the assist cap, AGL-2063 the site limit): N concurrent
1005
+ * requests all read the same pre-count, all pass, and all land. Doing the
1006
+ * read through the transaction is what fixes it. Firestore tracks the read
1007
+ * SET, so a second grant that read the same roster cannot commit — it retries,
1008
+ * re-reads a roster that now holds the first grant, and refuses.
1009
+ *
1010
+ * Only NEWLY granted hosts are charged. Changing an existing collaborator's
1011
+ * role on a site they already reach re-writes the same seat, and refusing that
1012
+ * would strand an over-limit org unable to even demote its way back.
1013
+ *
1014
+ * THE CAP IS PER SITE AND SO IS THE QUESTION (AGL-2439). This calls
1015
+ * `checkHostCollaboratorQuota(org, hostId, used)` and not
1016
+ * `checkSeatQuota(org, 'members', used)`: since AGL-2439 the purchased
1017
+ * quantity is an org-level POOL and the latter deliberately answers the
1018
+ * PLAN's cap with no pool in it. Passing the plan cap here would refuse a
1019
+ * site the seats the org bought and assigned to it.
1020
+ *
1021
+ * THE GRANDFATHER LIVES HERE, in what this function does NOT do. It runs on
1022
+ * the GRANT path only — `newlyScopedHosts` is empty for an existing seat — so
1023
+ * a site already above its corrected cap keeps every collaborator it has and
1024
+ * is merely refused the next one. There is no sweep, no reconciliation and no
1025
+ * revocation anywhere in this file, and none may be added: the cap binds
1026
+ * ALLOCATION, never ACCESS. `quota.retainedOverCap` is how many seats a site
1027
+ * is over by, carried on the refusal so the console can say it out loud
1028
+ * rather than leaving the customer to infer it from a rejected click.
1029
+ */ async function assertCollaboratorSeats(options) {
1030
+ const { orgRef, org, hostIds, self, read } = options;
1031
+ if (!hostIds.length) return;
1032
+ const entries = await readSeatEntries(orgRef, read);
1033
+ for (const hostId of hostIds){
1034
+ const used = countCollaboratorSeats(entries, hostId, self);
1035
+ const quota = checkHostCollaboratorQuota(org, hostId, used);
1036
+ if (!quota.allowed) throw new CollaboratorSeatLimitError(hostId, quota);
1037
+ }
1038
+ }
1039
+ /**
1040
+ * Which hosts a membership is about to reach for the FIRST time as a scoped
1041
+ * collaborator — the set the seat cap is charged for.
1042
+ *
1043
+ * Empty when the resulting membership is org-wide: a manager already reaches
1044
+ * every host and pays for it with a manager seat.
1045
+ */ function newlyScopedHosts(options) {
1046
+ var _ref;
1047
+ const { role, allHosts, hostAccess, existing } = options;
1048
+ if (isOrgWideMember({
1049
+ role,
1050
+ allHosts,
1051
+ hostAccess
1052
+ })) {
1053
+ return [];
1054
+ }
1055
+ const prior = (_ref = existing == null ? void 0 : existing.hostAccess) != null ? _ref : {};
1056
+ return Object.keys(hostAccess).filter((hostId)=>!prior[hostId]);
1057
+ }
1058
+ /**
1059
+ * Turn a seat refusal into the 403 the four admitting routes return, or null
1060
+ * when the error is something else and must keep propagating to the 500.
1061
+ *
1062
+ * Lives here beside `emailUnverifiedResponse` and `lockdownRefusal` so a
1063
+ * route's catch block is one line and cannot accidentally mask a real fault.
1064
+ */ export function collaboratorSeatRefusalResponse(error) {
1065
+ if (!(error instanceof CollaboratorSeatLimitError)) return null;
1066
+ return Response.json({
1067
+ error: error.message,
1068
+ code: 'collaborator_seat_limit',
1069
+ limit: error.limit,
1070
+ upgradeRequired: error.upgradeRequired,
1071
+ // AGL-2439: how many seats this site is over by. NOBODY was removed —
1072
+ // the client renders this as retention, not as a loss.
1073
+ retainedOverCap: error.retainedOverCap
1074
+ }, {
1075
+ status: 403
1076
+ });
1077
+ }
1078
+ /**
1079
+ * The same cap, asked BEFORE anything is written (AGL-2068).
1080
+ *
1081
+ * Not the enforcement — the transaction inside the grant is. This exists so
1082
+ * the two doors that only ever create an INVITE (`/api/hosts/members` for an
1083
+ * address with no account yet, and `/api/orgs/invites` create) refuse at the
1084
+ * point the admin is looking at, rather than mailing someone a link that will
1085
+ * be refused when they click it. A race here over-reserves invites; it cannot
1086
+ * over-grant access, because access is only ever granted through the
1087
+ * transactional path.
1088
+ */ export async function collaboratorSeatRefusal(options) {
1089
+ const { orgId, org, hostIds, self } = options;
1090
+ if (!hostIds.length) return null;
1091
+ try {
1092
+ await assertCollaboratorSeats({
1093
+ orgRef: firestore().collection('orgs').doc(orgId),
1094
+ org,
1095
+ hostIds,
1096
+ self: self != null ? self : {},
1097
+ read: (query)=>query.get()
1098
+ });
1099
+ } catch (error) {
1100
+ const refusal = collaboratorSeatRefusalResponse(error);
1101
+ if (refusal) return refusal;
1102
+ throw error;
1103
+ }
1104
+ return null;
1105
+ }
1106
+ /**
1107
+ * The refusal string, taken from `/api/orgs/members`.
1108
+ *
1109
+ * The four doors each phrased this differently — "upgrade your plan to invite
1110
+ * more members", "to add more members", "This organization is out of team
1111
+ * seats", "This workspace has used all N of its team seats" — which is what a
1112
+ * gate copied four times produces. One wording now, from the one place the
1113
+ * refusal is built. Nothing matches these strings but a human, so the
1114
+ * consolidation costs no caller.
1115
+ */ function managerSeatMessage(quota) {
1116
+ return quota.upgradeRequired ? `Team seat limit reached (${quota.limit}) — upgrade your ` + 'plan to add more members' : `Team seats full (${quota.limit}) — add seats for ` + `$${quota.addonPriceUsd}/mo each from Billing`;
1117
+ }
1118
+ /**
1119
+ * A manager seat refused, thrown rather than returned, for the reason
1120
+ * {@link CollaboratorSeatLimitError} is thrown: it has to travel out of
1121
+ * `upsertOrgMember`, whose contract is "make it so" and which three routes
1122
+ * already call as a bare `await`. A verdict would be silently discarded by
1123
+ * every one of them, which is the shape of the bug being fixed.
1124
+ */ export class ManagerSeatLimitError extends Error {
1125
+ constructor(quota){
1126
+ var _quota_retainedOverCap;
1127
+ super(managerSeatMessage(quota));
1128
+ this.name = 'ManagerSeatLimitError';
1129
+ this.limit = quota.limit;
1130
+ this.upgradeRequired = quota.upgradeRequired;
1131
+ this.addonPriceUsd = quota.addonPriceUsd;
1132
+ this.retainedOverCap = Math.max(0, (_quota_retainedOverCap = quota.retainedOverCap) != null ? _quota_retainedOverCap : 0);
1133
+ }
1134
+ }
1135
+ /**
1136
+ * The manager cap, evaluated against the POST-state and inside the same
1137
+ * transaction that performs the grant (AGL-2068, on the manager key).
1138
+ *
1139
+ * The collaborator cap above learned this the hard way and this is the same
1140
+ * defect one key over: all four doors that admit a manager — invite create,
1141
+ * invite accept, direct member add and SSO-JIT — read the roster, decided,
1142
+ * and then wrote, with nothing between the read and the write. N concurrent
1143
+ * accepts all measured against the same roster, all passed, and all landed.
1144
+ * Reading THROUGH the transaction is the fix: Firestore tracks the read set,
1145
+ * so a second grant that measured the same roster cannot commit — it retries,
1146
+ * re-reads a roster that now holds the first, and refuses.
1147
+ *
1148
+ * PENDING INVITES COUNT, AT EVERY DOOR. Only invite-create counted them
1149
+ * before, so the cap was enforced against a different population depending on
1150
+ * which door was used — and the doors that ignored them are the ones that
1151
+ * actually grant access. An invite reserves the seat it will become, and a
1152
+ * cap that only bites on acceptance is walked past by mailing N invitations
1153
+ * first. `readSeatEntries` is shared with the collaborator gate precisely so
1154
+ * the two populations cannot drift apart again.
1155
+ *
1156
+ * `checkSeatQuota(org, 'managers', used)` and NOT the per-host collaborator
1157
+ * quota: `managersPerOrg` really is org-level, so purchased add-ons raise it
1158
+ * (AGL-2439 removed that only for the per-site `members` key).
1159
+ *
1160
+ * THE GRANDFATHER LIVES HERE, in what this does NOT do. It charges only the
1161
+ * TRANSITION into an org-wide seat — `becomesManager` is false when the
1162
+ * membership already held one — so an org already above its cap keeps every
1163
+ * manager it has, can still have their role or profile rewritten, and is
1164
+ * merely refused the next one. There is no sweep and no revocation, and none
1165
+ * may be added: the cap binds ADMISSION, never ACCESS.
1166
+ */ async function assertManagerSeats(options) {
1167
+ const { orgRef, org, becomesManager, self, read } = options;
1168
+ if (!becomesManager) return;
1169
+ const entries = await readSeatEntries(orgRef, read);
1170
+ const used = countManagerSeatsExcluding(entries, self);
1171
+ const quota = checkSeatQuota(org, 'managers', used);
1172
+ if (!quota.allowed) {
1173
+ throw new ManagerSeatLimitError(_extends({}, quota, {
1174
+ retainedOverCap: Math.max(0, used - quota.limit)
1175
+ }));
1176
+ }
1177
+ }
1178
+ /**
1179
+ * Is this write admitting a manager who was not one already?
1180
+ *
1181
+ * The manager analogue of `newlyScopedHosts`, and it exists for the same
1182
+ * reason: a seat is charged when it is TAKEN, not every time the row holding
1183
+ * it is rewritten. Re-saving an existing manager's title, or moving them from
1184
+ * `editor` to `admin`, re-writes a seat they already hold — charging that
1185
+ * would strand an over-cap org unable to even demote its way back down.
1186
+ *
1187
+ * A scoped collaborator being promoted to org-wide DOES take a manager seat,
1188
+ * and gives one up on the collaborator side; that is a real transition and is
1189
+ * charged.
1190
+ */ function becomesOrgManager(options) {
1191
+ const next = isOrgWideMember({
1192
+ role: options.role,
1193
+ allHosts: options.allHosts,
1194
+ hostAccess: options.hostAccess
1195
+ });
1196
+ if (!next) return false;
1197
+ // An ABSENT row is not a manager, and `isOrgWideMember(undefined)` is
1198
+ // already false — but saying so explicitly keeps the "was it one before?"
1199
+ // question readable next to the legacy shape that predates `allHosts`.
1200
+ return !options.existing || !isOrgWideMember(options.existing);
1201
+ }
1202
+ /**
1203
+ * Turn a manager-seat refusal into the 403 the admitting routes return, or
1204
+ * null when the error is something else and must keep propagating to the 500.
1205
+ *
1206
+ * Sits beside `collaboratorSeatRefusalResponse` and stacks with it in a
1207
+ * route's catch block, each returning null for a non-match.
1208
+ */ export function managerSeatRefusalResponse(error) {
1209
+ if (!(error instanceof ManagerSeatLimitError)) return null;
1210
+ return Response.json({
1211
+ error: error.message,
1212
+ code: 'manager_seat_limit',
1213
+ limit: error.limit,
1214
+ upgradeRequired: error.upgradeRequired,
1215
+ // How many seats the org is over by. NOBODY was removed — the client
1216
+ // renders this as retention, not as a loss.
1217
+ retainedOverCap: error.retainedOverCap
1218
+ }, {
1219
+ status: 403
1220
+ });
1221
+ }
1222
+ /**
1223
+ * The same cap, asked BEFORE anything is written.
1224
+ *
1225
+ * Not the enforcement — the transaction inside `upsertOrgMember` is. This
1226
+ * exists for the one door that never calls it: `/api/orgs/invites` create
1227
+ * writes an invite document directly, so it refuses at the point the admin is
1228
+ * looking at rather than mailing someone a link that will be refused when
1229
+ * they click it. A race here over-reserves invites; it cannot over-grant
1230
+ * access, because access is only ever granted through the transactional path.
1231
+ */ export async function managerSeatRefusal(options) {
1232
+ const { orgId, org, becomesManager, self } = options;
1233
+ if (!becomesManager) return null;
1234
+ try {
1235
+ await assertManagerSeats({
1236
+ orgRef: firestore().collection('orgs').doc(orgId),
1237
+ org,
1238
+ becomesManager,
1239
+ self: self != null ? self : {},
1240
+ read: (query)=>query.get()
1241
+ });
1242
+ } catch (error) {
1243
+ const refusal = managerSeatRefusalResponse(error);
1244
+ if (refusal) return refusal;
1245
+ throw error;
1246
+ }
1247
+ return null;
1248
+ }
1249
+ /**
1250
+ * The owner seat is not writable through the membership door (AGL-1888).
1251
+ *
1252
+ * An exception, and modelled on {@link CollaboratorSeatLimitError}, for the
1253
+ * same reason: it has to travel out of a function whose contract is "make it
1254
+ * so" and which three routes call as a bare `await`. A returned verdict would
1255
+ * be ignorable at every one of them, which is the shape of the bug.
1256
+ */ export class OrgOwnerSeatError extends Error {
1257
+ constructor(reason){
1258
+ super(reason === 'grant' ? 'The owner role cannot be granted through org membership — ' + 'ownership moves only by transfer.' : 'This person owns the organization. Ownership moves only by ' + 'transfer, from Settings — an invitation cannot change it.');
1259
+ this.name = 'OrgOwnerSeatError';
1260
+ this.reason = reason;
1261
+ }
1262
+ }
1263
+ /**
1264
+ * Turn an owner-seat refusal into a 409, or null when the error is something
1265
+ * else and must keep propagating to the 500.
1266
+ *
1267
+ * Beside {@link collaboratorSeatRefusalResponse} so a route's catch block
1268
+ * stays one line and cannot accidentally mask a real fault.
1269
+ */ export function orgOwnerSeatRefusalResponse(error) {
1270
+ if (!(error instanceof OrgOwnerSeatError)) return null;
1271
+ return Response.json({
1272
+ error: error.message,
1273
+ code: 'org_owner_seat'
1274
+ }, {
1275
+ status: 409
1276
+ });
1277
+ }
1278
+ /**
1279
+ * Creates or updates a member transactionally with its reverse-index
1280
+ * entry, then re-syncs host projections.
1281
+ *
1282
+ * ## The owner seat is refused here, not only in the routes (AGL-1888)
1283
+ *
1284
+ * It used to say "owner-role guards live in the API routes — this is the
1285
+ * mechanism", and that was the defect. Both halves of the org-owner invariant
1286
+ * were enforced only at the doors an admin clicks, and invite ACCEPTANCE is a
1287
+ * door that re-validates neither:
1288
+ *
1289
+ * - **Granting.** `/api/orgs/members` and `/api/orgs/invites` create both
1290
+ * refuse `role === 'owner'` outright, but acceptance passes the invite
1291
+ * doc's STORED role straight through (`/api/orgs/invites` accept, and
1292
+ * `/api/auth/sso-jit`). That is safe today only because every writer of an
1293
+ * invite doc refuses `owner` and the collection is `allow write: if false`
1294
+ * — a latent escalation the moment a fourth invite-writer forgets, and the
1295
+ * invariant that an org has exactly ONE owner is what the whole SSO
1296
+ * break-glass guarantee rests on ({@link transferOrgOwnership} MOVES the
1297
+ * seat; nothing else may create one).
1298
+ * - **Demoting**, which was reachable, self-serve, and irreversible. Invite
1299
+ * creation never checked that the address is already a member, and
1300
+ * acceptance accommodates an existing member re-accepting. So any admin
1301
+ * could invite the OWNER'S own verified address as `viewer`; the owner
1302
+ * clicks a normal-looking invitation to their own organization; this
1303
+ * function merge-writes `role: 'viewer'`, `allHosts: false` onto the owner's
1304
+ * member doc. `orgs/{orgId}.ownerUid` still names them, but every
1305
+ * authorization read goes through the member doc — so `canManageOrg` is
1306
+ * now false, `transfer-ownership` checks `membership.member.role ===
1307
+ * 'owner'` and refuses them, `/api/orgs/members` refuses to edit the owner's
1308
+ * membership at all, and `findBreakGlassOrgOwners` (`where role == owner`)
1309
+ * finds nobody. The org loses its owner permanently, recoverable only by
1310
+ * staff. It is the AGL-1375 one-way door rebuilt out of the invite path,
1311
+ * and it needs no SSO to reach.
1312
+ *
1313
+ * Both checks live HERE because this is the single transaction every door
1314
+ * funnels through, and the org doc and the existing member doc are already in
1315
+ * its read set — so it costs nothing and cannot be forgotten by a fifth
1316
+ * caller. The route-level refusals stay: they are better error messages at
1317
+ * the point the admin is looking, not the control.
1318
+ *
1319
+ * The demotion guard asks BOTH `org.ownerUid` and the stored role, rather
1320
+ * than trusting either to stand for the other. They are supposed to agree;
1321
+ * an org where they have already diverged is exactly the one that most needs
1322
+ * the write refused.
1323
+ *
1324
+ * {@link createOrganization} and {@link transferOrgOwnership} are unaffected —
1325
+ * both write `role: 'owner'` with their own `tx.set`, and remain the only two
1326
+ * producers of an owner in the product.
1327
+ */ export async function upsertOrgMember(options) {
1328
+ const { orgId, uid, role, allHosts, hostAccess, roleId, email, seatAliasEmails, displayName, photoURL, title, invitedBy } = options;
1329
+ // Before the transaction is even opened: this one needs no reads, and
1330
+ // refusing here is what lets the spec assert that NOTHING was written
1331
+ // rather than that a throw happened somewhere.
1332
+ if (role === 'owner') throw new OrgOwnerSeatError('grant');
1333
+ const db = firestore();
1334
+ await db.runTransaction(async (tx)=>{
1335
+ var _org_name, _org_slug;
1336
+ var _existing_data;
1337
+ const orgSnapshot = await tx.get(db.collection('orgs').doc(orgId));
1338
+ if (!orgSnapshot.exists) throw new Error(`Unknown org: ${orgId}`);
1339
+ const org = orgSnapshot.data();
1340
+ const memberRef = db.collection('orgs').doc(orgId).collection('members').doc(uid);
1341
+ const existing = await tx.get(memberRef);
1342
+ // The owner's own row is not writable here (AGL-1888). Both facts, not
1343
+ // one standing in for the other — see the note on this function.
1344
+ if (org.ownerUid === uid || ((_existing_data = existing.data()) == null ? void 0 : _existing_data.role) === 'owner') {
1345
+ throw new OrgOwnerSeatError('demote');
1346
+ }
1347
+ // Collaborator seat cap (AGL-2068), inside this transaction and before
1348
+ // any write. This is the door `/api/orgs/members` and invite ACCEPTANCE
1349
+ // come through, and neither metered `membersPerHost` at all — both gate
1350
+ // on `isOrgWideMember`, which is false for exactly the site-scoped
1351
+ // collaborator this charges for. The roster read below joins this
1352
+ // transaction's read set, so concurrent accepts serialise instead of all
1353
+ // passing the same pre-count.
1354
+ await assertCollaboratorSeats({
1355
+ orgRef: db.collection('orgs').doc(orgId),
1356
+ org: orgSnapshot.data(),
1357
+ hostIds: newlyScopedHosts({
1358
+ role,
1359
+ allHosts: allHosts != null ? allHosts : false,
1360
+ hostAccess: hostAccess != null ? hostAccess : {},
1361
+ existing: existing.data()
1362
+ }),
1363
+ self: {
1364
+ uid,
1365
+ email,
1366
+ emails: seatAliasEmails
1367
+ },
1368
+ read: (query)=>tx.get(query)
1369
+ });
1370
+ // Manager seat cap, in the same read slot and for the same reason. This
1371
+ // is the door invite ACCEPTANCE, `/api/orgs/members` and SSO-JIT all come
1372
+ // through, and all three read the roster outside any transaction before
1373
+ // this — so concurrent accepts measured one roster and every one of them
1374
+ // passed. The read below joins this transaction's read set, which is what
1375
+ // serialises them.
1376
+ await assertManagerSeats({
1377
+ orgRef: db.collection('orgs').doc(orgId),
1378
+ org: orgSnapshot.data(),
1379
+ becomesManager: becomesOrgManager({
1380
+ role,
1381
+ allHosts: allHosts != null ? allHosts : false,
1382
+ hostAccess: hostAccess != null ? hostAccess : {},
1383
+ existing: existing.data()
1384
+ }),
1385
+ self: {
1386
+ uid,
1387
+ email,
1388
+ emails: seatAliasEmails
1389
+ },
1390
+ read: (query)=>tx.get(query)
1391
+ });
1392
+ tx.set(memberRef, _extends({
1393
+ role,
1394
+ allHosts: allHosts != null ? allHosts : false,
1395
+ hostAccess: hostAccess != null ? hostAccess : {}
1396
+ }, roleId !== undefined ? {
1397
+ roleId
1398
+ } : {}, email !== undefined ? {
1399
+ email
1400
+ } : {}, displayName !== undefined ? {
1401
+ displayName
1402
+ } : {}, photoURL !== undefined ? {
1403
+ photoURL
1404
+ } : {}, title !== undefined ? {
1405
+ title
1406
+ } : {}, invitedBy ? {
1407
+ invitedBy
1408
+ } : {}, existing.exists ? {} : {
1409
+ joinedAt: FieldValue.serverTimestamp()
1410
+ }), {
1411
+ merge: true
1412
+ });
1413
+ tx.set(db.collection('users').doc(uid).collection('orgs').doc(orgId), {
1414
+ role,
1415
+ orgName: (_org_name = org.name) != null ? _org_name : null,
1416
+ slug: (_org_slug = org.slug) != null ? _org_slug : null,
1417
+ // Mirrored from the member doc written just above (AGL-1032) — this
1418
+ // `set` has no merge, so the flag has to be part of it or the
1419
+ // console loses the collaborator/viewer distinction until the
1420
+ // projection pass below rewrites it.
1421
+ orgWide: isOrgWideMember({
1422
+ role,
1423
+ allHosts: allHosts != null ? allHosts : false,
1424
+ hostAccess: hostAccess != null ? hostAccess : {}
1425
+ })
1426
+ });
1427
+ });
1428
+ await syncOrgAuthProjections(orgId);
1429
+ // Reverse-index this member's now-current host access (AGL-844).
1430
+ await syncMemberHostProjections(orgId, uid);
1431
+ }
1432
+ /**
1433
+ * Fill in a roster row's display identity from an identity provider, writing
1434
+ * ONLY the fields that are currently blank (AGL-1131).
1435
+ *
1436
+ * Separate from `upsertOrgMember` because the caller is the SSO sign-in path
1437
+ * on its already-a-member branch, where the member's role, host access and
1438
+ * invite state are settled and must not be touched. `upsertOrgMember`
1439
+ * requires a `role` and re-asserts it, so reusing it here would let an SSO
1440
+ * sign-in quietly reset an admin to the org's `sso.defaultRole`.
1441
+ *
1442
+ * Absent-only, so it is safe on every sign-in: it backfills the rows that
1443
+ * predate the IdP mapping and then never writes again, and it can never
1444
+ * overwrite a name or photo a person chose.
1445
+ *
1446
+ * @returns the field names it wrote, for logging and tests.
1447
+ */ export async function backfillMemberIdentity(orgId, uid, identity, db = firestore()) {
1448
+ var _identity_displayName, _identity_photoURL;
1449
+ const ref = db.collection('orgs').doc(orgId).collection('members').doc(uid);
1450
+ const snapshot = await ref.get();
1451
+ // A missing row is NOT this function's job to create — creating one here
1452
+ // would mint a membership with no role, which every permission check reads
1453
+ // as a member of some kind.
1454
+ if (!snapshot.exists) return [];
1455
+ const blank = (value)=>typeof value !== 'string' || !value.trim();
1456
+ const patch = {};
1457
+ const displayName = (_identity_displayName = identity.displayName) == null ? void 0 : _identity_displayName.trim();
1458
+ const photoURL = (_identity_photoURL = identity.photoURL) == null ? void 0 : _identity_photoURL.trim();
1459
+ if (displayName && blank(snapshot.get('displayName'))) {
1460
+ patch['displayName'] = displayName;
1461
+ }
1462
+ if (photoURL && blank(snapshot.get('photoURL'))) {
1463
+ patch['photoURL'] = photoURL;
1464
+ }
1465
+ if (!Object.keys(patch).length) return [];
1466
+ await ref.set(patch, {
1467
+ merge: true
1468
+ });
1469
+ return Object.keys(patch);
1470
+ }
1471
+ /**
1472
+ * The same absent-only backfill, across every roster row that names `uid`.
1473
+ *
1474
+ * ## The hole this closes
1475
+ *
1476
+ * `orgs/{orgId}/members/{uid}.photoURL` is the ONLY avatar a member surface
1477
+ * can read — a colleague's auth record is unreadable from another member's
1478
+ * session, and an SSO member's lives in a pool the project cannot see at all
1479
+ * (AGL-1122). Three writers filled it, and between them they missed the
1480
+ * commonest account there is:
1481
+ *
1482
+ * - `upsertOrgMember` — someone ADDED you, so the adder's lookup had a record
1483
+ * to copy from.
1484
+ * - `backfillMemberIdentity` via the SSO sign-in (AGL-1131) — enterprise only.
1485
+ * - `propagateMemberPhoto` via Manage Account → Profile image (AGL-1976) — a
1486
+ * photo the person typed or browsed to.
1487
+ *
1488
+ * Nobody adds the person who CREATES a workspace, `createOrganization` writes
1489
+ * their row with a name and an email and no photo, and a Google sign-in never
1490
+ * visits the other two. So the owner of a workspace saw their own face in the
1491
+ * app bar, which reads the live auth record, and a grey initial in their own
1492
+ * Team list — measured on both rows of `test-org`, each with `photoURL` absent
1493
+ * while the auth record and `users/{uid}.photoUrl` carried the picture.
1494
+ *
1495
+ * ## Absent-only, like the function it fans out
1496
+ *
1497
+ * It runs on EVERY sign-in, so the reasoning in `backfillMemberIdentity`
1498
+ * applies unchanged and is the reason this is a fan-out of that function
1499
+ * rather than a second writer: an overwriting version would replace a photo
1500
+ * the person chose in Manage Account with their provider thumbnail on their
1501
+ * next sign-in, silently, forever. `propagateMemberPhoto` is the overwriting
1502
+ * direction and stays the only one, because its input is a choice the person
1503
+ * made rather than an assertion a directory made about them.
1504
+ *
1505
+ * Memberships come from `users/{uid}/orgs`, the reverse index — never a
1506
+ * collection-group query over `members`, which would read every workspace's
1507
+ * roster in the estate to find one person's rows.
1508
+ *
1509
+ * @returns the org ids whose row was written, for logging and tests.
1510
+ */ export async function backfillMemberIdentityEverywhere(uid, identity, db = firestore()) {
1511
+ var _identity_displayName, _identity_photoURL;
1512
+ if (!uid) return [];
1513
+ // Nothing to write beats a fan-out that reads every membership to discover
1514
+ // it has nothing to write — this runs on every sign-in.
1515
+ if (!((_identity_displayName = identity.displayName) == null ? void 0 : _identity_displayName.trim()) && !((_identity_photoURL = identity.photoURL) == null ? void 0 : _identity_photoURL.trim())) return [];
1516
+ const memberships = await db.collection('users').doc(uid).collection('orgs').get();
1517
+ const written = [];
1518
+ for (const row of memberships.docs){
1519
+ const fields = await backfillMemberIdentity(row.id, uid, identity, db);
1520
+ if (fields.length) written.push(row.id);
1521
+ }
1522
+ return written;
1523
+ }
1524
+ /**
1525
+ * Transfers org ownership (AGL-232): the target must already be on the
1526
+ * roster; the previous owner steps down to admin. One transaction across
1527
+ * the org doc, both member docs and both reverse-index entries, then the
1528
+ * host projections re-sync.
1529
+ *
1530
+ * **It moves `ownerUid` and must never touch `createdByUid`** (AGL-2265).
1531
+ * That field is the creator attribution the free-workspace ceiling counts
1532
+ * against, and it is what stops a transfer from being a way to launder the
1533
+ * count: hand a workspace to an alt account, create a fourth, take it back.
1534
+ * Nothing here writes it, and `free-workspace-cap.spec.ts` runs exactly that
1535
+ * sequence to keep it that way.
1536
+ */ export async function transferOrgOwnership(orgId, fromUid, toUid) {
1537
+ if (fromUid === toUid) throw new Error('Target already owns this org');
1538
+ const db = firestore();
1539
+ await db.runTransaction(async (tx)=>{
1540
+ const orgRef = db.collection('orgs').doc(orgId);
1541
+ const orgSnapshot = await tx.get(orgRef);
1542
+ if (!orgSnapshot.exists) throw new Error(`Unknown org: ${orgId}`);
1543
+ const org = orgSnapshot.data();
1544
+ if (org.ownerUid !== fromUid) {
1545
+ throw new Error('Only the current owner can transfer ownership');
1546
+ }
1547
+ const targetRef = orgRef.collection('members').doc(toUid);
1548
+ const target = await tx.get(targetRef);
1549
+ if (!target.exists) {
1550
+ throw new Error('The new owner must already be an org member');
1551
+ }
1552
+ tx.set(orgRef, {
1553
+ ownerUid: toUid,
1554
+ updatedAt: FieldValue.serverTimestamp()
1555
+ }, {
1556
+ merge: true
1557
+ });
1558
+ tx.set(targetRef, {
1559
+ role: 'owner',
1560
+ allHosts: true
1561
+ }, {
1562
+ merge: true
1563
+ });
1564
+ tx.set(orgRef.collection('members').doc(fromUid), {
1565
+ role: 'admin'
1566
+ }, {
1567
+ merge: true
1568
+ });
1569
+ tx.set(db.collection('users').doc(toUid).collection('orgs').doc(orgId), // Both principals end up owner/admin, which is org-wide reach whatever
1570
+ // they were before — a promoted site collaborator must lose the scoped
1571
+ // console along with the scoped membership (AGL-1032).
1572
+ {
1573
+ role: 'owner',
1574
+ orgWide: true
1575
+ }, {
1576
+ merge: true
1577
+ });
1578
+ tx.set(db.collection('users').doc(fromUid).collection('orgs').doc(orgId), {
1579
+ role: 'admin',
1580
+ orgWide: true
1581
+ }, {
1582
+ merge: true
1583
+ });
1584
+ });
1585
+ await syncOrgAuthProjections(orgId);
1586
+ // Both principals' host access changed (owner spans every host) — AGL-844.
1587
+ await Promise.all([
1588
+ syncMemberHostProjections(orgId, toUid),
1589
+ syncMemberHostProjections(orgId, fromUid)
1590
+ ]);
1591
+ /*
1592
+ * A workspace changing hands is the highest-consequence thing that can
1593
+ * happen to an account, and until AGL-118 it left no trace anywhere: the
1594
+ * transaction above rewrites five documents and wrote nothing that says it
1595
+ * happened, so the only evidence was the new state itself.
1596
+ *
1597
+ * BOTH principals are on the row. The actor is the outgoing owner, who is
1598
+ * the only party allowed to perform this, and the target names the
1599
+ * incoming one — a transfer identified by one party is half a record, and
1600
+ * the half it keeps is the one already implied by `ownerUid`.
1601
+ *
1602
+ * Emails are read after the fact and best-effort. The uids are the
1603
+ * identity; the addresses only save a reader a lookup, so a failure to
1604
+ * resolve them must not cost the entry.
1605
+ */ const [fromEmail, toEmail] = await Promise.all([
1606
+ fromUid,
1607
+ toUid
1608
+ ].map(async (uid)=>firestore().collection('orgs').doc(orgId).collection('members').doc(uid).get().then((snapshot)=>{
1609
+ const email = snapshot.get('email');
1610
+ return typeof email === 'string' ? email : null;
1611
+ }).catch(()=>null)));
1612
+ await logOrgActivity(orgId, {
1613
+ uid: fromUid,
1614
+ email: fromEmail
1615
+ }, 'Transferred workspace ownership', _extends({
1616
+ type: 'member',
1617
+ id: toUid
1618
+ }, toEmail ? {
1619
+ name: toEmail
1620
+ } : {}));
1621
+ }
1622
+ /**
1623
+ * Grants (or updates) per-host access for a uid without disturbing an
1624
+ * existing membership's org role or allHosts flag (AGL-238: the host user
1625
+ * manager rides org membership). Creates a viewer membership scoped to
1626
+ * just this host when the uid is not on the roster yet.
1627
+ */ export async function grantHostAccess(options) {
1628
+ const { orgId, uid, hostId, role, email, displayName, invitedBy } = options;
1629
+ const db = firestore();
1630
+ await db.runTransaction(async (tx)=>{
1631
+ const orgRef = db.collection('orgs').doc(orgId);
1632
+ const orgSnapshot = await tx.get(orgRef);
1633
+ if (!orgSnapshot.exists) throw new Error(`Unknown org: ${orgId}`);
1634
+ const org = orgSnapshot.data();
1635
+ const memberRef = orgRef.collection('members').doc(uid);
1636
+ const existing = await tx.get(memberRef);
1637
+ // Collaborator seat cap (AGL-2068). This door DID meter, but against
1638
+ // `hosts/{hostId}/members` — a display roster only its own route writes,
1639
+ // so it could not see anyone admitted by invite or by `/api/orgs/members`
1640
+ // and under-counted even when it fired. The count now comes off the org
1641
+ // roster + pending invites, which is where every door lands.
1642
+ await assertCollaboratorSeats({
1643
+ orgRef,
1644
+ org: orgSnapshot.data(),
1645
+ // Asked of the membership AS IT STANDS, not of the merged shape.
1646
+ // `grantHostAccess` never touches `role` or `allHosts`, so someone who
1647
+ // is already a manager stays one and keeps paying a manager seat — and
1648
+ // a legacy pre-`allHosts` row, which `isOrgWideMember` reads as org-wide
1649
+ // precisely so it is not locked out, must not be re-classified into a
1650
+ // collaborator seat by the act of writing a host key onto it.
1651
+ hostIds: (()=>{
1652
+ var _current_hostAccess;
1653
+ const current = existing.data();
1654
+ if (existing.exists && isOrgWideMember(current)) return [];
1655
+ if (current == null ? void 0 : (_current_hostAccess = current.hostAccess) == null ? void 0 : _current_hostAccess[hostId]) return [];
1656
+ return [
1657
+ hostId
1658
+ ];
1659
+ })(),
1660
+ self: {
1661
+ uid,
1662
+ email
1663
+ },
1664
+ read: (query)=>tx.get(query)
1665
+ });
1666
+ tx.set(memberRef, _extends({}, existing.exists ? {} : {
1667
+ role: 'viewer',
1668
+ allHosts: false,
1669
+ joinedAt: FieldValue.serverTimestamp()
1670
+ }, {
1671
+ hostAccess: {
1672
+ [hostId]: role
1673
+ }
1674
+ }, email !== undefined ? {
1675
+ email
1676
+ } : {}, displayName !== undefined ? {
1677
+ displayName
1678
+ } : {}, invitedBy ? {
1679
+ invitedBy
1680
+ } : {}), // merge deep-merges the hostAccess map, so other host grants and
1681
+ // the existing role/allHosts stay untouched.
1682
+ {
1683
+ merge: true
1684
+ });
1685
+ if (!existing.exists) {
1686
+ var _org_name, _org_slug;
1687
+ tx.set(db.collection('users').doc(uid).collection('orgs').doc(orgId), {
1688
+ role: 'viewer',
1689
+ orgName: (_org_name = org.name) != null ? _org_name : null,
1690
+ slug: (_org_slug = org.slug) != null ? _org_slug : null,
1691
+ // A brand-new site collaborator: on the org roster, but their console
1692
+ // is one site (AGL-1032). `role: 'viewer'` here is indistinguishable
1693
+ // from a genuine org-wide viewer's, which is the whole reason for
1694
+ // this flag. An EXISTING member keeps whatever reach they had — a
1695
+ // host grant never widens or narrows it.
1696
+ orgWide: false
1697
+ });
1698
+ }
1699
+ });
1700
+ await syncOrgAuthProjections(orgId);
1701
+ await syncMemberHostProjections(orgId, uid);
1702
+ }
1703
+ /**
1704
+ * Drops one host from a member's hostAccess map, then re-projects.
1705
+ *
1706
+ * `updateExisting`, not a merge-set (AGL-1766). A merge-set whose entire
1707
+ * payload is a delete sentinel still CREATES the document when it is absent,
1708
+ * and the row it minted here is not merely untidy — it is a MEMBERSHIP, and
1709
+ * one that reads as org-wide. `isOrgWideMember` treats "no `role`, no
1710
+ * `allHosts`, empty `hostAccess`" as the pre-`allHosts` LEGACY shape and
1711
+ * answers true (deliberately: reading it as "scoped, with access to nothing"
1712
+ * would lock real members out). A genuine site collaborator never looks like
1713
+ * that — `grantHostAccess` always writes `allHosts: false` — but a document
1714
+ * conjured from this patch alone does, exactly.
1715
+ *
1716
+ * So the consequences land away from here, which is what made it hard to see:
1717
+ * `resolveOrgMembership` finds the doc and returns a membership for someone
1718
+ * who was removed from the org; `syncOrgAuthProjections` on the next line
1719
+ * stamps it `scopeTokens: ['org']`, the read set the rules and every
1720
+ * Admin-SDK `memberCanSee` resolve from; and `countManagerSeats` bills it as
1721
+ * a manager seat. (It does NOT reach `hosts/*.memberRoles`, as AGL-1763
1722
+ * supposed — `hostRoleFor` requires an `isOrgRole(role)` and the phantom has
1723
+ * none.)
1724
+ *
1725
+ * Reachable without any race: `removeOrgMember` deletes the org member doc
1726
+ * but leaves the `hosts/{hostId}/members` roster row, which is what this is
1727
+ * called from. Deleting that leftover row re-created the membership it was
1728
+ * meant to finish removing. (AGL-1766's "stale double-submit" is NOT a route:
1729
+ * the caller 404s on the missing roster row before reaching here.)
1730
+ *
1731
+ * DOTTED FIELD PATH, not the nested map: `update()` accepts a delete sentinel
1732
+ * only at the top level of its patch (`@google-cloud/firestore` serializer,
1733
+ * `allowDeletes: 'root'`), so the nested form would throw INVALID_ARGUMENT.
1734
+ * The dotted path is top-level and clears the one key while leaving the rest
1735
+ * of `hostAccess` alone — the same field-by-field semantics the merge had.
1736
+ * Safe as a string path because host ids are `createResourceUid()` nanoids
1737
+ * (`A-Za-z0-9_-`), so none can contain the `.` the SDK splits on.
1738
+ *
1739
+ * REFUSE, and ignore the answer: revoking a grant that is not there is a
1740
+ * no-op and discards nothing (AGL-1760). The projections still run — they are
1741
+ * recomputed from the roster, so a pass that finds no member doc is exactly
1742
+ * the self-heal a stale row needs.
1743
+ */ export async function revokeHostAccess(orgId, uid, hostId) {
1744
+ await updateExisting(firestore().collection('orgs').doc(orgId).collection('members').doc(uid), {
1745
+ [`hostAccess.${hostId}`]: FieldValue.delete()
1746
+ });
1747
+ await syncOrgAuthProjections(orgId);
1748
+ await syncMemberHostProjections(orgId, uid);
1749
+ }
1750
+ /**
1751
+ * Removes a member + reverse index entry, then re-syncs projections.
1752
+ *
1753
+ * The addresses the member added in this workspace (AGL-2975) go in the
1754
+ * same batch. They sit beside the roster row rather than under it, so no
1755
+ * delete of the row reaches them, and an erasure of the person runs through
1756
+ * here once per workspace.
1757
+ */ export async function removeOrgMember(orgId, uid) {
1758
+ const db = firestore();
1759
+ const batch = db.batch();
1760
+ batch.delete(db.collection('orgs').doc(orgId).collection('members').doc(uid));
1761
+ batch.delete(db.collection('orgs').doc(orgId).collection(MEMBER_EMAIL_ALIASES_COLLECTION).doc(uid));
1762
+ batch.delete(db.collection('users').doc(uid).collection('orgs').doc(orgId));
1763
+ await batch.commit();
1764
+ await syncOrgAuthProjections(orgId);
1765
+ // The member is off the roster, so the sync above can't reach their rows —
1766
+ // drop the reverse index explicitly (AGL-844), like the orgs entry above.
1767
+ await deleteMemberHostProjections(orgId, uid);
1768
+ }
1769
+ /**
1770
+ * Registers a host under its org: org directory entry, hostIndex mirror,
1771
+ * and the initial memberRoles projection on the host doc.
1772
+ */ export async function registerOrgHost(orgId, hostId, subdomain) {
1773
+ const db = firestore();
1774
+ await db.collection('orgs').doc(orgId).set({
1775
+ hosts: {
1776
+ [hostId]: true
1777
+ },
1778
+ updatedAt: FieldValue.serverTimestamp()
1779
+ }, {
1780
+ merge: true
1781
+ });
1782
+ await db.collection('hostIndex').doc(hostId).set(_extends({
1783
+ orgId
1784
+ }, subdomain ? {
1785
+ subdomain
1786
+ } : {}));
1787
+ await syncOrgAuthProjections(orgId, hostId);
1788
+ // Seed the per-user projection for everyone who can reach the new host.
1789
+ await syncHostProjectionForMembers(orgId, hostId);
1790
+ }
1791
+ /**
1792
+ * The consent group a site belongs to, read off its owning org.
1793
+ *
1794
+ * The ONE server-side door to pooling. Every capture surface and every send
1795
+ * path resolves a group through this rather than reading
1796
+ * `CONSENT_GROUPS_FIELD` itself, so there is one place that decides what a
1797
+ * site's consent covers and one place a mistake could live.
1798
+ *
1799
+ * FAILS TO THE GROUP OF ONE. An org that cannot be resolved, or a read that
1800
+ * throws, answers "this site alone" — which withholds mail from an org that
1801
+ * had legitimately pooled and never sends mail on a pooling nobody could
1802
+ * confirm. That is the only direction a failure here may fall.
1803
+ *
1804
+ * The org read is `React.cache`-deduped per request by {@link getOrgForHost},
1805
+ * so a send that already resolved the org for its policy pays nothing extra.
1806
+ */ export async function consentGroupForSite(hostId, org) {
1807
+ var _ref;
1808
+ if (!hostId) throw new Error('[organizations] no site to resolve a group for');
1809
+ if (org) return consentGroupForHost(org, hostId);
1810
+ const resolved = await getOrgForHost(hostId).catch(()=>null);
1811
+ return consentGroupForHost((_ref = resolved == null ? void 0 : resolved.org) != null ? _ref : null, hostId);
1812
+ }
1813
+
1814
+ //# sourceMappingURL=organizations.js.map