@aglyn/aglyn 1.0.0-beta.220 → 1.0.0-beta.222

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 (358) hide show
  1. package/package.json +15 -11
  2. package/src/lib/app-utils/agent-openapi.js +1 -1
  3. package/src/lib/app-utils/agent-openapi.js.map +1 -1
  4. package/src/lib/app-utils/analytics-events.d.ts +3 -2
  5. package/src/lib/app-utils/analytics-events.js +3 -2
  6. package/src/lib/app-utils/analytics-events.js.map +1 -1
  7. package/src/lib/app-utils/api-plugins.d.ts +30 -0
  8. package/src/lib/app-utils/api-plugins.js +24 -1
  9. package/src/lib/app-utils/api-plugins.js.map +1 -1
  10. package/src/lib/app-utils/author-html.d.ts +1 -1
  11. package/src/lib/app-utils/author-html.js.map +1 -1
  12. package/src/lib/app-utils/bandwidth-cap.d.ts +12 -1
  13. package/src/lib/app-utils/bandwidth-cap.js +17 -1
  14. package/src/lib/app-utils/bandwidth-cap.js.map +1 -1
  15. package/src/lib/app-utils/compose-reusable-components.d.ts +19 -0
  16. package/src/lib/app-utils/compose-reusable-components.js +116 -26
  17. package/src/lib/app-utils/compose-reusable-components.js.map +1 -1
  18. package/src/lib/app-utils/console-routes.d.ts +4 -0
  19. package/src/lib/app-utils/console-routes.js +3 -0
  20. package/src/lib/app-utils/console-routes.js.map +1 -1
  21. package/src/lib/app-utils/contacts.d.ts +71 -0
  22. package/src/lib/app-utils/contacts.js +55 -0
  23. package/src/lib/app-utils/contacts.js.map +1 -1
  24. package/src/lib/app-utils/contexts.d.ts +1 -0
  25. package/src/lib/app-utils/contexts.js +1 -0
  26. package/src/lib/app-utils/contexts.js.map +1 -1
  27. package/src/lib/app-utils/crm-email-templates.js +21 -4
  28. package/src/lib/app-utils/crm-email-templates.js.map +1 -1
  29. package/src/lib/app-utils/crm-kinds.d.ts +2 -0
  30. package/src/lib/app-utils/crm-kinds.js +1 -0
  31. package/src/lib/app-utils/crm-kinds.js.map +1 -1
  32. package/src/lib/app-utils/crm.d.ts +1199 -67
  33. package/src/lib/app-utils/crm.js +1951 -310
  34. package/src/lib/app-utils/crm.js.map +1 -1
  35. package/src/lib/app-utils/csv-import.d.ts +10 -38
  36. package/src/lib/app-utils/csv-import.js +17 -42
  37. package/src/lib/app-utils/csv-import.js.map +1 -1
  38. package/src/lib/app-utils/csv.d.ts +32 -11
  39. package/src/lib/app-utils/csv.js +43 -5
  40. package/src/lib/app-utils/csv.js.map +1 -1
  41. package/src/lib/app-utils/default-site.d.ts +116 -0
  42. package/src/lib/app-utils/default-site.js +1024 -0
  43. package/src/lib/app-utils/default-site.js.map +1 -0
  44. package/src/lib/app-utils/docs-help.generated.d.ts +23 -23
  45. package/src/lib/app-utils/docs-help.generated.js +47 -10
  46. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  47. package/src/lib/app-utils/docs-index.generated.js +746 -151
  48. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  49. package/src/lib/app-utils/draft-placeholders.d.ts +76 -0
  50. package/src/lib/app-utils/draft-placeholders.js +115 -0
  51. package/src/lib/app-utils/draft-placeholders.js.map +1 -0
  52. package/src/lib/app-utils/entity-picker-context.d.ts +19 -15
  53. package/src/lib/app-utils/entity-picker-context.js +4 -4
  54. package/src/lib/app-utils/entity-picker-context.js.map +1 -1
  55. package/src/lib/app-utils/expand-repeatables.d.ts +66 -10
  56. package/src/lib/app-utils/expand-repeatables.js +88 -14
  57. package/src/lib/app-utils/expand-repeatables.js.map +1 -1
  58. package/src/lib/app-utils/forms.d.ts +33 -22
  59. package/src/lib/app-utils/forms.js +47 -28
  60. package/src/lib/app-utils/forms.js.map +1 -1
  61. package/src/lib/app-utils/full-use-cost.d.ts +277 -0
  62. package/src/lib/app-utils/full-use-cost.js +421 -0
  63. package/src/lib/app-utils/full-use-cost.js.map +1 -0
  64. package/src/lib/app-utils/health-report.js +33 -0
  65. package/src/lib/app-utils/health-report.js.map +1 -1
  66. package/src/lib/app-utils/leaving-notice.d.ts +96 -0
  67. package/src/lib/app-utils/leaving-notice.js +157 -0
  68. package/src/lib/app-utils/leaving-notice.js.map +1 -0
  69. package/src/lib/app-utils/llms-txt.d.ts +20 -0
  70. package/src/lib/app-utils/llms-txt.js +7 -1
  71. package/src/lib/app-utils/llms-txt.js.map +1 -1
  72. package/src/lib/app-utils/media-asset-facts.d.ts +16 -1
  73. package/src/lib/app-utils/media-asset-facts.js +40 -13
  74. package/src/lib/app-utils/media-asset-facts.js.map +1 -1
  75. package/src/lib/app-utils/media-bandwidth.d.ts +52 -0
  76. package/src/lib/app-utils/media-bandwidth.js +94 -0
  77. package/src/lib/app-utils/media-bandwidth.js.map +1 -0
  78. package/src/lib/app-utils/media-ref.d.ts +241 -33
  79. package/src/lib/app-utils/media-ref.js +324 -43
  80. package/src/lib/app-utils/media-ref.js.map +1 -1
  81. package/src/lib/app-utils/media-src.d.ts +8 -8
  82. package/src/lib/app-utils/media-src.js +27 -11
  83. package/src/lib/app-utils/media-src.js.map +1 -1
  84. package/src/lib/app-utils/media-takedown-reach.d.ts +4 -1
  85. package/src/lib/app-utils/media-takedown-reach.js +6 -3
  86. package/src/lib/app-utils/media-takedown-reach.js.map +1 -1
  87. package/src/lib/app-utils/notifications.d.ts +1 -1
  88. package/src/lib/app-utils/notifications.js +2 -0
  89. package/src/lib/app-utils/notifications.js.map +1 -1
  90. package/src/lib/app-utils/operator-alerts.js +17 -0
  91. package/src/lib/app-utils/operator-alerts.js.map +1 -1
  92. package/src/lib/app-utils/organizations.d.ts +51 -2
  93. package/src/lib/app-utils/organizations.js +56 -3
  94. package/src/lib/app-utils/organizations.js.map +1 -1
  95. package/src/lib/app-utils/page-record-sources.d.ts +87 -0
  96. package/src/lib/app-utils/page-record-sources.js +53 -0
  97. package/src/lib/app-utils/page-record-sources.js.map +1 -0
  98. package/src/lib/app-utils/paid-media-source.d.ts +3 -7
  99. package/src/lib/app-utils/paid-media-source.js +5 -19
  100. package/src/lib/app-utils/paid-media-source.js.map +1 -1
  101. package/src/lib/app-utils/person-erasure.d.ts +5 -2
  102. package/src/lib/app-utils/person-erasure.js.map +1 -1
  103. package/src/lib/app-utils/picklists.d.ts +187 -0
  104. package/src/lib/app-utils/picklists.js +320 -0
  105. package/src/lib/app-utils/picklists.js.map +1 -0
  106. package/src/lib/app-utils/plan-entitlements.d.ts +220 -50
  107. package/src/lib/app-utils/plan-entitlements.js +309 -115
  108. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  109. package/src/lib/app-utils/realm-host-surface.generated.js +2 -0
  110. package/src/lib/app-utils/realm-host-surface.generated.js.map +1 -1
  111. package/src/lib/app-utils/release-flags.d.ts +1 -1
  112. package/src/lib/app-utils/release-flags.js +11 -0
  113. package/src/lib/app-utils/release-flags.js.map +1 -1
  114. package/src/lib/app-utils/rendered-widths.d.ts +121 -0
  115. package/src/lib/app-utils/rendered-widths.js +211 -0
  116. package/src/lib/app-utils/rendered-widths.js.map +1 -0
  117. package/src/lib/app-utils/repeat-sources.d.ts +2 -1
  118. package/src/lib/app-utils/repeat-sources.js.map +1 -1
  119. package/src/lib/app-utils/screen-link-context-value.d.ts +7 -0
  120. package/src/lib/app-utils/screen-link-context-value.js.map +1 -1
  121. package/src/lib/app-utils/screen-link-context.js +14 -2
  122. package/src/lib/app-utils/screen-link-context.js.map +1 -1
  123. package/src/lib/app-utils/screen-route.d.ts +157 -36
  124. package/src/lib/app-utils/screen-route.js +191 -41
  125. package/src/lib/app-utils/screen-route.js.map +1 -1
  126. package/src/lib/app-utils/seo-audit.d.ts +11 -1
  127. package/src/lib/app-utils/seo-audit.js +33 -13
  128. package/src/lib/app-utils/seo-audit.js.map +1 -1
  129. package/src/lib/app-utils/seo-keywords.d.ts +11 -0
  130. package/src/lib/app-utils/seo-keywords.js +19 -5
  131. package/src/lib/app-utils/seo-keywords.js.map +1 -1
  132. package/src/lib/app-utils/seo-page-facts.d.ts +47 -4
  133. package/src/lib/app-utils/seo-page-facts.js +136 -31
  134. package/src/lib/app-utils/seo-page-facts.js.map +1 -1
  135. package/src/lib/app-utils/seo-site-scan.d.ts +42 -4
  136. package/src/lib/app-utils/seo-site-scan.js +113 -12
  137. package/src/lib/app-utils/seo-site-scan.js.map +1 -1
  138. package/src/lib/app-utils/server.d.ts +5 -4
  139. package/src/lib/app-utils/server.js +14 -5
  140. package/src/lib/app-utils/server.js.map +1 -1
  141. package/src/lib/app-utils/site-icon-set.d.ts +195 -0
  142. package/src/lib/app-utils/site-icon-set.js +351 -0
  143. package/src/lib/app-utils/site-icon-set.js.map +1 -0
  144. package/src/lib/app-utils/site-interactions.d.ts +101 -4
  145. package/src/lib/app-utils/site-interactions.js +120 -4
  146. package/src/lib/app-utils/site-interactions.js.map +1 -1
  147. package/src/lib/app-utils/starter-template-nodes.d.ts +7 -0
  148. package/src/lib/app-utils/starter-template-nodes.js +3 -0
  149. package/src/lib/app-utils/starter-template-nodes.js.map +1 -1
  150. package/src/lib/app-utils/starter-templates.d.ts +5 -19
  151. package/src/lib/app-utils/starter-templates.js +5 -49
  152. package/src/lib/app-utils/starter-templates.js.map +1 -1
  153. package/src/lib/app-utils/transfer-launcher-context.d.ts +136 -0
  154. package/src/lib/app-utils/transfer-launcher-context.js +23 -0
  155. package/src/lib/app-utils/transfer-launcher-context.js.map +1 -0
  156. package/src/lib/app-utils/transfer-resume-import.d.ts +39 -0
  157. package/src/lib/app-utils/transfer-resume-import.js +128 -0
  158. package/src/lib/app-utils/transfer-resume-import.js.map +1 -0
  159. package/src/lib/app-utils/upgrade-proposal.d.ts +66 -0
  160. package/src/lib/app-utils/upgrade-proposal.js +95 -0
  161. package/src/lib/app-utils/upgrade-proposal.js.map +1 -0
  162. package/src/lib/app-utils/utc-month.d.ts +33 -0
  163. package/src/lib/app-utils/utc-month.js +35 -0
  164. package/src/lib/app-utils/utc-month.js.map +1 -0
  165. package/src/lib/app-utils/visitor-record-ceiling.d.ts +4 -4
  166. package/src/lib/app-utils/visitor-record-ceiling.js +4 -4
  167. package/src/lib/app-utils/visitor-record-ceiling.js.map +1 -1
  168. package/src/lib/app-utils/wire-nodes.d.ts +39 -19
  169. package/src/lib/app-utils/wire-nodes.js +120 -34
  170. package/src/lib/app-utils/wire-nodes.js.map +1 -1
  171. package/src/lib/data-transfer/access.d.ts +74 -0
  172. package/src/lib/data-transfer/access.js +93 -0
  173. package/src/lib/data-transfer/access.js.map +1 -0
  174. package/src/lib/data-transfer/derive.d.ts +246 -0
  175. package/src/lib/data-transfer/derive.js +1007 -0
  176. package/src/lib/data-transfer/derive.js.map +1 -0
  177. package/src/lib/data-transfer/export-file.d.ts +46 -0
  178. package/src/lib/data-transfer/export-file.js +146 -0
  179. package/src/lib/data-transfer/export-file.js.map +1 -0
  180. package/src/lib/data-transfer/field-catalog.d.ts +134 -0
  181. package/src/lib/data-transfer/field-catalog.js +278 -0
  182. package/src/lib/data-transfer/field-catalog.js.map +1 -0
  183. package/src/lib/data-transfer/header-match.d.ts +129 -0
  184. package/src/lib/data-transfer/header-match.js +367 -0
  185. package/src/lib/data-transfer/header-match.js.map +1 -0
  186. package/src/lib/data-transfer/index.d.ts +34 -0
  187. package/src/lib/data-transfer/index.js +40 -0
  188. package/src/lib/data-transfer/index.js.map +1 -0
  189. package/src/lib/data-transfer/job.d.ts +190 -0
  190. package/src/lib/data-transfer/job.js +229 -0
  191. package/src/lib/data-transfer/job.js.map +1 -0
  192. package/src/lib/data-transfer/lookup.d.ts +45 -0
  193. package/src/lib/data-transfer/lookup.js +71 -0
  194. package/src/lib/data-transfer/lookup.js.map +1 -0
  195. package/src/lib/data-transfer/match.d.ts +99 -0
  196. package/src/lib/data-transfer/match.js +244 -0
  197. package/src/lib/data-transfer/match.js.map +1 -0
  198. package/src/lib/data-transfer/package-plan.d.ts +205 -0
  199. package/src/lib/data-transfer/package-plan.js +475 -0
  200. package/src/lib/data-transfer/package-plan.js.map +1 -0
  201. package/src/lib/data-transfer/package.d.ts +181 -0
  202. package/src/lib/data-transfer/package.js +426 -0
  203. package/src/lib/data-transfer/package.js.map +1 -0
  204. package/src/lib/data-transfer/picklist-map.d.ts +139 -0
  205. package/src/lib/data-transfer/picklist-map.js +271 -0
  206. package/src/lib/data-transfer/picklist-map.js.map +1 -0
  207. package/src/lib/data-transfer/plan.d.ts +176 -0
  208. package/src/lib/data-transfer/plan.js +451 -0
  209. package/src/lib/data-transfer/plan.js.map +1 -0
  210. package/src/lib/data-transfer/policy.d.ts +170 -0
  211. package/src/lib/data-transfer/policy.js +378 -0
  212. package/src/lib/data-transfer/policy.js.map +1 -0
  213. package/src/lib/data-transfer/resource.d.ts +211 -0
  214. package/src/lib/data-transfer/resource.js +241 -0
  215. package/src/lib/data-transfer/resource.js.map +1 -0
  216. package/src/lib/data-transfer/review.d.ts +70 -0
  217. package/src/lib/data-transfer/review.js +233 -0
  218. package/src/lib/data-transfer/review.js.map +1 -0
  219. package/src/lib/data-transfer/similarity.d.ts +40 -0
  220. package/src/lib/data-transfer/similarity.js +106 -0
  221. package/src/lib/data-transfer/similarity.js.map +1 -0
  222. package/src/lib/data-transfer/site-package.d.ts +365 -0
  223. package/src/lib/data-transfer/site-package.js +1077 -0
  224. package/src/lib/data-transfer/site-package.js.map +1 -0
  225. package/src/lib/data-transfer/source.d.ts +74 -0
  226. package/src/lib/data-transfer/source.js +293 -0
  227. package/src/lib/data-transfer/source.js.map +1 -0
  228. package/src/lib/data-transfer/transfer-api.d.ts +898 -0
  229. package/src/lib/data-transfer/transfer-api.js +107 -0
  230. package/src/lib/data-transfer/transfer-api.js.map +1 -0
  231. package/src/lib/foundation/definitions/org-billing.types.d.ts +31 -0
  232. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  233. package/src/lib/foundation/definitions/organization.types.d.ts +42 -1
  234. package/src/lib/foundation/definitions/organization.types.js.map +1 -1
  235. package/src/lib/foundation/definitions/platform.types.d.ts +6 -4
  236. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  237. package/src/lib/foundation/index.d.ts +1 -1
  238. package/src/lib/foundation/index.js.map +1 -1
  239. package/src/lib/plugin-manager/feature-plugins.d.ts +91 -2
  240. package/src/lib/plugin-manager/feature-plugins.js +48 -3
  241. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  242. package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +56 -0
  243. package/src/lib/plugin-manager/first-party-plugins.generated.js +1132 -27
  244. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  245. package/src/lib/plugin-manager/index.d.ts +0 -1
  246. package/src/lib/plugin-manager/index.js +0 -1
  247. package/src/lib/plugin-manager/index.js.map +1 -1
  248. package/src/lib/plugin-manager/interaction-recipes.d.ts +142 -0
  249. package/src/lib/plugin-manager/interaction-recipes.js +104 -0
  250. package/src/lib/plugin-manager/interaction-recipes.js.map +1 -0
  251. package/src/lib/plugin-manager/interaction-step-checks.d.ts +66 -0
  252. package/src/lib/plugin-manager/interaction-step-checks.js +86 -0
  253. package/src/lib/plugin-manager/interaction-step-checks.js.map +1 -0
  254. package/src/lib/plugin-manager/plugin-artifact-types.d.ts +235 -0
  255. package/src/lib/plugin-manager/plugin-artifact-types.js +99 -0
  256. package/src/lib/plugin-manager/plugin-artifact-types.js.map +1 -0
  257. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
  258. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  259. package/src/lib/plugin-manager/plugin-conversion-credit.d.ts +216 -0
  260. package/src/lib/plugin-manager/plugin-conversion-credit.js +91 -0
  261. package/src/lib/plugin-manager/plugin-conversion-credit.js.map +1 -0
  262. package/src/lib/plugin-manager/plugin-entity-pickers.d.ts +83 -0
  263. package/src/lib/plugin-manager/plugin-entity-pickers.js +87 -0
  264. package/src/lib/plugin-manager/plugin-entity-pickers.js.map +1 -0
  265. package/src/lib/plugin-manager/plugin-host-resources.d.ts +6 -0
  266. package/src/lib/plugin-manager/plugin-host-resources.js +6 -1
  267. package/src/lib/plugin-manager/plugin-host-resources.js.map +1 -1
  268. package/src/lib/plugin-manager/plugin-intake-gates.d.ts +75 -0
  269. package/src/lib/plugin-manager/plugin-intake-gates.js +49 -0
  270. package/src/lib/plugin-manager/plugin-intake-gates.js.map +1 -0
  271. package/src/lib/plugin-manager/plugin-jobs.d.ts +5 -5
  272. package/src/lib/plugin-manager/plugin-jobs.js +7 -7
  273. package/src/lib/plugin-manager/plugin-jobs.js.map +1 -1
  274. package/src/lib/plugin-manager/plugin-live-paths.d.ts +70 -0
  275. package/src/lib/plugin-manager/plugin-live-paths.js +66 -0
  276. package/src/lib/plugin-manager/plugin-live-paths.js.map +1 -0
  277. package/src/lib/plugin-manager/plugin-org-erasure.d.ts +3 -2
  278. package/src/lib/plugin-manager/plugin-org-erasure.js +30 -5
  279. package/src/lib/plugin-manager/plugin-org-erasure.js.map +1 -1
  280. package/src/lib/plugin-manager/plugin-person-erasure.d.ts +68 -9
  281. package/src/lib/plugin-manager/plugin-person-erasure.js +196 -51
  282. package/src/lib/plugin-manager/plugin-person-erasure.js.map +1 -1
  283. package/src/lib/plugin-manager/plugin-person-records.d.ts +275 -0
  284. package/src/lib/plugin-manager/plugin-person-records.js +107 -0
  285. package/src/lib/plugin-manager/plugin-person-records.js.map +1 -0
  286. package/src/lib/plugin-manager/plugin-record-counts.d.ts +76 -0
  287. package/src/lib/plugin-manager/plugin-record-counts.js +49 -0
  288. package/src/lib/plugin-manager/plugin-record-counts.js.map +1 -0
  289. package/src/lib/plugin-manager/plugin-record-email-state.d.ts +26 -0
  290. package/src/lib/plugin-manager/plugin-record-email-state.js +16 -0
  291. package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
  292. package/src/lib/plugin-manager/plugin-record-index.d.ts +10 -0
  293. package/src/lib/plugin-manager/plugin-record-index.js.map +1 -1
  294. package/src/lib/plugin-manager/plugin-record-lists.d.ts +86 -6
  295. package/src/lib/plugin-manager/plugin-record-lists.js +42 -5
  296. package/src/lib/plugin-manager/plugin-record-lists.js.map +1 -1
  297. package/src/lib/plugin-manager/plugin-record-origin.d.ts +79 -0
  298. package/src/lib/plugin-manager/plugin-record-origin.js +46 -0
  299. package/src/lib/plugin-manager/plugin-record-origin.js.map +1 -0
  300. package/src/lib/plugin-manager/plugin-record-pages.d.ts +37 -0
  301. package/src/lib/plugin-manager/plugin-record-pages.js +52 -0
  302. package/src/lib/plugin-manager/plugin-record-pages.js.map +1 -0
  303. package/src/lib/plugin-manager/plugin-record-timeline.d.ts +60 -0
  304. package/src/lib/plugin-manager/plugin-record-timeline.js +16 -0
  305. package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
  306. package/src/lib/plugin-manager/plugin-resource-drafts.d.ts +5 -5
  307. package/src/lib/plugin-manager/plugin-resource-drafts.js.map +1 -1
  308. package/src/lib/plugin-manager/plugin-revocations.d.ts +64 -0
  309. package/src/lib/plugin-manager/plugin-revocations.js +47 -0
  310. package/src/lib/plugin-manager/plugin-revocations.js.map +1 -0
  311. package/src/lib/plugin-manager/plugin-send-tallies.d.ts +69 -0
  312. package/src/lib/plugin-manager/plugin-send-tallies.js +45 -0
  313. package/src/lib/plugin-manager/plugin-send-tallies.js.map +1 -0
  314. package/src/lib/plugin-manager/plugin-server-steps.d.ts +170 -0
  315. package/src/lib/plugin-manager/plugin-server-steps.js +116 -0
  316. package/src/lib/plugin-manager/plugin-server-steps.js.map +1 -0
  317. package/src/lib/plugin-manager/plugin-site-bundle.d.ts +40 -0
  318. package/src/lib/plugin-manager/plugin-site-bundle.js +4 -0
  319. package/src/lib/plugin-manager/plugin-site-bundle.js.map +1 -1
  320. package/src/lib/plugin-manager/plugin-site-export.d.ts +57 -1
  321. package/src/lib/plugin-manager/plugin-site-export.js +23 -10
  322. package/src/lib/plugin-manager/plugin-site-export.js.map +1 -1
  323. package/src/lib/plugin-manager/plugin-sitemap-readers.d.ts +146 -0
  324. package/src/lib/plugin-manager/plugin-sitemap-readers.js +110 -0
  325. package/src/lib/plugin-manager/plugin-sitemap-readers.js.map +1 -0
  326. package/src/lib/plugin-manager/plugin-transfer-resources.d.ts +714 -0
  327. package/src/lib/plugin-manager/plugin-transfer-resources.js +443 -0
  328. package/src/lib/plugin-manager/plugin-transfer-resources.js.map +1 -0
  329. package/src/lib/plugin-manager/plugin-usage-axes.d.ts +54 -5
  330. package/src/lib/plugin-manager/plugin-usage-axes.js +11 -3
  331. package/src/lib/plugin-manager/plugin-usage-axes.js.map +1 -1
  332. package/src/lib/plugin-manager/plugin-user-erasure.js +9 -1
  333. package/src/lib/plugin-manager/plugin-user-erasure.js.map +1 -1
  334. package/src/lib/plugin-manager/plugin-visitor-doors.d.ts +104 -0
  335. package/src/lib/plugin-manager/plugin-visitor-doors.js +108 -0
  336. package/src/lib/plugin-manager/plugin-visitor-doors.js.map +1 -0
  337. package/src/lib/plugin-manager/realm-host-aglyn.generated.js +2 -0
  338. package/src/lib/plugin-manager/realm-host-aglyn.generated.js.map +1 -1
  339. package/src/lib/plugin-manager/repeat-rows.d.ts +5 -6
  340. package/src/lib/plugin-manager/repeat-rows.js.map +1 -1
  341. package/src/lib/app-utils/actions.d.ts +0 -310
  342. package/src/lib/app-utils/actions.js +0 -506
  343. package/src/lib/app-utils/actions.js.map +0 -1
  344. package/src/lib/app-utils/automation-placeholders.d.ts +0 -72
  345. package/src/lib/app-utils/automation-placeholders.js +0 -207
  346. package/src/lib/app-utils/automation-placeholders.js.map +0 -1
  347. package/src/lib/app-utils/dataset-models.d.ts +0 -339
  348. package/src/lib/app-utils/dataset-models.js +0 -722
  349. package/src/lib/app-utils/dataset-models.js.map +0 -1
  350. package/src/lib/app-utils/datasets.d.ts +0 -171
  351. package/src/lib/app-utils/datasets.js +0 -227
  352. package/src/lib/app-utils/datasets.js.map +0 -1
  353. package/src/lib/app-utils/form-abuse-ceiling.d.ts +0 -146
  354. package/src/lib/app-utils/form-abuse-ceiling.js +0 -158
  355. package/src/lib/app-utils/form-abuse-ceiling.js.map +0 -1
  356. package/src/lib/plugin-manager/order-fulfilment.d.ts +0 -142
  357. package/src/lib/plugin-manager/order-fulfilment.js +0 -104
  358. package/src/lib/plugin-manager/order-fulfilment.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/operator-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynNotificationType, NotificationLevel } from './notifications'\n\n/**\n * OPERATOR ALERTS (AGL-3377): the events whoever runs an install must hear\n * about, as one typed catalog.\n *\n * Every alert is an entry here (or a plugin's entry in the same shape,\n * registered through `@aglyn/aglyn/plugin-manager/operator-alerts`) and is\n * raised through ONE server entry point, `raiseOperatorAlert` in\n * `@aglyn/tenant-data-admin/server/operator-alerts`. That entry point writes\n * the staff console notification, dedupes a flapping condition, honours the\n * per-type switches staff set on Staff → Operator alerts, and delivers by\n * email (the `operator-alert` system email) and the optional out-of-band\n * webhook. No call site composes or sends its own mail.\n *\n * The catalog holds nothing install-specific: no address, no domain, no\n * host. Recipients come from `resolveStaffAlertRecipients()` and the webhook\n * from `OPERATOR_ALERT_WEBHOOK_URL`, so an entry reads the same on aglyn.com,\n * on a self-hosted install and in an OSS consumer's deployment.\n *\n * Copy is written against merge tokens (`{{orgId}}`), filled from the\n * `context` the call site passes. A token the call site did not supply is\n * blanked, so every sentence must still read when its optional tokens are\n * empty. Never put a data subject's address or other personal data into a\n * context: the alert travels to an inbox and a webhook, not only the console.\n */\n\n/**\n * How much an alert matters.\n *\n * - `must` — money moving with nobody looking, a legal duty unmet, data not\n * protected or not erased. On and immediate unless staff change it.\n * - `should` — a fault an operator wants to know about today.\n * - `low` — routine review work; on, and batched into the daily digest.\n */\nexport type OperatorAlertTier = 'must' | 'should' | 'low'\n\nexport type OperatorAlertCategory =\n | 'billing'\n | 'payments'\n | 'legal'\n | 'data'\n | 'ops'\n | 'deliverability'\n | 'security'\n | 'support'\n\n/** `digest` batches the alert into the once-a-day operator digest. */\nexport type OperatorAlertDelivery = 'immediate' | 'digest'\n\nexport interface OperatorAlertDefinition {\n /**\n * Stable id, also the key of its settings row. Never rename: staff\n * switches are stored under it. A plugin's types start with its plugin id\n * and a dot (`commerce.taxNotReversed`).\n */\n type: string\n /** The settings row's name. */\n label: string\n /** What it means and the first thing to do, for the settings page. */\n description: string\n tier: OperatorAlertTier\n category: OperatorAlertCategory\n /** The notification title and email subject, with `{{tokens}}`. */\n title: string\n /** What happened and what to do, with `{{tokens}}`. */\n body: string\n /** Console path the alert opens, with `{{tokens}}`; the call may override. */\n link?: string\n /** The coded default; staff may change it. */\n delivery: OperatorAlertDelivery\n /**\n * How long one `dedupeKey` stays quiet after it fired, in minutes. A\n * condition that flaps inside the window is told once. `0` never dedupes.\n * A call without a `dedupeKey` is never deduped.\n */\n dedupeWindowMinutes: number\n /**\n * Fire only once the same `dedupeKey` has been raised this many times\n * inside the window — for an endpoint any stranger can hit, where one\n * refused request is noise and several are a broken secret. Default 1.\n */\n minOccurrences?: number\n /** The coded default; staff may change it. */\n defaultEnabled: boolean\n /**\n * The console notification's type. Absent is the generic\n * `system.operatorAlert`; a type that already had its own notification\n * keeps it, so its icon, label and everyone's mutes are unchanged.\n */\n notificationType?: AglynNotificationType\n /**\n * How loud its console notification is drawn (AGL-3437). Absent, the\n * tier answers — see {@link operatorAlertLevel}. Set where the tone and\n * the importance part ways: a recovery is worth hearing about and is good\n * news, and a degraded check is a must-know that is not yet an outage.\n */\n level?: NotificationLevel\n /** The plugin that contributed it; absent for core's own. */\n pluginId?: string\n}\n\n/** Values a call site passes for an alert's `{{tokens}}`. */\nexport type OperatorAlertContext = Readonly<\n Record<string, string | number | boolean | null | undefined>\n>\n\n/** The notification type an alert without its own writes under. */\nexport const OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE: AglynNotificationType =\n 'system.operatorAlert'\n\nexport const OPERATOR_ALERT_TIER_LABELS: Record<OperatorAlertTier, string> = {\n must: 'Must know',\n should: 'Should know',\n low: 'Routine',\n}\n\n/**\n * The level an alert's notification is drawn at when its entry names none:\n * a must-know is red, a should-know amber, routine review work informational.\n */\nexport const OPERATOR_ALERT_TIER_LEVELS: Record<\n OperatorAlertTier,\n NotificationLevel\n> = {\n must: 'critical',\n should: 'warning',\n low: 'info',\n}\n\n/** The level an alert's console notification carries (AGL-3437). */\nexport function operatorAlertLevel(\n definition: Pick<OperatorAlertDefinition, 'tier' | 'level'>,\n): NotificationLevel {\n return definition.level ?? OPERATOR_ALERT_TIER_LEVELS[definition.tier]\n}\n\nexport const OPERATOR_ALERT_CATEGORY_LABELS: Record<\n OperatorAlertCategory,\n string\n> = {\n billing: 'Billing',\n payments: 'Payments & payouts',\n legal: 'Legal',\n data: 'Data protection',\n ops: 'Operations',\n deliverability: 'Email deliverability',\n security: 'Security',\n support: 'Support',\n}\n\nconst DAY = 24 * 60\n\n/**\n * Core's alerts. Plugins contribute theirs through the plugin-manager\n * registry; nothing plugin-specific belongs in this list.\n */\nexport const CORE_OPERATOR_ALERTS: readonly OperatorAlertDefinition[] = [\n // ── Fraud, risk and legal duties already alerting before AGL-3377 ──────\n {\n type: 'system.abuseReportUrgent',\n label: 'Urgent abuse, fraud or risk alert',\n description:\n 'Phishing, malware or CSAM reported on a site, card testing, a flagged or disputed payment, a held phishing email, a seller under review. Review it on Staff → Abuse reports.',\n tier: 'must',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n link: '/admin/abuse-reports',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.abuseReportUrgent',\n },\n {\n type: 'system.disputeUnattributed',\n label: 'Card dispute with no owner',\n description:\n 'A chargeback arrived that no subscription, order or marketplace purchase claimed, so nothing is answering it. Find the charge in Stripe and respond before the evidence deadline.',\n tier: 'must',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.disputeUnattributed',\n },\n {\n type: 'system.dmcaCounterNotice',\n label: 'DMCA counter-notice',\n description:\n 'A customer contested a takedown. The statutory clock is already running; forward it to the complainant and restore on time unless they file suit.',\n tier: 'must',\n category: 'legal',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.dmcaCounterNotice',\n },\n {\n type: 'system.billingWebhookHalfApplied',\n label: 'Billing webhook half applied',\n description:\n 'A Stripe event threw after its handlers began, so its effects may be partly applied and Stripe will not retry it. Reconcile the event by hand.',\n tier: 'must',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.billingWebhookHalfApplied',\n },\n\n // ── MUST: billing ───────────────────────────────────────────────────────\n {\n type: 'billing.platformDisputeLost',\n label: 'Subscription dispute lost',\n description:\n 'A card dispute on a workspace subscription closed as lost: the money has been taken back. Decide whether the workspace keeps its plan.',\n tier: 'must',\n category: 'billing',\n title: 'Subscription dispute lost on workspace {{orgId}}',\n body:\n 'The card dispute {{disputeId}} on invoice {{invoiceId}} for workspace {{orgName}} ({{orgId}}) closed as lost ({{reason}}), and {{amount}} was taken back. The workspace keeps its plan until somebody decides otherwise.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.orphanedSubscription',\n label: 'Stripe is billing a workspace that does not exist',\n description:\n 'A subscription names a workspace that was deleted or erased, so the customer keeps being charged for nothing. Cancel or refund it in Stripe.',\n tier: 'must',\n category: 'billing',\n title: 'Subscription bills a missing workspace',\n body:\n 'Stripe subscription {{subscriptionId}} (customer {{customerId}}, plan {{plan}}) names workspace {{orgId}}, which {{reason}}. The {{eventType}} event changed nothing. Cancel or refund it in Stripe.',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.usageNotReported',\n label: 'Metered usage not reported to Stripe',\n description:\n 'A closed month measured billable usage and it reached no invoice. Report it to Stripe by hand or the revenue is lost.',\n tier: 'must',\n category: 'billing',\n title: 'Usage for {{month}} not billed on {{orgId}}',\n body:\n 'The closed month {{month}} measured {{amount}} of billable usage on workspace {{orgName}} ({{orgId}}), and it was not reported to Stripe ({{reason}}). Nothing will invoice it until somebody does.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 7 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.webhookSignatureRejected',\n label: 'Billing webhook failing its signature check',\n description:\n 'Repeated deliveries to the billing webhook were refused because their signature did not match. Usually a rolled signing secret: until it is fixed, Stripe cannot tell the install about money.',\n tier: 'must',\n category: 'billing',\n title: 'Billing webhook is refusing deliveries',\n body:\n 'The billing webhook refused several signed deliveries within six hours because their Stripe signature matched none of the configured secrets. If Stripe is the sender, its signing secret no longer matches this install’s, and no billing event is being applied.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n minOccurrences: 3,\n defaultEnabled: true,\n },\n {\n type: 'billing.webhookInert',\n label: 'Billing webhook delivery moved nothing',\n description:\n 'Stripe delivered an event this install subscribed to, and no handler did anything with it. Usually a handler that stopped being registered.',\n tier: 'must',\n category: 'billing',\n title: 'A {{eventType}} delivery changed nothing',\n body:\n 'Stripe event {{eventId}} ({{eventType}}) was accepted and no handler acted on it. Check which handler stopped being registered, then replay the event.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n\n // ── MUST: data protection and records ───────────────────────────────────\n {\n type: 'data.personErasureFailed',\n label: 'Personal data erasure failed',\n description:\n 'A data subject’s erasure request failed. It will be retried, but the legal deadline keeps running: read the error and fix the cause.',\n tier: 'must',\n category: 'data',\n title: 'Erasure request {{requestId}} failed',\n body:\n 'The erasure request {{requestId}} on workspace {{orgName}} ({{orgId}}) failed (attempt {{attempt}}): {{error}}. The statutory deadline keeps running while it retries.',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'data.orgErasureFailed',\n label: 'Workspace erasure failed',\n description:\n 'A workspace scheduled for erasure could not be erased. Its data is still held after the date it was promised gone.',\n tier: 'must',\n category: 'data',\n title: 'Workspace {{orgId}} could not be erased',\n body:\n 'The scheduled erasure of workspace {{orgName}} ({{orgId}}) did not complete: {{reason}}. It will be retried on the next run; its data is held until it succeeds.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'data.backupExportFailed',\n label: 'Database export failed',\n description:\n 'The scheduled database export did not start. A stale export means the worst day has nothing to restore from.',\n tier: 'must',\n category: 'data',\n title: 'Database export failed',\n body:\n 'The scheduled database export did not start ({{reason}}). The last good export is getting older; check the service account’s export permission and the bucket.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 12 * 60,\n defaultEnabled: true,\n },\n\n // ── MUST: legal ─────────────────────────────────────────────────────────\n {\n type: 'legal.abuseReportHigh',\n label: 'Takedown, impersonation or illegal-content report',\n description:\n 'A DMCA takedown, impersonation or illegal-content report. Each carries a response duty; review it on Staff → Abuse reports.',\n tier: 'must',\n category: 'legal',\n title: '{{category}} report on {{site}}',\n body:\n '{{site}} was reported for {{category}}. Reference {{reference}}. Review it and respond within the policy’s window.',\n link: '/admin/abuse-reports',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n },\n\n // ── MUST/SHOULD: health ─────────────────────────────────────────────────\n {\n type: 'system.healthDegraded',\n label: 'Health check degraded',\n description:\n 'A health check went from healthy to degraded: scheduled jobs silent, backups stale, billing deliveries failing, sign-ups refused, server errors, a site not rendering. Told once per incident.',\n tier: 'must',\n category: 'ops',\n title: '{{check}} is degraded',\n body: '{{check}} went from healthy to degraded. {{detail}}',\n link: '/admin/health',\n delivery: 'immediate',\n // Must-know, and still amber rather than red: degraded is not down.\n level: 'warning',\n // A check flapping across the line is told once an hour, not on\n // every swing.\n dedupeWindowMinutes: 60,\n defaultEnabled: true,\n },\n {\n type: 'system.healthRecovered',\n label: 'Health check recovered',\n description: 'A degraded health check is healthy again.',\n tier: 'should',\n category: 'ops',\n title: '{{check}} recovered',\n body: '{{check}} is healthy again after {{duration}}.',\n link: '/admin/health',\n delivery: 'immediate',\n level: 'success',\n // A check flapping across the line is told once an hour, not on\n // every swing.\n dedupeWindowMinutes: 60,\n defaultEnabled: true,\n },\n\n // ── SHOULD: billing ─────────────────────────────────────────────────────\n {\n type: 'billing.autoLocked',\n label: 'Workspace billing locked automatically',\n description:\n 'A workspace crossed its spend guard and the platform locked its billable activity. Check it is not a false positive before the customer notices.',\n tier: 'should',\n category: 'billing',\n title: 'Billing locked on {{orgName}}',\n body:\n 'Workspace {{orgName}} ({{orgId}}) was locked automatically: {{reason}}.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.platformInvoiceUncollectible',\n label: 'Subscription invoice voided or uncollectible',\n description:\n 'A workspace invoice was voided or marked uncollectible: revenue the books expected is not coming.',\n tier: 'should',\n category: 'billing',\n title: 'Invoice {{status}} on {{orgId}}',\n body:\n 'Invoice {{invoiceId}} for {{amount}} on workspace {{orgName}} ({{orgId}}) was marked {{status}}, so Stripe has stopped collecting it.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.connectPayoutFailed',\n label: 'Connected account payout or transfer failed',\n description:\n 'Money due to a merchant or seller did not arrive. Stripe retries on its own schedule; a human decides what happens next.',\n tier: 'should',\n category: 'billing',\n title: 'A {{kind}} to {{account}} failed',\n body:\n 'A {{amount}} Stripe {{kind}} ({{stripeId}}) to connected account {{account}} ({{merchant}}) failed: {{reason}}. The merchant’s funds are not where the ledger says.',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'staff.paymentFailed',\n label: 'Workspace payment failed',\n description:\n 'A paying workspace’s renewal failed. Stripe is retrying; the customer has been told.',\n tier: 'should',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'staff.paymentFailed',\n },\n\n // ── SHOULD: security ────────────────────────────────────────────────────\n {\n type: 'security.staffGranted',\n label: 'Staff access granted or raised',\n description:\n 'Somebody was made staff, or a staff role was raised. If nobody on the team did it, revoke it and rotate credentials.',\n tier: 'should',\n category: 'security',\n title: '{{target}} is now {{role}} staff',\n body: '{{actor}} granted {{target}} the {{role}} staff role (was {{previous}}).',\n link: '/admin/users',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n },\n\n // ── SHOULD: support ─────────────────────────────────────────────────────\n {\n type: 'support.slaBreached',\n label: 'Support ticket past its response time',\n description:\n 'A support ticket passed its promised response time without a staff reply.',\n tier: 'should',\n category: 'support',\n title: 'Ticket past its response time: {{subject}}',\n body:\n 'The {{tier}} ticket “{{subject}}” from workspace {{orgName}} ({{orgId}}) was due a first response {{overdue}} ago and has none.',\n // The ticket itself: the support page opens `?ticketId=` on load.\n link: '/admin/support?ticketId={{ticketId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'support.ticketOpened',\n label: 'New support ticket',\n description: 'A subscriber opened a support ticket.',\n tier: 'should',\n category: 'support',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'support.ticketOpened',\n // A customer asking for help, not a fault.\n level: 'info',\n },\n {\n type: 'support.ticketReply',\n label: 'Support ticket reply',\n description: 'A subscriber replied on a support ticket.',\n tier: 'should',\n category: 'support',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'support.ticketReply',\n level: 'info',\n },\n\n // ── SHOULD: deliverability ──────────────────────────────────────────────\n {\n type: 'deliverability.sendRateSaturated',\n label: 'Email send-rate governor saturated',\n description:\n 'The platform’s email send-rate budget is exhausted, so sends are being deferred. Raise the budget or find the sender filling it.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email sending is being throttled',\n body:\n 'The {{scope}} send-rate budget is full ({{detail}}), so sends are being deferred.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.sendRateUnavailable',\n label: 'Email send-rate governor unavailable',\n description:\n 'The send-rate governor could not read its counters, so mail is going out unmetered: nothing is holding the shared domains under their hourly ceiling.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email send-rate governor unavailable',\n body:\n 'The send-rate governor could not read its counters ({{error}}), so sends are being allowed unmetered until it can.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.gatewayBlocking',\n label: 'A recipient gateway is blocking the shared domain',\n description:\n 'A mail gateway is refusing mail from the shared sending domain, so everyone behind that gateway stops receiving it.',\n tier: 'should',\n category: 'deliverability',\n title: '{{gateway}} is blocking {{domain}}',\n body:\n 'Mail from {{domain}} is being refused by {{gateway}} ({{detail}}). Recipients behind that gateway are not receiving it.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.providerCredentialsRejected',\n label: 'Email provider rejected the credentials',\n description:\n 'The email provider refused the API key, or a sending pool is unhealthy: invites, password resets and receipts are not leaving. The webhook channel is the one that still reaches you.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email delivery is failing: {{problem}}',\n body: '{{detail}}',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n // A purchasing ceiling, not a job failure: the provisioning sweep ran\n // and every site keeps sending, so the copy must not read as an outage.\n type: 'deliverability.sendingDomainCapacityFull',\n label: 'Sending-domain allowance full',\n description:\n 'Every dedicated sending domain the provider allowance covers is in use, so new sites that ask for one stay on the shared pool. No mail is lost; raise the allowance or move merchants onto domains they own.',\n tier: 'should',\n category: 'deliverability',\n title: 'Sending-domain allowance full ({{held}}/{{capacity}})',\n body:\n 'The sending-domain allowance is full ({{held}} of {{capacity}} in use), so sites that asked for a dedicated domain keep sending on the shared pool. No mail is lost: what they go without is the isolation a domain of their own buys, and their campaigns are held to the pool’s stricter reputation limits. Raise AGLYN_SENDING_DOMAIN_CAPACITY once the provider allowance covers it, or move merchants onto domains they own.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n\n // ── SHOULD: operations ──────────────────────────────────────────────────\n {\n type: 'ops.reaperStuck',\n label: 'A reaper is stuck or failing',\n description:\n 'A cleanup job (sending domains, unverified workspaces) could not finish, so what it removes keeps accumulating.',\n tier: 'should',\n category: 'ops',\n title: '{{job}} is stuck',\n body: '{{job}} could not finish: {{detail}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'ops.pluginJobFailed',\n label: 'A plugin job failed',\n description:\n 'A plugin’s scheduled job threw: consent-group changes, the publish outbox, sending-domain provisioning, a plugin cron.',\n tier: 'should',\n category: 'ops',\n // `job` is a noun phrase that reads as a subject on its own (\"The\n // publish outbox drain\", \"The scheduled job acme-mail-send\"), so the\n // title and the body open with the same words.\n title: '{{job}} failed',\n body: '{{job}} failed: {{error}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n type: 'system.pluginVerifierRegression',\n label: 'Plugin verifier regression',\n description:\n 'A live plugin version stopped passing the static verifier.',\n tier: 'should',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.pluginVerifierRegression',\n },\n {\n type: 'system.bandwidthCeilingTripped',\n label: 'Bandwidth ceiling reached',\n description:\n 'A site crossed the per-month bandwidth abuse ceiling.',\n tier: 'should',\n category: 'ops',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.bandwidthCeilingTripped',\n },\n\n // ── LOW ─────────────────────────────────────────────────────────────────\n {\n type: 'marketplace.review',\n label: 'Plugin listing waiting for review',\n description: 'A plugin listing or version was submitted for review.',\n tier: 'low',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'marketplace.review',\n },\n {\n type: 'system.scopeDrift',\n label: 'Resources missing a sharing scope',\n description:\n 'The weekly sweep found documents invisible to every site-scoped read: a creation path forgot the field.',\n tier: 'low',\n category: 'data',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.scopeDrift',\n },\n]\n\n/** An amount in minor units, as an alert prints it: `$12.40`. */\nexport function formatOperatorAlertAmount(\n cents: number,\n currency = 'usd',\n): string {\n const value = Number(cents) / 100\n if (!Number.isFinite(value)) return ''\n try {\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: String(currency || 'usd').toUpperCase(),\n }).format(value)\n } catch {\n return `${value.toFixed(2)} ${String(currency).toUpperCase()}`\n }\n}\n\n/** Replaces each `{{token}}` with its context value, or nothing. */\nexport function renderOperatorAlertTemplate(\n template: string,\n context: OperatorAlertContext | undefined,\n): string {\n return String(template ?? '')\n .replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_match, name: string) => {\n const value = context?.[name]\n return value === null || value === undefined ? '' : String(value)\n })\n .replace(/\\(\\s*\\)/g, '')\n .replace(/[ \\t]{2,}/g, ' ')\n .replace(/ ([.,;:])/g, '$1')\n .trim()\n}\n\n/** A staff member's answer for one type; an absent key keeps the default. */\nexport interface OperatorAlertTypeSetting {\n enabled?: boolean\n delivery?: OperatorAlertDelivery\n}\n\n/** `platformSettings/operatorAlerts`, keyed by alert type. */\nexport interface OperatorAlertSettings {\n types?: Record<string, OperatorAlertTypeSetting>\n}\n\n/** Whether a type is on, and how it is delivered, after staff's answers. */\nexport function effectiveOperatorAlertSetting(\n definition: Pick<OperatorAlertDefinition, 'type' | 'delivery' | 'defaultEnabled'>,\n settings: OperatorAlertSettings | null | undefined,\n): { enabled: boolean; delivery: OperatorAlertDelivery } {\n const answer = settings?.types?.[definition.type]\n return {\n enabled:\n typeof answer?.enabled === 'boolean'\n ? answer.enabled\n : definition.defaultEnabled,\n delivery:\n answer?.delivery === 'immediate' || answer?.delivery === 'digest'\n ? answer.delivery\n : definition.delivery,\n }\n}\n\n/**\n * Staff notification types whose alert emails the operator on a coded\n * default of immediate (AGL-3375), derived from the catalog so there is one\n * list.\n */\nexport const OPERATOR_ALERT_NOTIFICATION_TYPES: ReadonlySet<string> = new Set(\n CORE_OPERATOR_ALERTS.flatMap((definition) =>\n definition.notificationType ? [definition.notificationType] : [],\n ),\n)\n"],"names":["OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE","OPERATOR_ALERT_TIER_LABELS","must","should","low","OPERATOR_ALERT_TIER_LEVELS","operatorAlertLevel","definition","level","tier","OPERATOR_ALERT_CATEGORY_LABELS","billing","payments","legal","data","ops","deliverability","security","support","DAY","CORE_OPERATOR_ALERTS","type","label","description","category","title","body","link","delivery","dedupeWindowMinutes","defaultEnabled","notificationType","minOccurrences","formatOperatorAlertAmount","cents","currency","value","Number","isFinite","Intl","NumberFormat","style","String","toUpperCase","format","toFixed","renderOperatorAlertTemplate","template","context","replace","_match","name","undefined","trim","effectiveOperatorAlertSetting","settings","answer","types","enabled","OPERATOR_ALERT_NOTIFICATION_TYPES","Set","flatMap"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA6GD,iEAAiE,GACjE,OAAO,MAAMA,2CACX,uBAAsB;AAExB,OAAO,MAAMC,6BAAgE;IAC3EC,MAAM;IACNC,QAAQ;IACRC,KAAK;AACP,EAAC;AAED;;;CAGC,GACD,OAAO,MAAMC,6BAGT;IACFH,MAAM;IACNC,QAAQ;IACRC,KAAK;AACP,EAAC;AAED,kEAAkE,GAClE,OAAO,SAASE,mBACdC,UAA2D;QAEpDA;IAAP,QAAOA,oBAAAA,WAAWC,KAAK,YAAhBD,oBAAoBF,0BAA0B,CAACE,WAAWE,IAAI,CAAC;AACxE;AAEA,OAAO,MAAMC,iCAGT;IACFC,SAAS;IACTC,UAAU;IACVC,OAAO;IACPC,MAAM;IACNC,KAAK;IACLC,gBAAgB;IAChBC,UAAU;IACVC,SAAS;AACX,EAAC;AAED,MAAMC,MAAM,KAAK;AAEjB;;;CAGC,GACD,OAAO,MAAMC,uBAA2D;IACtE,0EAA0E;IAC1E;QACEC,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IAEA,2EAA2E;IAC3E;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAIV;QACzBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBG,gBAAgB;QAChBF,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAK;QAC1BC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACV,oEAAoE;QACpEpB,OAAO;QACP,gEAAgE;QAChE,eAAe;QACfqB,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVpB,OAAO;QACP,gEAAgE;QAChE,eAAe;QACfqB,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IAEA,2EAA2E;IAC3E;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACF,kEAAkE;QAClEC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;QAClB,2CAA2C;QAC3CvB,OAAO;IACT;IACA;QACEa,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;QAClBvB,OAAO;IACT;IAEA,2EAA2E;IAC3E;QACEa,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACE,sEAAsE;QACtE,wEAAwE;QACxET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACV,kEAAkE;QAClE,qEAAqE;QACrE,+CAA+C;QAC/CC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IAEA,2EAA2E;IAC3E;QACEV,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;CACD,CAAA;AAED,+DAA+D,GAC/D,OAAO,SAASE,0BACdC,KAAa,EACbC,WAAW,KAAK;IAEhB,MAAMC,QAAQC,OAAOH,SAAS;IAC9B,IAAI,CAACG,OAAOC,QAAQ,CAACF,QAAQ,OAAO;IACpC,IAAI;QACF,OAAO,IAAIG,KAAKC,YAAY,CAAC,SAAS;YACpCC,OAAO;YACPN,UAAUO,OAAOP,YAAY,OAAOQ,WAAW;QACjD,GAAGC,MAAM,CAACR;IACZ,EAAE,eAAM;QACN,OAAO,GAAGA,MAAMS,OAAO,CAAC,GAAG,CAAC,EAAEH,OAAOP,UAAUQ,WAAW,IAAI;IAChE;AACF;AAEA,kEAAkE,GAClE,OAAO,SAASG,4BACdC,QAAgB,EAChBC,OAAyC;IAEzC,OAAON,OAAOK,mBAAAA,WAAY,IACvBE,OAAO,CAAC,2BAA2B,CAACC,QAAQC;QAC3C,MAAMf,QAAQY,2BAAAA,OAAS,CAACG,KAAK;QAC7B,OAAOf,UAAU,QAAQA,UAAUgB,YAAY,KAAKV,OAAON;IAC7D,GACCa,OAAO,CAAC,YAAY,IACpBA,OAAO,CAAC,cAAc,KACtBA,OAAO,CAAC,cAAc,MACtBI,IAAI;AACT;AAaA,0EAA0E,GAC1E,OAAO,SAASC,8BACd/C,UAAiF,EACjFgD,QAAkD;QAEnCA;IAAf,MAAMC,SAASD,6BAAAA,kBAAAA,SAAUE,KAAK,qBAAfF,eAAiB,CAAChD,WAAWc,IAAI,CAAC;IACjD,OAAO;QACLqC,SACE,QAAOF,0BAAAA,OAAQE,OAAO,MAAK,YACvBF,OAAOE,OAAO,GACdnD,WAAWuB,cAAc;QAC/BF,UACE4B,CAAAA,0BAAAA,OAAQ5B,QAAQ,MAAK,eAAe4B,CAAAA,0BAAAA,OAAQ5B,QAAQ,MAAK,WACrD4B,OAAO5B,QAAQ,GACfrB,WAAWqB,QAAQ;IAC3B;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAM+B,oCAAyD,IAAIC,IACxExC,qBAAqByC,OAAO,CAAC,CAACtD,aAC5BA,WAAWwB,gBAAgB,GAAG;QAACxB,WAAWwB,gBAAgB;KAAC,GAAG,EAAE,GAEnE"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/operator-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynNotificationType, NotificationLevel } from './notifications'\n\n/**\n * OPERATOR ALERTS (AGL-3377): the events whoever runs an install must hear\n * about, as one typed catalog.\n *\n * Every alert is an entry here (or a plugin's entry in the same shape,\n * registered through `@aglyn/aglyn/plugin-manager/operator-alerts`) and is\n * raised through ONE server entry point, `raiseOperatorAlert` in\n * `@aglyn/tenant-data-admin/server/operator-alerts`. That entry point writes\n * the staff console notification, dedupes a flapping condition, honours the\n * per-type switches staff set on Staff → Operator alerts, and delivers by\n * email (the `operator-alert` system email) and the optional out-of-band\n * webhook. No call site composes or sends its own mail.\n *\n * The catalog holds nothing install-specific: no address, no domain, no\n * host. Recipients come from `resolveStaffAlertRecipients()` and the webhook\n * from `OPERATOR_ALERT_WEBHOOK_URL`, so an entry reads the same on aglyn.com,\n * on a self-hosted install and in an OSS consumer's deployment.\n *\n * Copy is written against merge tokens (`{{orgId}}`), filled from the\n * `context` the call site passes. A token the call site did not supply is\n * blanked, so every sentence must still read when its optional tokens are\n * empty. Never put a data subject's address or other personal data into a\n * context: the alert travels to an inbox and a webhook, not only the console.\n */\n\n/**\n * How much an alert matters.\n *\n * - `must` — money moving with nobody looking, a legal duty unmet, data not\n * protected or not erased. On and immediate unless staff change it.\n * - `should` — a fault an operator wants to know about today.\n * - `low` — routine review work; on, and batched into the daily digest.\n */\nexport type OperatorAlertTier = 'must' | 'should' | 'low'\n\nexport type OperatorAlertCategory =\n | 'billing'\n | 'payments'\n | 'legal'\n | 'data'\n | 'ops'\n | 'deliverability'\n | 'security'\n | 'support'\n\n/** `digest` batches the alert into the once-a-day operator digest. */\nexport type OperatorAlertDelivery = 'immediate' | 'digest'\n\nexport interface OperatorAlertDefinition {\n /**\n * Stable id, also the key of its settings row. Never rename: staff\n * switches are stored under it. A plugin's types start with its plugin id\n * and a dot (`commerce.taxNotReversed`).\n */\n type: string\n /** The settings row's name. */\n label: string\n /** What it means and the first thing to do, for the settings page. */\n description: string\n tier: OperatorAlertTier\n category: OperatorAlertCategory\n /** The notification title and email subject, with `{{tokens}}`. */\n title: string\n /** What happened and what to do, with `{{tokens}}`. */\n body: string\n /** Console path the alert opens, with `{{tokens}}`; the call may override. */\n link?: string\n /** The coded default; staff may change it. */\n delivery: OperatorAlertDelivery\n /**\n * How long one `dedupeKey` stays quiet after it fired, in minutes. A\n * condition that flaps inside the window is told once. `0` never dedupes.\n * A call without a `dedupeKey` is never deduped.\n */\n dedupeWindowMinutes: number\n /**\n * Fire only once the same `dedupeKey` has been raised this many times\n * inside the window — for an endpoint any stranger can hit, where one\n * refused request is noise and several are a broken secret. Default 1.\n */\n minOccurrences?: number\n /** The coded default; staff may change it. */\n defaultEnabled: boolean\n /**\n * The console notification's type. Absent is the generic\n * `system.operatorAlert`; a type that already had its own notification\n * keeps it, so its icon, label and everyone's mutes are unchanged.\n */\n notificationType?: AglynNotificationType\n /**\n * How loud its console notification is drawn (AGL-3437). Absent, the\n * tier answers — see {@link operatorAlertLevel}. Set where the tone and\n * the importance part ways: a recovery is worth hearing about and is good\n * news, and a degraded check is a must-know that is not yet an outage.\n */\n level?: NotificationLevel\n /** The plugin that contributed it; absent for core's own. */\n pluginId?: string\n}\n\n/** Values a call site passes for an alert's `{{tokens}}`. */\nexport type OperatorAlertContext = Readonly<\n Record<string, string | number | boolean | null | undefined>\n>\n\n/** The notification type an alert without its own writes under. */\nexport const OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE: AglynNotificationType =\n 'system.operatorAlert'\n\nexport const OPERATOR_ALERT_TIER_LABELS: Record<OperatorAlertTier, string> = {\n must: 'Must know',\n should: 'Should know',\n low: 'Routine',\n}\n\n/**\n * The level an alert's notification is drawn at when its entry names none:\n * a must-know is red, a should-know amber, routine review work informational.\n */\nexport const OPERATOR_ALERT_TIER_LEVELS: Record<\n OperatorAlertTier,\n NotificationLevel\n> = {\n must: 'critical',\n should: 'warning',\n low: 'info',\n}\n\n/** The level an alert's console notification carries (AGL-3437). */\nexport function operatorAlertLevel(\n definition: Pick<OperatorAlertDefinition, 'tier' | 'level'>,\n): NotificationLevel {\n return definition.level ?? OPERATOR_ALERT_TIER_LEVELS[definition.tier]\n}\n\nexport const OPERATOR_ALERT_CATEGORY_LABELS: Record<\n OperatorAlertCategory,\n string\n> = {\n billing: 'Billing',\n payments: 'Payments & payouts',\n legal: 'Legal',\n data: 'Data protection',\n ops: 'Operations',\n deliverability: 'Email deliverability',\n security: 'Security',\n support: 'Support',\n}\n\nconst DAY = 24 * 60\n\n/**\n * Core's alerts. Plugins contribute theirs through the plugin-manager\n * registry; nothing plugin-specific belongs in this list.\n */\nexport const CORE_OPERATOR_ALERTS: readonly OperatorAlertDefinition[] = [\n // ── Fraud, risk and legal duties already alerting before AGL-3377 ──────\n {\n type: 'system.abuseReportUrgent',\n label: 'Urgent abuse, fraud or risk alert',\n description:\n 'Phishing, malware or CSAM reported on a site, card testing, a flagged or disputed payment, a held phishing email, a seller under review. Review it on Staff → Abuse reports.',\n tier: 'must',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n link: '/admin/abuse-reports',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.abuseReportUrgent',\n },\n {\n // The one lock no person placed (AGL-3450): staff confirm it before the\n // customer's appeal arrives, and re-place it as `abuse` or lift it.\n type: 'security.pageSecurityHold',\n label: 'Automatic security hold on a new workspace',\n description:\n 'The page screen held a phishing page from a workspace in its first two weeks and locked the workspace, the site and the publishing account as security. Confirm it on Staff → Abuse reports, then re-place it as abuse on Staff → Lockdown, or lift it there if it is a false positive.',\n tier: 'must',\n category: 'security',\n title: 'Security hold placed on {{orgName}}',\n body:\n '{{page}} on {{siteName}} ({{hostId}}) was held for review: {{signal}} Locked as security, pending your review: {{locks}}. The abuse row is {{reference}}. Lift the hold, or re-place it as abuse, on Staff → Lockdown (/admin/lockdown).',\n link: '/admin/abuse-reports?report={{reviewId}}',\n delivery: 'immediate',\n // One hold per workspace; a repeat inside the day is the same event.\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n notificationType: 'system.abuseReportUrgent',\n },\n {\n type: 'system.disputeUnattributed',\n label: 'Card dispute with no owner',\n description:\n 'A chargeback arrived that no subscription, order or marketplace purchase claimed, so nothing is answering it. Find the charge in Stripe and respond before the evidence deadline.',\n tier: 'must',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.disputeUnattributed',\n },\n {\n type: 'system.dmcaCounterNotice',\n label: 'DMCA counter-notice',\n description:\n 'A customer contested a takedown. The statutory clock is already running; forward it to the complainant and restore on time unless they file suit.',\n tier: 'must',\n category: 'legal',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.dmcaCounterNotice',\n },\n {\n type: 'system.billingWebhookHalfApplied',\n label: 'Billing webhook half applied',\n description:\n 'A Stripe event threw after its handlers began, so its effects may be partly applied and Stripe will not retry it. Reconcile the event by hand.',\n tier: 'must',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.billingWebhookHalfApplied',\n },\n\n // ── MUST: billing ───────────────────────────────────────────────────────\n {\n type: 'billing.platformDisputeLost',\n label: 'Subscription dispute lost',\n description:\n 'A card dispute on a workspace subscription closed as lost: the money has been taken back. Decide whether the workspace keeps its plan.',\n tier: 'must',\n category: 'billing',\n title: 'Subscription dispute lost on workspace {{orgId}}',\n body:\n 'The card dispute {{disputeId}} on invoice {{invoiceId}} for workspace {{orgName}} ({{orgId}}) closed as lost ({{reason}}), and {{amount}} was taken back. The workspace keeps its plan until somebody decides otherwise.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.orphanedSubscription',\n label: 'Stripe is billing a workspace that does not exist',\n description:\n 'A subscription names a workspace that was deleted or erased, so the customer keeps being charged for nothing. Cancel or refund it in Stripe.',\n tier: 'must',\n category: 'billing',\n title: 'Subscription bills a missing workspace',\n body:\n 'Stripe subscription {{subscriptionId}} (customer {{customerId}}, plan {{plan}}) names workspace {{orgId}}, which {{reason}}. The {{eventType}} event changed nothing. Cancel or refund it in Stripe.',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.usageNotReported',\n label: 'Metered usage not reported to Stripe',\n description:\n 'A closed month measured billable usage and it reached no invoice. Report it to Stripe by hand or the revenue is lost.',\n tier: 'must',\n category: 'billing',\n title: 'Usage for {{month}} not billed on {{orgId}}',\n body:\n 'The closed month {{month}} measured {{amount}} of billable usage on workspace {{orgName}} ({{orgId}}), and it was not reported to Stripe ({{reason}}). Nothing will invoice it until somebody does.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 7 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.webhookSignatureRejected',\n label: 'Billing webhook failing its signature check',\n description:\n 'Repeated deliveries to the billing webhook were refused because their signature did not match. Usually a rolled signing secret: until it is fixed, Stripe cannot tell the install about money.',\n tier: 'must',\n category: 'billing',\n title: 'Billing webhook is refusing deliveries',\n body:\n 'The billing webhook refused several signed deliveries within six hours because their Stripe signature matched none of the configured secrets. If Stripe is the sender, its signing secret no longer matches this install’s, and no billing event is being applied.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n minOccurrences: 3,\n defaultEnabled: true,\n },\n {\n type: 'billing.webhookInert',\n label: 'Billing webhook delivery moved nothing',\n description:\n 'Stripe delivered an event this install subscribed to, and no handler did anything with it. Usually a handler that stopped being registered.',\n tier: 'must',\n category: 'billing',\n title: 'A {{eventType}} delivery changed nothing',\n body:\n 'Stripe event {{eventId}} ({{eventType}}) was accepted and no handler acted on it. Check which handler stopped being registered, then replay the event.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n\n // ── MUST: data protection and records ───────────────────────────────────\n {\n type: 'data.personErasureFailed',\n label: 'Personal data erasure failed',\n description:\n 'A data subject’s erasure request failed. It will be retried, but the legal deadline keeps running: read the error and fix the cause.',\n tier: 'must',\n category: 'data',\n title: 'Erasure request {{requestId}} failed',\n body:\n 'The erasure request {{requestId}} on workspace {{orgName}} ({{orgId}}) failed (attempt {{attempt}}): {{error}}. The statutory deadline keeps running while it retries.',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'data.orgErasureFailed',\n label: 'Workspace erasure failed',\n description:\n 'A workspace scheduled for erasure could not be erased. Its data is still held after the date it was promised gone.',\n tier: 'must',\n category: 'data',\n title: 'Workspace {{orgId}} could not be erased',\n body:\n 'The scheduled erasure of workspace {{orgName}} ({{orgId}}) did not complete: {{reason}}. It will be retried on the next run; its data is held until it succeeds.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'data.backupExportFailed',\n label: 'Database export failed',\n description:\n 'The scheduled database export did not start. A stale export means the worst day has nothing to restore from.',\n tier: 'must',\n category: 'data',\n title: 'Database export failed',\n body:\n 'The scheduled database export did not start ({{reason}}). The last good export is getting older; check the service account’s export permission and the bucket.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 12 * 60,\n defaultEnabled: true,\n },\n\n // ── MUST: legal ─────────────────────────────────────────────────────────\n {\n type: 'legal.abuseReportHigh',\n label: 'Takedown, impersonation or illegal-content report',\n description:\n 'A DMCA takedown, impersonation or illegal-content report. Each carries a response duty; review it on Staff → Abuse reports.',\n tier: 'must',\n category: 'legal',\n title: '{{category}} report on {{site}}',\n body:\n '{{site}} was reported for {{category}}. Reference {{reference}}. Review it and respond within the policy’s window.',\n link: '/admin/abuse-reports',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n },\n\n // ── MUST/SHOULD: health ─────────────────────────────────────────────────\n {\n type: 'system.healthDegraded',\n label: 'Health check degraded',\n description:\n 'A health check went from healthy to degraded: scheduled jobs silent, backups stale, billing deliveries failing, sign-ups refused, server errors, a site not rendering. Told once per incident.',\n tier: 'must',\n category: 'ops',\n title: '{{check}} is degraded',\n body: '{{check}} went from healthy to degraded. {{detail}}',\n link: '/admin/health',\n delivery: 'immediate',\n // Must-know, and still amber rather than red: degraded is not down.\n level: 'warning',\n // A check flapping across the line is told once an hour, not on\n // every swing.\n dedupeWindowMinutes: 60,\n defaultEnabled: true,\n },\n {\n type: 'system.healthRecovered',\n label: 'Health check recovered',\n description: 'A degraded health check is healthy again.',\n tier: 'should',\n category: 'ops',\n title: '{{check}} recovered',\n body: '{{check}} is healthy again after {{duration}}.',\n link: '/admin/health',\n delivery: 'immediate',\n level: 'success',\n // A check flapping across the line is told once an hour, not on\n // every swing.\n dedupeWindowMinutes: 60,\n defaultEnabled: true,\n },\n\n // ── SHOULD: billing ─────────────────────────────────────────────────────\n {\n type: 'billing.autoLocked',\n label: 'Workspace billing locked automatically',\n description:\n 'A workspace crossed its spend guard and the platform locked its billable activity. Check it is not a false positive before the customer notices.',\n tier: 'should',\n category: 'billing',\n title: 'Billing locked on {{orgName}}',\n body:\n 'Workspace {{orgName}} ({{orgId}}) was locked automatically: {{reason}}.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.platformInvoiceUncollectible',\n label: 'Subscription invoice voided or uncollectible',\n description:\n 'A workspace invoice was voided or marked uncollectible: revenue the books expected is not coming.',\n tier: 'should',\n category: 'billing',\n title: 'Invoice {{status}} on {{orgId}}',\n body:\n 'Invoice {{invoiceId}} for {{amount}} on workspace {{orgName}} ({{orgId}}) was marked {{status}}, so Stripe has stopped collecting it.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.connectPayoutFailed',\n label: 'Connected account payout or transfer failed',\n description:\n 'Money due to a merchant or seller did not arrive. Stripe retries on its own schedule; a human decides what happens next.',\n tier: 'should',\n category: 'billing',\n title: 'A {{kind}} to {{account}} failed',\n body:\n 'A {{amount}} Stripe {{kind}} ({{stripeId}}) to connected account {{account}} ({{merchant}}) failed: {{reason}}. The merchant’s funds are not where the ledger says.',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'staff.paymentFailed',\n label: 'Workspace payment failed',\n description:\n 'A paying workspace’s renewal failed. Stripe is retrying; the customer has been told.',\n tier: 'should',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'staff.paymentFailed',\n },\n\n // ── SHOULD: security ────────────────────────────────────────────────────\n {\n type: 'security.staffGranted',\n label: 'Staff access granted or raised',\n description:\n 'Somebody was made staff, or a staff role was raised. If nobody on the team did it, revoke it and rotate credentials.',\n tier: 'should',\n category: 'security',\n title: '{{target}} is now {{role}} staff',\n body: '{{actor}} granted {{target}} the {{role}} staff role (was {{previous}}).',\n link: '/admin/users',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n },\n\n // ── SHOULD: support ─────────────────────────────────────────────────────\n {\n type: 'support.slaBreached',\n label: 'Support ticket past its response time',\n description:\n 'A support ticket passed its promised response time without a staff reply.',\n tier: 'should',\n category: 'support',\n title: 'Ticket past its response time: {{subject}}',\n body:\n 'The {{tier}} ticket “{{subject}}” from workspace {{orgName}} ({{orgId}}) was due a first response {{overdue}} ago and has none.',\n // The ticket itself: the support page opens `?ticketId=` on load.\n link: '/admin/support?ticketId={{ticketId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'support.ticketOpened',\n label: 'New support ticket',\n description: 'A subscriber opened a support ticket.',\n tier: 'should',\n category: 'support',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'support.ticketOpened',\n // A customer asking for help, not a fault.\n level: 'info',\n },\n {\n type: 'support.ticketReply',\n label: 'Support ticket reply',\n description: 'A subscriber replied on a support ticket.',\n tier: 'should',\n category: 'support',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'support.ticketReply',\n level: 'info',\n },\n\n // ── SHOULD: deliverability ──────────────────────────────────────────────\n {\n type: 'deliverability.sendRateSaturated',\n label: 'Email send-rate governor saturated',\n description:\n 'The platform’s email send-rate budget is exhausted, so sends are being deferred. Raise the budget or find the sender filling it.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email sending is being throttled',\n body:\n 'The {{scope}} send-rate budget is full ({{detail}}), so sends are being deferred.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.sendRateUnavailable',\n label: 'Email send-rate governor unavailable',\n description:\n 'The send-rate governor could not read its counters, so mail is going out unmetered: nothing is holding the shared domains under their hourly ceiling.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email send-rate governor unavailable',\n body:\n 'The send-rate governor could not read its counters ({{error}}), so sends are being allowed unmetered until it can.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.gatewayBlocking',\n label: 'A recipient gateway is blocking the shared domain',\n description:\n 'A mail gateway is refusing mail from the shared sending domain, so everyone behind that gateway stops receiving it.',\n tier: 'should',\n category: 'deliverability',\n title: '{{gateway}} is blocking {{domain}}',\n body:\n 'Mail from {{domain}} is being refused by {{gateway}} ({{detail}}). Recipients behind that gateway are not receiving it.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.providerCredentialsRejected',\n label: 'Email provider rejected the credentials',\n description:\n 'The email provider refused the API key, or a sending pool is unhealthy: invites, password resets and receipts are not leaving. The webhook channel is the one that still reaches you.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email delivery is failing: {{problem}}',\n body: '{{detail}}',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n // A purchasing ceiling, not a job failure: the provisioning sweep ran\n // and every site keeps sending, so the copy must not read as an outage.\n type: 'deliverability.sendingDomainCapacityFull',\n label: 'Sending-domain allowance full',\n description:\n 'Every dedicated sending domain the provider allowance covers is in use, so new sites that ask for one stay on the shared pool. No mail is lost; raise the allowance or move merchants onto domains they own.',\n tier: 'should',\n category: 'deliverability',\n title: 'Sending-domain allowance full ({{held}}/{{capacity}})',\n body:\n 'The sending-domain allowance is full ({{held}} of {{capacity}} in use), so sites that asked for a dedicated domain keep sending on the shared pool. No mail is lost: what they go without is the isolation a domain of their own buys, and their campaigns are held to the pool’s stricter reputation limits. Raise AGLYN_SENDING_DOMAIN_CAPACITY once the provider allowance covers it, or move merchants onto domains they own.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n\n // ── SHOULD: operations ──────────────────────────────────────────────────\n {\n type: 'ops.reaperStuck',\n label: 'A reaper is stuck or failing',\n description:\n 'A cleanup job (sending domains, unverified workspaces) could not finish, so what it removes keeps accumulating.',\n tier: 'should',\n category: 'ops',\n title: '{{job}} is stuck',\n body: '{{job}} could not finish: {{detail}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'ops.pluginJobFailed',\n label: 'A plugin job failed',\n description:\n 'A plugin’s scheduled job threw: consent-group changes, the publish outbox, sending-domain provisioning, a plugin cron.',\n tier: 'should',\n category: 'ops',\n // `job` is a noun phrase that reads as a subject on its own (\"The\n // publish outbox drain\", \"The scheduled job acme-mail-send\"), so the\n // title and the body open with the same words.\n title: '{{job}} failed',\n body: '{{job}} failed: {{error}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n type: 'system.pluginVerifierRegression',\n label: 'Plugin verifier regression',\n description:\n 'A live plugin version stopped passing the static verifier.',\n tier: 'should',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.pluginVerifierRegression',\n },\n {\n type: 'system.bandwidthCeilingTripped',\n label: 'Bandwidth ceiling reached',\n description:\n 'A site crossed the per-month bandwidth abuse ceiling.',\n tier: 'should',\n category: 'ops',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.bandwidthCeilingTripped',\n },\n\n // ── LOW ─────────────────────────────────────────────────────────────────\n {\n type: 'marketplace.review',\n label: 'Plugin listing waiting for review',\n description: 'A plugin listing or version was submitted for review.',\n tier: 'low',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'marketplace.review',\n },\n {\n type: 'system.scopeDrift',\n label: 'Resources missing a sharing scope',\n description:\n 'The weekly sweep found documents invisible to every site-scoped read: a creation path forgot the field.',\n tier: 'low',\n category: 'data',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.scopeDrift',\n },\n]\n\n/** An amount in minor units, as an alert prints it: `$12.40`. */\nexport function formatOperatorAlertAmount(\n cents: number,\n currency = 'usd',\n): string {\n const value = Number(cents) / 100\n if (!Number.isFinite(value)) return ''\n try {\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: String(currency || 'usd').toUpperCase(),\n }).format(value)\n } catch {\n return `${value.toFixed(2)} ${String(currency).toUpperCase()}`\n }\n}\n\n/** Replaces each `{{token}}` with its context value, or nothing. */\nexport function renderOperatorAlertTemplate(\n template: string,\n context: OperatorAlertContext | undefined,\n): string {\n return String(template ?? '')\n .replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_match, name: string) => {\n const value = context?.[name]\n return value === null || value === undefined ? '' : String(value)\n })\n .replace(/\\(\\s*\\)/g, '')\n .replace(/[ \\t]{2,}/g, ' ')\n .replace(/ ([.,;:])/g, '$1')\n .trim()\n}\n\n/** A staff member's answer for one type; an absent key keeps the default. */\nexport interface OperatorAlertTypeSetting {\n enabled?: boolean\n delivery?: OperatorAlertDelivery\n}\n\n/** `platformSettings/operatorAlerts`, keyed by alert type. */\nexport interface OperatorAlertSettings {\n types?: Record<string, OperatorAlertTypeSetting>\n}\n\n/** Whether a type is on, and how it is delivered, after staff's answers. */\nexport function effectiveOperatorAlertSetting(\n definition: Pick<OperatorAlertDefinition, 'type' | 'delivery' | 'defaultEnabled'>,\n settings: OperatorAlertSettings | null | undefined,\n): { enabled: boolean; delivery: OperatorAlertDelivery } {\n const answer = settings?.types?.[definition.type]\n return {\n enabled:\n typeof answer?.enabled === 'boolean'\n ? answer.enabled\n : definition.defaultEnabled,\n delivery:\n answer?.delivery === 'immediate' || answer?.delivery === 'digest'\n ? answer.delivery\n : definition.delivery,\n }\n}\n\n/**\n * Staff notification types whose alert emails the operator on a coded\n * default of immediate (AGL-3375), derived from the catalog so there is one\n * list.\n */\nexport const OPERATOR_ALERT_NOTIFICATION_TYPES: ReadonlySet<string> = new Set(\n CORE_OPERATOR_ALERTS.flatMap((definition) =>\n definition.notificationType ? [definition.notificationType] : [],\n ),\n)\n"],"names":["OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE","OPERATOR_ALERT_TIER_LABELS","must","should","low","OPERATOR_ALERT_TIER_LEVELS","operatorAlertLevel","definition","level","tier","OPERATOR_ALERT_CATEGORY_LABELS","billing","payments","legal","data","ops","deliverability","security","support","DAY","CORE_OPERATOR_ALERTS","type","label","description","category","title","body","link","delivery","dedupeWindowMinutes","defaultEnabled","notificationType","minOccurrences","formatOperatorAlertAmount","cents","currency","value","Number","isFinite","Intl","NumberFormat","style","String","toUpperCase","format","toFixed","renderOperatorAlertTemplate","template","context","replace","_match","name","undefined","trim","effectiveOperatorAlertSetting","settings","answer","types","enabled","OPERATOR_ALERT_NOTIFICATION_TYPES","Set","flatMap"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA6GD,iEAAiE,GACjE,OAAO,MAAMA,2CACX,uBAAsB;AAExB,OAAO,MAAMC,6BAAgE;IAC3EC,MAAM;IACNC,QAAQ;IACRC,KAAK;AACP,EAAC;AAED;;;CAGC,GACD,OAAO,MAAMC,6BAGT;IACFH,MAAM;IACNC,QAAQ;IACRC,KAAK;AACP,EAAC;AAED,kEAAkE,GAClE,OAAO,SAASE,mBACdC,UAA2D;QAEpDA;IAAP,QAAOA,oBAAAA,WAAWC,KAAK,YAAhBD,oBAAoBF,0BAA0B,CAACE,WAAWE,IAAI,CAAC;AACxE;AAEA,OAAO,MAAMC,iCAGT;IACFC,SAAS;IACTC,UAAU;IACVC,OAAO;IACPC,MAAM;IACNC,KAAK;IACLC,gBAAgB;IAChBC,UAAU;IACVC,SAAS;AACX,EAAC;AAED,MAAMC,MAAM,KAAK;AAEjB;;;CAGC,GACD,OAAO,MAAMC,uBAA2D;IACtE,0EAA0E;IAC1E;QACEC,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACE,wEAAwE;QACxE,oEAAoE;QACpEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACV,qEAAqE;QACrEC,qBAAqBV;QACrBW,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IAEA,2EAA2E;IAC3E;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAIV;QACzBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBG,gBAAgB;QAChBF,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAK;QAC1BC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACV,oEAAoE;QACpEpB,OAAO;QACP,gEAAgE;QAChE,eAAe;QACfqB,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVpB,OAAO;QACP,gEAAgE;QAChE,eAAe;QACfqB,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IAEA,2EAA2E;IAC3E;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACF,kEAAkE;QAClEC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;QAClB,2CAA2C;QAC3CvB,OAAO;IACT;IACA;QACEa,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;QAClBvB,OAAO;IACT;IAEA,2EAA2E;IAC3E;QACEa,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACE,sEAAsE;QACtE,wEAAwE;QACxET,MAAM;QACNC,OAAO;QACPC,aACE;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"}
@@ -20,7 +20,7 @@
20
20
  * UI, and the Firestore rules' mental model. See
21
21
  * docs/MULTI_TENANT_FIRESTORE.md.
22
22
  */
23
- import type { AglynOrgMember, HostAccessRole, HostUid, OrgRole, ScopeToken, UserOrgMembership } from '../foundation';
23
+ import type { AglynOrgMember, AglynOrgOwnerHandoff, HostAccessRole, HostUid, OrgRole, OwnerHandoffPreviousOwner, ScopeToken, UserOrgMembership } from '../foundation';
24
24
  /** Same lexical policy as host subdomains: 3–30 lowercase/digits/dashes. */
25
25
  export declare const ORG_SLUG_PATTERN: RegExp;
26
26
  export declare function isBlockedOrgSlug(slug: string): boolean;
@@ -131,7 +131,52 @@ export declare function isOrgWideMember(member: Partial<AglynOrgMember> | null |
131
131
  * Accepts pending INVITES as well as members — an invite carries the same
132
132
  * `role`/`allHosts`/`hostAccess` shape and reserves the seat it will become.
133
133
  */
134
- export declare function countManagerSeats(entries: ReadonlyArray<Partial<AglynOrgMember> | null | undefined>): number;
134
+ export declare function countManagerSeats(entries: ReadonlyArray<(Partial<AglynOrgMember> & {
135
+ handoff?: unknown;
136
+ }) | null | undefined>): number;
137
+ /**
138
+ * Whether a roster row or pending invite takes one of the customer's seats
139
+ * at all (AGL-3466). Every seat counter asks this before it asks which kind
140
+ * of seat, so the two populations below never include either exception.
141
+ *
142
+ * Two kinds of entry hold access without a seat:
143
+ *
144
+ * - **Platform staff.** A row or invite stamped `staffSeat: true` belongs to
145
+ * an account holding the `staff` claim. Staff build workspaces for
146
+ * prospects and join them to help; billing the customer a seat for either
147
+ * is what made a Free workspace (one seat) unable to invite the client it
148
+ * was built for without a hand-written override.
149
+ * - **An owner handoff.** An invite carrying `handoff` moves the owner seat
150
+ * from one person to another rather than adding one. Whether the outgoing
151
+ * owner staying on needs a seat of their own is decided when the handoff is
152
+ * sent and again when it is accepted, never by counting the invite.
153
+ */
154
+ export declare function usesCustomerSeat(entry: {
155
+ staffSeat?: unknown;
156
+ handoff?: unknown;
157
+ } | null | undefined): boolean;
158
+ /**
159
+ * Whether a roster row belongs to platform staff (AGL-3466) — the Team
160
+ * page's "no seat" chip, and the row every seat count leaves out.
161
+ */
162
+ export declare function isStaffSeat(entry: {
163
+ staffSeat?: unknown;
164
+ } | null | undefined): boolean;
165
+ /** The outgoing owner's choices, in the order the picker offers them. */
166
+ export declare const OWNER_HANDOFF_PREVIOUS_OWNER: readonly OwnerHandoffPreviousOwner[];
167
+ /** What each choice does, for the picker and the confirmation. */
168
+ export declare const OWNER_HANDOFF_PREVIOUS_OWNER_LABELS: Record<OwnerHandoffPreviousOwner, string>;
169
+ /**
170
+ * The handoff half of an invite request, or null when it is not a usable
171
+ * one (AGL-3466).
172
+ *
173
+ * Read in one place because the same body reaches the server from three
174
+ * surfaces (the Team page, the staff organization page and the staff site
175
+ * page), and an unknown `previousOwner` must be refused rather than guessed:
176
+ * guessing `leave` takes someone off a roster they meant to stay on, and
177
+ * guessing `stay` spends a seat they meant to free.
178
+ */
179
+ export declare function parseOwnerHandoff(raw: unknown): AglynOrgOwnerHandoff | null;
135
180
  /**
136
181
  * One roster row or pending invite, as the collaborator counter needs to see
137
182
  * it (AGL-2068). Deliberately structural rather than `Partial<AglynOrgMember>`
@@ -146,6 +191,10 @@ export interface CollaboratorSeatEntry {
146
191
  role?: unknown;
147
192
  allHosts?: boolean;
148
193
  hostAccess?: Record<string, unknown> | null;
194
+ /** Platform staff hold no seat (AGL-3466) — see `usesCustomerSeat`. */
195
+ staffSeat?: boolean | null;
196
+ /** An owner-handoff invite holds no seat (AGL-3466). */
197
+ handoff?: unknown;
149
198
  }
150
199
  /**
151
200
  * Every principal consuming a COLLABORATOR seat on one host (AGL-2068).
@@ -197,9 +197,62 @@ export function orgRoleAtLeast(role, minimum) {
197
197
  * `role`/`allHosts`/`hostAccess` shape and reserves the seat it will become.
198
198
  */ export function countManagerSeats(entries) {
199
199
  let count = 0;
200
- for (const entry of entries)if (isOrgWideMember(entry)) count += 1;
200
+ for (const entry of entries){
201
+ if (usesCustomerSeat(entry) && isOrgWideMember(entry)) count += 1;
202
+ }
201
203
  return count;
202
204
  }
205
+ /**
206
+ * Whether a roster row or pending invite takes one of the customer's seats
207
+ * at all (AGL-3466). Every seat counter asks this before it asks which kind
208
+ * of seat, so the two populations below never include either exception.
209
+ *
210
+ * Two kinds of entry hold access without a seat:
211
+ *
212
+ * - **Platform staff.** A row or invite stamped `staffSeat: true` belongs to
213
+ * an account holding the `staff` claim. Staff build workspaces for
214
+ * prospects and join them to help; billing the customer a seat for either
215
+ * is what made a Free workspace (one seat) unable to invite the client it
216
+ * was built for without a hand-written override.
217
+ * - **An owner handoff.** An invite carrying `handoff` moves the owner seat
218
+ * from one person to another rather than adding one. Whether the outgoing
219
+ * owner staying on needs a seat of their own is decided when the handoff is
220
+ * sent and again when it is accepted, never by counting the invite.
221
+ */ export function usesCustomerSeat(entry) {
222
+ if (!entry) return false;
223
+ if (entry.staffSeat === true) return false;
224
+ return !entry.handoff;
225
+ }
226
+ /**
227
+ * Whether a roster row belongs to platform staff (AGL-3466) — the Team
228
+ * page's "no seat" chip, and the row every seat count leaves out.
229
+ */ export function isStaffSeat(entry) {
230
+ return (entry == null ? void 0 : entry.staffSeat) === true;
231
+ }
232
+ /** The outgoing owner's choices, in the order the picker offers them. */ export const OWNER_HANDOFF_PREVIOUS_OWNER = [
233
+ 'stay',
234
+ 'leave'
235
+ ];
236
+ /** What each choice does, for the picker and the confirmation. */ export const OWNER_HANDOFF_PREVIOUS_OWNER_LABELS = {
237
+ stay: 'Stay on as an admin after the handoff',
238
+ leave: 'Leave after the handoff'
239
+ };
240
+ /**
241
+ * The handoff half of an invite request, or null when it is not a usable
242
+ * one (AGL-3466).
243
+ *
244
+ * Read in one place because the same body reaches the server from three
245
+ * surfaces (the Team page, the staff organization page and the staff site
246
+ * page), and an unknown `previousOwner` must be refused rather than guessed:
247
+ * guessing `leave` takes someone off a roster they meant to stay on, and
248
+ * guessing `stay` spends a seat they meant to free.
249
+ */ export function parseOwnerHandoff(raw) {
250
+ if (!raw || typeof raw !== 'object') return null;
251
+ const previousOwner = raw.previousOwner;
252
+ return previousOwner === 'stay' || previousOwner === 'leave' ? {
253
+ previousOwner
254
+ } : null;
255
+ }
203
256
  /**
204
257
  * The identity key one entry occupies, or `''` when it identifies nobody.
205
258
  *
@@ -254,7 +307,7 @@ export function orgRoleAtLeast(role, minimum) {
254
307
  if (!hostId) return keys;
255
308
  for (const entry of entries){
256
309
  var _entry_hostAccess;
257
- if (!entry) continue;
310
+ if (!usesCustomerSeat(entry)) continue;
258
311
  // Managers are the other counter's population, whatever host map they
259
312
  // happen to carry — a promoted collaborator keeps their old `hostAccess`.
260
313
  if (isOrgWideMember(entry)) continue;
@@ -323,7 +376,7 @@ export function orgRoleAtLeast(role, minimum) {
323
376
  */ export function managerSeatKeys(entries) {
324
377
  const keys = new Set();
325
378
  for (const entry of entries){
326
- if (!entry) continue;
379
+ if (!usesCustomerSeat(entry)) continue;
327
380
  if (!isOrgWideMember(entry)) continue;
328
381
  const key = collaboratorSeatKey(entry);
329
382
  if (key) keys.add(key);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/organizations.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Organization helpers (AGL-233): slug policy for workspace subdomains\n * (`{slug}.aglyn.com`) and role math shared by the org APIs, the console\n * UI, and the Firestore rules' mental model. See\n * docs/MULTI_TENANT_FIRESTORE.md.\n */\n\nimport type {\n AglynOrgMember,\n HostAccessRole,\n HostUid,\n OrgRole,\n ScopeToken,\n UserOrgMembership,\n} from '../foundation'\nimport {\n consoleLabelUnder,\n generateSubdomain,\n isBlockedSubdomain,\n SUBDOMAIN_PATTERN,\n} from './host-naming'\nimport {\n hostScopeToken,\n ORG_SCOPE_TOKEN,\n visibleToTokens,\n} from './scope-tokens'\n\n/** Same lexical policy as host subdomains: 3–30 lowercase/digits/dashes. */\nexport const ORG_SLUG_PATTERN = SUBDOMAIN_PATTERN\n\n/**\n * Org workspace slugs ride the host reserved/profanity blocklist plus\n * console-specific labels the workspace router must own.\n */\nconst RESERVED_ORG_ONLY = new Set(['staff', 'org', 'orgs', 'workspace'])\n\n/**\n * The label this deployment serves its own console on, when it has one\n * (AGL-3295).\n *\n * `app` and `console` are reserved above for every install. One whose console\n * lives at, say, `studio.example.com` needs `studio` refused as well, or an\n * organization could claim a workspace address the console itself answers.\n * Read from the same two settings the console's host gate reads; with either\n * unset nothing extra is reserved, because nothing extra is shadowed.\n */\nconst CONSOLE_LABEL = consoleLabelUnder(\n process.env.NEXT_PUBLIC_CONSOLE_URL,\n process.env.NEXT_PUBLIC_WORKSPACE_DOMAIN,\n)\n\nexport function isBlockedOrgSlug(slug: string): boolean {\n const normalized = slug.toLowerCase()\n return (\n RESERVED_ORG_ONLY.has(normalized) ||\n normalized === CONSOLE_LABEL ||\n isBlockedSubdomain(slug)\n )\n}\n\nexport function isValidOrgSlug(slug: string): boolean {\n return ORG_SLUG_PATTERN.test(slug) && !isBlockedOrgSlug(slug)\n}\n\n/** Best-effort slug from an org name; '' when nothing usable remains. */\nexport function generateOrgSlug(name: string): string {\n const slug = generateSubdomain(name)\n return slug && !isBlockedOrgSlug(slug) ? slug : ''\n}\n\nexport const ORG_ROLES: readonly OrgRole[] = [\n 'owner',\n 'admin',\n 'editor',\n 'viewer',\n]\n\nconst ORG_ROLE_WEIGHT: Record<OrgRole, number> = {\n owner: 3,\n admin: 2,\n editor: 1,\n viewer: 0,\n}\n\nexport function isOrgRole(value: unknown): value is OrgRole {\n return typeof value === 'string' && value in ORG_ROLE_WEIGHT\n}\n\nexport function orgRoleAtLeast(\n role: OrgRole | null | undefined,\n minimum: OrgRole,\n): boolean {\n if (!role || !(role in ORG_ROLE_WEIGHT)) return false\n return ORG_ROLE_WEIGHT[role] >= ORG_ROLE_WEIGHT[minimum]\n}\n\n/** Org settings, members, invites, host creation: admin and owner. */\nexport function canManageOrg(role: OrgRole | null | undefined): boolean {\n return orgRoleAtLeast(role, 'admin')\n}\n\n/**\n * The member's effective role on one host, or null for no access.\n * Owner/admin span every host; editor/viewer resolve through `allHosts`\n * (mapped to their org role) or the per-host access map.\n */\nexport function hostRoleFor(\n member: Partial<AglynOrgMember> | null | undefined,\n hostId: HostUid,\n): HostAccessRole | null {\n const role = member?.role\n if (!isOrgRole(role)) return null\n if (role === 'owner' || role === 'admin') return 'admin'\n const explicit = member?.hostAccess?.[hostId]\n if (explicit) return explicit\n return member?.allHosts ? role : null\n}\n\n/**\n * Host roles that may MUTATE content — the axis `canWriteHostContent()` in\n * the security rules asks (AGL-2334).\n *\n * `author` is here and absent from `HOST_PUBLISH_ROLES` below: that single\n * difference IS the \"edit content but not publish\" role. Kept as two named\n * sets rather than two inline comparisons because they are read in three\n * places that must not drift — this module, the ~20 server gates, and the\n * rules — and because a new host role added to the union has to be a\n * deliberate decision on BOTH lists rather than an omission from one.\n */\nexport const HOST_CONTENT_WRITE_ROLES: readonly HostAccessRole[] = [\n 'admin',\n 'editor',\n 'author',\n]\n\n/**\n * Host roles that may make content LIVE. Everything that registers a route,\n * moves a live-version pointer, schedules either of those for later, or\n * flips a collection entry to `published` asks this and not the set above.\n *\n * The list is short and it is the whole product decision: `author` is not on\n * it. If a role is ever added here, the rules' `canPublishHostContent()` and\n * the publish-surface field freezes below it must be updated in the same\n * change — they are the enforcement, this is the server-side echo of it.\n */\nexport const HOST_PUBLISH_ROLES: readonly HostAccessRole[] = ['admin', 'editor']\n\n/**\n * Whether the member may mutate a host's content — admin, editor **or\n * author** on it.\n *\n * An author is a content editor in every respect this predicate covers; what\n * it deliberately does NOT cover is publication. Any caller that is really\n * asking \"may this person make something live\" must call `canPublishHost`\n * instead — this one answering `true` for an author is the point of the role,\n * not a gap in it.\n */\nexport function canWriteHost(\n member: Partial<AglynOrgMember> | null | undefined,\n hostId: HostUid,\n): boolean {\n const role = hostRoleFor(member, hostId)\n return !!role && HOST_CONTENT_WRITE_ROLES.includes(role)\n}\n\n/**\n * Whether the member may PUBLISH on a host — register a route, move a live\n * version pointer, schedule either for later, or publish a collection entry.\n *\n * Strictly narrower than `canWriteHost`: `author` edits and does not publish.\n * This is the server-side echo of the rules, not the enforcement — publishing\n * is a set of client-direct Firestore writes across five document shapes, so\n * `cloud/firebase-firestore.rules` is where it is actually refused. Server\n * routes that schedule or trigger a publish ask this so the console can say\n * no with a message instead of surfacing a bare permission-denied.\n */\nexport function canPublishHost(\n member: Partial<AglynOrgMember> | null | undefined,\n hostId: HostUid,\n): boolean {\n const role = hostRoleFor(member, hostId)\n return !!role && HOST_PUBLISH_ROLES.includes(role)\n}\n\n/**\n * The same question asked of a bare `memberRoles` value rather than a member\n * document — the shape every Admin-SDK route already has in hand after its\n * `hosts/{hostId}` read, so it does not have to re-load the org membership.\n *\n * `String(role)` at the call sites keeps `undefined` out; an unknown value\n * is simply not in the set, which fails CLOSED.\n */\nexport function hostRoleCanWrite(role: unknown): boolean {\n return HOST_CONTENT_WRITE_ROLES.includes(role as HostAccessRole)\n}\n\n/** Publish-axis twin of `hostRoleCanWrite`; `author` is excluded. */\nexport function hostRoleCanPublish(role: unknown): boolean {\n return HOST_PUBLISH_ROLES.includes(role as HostAccessRole)\n}\n\n/**\n * Whether a member's reach is the whole org rather than a list of sites.\n *\n * Owner and admin always are; editor/viewer are only when `allHosts` says\n * so — **or** when the membership predates the flag entirely. A pre-\n * `allHosts` doc carries neither the flag nor a `hostAccess` map, and\n * reading that as \"scoped, with access to nothing\" would lock real members\n * out of their own workspace. A site collaborator ALWAYS has a non-empty\n * `hostAccess` (that is what `grantHostAccess` writes), so the absence of\n * both is the legacy shape, not a scoped one.\n *\n * Single source of truth for `resolveOrgPermissions` (the server gate),\n * `projectMemberScopeTokens` (the rules projection), and the rules'\n * own `isOrgWideMember()` — three copies of this predicate that disagree\n * is a privilege bug, not a style problem (AGL-1026, AGL-1038).\n */\nexport function isOrgWideMember(\n member: Partial<AglynOrgMember> | null | undefined,\n): boolean {\n if (!member) return false\n const role = member.role\n if (role === 'owner' || role === 'admin') return true\n if (member.allHosts === true) return true\n const scoping = member.hostAccess\n return (\n member.allHosts === undefined && !Object.keys(scoping ?? {}).length\n )\n}\n\n/**\n * How many of these roster entries consume a **manager** seat (AGL-1113).\n *\n * `orgs/{orgId}/members` holds BOTH org managers and site-scoped\n * collaborators — adding a collaborator to one site writes an org member doc\n * with `allHosts:false` + `hostAccess`, so a raw `.count()` of the collection\n * billed every collaborator as a manager. Collaborators are metered\n * separately, per host, against `membersPerHost` at\n * `hosts/{hostId}/members` — counting them here charged them twice and\n * tripped the manager gate for orgs nowhere near their manager limit.\n *\n * Reuses `isOrgWideMember` rather than filtering on `allHosts` directly:\n * owner/admin are managers whatever the flag says, and a pre-`allHosts` doc\n * is a manager too. A Firestore `where('allHosts','==',true)` count gets both\n * of those wrong, which is why callers pass entries in and count here.\n *\n * Accepts pending INVITES as well as members — an invite carries the same\n * `role`/`allHosts`/`hostAccess` shape and reserves the seat it will become.\n */\nexport function countManagerSeats(\n entries: ReadonlyArray<Partial<AglynOrgMember> | null | undefined>,\n): number {\n let count = 0\n for (const entry of entries) if (isOrgWideMember(entry)) count += 1\n return count\n}\n\n/**\n * One roster row or pending invite, as the collaborator counter needs to see\n * it (AGL-2068). Deliberately structural rather than `Partial<AglynOrgMember>`\n * — an INVITE is not a member document, it is keyed by email and has no uid,\n * and the whole point of this counter is that the two populations must be\n * counted together or the cap is enforced against a fraction of itself.\n */\nexport interface CollaboratorSeatEntry {\n /** Member documents are keyed by uid; invites have none. */\n uid?: string | null\n email?: string | null\n role?: unknown\n allHosts?: boolean\n hostAccess?: Record<string, unknown> | null\n}\n\n/**\n * The identity key one entry occupies, or `''` when it identifies nobody.\n *\n * EMAIL FIRST, uid second, and that order is the join (AGL-2068). A person\n * admitted to a site is one seat whether they arrive as an accepted member or\n * as a still-pending invite, and those two rows live in different collections\n * keyed different ways — `orgs/{id}/members/{uid}` and\n * `orgs/{id}/invites/{inviteId}` — with the address as the only field both\n * carry. Counting by uid would count the invite and the membership it becomes\n * as two seats; counting only invites would miss every member.\n *\n * Legacy roster rows written before `email` was mirrored fall back to the uid,\n * which cannot collide with an address (the `uid:` prefix is not a legal\n * local-part/domain pair) and so is safe to mix into the same set.\n */\nfunction collaboratorSeatKey(entry: CollaboratorSeatEntry): string {\n const email =\n typeof entry.email === 'string' ? entry.email.trim().toLowerCase() : ''\n if (email) return email\n const uid = typeof entry.uid === 'string' ? entry.uid.trim() : ''\n return uid ? `uid:${uid}` : ''\n}\n\n/**\n * Every principal consuming a COLLABORATOR seat on one host (AGL-2068).\n *\n * ## Why this exists at all\n *\n * `membersPerHost` was enforced on one of the four doors that admit a site\n * collaborator, and that one door counted `hosts/{hostId}/members` — a\n * DISPLAY roster only `/api/hosts/members` writes. The other three\n * (`/api/orgs/invites` create, `/api/orgs/invites` accept, and the\n * `hostAccess` branch of `/api/orgs/members`) land in `orgs/{id}/members` and\n * the `hosts/{id}.memberRoles` projection, and were invisible to it. So the\n * cap was both skipped on three paths and under-counted on the fourth, and a\n * free org — `membersPerHost: 1` — could take on unlimited collaborators,\n * each with full site access, on a plan that is never invoiced.\n *\n * verbatim: **\"We need to make sure the free/hobby tier\n * does hard cap so it always actually stays free.\"**\n *\n * ## What counts\n *\n * A SCOPED grant on `hostId`, and nothing else. An org-wide member\n * (owner/admin, or `allHosts`) reaches every host but consumes a MANAGER\n * seat — `countManagerSeats` is that side, and charging them here would bill\n * one person twice and trip a cap they are not on. This is the same split\n * AGL-1113 drew; it is now drawn from one place instead of three.\n *\n * Pending invites count, for the reason the manager gate counts them: an\n * invite reserves the seat it will become, and a cap that only bites on\n * acceptance can be walked past by mailing out N invites first.\n */\nexport function collaboratorSeatKeys(\n entries: ReadonlyArray<CollaboratorSeatEntry | null | undefined>,\n hostId: string,\n): Set<string> {\n const keys = new Set<string>()\n if (!hostId) return keys\n for (const entry of entries) {\n if (!entry) continue\n // Managers are the other counter's population, whatever host map they\n // happen to carry — a promoted collaborator keeps their old `hostAccess`.\n if (isOrgWideMember(entry as Partial<AglynOrgMember>)) continue\n if (!entry.hostAccess?.[hostId]) continue\n const key = collaboratorSeatKey(entry)\n if (key) keys.add(key)\n }\n return keys\n}\n\n/**\n * How many collaborator seats on `hostId` are spoken for by someone OTHER\n * than `exclude` (AGL-2068).\n *\n * `exclude` is the principal the caller is about to admit, and dropping them\n * is what makes this composable with `checkSeatQuota(…, used)`, whose\n * contract is \"usage BEFORE the resource being created\". Without it the\n * accept path refuses its own invite: at the moment acceptance is decided the\n * invitee holds a pending invite AND is about to hold a membership, so they\n * would be counted once or twice against a cap they are the subject of.\n *\n * Both identities are dropped, not whichever one is present. A person can be\n * on the roster under a uid with no mirrored email and hold an invite under\n * that address, and the seat is still one seat.\n *\n * ## `emails`, not `email` — an account holds several (AGL-2486)\n *\n * Since an invitation may be accepted at any CONFIRMED address on the account,\n * the pending invite the accepter is about to consume can be keyed by an\n * address that is not their primary. `exclude.email` alone is the primary, so\n * that invite survived the exclusion and the person was counted against a cap\n * they are the subject of — the exact double-count the paragraph above says\n * this function exists to prevent, re-opened by widening the accept match.\n *\n * It fails CLOSED (a spurious \"out of seats\" refusal, never a free seat), and\n * only for an org sitting exactly on its cap, which is why it survived review.\n *\n * `emails` takes every address PROVEN to belong to the principal being\n * admitted. Passing an address that is not theirs would hand out a free seat,\n * so callers must pass confirmed addresses for that uid and nothing else.\n */\nexport function countCollaboratorSeats(\n entries: ReadonlyArray<CollaboratorSeatEntry | null | undefined>,\n hostId: string,\n exclude?: {\n uid?: string | null\n email?: string | null\n /** Further addresses on the SAME account; see above. */\n emails?: readonly (string | null | undefined)[] | null\n },\n): number {\n const keys = collaboratorSeatKeys(entries, hostId)\n for (const candidate of [exclude?.email, ...(exclude?.emails ?? [])]) {\n const email = typeof candidate === 'string' ? candidate.trim().toLowerCase() : ''\n if (email) keys.delete(email)\n }\n const uid = typeof exclude?.uid === 'string' ? exclude.uid.trim() : ''\n if (uid) keys.delete(`uid:${uid}`)\n return keys.size\n}\n\n/**\n * Every principal consuming a MANAGER seat, as identities rather than rows.\n *\n * The org-wide half of `collaboratorSeatKeys`, drawn from the same entries and\n * split on the same predicate, so the two counters partition one roster\n * instead of overlapping on it.\n *\n * Deduplicating by identity is the difference from {@link countManagerSeats},\n * and it is what makes this composable with an `exclude`: a person holding a\n * pending invite AND the membership it became is one seat, and the row-wise\n * counter reports two because it is handed the two collections separately and\n * sums them.\n */\nexport function managerSeatKeys(\n entries: ReadonlyArray<CollaboratorSeatEntry | null | undefined>,\n): Set<string> {\n const keys = new Set<string>()\n for (const entry of entries) {\n if (!entry) continue\n if (!isOrgWideMember(entry as Partial<AglynOrgMember>)) continue\n const key = collaboratorSeatKey(entry)\n if (key) keys.add(key)\n }\n return keys\n}\n\n/**\n * How many manager seats are spoken for by someone OTHER than `exclude`.\n *\n * The manager twin of {@link countCollaboratorSeats}, and it exists for the\n * same reason that one takes an `exclude`: `checkSeatQuota`'s contract is\n * \"usage BEFORE the resource being created\", and on the invite-accept path the\n * accepter holds a pending invite and is about to hold a membership at the\n * same instant. Counted plainly they are charged once or twice for a seat they\n * are the subject of, and an org on its last seat refuses its own invitation.\n *\n * Both identities are dropped, and `emails` carries the further addresses\n * proven to belong to that account — an invitation may be accepted at any\n * confirmed address, so the invite being consumed need not be keyed by the\n * primary one. Passing an address that is not theirs would hand out a free\n * seat, so callers must pass confirmed addresses for that uid and nothing\n * else.\n *\n * Kept SEPARATE from {@link countManagerSeats} rather than replacing it: that\n * one is row-wise and is what the billing and console surfaces count with, and\n * quietly making it identity-wise would change every one of those totals.\n */\nexport function countManagerSeatsExcluding(\n entries: ReadonlyArray<CollaboratorSeatEntry | null | undefined>,\n exclude?: {\n uid?: string | null\n email?: string | null\n /** Further addresses on the SAME account; see above. */\n emails?: readonly (string | null | undefined)[] | null\n },\n): number {\n const keys = managerSeatKeys(entries)\n for (const candidate of [exclude?.email, ...(exclude?.emails ?? [])]) {\n const email = typeof candidate === 'string' ? candidate.trim().toLowerCase() : ''\n if (email) keys.delete(email)\n }\n const uid = typeof exclude?.uid === 'string' ? exclude.uid.trim() : ''\n if (uid) keys.delete(`uid:${uid}`)\n return keys.size\n}\n\n/**\n * What KIND of user a console row represents (AGL-1114) — the distinction\n * that decides which seat they consume, and the one the Team table could not\n * express because a manager and a collaborator differ only by fields the\n * table never showed.\n *\n * Deliberately NOT the same axis as `role`. A `role: 'editor'` is a manager\n * when their reach is the whole org and a collaborator when it is two sites;\n * reading the role alone is the mistake that let a collaborator look like\n * team staff (see the org-leak and role-gate work).\n *\n * `siteMember` is the third kind and never appears in `orgs/{id}/members` at\n * all: it is an end-user account on a PUBLISHED site (`siteMembers`), not a\n * console user. It has no Firebase Auth record of any kind — a site member is\n * a Firestore document under `hosts/{hostId}/siteMembers` with its own scrypt\n * hash and its own session cookie — so no surface that lists AUTH accounts,\n * the staff user directory included, can contain one. The label is here so the\n * vocabulary is complete, for a surface that lists site accounts and needs a\n * word for them. Free and uncounted on every plan (AGL-889).\n */\nexport type ConsoleUserType = 'manager' | 'collaborator' | 'siteMember'\n\nexport const CONSOLE_USER_TYPE_LABELS: Record<ConsoleUserType, string> = {\n manager: 'Team manager',\n collaborator: 'Site collaborator',\n siteMember: 'Site member',\n}\n\n/** One line on what each kind is and what it costs — tooltip/legend copy. */\nexport const CONSOLE_USER_TYPE_HINTS: Record<ConsoleUserType, string> = {\n manager:\n 'Reaches the whole organization. Uses one of your team (manager) seats.',\n collaborator:\n 'Scoped to specific sites. Uses a collaborator seat on each site they ' +\n 'can reach, not a team seat.',\n siteMember:\n 'An end-user account on a published site, not a console user. Free and ' +\n 'unlimited on every plan.',\n}\n\n/**\n * Classify an `orgs/{orgId}/members` entry (or a pending invite, same shape).\n * Never returns `siteMember` — nothing in that collection is one; the value\n * exists for surfaces that list published-site accounts and must label them —\n * never for one that lists both, since the two populations do not share an\n * identity system.\n */\nexport function consoleUserType(\n member: Partial<AglynOrgMember> | null | undefined,\n): Exclude<ConsoleUserType, 'siteMember'> {\n return isOrgWideMember(member) ? 'manager' : 'collaborator'\n}\n\n/**\n * Whether an account is governed by enterprise SSO (AGL-1128) — it lives in\n * an org's GCIP tenant pool rather than the project pool, so its identity is\n * owned by the customer's IdP.\n *\n * `tenantId` is the whole signal: Firebase sets it on the user whenever the\n * account belongs to a tenant, and only SSO accounts do.\n */\nexport function isSsoGovernedAccount(\n user: { tenantId?: string | null } | null | undefined,\n): boolean {\n return Boolean(user?.tenantId)\n}\n\n/**\n * Whether the account may connect an additional consumer sign-in provider.\n *\n * **No, for any SSO-governed account.** The customer's IdP is the single gate\n * they bought: they revoke there, enforce MFA there, offboard there. A linked\n * personal Google account is a way in their IdP can never see or revoke —\n * exactly what SSO is purchased to prevent.\n *\n * Deliberately NOT gated on `sso.enforced`. That flag exists so we never\n * LOCK OUT an existing sign-in method; it is not a licence to hand out new\n * bypasses meanwhile. Nothing here removes a provider an account already has,\n * so this can never lock anyone out.\n *\n * This is the intent, not the boundary — the boundary is upstream, since a\n * GCIP tenant only accepts providers enabled on that tenant. Both matter: the\n * boundary is remote config that could drift, and a UI that offers a\n * security-regressing action is wrong even when the action would fail.\n */\nexport function canLinkSocialProvider(\n user: { tenantId?: string | null } | null | undefined,\n): boolean {\n return !isSsoGovernedAccount(user)\n}\n\n/**\n * The same question as `isOrgWideMember`, asked of the `users/{uid}/orgs`\n * reverse-index row instead of the member doc (AGL-1032).\n *\n * The console navigates from the index — it is what `useOrgScope` loads —\n * and the index carries a MIRROR of the predicate, not the inputs to it, so\n * this cannot recompute: `role: 'viewer'` on the index means \"viewer\",\n * whether the member reaches every site or one.\n *\n * Reads a missing flag as org-wide, for the same reason `isOrgWideMember`\n * reads a missing `allHosts` that way: rows written before the mirror\n * existed have no flag, and hiding the workspace from a real member is a\n * worse failure than showing org chrome to a collaborator whose reads the\n * rules refuse anyway. Owner/admin are org-wide by role regardless of the\n * mirror, so a stale row can never lock an admin out.\n *\n * Navigation only — never an access decision. AGL-1026 (rules) and the\n * Admin-SDK scope checks are the boundary.\n */\nexport function isOrgWideMembership(\n membership:\n | Pick<UserOrgMembership, 'role' | 'orgWide'>\n | null\n | undefined,\n): boolean {\n if (!membership) return true\n if (membership.role === 'owner' || membership.role === 'admin') return true\n return membership.orgWide !== false\n}\n\n/**\n * The `scopeTokens` projection stamped onto member docs (AGL-1038) so the\n * rules can intersect a caller's reach with a resource's `visibleTo` —\n * rules have no `.map()`, so `hostAccess`'s keys cannot be turned into\n * tokens at evaluation time and have to be denormalized here.\n *\n * Org-wide members get `['org']`; they own the org and read everything in\n * it, scoped or not (the rules short-circuit on them anyway). A scoped\n * collaborator gets `'org'` plus one token per granted host — `'org'`\n * because an org-WIDE resource stays readable by any member, which is\n * today's behavior and not something this project narrows.\n *\n * Not capped at `MAX_SCOPE_HOSTS`: the rules' `hasAny` has no 30-value\n * limit, only the client `array-contains-any` query does, so a member of\n * more than 30 sites is a listing problem for AGL-1044 to chunk, not a\n * reason to under-grant here.\n */\nexport function projectMemberScopeTokens(\n member: Partial<AglynOrgMember> | null | undefined,\n): ScopeToken[] {\n if (isOrgWideMember(member)) return [ORG_SCOPE_TOKEN]\n const hostIds = Object.keys(member?.hostAccess ?? {})\n return [ORG_SCOPE_TOKEN, ...hostIds.map(hostScopeToken)]\n}\n\n/**\n * A member's effective read set: the stored `scopeTokens` projection when it\n * is there, recomputed from the membership when it is not.\n *\n * The fallback is the security-relevant half and it was written out by hand\n * in four places (`use-scope-tokens`, `erase-scope`, the media sign route,\n * and by omission in `resolveMediaScope`, which only ever recomputed). Four\n * copies of \"what may this member see\" is the shape that eventually\n * disagrees, and a copy that reads `member.scopeTokens` WITHOUT the\n * `.length` guard silently grants nothing to a member the AGL-1040 backfill\n * has not reached — or, written the other way round, grants everything.\n *\n * Recomputing is the safe fallback rather than a convenience: it produces\n * exactly what `grantHostAccess` would have stamped, so an unstamped member\n * doc behaves as its `hostAccess` says, never as \"no access\" and never as\n * org-wide.\n */\nexport function memberScopeTokens(\n member: Partial<AglynOrgMember> | null | undefined,\n): ScopeToken[] {\n const stored = member?.scopeTokens\n return stored?.length ? stored : projectMemberScopeTokens(member)\n}\n\n/**\n * May this member see a resource carrying `visibleTo`? (AGL-1037/1038)\n *\n * The whole client-side/server-side authorization question in one place:\n * org-wide members read everything they own; everyone else needs their read\n * set to intersect the resource's scope. Absent/empty `visibleTo` is\n * org-wide by the same rule the Firestore rules use, so an unbackfilled\n * resource does not vanish.\n *\n * Every Admin-SDK path must call this (or `scopeAllows`, which wraps an\n * already-resolved scope) — the Admin SDK bypasses rules entirely, so this\n * IS the enforcement there, not a second opinion about it.\n *\n * NOT a membership check, and never a substitute for one: a `null` member\n * \"can see\" an org-wide resource here, because the question this answers is\n * \"is this resource in scope for that reach\", not \"is this person in the\n * org\". Resolve membership first and refuse the caller if there is none —\n * conflating the two is exactly how AGL-1026 exposed a whole org.\n */\nexport function memberCanSee(\n member: Partial<AglynOrgMember> | null | undefined,\n visibleTo: readonly string[] | null | undefined,\n): boolean {\n if (isOrgWideMember(member)) return true\n return visibleToTokens(visibleTo ?? undefined, memberScopeTokens(member))\n}\n\n/**\n * The `memberRoles` projection stamped onto host docs so Firestore rules\n * authorize host content with the single host-doc read they already do\n * (docs/MULTI_TENANT_FIRESTORE.md §5). Recomputed by the membership API\n * whenever a member changes; owner/admin appear on every host.\n */\nexport function projectHostMemberRoles(\n members: ReadonlyArray<Partial<AglynOrgMember> & { $id: string }>,\n hostId: HostUid,\n): Record<string, HostAccessRole> {\n const projection: Record<string, HostAccessRole> = {}\n for (const member of members) {\n const role = hostRoleFor(member, hostId)\n if (role) projection[member.$id] = role\n }\n return projection\n}\n"],"names":["consoleLabelUnder","generateSubdomain","isBlockedSubdomain","SUBDOMAIN_PATTERN","hostScopeToken","ORG_SCOPE_TOKEN","visibleToTokens","ORG_SLUG_PATTERN","RESERVED_ORG_ONLY","Set","CONSOLE_LABEL","process","env","NEXT_PUBLIC_CONSOLE_URL","NEXT_PUBLIC_WORKSPACE_DOMAIN","isBlockedOrgSlug","slug","normalized","toLowerCase","has","isValidOrgSlug","test","generateOrgSlug","name","ORG_ROLES","ORG_ROLE_WEIGHT","owner","admin","editor","viewer","isOrgRole","value","orgRoleAtLeast","role","minimum","canManageOrg","hostRoleFor","member","hostId","explicit","hostAccess","allHosts","HOST_CONTENT_WRITE_ROLES","HOST_PUBLISH_ROLES","canWriteHost","includes","canPublishHost","hostRoleCanWrite","hostRoleCanPublish","isOrgWideMember","scoping","undefined","Object","keys","length","countManagerSeats","entries","count","entry","collaboratorSeatKey","email","trim","uid","collaboratorSeatKeys","key","add","countCollaboratorSeats","exclude","candidate","emails","delete","size","managerSeatKeys","countManagerSeatsExcluding","CONSOLE_USER_TYPE_LABELS","manager","collaborator","siteMember","CONSOLE_USER_TYPE_HINTS","consoleUserType","isSsoGovernedAccount","user","Boolean","tenantId","canLinkSocialProvider","isOrgWideMembership","membership","orgWide","projectMemberScopeTokens","hostIds","map","memberScopeTokens","stored","scopeTokens","memberCanSee","visibleTo","projectHostMemberRoles","members","projection","$id"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/organizations.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Organization helpers (AGL-233): slug policy for workspace subdomains\n * (`{slug}.aglyn.com`) and role math shared by the org APIs, the console\n * UI, and the Firestore rules' mental model. See\n * docs/MULTI_TENANT_FIRESTORE.md.\n */\n\nimport type {\n AglynOrgMember,\n AglynOrgOwnerHandoff,\n HostAccessRole,\n HostUid,\n OrgRole,\n OwnerHandoffPreviousOwner,\n ScopeToken,\n UserOrgMembership,\n} from '../foundation'\nimport {\n consoleLabelUnder,\n generateSubdomain,\n isBlockedSubdomain,\n SUBDOMAIN_PATTERN,\n} from './host-naming'\nimport {\n hostScopeToken,\n ORG_SCOPE_TOKEN,\n visibleToTokens,\n} from './scope-tokens'\n\n/** Same lexical policy as host subdomains: 3–30 lowercase/digits/dashes. */\nexport const ORG_SLUG_PATTERN = SUBDOMAIN_PATTERN\n\n/**\n * Org workspace slugs ride the host reserved/profanity blocklist plus\n * console-specific labels the workspace router must own.\n */\nconst RESERVED_ORG_ONLY = new Set(['staff', 'org', 'orgs', 'workspace'])\n\n/**\n * The label this deployment serves its own console on, when it has one\n * (AGL-3295).\n *\n * `app` and `console` are reserved above for every install. One whose console\n * lives at, say, `studio.example.com` needs `studio` refused as well, or an\n * organization could claim a workspace address the console itself answers.\n * Read from the same two settings the console's host gate reads; with either\n * unset nothing extra is reserved, because nothing extra is shadowed.\n */\nconst CONSOLE_LABEL = consoleLabelUnder(\n process.env.NEXT_PUBLIC_CONSOLE_URL,\n process.env.NEXT_PUBLIC_WORKSPACE_DOMAIN,\n)\n\nexport function isBlockedOrgSlug(slug: string): boolean {\n const normalized = slug.toLowerCase()\n return (\n RESERVED_ORG_ONLY.has(normalized) ||\n normalized === CONSOLE_LABEL ||\n isBlockedSubdomain(slug)\n )\n}\n\nexport function isValidOrgSlug(slug: string): boolean {\n return ORG_SLUG_PATTERN.test(slug) && !isBlockedOrgSlug(slug)\n}\n\n/** Best-effort slug from an org name; '' when nothing usable remains. */\nexport function generateOrgSlug(name: string): string {\n const slug = generateSubdomain(name)\n return slug && !isBlockedOrgSlug(slug) ? slug : ''\n}\n\nexport const ORG_ROLES: readonly OrgRole[] = [\n 'owner',\n 'admin',\n 'editor',\n 'viewer',\n]\n\nconst ORG_ROLE_WEIGHT: Record<OrgRole, number> = {\n owner: 3,\n admin: 2,\n editor: 1,\n viewer: 0,\n}\n\nexport function isOrgRole(value: unknown): value is OrgRole {\n return typeof value === 'string' && value in ORG_ROLE_WEIGHT\n}\n\nexport function orgRoleAtLeast(\n role: OrgRole | null | undefined,\n minimum: OrgRole,\n): boolean {\n if (!role || !(role in ORG_ROLE_WEIGHT)) return false\n return ORG_ROLE_WEIGHT[role] >= ORG_ROLE_WEIGHT[minimum]\n}\n\n/** Org settings, members, invites, host creation: admin and owner. */\nexport function canManageOrg(role: OrgRole | null | undefined): boolean {\n return orgRoleAtLeast(role, 'admin')\n}\n\n/**\n * The member's effective role on one host, or null for no access.\n * Owner/admin span every host; editor/viewer resolve through `allHosts`\n * (mapped to their org role) or the per-host access map.\n */\nexport function hostRoleFor(\n member: Partial<AglynOrgMember> | null | undefined,\n hostId: HostUid,\n): HostAccessRole | null {\n const role = member?.role\n if (!isOrgRole(role)) return null\n if (role === 'owner' || role === 'admin') return 'admin'\n const explicit = member?.hostAccess?.[hostId]\n if (explicit) return explicit\n return member?.allHosts ? role : null\n}\n\n/**\n * Host roles that may MUTATE content — the axis `canWriteHostContent()` in\n * the security rules asks (AGL-2334).\n *\n * `author` is here and absent from `HOST_PUBLISH_ROLES` below: that single\n * difference IS the \"edit content but not publish\" role. Kept as two named\n * sets rather than two inline comparisons because they are read in three\n * places that must not drift — this module, the ~20 server gates, and the\n * rules — and because a new host role added to the union has to be a\n * deliberate decision on BOTH lists rather than an omission from one.\n */\nexport const HOST_CONTENT_WRITE_ROLES: readonly HostAccessRole[] = [\n 'admin',\n 'editor',\n 'author',\n]\n\n/**\n * Host roles that may make content LIVE. Everything that registers a route,\n * moves a live-version pointer, schedules either of those for later, or\n * flips a collection entry to `published` asks this and not the set above.\n *\n * The list is short and it is the whole product decision: `author` is not on\n * it. If a role is ever added here, the rules' `canPublishHostContent()` and\n * the publish-surface field freezes below it must be updated in the same\n * change — they are the enforcement, this is the server-side echo of it.\n */\nexport const HOST_PUBLISH_ROLES: readonly HostAccessRole[] = ['admin', 'editor']\n\n/**\n * Whether the member may mutate a host's content — admin, editor **or\n * author** on it.\n *\n * An author is a content editor in every respect this predicate covers; what\n * it deliberately does NOT cover is publication. Any caller that is really\n * asking \"may this person make something live\" must call `canPublishHost`\n * instead — this one answering `true` for an author is the point of the role,\n * not a gap in it.\n */\nexport function canWriteHost(\n member: Partial<AglynOrgMember> | null | undefined,\n hostId: HostUid,\n): boolean {\n const role = hostRoleFor(member, hostId)\n return !!role && HOST_CONTENT_WRITE_ROLES.includes(role)\n}\n\n/**\n * Whether the member may PUBLISH on a host — register a route, move a live\n * version pointer, schedule either for later, or publish a collection entry.\n *\n * Strictly narrower than `canWriteHost`: `author` edits and does not publish.\n * This is the server-side echo of the rules, not the enforcement — publishing\n * is a set of client-direct Firestore writes across five document shapes, so\n * `cloud/firebase-firestore.rules` is where it is actually refused. Server\n * routes that schedule or trigger a publish ask this so the console can say\n * no with a message instead of surfacing a bare permission-denied.\n */\nexport function canPublishHost(\n member: Partial<AglynOrgMember> | null | undefined,\n hostId: HostUid,\n): boolean {\n const role = hostRoleFor(member, hostId)\n return !!role && HOST_PUBLISH_ROLES.includes(role)\n}\n\n/**\n * The same question asked of a bare `memberRoles` value rather than a member\n * document — the shape every Admin-SDK route already has in hand after its\n * `hosts/{hostId}` read, so it does not have to re-load the org membership.\n *\n * `String(role)` at the call sites keeps `undefined` out; an unknown value\n * is simply not in the set, which fails CLOSED.\n */\nexport function hostRoleCanWrite(role: unknown): boolean {\n return HOST_CONTENT_WRITE_ROLES.includes(role as HostAccessRole)\n}\n\n/** Publish-axis twin of `hostRoleCanWrite`; `author` is excluded. */\nexport function hostRoleCanPublish(role: unknown): boolean {\n return HOST_PUBLISH_ROLES.includes(role as HostAccessRole)\n}\n\n/**\n * Whether a member's reach is the whole org rather than a list of sites.\n *\n * Owner and admin always are; editor/viewer are only when `allHosts` says\n * so — **or** when the membership predates the flag entirely. A pre-\n * `allHosts` doc carries neither the flag nor a `hostAccess` map, and\n * reading that as \"scoped, with access to nothing\" would lock real members\n * out of their own workspace. A site collaborator ALWAYS has a non-empty\n * `hostAccess` (that is what `grantHostAccess` writes), so the absence of\n * both is the legacy shape, not a scoped one.\n *\n * Single source of truth for `resolveOrgPermissions` (the server gate),\n * `projectMemberScopeTokens` (the rules projection), and the rules'\n * own `isOrgWideMember()` — three copies of this predicate that disagree\n * is a privilege bug, not a style problem (AGL-1026, AGL-1038).\n */\nexport function isOrgWideMember(\n member: Partial<AglynOrgMember> | null | undefined,\n): boolean {\n if (!member) return false\n const role = member.role\n if (role === 'owner' || role === 'admin') return true\n if (member.allHosts === true) return true\n const scoping = member.hostAccess\n return (\n member.allHosts === undefined && !Object.keys(scoping ?? {}).length\n )\n}\n\n/**\n * How many of these roster entries consume a **manager** seat (AGL-1113).\n *\n * `orgs/{orgId}/members` holds BOTH org managers and site-scoped\n * collaborators — adding a collaborator to one site writes an org member doc\n * with `allHosts:false` + `hostAccess`, so a raw `.count()` of the collection\n * billed every collaborator as a manager. Collaborators are metered\n * separately, per host, against `membersPerHost` at\n * `hosts/{hostId}/members` — counting them here charged them twice and\n * tripped the manager gate for orgs nowhere near their manager limit.\n *\n * Reuses `isOrgWideMember` rather than filtering on `allHosts` directly:\n * owner/admin are managers whatever the flag says, and a pre-`allHosts` doc\n * is a manager too. A Firestore `where('allHosts','==',true)` count gets both\n * of those wrong, which is why callers pass entries in and count here.\n *\n * Accepts pending INVITES as well as members — an invite carries the same\n * `role`/`allHosts`/`hostAccess` shape and reserves the seat it will become.\n */\nexport function countManagerSeats(\n entries: ReadonlyArray<\n | (Partial<AglynOrgMember> & { handoff?: unknown })\n | null\n | undefined\n >,\n): number {\n let count = 0\n for (const entry of entries) {\n if (usesCustomerSeat(entry) && isOrgWideMember(entry)) count += 1\n }\n return count\n}\n\n/**\n * Whether a roster row or pending invite takes one of the customer's seats\n * at all (AGL-3466). Every seat counter asks this before it asks which kind\n * of seat, so the two populations below never include either exception.\n *\n * Two kinds of entry hold access without a seat:\n *\n * - **Platform staff.** A row or invite stamped `staffSeat: true` belongs to\n * an account holding the `staff` claim. Staff build workspaces for\n * prospects and join them to help; billing the customer a seat for either\n * is what made a Free workspace (one seat) unable to invite the client it\n * was built for without a hand-written override.\n * - **An owner handoff.** An invite carrying `handoff` moves the owner seat\n * from one person to another rather than adding one. Whether the outgoing\n * owner staying on needs a seat of their own is decided when the handoff is\n * sent and again when it is accepted, never by counting the invite.\n */\nexport function usesCustomerSeat(\n entry:\n | { staffSeat?: unknown; handoff?: unknown }\n | null\n | undefined,\n): boolean {\n if (!entry) return false\n if (entry.staffSeat === true) return false\n return !entry.handoff\n}\n\n/**\n * Whether a roster row belongs to platform staff (AGL-3466) — the Team\n * page's \"no seat\" chip, and the row every seat count leaves out.\n */\nexport function isStaffSeat(\n entry: { staffSeat?: unknown } | null | undefined,\n): boolean {\n return entry?.staffSeat === true\n}\n\n/** The outgoing owner's choices, in the order the picker offers them. */\nexport const OWNER_HANDOFF_PREVIOUS_OWNER: readonly OwnerHandoffPreviousOwner[] = [\n 'stay',\n 'leave',\n]\n\n/** What each choice does, for the picker and the confirmation. */\nexport const OWNER_HANDOFF_PREVIOUS_OWNER_LABELS: Record<\n OwnerHandoffPreviousOwner,\n string\n> = {\n stay: 'Stay on as an admin after the handoff',\n leave: 'Leave after the handoff',\n}\n\n/**\n * The handoff half of an invite request, or null when it is not a usable\n * one (AGL-3466).\n *\n * Read in one place because the same body reaches the server from three\n * surfaces (the Team page, the staff organization page and the staff site\n * page), and an unknown `previousOwner` must be refused rather than guessed:\n * guessing `leave` takes someone off a roster they meant to stay on, and\n * guessing `stay` spends a seat they meant to free.\n */\nexport function parseOwnerHandoff(raw: unknown): AglynOrgOwnerHandoff | null {\n if (!raw || typeof raw !== 'object') return null\n const previousOwner = (raw as { previousOwner?: unknown }).previousOwner\n return previousOwner === 'stay' || previousOwner === 'leave'\n ? { previousOwner }\n : null\n}\n\n/**\n * One roster row or pending invite, as the collaborator counter needs to see\n * it (AGL-2068). Deliberately structural rather than `Partial<AglynOrgMember>`\n * — an INVITE is not a member document, it is keyed by email and has no uid,\n * and the whole point of this counter is that the two populations must be\n * counted together or the cap is enforced against a fraction of itself.\n */\nexport interface CollaboratorSeatEntry {\n /** Member documents are keyed by uid; invites have none. */\n uid?: string | null\n email?: string | null\n role?: unknown\n allHosts?: boolean\n hostAccess?: Record<string, unknown> | null\n /** Platform staff hold no seat (AGL-3466) — see `usesCustomerSeat`. */\n staffSeat?: boolean | null\n /** An owner-handoff invite holds no seat (AGL-3466). */\n handoff?: unknown\n}\n\n/**\n * The identity key one entry occupies, or `''` when it identifies nobody.\n *\n * EMAIL FIRST, uid second, and that order is the join (AGL-2068). A person\n * admitted to a site is one seat whether they arrive as an accepted member or\n * as a still-pending invite, and those two rows live in different collections\n * keyed different ways — `orgs/{id}/members/{uid}` and\n * `orgs/{id}/invites/{inviteId}` — with the address as the only field both\n * carry. Counting by uid would count the invite and the membership it becomes\n * as two seats; counting only invites would miss every member.\n *\n * Legacy roster rows written before `email` was mirrored fall back to the uid,\n * which cannot collide with an address (the `uid:` prefix is not a legal\n * local-part/domain pair) and so is safe to mix into the same set.\n */\nfunction collaboratorSeatKey(entry: CollaboratorSeatEntry): string {\n const email =\n typeof entry.email === 'string' ? entry.email.trim().toLowerCase() : ''\n if (email) return email\n const uid = typeof entry.uid === 'string' ? entry.uid.trim() : ''\n return uid ? `uid:${uid}` : ''\n}\n\n/**\n * Every principal consuming a COLLABORATOR seat on one host (AGL-2068).\n *\n * ## Why this exists at all\n *\n * `membersPerHost` was enforced on one of the four doors that admit a site\n * collaborator, and that one door counted `hosts/{hostId}/members` — a\n * DISPLAY roster only `/api/hosts/members` writes. The other three\n * (`/api/orgs/invites` create, `/api/orgs/invites` accept, and the\n * `hostAccess` branch of `/api/orgs/members`) land in `orgs/{id}/members` and\n * the `hosts/{id}.memberRoles` projection, and were invisible to it. So the\n * cap was both skipped on three paths and under-counted on the fourth, and a\n * free org — `membersPerHost: 1` — could take on unlimited collaborators,\n * each with full site access, on a plan that is never invoiced.\n *\n * verbatim: **\"We need to make sure the free/hobby tier\n * does hard cap so it always actually stays free.\"**\n *\n * ## What counts\n *\n * A SCOPED grant on `hostId`, and nothing else. An org-wide member\n * (owner/admin, or `allHosts`) reaches every host but consumes a MANAGER\n * seat — `countManagerSeats` is that side, and charging them here would bill\n * one person twice and trip a cap they are not on. This is the same split\n * AGL-1113 drew; it is now drawn from one place instead of three.\n *\n * Pending invites count, for the reason the manager gate counts them: an\n * invite reserves the seat it will become, and a cap that only bites on\n * acceptance can be walked past by mailing out N invites first.\n */\nexport function collaboratorSeatKeys(\n entries: ReadonlyArray<CollaboratorSeatEntry | null | undefined>,\n hostId: string,\n): Set<string> {\n const keys = new Set<string>()\n if (!hostId) return keys\n for (const entry of entries) {\n if (!usesCustomerSeat(entry)) continue\n // Managers are the other counter's population, whatever host map they\n // happen to carry — a promoted collaborator keeps their old `hostAccess`.\n if (isOrgWideMember(entry as Partial<AglynOrgMember>)) continue\n if (!entry.hostAccess?.[hostId]) continue\n const key = collaboratorSeatKey(entry)\n if (key) keys.add(key)\n }\n return keys\n}\n\n/**\n * How many collaborator seats on `hostId` are spoken for by someone OTHER\n * than `exclude` (AGL-2068).\n *\n * `exclude` is the principal the caller is about to admit, and dropping them\n * is what makes this composable with `checkSeatQuota(…, used)`, whose\n * contract is \"usage BEFORE the resource being created\". Without it the\n * accept path refuses its own invite: at the moment acceptance is decided the\n * invitee holds a pending invite AND is about to hold a membership, so they\n * would be counted once or twice against a cap they are the subject of.\n *\n * Both identities are dropped, not whichever one is present. A person can be\n * on the roster under a uid with no mirrored email and hold an invite under\n * that address, and the seat is still one seat.\n *\n * ## `emails`, not `email` — an account holds several (AGL-2486)\n *\n * Since an invitation may be accepted at any CONFIRMED address on the account,\n * the pending invite the accepter is about to consume can be keyed by an\n * address that is not their primary. `exclude.email` alone is the primary, so\n * that invite survived the exclusion and the person was counted against a cap\n * they are the subject of — the exact double-count the paragraph above says\n * this function exists to prevent, re-opened by widening the accept match.\n *\n * It fails CLOSED (a spurious \"out of seats\" refusal, never a free seat), and\n * only for an org sitting exactly on its cap, which is why it survived review.\n *\n * `emails` takes every address PROVEN to belong to the principal being\n * admitted. Passing an address that is not theirs would hand out a free seat,\n * so callers must pass confirmed addresses for that uid and nothing else.\n */\nexport function countCollaboratorSeats(\n entries: ReadonlyArray<CollaboratorSeatEntry | null | undefined>,\n hostId: string,\n exclude?: {\n uid?: string | null\n email?: string | null\n /** Further addresses on the SAME account; see above. */\n emails?: readonly (string | null | undefined)[] | null\n },\n): number {\n const keys = collaboratorSeatKeys(entries, hostId)\n for (const candidate of [exclude?.email, ...(exclude?.emails ?? [])]) {\n const email = typeof candidate === 'string' ? candidate.trim().toLowerCase() : ''\n if (email) keys.delete(email)\n }\n const uid = typeof exclude?.uid === 'string' ? exclude.uid.trim() : ''\n if (uid) keys.delete(`uid:${uid}`)\n return keys.size\n}\n\n/**\n * Every principal consuming a MANAGER seat, as identities rather than rows.\n *\n * The org-wide half of `collaboratorSeatKeys`, drawn from the same entries and\n * split on the same predicate, so the two counters partition one roster\n * instead of overlapping on it.\n *\n * Deduplicating by identity is the difference from {@link countManagerSeats},\n * and it is what makes this composable with an `exclude`: a person holding a\n * pending invite AND the membership it became is one seat, and the row-wise\n * counter reports two because it is handed the two collections separately and\n * sums them.\n */\nexport function managerSeatKeys(\n entries: ReadonlyArray<CollaboratorSeatEntry | null | undefined>,\n): Set<string> {\n const keys = new Set<string>()\n for (const entry of entries) {\n if (!usesCustomerSeat(entry)) continue\n if (!isOrgWideMember(entry as Partial<AglynOrgMember>)) continue\n const key = collaboratorSeatKey(entry)\n if (key) keys.add(key)\n }\n return keys\n}\n\n/**\n * How many manager seats are spoken for by someone OTHER than `exclude`.\n *\n * The manager twin of {@link countCollaboratorSeats}, and it exists for the\n * same reason that one takes an `exclude`: `checkSeatQuota`'s contract is\n * \"usage BEFORE the resource being created\", and on the invite-accept path the\n * accepter holds a pending invite and is about to hold a membership at the\n * same instant. Counted plainly they are charged once or twice for a seat they\n * are the subject of, and an org on its last seat refuses its own invitation.\n *\n * Both identities are dropped, and `emails` carries the further addresses\n * proven to belong to that account — an invitation may be accepted at any\n * confirmed address, so the invite being consumed need not be keyed by the\n * primary one. Passing an address that is not theirs would hand out a free\n * seat, so callers must pass confirmed addresses for that uid and nothing\n * else.\n *\n * Kept SEPARATE from {@link countManagerSeats} rather than replacing it: that\n * one is row-wise and is what the billing and console surfaces count with, and\n * quietly making it identity-wise would change every one of those totals.\n */\nexport function countManagerSeatsExcluding(\n entries: ReadonlyArray<CollaboratorSeatEntry | null | undefined>,\n exclude?: {\n uid?: string | null\n email?: string | null\n /** Further addresses on the SAME account; see above. */\n emails?: readonly (string | null | undefined)[] | null\n },\n): number {\n const keys = managerSeatKeys(entries)\n for (const candidate of [exclude?.email, ...(exclude?.emails ?? [])]) {\n const email = typeof candidate === 'string' ? candidate.trim().toLowerCase() : ''\n if (email) keys.delete(email)\n }\n const uid = typeof exclude?.uid === 'string' ? exclude.uid.trim() : ''\n if (uid) keys.delete(`uid:${uid}`)\n return keys.size\n}\n\n/**\n * What KIND of user a console row represents (AGL-1114) — the distinction\n * that decides which seat they consume, and the one the Team table could not\n * express because a manager and a collaborator differ only by fields the\n * table never showed.\n *\n * Deliberately NOT the same axis as `role`. A `role: 'editor'` is a manager\n * when their reach is the whole org and a collaborator when it is two sites;\n * reading the role alone is the mistake that let a collaborator look like\n * team staff (see the org-leak and role-gate work).\n *\n * `siteMember` is the third kind and never appears in `orgs/{id}/members` at\n * all: it is an end-user account on a PUBLISHED site (`siteMembers`), not a\n * console user. It has no Firebase Auth record of any kind — a site member is\n * a Firestore document under `hosts/{hostId}/siteMembers` with its own scrypt\n * hash and its own session cookie — so no surface that lists AUTH accounts,\n * the staff user directory included, can contain one. The label is here so the\n * vocabulary is complete, for a surface that lists site accounts and needs a\n * word for them. Free and uncounted on every plan (AGL-889).\n */\nexport type ConsoleUserType = 'manager' | 'collaborator' | 'siteMember'\n\nexport const CONSOLE_USER_TYPE_LABELS: Record<ConsoleUserType, string> = {\n manager: 'Team manager',\n collaborator: 'Site collaborator',\n siteMember: 'Site member',\n}\n\n/** One line on what each kind is and what it costs — tooltip/legend copy. */\nexport const CONSOLE_USER_TYPE_HINTS: Record<ConsoleUserType, string> = {\n manager:\n 'Reaches the whole organization. Uses one of your team (manager) seats.',\n collaborator:\n 'Scoped to specific sites. Uses a collaborator seat on each site they ' +\n 'can reach, not a team seat.',\n siteMember:\n 'An end-user account on a published site, not a console user. Free and ' +\n 'unlimited on every plan.',\n}\n\n/**\n * Classify an `orgs/{orgId}/members` entry (or a pending invite, same shape).\n * Never returns `siteMember` — nothing in that collection is one; the value\n * exists for surfaces that list published-site accounts and must label them —\n * never for one that lists both, since the two populations do not share an\n * identity system.\n */\nexport function consoleUserType(\n member: Partial<AglynOrgMember> | null | undefined,\n): Exclude<ConsoleUserType, 'siteMember'> {\n return isOrgWideMember(member) ? 'manager' : 'collaborator'\n}\n\n/**\n * Whether an account is governed by enterprise SSO (AGL-1128) — it lives in\n * an org's GCIP tenant pool rather than the project pool, so its identity is\n * owned by the customer's IdP.\n *\n * `tenantId` is the whole signal: Firebase sets it on the user whenever the\n * account belongs to a tenant, and only SSO accounts do.\n */\nexport function isSsoGovernedAccount(\n user: { tenantId?: string | null } | null | undefined,\n): boolean {\n return Boolean(user?.tenantId)\n}\n\n/**\n * Whether the account may connect an additional consumer sign-in provider.\n *\n * **No, for any SSO-governed account.** The customer's IdP is the single gate\n * they bought: they revoke there, enforce MFA there, offboard there. A linked\n * personal Google account is a way in their IdP can never see or revoke —\n * exactly what SSO is purchased to prevent.\n *\n * Deliberately NOT gated on `sso.enforced`. That flag exists so we never\n * LOCK OUT an existing sign-in method; it is not a licence to hand out new\n * bypasses meanwhile. Nothing here removes a provider an account already has,\n * so this can never lock anyone out.\n *\n * This is the intent, not the boundary — the boundary is upstream, since a\n * GCIP tenant only accepts providers enabled on that tenant. Both matter: the\n * boundary is remote config that could drift, and a UI that offers a\n * security-regressing action is wrong even when the action would fail.\n */\nexport function canLinkSocialProvider(\n user: { tenantId?: string | null } | null | undefined,\n): boolean {\n return !isSsoGovernedAccount(user)\n}\n\n/**\n * The same question as `isOrgWideMember`, asked of the `users/{uid}/orgs`\n * reverse-index row instead of the member doc (AGL-1032).\n *\n * The console navigates from the index — it is what `useOrgScope` loads —\n * and the index carries a MIRROR of the predicate, not the inputs to it, so\n * this cannot recompute: `role: 'viewer'` on the index means \"viewer\",\n * whether the member reaches every site or one.\n *\n * Reads a missing flag as org-wide, for the same reason `isOrgWideMember`\n * reads a missing `allHosts` that way: rows written before the mirror\n * existed have no flag, and hiding the workspace from a real member is a\n * worse failure than showing org chrome to a collaborator whose reads the\n * rules refuse anyway. Owner/admin are org-wide by role regardless of the\n * mirror, so a stale row can never lock an admin out.\n *\n * Navigation only — never an access decision. AGL-1026 (rules) and the\n * Admin-SDK scope checks are the boundary.\n */\nexport function isOrgWideMembership(\n membership:\n | Pick<UserOrgMembership, 'role' | 'orgWide'>\n | null\n | undefined,\n): boolean {\n if (!membership) return true\n if (membership.role === 'owner' || membership.role === 'admin') return true\n return membership.orgWide !== false\n}\n\n/**\n * The `scopeTokens` projection stamped onto member docs (AGL-1038) so the\n * rules can intersect a caller's reach with a resource's `visibleTo` —\n * rules have no `.map()`, so `hostAccess`'s keys cannot be turned into\n * tokens at evaluation time and have to be denormalized here.\n *\n * Org-wide members get `['org']`; they own the org and read everything in\n * it, scoped or not (the rules short-circuit on them anyway). A scoped\n * collaborator gets `'org'` plus one token per granted host — `'org'`\n * because an org-WIDE resource stays readable by any member, which is\n * today's behavior and not something this project narrows.\n *\n * Not capped at `MAX_SCOPE_HOSTS`: the rules' `hasAny` has no 30-value\n * limit, only the client `array-contains-any` query does, so a member of\n * more than 30 sites is a listing problem for AGL-1044 to chunk, not a\n * reason to under-grant here.\n */\nexport function projectMemberScopeTokens(\n member: Partial<AglynOrgMember> | null | undefined,\n): ScopeToken[] {\n if (isOrgWideMember(member)) return [ORG_SCOPE_TOKEN]\n const hostIds = Object.keys(member?.hostAccess ?? {})\n return [ORG_SCOPE_TOKEN, ...hostIds.map(hostScopeToken)]\n}\n\n/**\n * A member's effective read set: the stored `scopeTokens` projection when it\n * is there, recomputed from the membership when it is not.\n *\n * The fallback is the security-relevant half and it was written out by hand\n * in four places (`use-scope-tokens`, `erase-scope`, the media sign route,\n * and by omission in `resolveMediaScope`, which only ever recomputed). Four\n * copies of \"what may this member see\" is the shape that eventually\n * disagrees, and a copy that reads `member.scopeTokens` WITHOUT the\n * `.length` guard silently grants nothing to a member the AGL-1040 backfill\n * has not reached — or, written the other way round, grants everything.\n *\n * Recomputing is the safe fallback rather than a convenience: it produces\n * exactly what `grantHostAccess` would have stamped, so an unstamped member\n * doc behaves as its `hostAccess` says, never as \"no access\" and never as\n * org-wide.\n */\nexport function memberScopeTokens(\n member: Partial<AglynOrgMember> | null | undefined,\n): ScopeToken[] {\n const stored = member?.scopeTokens\n return stored?.length ? stored : projectMemberScopeTokens(member)\n}\n\n/**\n * May this member see a resource carrying `visibleTo`? (AGL-1037/1038)\n *\n * The whole client-side/server-side authorization question in one place:\n * org-wide members read everything they own; everyone else needs their read\n * set to intersect the resource's scope. Absent/empty `visibleTo` is\n * org-wide by the same rule the Firestore rules use, so an unbackfilled\n * resource does not vanish.\n *\n * Every Admin-SDK path must call this (or `scopeAllows`, which wraps an\n * already-resolved scope) — the Admin SDK bypasses rules entirely, so this\n * IS the enforcement there, not a second opinion about it.\n *\n * NOT a membership check, and never a substitute for one: a `null` member\n * \"can see\" an org-wide resource here, because the question this answers is\n * \"is this resource in scope for that reach\", not \"is this person in the\n * org\". Resolve membership first and refuse the caller if there is none —\n * conflating the two is exactly how AGL-1026 exposed a whole org.\n */\nexport function memberCanSee(\n member: Partial<AglynOrgMember> | null | undefined,\n visibleTo: readonly string[] | null | undefined,\n): boolean {\n if (isOrgWideMember(member)) return true\n return visibleToTokens(visibleTo ?? undefined, memberScopeTokens(member))\n}\n\n/**\n * The `memberRoles` projection stamped onto host docs so Firestore rules\n * authorize host content with the single host-doc read they already do\n * (docs/MULTI_TENANT_FIRESTORE.md §5). Recomputed by the membership API\n * whenever a member changes; owner/admin appear on every host.\n */\nexport function projectHostMemberRoles(\n members: ReadonlyArray<Partial<AglynOrgMember> & { $id: string }>,\n hostId: HostUid,\n): Record<string, HostAccessRole> {\n const projection: Record<string, HostAccessRole> = {}\n for (const member of members) {\n const role = hostRoleFor(member, hostId)\n if (role) projection[member.$id] = role\n }\n return projection\n}\n"],"names":["consoleLabelUnder","generateSubdomain","isBlockedSubdomain","SUBDOMAIN_PATTERN","hostScopeToken","ORG_SCOPE_TOKEN","visibleToTokens","ORG_SLUG_PATTERN","RESERVED_ORG_ONLY","Set","CONSOLE_LABEL","process","env","NEXT_PUBLIC_CONSOLE_URL","NEXT_PUBLIC_WORKSPACE_DOMAIN","isBlockedOrgSlug","slug","normalized","toLowerCase","has","isValidOrgSlug","test","generateOrgSlug","name","ORG_ROLES","ORG_ROLE_WEIGHT","owner","admin","editor","viewer","isOrgRole","value","orgRoleAtLeast","role","minimum","canManageOrg","hostRoleFor","member","hostId","explicit","hostAccess","allHosts","HOST_CONTENT_WRITE_ROLES","HOST_PUBLISH_ROLES","canWriteHost","includes","canPublishHost","hostRoleCanWrite","hostRoleCanPublish","isOrgWideMember","scoping","undefined","Object","keys","length","countManagerSeats","entries","count","entry","usesCustomerSeat","staffSeat","handoff","isStaffSeat","OWNER_HANDOFF_PREVIOUS_OWNER","OWNER_HANDOFF_PREVIOUS_OWNER_LABELS","stay","leave","parseOwnerHandoff","raw","previousOwner","collaboratorSeatKey","email","trim","uid","collaboratorSeatKeys","key","add","countCollaboratorSeats","exclude","candidate","emails","delete","size","managerSeatKeys","countManagerSeatsExcluding","CONSOLE_USER_TYPE_LABELS","manager","collaborator","siteMember","CONSOLE_USER_TYPE_HINTS","consoleUserType","isSsoGovernedAccount","user","Boolean","tenantId","canLinkSocialProvider","isOrgWideMembership","membership","orgWide","projectMemberScopeTokens","hostIds","map","memberScopeTokens","stored","scopeTokens","memberCanSee","visibleTo","projectHostMemberRoles","members","projection","$id"],"mappings":"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"}