@aglyn/aglyn 1.0.0-beta.221 → 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 (346) 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 +22 -22
  45. package/src/lib/app-utils/docs-help.generated.js +44 -10
  46. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  47. package/src/lib/app-utils/docs-index.generated.js +738 -150
  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 +11 -0
  65. package/src/lib/app-utils/health-report.js.map +1 -1
  66. package/src/lib/app-utils/llms-txt.d.ts +20 -0
  67. package/src/lib/app-utils/llms-txt.js +7 -1
  68. package/src/lib/app-utils/llms-txt.js.map +1 -1
  69. package/src/lib/app-utils/media-asset-facts.d.ts +16 -1
  70. package/src/lib/app-utils/media-asset-facts.js +40 -13
  71. package/src/lib/app-utils/media-asset-facts.js.map +1 -1
  72. package/src/lib/app-utils/media-bandwidth.d.ts +52 -0
  73. package/src/lib/app-utils/media-bandwidth.js +94 -0
  74. package/src/lib/app-utils/media-bandwidth.js.map +1 -0
  75. package/src/lib/app-utils/media-ref.d.ts +241 -33
  76. package/src/lib/app-utils/media-ref.js +324 -43
  77. package/src/lib/app-utils/media-ref.js.map +1 -1
  78. package/src/lib/app-utils/media-src.d.ts +8 -8
  79. package/src/lib/app-utils/media-src.js +27 -11
  80. package/src/lib/app-utils/media-src.js.map +1 -1
  81. package/src/lib/app-utils/media-takedown-reach.d.ts +4 -1
  82. package/src/lib/app-utils/media-takedown-reach.js +6 -3
  83. package/src/lib/app-utils/media-takedown-reach.js.map +1 -1
  84. package/src/lib/app-utils/notifications.d.ts +1 -1
  85. package/src/lib/app-utils/notifications.js +2 -0
  86. package/src/lib/app-utils/notifications.js.map +1 -1
  87. package/src/lib/app-utils/organizations.d.ts +51 -2
  88. package/src/lib/app-utils/organizations.js +56 -3
  89. package/src/lib/app-utils/organizations.js.map +1 -1
  90. package/src/lib/app-utils/page-record-sources.d.ts +87 -0
  91. package/src/lib/app-utils/page-record-sources.js +53 -0
  92. package/src/lib/app-utils/page-record-sources.js.map +1 -0
  93. package/src/lib/app-utils/paid-media-source.d.ts +3 -7
  94. package/src/lib/app-utils/paid-media-source.js +5 -19
  95. package/src/lib/app-utils/paid-media-source.js.map +1 -1
  96. package/src/lib/app-utils/person-erasure.d.ts +5 -2
  97. package/src/lib/app-utils/person-erasure.js.map +1 -1
  98. package/src/lib/app-utils/picklists.d.ts +187 -0
  99. package/src/lib/app-utils/picklists.js +320 -0
  100. package/src/lib/app-utils/picklists.js.map +1 -0
  101. package/src/lib/app-utils/plan-entitlements.d.ts +220 -50
  102. package/src/lib/app-utils/plan-entitlements.js +309 -115
  103. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  104. package/src/lib/app-utils/realm-host-surface.generated.js +2 -0
  105. package/src/lib/app-utils/realm-host-surface.generated.js.map +1 -1
  106. package/src/lib/app-utils/release-flags.d.ts +1 -1
  107. package/src/lib/app-utils/release-flags.js +11 -0
  108. package/src/lib/app-utils/release-flags.js.map +1 -1
  109. package/src/lib/app-utils/rendered-widths.d.ts +121 -0
  110. package/src/lib/app-utils/rendered-widths.js +211 -0
  111. package/src/lib/app-utils/rendered-widths.js.map +1 -0
  112. package/src/lib/app-utils/repeat-sources.d.ts +2 -1
  113. package/src/lib/app-utils/repeat-sources.js.map +1 -1
  114. package/src/lib/app-utils/screen-route.d.ts +154 -36
  115. package/src/lib/app-utils/screen-route.js +185 -40
  116. package/src/lib/app-utils/screen-route.js.map +1 -1
  117. package/src/lib/app-utils/seo-audit.d.ts +11 -1
  118. package/src/lib/app-utils/seo-audit.js +33 -13
  119. package/src/lib/app-utils/seo-audit.js.map +1 -1
  120. package/src/lib/app-utils/seo-keywords.d.ts +11 -0
  121. package/src/lib/app-utils/seo-keywords.js +19 -5
  122. package/src/lib/app-utils/seo-keywords.js.map +1 -1
  123. package/src/lib/app-utils/seo-page-facts.d.ts +47 -4
  124. package/src/lib/app-utils/seo-page-facts.js +136 -31
  125. package/src/lib/app-utils/seo-page-facts.js.map +1 -1
  126. package/src/lib/app-utils/seo-site-scan.d.ts +42 -4
  127. package/src/lib/app-utils/seo-site-scan.js +113 -12
  128. package/src/lib/app-utils/seo-site-scan.js.map +1 -1
  129. package/src/lib/app-utils/server.d.ts +5 -4
  130. package/src/lib/app-utils/server.js +14 -5
  131. package/src/lib/app-utils/server.js.map +1 -1
  132. package/src/lib/app-utils/site-icon-set.d.ts +195 -0
  133. package/src/lib/app-utils/site-icon-set.js +351 -0
  134. package/src/lib/app-utils/site-icon-set.js.map +1 -0
  135. package/src/lib/app-utils/site-interactions.d.ts +101 -4
  136. package/src/lib/app-utils/site-interactions.js +120 -4
  137. package/src/lib/app-utils/site-interactions.js.map +1 -1
  138. package/src/lib/app-utils/starter-template-nodes.d.ts +7 -0
  139. package/src/lib/app-utils/starter-template-nodes.js +3 -0
  140. package/src/lib/app-utils/starter-template-nodes.js.map +1 -1
  141. package/src/lib/app-utils/starter-templates.d.ts +5 -19
  142. package/src/lib/app-utils/starter-templates.js +5 -49
  143. package/src/lib/app-utils/starter-templates.js.map +1 -1
  144. package/src/lib/app-utils/transfer-launcher-context.d.ts +136 -0
  145. package/src/lib/app-utils/transfer-launcher-context.js +23 -0
  146. package/src/lib/app-utils/transfer-launcher-context.js.map +1 -0
  147. package/src/lib/app-utils/transfer-resume-import.d.ts +39 -0
  148. package/src/lib/app-utils/transfer-resume-import.js +128 -0
  149. package/src/lib/app-utils/transfer-resume-import.js.map +1 -0
  150. package/src/lib/app-utils/upgrade-proposal.d.ts +66 -0
  151. package/src/lib/app-utils/upgrade-proposal.js +95 -0
  152. package/src/lib/app-utils/upgrade-proposal.js.map +1 -0
  153. package/src/lib/app-utils/utc-month.d.ts +33 -0
  154. package/src/lib/app-utils/utc-month.js +35 -0
  155. package/src/lib/app-utils/utc-month.js.map +1 -0
  156. package/src/lib/app-utils/visitor-record-ceiling.d.ts +4 -4
  157. package/src/lib/app-utils/visitor-record-ceiling.js +4 -4
  158. package/src/lib/app-utils/visitor-record-ceiling.js.map +1 -1
  159. package/src/lib/app-utils/wire-nodes.d.ts +39 -19
  160. package/src/lib/app-utils/wire-nodes.js +120 -34
  161. package/src/lib/app-utils/wire-nodes.js.map +1 -1
  162. package/src/lib/data-transfer/access.d.ts +74 -0
  163. package/src/lib/data-transfer/access.js +93 -0
  164. package/src/lib/data-transfer/access.js.map +1 -0
  165. package/src/lib/data-transfer/derive.d.ts +246 -0
  166. package/src/lib/data-transfer/derive.js +1007 -0
  167. package/src/lib/data-transfer/derive.js.map +1 -0
  168. package/src/lib/data-transfer/export-file.d.ts +46 -0
  169. package/src/lib/data-transfer/export-file.js +146 -0
  170. package/src/lib/data-transfer/export-file.js.map +1 -0
  171. package/src/lib/data-transfer/field-catalog.d.ts +134 -0
  172. package/src/lib/data-transfer/field-catalog.js +278 -0
  173. package/src/lib/data-transfer/field-catalog.js.map +1 -0
  174. package/src/lib/data-transfer/header-match.d.ts +129 -0
  175. package/src/lib/data-transfer/header-match.js +367 -0
  176. package/src/lib/data-transfer/header-match.js.map +1 -0
  177. package/src/lib/data-transfer/index.d.ts +34 -0
  178. package/src/lib/data-transfer/index.js +40 -0
  179. package/src/lib/data-transfer/index.js.map +1 -0
  180. package/src/lib/data-transfer/job.d.ts +190 -0
  181. package/src/lib/data-transfer/job.js +229 -0
  182. package/src/lib/data-transfer/job.js.map +1 -0
  183. package/src/lib/data-transfer/lookup.d.ts +45 -0
  184. package/src/lib/data-transfer/lookup.js +71 -0
  185. package/src/lib/data-transfer/lookup.js.map +1 -0
  186. package/src/lib/data-transfer/match.d.ts +99 -0
  187. package/src/lib/data-transfer/match.js +244 -0
  188. package/src/lib/data-transfer/match.js.map +1 -0
  189. package/src/lib/data-transfer/package-plan.d.ts +205 -0
  190. package/src/lib/data-transfer/package-plan.js +475 -0
  191. package/src/lib/data-transfer/package-plan.js.map +1 -0
  192. package/src/lib/data-transfer/package.d.ts +181 -0
  193. package/src/lib/data-transfer/package.js +426 -0
  194. package/src/lib/data-transfer/package.js.map +1 -0
  195. package/src/lib/data-transfer/picklist-map.d.ts +139 -0
  196. package/src/lib/data-transfer/picklist-map.js +271 -0
  197. package/src/lib/data-transfer/picklist-map.js.map +1 -0
  198. package/src/lib/data-transfer/plan.d.ts +176 -0
  199. package/src/lib/data-transfer/plan.js +451 -0
  200. package/src/lib/data-transfer/plan.js.map +1 -0
  201. package/src/lib/data-transfer/policy.d.ts +170 -0
  202. package/src/lib/data-transfer/policy.js +378 -0
  203. package/src/lib/data-transfer/policy.js.map +1 -0
  204. package/src/lib/data-transfer/resource.d.ts +211 -0
  205. package/src/lib/data-transfer/resource.js +241 -0
  206. package/src/lib/data-transfer/resource.js.map +1 -0
  207. package/src/lib/data-transfer/review.d.ts +70 -0
  208. package/src/lib/data-transfer/review.js +233 -0
  209. package/src/lib/data-transfer/review.js.map +1 -0
  210. package/src/lib/data-transfer/similarity.d.ts +40 -0
  211. package/src/lib/data-transfer/similarity.js +106 -0
  212. package/src/lib/data-transfer/similarity.js.map +1 -0
  213. package/src/lib/data-transfer/site-package.d.ts +365 -0
  214. package/src/lib/data-transfer/site-package.js +1077 -0
  215. package/src/lib/data-transfer/site-package.js.map +1 -0
  216. package/src/lib/data-transfer/source.d.ts +74 -0
  217. package/src/lib/data-transfer/source.js +293 -0
  218. package/src/lib/data-transfer/source.js.map +1 -0
  219. package/src/lib/data-transfer/transfer-api.d.ts +898 -0
  220. package/src/lib/data-transfer/transfer-api.js +107 -0
  221. package/src/lib/data-transfer/transfer-api.js.map +1 -0
  222. package/src/lib/foundation/definitions/org-billing.types.d.ts +31 -0
  223. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  224. package/src/lib/foundation/definitions/organization.types.d.ts +42 -1
  225. package/src/lib/foundation/definitions/organization.types.js.map +1 -1
  226. package/src/lib/foundation/definitions/platform.types.d.ts +6 -4
  227. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  228. package/src/lib/foundation/index.d.ts +1 -1
  229. package/src/lib/foundation/index.js.map +1 -1
  230. package/src/lib/plugin-manager/feature-plugins.d.ts +91 -2
  231. package/src/lib/plugin-manager/feature-plugins.js +48 -3
  232. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  233. package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +50 -0
  234. package/src/lib/plugin-manager/first-party-plugins.generated.js +1129 -28
  235. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  236. package/src/lib/plugin-manager/index.d.ts +0 -1
  237. package/src/lib/plugin-manager/index.js +0 -1
  238. package/src/lib/plugin-manager/index.js.map +1 -1
  239. package/src/lib/plugin-manager/interaction-recipes.d.ts +142 -0
  240. package/src/lib/plugin-manager/interaction-recipes.js +104 -0
  241. package/src/lib/plugin-manager/interaction-recipes.js.map +1 -0
  242. package/src/lib/plugin-manager/interaction-step-checks.d.ts +66 -0
  243. package/src/lib/plugin-manager/interaction-step-checks.js +86 -0
  244. package/src/lib/plugin-manager/interaction-step-checks.js.map +1 -0
  245. package/src/lib/plugin-manager/plugin-artifact-types.d.ts +235 -0
  246. package/src/lib/plugin-manager/plugin-artifact-types.js +99 -0
  247. package/src/lib/plugin-manager/plugin-artifact-types.js.map +1 -0
  248. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
  249. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  250. package/src/lib/plugin-manager/plugin-conversion-credit.d.ts +216 -0
  251. package/src/lib/plugin-manager/plugin-conversion-credit.js +91 -0
  252. package/src/lib/plugin-manager/plugin-conversion-credit.js.map +1 -0
  253. package/src/lib/plugin-manager/plugin-entity-pickers.d.ts +83 -0
  254. package/src/lib/plugin-manager/plugin-entity-pickers.js +87 -0
  255. package/src/lib/plugin-manager/plugin-entity-pickers.js.map +1 -0
  256. package/src/lib/plugin-manager/plugin-host-resources.d.ts +6 -0
  257. package/src/lib/plugin-manager/plugin-host-resources.js +6 -1
  258. package/src/lib/plugin-manager/plugin-host-resources.js.map +1 -1
  259. package/src/lib/plugin-manager/plugin-intake-gates.d.ts +75 -0
  260. package/src/lib/plugin-manager/plugin-intake-gates.js +49 -0
  261. package/src/lib/plugin-manager/plugin-intake-gates.js.map +1 -0
  262. package/src/lib/plugin-manager/plugin-jobs.d.ts +5 -5
  263. package/src/lib/plugin-manager/plugin-jobs.js +7 -7
  264. package/src/lib/plugin-manager/plugin-jobs.js.map +1 -1
  265. package/src/lib/plugin-manager/plugin-live-paths.d.ts +70 -0
  266. package/src/lib/plugin-manager/plugin-live-paths.js +66 -0
  267. package/src/lib/plugin-manager/plugin-live-paths.js.map +1 -0
  268. package/src/lib/plugin-manager/plugin-org-erasure.d.ts +3 -2
  269. package/src/lib/plugin-manager/plugin-org-erasure.js +30 -5
  270. package/src/lib/plugin-manager/plugin-org-erasure.js.map +1 -1
  271. package/src/lib/plugin-manager/plugin-person-erasure.d.ts +68 -9
  272. package/src/lib/plugin-manager/plugin-person-erasure.js +196 -51
  273. package/src/lib/plugin-manager/plugin-person-erasure.js.map +1 -1
  274. package/src/lib/plugin-manager/plugin-person-records.d.ts +275 -0
  275. package/src/lib/plugin-manager/plugin-person-records.js +107 -0
  276. package/src/lib/plugin-manager/plugin-person-records.js.map +1 -0
  277. package/src/lib/plugin-manager/plugin-record-counts.d.ts +76 -0
  278. package/src/lib/plugin-manager/plugin-record-counts.js +49 -0
  279. package/src/lib/plugin-manager/plugin-record-counts.js.map +1 -0
  280. package/src/lib/plugin-manager/plugin-record-email-state.d.ts +26 -0
  281. package/src/lib/plugin-manager/plugin-record-email-state.js +16 -0
  282. package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
  283. package/src/lib/plugin-manager/plugin-record-index.d.ts +10 -0
  284. package/src/lib/plugin-manager/plugin-record-index.js.map +1 -1
  285. package/src/lib/plugin-manager/plugin-record-lists.d.ts +86 -6
  286. package/src/lib/plugin-manager/plugin-record-lists.js +42 -5
  287. package/src/lib/plugin-manager/plugin-record-lists.js.map +1 -1
  288. package/src/lib/plugin-manager/plugin-record-origin.d.ts +79 -0
  289. package/src/lib/plugin-manager/plugin-record-origin.js +46 -0
  290. package/src/lib/plugin-manager/plugin-record-origin.js.map +1 -0
  291. package/src/lib/plugin-manager/plugin-record-pages.d.ts +37 -0
  292. package/src/lib/plugin-manager/plugin-record-pages.js +52 -0
  293. package/src/lib/plugin-manager/plugin-record-pages.js.map +1 -0
  294. package/src/lib/plugin-manager/plugin-record-timeline.d.ts +60 -0
  295. package/src/lib/plugin-manager/plugin-record-timeline.js +16 -0
  296. package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
  297. package/src/lib/plugin-manager/plugin-resource-drafts.d.ts +5 -5
  298. package/src/lib/plugin-manager/plugin-resource-drafts.js.map +1 -1
  299. package/src/lib/plugin-manager/plugin-revocations.d.ts +64 -0
  300. package/src/lib/plugin-manager/plugin-revocations.js +47 -0
  301. package/src/lib/plugin-manager/plugin-revocations.js.map +1 -0
  302. package/src/lib/plugin-manager/plugin-send-tallies.d.ts +69 -0
  303. package/src/lib/plugin-manager/plugin-send-tallies.js +45 -0
  304. package/src/lib/plugin-manager/plugin-send-tallies.js.map +1 -0
  305. package/src/lib/plugin-manager/plugin-site-bundle.d.ts +40 -0
  306. package/src/lib/plugin-manager/plugin-site-bundle.js +4 -0
  307. package/src/lib/plugin-manager/plugin-site-bundle.js.map +1 -1
  308. package/src/lib/plugin-manager/plugin-site-export.d.ts +57 -1
  309. package/src/lib/plugin-manager/plugin-site-export.js +23 -10
  310. package/src/lib/plugin-manager/plugin-site-export.js.map +1 -1
  311. package/src/lib/plugin-manager/plugin-sitemap-readers.d.ts +146 -0
  312. package/src/lib/plugin-manager/plugin-sitemap-readers.js +110 -0
  313. package/src/lib/plugin-manager/plugin-sitemap-readers.js.map +1 -0
  314. package/src/lib/plugin-manager/plugin-transfer-resources.d.ts +714 -0
  315. package/src/lib/plugin-manager/plugin-transfer-resources.js +443 -0
  316. package/src/lib/plugin-manager/plugin-transfer-resources.js.map +1 -0
  317. package/src/lib/plugin-manager/plugin-usage-axes.d.ts +54 -5
  318. package/src/lib/plugin-manager/plugin-usage-axes.js +11 -3
  319. package/src/lib/plugin-manager/plugin-usage-axes.js.map +1 -1
  320. package/src/lib/plugin-manager/plugin-user-erasure.js +9 -1
  321. package/src/lib/plugin-manager/plugin-user-erasure.js.map +1 -1
  322. package/src/lib/plugin-manager/plugin-visitor-doors.d.ts +104 -0
  323. package/src/lib/plugin-manager/plugin-visitor-doors.js +108 -0
  324. package/src/lib/plugin-manager/plugin-visitor-doors.js.map +1 -0
  325. package/src/lib/plugin-manager/realm-host-aglyn.generated.js +2 -0
  326. package/src/lib/plugin-manager/realm-host-aglyn.generated.js.map +1 -1
  327. package/src/lib/plugin-manager/repeat-rows.d.ts +5 -6
  328. package/src/lib/plugin-manager/repeat-rows.js.map +1 -1
  329. package/src/lib/app-utils/actions.d.ts +0 -310
  330. package/src/lib/app-utils/actions.js +0 -506
  331. package/src/lib/app-utils/actions.js.map +0 -1
  332. package/src/lib/app-utils/automation-placeholders.d.ts +0 -72
  333. package/src/lib/app-utils/automation-placeholders.js +0 -207
  334. package/src/lib/app-utils/automation-placeholders.js.map +0 -1
  335. package/src/lib/app-utils/dataset-models.d.ts +0 -339
  336. package/src/lib/app-utils/dataset-models.js +0 -722
  337. package/src/lib/app-utils/dataset-models.js.map +0 -1
  338. package/src/lib/app-utils/datasets.d.ts +0 -171
  339. package/src/lib/app-utils/datasets.js +0 -227
  340. package/src/lib/app-utils/datasets.js.map +0 -1
  341. package/src/lib/app-utils/form-abuse-ceiling.d.ts +0 -146
  342. package/src/lib/app-utils/form-abuse-ceiling.js +0 -158
  343. package/src/lib/app-utils/form-abuse-ceiling.js.map +0 -1
  344. package/src/lib/plugin-manager/order-fulfilment.d.ts +0 -142
  345. package/src/lib/plugin-manager/order-fulfilment.js +0 -104
  346. package/src/lib/plugin-manager/order-fulfilment.js.map +0 -1
@@ -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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"}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PageRecordScope } from './expand-repeatables';
18
+ /**
19
+ * WHICH RECORD A PAGE IS DRAWN FOR, ON THE BESIGNER'S CANVAS (AGL-3475).
20
+ *
21
+ * A record template renders once per record on the published site: the
22
+ * composition hands it the routed record (`PageRecordScope`) and its
23
+ * `{{item.*}}` tokens fill in. The canvas has no routed record — it edits the
24
+ * template — so without one it shows the raw tokens, and the author designs a
25
+ * page of `{{item.name}}` placeholders.
26
+ *
27
+ * So a plugin that serves pages per record answers here, for the page in the
28
+ * editor: whether it is a template of theirs, and if so a record to draw it
29
+ * for, with the others an author may switch to. The canvas lays that record
30
+ * over every element's render copy exactly as a repeat lays its first record
31
+ * over its template (AGL-3111) — the nodes keep their tokens, and a save
32
+ * writes the template.
33
+ *
34
+ * The editor's side only: a hook, mounted by the console beside the canvas,
35
+ * because the designer reads no database. `repeat-sources.ts` is the same
36
+ * shape for a repeat's rows.
37
+ */
38
+ /** One record an author can preview the page with. */
39
+ export interface PageRecordChoice {
40
+ /** The record's id. */
41
+ id: string;
42
+ /** What the picker shows for it. */
43
+ label: string;
44
+ }
45
+ /** What a source answers for one page. */
46
+ export type PageRecordAnswer =
47
+ /** Not a page this source serves records on. */
48
+ {
49
+ status: 'none';
50
+ } | {
51
+ status: 'loading';
52
+ } | (PageRecordScope & {
53
+ status: 'ready';
54
+ /** What the page is a template of: "Services". */
55
+ label: string;
56
+ /** The record being drawn. */
57
+ selectedId: string;
58
+ /** Every record the picker offers, the drawn one included. */
59
+ choices: readonly PageRecordChoice[];
60
+ /** Draws the page for another of the choices. */
61
+ select: (id: string) => void;
62
+ });
63
+ /** What a source is asked. */
64
+ export interface PageRecordRequest {
65
+ hostId: string;
66
+ screenId: string;
67
+ }
68
+ export interface PageRecordSource {
69
+ /** Stable id; one source per id. */
70
+ id: string;
71
+ /**
72
+ * A React hook, called once per source for the page in the editor, every
73
+ * render. Answers `none` for a page it does not serve.
74
+ */
75
+ usePageRecord: (request: PageRecordRequest) => PageRecordAnswer;
76
+ }
77
+ /**
78
+ * Registers a source, replacing any under its id. Returns the unregister,
79
+ * which removes it only while it is still the one registered. Call it from a
80
+ * registration function the plugin loader calls by name, never from a
81
+ * module's top level (AGL-3025).
82
+ */
83
+ export declare function registerPageRecordSource(source: PageRecordSource): () => void;
84
+ /** Every registered source, the SAME array until the set changes. */
85
+ export declare function listPageRecordSources(): readonly PageRecordSource[];
86
+ /** Called after the registered set changes. Returns the unsubscribe. */
87
+ export declare function subscribePageRecordSources(listener: () => void): () => void;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ const sources = new Map();
17
+ const listeners = new Set();
18
+ /** Replaced only when the set changes: the snapshot a store compares. */ let snapshot = [];
19
+ const notify = ()=>{
20
+ snapshot = [
21
+ ...sources.values()
22
+ ];
23
+ for (const listener of [
24
+ ...listeners
25
+ ])listener();
26
+ };
27
+ /**
28
+ * Registers a source, replacing any under its id. Returns the unregister,
29
+ * which removes it only while it is still the one registered. Call it from a
30
+ * registration function the plugin loader calls by name, never from a
31
+ * module's top level (AGL-3025).
32
+ */ export function registerPageRecordSource(source) {
33
+ if (sources.get(source.id) !== source) {
34
+ sources.set(source.id, source);
35
+ notify();
36
+ }
37
+ return ()=>{
38
+ if (sources.get(source.id) !== source) return;
39
+ sources.delete(source.id);
40
+ notify();
41
+ };
42
+ }
43
+ /** Every registered source, the SAME array until the set changes. */ export function listPageRecordSources() {
44
+ return snapshot;
45
+ }
46
+ /** Called after the registered set changes. Returns the unsubscribe. */ export function subscribePageRecordSources(listener) {
47
+ listeners.add(listener);
48
+ return ()=>{
49
+ listeners.delete(listener);
50
+ };
51
+ }
52
+
53
+ //# sourceMappingURL=page-record-sources.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/page-record-sources.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 { PageRecordScope } from './expand-repeatables'\n\n/**\n * WHICH RECORD A PAGE IS DRAWN FOR, ON THE BESIGNER'S CANVAS (AGL-3475).\n *\n * A record template renders once per record on the published site: the\n * composition hands it the routed record (`PageRecordScope`) and its\n * `{{item.*}}` tokens fill in. The canvas has no routed record — it edits the\n * template — so without one it shows the raw tokens, and the author designs a\n * page of `{{item.name}}` placeholders.\n *\n * So a plugin that serves pages per record answers here, for the page in the\n * editor: whether it is a template of theirs, and if so a record to draw it\n * for, with the others an author may switch to. The canvas lays that record\n * over every element's render copy exactly as a repeat lays its first record\n * over its template (AGL-3111) — the nodes keep their tokens, and a save\n * writes the template.\n *\n * The editor's side only: a hook, mounted by the console beside the canvas,\n * because the designer reads no database. `repeat-sources.ts` is the same\n * shape for a repeat's rows.\n */\n\n/** One record an author can preview the page with. */\nexport interface PageRecordChoice {\n /** The record's id. */\n id: string\n /** What the picker shows for it. */\n label: string\n}\n\n/** What a source answers for one page. */\nexport type PageRecordAnswer =\n /** Not a page this source serves records on. */\n | { status: 'none' }\n | { status: 'loading' }\n | (PageRecordScope & {\n status: 'ready'\n /** What the page is a template of: \"Services\". */\n label: string\n /** The record being drawn. */\n selectedId: string\n /** Every record the picker offers, the drawn one included. */\n choices: readonly PageRecordChoice[]\n /** Draws the page for another of the choices. */\n select: (id: string) => void\n })\n\n/** What a source is asked. */\nexport interface PageRecordRequest {\n hostId: string\n screenId: string\n}\n\nexport interface PageRecordSource {\n /** Stable id; one source per id. */\n id: string\n /**\n * A React hook, called once per source for the page in the editor, every\n * render. Answers `none` for a page it does not serve.\n */\n usePageRecord: (request: PageRecordRequest) => PageRecordAnswer\n}\n\nconst sources = new Map<string, PageRecordSource>()\nconst listeners = new Set<() => void>()\n/** Replaced only when the set changes: the snapshot a store compares. */\nlet snapshot: readonly PageRecordSource[] = []\n\nconst notify = () => {\n snapshot = [...sources.values()]\n for (const listener of [...listeners]) listener()\n}\n\n/**\n * Registers a source, replacing any under its id. Returns the unregister,\n * which removes it only while it is still the one registered. Call it from a\n * registration function the plugin loader calls by name, never from a\n * module's top level (AGL-3025).\n */\nexport function registerPageRecordSource(source: PageRecordSource): () => void {\n if (sources.get(source.id) !== source) {\n sources.set(source.id, source)\n notify()\n }\n return () => {\n if (sources.get(source.id) !== source) return\n sources.delete(source.id)\n notify()\n }\n}\n\n/** Every registered source, the SAME array until the set changes. */\nexport function listPageRecordSources(): readonly PageRecordSource[] {\n return snapshot\n}\n\n/** Called after the registered set changes. Returns the unsubscribe. */\nexport function subscribePageRecordSources(listener: () => void): () => void {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n}\n"],"names":["sources","Map","listeners","Set","snapshot","notify","values","listener","registerPageRecordSource","source","get","id","set","delete","listPageRecordSources","subscribePageRecordSources","add"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAkED,MAAMA,UAAU,IAAIC;AACpB,MAAMC,YAAY,IAAIC;AACtB,uEAAuE,GACvE,IAAIC,WAAwC,EAAE;AAE9C,MAAMC,SAAS;IACbD,WAAW;WAAIJ,QAAQM,MAAM;KAAG;IAChC,KAAK,MAAMC,YAAY;WAAIL;KAAU,CAAEK;AACzC;AAEA;;;;;CAKC,GACD,OAAO,SAASC,yBAAyBC,MAAwB;IAC/D,IAAIT,QAAQU,GAAG,CAACD,OAAOE,EAAE,MAAMF,QAAQ;QACrCT,QAAQY,GAAG,CAACH,OAAOE,EAAE,EAAEF;QACvBJ;IACF;IACA,OAAO;QACL,IAAIL,QAAQU,GAAG,CAACD,OAAOE,EAAE,MAAMF,QAAQ;QACvCT,QAAQa,MAAM,CAACJ,OAAOE,EAAE;QACxBN;IACF;AACF;AAEA,mEAAmE,GACnE,OAAO,SAASS;IACd,OAAOV;AACT;AAEA,sEAAsE,GACtE,OAAO,SAASW,2BAA2BR,QAAoB;IAC7DL,UAAUc,GAAG,CAACT;IACd,OAAO;QACLL,UAAUW,MAAM,CAACN;IACnB;AACF"}
@@ -56,13 +56,9 @@ export type PaidMediaSource =
56
56
  */
57
57
  export declare function parsePaidMediaSource(stored: unknown): PaidMediaSource;
58
58
  /**
59
- * The library asset a media object key belongs to, or null.
60
- *
61
- * Every writer keys an asset's object as `{base}/media/[folders/]{mediaId}`,
62
- * with `base` either `hosts/{hostId}` or `orgs/{orgId}`. So a key in that
63
- * shape whose last segment is id-shaped IS that asset, wherever a folder move
64
- * has since put it. Derived objects (`…__r720p.mp4`, `…__poster.webp`) carry
65
- * a dot and are no asset's key.
59
+ * The library asset a media object key belongs to, or null. The grammar is
60
+ * {@link mediaAssetOfObjectKey}'s, which the render-time resolver shares
61
+ * (AGL-3506); this name stays for the paid-media callers.
66
62
  *
67
63
  * This names the asset and decides nothing about access: a caller that signs
68
64
  * still has to check the bucket and that the library is its own.
@@ -13,7 +13,7 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { isFirstPartyHost, MEDIA_CDN_ROUTE, MEDIA_REF_PREFIX, parseMediaRef } from "./media-ref.js";
16
+ */ import { isFirstPartyHost, MEDIA_CDN_ROUTE, mediaAssetOfObjectKey, MEDIA_REF_PREFIX, parseMediaRef } from "./media-ref.js";
17
17
  const STORAGE_DOWNLOAD_HOST = 'firebasestorage.googleapis.com';
18
18
  const GCS_HOST = 'storage.googleapis.com';
19
19
  /**
@@ -131,28 +131,14 @@ const GCS_HOST = 'storage.googleapis.com';
131
131
  };
132
132
  }
133
133
  /**
134
- * The library asset a media object key belongs to, or null.
135
- *
136
- * Every writer keys an asset's object as `{base}/media/[folders/]{mediaId}`,
137
- * with `base` either `hosts/{hostId}` or `orgs/{orgId}`. So a key in that
138
- * shape whose last segment is id-shaped IS that asset, wherever a folder move
139
- * has since put it. Derived objects (`…__r720p.mp4`, `…__poster.webp`) carry
140
- * a dot and are no asset's key.
134
+ * The library asset a media object key belongs to, or null. The grammar is
135
+ * {@link mediaAssetOfObjectKey}'s, which the render-time resolver shares
136
+ * (AGL-3506); this name stays for the paid-media callers.
141
137
  *
142
138
  * This names the asset and decides nothing about access: a caller that signs
143
139
  * still has to check the bucket and that the library is its own.
144
140
  */ export function paidMediaAssetOfObject(objectPath) {
145
- const segments = String(objectPath != null ? objectPath : '').split('/');
146
- if (segments.length < 4 || segments[2] !== 'media') return null;
147
- if (segments.some((segment)=>!segment || segment === '..')) return null;
148
- const [root, scopeId] = segments;
149
- const scope = root === 'hosts' ? scopeId : root === 'orgs' ? `org:${scopeId}` : null;
150
- if (!scope) return null;
151
- const ref = parseMediaRef(`${MEDIA_REF_PREFIX}${scope}/${segments[segments.length - 1]}`);
152
- return ref ? {
153
- scope: ref.scope,
154
- mediaId: ref.mediaId
155
- } : null;
141
+ return mediaAssetOfObjectKey(objectPath);
156
142
  }
157
143
  /**
158
144
  * The library asset a stored paid-media URL names, in whichever form it was
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/paid-media-source.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 {\n isFirstPartyHost,\n MEDIA_CDN_ROUTE,\n MEDIA_REF_PREFIX,\n parseMediaRef,\n} from './media-ref'\n\n/**\n * What a URL stored on a product's paid media names (AGL-2814).\n *\n * A members video is stored as whatever wrote it: the paid-media picker's\n * reference, an older picker's CDN path, the raw Storage download URL a\n * free-tier library hands out, or a hotlink typed into an import. Three\n * places have to agree on which ASSET that is: the stream route that signs\n * it, the console that shows the author whether it is protected, and the\n * media route that refuses to publish a file a product still sells. One\n * grammar, here, so they cannot disagree.\n *\n * Pure and environment-light: nothing here reads Firestore, Storage or a\n * secret, so the console bundle can carry it.\n */\nexport type PaidMediaSource =\n /** A media-library asset, named by scope segment and id. */\n | { kind: 'asset'; scope: string; mediaId: string }\n /** An object in a Storage bucket, from a download or GCS URL. */\n | { kind: 'storage-object'; bucket: string; objectPath: string }\n /** Somebody else's server. */\n | { kind: 'external'; url: string }\n /** Nothing that can be delivered, including a Storage URL we cannot read. */\n | { kind: 'malformed' }\n\nconst STORAGE_DOWNLOAD_HOST = 'firebasestorage.googleapis.com'\nconst GCS_HOST = 'storage.googleapis.com'\n\n/**\n * The Storage emulator's `host:port`, whose download URLs have the same shape\n * as production's. Read per call so a test can set it.\n */\nfunction storageEmulatorHost(): string {\n const env =\n typeof process === 'undefined' ? undefined : process.env?.['FIREBASE_STORAGE_EMULATOR_HOST']\n return String(env ?? '')\n .trim()\n .toLowerCase()\n}\n\n/** A library asset from `/api/media/cdn/{scope}/{mediaId}[/{hash}]`. */\nfunction assetFromCdnPath(path: string): PaidMediaSource {\n const rest = path.slice(`${MEDIA_CDN_ROUTE}/`.length).split(/[?#]/)[0]\n const segments = rest.split('/')\n if (segments.length < 2 || segments.length > 3) return { kind: 'malformed' }\n let scope: string\n try {\n scope = decodeURIComponent(segments[0])\n } catch {\n return { kind: 'malformed' }\n }\n // Parsed through the reference grammar rather than a second copy of it. A\n // content pin in the path is dropped: a paid link names the asset, and the\n // stable URL always serves its current bytes.\n const ref = parseMediaRef(`${MEDIA_REF_PREFIX}${scope}/${segments[1]}`)\n return ref\n ? { kind: 'asset', scope: ref.scope, mediaId: ref.mediaId }\n : { kind: 'malformed' }\n}\n\n/** Decodes each `/`-separated piece of a path; null when one is malformed. */\nfunction decodePath(pieces: string[]): string | null {\n try {\n return pieces.map((piece) => decodeURIComponent(piece)).join('/')\n } catch {\n return null\n }\n}\n\n/**\n * Classifies a stored value. The same input always gives the same answer, so\n * every branch is checkable without a project.\n */\nexport function parsePaidMediaSource(stored: unknown): PaidMediaSource {\n const value = typeof stored === 'string' ? stored.trim() : ''\n if (!value) return { kind: 'malformed' }\n if (value.startsWith(MEDIA_REF_PREFIX)) {\n const ref = parseMediaRef(value)\n return ref\n ? { kind: 'asset', scope: ref.scope, mediaId: ref.mediaId }\n : { kind: 'malformed' }\n }\n if (value.startsWith(`${MEDIA_CDN_ROUTE}/`)) return assetFromCdnPath(value)\n if (!/^https?:\\/\\//i.test(value)) return { kind: 'malformed' }\n let url: URL\n try {\n url = new URL(value)\n } catch {\n return { kind: 'malformed' }\n }\n const hostname = url.hostname.toLowerCase()\n const emulator = storageEmulatorHost()\n if (\n hostname === STORAGE_DOWNLOAD_HOST ||\n (emulator && url.host.toLowerCase() === emulator)\n ) {\n // `/v0/b/{bucket}/o/{object}`, with the object key percent-encoded as one\n // segment. A download URL in any other shape is still a token URL, so it\n // is refused rather than handed through as a hotlink.\n const match = /^\\/v0\\/b\\/([^/]+)\\/o\\/([^/]+)$/.exec(url.pathname)\n const bucket = match ? decodePath([match[1]]) : null\n const objectPath = match ? decodePath([match[2]]) : null\n return bucket && objectPath\n ? { kind: 'storage-object', bucket, objectPath }\n : { kind: 'malformed' }\n }\n if (hostname === GCS_HOST) {\n const [, bucketPiece, ...objectPieces] = url.pathname.split('/')\n const bucket = bucketPiece ? decodePath([bucketPiece]) : null\n const objectPath = objectPieces.length ? decodePath(objectPieces) : null\n return bucket && objectPath\n ? { kind: 'storage-object', bucket, objectPath }\n : { kind: 'malformed' }\n }\n if (\n isFirstPartyHost(hostname) &&\n url.pathname.startsWith(`${MEDIA_CDN_ROUTE}/`)\n ) {\n return assetFromCdnPath(url.pathname)\n }\n return { kind: 'external', url: value }\n}\n\n/**\n * The library asset a media object key belongs to, or null.\n *\n * Every writer keys an asset's object as `{base}/media/[folders/]{mediaId}`,\n * with `base` either `hosts/{hostId}` or `orgs/{orgId}`. So a key in that\n * shape whose last segment is id-shaped IS that asset, wherever a folder move\n * has since put it. Derived objects (`…__r720p.mp4`, `…__poster.webp`) carry\n * a dot and are no asset's key.\n *\n * This names the asset and decides nothing about access: a caller that signs\n * still has to check the bucket and that the library is its own.\n */\nexport function paidMediaAssetOfObject(\n objectPath: string,\n): { scope: string; mediaId: string } | null {\n const segments = String(objectPath ?? '').split('/')\n if (segments.length < 4 || segments[2] !== 'media') return null\n if (segments.some((segment) => !segment || segment === '..')) return null\n const [root, scopeId] = segments\n const scope =\n root === 'hosts' ? scopeId : root === 'orgs' ? `org:${scopeId}` : null\n if (!scope) return null\n const ref = parseMediaRef(\n `${MEDIA_REF_PREFIX}${scope}/${segments[segments.length - 1]}`,\n )\n return ref ? { scope: ref.scope, mediaId: ref.mediaId } : null\n}\n\n/**\n * The library asset a stored paid-media URL names, in whichever form it was\n * written, or null for a hotlink, a malformed value, or a Storage URL that\n * names no asset. Pass `bucket` to refuse a download URL from any other\n * bucket, which is what every server-side caller should do.\n */\nexport function paidMediaAssetOf(\n stored: unknown,\n options?: { bucket?: string },\n): { scope: string; mediaId: string } | null {\n const source = parsePaidMediaSource(stored)\n if (source.kind === 'asset') {\n return { scope: source.scope, mediaId: source.mediaId }\n }\n if (source.kind !== 'storage-object') return null\n if (options?.bucket !== undefined && source.bucket !== options.bucket) {\n return null\n }\n return paidMediaAssetOfObject(source.objectPath)\n}\n\n/**\n * Whether two scope segments name the same library: the same host, or the\n * same org whatever site either is qualified with.\n */\nexport function samePaidMediaLibrary(a: string, b: string): boolean {\n const library = (scope: string) =>\n scope.startsWith('org:') ? `org:${scope.slice(4).split(':')[0]}` : scope\n return library(a) === library(b)\n}\n"],"names":["isFirstPartyHost","MEDIA_CDN_ROUTE","MEDIA_REF_PREFIX","parseMediaRef","STORAGE_DOWNLOAD_HOST","GCS_HOST","storageEmulatorHost","process","env","undefined","String","trim","toLowerCase","assetFromCdnPath","path","rest","slice","length","split","segments","kind","scope","decodeURIComponent","ref","mediaId","decodePath","pieces","map","piece","join","parsePaidMediaSource","stored","value","startsWith","test","url","URL","hostname","emulator","host","match","exec","pathname","bucket","objectPath","bucketPiece","objectPieces","paidMediaAssetOfObject","some","segment","root","scopeId","paidMediaAssetOf","options","source","samePaidMediaLibrary","a","b","library"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/paid-media-source.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 {\n isFirstPartyHost,\n MEDIA_CDN_ROUTE,\n mediaAssetOfObjectKey,\n MEDIA_REF_PREFIX,\n parseMediaRef,\n} from './media-ref'\n\n/**\n * What a URL stored on a product's paid media names (AGL-2814).\n *\n * A members video is stored as whatever wrote it: the paid-media picker's\n * reference, an older picker's CDN path, the raw Storage download URL a\n * free-tier library hands out, or a hotlink typed into an import. Three\n * places have to agree on which ASSET that is: the stream route that signs\n * it, the console that shows the author whether it is protected, and the\n * media route that refuses to publish a file a product still sells. One\n * grammar, here, so they cannot disagree.\n *\n * Pure and environment-light: nothing here reads Firestore, Storage or a\n * secret, so the console bundle can carry it.\n */\nexport type PaidMediaSource =\n /** A media-library asset, named by scope segment and id. */\n | { kind: 'asset'; scope: string; mediaId: string }\n /** An object in a Storage bucket, from a download or GCS URL. */\n | { kind: 'storage-object'; bucket: string; objectPath: string }\n /** Somebody else's server. */\n | { kind: 'external'; url: string }\n /** Nothing that can be delivered, including a Storage URL we cannot read. */\n | { kind: 'malformed' }\n\nconst STORAGE_DOWNLOAD_HOST = 'firebasestorage.googleapis.com'\nconst GCS_HOST = 'storage.googleapis.com'\n\n/**\n * The Storage emulator's `host:port`, whose download URLs have the same shape\n * as production's. Read per call so a test can set it.\n */\nfunction storageEmulatorHost(): string {\n const env =\n typeof process === 'undefined' ? undefined : process.env?.['FIREBASE_STORAGE_EMULATOR_HOST']\n return String(env ?? '')\n .trim()\n .toLowerCase()\n}\n\n/** A library asset from `/api/media/cdn/{scope}/{mediaId}[/{hash}]`. */\nfunction assetFromCdnPath(path: string): PaidMediaSource {\n const rest = path.slice(`${MEDIA_CDN_ROUTE}/`.length).split(/[?#]/)[0]\n const segments = rest.split('/')\n if (segments.length < 2 || segments.length > 3) return { kind: 'malformed' }\n let scope: string\n try {\n scope = decodeURIComponent(segments[0])\n } catch {\n return { kind: 'malformed' }\n }\n // Parsed through the reference grammar rather than a second copy of it. A\n // content pin in the path is dropped: a paid link names the asset, and the\n // stable URL always serves its current bytes.\n const ref = parseMediaRef(`${MEDIA_REF_PREFIX}${scope}/${segments[1]}`)\n return ref\n ? { kind: 'asset', scope: ref.scope, mediaId: ref.mediaId }\n : { kind: 'malformed' }\n}\n\n/** Decodes each `/`-separated piece of a path; null when one is malformed. */\nfunction decodePath(pieces: string[]): string | null {\n try {\n return pieces.map((piece) => decodeURIComponent(piece)).join('/')\n } catch {\n return null\n }\n}\n\n/**\n * Classifies a stored value. The same input always gives the same answer, so\n * every branch is checkable without a project.\n */\nexport function parsePaidMediaSource(stored: unknown): PaidMediaSource {\n const value = typeof stored === 'string' ? stored.trim() : ''\n if (!value) return { kind: 'malformed' }\n if (value.startsWith(MEDIA_REF_PREFIX)) {\n const ref = parseMediaRef(value)\n return ref\n ? { kind: 'asset', scope: ref.scope, mediaId: ref.mediaId }\n : { kind: 'malformed' }\n }\n if (value.startsWith(`${MEDIA_CDN_ROUTE}/`)) return assetFromCdnPath(value)\n if (!/^https?:\\/\\//i.test(value)) return { kind: 'malformed' }\n let url: URL\n try {\n url = new URL(value)\n } catch {\n return { kind: 'malformed' }\n }\n const hostname = url.hostname.toLowerCase()\n const emulator = storageEmulatorHost()\n if (\n hostname === STORAGE_DOWNLOAD_HOST ||\n (emulator && url.host.toLowerCase() === emulator)\n ) {\n // `/v0/b/{bucket}/o/{object}`, with the object key percent-encoded as one\n // segment. A download URL in any other shape is still a token URL, so it\n // is refused rather than handed through as a hotlink.\n const match = /^\\/v0\\/b\\/([^/]+)\\/o\\/([^/]+)$/.exec(url.pathname)\n const bucket = match ? decodePath([match[1]]) : null\n const objectPath = match ? decodePath([match[2]]) : null\n return bucket && objectPath\n ? { kind: 'storage-object', bucket, objectPath }\n : { kind: 'malformed' }\n }\n if (hostname === GCS_HOST) {\n const [, bucketPiece, ...objectPieces] = url.pathname.split('/')\n const bucket = bucketPiece ? decodePath([bucketPiece]) : null\n const objectPath = objectPieces.length ? decodePath(objectPieces) : null\n return bucket && objectPath\n ? { kind: 'storage-object', bucket, objectPath }\n : { kind: 'malformed' }\n }\n if (\n isFirstPartyHost(hostname) &&\n url.pathname.startsWith(`${MEDIA_CDN_ROUTE}/`)\n ) {\n return assetFromCdnPath(url.pathname)\n }\n return { kind: 'external', url: value }\n}\n\n/**\n * The library asset a media object key belongs to, or null. The grammar is\n * {@link mediaAssetOfObjectKey}'s, which the render-time resolver shares\n * (AGL-3506); this name stays for the paid-media callers.\n *\n * This names the asset and decides nothing about access: a caller that signs\n * still has to check the bucket and that the library is its own.\n */\nexport function paidMediaAssetOfObject(\n objectPath: string,\n): { scope: string; mediaId: string } | null {\n return mediaAssetOfObjectKey(objectPath)\n}\n\n/**\n * The library asset a stored paid-media URL names, in whichever form it was\n * written, or null for a hotlink, a malformed value, or a Storage URL that\n * names no asset. Pass `bucket` to refuse a download URL from any other\n * bucket, which is what every server-side caller should do.\n */\nexport function paidMediaAssetOf(\n stored: unknown,\n options?: { bucket?: string },\n): { scope: string; mediaId: string } | null {\n const source = parsePaidMediaSource(stored)\n if (source.kind === 'asset') {\n return { scope: source.scope, mediaId: source.mediaId }\n }\n if (source.kind !== 'storage-object') return null\n if (options?.bucket !== undefined && source.bucket !== options.bucket) {\n return null\n }\n return paidMediaAssetOfObject(source.objectPath)\n}\n\n/**\n * Whether two scope segments name the same library: the same host, or the\n * same org whatever site either is qualified with.\n */\nexport function samePaidMediaLibrary(a: string, b: string): boolean {\n const library = (scope: string) =>\n scope.startsWith('org:') ? `org:${scope.slice(4).split(':')[0]}` : scope\n return library(a) === library(b)\n}\n"],"names":["isFirstPartyHost","MEDIA_CDN_ROUTE","mediaAssetOfObjectKey","MEDIA_REF_PREFIX","parseMediaRef","STORAGE_DOWNLOAD_HOST","GCS_HOST","storageEmulatorHost","process","env","undefined","String","trim","toLowerCase","assetFromCdnPath","path","rest","slice","length","split","segments","kind","scope","decodeURIComponent","ref","mediaId","decodePath","pieces","map","piece","join","parsePaidMediaSource","stored","value","startsWith","test","url","URL","hostname","emulator","host","match","exec","pathname","bucket","objectPath","bucketPiece","objectPieces","paidMediaAssetOfObject","paidMediaAssetOf","options","source","samePaidMediaLibrary","a","b","library"],"mappings":"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"}
@@ -71,8 +71,11 @@ export interface PersonErasureRequest {
71
71
  */
72
72
  pendingSinceMs?: number;
73
73
  erasedAtMs?: number;
74
- /** Counts only — what the sweep removed or anonymized, for the audit. */
75
- result?: Record<string, number>;
74
+ /**
75
+ * Counts only — what the sweep removed or anonymized, for the audit: the
76
+ * platform's own figures, and each plugin's share under `plugins`.
77
+ */
78
+ result?: Readonly<Record<string, unknown>>;
76
79
  failedAtMs?: number;
77
80
  failureCount?: number;
78
81
  /** The last failure's message, for the staff reader. Never the data. */
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/person-erasure.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 * Privacy erasure of one PERSON from one workspace (AGL-2623).\n *\n * The CRM's **Delete contact** is a detach: one site lets go of a shared row\n * and the document dies only when the last holder does. A privacy erasure is\n * the opposite act — the person is removed from every site in the workspace\n * regardless of who holds them, together with everything the workspace keeps\n * beside them — and it is filed as a REQUEST rather than executed on the\n * click, so that the same daily job that executes workspace erasures\n * executes these, with one audit trail and one place to watch the queue.\n *\n * A request lives at `personErasures/{orgId}__{sha256(email)}`: keyed by\n * the workspace AND the person, because the workspace is the controller of\n * this data and a second workspace that also knows the person has a\n * relationship of its own that this request has no claim on. The id is\n * deterministic so a second request for the same person is the same\n * document rather than a second one, and the address itself is carried only\n * while the request is pending — the sweep needs it to find the records,\n * and the completed request keeps the hash alone.\n *\n * Pure data module: names, types and the small helpers the dialog, the\n * route and the job would otherwise each write for themselves. No\n * Firestore, no React, no `node:` import — the client barrel re-exports it.\n */\n\nimport { normalizeContactEmail } from './contacts'\n\n/** Top-level collection holding one document per (workspace, person). */\nexport const PERSON_ERASURES_COLLECTION = 'personErasures'\n\n/**\n * The document id: workspace first, then the person's key, joined by a\n * separator neither can contain. A Firestore id may not hold a `/`, and a\n * person key is hex, so a double underscore is unambiguous.\n */\nexport function personErasureId(orgId: string, personKey: string): string {\n if (!orgId || !personKey) {\n throw new Error('a person erasure names a workspace and a person')\n }\n return `${orgId}__${personKey}`\n}\n\n/**\n * The field a pending request stamps on the records it covers — the contact\n * document and each site's lead — so a record page can show the state off\n * the document it already listens to, with no second read. Epoch millis.\n *\n * A marker, not the request: the job reads the queue, never this field, so\n * a client write that strips it hides the banner and changes nothing else.\n */\nexport const CONTACT_ERASURE_REQUESTED_FIELD = 'erasureRequestedAtMs'\n\n/** When a record's erasure was requested, or null for a record with none. */\nexport function readErasureRequestedAtMs(\n row: Record<string, unknown> | null | undefined,\n): number | null {\n const value = (row ?? {})[CONTACT_ERASURE_REQUESTED_FIELD]\n return typeof value === 'number' && Number.isFinite(value) && value > 0\n ? value\n : null\n}\n\n/**\n * Where a request is in its life.\n *\n * - `pending` — filed, waiting for the next run. `pendingSinceMs` is set,\n * which is what the job orders on.\n * - `erased` — the sweep ran and reported its counts. The address is gone\n * from the document.\n * - `failed` — the sweep threw. The request stays pending (it keeps\n * `pendingSinceMs`, moved to the back of the queue) so the next run tries\n * again, and the failure is on the document for a staff member to read.\n */\nexport type PersonErasureStatus = 'pending' | 'erased' | 'failed'\n\n/** `personErasures/{orgId}__{personKey}` document shape. */\nexport interface PersonErasureRequest {\n orgId: string\n /** `sha256(normalizedEmail)` — the same key `emailDeliveries` files under. */\n personKey: string\n status: PersonErasureStatus\n /**\n * The address, present ONLY while the request is pending: the sweep\n * queries by it, and nothing else may. Deleted by the job on completion.\n */\n email?: string\n requestedAtMs: number\n /** The workspace admin who filed it. */\n requestedByUid: string\n /** The site the request was filed from — for the activity feed's link. */\n hostId?: string\n /** The record it was filed from, when it was a contact. */\n contactId?: string\n /** The record it was filed from, when it was a lead. */\n leadId?: string\n /**\n * Set while pending. Absent once erased, so `orderBy('pendingSinceMs')`\n * lists exactly the queue — a document without the field is not returned.\n */\n pendingSinceMs?: number\n erasedAtMs?: number\n /** Counts only — what the sweep removed or anonymized, for the audit. */\n result?: Record<string, number>\n failedAtMs?: number\n failureCount?: number\n /** The last failure's message, for the staff reader. Never the data. */\n lastError?: string\n}\n\n/**\n * The `reason` a per-site suppression row carries when it was written by\n * an erasure. Such a row keeps NO address — the id is the hash — and does\n * two jobs the ordinary suppression already does one of: every campaign\n * gate refuses the address, and the contact capture path refuses to create\n * a record for it, so a later form fill or order does not quietly rebuild\n * the person the workspace just erased.\n */\nexport const PERSON_ERASURE_SUPPRESSION_REASON = 'erasure'\n\n/**\n * What a door says when it refuses to create a record for an erased address\n * — the contact create route and both lead-convert doors alike. The person\n * asked to be removed and a workspace admin filed it; a record cannot be\n * re-created by hand any more than by a form, and the sentence says where\n * the decision lives rather than implying the address is malformed.\n */\nexport const CONTACT_ERASED_MESSAGE =\n 'This person was erased from your workspace at their request, so a ' +\n 'record cannot be created for this address.'\n\n/**\n * What one request removes across the workspace — the dialog's list and the\n * docs' list, from one source so the two cannot drift. Each line names a\n * thing a reader recognizes from the console, not a collection.\n */\nexport const PERSON_ERASURE_REMOVES: readonly string[] = [\n 'The contact record on every site in this workspace — profile, notes, tags, timeline and custom fields',\n 'Every lead for this address, on every site',\n 'The tasks and logged activities filed against the person',\n 'The person on any email audience list',\n 'The campaign delivery log — every message sent to the address, with its opens and clicks',\n]\n\n/**\n * What survives, and how. Orders and bookings are the workspace's financial\n * and appointment records, and tax law keeps them; the person is taken off\n * them rather than the record destroyed. A deal is the team's own pipeline\n * record and is unlinked, not deleted.\n */\nexport const PERSON_ERASURE_RETAINS: readonly string[] = [\n 'Orders and bookings stay as financial records with the name, email, phone and addresses removed',\n 'Deals stay on the pipeline, unlinked from the person',\n 'Companies stay, with their contact count reduced',\n]\n\n/**\n * What the request does not reach, so the admin can finish the job by hand\n * where the person asked for everything. A form submission keeps the\n * address inside the answers it recorded, under whatever the form called\n * the field, so there is no key to find them by; a site member holds a\n * login of their own.\n */\nexport const PERSON_ERASURE_NOT_REACHED: readonly string[] = [\n 'Form submissions — delete them from the Inbox',\n 'A site member account — close it from the site’s Members page',\n]\n\n/**\n * Whether the typed confirmation names the record's address.\n *\n * Normalized on both sides, so `Jane@Example.com` confirms `jane@example.com`\n * — the confirmation is there to make the admin read the address, not to\n * test their capitalization.\n */\nexport function personErasureConfirmationMatches(\n typed: unknown,\n email: unknown,\n): boolean {\n const wanted = normalizeContactEmail(email)\n if (!wanted) return false\n return normalizeContactEmail(typed) === wanted\n}\n"],"names":["normalizeContactEmail","PERSON_ERASURES_COLLECTION","personErasureId","orgId","personKey","Error","CONTACT_ERASURE_REQUESTED_FIELD","readErasureRequestedAtMs","row","value","Number","isFinite","PERSON_ERASURE_SUPPRESSION_REASON","CONTACT_ERASED_MESSAGE","PERSON_ERASURE_REMOVES","PERSON_ERASURE_RETAINS","PERSON_ERASURE_NOT_REACHED","personErasureConfirmationMatches","typed","email","wanted"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GAED,SAASA,qBAAqB,QAAQ,gBAAY;AAElD,uEAAuE,GACvE,OAAO,MAAMC,6BAA6B,iBAAgB;AAE1D;;;;CAIC,GACD,OAAO,SAASC,gBAAgBC,KAAa,EAAEC,SAAiB;IAC9D,IAAI,CAACD,SAAS,CAACC,WAAW;QACxB,MAAM,IAAIC,MAAM;IAClB;IACA,OAAO,GAAGF,MAAM,EAAE,EAAEC,WAAW;AACjC;AAEA;;;;;;;CAOC,GACD,OAAO,MAAME,kCAAkC,uBAAsB;AAErE,2EAA2E,GAC3E,OAAO,SAASC,yBACdC,GAA+C;IAE/C,MAAMC,QAAQ,CAACD,cAAAA,MAAO,CAAC,EAAE,CAACF,gCAAgC;IAC1D,OAAO,OAAOG,UAAU,YAAYC,OAAOC,QAAQ,CAACF,UAAUA,QAAQ,IAClEA,QACA;AACN;AAiDA;;;;;;;CAOC,GACD,OAAO,MAAMG,oCAAoC,UAAS;AAE1D;;;;;;CAMC,GACD,OAAO,MAAMC,yBACX,uEACA,6CAA4C;AAE9C;;;;CAIC,GACD,OAAO,MAAMC,yBAA4C;IACvD;IACA;IACA;IACA;IACA;CACD,CAAA;AAED;;;;;CAKC,GACD,OAAO,MAAMC,yBAA4C;IACvD;IACA;IACA;CACD,CAAA;AAED;;;;;;CAMC,GACD,OAAO,MAAMC,6BAAgD;IAC3D;IACA;CACD,CAAA;AAED;;;;;;CAMC,GACD,OAAO,SAASC,iCACdC,KAAc,EACdC,KAAc;IAEd,MAAMC,SAASpB,sBAAsBmB;IACrC,IAAI,CAACC,QAAQ,OAAO;IACpB,OAAOpB,sBAAsBkB,WAAWE;AAC1C"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/person-erasure.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 * Privacy erasure of one PERSON from one workspace (AGL-2623).\n *\n * The CRM's **Delete contact** is a detach: one site lets go of a shared row\n * and the document dies only when the last holder does. A privacy erasure is\n * the opposite act — the person is removed from every site in the workspace\n * regardless of who holds them, together with everything the workspace keeps\n * beside them — and it is filed as a REQUEST rather than executed on the\n * click, so that the same daily job that executes workspace erasures\n * executes these, with one audit trail and one place to watch the queue.\n *\n * A request lives at `personErasures/{orgId}__{sha256(email)}`: keyed by\n * the workspace AND the person, because the workspace is the controller of\n * this data and a second workspace that also knows the person has a\n * relationship of its own that this request has no claim on. The id is\n * deterministic so a second request for the same person is the same\n * document rather than a second one, and the address itself is carried only\n * while the request is pending — the sweep needs it to find the records,\n * and the completed request keeps the hash alone.\n *\n * Pure data module: names, types and the small helpers the dialog, the\n * route and the job would otherwise each write for themselves. No\n * Firestore, no React, no `node:` import — the client barrel re-exports it.\n */\n\nimport { normalizeContactEmail } from './contacts'\n\n/** Top-level collection holding one document per (workspace, person). */\nexport const PERSON_ERASURES_COLLECTION = 'personErasures'\n\n/**\n * The document id: workspace first, then the person's key, joined by a\n * separator neither can contain. A Firestore id may not hold a `/`, and a\n * person key is hex, so a double underscore is unambiguous.\n */\nexport function personErasureId(orgId: string, personKey: string): string {\n if (!orgId || !personKey) {\n throw new Error('a person erasure names a workspace and a person')\n }\n return `${orgId}__${personKey}`\n}\n\n/**\n * The field a pending request stamps on the records it covers — the contact\n * document and each site's lead — so a record page can show the state off\n * the document it already listens to, with no second read. Epoch millis.\n *\n * A marker, not the request: the job reads the queue, never this field, so\n * a client write that strips it hides the banner and changes nothing else.\n */\nexport const CONTACT_ERASURE_REQUESTED_FIELD = 'erasureRequestedAtMs'\n\n/** When a record's erasure was requested, or null for a record with none. */\nexport function readErasureRequestedAtMs(\n row: Record<string, unknown> | null | undefined,\n): number | null {\n const value = (row ?? {})[CONTACT_ERASURE_REQUESTED_FIELD]\n return typeof value === 'number' && Number.isFinite(value) && value > 0\n ? value\n : null\n}\n\n/**\n * Where a request is in its life.\n *\n * - `pending` — filed, waiting for the next run. `pendingSinceMs` is set,\n * which is what the job orders on.\n * - `erased` — the sweep ran and reported its counts. The address is gone\n * from the document.\n * - `failed` — the sweep threw. The request stays pending (it keeps\n * `pendingSinceMs`, moved to the back of the queue) so the next run tries\n * again, and the failure is on the document for a staff member to read.\n */\nexport type PersonErasureStatus = 'pending' | 'erased' | 'failed'\n\n/** `personErasures/{orgId}__{personKey}` document shape. */\nexport interface PersonErasureRequest {\n orgId: string\n /** `sha256(normalizedEmail)` — the same key `emailDeliveries` files under. */\n personKey: string\n status: PersonErasureStatus\n /**\n * The address, present ONLY while the request is pending: the sweep\n * queries by it, and nothing else may. Deleted by the job on completion.\n */\n email?: string\n requestedAtMs: number\n /** The workspace admin who filed it. */\n requestedByUid: string\n /** The site the request was filed from — for the activity feed's link. */\n hostId?: string\n /** The record it was filed from, when it was a contact. */\n contactId?: string\n /** The record it was filed from, when it was a lead. */\n leadId?: string\n /**\n * Set while pending. Absent once erased, so `orderBy('pendingSinceMs')`\n * lists exactly the queue — a document without the field is not returned.\n */\n pendingSinceMs?: number\n erasedAtMs?: number\n /**\n * Counts only — what the sweep removed or anonymized, for the audit: the\n * platform's own figures, and each plugin's share under `plugins`.\n */\n result?: Readonly<Record<string, unknown>>\n failedAtMs?: number\n failureCount?: number\n /** The last failure's message, for the staff reader. Never the data. */\n lastError?: string\n}\n\n/**\n * The `reason` a per-site suppression row carries when it was written by\n * an erasure. Such a row keeps NO address — the id is the hash — and does\n * two jobs the ordinary suppression already does one of: every campaign\n * gate refuses the address, and the contact capture path refuses to create\n * a record for it, so a later form fill or order does not quietly rebuild\n * the person the workspace just erased.\n */\nexport const PERSON_ERASURE_SUPPRESSION_REASON = 'erasure'\n\n/**\n * What a door says when it refuses to create a record for an erased address\n * — the contact create route and both lead-convert doors alike. The person\n * asked to be removed and a workspace admin filed it; a record cannot be\n * re-created by hand any more than by a form, and the sentence says where\n * the decision lives rather than implying the address is malformed.\n */\nexport const CONTACT_ERASED_MESSAGE =\n 'This person was erased from your workspace at their request, so a ' +\n 'record cannot be created for this address.'\n\n/**\n * What one request removes across the workspace — the dialog's list and the\n * docs' list, from one source so the two cannot drift. Each line names a\n * thing a reader recognizes from the console, not a collection.\n */\nexport const PERSON_ERASURE_REMOVES: readonly string[] = [\n 'The contact record on every site in this workspace — profile, notes, tags, timeline and custom fields',\n 'Every lead for this address, on every site',\n 'The tasks and logged activities filed against the person',\n 'The person on any email audience list',\n 'The campaign delivery log — every message sent to the address, with its opens and clicks',\n]\n\n/**\n * What survives, and how. Orders and bookings are the workspace's financial\n * and appointment records, and tax law keeps them; the person is taken off\n * them rather than the record destroyed. A deal is the team's own pipeline\n * record and is unlinked, not deleted.\n */\nexport const PERSON_ERASURE_RETAINS: readonly string[] = [\n 'Orders and bookings stay as financial records with the name, email, phone and addresses removed',\n 'Deals stay on the pipeline, unlinked from the person',\n 'Companies stay, with their contact count reduced',\n]\n\n/**\n * What the request does not reach, so the admin can finish the job by hand\n * where the person asked for everything. A form submission keeps the\n * address inside the answers it recorded, under whatever the form called\n * the field, so there is no key to find them by; a site member holds a\n * login of their own.\n */\nexport const PERSON_ERASURE_NOT_REACHED: readonly string[] = [\n 'Form submissions — delete them from the Inbox',\n 'A site member account — close it from the site’s Members page',\n]\n\n/**\n * Whether the typed confirmation names the record's address.\n *\n * Normalized on both sides, so `Jane@Example.com` confirms `jane@example.com`\n * — the confirmation is there to make the admin read the address, not to\n * test their capitalization.\n */\nexport function personErasureConfirmationMatches(\n typed: unknown,\n email: unknown,\n): boolean {\n const wanted = normalizeContactEmail(email)\n if (!wanted) return false\n return normalizeContactEmail(typed) === wanted\n}\n"],"names":["normalizeContactEmail","PERSON_ERASURES_COLLECTION","personErasureId","orgId","personKey","Error","CONTACT_ERASURE_REQUESTED_FIELD","readErasureRequestedAtMs","row","value","Number","isFinite","PERSON_ERASURE_SUPPRESSION_REASON","CONTACT_ERASED_MESSAGE","PERSON_ERASURE_REMOVES","PERSON_ERASURE_RETAINS","PERSON_ERASURE_NOT_REACHED","personErasureConfirmationMatches","typed","email","wanted"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GAED,SAASA,qBAAqB,QAAQ,gBAAY;AAElD,uEAAuE,GACvE,OAAO,MAAMC,6BAA6B,iBAAgB;AAE1D;;;;CAIC,GACD,OAAO,SAASC,gBAAgBC,KAAa,EAAEC,SAAiB;IAC9D,IAAI,CAACD,SAAS,CAACC,WAAW;QACxB,MAAM,IAAIC,MAAM;IAClB;IACA,OAAO,GAAGF,MAAM,EAAE,EAAEC,WAAW;AACjC;AAEA;;;;;;;CAOC,GACD,OAAO,MAAME,kCAAkC,uBAAsB;AAErE,2EAA2E,GAC3E,OAAO,SAASC,yBACdC,GAA+C;IAE/C,MAAMC,QAAQ,CAACD,cAAAA,MAAO,CAAC,EAAE,CAACF,gCAAgC;IAC1D,OAAO,OAAOG,UAAU,YAAYC,OAAOC,QAAQ,CAACF,UAAUA,QAAQ,IAClEA,QACA;AACN;AAoDA;;;;;;;CAOC,GACD,OAAO,MAAMG,oCAAoC,UAAS;AAE1D;;;;;;CAMC,GACD,OAAO,MAAMC,yBACX,uEACA,6CAA4C;AAE9C;;;;CAIC,GACD,OAAO,MAAMC,yBAA4C;IACvD;IACA;IACA;IACA;IACA;CACD,CAAA;AAED;;;;;CAKC,GACD,OAAO,MAAMC,yBAA4C;IACvD;IACA;IACA;CACD,CAAA;AAED;;;;;;CAMC,GACD,OAAO,MAAMC,6BAAgD;IAC3D;IACA;CACD,CAAA;AAED;;;;;;CAMC,GACD,OAAO,SAASC,iCACdC,KAAc,EACdC,KAAc;IAEd,MAAMC,SAASpB,sBAAsBmB;IACrC,IAAI,CAACC,QAAQ,OAAO;IACpB,OAAOpB,sBAAsBkB,WAAWE;AAC1C"}