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

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 (455) hide show
  1. package/package.json +12 -12
  2. package/src/lib/app-utils/actions.d.ts +26 -433
  3. package/src/lib/app-utils/actions.js +108 -633
  4. package/src/lib/app-utils/actions.js.map +1 -1
  5. package/src/lib/app-utils/activity-presenter.d.ts +3 -3
  6. package/src/lib/app-utils/activity-presenter.js +36 -52
  7. package/src/lib/app-utils/activity-presenter.js.map +1 -1
  8. package/src/lib/app-utils/analytics-events.d.ts +1 -1
  9. package/src/lib/app-utils/analytics-events.js.map +1 -1
  10. package/src/lib/app-utils/analytics-provider.d.ts +182 -0
  11. package/src/lib/app-utils/analytics-provider.js +133 -0
  12. package/src/lib/app-utils/analytics-provider.js.map +1 -0
  13. package/src/lib/app-utils/api-plugins.d.ts +46 -0
  14. package/src/lib/app-utils/api-plugins.js +19 -0
  15. package/src/lib/app-utils/api-plugins.js.map +1 -1
  16. package/src/lib/app-utils/{marketplace-overrides.d.ts → artifact-overrides.d.ts} +9 -6
  17. package/src/lib/app-utils/{marketplace-overrides.js → artifact-overrides.js} +10 -7
  18. package/src/lib/app-utils/artifact-overrides.js.map +1 -0
  19. package/src/lib/app-utils/{marketplace-provenance.d.ts → artifact-provenance.d.ts} +12 -10
  20. package/src/lib/app-utils/{marketplace-provenance.js → artifact-provenance.js} +9 -7
  21. package/src/lib/app-utils/artifact-provenance.js.map +1 -0
  22. package/src/lib/app-utils/binding-token-catalog.d.ts +5 -13
  23. package/src/lib/app-utils/binding-token-catalog.js +17 -41
  24. package/src/lib/app-utils/binding-token-catalog.js.map +1 -1
  25. package/src/lib/app-utils/brand-support-line.d.ts +36 -0
  26. package/src/lib/app-utils/brand-support-line.js +38 -0
  27. package/src/lib/app-utils/brand-support-line.js.map +1 -0
  28. package/src/lib/app-utils/collection-entries.d.ts +2 -2
  29. package/src/lib/app-utils/collection-entries.js.map +1 -1
  30. package/src/lib/app-utils/consent-group-change.d.ts +4 -0
  31. package/src/lib/app-utils/consent-group-change.js.map +1 -1
  32. package/src/lib/app-utils/consent-groups.d.ts +1 -1
  33. package/src/lib/app-utils/consent-groups.js.map +1 -1
  34. package/src/lib/app-utils/console-routes.d.ts +15 -42
  35. package/src/lib/app-utils/console-routes.js +10 -39
  36. package/src/lib/app-utils/console-routes.js.map +1 -1
  37. package/src/lib/app-utils/contacts.d.ts +24 -2
  38. package/src/lib/app-utils/contacts.js +21 -5
  39. package/src/lib/app-utils/contacts.js.map +1 -1
  40. package/src/lib/app-utils/container-membership.d.ts +93 -0
  41. package/src/lib/app-utils/container-membership.js +165 -0
  42. package/src/lib/app-utils/container-membership.js.map +1 -0
  43. package/src/lib/app-utils/crm-task-reminders.d.ts +23 -6
  44. package/src/lib/app-utils/crm-task-reminders.js +32 -11
  45. package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
  46. package/src/lib/app-utils/crm.d.ts +24 -4
  47. package/src/lib/app-utils/crm.js +23 -0
  48. package/src/lib/app-utils/crm.js.map +1 -1
  49. package/src/lib/app-utils/cross-pool-desync.d.ts +39 -0
  50. package/src/lib/app-utils/cross-pool-desync.js +47 -0
  51. package/src/lib/app-utils/cross-pool-desync.js.map +1 -0
  52. package/src/lib/app-utils/csv-upload.d.ts +29 -0
  53. package/src/lib/app-utils/csv-upload.js +29 -0
  54. package/src/lib/app-utils/csv-upload.js.map +1 -0
  55. package/src/lib/app-utils/{dataset-csv.d.ts → csv.d.ts} +10 -43
  56. package/src/lib/app-utils/{dataset-csv.js → csv.js} +10 -81
  57. package/src/lib/app-utils/csv.js.map +1 -0
  58. package/src/lib/app-utils/dataset-models.d.ts +5 -5
  59. package/src/lib/app-utils/dataset-models.js +19 -2
  60. package/src/lib/app-utils/dataset-models.js.map +1 -1
  61. package/src/lib/app-utils/datasets.d.ts +65 -30
  62. package/src/lib/app-utils/datasets.js +74 -24
  63. package/src/lib/app-utils/datasets.js.map +1 -1
  64. package/src/lib/app-utils/docs-deflection.d.ts +6 -6
  65. package/src/lib/app-utils/docs-deflection.js.map +1 -1
  66. package/src/lib/app-utils/docs-help.generated.d.ts +14 -8
  67. package/src/lib/app-utils/docs-help.generated.js +23 -6
  68. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  69. package/src/lib/app-utils/docs-index.generated.d.ts +2 -2
  70. package/src/lib/app-utils/docs-index.generated.js +570 -346
  71. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  72. package/src/lib/app-utils/docs-retrieval.d.ts +3 -3
  73. package/src/lib/app-utils/docs-retrieval.js +14 -10
  74. package/src/lib/app-utils/docs-retrieval.js.map +1 -1
  75. package/src/lib/app-utils/duplicate-resource.d.ts +4 -1
  76. package/src/lib/app-utils/duplicate-resource.js +7 -4
  77. package/src/lib/app-utils/duplicate-resource.js.map +1 -1
  78. package/src/lib/app-utils/dynamic-list-rule.d.ts +1 -1
  79. package/src/lib/app-utils/dynamic-list-rule.js +3 -3
  80. package/src/lib/app-utils/dynamic-list-rule.js.map +1 -1
  81. package/src/lib/app-utils/element-ui.d.ts +1 -1
  82. package/src/lib/app-utils/element-ui.js +1 -1
  83. package/src/lib/app-utils/element-ui.js.map +1 -1
  84. package/src/lib/app-utils/email-starter-policy.js +3 -3
  85. package/src/lib/app-utils/email-starter-policy.js.map +1 -1
  86. package/src/lib/app-utils/email-text.d.ts +80 -0
  87. package/src/lib/app-utils/email-text.js +189 -0
  88. package/src/lib/app-utils/email-text.js.map +1 -0
  89. package/src/lib/app-utils/{list-assignment-policy.d.ts → enrollment-basis.d.ts} +4 -0
  90. package/src/lib/app-utils/{list-assignment-policy.js → enrollment-basis.js} +5 -1
  91. package/src/lib/app-utils/enrollment-basis.js.map +1 -0
  92. package/src/lib/app-utils/entity-picker-context.d.ts +10 -5
  93. package/src/lib/app-utils/entity-picker-context.js.map +1 -1
  94. package/src/lib/app-utils/error-beacon.d.ts +75 -6
  95. package/src/lib/app-utils/error-beacon.js +160 -21
  96. package/src/lib/app-utils/error-beacon.js.map +1 -1
  97. package/src/lib/app-utils/expand-repeatables.d.ts +10 -10
  98. package/src/lib/app-utils/expand-repeatables.js +14 -14
  99. package/src/lib/app-utils/expand-repeatables.js.map +1 -1
  100. package/src/lib/app-utils/forms.js +2 -2
  101. package/src/lib/app-utils/forms.js.map +1 -1
  102. package/src/lib/app-utils/health-report.d.ts +2 -3
  103. package/src/lib/app-utils/health-report.js +8 -6
  104. package/src/lib/app-utils/health-report.js.map +1 -1
  105. package/src/lib/app-utils/host-events.d.ts +82 -0
  106. package/src/lib/app-utils/host-events.js +62 -0
  107. package/src/lib/app-utils/host-events.js.map +1 -0
  108. package/src/lib/app-utils/inbound-mail-domain.d.ts +27 -0
  109. package/src/lib/app-utils/inbound-mail-domain.js +31 -0
  110. package/src/lib/app-utils/inbound-mail-domain.js.map +1 -0
  111. package/src/lib/app-utils/lockdown.d.ts +11 -0
  112. package/src/lib/app-utils/lockdown.js +33 -3
  113. package/src/lib/app-utils/lockdown.js.map +1 -1
  114. package/src/lib/app-utils/margin-utilization.d.ts +4 -69
  115. package/src/lib/app-utils/margin-utilization.js +82 -69
  116. package/src/lib/app-utils/margin-utilization.js.map +1 -1
  117. package/src/lib/app-utils/member-email-aliases.d.ts +25 -0
  118. package/src/lib/app-utils/member-email-aliases.js +29 -0
  119. package/src/lib/app-utils/member-email-aliases.js.map +1 -1
  120. package/src/lib/app-utils/node-definition-sanitizer.d.ts +16 -16
  121. package/src/lib/app-utils/node-definition-sanitizer.js +24 -20
  122. package/src/lib/app-utils/node-definition-sanitizer.js.map +1 -1
  123. package/src/lib/app-utils/node-interactions.d.ts +7 -7
  124. package/src/lib/app-utils/node-interactions.js.map +1 -1
  125. package/src/lib/app-utils/notifications.d.ts +147 -47
  126. package/src/lib/app-utils/notifications.js +182 -44
  127. package/src/lib/app-utils/notifications.js.map +1 -1
  128. package/src/lib/app-utils/operator-alerts.d.ts +15 -1
  129. package/src/lib/app-utils/operator-alerts.js +47 -10
  130. package/src/lib/app-utils/operator-alerts.js.map +1 -1
  131. package/src/lib/app-utils/payments-configured.d.ts +8 -21
  132. package/src/lib/app-utils/payments-configured.js +8 -23
  133. package/src/lib/app-utils/payments-configured.js.map +1 -1
  134. package/src/lib/app-utils/plan-entitlements.d.ts +207 -306
  135. package/src/lib/app-utils/plan-entitlements.js +345 -528
  136. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  137. package/src/lib/app-utils/platform-brand.d.ts +10 -0
  138. package/src/lib/app-utils/platform-brand.js +14 -3
  139. package/src/lib/app-utils/platform-brand.js.map +1 -1
  140. package/src/lib/app-utils/platform-marketing-consent.d.ts +56 -2
  141. package/src/lib/app-utils/platform-marketing-consent.js +6 -5
  142. package/src/lib/app-utils/platform-marketing-consent.js.map +1 -1
  143. package/src/lib/app-utils/plugin-api-rate-limit.d.ts +7 -1
  144. package/src/lib/app-utils/plugin-api-rate-limit.js +11 -3
  145. package/src/lib/app-utils/plugin-api-rate-limit.js.map +1 -1
  146. package/src/lib/app-utils/plugin-bundle-checks.js +2 -2
  147. package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
  148. package/src/lib/app-utils/plugin-host-events.generated.d.ts +12 -0
  149. package/src/lib/app-utils/plugin-host-events.generated.js +169 -0
  150. package/src/lib/app-utils/plugin-host-events.generated.js.map +1 -0
  151. package/src/lib/app-utils/plugin-release-flags.generated.d.ts +12 -0
  152. package/src/lib/app-utils/plugin-release-flags.generated.js +106 -0
  153. package/src/lib/app-utils/plugin-release-flags.generated.js.map +1 -0
  154. package/src/lib/app-utils/plugin-starter-templates.generated.d.ts +12 -0
  155. package/src/lib/app-utils/plugin-starter-templates.generated.js +711 -0
  156. package/src/lib/app-utils/plugin-starter-templates.generated.js.map +1 -0
  157. package/src/lib/app-utils/realm-host-surface.generated.js +2 -0
  158. package/src/lib/app-utils/realm-host-surface.generated.js.map +1 -1
  159. package/src/lib/app-utils/release-flags.d.ts +14 -5
  160. package/src/lib/app-utils/release-flags.js +21 -125
  161. package/src/lib/app-utils/release-flags.js.map +1 -1
  162. package/src/lib/app-utils/repeat-query.d.ts +56 -0
  163. package/src/lib/app-utils/{dataset-query.js → repeat-query.js} +16 -16
  164. package/src/lib/app-utils/repeat-query.js.map +1 -0
  165. package/src/lib/app-utils/repeat-sources.d.ts +9 -0
  166. package/src/lib/app-utils/repeat-sources.js.map +1 -1
  167. package/src/lib/app-utils/retained-refusals.d.ts +73 -0
  168. package/src/lib/app-utils/retained-refusals.js +186 -0
  169. package/src/lib/app-utils/retained-refusals.js.map +1 -0
  170. package/src/lib/app-utils/scope-tokens.d.ts +27 -0
  171. package/src/lib/app-utils/scope-tokens.js +61 -0
  172. package/src/lib/app-utils/scope-tokens.js.map +1 -1
  173. package/src/lib/app-utils/screen-link-context.js +3 -3
  174. package/src/lib/app-utils/screen-link-context.js.map +1 -1
  175. package/src/lib/app-utils/screen-route.js +1 -1
  176. package/src/lib/app-utils/screen-route.js.map +1 -1
  177. package/src/lib/app-utils/seo-audit.d.ts +132 -0
  178. package/src/lib/app-utils/seo-audit.js +246 -0
  179. package/src/lib/app-utils/seo-audit.js.map +1 -0
  180. package/src/lib/app-utils/seo-keywords.d.ts +48 -0
  181. package/src/lib/app-utils/seo-keywords.js +60 -0
  182. package/src/lib/app-utils/seo-keywords.js.map +1 -0
  183. package/src/lib/app-utils/seo-page-facts.d.ts +66 -0
  184. package/src/lib/app-utils/seo-page-facts.js +155 -0
  185. package/src/lib/app-utils/seo-page-facts.js.map +1 -0
  186. package/src/lib/app-utils/seo-site-scan.d.ts +126 -0
  187. package/src/lib/app-utils/seo-site-scan.js +162 -0
  188. package/src/lib/app-utils/seo-site-scan.js.map +1 -0
  189. package/src/lib/app-utils/seo-title-variables.js +1 -1
  190. package/src/lib/app-utils/seo-title-variables.js.map +1 -1
  191. package/src/lib/app-utils/server.d.ts +13 -18
  192. package/src/lib/app-utils/server.js +33 -51
  193. package/src/lib/app-utils/server.js.map +1 -1
  194. package/src/lib/app-utils/site-interactions.d.ts +501 -0
  195. package/src/lib/app-utils/site-interactions.js +554 -0
  196. package/src/lib/app-utils/site-interactions.js.map +1 -0
  197. package/src/lib/app-utils/{marketplace-theme.d.ts → site-theme.d.ts} +13 -2
  198. package/src/lib/app-utils/{marketplace-theme.js → site-theme.js} +2 -2
  199. package/src/lib/app-utils/site-theme.js.map +1 -0
  200. package/src/lib/app-utils/sitemap.d.ts +4 -3
  201. package/src/lib/app-utils/sitemap.js +9 -7
  202. package/src/lib/app-utils/sitemap.js.map +1 -1
  203. package/src/lib/app-utils/stale-build-error.d.ts +51 -0
  204. package/src/lib/app-utils/stale-build-error.js +100 -1
  205. package/src/lib/app-utils/stale-build-error.js.map +1 -1
  206. package/src/lib/app-utils/starter-template-nodes.d.ts +137 -0
  207. package/src/lib/app-utils/starter-template-nodes.js +129 -0
  208. package/src/lib/app-utils/starter-template-nodes.js.map +1 -0
  209. package/src/lib/app-utils/starter-templates.d.ts +11 -34
  210. package/src/lib/app-utils/starter-templates.js +19 -230
  211. package/src/lib/app-utils/starter-templates.js.map +1 -1
  212. package/src/lib/app-utils/{email-topics.d.ts → subscription-topics.d.ts} +64 -48
  213. package/src/lib/app-utils/{email-topics.js → subscription-topics.js} +39 -109
  214. package/src/lib/app-utils/subscription-topics.js.map +1 -0
  215. package/src/lib/app-utils/tax-jurisdiction-figures.d.ts +118 -0
  216. package/src/lib/app-utils/tax-jurisdiction-figures.js +123 -0
  217. package/src/lib/app-utils/tax-jurisdiction-figures.js.map +1 -0
  218. package/src/lib/app-utils/theme-library.d.ts +10 -3
  219. package/src/lib/app-utils/theme-library.js +13 -4
  220. package/src/lib/app-utils/theme-library.js.map +1 -1
  221. package/src/lib/app-utils/usage-budget.d.ts +73 -188
  222. package/src/lib/app-utils/usage-budget.js +58 -194
  223. package/src/lib/app-utils/usage-budget.js.map +1 -1
  224. package/src/lib/app-utils/{campaign-attribution.d.ts → utm-attribution.d.ts} +11 -6
  225. package/src/lib/app-utils/{campaign-attribution.js → utm-attribution.js} +13 -8
  226. package/src/lib/app-utils/utm-attribution.js.map +1 -0
  227. package/src/lib/app-utils/{campaign-forwarding.d.ts → utm-forwarding.d.ts} +14 -14
  228. package/src/lib/app-utils/{campaign-forwarding.js → utm-forwarding.js} +26 -26
  229. package/src/lib/app-utils/utm-forwarding.js.map +1 -0
  230. package/src/lib/app-utils/{campaign-touch.d.ts → utm-touch.d.ts} +25 -17
  231. package/src/lib/app-utils/{campaign-touch.js → utm-touch.js} +38 -30
  232. package/src/lib/app-utils/utm-touch.js.map +1 -0
  233. package/src/lib/app-utils/variables.d.ts +9 -9
  234. package/src/lib/app-utils/variables.js.map +1 -1
  235. package/src/lib/app-utils/video-object.js +6 -5
  236. package/src/lib/app-utils/video-object.js.map +1 -1
  237. package/src/lib/app-utils/visitor-consent.d.ts +41 -164
  238. package/src/lib/app-utils/visitor-consent.js +66 -277
  239. package/src/lib/app-utils/visitor-consent.js.map +1 -1
  240. package/src/lib/app-utils/visitor-record-ceiling.d.ts +32 -0
  241. package/src/lib/app-utils/visitor-record-ceiling.js +39 -0
  242. package/src/lib/app-utils/visitor-record-ceiling.js.map +1 -1
  243. package/src/lib/app-utils/web-vitals-rum.d.ts +7 -7
  244. package/src/lib/app-utils/web-vitals-rum.js +31 -63
  245. package/src/lib/app-utils/web-vitals-rum.js.map +1 -1
  246. package/src/lib/app-utils/where-used.d.ts +11 -6
  247. package/src/lib/app-utils/where-used.js +3 -8
  248. package/src/lib/app-utils/where-used.js.map +1 -1
  249. package/src/lib/foundation/definitions/components.types.d.ts +1 -1
  250. package/src/lib/foundation/definitions/components.types.js +1 -1
  251. package/src/lib/foundation/definitions/components.types.js.map +1 -1
  252. package/src/lib/foundation/definitions/org-billing.types.d.ts +11 -137
  253. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  254. package/src/lib/foundation/definitions/organization.types.d.ts +1 -3
  255. package/src/lib/foundation/definitions/organization.types.js.map +1 -1
  256. package/src/lib/foundation/definitions/platform.types.d.ts +11 -5
  257. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  258. package/src/lib/foundation/definitions/write-deny-coverage.util.d.ts +16 -0
  259. package/src/lib/foundation/definitions/write-deny-coverage.util.js +20 -0
  260. package/src/lib/foundation/definitions/write-deny-coverage.util.js.map +1 -1
  261. package/src/lib/foundation/index.d.ts +1 -1
  262. package/src/lib/foundation/index.js.map +1 -1
  263. package/src/lib/plugin-manager/besigner-documents.d.ts +96 -0
  264. package/src/lib/plugin-manager/besigner-documents.js +61 -0
  265. package/src/lib/plugin-manager/besigner-documents.js.map +1 -0
  266. package/src/lib/plugin-manager/computed-variables.d.ts +71 -0
  267. package/src/lib/plugin-manager/computed-variables.js +74 -0
  268. package/src/lib/plugin-manager/computed-variables.js.map +1 -0
  269. package/src/lib/plugin-manager/custom-fields.d.ts +29 -2
  270. package/src/lib/plugin-manager/custom-fields.js +47 -1
  271. package/src/lib/plugin-manager/custom-fields.js.map +1 -1
  272. package/src/lib/plugin-manager/feature-plugins.d.ts +372 -99
  273. package/src/lib/plugin-manager/feature-plugins.js +126 -51
  274. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  275. package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +134 -0
  276. package/src/lib/plugin-manager/first-party-plugins.generated.js +840 -10
  277. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  278. package/src/lib/plugin-manager/index.d.ts +0 -1
  279. package/src/lib/plugin-manager/index.js +0 -1
  280. package/src/lib/plugin-manager/index.js.map +1 -1
  281. package/src/lib/plugin-manager/plugin-activity-actions.d.ts +12 -0
  282. package/src/lib/plugin-manager/plugin-activity-actions.js +25 -0
  283. package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
  284. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +9 -4
  285. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  286. package/src/lib/plugin-manager/plugin-containers.d.ts +68 -0
  287. package/src/lib/plugin-manager/plugin-containers.generated.d.ts +10 -0
  288. package/src/lib/plugin-manager/plugin-containers.generated.js +20 -0
  289. package/src/lib/plugin-manager/plugin-containers.generated.js.map +1 -0
  290. package/src/lib/plugin-manager/plugin-containers.js +27 -0
  291. package/src/lib/plugin-manager/plugin-containers.js.map +1 -0
  292. package/src/lib/plugin-manager/plugin-contributions.d.ts +1 -1
  293. package/src/lib/plugin-manager/plugin-contributions.js.map +1 -1
  294. package/src/lib/plugin-manager/plugin-declarations-repair.d.ts +36 -0
  295. package/src/lib/plugin-manager/plugin-declarations-repair.js +64 -0
  296. package/src/lib/plugin-manager/plugin-declarations-repair.js.map +1 -0
  297. package/src/lib/plugin-manager/plugin-dependents.d.ts +101 -0
  298. package/src/lib/plugin-manager/plugin-dependents.js +75 -0
  299. package/src/lib/plugin-manager/plugin-dependents.js.map +1 -0
  300. package/src/lib/plugin-manager/plugin-distribution.d.ts +55 -0
  301. package/src/lib/plugin-manager/plugin-distribution.js +45 -0
  302. package/src/lib/plugin-manager/plugin-distribution.js.map +1 -0
  303. package/src/lib/plugin-manager/plugin-entitlements.d.ts +8 -0
  304. package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
  305. package/src/lib/plugin-manager/plugin-host-collections.d.ts +17 -0
  306. package/src/lib/plugin-manager/plugin-host-collections.js +6 -0
  307. package/src/lib/plugin-manager/plugin-host-collections.js.map +1 -1
  308. package/src/lib/plugin-manager/plugin-host-resources.d.ts +133 -0
  309. package/src/lib/plugin-manager/plugin-host-resources.js +52 -0
  310. package/src/lib/plugin-manager/plugin-host-resources.js.map +1 -0
  311. package/src/lib/plugin-manager/plugin-membership-detach.js +1 -1
  312. package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
  313. package/src/lib/plugin-manager/plugin-org-erasure.d.ts +28 -2
  314. package/src/lib/plugin-manager/plugin-org-erasure.js +61 -2
  315. package/src/lib/plugin-manager/plugin-org-erasure.js.map +1 -1
  316. package/src/lib/plugin-manager/plugin-plan-entitlements.d.ts +123 -0
  317. package/src/lib/plugin-manager/plugin-plan-entitlements.js +101 -0
  318. package/src/lib/plugin-manager/plugin-plan-entitlements.js.map +1 -0
  319. package/src/lib/plugin-manager/plugin-record-email-state.d.ts +69 -0
  320. package/src/lib/plugin-manager/plugin-record-email-state.js +32 -0
  321. package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
  322. package/src/lib/plugin-manager/plugin-record-index.d.ts +95 -0
  323. package/src/lib/plugin-manager/plugin-record-index.js +51 -0
  324. package/src/lib/plugin-manager/plugin-record-index.js.map +1 -0
  325. package/src/lib/plugin-manager/plugin-record-lists.d.ts +115 -0
  326. package/src/lib/plugin-manager/plugin-record-lists.js +75 -0
  327. package/src/lib/plugin-manager/plugin-record-lists.js.map +1 -0
  328. package/src/lib/plugin-manager/plugin-record-timeline.d.ts +42 -8
  329. package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
  330. package/src/lib/plugin-manager/plugin-record-written.d.ts +58 -0
  331. package/src/lib/plugin-manager/plugin-record-written.js +42 -0
  332. package/src/lib/plugin-manager/plugin-record-written.js.map +1 -0
  333. package/src/lib/plugin-manager/plugin-revenue-sources.d.ts +213 -0
  334. package/src/lib/plugin-manager/plugin-revenue-sources.js +194 -0
  335. package/src/lib/plugin-manager/plugin-revenue-sources.js.map +1 -0
  336. package/src/lib/plugin-manager/plugin-site-beacons.d.ts +71 -0
  337. package/src/lib/plugin-manager/plugin-site-beacons.js +54 -0
  338. package/src/lib/plugin-manager/plugin-site-beacons.js.map +1 -0
  339. package/src/lib/plugin-manager/plugin-site-bundle.d.ts +145 -0
  340. package/src/lib/plugin-manager/plugin-site-bundle.js +78 -0
  341. package/src/lib/plugin-manager/plugin-site-bundle.js.map +1 -0
  342. package/src/lib/plugin-manager/plugin-site-cache.d.ts +12 -0
  343. package/src/lib/plugin-manager/plugin-site-cache.js.map +1 -1
  344. package/src/lib/plugin-manager/plugin-site-export.d.ts +81 -0
  345. package/src/lib/plugin-manager/plugin-site-export.js +40 -0
  346. package/src/lib/plugin-manager/plugin-site-export.js.map +1 -0
  347. package/src/lib/plugin-manager/plugin-sitemap-sections.d.ts +88 -0
  348. package/src/lib/plugin-manager/plugin-sitemap-sections.js +29 -0
  349. package/src/lib/plugin-manager/plugin-sitemap-sections.js.map +1 -0
  350. package/src/lib/plugin-manager/plugin-subscription-topics.generated.d.ts +11 -0
  351. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js +41 -0
  352. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js.map +1 -0
  353. package/src/lib/plugin-manager/plugin-tax-profile.d.ts +20 -4
  354. package/src/lib/plugin-manager/plugin-tax-profile.js.map +1 -1
  355. package/src/lib/plugin-manager/plugin-tax-return-sources.d.ts +255 -0
  356. package/src/lib/plugin-manager/plugin-tax-return-sources.js +191 -0
  357. package/src/lib/plugin-manager/plugin-tax-return-sources.js.map +1 -0
  358. package/src/lib/plugin-manager/plugin-template-sources.d.ts +75 -0
  359. package/src/lib/plugin-manager/plugin-template-sources.js +37 -0
  360. package/src/lib/plugin-manager/plugin-template-sources.js.map +1 -0
  361. package/src/lib/plugin-manager/plugin-usage-axes.d.ts +319 -0
  362. package/src/lib/plugin-manager/plugin-usage-axes.js +186 -0
  363. package/src/lib/plugin-manager/plugin-usage-axes.js.map +1 -0
  364. package/src/lib/plugin-manager/plugin-usage-meters.d.ts +108 -0
  365. package/src/lib/plugin-manager/plugin-usage-meters.js +105 -0
  366. package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -0
  367. package/src/lib/plugin-manager/plugin-zones.d.ts +4 -5
  368. package/src/lib/plugin-manager/plugin-zones.js.map +1 -1
  369. package/src/lib/plugin-manager/realm-host-aglyn.generated.js +2 -0
  370. package/src/lib/plugin-manager/realm-host-aglyn.generated.js.map +1 -1
  371. package/src/lib/plugin-manager/record-captured-contact.d.ts +1 -14
  372. package/src/lib/plugin-manager/record-captured-contact.js +10 -32
  373. package/src/lib/plugin-manager/record-captured-contact.js.map +1 -1
  374. package/src/lib/plugin-manager/repeat-rows.d.ts +124 -0
  375. package/src/lib/plugin-manager/repeat-rows.js +108 -0
  376. package/src/lib/plugin-manager/repeat-rows.js.map +1 -0
  377. package/src/lib/plugin-manager/submission-record-target.d.ts +144 -0
  378. package/src/lib/plugin-manager/submission-record-target.js +125 -0
  379. package/src/lib/plugin-manager/submission-record-target.js.map +1 -0
  380. package/src/lib/plugin-manager/usage-alert-contributors.d.ts +76 -6
  381. package/src/lib/plugin-manager/usage-alert-contributors.js +23 -10
  382. package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
  383. package/src/lib/plugin-manager/video-embed-provider.d.ts +108 -0
  384. package/src/lib/plugin-manager/video-embed-provider.js +165 -0
  385. package/src/lib/plugin-manager/video-embed-provider.js.map +1 -0
  386. package/src/lib/types/nodes.d.ts +2 -2
  387. package/src/lib/types/nodes.js.map +1 -1
  388. package/src/lib/app-utils/assist-credits.d.ts +0 -482
  389. package/src/lib/app-utils/assist-credits.js +0 -509
  390. package/src/lib/app-utils/assist-credits.js.map +0 -1
  391. package/src/lib/app-utils/campaign-attribution.js.map +0 -1
  392. package/src/lib/app-utils/campaign-forwarding.js.map +0 -1
  393. package/src/lib/app-utils/campaign-membership.d.ts +0 -121
  394. package/src/lib/app-utils/campaign-membership.js +0 -189
  395. package/src/lib/app-utils/campaign-membership.js.map +0 -1
  396. package/src/lib/app-utils/campaign-touch.js.map +0 -1
  397. package/src/lib/app-utils/console-record-links.d.ts +0 -163
  398. package/src/lib/app-utils/console-record-links.js +0 -180
  399. package/src/lib/app-utils/console-record-links.js.map +0 -1
  400. package/src/lib/app-utils/contact-holder.d.ts +0 -68
  401. package/src/lib/app-utils/contact-holder.js +0 -129
  402. package/src/lib/app-utils/contact-holder.js.map +0 -1
  403. package/src/lib/app-utils/contact-merge.d.ts +0 -136
  404. package/src/lib/app-utils/contact-merge.js +0 -412
  405. package/src/lib/app-utils/contact-merge.js.map +0 -1
  406. package/src/lib/app-utils/crm-booking.d.ts +0 -75
  407. package/src/lib/app-utils/crm-booking.js +0 -133
  408. package/src/lib/app-utils/crm-booking.js.map +0 -1
  409. package/src/lib/app-utils/crm-csv.d.ts +0 -239
  410. package/src/lib/app-utils/crm-csv.js +0 -467
  411. package/src/lib/app-utils/crm-csv.js.map +0 -1
  412. package/src/lib/app-utils/crm-digest.d.ts +0 -185
  413. package/src/lib/app-utils/crm-digest.js +0 -272
  414. package/src/lib/app-utils/crm-digest.js.map +0 -1
  415. package/src/lib/app-utils/crm-inbound.d.ts +0 -245
  416. package/src/lib/app-utils/crm-inbound.js +0 -407
  417. package/src/lib/app-utils/crm-inbound.js.map +0 -1
  418. package/src/lib/app-utils/crm-reports.d.ts +0 -479
  419. package/src/lib/app-utils/crm-reports.js +0 -680
  420. package/src/lib/app-utils/crm-reports.js.map +0 -1
  421. package/src/lib/app-utils/dataset-csv.js.map +0 -1
  422. package/src/lib/app-utils/dataset-query.d.ts +0 -57
  423. package/src/lib/app-utils/dataset-query.js.map +0 -1
  424. package/src/lib/app-utils/email-topics.js.map +0 -1
  425. package/src/lib/app-utils/form-dataset-binding.d.ts +0 -50
  426. package/src/lib/app-utils/form-dataset-binding.js +0 -74
  427. package/src/lib/app-utils/form-dataset-binding.js.map +0 -1
  428. package/src/lib/app-utils/list-assignment-policy.js.map +0 -1
  429. package/src/lib/app-utils/list-import.d.ts +0 -158
  430. package/src/lib/app-utils/list-import.js +0 -332
  431. package/src/lib/app-utils/list-import.js.map +0 -1
  432. package/src/lib/app-utils/marketplace-listing-visibility.d.ts +0 -122
  433. package/src/lib/app-utils/marketplace-listing-visibility.js +0 -147
  434. package/src/lib/app-utils/marketplace-listing-visibility.js.map +0 -1
  435. package/src/lib/app-utils/marketplace-overrides.js.map +0 -1
  436. package/src/lib/app-utils/marketplace-provenance.js.map +0 -1
  437. package/src/lib/app-utils/marketplace-theme.js.map +0 -1
  438. package/src/lib/app-utils/marketplace-update-state.d.ts +0 -119
  439. package/src/lib/app-utils/marketplace-update-state.js +0 -132
  440. package/src/lib/app-utils/marketplace-update-state.js.map +0 -1
  441. package/src/lib/app-utils/publisher-agreement.d.ts +0 -289
  442. package/src/lib/app-utils/publisher-agreement.js +0 -321
  443. package/src/lib/app-utils/publisher-agreement.js.map +0 -1
  444. package/src/lib/app-utils/wistia-embed.d.ts +0 -104
  445. package/src/lib/app-utils/wistia-embed.js +0 -138
  446. package/src/lib/app-utils/wistia-embed.js.map +0 -1
  447. package/src/lib/app-utils/workflows.d.ts +0 -136
  448. package/src/lib/app-utils/workflows.js +0 -346
  449. package/src/lib/app-utils/workflows.js.map +0 -1
  450. package/src/lib/plugin-manager/commerce-zone-props.d.ts +0 -172
  451. package/src/lib/plugin-manager/commerce-zone-props.js +0 -31
  452. package/src/lib/plugin-manager/commerce-zone-props.js.map +0 -1
  453. package/src/lib/plugin-manager/plugin-metered-lines.d.ts +0 -97
  454. package/src/lib/plugin-manager/plugin-metered-lines.js +0 -123
  455. package/src/lib/plugin-manager/plugin-metered-lines.js.map +0 -1
@@ -69,6 +69,16 @@ export declare const PLATFORM_BRAND_LEGAL_NAME: string;
69
69
  * console at all.
70
70
  */
71
71
  export declare const PLATFORM_SUPPORT_URL: string;
72
+ /**
73
+ * This install's console origin, with no trailing slash — what a link in a
74
+ * notification or an email the platform sends is built on (AGL-3080).
75
+ *
76
+ * `NEXT_PUBLIC_CONSOLE_URL`, which every self-hosted install sets; Aglyn's
77
+ * own console only for a build that names none. One reading for the support
78
+ * page above and for every scheduled sender, a plugin's included, so none of
79
+ * them spells the fallback again.
80
+ */
81
+ export declare function platformConsoleOrigin(): string;
72
82
  /** True when this deployment still answers to Aglyn's own brand. */
73
83
  export declare const isAglynOperatedBrand: () => boolean;
74
84
  /**
@@ -110,14 +110,25 @@ var _clean4;
110
110
  * platform sends. `app.aglyn.com` is left only for a build that names no
111
111
  * console at all.
112
112
  */ export const PLATFORM_SUPPORT_URL = (()=>{
113
- var _clean;
114
113
  const configured = clean(process.env.NEXT_PUBLIC_PLATFORM_SUPPORT_URL);
115
114
  if (configured) return configured;
116
115
  const operatorEmail = clean(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL);
117
116
  if (operatorEmail) return `mailto:${operatorEmail}`;
118
- const consoleOrigin = (_clean = clean(process.env.NEXT_PUBLIC_CONSOLE_URL)) == null ? void 0 : _clean.replace(/\/+$/, '');
119
- return `${consoleOrigin || 'https://app.aglyn.com'}/support`;
117
+ return `${platformConsoleOrigin()}/support`;
120
118
  })();
119
+ /**
120
+ * This install's console origin, with no trailing slash — what a link in a
121
+ * notification or an email the platform sends is built on (AGL-3080).
122
+ *
123
+ * `NEXT_PUBLIC_CONSOLE_URL`, which every self-hosted install sets; Aglyn's
124
+ * own console only for a build that names none. One reading for the support
125
+ * page above and for every scheduled sender, a plugin's included, so none of
126
+ * them spells the fallback again.
127
+ */ export function platformConsoleOrigin() {
128
+ var _clean;
129
+ const configured = (_clean = clean(process.env.NEXT_PUBLIC_CONSOLE_URL)) == null ? void 0 : _clean.replace(/\/+$/, '');
130
+ return configured || 'https://app.aglyn.com';
131
+ }
121
132
  /** True when this deployment still answers to Aglyn's own brand. */ export const isAglynOperatedBrand = ()=>PLATFORM_BRAND_NAME === 'Aglyn';
122
133
  /**
123
134
  * Where the platform's own front door is — the marketing home, not the help
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/platform-brand.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 * What this deployment calls itself (AGL-2153).\n *\n * the rule for the self-host path: *\"the self hosted owner should not have\n * to edit the source to update aglyn branded items urls or personal\n * identification items, should move these to env vars\"*. AGL-2016 did that for\n * the values where getting it wrong misroutes a legal notice. This is the\n * other half — the product name itself, which was a bare `'Aglyn'` literal in\n * nine places.\n *\n * ## Why one module rather than nine reads\n *\n * The literals were not merely duplicated, they were duplicated across a\n * boundary: `PLATFORM_GENERATOR_NAME` exists here AND as a hand-copied\n * constant in `apps/tenant/middleware.ts`, because the edge bundle cannot\n * import this lib. A single source they both read is what stops the copy from\n * drifting into a deployment that half-renames itself.\n *\n * ## Why a default at all, when operator-identity.ts refuses to have one\n *\n * `operator-identity.ts` sets out the rule and it is the right one: a value\n * whose wrongness is invisible to the operator and visible only off-site must\n * not be guessed. The brand is the opposite case. An operator who does not set\n * it sees \"Aglyn\" in their own browser tab on their own first page load, in\n * their own console. That is a default they can see and correct, which is a\n * default rather than a trap — the same test AGL-2022 applied to the tenant\n * apex.\n *\n * ## Why `NEXT_PUBLIC_`, and why dot notation is load-bearing\n *\n * The brand is printed to the public — that is what it is for — so\n * `NEXT_PUBLIC_` carries no disclosure risk. Next inlines `NEXT_PUBLIC_*` into\n * the browser bundle by textually substituting `process.env.NAME`; the\n * **bracket** form is never substituted and reads `undefined` in the browser\n * (AGL-2037). Every read below is dot notation on purpose.\n *\n * Build-time consequence for self-hosters: these are baked in when the image\n * is built, so they must be set *before* `docker compose build`, not merely\n * before `up`. `docs/SELF_HOSTING.md` says so.\n */\n\n/** A non-empty trimmed string, else undefined — `' '` is a half-finished `.env`. */\nconst clean = (value: unknown): string | undefined => {\n const text = typeof value === 'string' ? value.trim() : ''\n return text.length ? text : undefined\n}\n\n/**\n * The product name, as shown to end users.\n *\n * This is the browser-tab affix, the PWA name, the WebAuthn relying-party name\n * the OS credential prompt displays, the `<meta name=\"generator\">` and\n * `x-powered-by` on published sites, and the platform default `productName` /\n * `fromName` every non-white-label org's surfaces resolve to.\n */\nexport const PLATFORM_BRAND_NAME =\n clean(process.env.NEXT_PUBLIC_PLATFORM_BRAND_NAME) ?? 'Aglyn'\n\n/**\n * The legal entity operating the software's own cloud, for copy that needs a\n * company rather than a product (`BRAND.ORG_NAME_LEGAL`).\n *\n * NOT the same thing as `operatorIdentity().name`, and the distinction matters.\n * This is a *brand* string with a visible default; that one is the identity a\n * statutory notice is addressed to and refuses to default. An operator who\n * sets only this has renamed the product, not published who they are.\n */\nexport const PLATFORM_BRAND_LEGAL_NAME =\n clean(process.env.NEXT_PUBLIC_PLATFORM_BRAND_LEGAL_NAME) ??\n `${PLATFORM_BRAND_NAME} LLC`\n\n/**\n * Where a \"need help?\" line points.\n *\n * Falls through the operator's OWN support address before it falls back to\n * ours, deliberately: an operator who followed the runbook has already set\n * `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, and making them discover a second\n * variable to stop pointing their customers at Aglyn's support desk — which\n * cannot help those customers with anything — is a trap dressed as a default.\n *\n * Read here rather than imported from `operator-identity.ts` so this module\n * stays dependency-free and safe to import from anywhere, including the places\n * that cannot take the operator module's weight.\n *\n * ## Why the last fallback is the CONSOLE and not the marketing site\n *\n * It was `aglyn.com/support`, which answers 404 and has never existed\n * (AGL-3262) — MEASURED against the live site, not inferred: `/support`,\n * `/help` and `/docs` all render the not-found page and none is in the\n * sitemap. A support URL that 404s is worse than none, because it is printed\n * on a receipt beside a charge the reader is already unsure about.\n *\n * `app.aglyn.com/support` is a real page (AGL-3265): it resolves the\n * workspace from the session, so a customer with one lands on its support\n * umbrella and a customer with several picks. That is the whole reason it\n * exists — it is the one support address that needs nothing interpolated\n * into it, which is exactly what this constant is.\n *\n * The console is THIS install's (AGL-3322). `/support` is a console route, so\n * every console serves its own, and `NEXT_PUBLIC_CONSOLE_URL` is required of\n * every self-hosted install — so one that configured neither variable above\n * sends its customers to its own support page rather than to ours, where they\n * have no account. The line is in the footer of every system email the\n * platform sends. `app.aglyn.com` is left only for a build that names no\n * console at all.\n */\nexport const PLATFORM_SUPPORT_URL: string = (() => {\n const configured = clean(process.env.NEXT_PUBLIC_PLATFORM_SUPPORT_URL)\n if (configured) return configured\n const operatorEmail = clean(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL)\n if (operatorEmail) return `mailto:${operatorEmail}`\n const consoleOrigin = clean(process.env.NEXT_PUBLIC_CONSOLE_URL)?.replace(\n /\\/+$/,\n '',\n )\n return `${consoleOrigin || 'https://app.aglyn.com'}/support`\n})()\n\n/** True when this deployment still answers to Aglyn's own brand. */\nexport const isAglynOperatedBrand = (): boolean =>\n PLATFORM_BRAND_NAME === 'Aglyn'\n\n/**\n * Where the platform's own front door is — the marketing home, not the help\n * desk.\n *\n * ## Why this is not `PLATFORM_SUPPORT_URL`\n *\n * The \"Made with …\" badge on a published free-tier site used to link to\n * `supportUrl`, and on this deployment `supportUrl` falls through to\n * `mailto:` + the operator support address. So the one link the platform gets\n * on every free customer's site — its only organic acquisition surface —\n * opened the visitor's mail client addressed to our support desk. A visitor\n * who liked the site and clicked to find out what built it got a blank email,\n * and support got the misdirected replies.\n *\n * They are genuinely different destinations. `supportUrl` answers \"I am a\n * customer and something is wrong\"; this answers \"what is this, and can I have\n * one\". Keeping one value for both guarantees one of them is wrong.\n *\n * ## Why it is null rather than aglyn.com off-brand\n *\n * A self-hosted deployment that renamed the product but set no home URL has\n * nowhere honest to send that visitor: \"Made with Foo\" pointing at aglyn.com\n * advertises the wrong company on Foo's customers' sites. The badge already\n * renders as a plain label when it has no destination (AGL-2428), which is the\n * correct outcome — so the Aglyn default applies only while this really is the\n * Aglyn-operated brand.\n */\nexport const PLATFORM_HOME_URL: string | null =\n clean(process.env.NEXT_PUBLIC_PLATFORM_HOME_URL) ??\n (isAglynOperatedBrand() ? 'https://aglyn.com' : null)\n\n/**\n * The platform's own logo MARK — the compact, square, no-wordmark form — as a\n * URL an `<img>` on a PUBLISHED SITE can load.\n *\n * Site-relative on purpose: every app that renders it serves\n * `public/_static/…` from its own origin, so one path works on\n * `{sub}.aglyn.app` and on a customer's custom domain alike without knowing\n * either. The white variant is the one that survives the badge's dark pill and\n * an org's `primaryColor` behind it.\n *\n * Null unless this is the Aglyn-operated brand, for the same reason as\n * {@link PLATFORM_HOME_URL}: a renamed deployment must not stamp our mark on\n * its customers' sites. Such an operator sets `NEXT_PUBLIC_PLATFORM_MARK_URL`\n * to their own, or gets a text-only badge.\n */\nexport const PLATFORM_MARK_URL: string | null =\n clean(process.env.NEXT_PUBLIC_PLATFORM_MARK_URL) ??\n (isAglynOperatedBrand()\n ? '/_static/images/brand/aglyn-logo-mark-white.svg'\n : null)\n\n/** An absolute `https://` URL, else undefined — anything else has no origin in an inbox. */\nconst httpsUrl = (value: string | undefined): string | undefined => {\n if (!value) return undefined\n try {\n return new URL(value).protocol === 'https:' ? value : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * The platform's logo as its own system email draws it: the header of mail\n * sent under the platform's brand (AGL-3322).\n *\n * ABSOLUTE and https, unlike {@link PLATFORM_MARK_URL}. That mark is a\n * site-relative path because every app that draws it serves the file from its\n * own origin; an inbox has no origin, so a relative path there is a\n * broken-image box. A configured value that is not an absolute `https://` URL\n * is ignored rather than handed on to fail in somebody's inbox.\n *\n * The WORDMARK rather than the square mark: a header is read at a glance, and\n * the wordmark names the sender where the mark would need the reader to\n * already know it.\n *\n * A PNG sized for the slot (AGL-3367): 396×120, three times the 132×40 the\n * header draws it at, about 15 KB, served as a static file off aglyn.com's\n * edge. The master-resolution file behind the media CDN's 60-second cache is\n * four times the bytes and makes the header the last thing in the email to\n * appear. Not an SVG: Gmail and Outlook do not draw SVG in mail, so every\n * recipient on either would get a broken-image box where the logo belongs.\n *\n * Null unless this is the Aglyn-operated brand, for the reason\n * {@link PLATFORM_HOME_URL} is: a renamed deployment must not put our logo at\n * the top of its customers' mail. Such an operator sets\n * `NEXT_PUBLIC_PLATFORM_EMAIL_LOGO_URL`, or gets its product name as the\n * header, in bold text.\n *\n * Deliberately NOT `PLATFORM_BRANDING_PROFILE.emailLogoUrl`, which stays null.\n * That field is an ORG's email logo: a white-label profile inherits it when\n * the org set none, and a send that carries one is read as an org's own mail.\n * Putting the platform's logo there would stamp it on every white-label org\n * that left the field blank.\n */\nexport const PLATFORM_EMAIL_LOGO_URL: string | null =\n httpsUrl(clean(process.env.NEXT_PUBLIC_PLATFORM_EMAIL_LOGO_URL)) ??\n (isAglynOperatedBrand()\n ? 'https://aglyn.com/_static/images/brand/aglyn-email-wordmark.png'\n : null)\n\n/**\n * The postal address printed beside {@link PLATFORM_BRAND_LEGAL_NAME} at the\n * foot of the platform's own system email (AGL-3322).\n *\n * A BRAND string, beside the legal name whose line it completes, rather than\n * a field of `operatorIdentity()`, and the difference is the default. The\n * operator identity is what a statutory notice is addressed to and refuses to\n * guess; this is a line in a footer the operator sees in their own mail and\n * can correct, and for the Aglyn-operated brand the right value is known: the\n * registered agent's address the Aglyn marketing site's email footer prints.\n *\n * Null unless this is the Aglyn-operated brand: a renamed deployment must\n * never print our address as its own. Such an operator sets\n * `NEXT_PUBLIC_PLATFORM_POSTAL_ADDRESS`, or their footer carries the legal\n * name alone. Line breaks in a configured value are folded into commas,\n * because the footer prints the address on one line.\n */\nexport const PLATFORM_POSTAL_ADDRESS: string | null =\n clean(process.env.NEXT_PUBLIC_PLATFORM_POSTAL_ADDRESS)\n ?.split(/\\s*\\n\\s*/)\n .filter(Boolean)\n .join(', ') ??\n (isAglynOperatedBrand()\n ? 'c/o Northwest Registered Agent, LLC, 5900 Balcones Drive STE 100, Austin, TX 78731'\n : null)\n\n/**\n * A branding profile with every field present — what a branded surface\n * renders. Image/color/domain fields are nullable (Aglyn's own surfaces\n * bake those in rather than carry a URL); the remaining text fields always\n * have a value so callers never string-concatenate `undefined`.\n *\n * `supportUrl` is nullable too, and it is the one field where null carries a\n * decision rather than an absence — see {@link resolveBrandingProfile}. A\n * caller must render NOTHING for it, never a placeholder and never a\n * substitute.\n */\nexport interface ResolvedBrandingProfile {\n productName: string\n logoUrl: string | null\n /** The logo for dark grounds; null means {@link logoUrl} serves both. */\n logoDarkUrl: string | null\n faviconUrl: string | null\n /** The favicon for a dark tab strip; null means {@link faviconUrl} serves both. */\n faviconDarkUrl: string | null\n primaryColor: string | null\n supportUrl: string | null\n /**\n * The brand's own FRONT DOOR — where \"Made with <product>\" sends a visitor\n * who wants to know what built the site they are looking at.\n *\n * Deliberately separate from {@link ResolvedBrandingProfile.supportUrl},\n * which the badge used to borrow: a help-desk address is the wrong answer to\n * \"what is this\", and on this deployment it resolves to a `mailto:`, so the\n * badge opened a blank email instead of a web page.\n *\n * Nullable for the same reason `supportUrl` is — a brand with nowhere to\n * send that visitor gets a plain label, never a substitute destination.\n */\n homeUrl: string | null\n fromName: string\n emailLogoUrl: string | null\n customConsoleDomain: string | null\n}\n\n/**\n * The PLATFORM (non-white-label) brand — the fallback every surface gets when\n * an org lacks the `whiteLabel` entitlement, and the gap-filler for a partial\n * agency profile.\n *\n * It lives beside the literals it is assembled from rather than beside the\n * entitlement gate that hands it out, and that placement is load-bearing\n * rather than tidying. A published tenant page renders the attribution badge\n * from this constant and reads nothing else out of `plan-entitlements.ts`;\n * defining it there put that module's whole plan, quota and entitlement table\n * — the single largest first-party module a visitor could reach — into the\n * first load of every anonymous visitor to every customer site.\n * `plan-entitlements.ts` re-exports it, so every existing caller is unchanged.\n *\n * The name says the PLATFORM's brand rather than Aglyn's because on a\n * self-host install that assertion stops being true: which platform this is,\n * is configuration. `resolveBrandingProfile` is the single resolver EVERY\n * branded surface routes through — console chrome, published-site badge and\n * title, transactional email — and this is its fallback, so all of them get a\n * self-host brand without extending the white-label machinery at all.\n */\nexport const PLATFORM_BRANDING_PROFILE: ResolvedBrandingProfile = {\n productName: PLATFORM_BRAND_NAME,\n // The platform's own surfaces still bake their logo in — this is here for\n // the surfaces that CANNOT, which is every surface rendered on somebody\n // else's site. The free-tier attribution badge is the one that exists\n // today, and it carried no mark at all while this was null.\n logoUrl: PLATFORM_MARK_URL,\n logoDarkUrl: null,\n faviconUrl: null,\n faviconDarkUrl: null,\n primaryColor: null,\n supportUrl: PLATFORM_SUPPORT_URL,\n homeUrl: PLATFORM_HOME_URL,\n fromName: PLATFORM_BRAND_NAME,\n emailLogoUrl: null,\n customConsoleDomain: null,\n}\n"],"names":["clean","httpsUrl","value","text","trim","length","undefined","PLATFORM_BRAND_NAME","process","env","NEXT_PUBLIC_PLATFORM_BRAND_NAME","PLATFORM_BRAND_LEGAL_NAME","NEXT_PUBLIC_PLATFORM_BRAND_LEGAL_NAME","PLATFORM_SUPPORT_URL","configured","NEXT_PUBLIC_PLATFORM_SUPPORT_URL","operatorEmail","NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL","consoleOrigin","NEXT_PUBLIC_CONSOLE_URL","replace","isAglynOperatedBrand","PLATFORM_HOME_URL","NEXT_PUBLIC_PLATFORM_HOME_URL","PLATFORM_MARK_URL","NEXT_PUBLIC_PLATFORM_MARK_URL","URL","protocol","PLATFORM_EMAIL_LOGO_URL","NEXT_PUBLIC_PLATFORM_EMAIL_LOGO_URL","PLATFORM_POSTAL_ADDRESS","NEXT_PUBLIC_PLATFORM_POSTAL_ADDRESS","split","filter","Boolean","join","PLATFORM_BRANDING_PROFILE","productName","logoUrl","logoDarkUrl","faviconUrl","faviconDarkUrl","primaryColor","supportUrl","homeUrl","fromName","emailLogoUrl","customConsoleDomain"],"mappings":"IAyEEA,QAYAA,SAkFAA,SAmBAA,SAiDAC;IAuBAD;AAlQF;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GAED,oFAAoF,GACpF,MAAMA,QAAQ,CAACE;IACb,MAAMC,OAAO,OAAOD,UAAU,WAAWA,MAAME,IAAI,KAAK;IACxD,OAAOD,KAAKE,MAAM,GAAGF,OAAOG;AAC9B;AAEA;;;;;;;CAOC,GACD,OAAO,MAAMC,uBACXP,SAAAA,MAAMQ,QAAQC,GAAG,CAACC,+BAA+B,aAAjDV,SAAsD,QAAO;AAE/D;;;;;;;;CAQC,GACD,OAAO,MAAMW,6BACXX,UAAAA,MAAMQ,QAAQC,GAAG,CAACG,qCAAqC,aAAvDZ,UACA,GAAGO,oBAAoB,IAAI,CAAC,CAAA;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GACD,OAAO,MAAMM,uBAA+B,AAAC,CAAA;QAKrBb;IAJtB,MAAMc,aAAad,MAAMQ,QAAQC,GAAG,CAACM,gCAAgC;IACrE,IAAID,YAAY,OAAOA;IACvB,MAAME,gBAAgBhB,MAAMQ,QAAQC,GAAG,CAACQ,kCAAkC;IAC1E,IAAID,eAAe,OAAO,CAAC,OAAO,EAAEA,eAAe;IACnD,MAAME,iBAAgBlB,SAAAA,MAAMQ,QAAQC,GAAG,CAACU,uBAAuB,sBAAzCnB,OAA4CoB,OAAO,CACvE,QACA;IAEF,OAAO,GAAGF,iBAAiB,wBAAwB,QAAQ,CAAC;AAC9D,CAAA,IAAI;AAEJ,kEAAkE,GAClE,OAAO,MAAMG,uBAAuB,IAClCd,wBAAwB,QAAO;AAEjC;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,MAAMe,qBACXtB,UAAAA,MAAMQ,QAAQC,GAAG,CAACc,6BAA6B,aAA/CvB,UACCqB,yBAAyB,sBAAsB,KAAK;AAEvD;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMG,qBACXxB,UAAAA,MAAMQ,QAAQC,GAAG,CAACgB,6BAA6B,aAA/CzB,UACCqB,yBACG,oDACA,KAAK;AAEX,0FAA0F,GAC1F,MAAMpB,WAAW,CAACC;IAChB,IAAI,CAACA,OAAO,OAAOI;IACnB,IAAI;QACF,OAAO,IAAIoB,IAAIxB,OAAOyB,QAAQ,KAAK,WAAWzB,QAAQI;IACxD,EAAE,eAAM;QACN,OAAOA;IACT;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgCC,GACD,OAAO,MAAMsB,2BACX3B,YAAAA,SAASD,MAAMQ,QAAQC,GAAG,CAACoB,mCAAmC,cAA9D5B,YACCoB,yBACG,oEACA,KAAK;AAEX;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,MAAMS,mCACX9B,UAAAA,MAAMQ,QAAQC,GAAG,CAACsB,mCAAmC,sBAArD/B,QACIgC,KAAK,CAAC,YACPC,MAAM,CAACC,SACPC,IAAI,CAAC,wBACPd,yBACG,uFACA,KAAK;AAyCX;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAMe,4BAAqD;IAChEC,aAAa9B;IACb,0EAA0E;IAC1E,wEAAwE;IACxE,sEAAsE;IACtE,4DAA4D;IAC5D+B,SAASd;IACTe,aAAa;IACbC,YAAY;IACZC,gBAAgB;IAChBC,cAAc;IACdC,YAAY9B;IACZ+B,SAAStB;IACTuB,UAAUtC;IACVuC,cAAc;IACdC,qBAAqB;AACvB,EAAC"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/platform-brand.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 * What this deployment calls itself (AGL-2153).\n *\n * the rule for the self-host path: *\"the self hosted owner should not have\n * to edit the source to update aglyn branded items urls or personal\n * identification items, should move these to env vars\"*. AGL-2016 did that for\n * the values where getting it wrong misroutes a legal notice. This is the\n * other half — the product name itself, which was a bare `'Aglyn'` literal in\n * nine places.\n *\n * ## Why one module rather than nine reads\n *\n * The literals were not merely duplicated, they were duplicated across a\n * boundary: `PLATFORM_GENERATOR_NAME` exists here AND as a hand-copied\n * constant in `apps/tenant/middleware.ts`, because the edge bundle cannot\n * import this lib. A single source they both read is what stops the copy from\n * drifting into a deployment that half-renames itself.\n *\n * ## Why a default at all, when operator-identity.ts refuses to have one\n *\n * `operator-identity.ts` sets out the rule and it is the right one: a value\n * whose wrongness is invisible to the operator and visible only off-site must\n * not be guessed. The brand is the opposite case. An operator who does not set\n * it sees \"Aglyn\" in their own browser tab on their own first page load, in\n * their own console. That is a default they can see and correct, which is a\n * default rather than a trap — the same test AGL-2022 applied to the tenant\n * apex.\n *\n * ## Why `NEXT_PUBLIC_`, and why dot notation is load-bearing\n *\n * The brand is printed to the public — that is what it is for — so\n * `NEXT_PUBLIC_` carries no disclosure risk. Next inlines `NEXT_PUBLIC_*` into\n * the browser bundle by textually substituting `process.env.NAME`; the\n * **bracket** form is never substituted and reads `undefined` in the browser\n * (AGL-2037). Every read below is dot notation on purpose.\n *\n * Build-time consequence for self-hosters: these are baked in when the image\n * is built, so they must be set *before* `docker compose build`, not merely\n * before `up`. `docs/SELF_HOSTING.md` says so.\n */\n\n/** A non-empty trimmed string, else undefined — `' '` is a half-finished `.env`. */\nconst clean = (value: unknown): string | undefined => {\n const text = typeof value === 'string' ? value.trim() : ''\n return text.length ? text : undefined\n}\n\n/**\n * The product name, as shown to end users.\n *\n * This is the browser-tab affix, the PWA name, the WebAuthn relying-party name\n * the OS credential prompt displays, the `<meta name=\"generator\">` and\n * `x-powered-by` on published sites, and the platform default `productName` /\n * `fromName` every non-white-label org's surfaces resolve to.\n */\nexport const PLATFORM_BRAND_NAME =\n clean(process.env.NEXT_PUBLIC_PLATFORM_BRAND_NAME) ?? 'Aglyn'\n\n/**\n * The legal entity operating the software's own cloud, for copy that needs a\n * company rather than a product (`BRAND.ORG_NAME_LEGAL`).\n *\n * NOT the same thing as `operatorIdentity().name`, and the distinction matters.\n * This is a *brand* string with a visible default; that one is the identity a\n * statutory notice is addressed to and refuses to default. An operator who\n * sets only this has renamed the product, not published who they are.\n */\nexport const PLATFORM_BRAND_LEGAL_NAME =\n clean(process.env.NEXT_PUBLIC_PLATFORM_BRAND_LEGAL_NAME) ??\n `${PLATFORM_BRAND_NAME} LLC`\n\n/**\n * Where a \"need help?\" line points.\n *\n * Falls through the operator's OWN support address before it falls back to\n * ours, deliberately: an operator who followed the runbook has already set\n * `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, and making them discover a second\n * variable to stop pointing their customers at Aglyn's support desk — which\n * cannot help those customers with anything — is a trap dressed as a default.\n *\n * Read here rather than imported from `operator-identity.ts` so this module\n * stays dependency-free and safe to import from anywhere, including the places\n * that cannot take the operator module's weight.\n *\n * ## Why the last fallback is the CONSOLE and not the marketing site\n *\n * It was `aglyn.com/support`, which answers 404 and has never existed\n * (AGL-3262) — MEASURED against the live site, not inferred: `/support`,\n * `/help` and `/docs` all render the not-found page and none is in the\n * sitemap. A support URL that 404s is worse than none, because it is printed\n * on a receipt beside a charge the reader is already unsure about.\n *\n * `app.aglyn.com/support` is a real page (AGL-3265): it resolves the\n * workspace from the session, so a customer with one lands on its support\n * umbrella and a customer with several picks. That is the whole reason it\n * exists — it is the one support address that needs nothing interpolated\n * into it, which is exactly what this constant is.\n *\n * The console is THIS install's (AGL-3322). `/support` is a console route, so\n * every console serves its own, and `NEXT_PUBLIC_CONSOLE_URL` is required of\n * every self-hosted install — so one that configured neither variable above\n * sends its customers to its own support page rather than to ours, where they\n * have no account. The line is in the footer of every system email the\n * platform sends. `app.aglyn.com` is left only for a build that names no\n * console at all.\n */\nexport const PLATFORM_SUPPORT_URL: string = (() => {\n const configured = clean(process.env.NEXT_PUBLIC_PLATFORM_SUPPORT_URL)\n if (configured) return configured\n const operatorEmail = clean(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL)\n if (operatorEmail) return `mailto:${operatorEmail}`\n return `${platformConsoleOrigin()}/support`\n})()\n\n/**\n * This install's console origin, with no trailing slash — what a link in a\n * notification or an email the platform sends is built on (AGL-3080).\n *\n * `NEXT_PUBLIC_CONSOLE_URL`, which every self-hosted install sets; Aglyn's\n * own console only for a build that names none. One reading for the support\n * page above and for every scheduled sender, a plugin's included, so none of\n * them spells the fallback again.\n */\nexport function platformConsoleOrigin(): string {\n const configured = clean(process.env.NEXT_PUBLIC_CONSOLE_URL)?.replace(/\\/+$/, '')\n return configured || 'https://app.aglyn.com'\n}\n\n/** True when this deployment still answers to Aglyn's own brand. */\nexport const isAglynOperatedBrand = (): boolean =>\n PLATFORM_BRAND_NAME === 'Aglyn'\n\n/**\n * Where the platform's own front door is — the marketing home, not the help\n * desk.\n *\n * ## Why this is not `PLATFORM_SUPPORT_URL`\n *\n * The \"Made with …\" badge on a published free-tier site used to link to\n * `supportUrl`, and on this deployment `supportUrl` falls through to\n * `mailto:` + the operator support address. So the one link the platform gets\n * on every free customer's site — its only organic acquisition surface —\n * opened the visitor's mail client addressed to our support desk. A visitor\n * who liked the site and clicked to find out what built it got a blank email,\n * and support got the misdirected replies.\n *\n * They are genuinely different destinations. `supportUrl` answers \"I am a\n * customer and something is wrong\"; this answers \"what is this, and can I have\n * one\". Keeping one value for both guarantees one of them is wrong.\n *\n * ## Why it is null rather than aglyn.com off-brand\n *\n * A self-hosted deployment that renamed the product but set no home URL has\n * nowhere honest to send that visitor: \"Made with Foo\" pointing at aglyn.com\n * advertises the wrong company on Foo's customers' sites. The badge already\n * renders as a plain label when it has no destination (AGL-2428), which is the\n * correct outcome — so the Aglyn default applies only while this really is the\n * Aglyn-operated brand.\n */\nexport const PLATFORM_HOME_URL: string | null =\n clean(process.env.NEXT_PUBLIC_PLATFORM_HOME_URL) ??\n (isAglynOperatedBrand() ? 'https://aglyn.com' : null)\n\n/**\n * The platform's own logo MARK — the compact, square, no-wordmark form — as a\n * URL an `<img>` on a PUBLISHED SITE can load.\n *\n * Site-relative on purpose: every app that renders it serves\n * `public/_static/…` from its own origin, so one path works on\n * `{sub}.aglyn.app` and on a customer's custom domain alike without knowing\n * either. The white variant is the one that survives the badge's dark pill and\n * an org's `primaryColor` behind it.\n *\n * Null unless this is the Aglyn-operated brand, for the same reason as\n * {@link PLATFORM_HOME_URL}: a renamed deployment must not stamp our mark on\n * its customers' sites. Such an operator sets `NEXT_PUBLIC_PLATFORM_MARK_URL`\n * to their own, or gets a text-only badge.\n */\nexport const PLATFORM_MARK_URL: string | null =\n clean(process.env.NEXT_PUBLIC_PLATFORM_MARK_URL) ??\n (isAglynOperatedBrand()\n ? '/_static/images/brand/aglyn-logo-mark-white.svg'\n : null)\n\n/** An absolute `https://` URL, else undefined — anything else has no origin in an inbox. */\nconst httpsUrl = (value: string | undefined): string | undefined => {\n if (!value) return undefined\n try {\n return new URL(value).protocol === 'https:' ? value : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * The platform's logo as its own system email draws it: the header of mail\n * sent under the platform's brand (AGL-3322).\n *\n * ABSOLUTE and https, unlike {@link PLATFORM_MARK_URL}. That mark is a\n * site-relative path because every app that draws it serves the file from its\n * own origin; an inbox has no origin, so a relative path there is a\n * broken-image box. A configured value that is not an absolute `https://` URL\n * is ignored rather than handed on to fail in somebody's inbox.\n *\n * The WORDMARK rather than the square mark: a header is read at a glance, and\n * the wordmark names the sender where the mark would need the reader to\n * already know it.\n *\n * A PNG sized for the slot (AGL-3367): 396×120, three times the 132×40 the\n * header draws it at, about 15 KB, served as a static file off aglyn.com's\n * edge. The master-resolution file behind the media CDN's 60-second cache is\n * four times the bytes and makes the header the last thing in the email to\n * appear. Not an SVG: Gmail and Outlook do not draw SVG in mail, so every\n * recipient on either would get a broken-image box where the logo belongs.\n *\n * Null unless this is the Aglyn-operated brand, for the reason\n * {@link PLATFORM_HOME_URL} is: a renamed deployment must not put our logo at\n * the top of its customers' mail. Such an operator sets\n * `NEXT_PUBLIC_PLATFORM_EMAIL_LOGO_URL`, or gets its product name as the\n * header, in bold text.\n *\n * Deliberately NOT `PLATFORM_BRANDING_PROFILE.emailLogoUrl`, which stays null.\n * That field is an ORG's email logo: a white-label profile inherits it when\n * the org set none, and a send that carries one is read as an org's own mail.\n * Putting the platform's logo there would stamp it on every white-label org\n * that left the field blank.\n */\nexport const PLATFORM_EMAIL_LOGO_URL: string | null =\n httpsUrl(clean(process.env.NEXT_PUBLIC_PLATFORM_EMAIL_LOGO_URL)) ??\n (isAglynOperatedBrand()\n ? 'https://aglyn.com/_static/images/brand/aglyn-email-wordmark.png'\n : null)\n\n/**\n * The postal address printed beside {@link PLATFORM_BRAND_LEGAL_NAME} at the\n * foot of the platform's own system email (AGL-3322).\n *\n * A BRAND string, beside the legal name whose line it completes, rather than\n * a field of `operatorIdentity()`, and the difference is the default. The\n * operator identity is what a statutory notice is addressed to and refuses to\n * guess; this is a line in a footer the operator sees in their own mail and\n * can correct, and for the Aglyn-operated brand the right value is known: the\n * registered agent's address the Aglyn marketing site's email footer prints.\n *\n * Null unless this is the Aglyn-operated brand: a renamed deployment must\n * never print our address as its own. Such an operator sets\n * `NEXT_PUBLIC_PLATFORM_POSTAL_ADDRESS`, or their footer carries the legal\n * name alone. Line breaks in a configured value are folded into commas,\n * because the footer prints the address on one line.\n */\nexport const PLATFORM_POSTAL_ADDRESS: string | null =\n clean(process.env.NEXT_PUBLIC_PLATFORM_POSTAL_ADDRESS)\n ?.split(/\\s*\\n\\s*/)\n .filter(Boolean)\n .join(', ') ??\n (isAglynOperatedBrand()\n ? 'c/o Northwest Registered Agent, LLC, 5900 Balcones Drive STE 100, Austin, TX 78731'\n : null)\n\n/**\n * A branding profile with every field present — what a branded surface\n * renders. Image/color/domain fields are nullable (Aglyn's own surfaces\n * bake those in rather than carry a URL); the remaining text fields always\n * have a value so callers never string-concatenate `undefined`.\n *\n * `supportUrl` is nullable too, and it is the one field where null carries a\n * decision rather than an absence — see {@link resolveBrandingProfile}. A\n * caller must render NOTHING for it, never a placeholder and never a\n * substitute.\n */\nexport interface ResolvedBrandingProfile {\n productName: string\n logoUrl: string | null\n /** The logo for dark grounds; null means {@link logoUrl} serves both. */\n logoDarkUrl: string | null\n faviconUrl: string | null\n /** The favicon for a dark tab strip; null means {@link faviconUrl} serves both. */\n faviconDarkUrl: string | null\n primaryColor: string | null\n supportUrl: string | null\n /**\n * The brand's own FRONT DOOR — where \"Made with <product>\" sends a visitor\n * who wants to know what built the site they are looking at.\n *\n * Deliberately separate from {@link ResolvedBrandingProfile.supportUrl},\n * which the badge used to borrow: a help-desk address is the wrong answer to\n * \"what is this\", and on this deployment it resolves to a `mailto:`, so the\n * badge opened a blank email instead of a web page.\n *\n * Nullable for the same reason `supportUrl` is — a brand with nowhere to\n * send that visitor gets a plain label, never a substitute destination.\n */\n homeUrl: string | null\n fromName: string\n emailLogoUrl: string | null\n customConsoleDomain: string | null\n}\n\n/**\n * The PLATFORM (non-white-label) brand — the fallback every surface gets when\n * an org lacks the `whiteLabel` entitlement, and the gap-filler for a partial\n * agency profile.\n *\n * It lives beside the literals it is assembled from rather than beside the\n * entitlement gate that hands it out, and that placement is load-bearing\n * rather than tidying. A published tenant page renders the attribution badge\n * from this constant and reads nothing else out of `plan-entitlements.ts`;\n * defining it there put that module's whole plan, quota and entitlement table\n * — the single largest first-party module a visitor could reach — into the\n * first load of every anonymous visitor to every customer site.\n * `plan-entitlements.ts` re-exports it, so every existing caller is unchanged.\n *\n * The name says the PLATFORM's brand rather than Aglyn's because on a\n * self-host install that assertion stops being true: which platform this is,\n * is configuration. `resolveBrandingProfile` is the single resolver EVERY\n * branded surface routes through — console chrome, published-site badge and\n * title, transactional email — and this is its fallback, so all of them get a\n * self-host brand without extending the white-label machinery at all.\n */\nexport const PLATFORM_BRANDING_PROFILE: ResolvedBrandingProfile = {\n productName: PLATFORM_BRAND_NAME,\n // The platform's own surfaces still bake their logo in — this is here for\n // the surfaces that CANNOT, which is every surface rendered on somebody\n // else's site. The free-tier attribution badge is the one that exists\n // today, and it carried no mark at all while this was null.\n logoUrl: PLATFORM_MARK_URL,\n logoDarkUrl: null,\n faviconUrl: null,\n faviconDarkUrl: null,\n primaryColor: null,\n supportUrl: PLATFORM_SUPPORT_URL,\n homeUrl: PLATFORM_HOME_URL,\n fromName: PLATFORM_BRAND_NAME,\n emailLogoUrl: null,\n customConsoleDomain: null,\n}\n"],"names":["clean","httpsUrl","value","text","trim","length","undefined","PLATFORM_BRAND_NAME","process","env","NEXT_PUBLIC_PLATFORM_BRAND_NAME","PLATFORM_BRAND_LEGAL_NAME","NEXT_PUBLIC_PLATFORM_BRAND_LEGAL_NAME","PLATFORM_SUPPORT_URL","configured","NEXT_PUBLIC_PLATFORM_SUPPORT_URL","operatorEmail","NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL","platformConsoleOrigin","NEXT_PUBLIC_CONSOLE_URL","replace","isAglynOperatedBrand","PLATFORM_HOME_URL","NEXT_PUBLIC_PLATFORM_HOME_URL","PLATFORM_MARK_URL","NEXT_PUBLIC_PLATFORM_MARK_URL","URL","protocol","PLATFORM_EMAIL_LOGO_URL","NEXT_PUBLIC_PLATFORM_EMAIL_LOGO_URL","PLATFORM_POSTAL_ADDRESS","NEXT_PUBLIC_PLATFORM_POSTAL_ADDRESS","split","filter","Boolean","join","PLATFORM_BRANDING_PROFILE","productName","logoUrl","logoDarkUrl","faviconUrl","faviconDarkUrl","primaryColor","supportUrl","homeUrl","fromName","emailLogoUrl","customConsoleDomain"],"mappings":"IAyEEA,QAYAA,SA4FAA,SAmBAA,SAiDAC;IAuBAD;AA5QF;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GAED,oFAAoF,GACpF,MAAMA,QAAQ,CAACE;IACb,MAAMC,OAAO,OAAOD,UAAU,WAAWA,MAAME,IAAI,KAAK;IACxD,OAAOD,KAAKE,MAAM,GAAGF,OAAOG;AAC9B;AAEA;;;;;;;CAOC,GACD,OAAO,MAAMC,uBACXP,SAAAA,MAAMQ,QAAQC,GAAG,CAACC,+BAA+B,aAAjDV,SAAsD,QAAO;AAE/D;;;;;;;;CAQC,GACD,OAAO,MAAMW,6BACXX,UAAAA,MAAMQ,QAAQC,GAAG,CAACG,qCAAqC,aAAvDZ,UACA,GAAGO,oBAAoB,IAAI,CAAC,CAAA;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GACD,OAAO,MAAMM,uBAA+B,AAAC,CAAA;IAC3C,MAAMC,aAAad,MAAMQ,QAAQC,GAAG,CAACM,gCAAgC;IACrE,IAAID,YAAY,OAAOA;IACvB,MAAME,gBAAgBhB,MAAMQ,QAAQC,GAAG,CAACQ,kCAAkC;IAC1E,IAAID,eAAe,OAAO,CAAC,OAAO,EAAEA,eAAe;IACnD,OAAO,GAAGE,wBAAwB,QAAQ,CAAC;AAC7C,CAAA,IAAI;AAEJ;;;;;;;;CAQC,GACD,OAAO,SAASA;QACKlB;IAAnB,MAAMc,cAAad,SAAAA,MAAMQ,QAAQC,GAAG,CAACU,uBAAuB,sBAAzCnB,OAA4CoB,OAAO,CAAC,QAAQ;IAC/E,OAAON,cAAc;AACvB;AAEA,kEAAkE,GAClE,OAAO,MAAMO,uBAAuB,IAClCd,wBAAwB,QAAO;AAEjC;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,MAAMe,qBACXtB,UAAAA,MAAMQ,QAAQC,GAAG,CAACc,6BAA6B,aAA/CvB,UACCqB,yBAAyB,sBAAsB,KAAK;AAEvD;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMG,qBACXxB,UAAAA,MAAMQ,QAAQC,GAAG,CAACgB,6BAA6B,aAA/CzB,UACCqB,yBACG,oDACA,KAAK;AAEX,0FAA0F,GAC1F,MAAMpB,WAAW,CAACC;IAChB,IAAI,CAACA,OAAO,OAAOI;IACnB,IAAI;QACF,OAAO,IAAIoB,IAAIxB,OAAOyB,QAAQ,KAAK,WAAWzB,QAAQI;IACxD,EAAE,eAAM;QACN,OAAOA;IACT;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgCC,GACD,OAAO,MAAMsB,2BACX3B,YAAAA,SAASD,MAAMQ,QAAQC,GAAG,CAACoB,mCAAmC,cAA9D5B,YACCoB,yBACG,oEACA,KAAK;AAEX;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,MAAMS,mCACX9B,UAAAA,MAAMQ,QAAQC,GAAG,CAACsB,mCAAmC,sBAArD/B,QACIgC,KAAK,CAAC,YACPC,MAAM,CAACC,SACPC,IAAI,CAAC,wBACPd,yBACG,uFACA,KAAK;AAyCX;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAMe,4BAAqD;IAChEC,aAAa9B;IACb,0EAA0E;IAC1E,wEAAwE;IACxE,sEAAsE;IACtE,4DAA4D;IAC5D+B,SAASd;IACTe,aAAa;IACbC,YAAY;IACZC,gBAAgB;IAChBC,cAAc;IACdC,YAAY9B;IACZ+B,SAAStB;IACTuB,UAAUtC;IACVuC,cAAc;IACdC,qBAAqB;AACvB,EAAC"}
@@ -14,11 +14,65 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ /**
18
+ * Marketing consent for the PLATFORM's own product email (AGL-3185).
19
+ *
20
+ * The strict policy (`marketing-consent.ts`) means a person with no declared
21
+ * basis is unsendable, and until this module the only doors that declared
22
+ * one were the marketing site's forms. Somebody who created a console account
23
+ * — the warmest audience the platform has — was never asked. Three console
24
+ * doors ask now: a checkbox on the sign-up form, a switch in the account
25
+ * preferences, and a one-time prompt for accounts that predate the checkbox.
26
+ *
27
+ * ## One wording, versioned
28
+ *
29
+ * Every door shows {@link PLATFORM_MARKETING_CONSENT_TEXT} and every record
30
+ * stores {@link PLATFORM_MARKETING_CONSENT_TEXT_VERSION} beside the exact
31
+ * timestamp, so "what did this person agree to" is answered by a version and
32
+ * not by whatever the checkbox says today. Changing the sentence is a bump of
33
+ * the version in the same edit.
34
+ *
35
+ * ## The record lives in two places, and the two do not disagree
36
+ *
37
+ * The decision is written by the server onto the person's own `users/{uid}`
38
+ * document, so the console can show a preference without reading the
39
+ * operator's CRM, and onto the operator's marketing contact for the same
40
+ * person, so the send path sees a declared basis with provenance. The
41
+ * contact half is written only when the deployment names a marketing host
42
+ * (`PLATFORM_MARKETING_HOST_ID`, read in `@aglyn/tenant-data-admin`); a
43
+ * self-hosted install with no audience of its own records the preference
44
+ * and skips the contact — and keeps asking, so the answers are already on
45
+ * file the day the operator names a marketing host.
46
+ *
47
+ * ## An unsubscribe reaches the account, for its own list only (AGL-3305)
48
+ *
49
+ * The marketing site's emails carry their own way out, and a person who uses
50
+ * it has answered the console's question too — but only if what they left
51
+ * is the list the question was about. Leaving
52
+ * {@link PLATFORM_MARKETING_TOPIC_ID}, or everything the site sends, turns
53
+ * the account's answer to No through an email door; leaving the newsletter
54
+ * leaves it alone. The email doors write the account and nothing else: the
55
+ * site's lists already say what was left, and declining the contact's basis
56
+ * would stop every list, not the one the person chose.
57
+ *
58
+ * ## Absence is not refusal
59
+ *
60
+ * An unticked sign-up checkbox records nothing. A switch turned off and a
61
+ * prompt answered "no" record a refusal, which the strict policy already
62
+ * honors. A prompt dismissed without an answer records only the dismissal,
63
+ * and the prompt stays away for {@link PLATFORM_MARKETING_PROMPT_SNOOZE_MS}.
64
+ *
65
+ * Pure and dependency-light on purpose: the sign-up page renders the text,
66
+ * the console route validates the body, and the server library writes the
67
+ * record, all from this one module.
68
+ */
17
69
  import { type MarketingConsentSource } from './marketing-consent';
18
70
  /**
19
71
  * The list the account's answer stands for on the marketing site (AGL-3305):
20
- * the built-in "Product updates" topic, which is what the platform's own
21
- * announcements are sent on.
72
+ * the built-in "Product updates" stream, which is what the platform's own
73
+ * announcements are sent on. A wire value like every topic id — it keys the
74
+ * opt-outs already recorded on the marketing host — and the same stream the
75
+ * built-in catalog offers every site as "Product updates".
22
76
  *
23
77
  * Named so that "which unsubscribe is about this answer" has one spelling.
24
78
  * Leaving this list, or leaving everything the site sends, is a No to the
@@ -64,20 +64,21 @@
64
64
  * Pure and dependency-light on purpose: the sign-up page renders the text,
65
65
  * the console route validates the body, and the server library writes the
66
66
  * record, all from this one module.
67
- */ import { EMAIL_TOPIC_PRODUCT_UPDATES } from "./email-topics.js";
68
- import { MARKETING_CONSENT_FIELD, MARKETING_CONSENT_SOURCE_FIELD } from "./marketing-consent.js";
67
+ */ import { MARKETING_CONSENT_FIELD, MARKETING_CONSENT_SOURCE_FIELD } from "./marketing-consent.js";
69
68
  import { PLATFORM_BRAND_NAME } from "./platform-brand.js";
70
69
  /**
71
70
  * The list the account's answer stands for on the marketing site (AGL-3305):
72
- * the built-in "Product updates" topic, which is what the platform's own
73
- * announcements are sent on.
71
+ * the built-in "Product updates" stream, which is what the platform's own
72
+ * announcements are sent on. A wire value like every topic id — it keys the
73
+ * opt-outs already recorded on the marketing host — and the same stream the
74
+ * built-in catalog offers every site as "Product updates".
74
75
  *
75
76
  * Named so that "which unsubscribe is about this answer" has one spelling.
76
77
  * Leaving this list, or leaving everything the site sends, is a No to the
77
78
  * question the console asked. Leaving any other list is not, and must not
78
79
  * touch the answer: someone who stops the newsletter has said nothing about
79
80
  * product updates.
80
- */ export const PLATFORM_MARKETING_TOPIC_ID = EMAIL_TOPIC_PRODUCT_UPDATES;
81
+ */ export const PLATFORM_MARKETING_TOPIC_ID = 'product-updates';
81
82
  /** The console doors a decision can come through, as the record names them. */ export const PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS = [
82
83
  'console-signup',
83
84
  'console-preferences',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/platform-marketing-consent.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 * Marketing consent for the PLATFORM's own product email (AGL-3185).\n *\n * The strict policy (`marketing-consent.ts`) means a person with no declared\n * basis is unsendable, and until this module the only doors that declared\n * one were the marketing site's forms. Somebody who created a console account\n * — the warmest audience the platform has — was never asked. Three console\n * doors ask now: a checkbox on the sign-up form, a switch in the account\n * preferences, and a one-time prompt for accounts that predate the checkbox.\n *\n * ## One wording, versioned\n *\n * Every door shows {@link PLATFORM_MARKETING_CONSENT_TEXT} and every record\n * stores {@link PLATFORM_MARKETING_CONSENT_TEXT_VERSION} beside the exact\n * timestamp, so \"what did this person agree to\" is answered by a version and\n * not by whatever the checkbox says today. Changing the sentence is a bump of\n * the version in the same edit.\n *\n * ## The record lives in two places, and the two do not disagree\n *\n * The decision is written by the server onto the person's own `users/{uid}`\n * document, so the console can show a preference without reading the\n * operator's CRM, and onto the operator's marketing contact for the same\n * person, so the send path sees a declared basis with provenance. The\n * contact half is written only when the deployment names a marketing host\n * (`PLATFORM_MARKETING_HOST_ID`, read in `@aglyn/tenant-data-admin`); a\n * self-hosted install with no audience of its own records the preference\n * and skips the contact — and keeps asking, so the answers are already on\n * file the day the operator names a marketing host.\n *\n * ## An unsubscribe reaches the account, for its own list only (AGL-3305)\n *\n * The marketing site's emails carry their own way out, and a person who uses\n * it has answered the console's question too — but only if what they left\n * is the list the question was about. Leaving\n * {@link PLATFORM_MARKETING_TOPIC_ID}, or everything the site sends, turns\n * the account's answer to No through an email door; leaving the newsletter\n * leaves it alone. The email doors write the account and nothing else: the\n * site's lists already say what was left, and declining the contact's basis\n * would stop every list, not the one the person chose.\n *\n * ## Absence is not refusal\n *\n * An unticked sign-up checkbox records nothing. A switch turned off and a\n * prompt answered \"no\" record a refusal, which the strict policy already\n * honors. A prompt dismissed without an answer records only the dismissal,\n * and the prompt stays away for {@link PLATFORM_MARKETING_PROMPT_SNOOZE_MS}.\n *\n * Pure and dependency-light on purpose: the sign-up page renders the text,\n * the console route validates the body, and the server library writes the\n * record, all from this one module.\n */\n\nimport { EMAIL_TOPIC_PRODUCT_UPDATES } from './email-topics'\nimport {\n MARKETING_CONSENT_FIELD,\n MARKETING_CONSENT_SOURCE_FIELD,\n type MarketingConsentSource,\n} from './marketing-consent'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\n\n/**\n * The list the account's answer stands for on the marketing site (AGL-3305):\n * the built-in \"Product updates\" topic, which is what the platform's own\n * announcements are sent on.\n *\n * Named so that \"which unsubscribe is about this answer\" has one spelling.\n * Leaving this list, or leaving everything the site sends, is a No to the\n * question the console asked. Leaving any other list is not, and must not\n * touch the answer: someone who stops the newsletter has said nothing about\n * product updates.\n */\nexport const PLATFORM_MARKETING_TOPIC_ID = EMAIL_TOPIC_PRODUCT_UPDATES\n\n/** The console doors a decision can come through, as the record names them. */\nexport const PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS = [\n 'console-signup',\n 'console-preferences',\n 'console-prompt',\n] as const\n\nexport type PlatformMarketingConsoleSourceKind =\n (typeof PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS)[number]\n\n/**\n * The email doors (AGL-3305): the marketing site's own unsubscribe,\n * preference and resubscribe pages, when what the person changed there was\n * {@link PLATFORM_MARKETING_TOPIC_ID} or everything.\n *\n * Kept apart from the console doors because nothing the console sends may\n * claim one. The server records these from the signed link a recipient\n * followed; a console request naming one is refused, because a click in the\n * console is not a click in a mailbox.\n */\nexport const PLATFORM_MARKETING_EMAIL_SOURCE_KINDS = [\n 'email-unsubscribe',\n 'email-preferences',\n 'email-resubscribe',\n] as const\n\nexport type PlatformMarketingEmailSourceKind =\n (typeof PLATFORM_MARKETING_EMAIL_SOURCE_KINDS)[number]\n\n/** Every door a stored decision can name. */\nexport const PLATFORM_MARKETING_CONSENT_SOURCE_KINDS = [\n ...PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS,\n ...PLATFORM_MARKETING_EMAIL_SOURCE_KINDS,\n] as const\n\nexport type PlatformMarketingConsentSourceKind =\n (typeof PLATFORM_MARKETING_CONSENT_SOURCE_KINDS)[number]\n\n/** Any door, for READING a stored decision. */\nexport function isPlatformMarketingConsentSourceKind(\n value: unknown,\n): value is PlatformMarketingConsentSourceKind {\n return (PLATFORM_MARKETING_CONSENT_SOURCE_KINDS as readonly unknown[]).includes(\n value,\n )\n}\n\n/** A console door: the only kind a console request may record. */\nexport function isPlatformMarketingConsoleSourceKind(\n value: unknown,\n): value is PlatformMarketingConsoleSourceKind {\n return (PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS as readonly unknown[]).includes(\n value,\n )\n}\n\n/** An email door: a decision made from a link in one of the site's emails. */\nexport function isPlatformMarketingEmailSourceKind(\n value: unknown,\n): value is PlatformMarketingEmailSourceKind {\n return (PLATFORM_MARKETING_EMAIL_SOURCE_KINDS as readonly unknown[]).includes(\n value,\n )\n}\n\n/** What a door records: a grant or a refusal. Absence is never recorded. */\nexport const PLATFORM_MARKETING_CONSENT_DECISIONS = ['granted', 'declined'] as const\n\nexport type PlatformMarketingConsentDecision =\n (typeof PLATFORM_MARKETING_CONSENT_DECISIONS)[number]\n\nexport function isPlatformMarketingConsentDecision(\n value: unknown,\n): value is PlatformMarketingConsentDecision {\n return (PLATFORM_MARKETING_CONSENT_DECISIONS as readonly unknown[]).includes(\n value,\n )\n}\n\n/**\n * The version of the consent wording every door shows. Bumped in the same\n * edit as {@link platformMarketingConsentText}; a record stores the version\n * it was shown under, so the two must move together.\n */\nexport const PLATFORM_MARKETING_CONSENT_TEXT_VERSION = '2026-09-20'\n\n/**\n * The sentence the person agrees to, with the deployment's brand in it.\n *\n * Affirmative and standalone: it names what is sent, who sends it, and that\n * it can be stopped — never bundled with the terms acceptance, and never\n * pre-ticked anywhere it is rendered.\n */\nexport function platformMarketingConsentText(brandName: string): string {\n return `Send me product updates from ${brandName}. You can opt out any time.`\n}\n\n/** {@link platformMarketingConsentText} for the brand this deployment wears. */\nexport const PLATFORM_MARKETING_CONSENT_TEXT = platformMarketingConsentText(\n PLATFORM_BRAND_NAME,\n)\n\n/**\n * The `users/{uid}` fields the decision is mirrored onto.\n *\n * The same names the contact record uses for the same facts, so one reader\n * can parse either document. The prompt-dismissal stamp has no contact\n * counterpart: it is about what the console asked, not what the person\n * agreed to.\n */\nexport const USER_MARKETING_CONSENT_FIELD = MARKETING_CONSENT_FIELD\nexport const USER_MARKETING_CONSENT_AT_FIELD = 'marketingConsentAtMs'\nexport const USER_MARKETING_CONSENT_SOURCE_FIELD = MARKETING_CONSENT_SOURCE_FIELD\nexport const USER_MARKETING_PROMPT_DISMISSED_AT_FIELD =\n 'marketingConsentPromptDismissedAtMs'\n\n/** How long a dismissed prompt stays away: ninety days. */\nexport const PLATFORM_MARKETING_PROMPT_SNOOZE_MS = 90 * 24 * 60 * 60 * 1000\n\n/** What the person's own document says about product email. */\nexport interface PlatformMarketingConsentState {\n /** `null` when nothing has been recorded — asked, but not answered. */\n decision: PlatformMarketingConsentDecision | null\n /** When the decision was recorded. */\n atMs: number | null\n /** The wording version the decision was made under. */\n textVersion: string | null\n /**\n * Which door recorded it — a console door, or an email door for a decision\n * mirrored from the marketing site's own pages. `null` for a record another\n * writer left.\n */\n sourceKind: PlatformMarketingConsentSourceKind | null\n /** When the prompt was last dismissed without an answer. */\n promptDismissedAtMs: number | null\n}\n\nfunction finiteOrNull(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * Reads the decision off a `users/{uid}` document, or off no document.\n *\n * Every unusable value reads as its absent case rather than throwing: a\n * malformed field must degrade to \"nothing recorded\", which asks again,\n * never to a grant.\n */\nexport function readPlatformMarketingConsent(\n document: Record<string, unknown> | null | undefined,\n): PlatformMarketingConsentState {\n const consent = document?.[USER_MARKETING_CONSENT_FIELD]\n const rawSource = document?.[USER_MARKETING_CONSENT_SOURCE_FIELD]\n const source =\n rawSource && typeof rawSource === 'object' && !Array.isArray(rawSource)\n ? (rawSource as Record<string, unknown>)\n : null\n const textVersion = source?.['textVersion']\n return {\n decision: consent === true ? 'granted' : consent === false ? 'declined' : null,\n atMs: finiteOrNull(document?.[USER_MARKETING_CONSENT_AT_FIELD]),\n textVersion:\n typeof textVersion === 'string' && textVersion ? textVersion : null,\n sourceKind: isPlatformMarketingConsentSourceKind(source?.['kind'])\n ? source['kind']\n : null,\n promptDismissedAtMs: finiteOrNull(\n document?.[USER_MARKETING_PROMPT_DISMISSED_AT_FIELD],\n ),\n }\n}\n\n/**\n * Whether the console should ask this person now.\n *\n * Never after a decision, in either direction: a \"no\" is an answer and a\n * \"yes\" needs no repeating. A dismissal is not an answer, so the prompt\n * returns once the snooze has run out.\n */\nexport function platformMarketingPromptDue(\n state: Pick<PlatformMarketingConsentState, 'decision' | 'promptDismissedAtMs'>,\n nowMs: number,\n): boolean {\n if (state.decision !== null) return false\n if (state.promptDismissedAtMs === null) return true\n return nowMs - state.promptDismissedAtMs >= PLATFORM_MARKETING_PROMPT_SNOOZE_MS\n}\n\n/**\n * What each door did, in prose, for whoever audits the record later.\n *\n * Keyed by door and decision so the sentence is decided once, here, and the\n * three doors cannot describe the same act three different ways.\n */\nconst PROVENANCE_REASONS: Record<\n PlatformMarketingConsoleSourceKind,\n Record<PlatformMarketingConsentDecision, string>\n> = {\n 'console-signup': {\n granted: 'Ticked the product-updates checkbox on the console sign-up form.',\n declined: 'Declined product updates on the console sign-up form.',\n },\n 'console-preferences': {\n granted: 'Turned product updates on in the console account preferences.',\n declined: 'Turned product updates off in the console account preferences.',\n },\n 'console-prompt': {\n granted: 'Answered yes to the product-updates prompt in the console.',\n declined: 'Answered no to the product-updates prompt in the console.',\n },\n}\n\n/**\n * The provenance stored with a console decision, on both documents.\n *\n * `actor: 'person'` is what keeps {@link MarketingConsentSource} honest here:\n * the reader treats provenance as an operator's assertion unless it says\n * otherwise, and this is the person's own click, carrying which door and\n * which wording. `by` is the account that clicked — the subject and the\n * actor are the same human, which is the fact the field records.\n */\nexport function platformMarketingConsentSource(input: {\n kind: PlatformMarketingConsoleSourceKind\n decision: PlatformMarketingConsentDecision\n uid: string\n atMs: number\n textVersion: string\n}): MarketingConsentSource {\n return {\n kind: input.kind,\n by: input.uid,\n atMs: input.atMs,\n reason: PROVENANCE_REASONS[input.kind][input.decision],\n textVersion: input.textVersion,\n actor: 'person',\n }\n}\n\n/**\n * What an email door did, and which decisions each door can make.\n *\n * The pairs are the whole vocabulary: an unsubscribe only ever refuses, a\n * resubscribe only ever restores, and only the preference page does both.\n * Typed as the union rather than a free kind and decision, so an\n * \"unsubscribe that granted\" cannot be written down at all.\n */\nexport type PlatformMarketingEmailDecision =\n | { kind: 'email-unsubscribe'; decision: 'declined' }\n | { kind: 'email-preferences'; decision: PlatformMarketingConsentDecision }\n | { kind: 'email-resubscribe'; decision: 'granted' }\n\nconst EMAIL_PROVENANCE_REASONS: {\n [K in PlatformMarketingEmailDecision as `${K['kind']}:${K['decision']}`]: string\n} = {\n 'email-unsubscribe:declined':\n 'Unsubscribed from every email the marketing site sends, from a link in one of its emails.',\n 'email-preferences:declined':\n 'Left product updates on the marketing site’s email preference page.',\n 'email-preferences:granted':\n 'Chose product updates again on the marketing site’s email preference page.',\n 'email-resubscribe:granted':\n 'Resubscribed from the link in one of the marketing site’s emails.',\n}\n\n/**\n * The provenance stored with a decision mirrored from the marketing site's\n * own pages (AGL-3305), on the account only.\n *\n * Still `actor: 'person'`: the click was the recipient's, on a link signed\n * for their address. No `textVersion`, because those pages do not version\n * their wording; the field is absent rather than borrowing the console's\n * version for a sentence the person never saw.\n */\nexport function platformMarketingEmailSource(\n input: PlatformMarketingEmailDecision & { uid: string; atMs: number },\n): MarketingConsentSource {\n return {\n kind: input.kind,\n by: input.uid,\n atMs: input.atMs,\n // The union narrows the pair; the template type cannot see that, so the\n // key is asserted to the table's own keys rather than widened to string.\n reason: EMAIL_PROVENANCE_REASONS[\n `${input.kind}:${input.decision}` as keyof typeof EMAIL_PROVENANCE_REASONS\n ],\n actor: 'person',\n }\n}\n\n/**\n * The `users/{uid}` fields a decision writes, as one merge-able object.\n *\n * `false` for a refusal, not a deleted field: absence is the third state and\n * means \"never asked\", which a person who said no must never read as again.\n */\nexport function platformMarketingUserFields(input: {\n decision: PlatformMarketingConsentDecision\n atMs: number\n source: MarketingConsentSource\n}): Record<string, unknown> {\n return {\n [USER_MARKETING_CONSENT_FIELD]: input.decision === 'granted',\n [USER_MARKETING_CONSENT_AT_FIELD]: input.atMs,\n [USER_MARKETING_CONSENT_SOURCE_FIELD]: input.source,\n }\n}\n"],"names":["EMAIL_TOPIC_PRODUCT_UPDATES","MARKETING_CONSENT_FIELD","MARKETING_CONSENT_SOURCE_FIELD","PLATFORM_BRAND_NAME","PLATFORM_MARKETING_TOPIC_ID","PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS","PLATFORM_MARKETING_EMAIL_SOURCE_KINDS","PLATFORM_MARKETING_CONSENT_SOURCE_KINDS","isPlatformMarketingConsentSourceKind","value","includes","isPlatformMarketingConsoleSourceKind","isPlatformMarketingEmailSourceKind","PLATFORM_MARKETING_CONSENT_DECISIONS","isPlatformMarketingConsentDecision","PLATFORM_MARKETING_CONSENT_TEXT_VERSION","platformMarketingConsentText","brandName","PLATFORM_MARKETING_CONSENT_TEXT","USER_MARKETING_CONSENT_FIELD","USER_MARKETING_CONSENT_AT_FIELD","USER_MARKETING_CONSENT_SOURCE_FIELD","USER_MARKETING_PROMPT_DISMISSED_AT_FIELD","PLATFORM_MARKETING_PROMPT_SNOOZE_MS","finiteOrNull","Number","isFinite","readPlatformMarketingConsent","document","consent","rawSource","source","Array","isArray","textVersion","decision","atMs","sourceKind","promptDismissedAtMs","platformMarketingPromptDue","state","nowMs","PROVENANCE_REASONS","granted","declined","platformMarketingConsentSource","input","kind","by","uid","reason","actor","EMAIL_PROVENANCE_REASONS","platformMarketingEmailSource","platformMarketingUserFields"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmDC,GAED,SAASA,2BAA2B,QAAQ,oBAAgB;AAC5D,SACEC,uBAAuB,EACvBC,8BAA8B,QAEzB,yBAAqB;AAC5B,SAASC,mBAAmB,QAAQ,sBAAkB;AAEtD;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,8BAA8BJ,4BAA2B;AAEtE,6EAA6E,GAC7E,OAAO,MAAMK,0CAA0C;IACrD;IACA;IACA;CACD,CAAS;AAKV;;;;;;;;;CASC,GACD,OAAO,MAAMC,wCAAwC;IACnD;IACA;IACA;CACD,CAAS;AAKV,2CAA2C,GAC3C,OAAO,MAAMC,0CAA0C;OAClDF;OACAC;CACJ,CAAS;AAKV,6CAA6C,GAC7C,OAAO,SAASE,qCACdC,KAAc;IAEd,OAAO,AAACF,wCAA+DG,QAAQ,CAC7ED;AAEJ;AAEA,gEAAgE,GAChE,OAAO,SAASE,qCACdF,KAAc;IAEd,OAAO,AAACJ,wCAA+DK,QAAQ,CAC7ED;AAEJ;AAEA,4EAA4E,GAC5E,OAAO,SAASG,mCACdH,KAAc;IAEd,OAAO,AAACH,sCAA6DI,QAAQ,CAC3ED;AAEJ;AAEA,0EAA0E,GAC1E,OAAO,MAAMI,uCAAuC;IAAC;IAAW;CAAW,CAAS;AAKpF,OAAO,SAASC,mCACdL,KAAc;IAEd,OAAO,AAACI,qCAA4DH,QAAQ,CAC1ED;AAEJ;AAEA;;;;CAIC,GACD,OAAO,MAAMM,0CAA0C,aAAY;AAEnE;;;;;;CAMC,GACD,OAAO,SAASC,6BAA6BC,SAAiB;IAC5D,OAAO,CAAC,6BAA6B,EAAEA,UAAU,2BAA2B,CAAC;AAC/E;AAEA,8EAA8E,GAC9E,OAAO,MAAMC,kCAAkCF,6BAC7Cb,qBACD;AAED;;;;;;;CAOC,GACD,OAAO,MAAMgB,+BAA+BlB,wBAAuB;AACnE,OAAO,MAAMmB,kCAAkC,uBAAsB;AACrE,OAAO,MAAMC,sCAAsCnB,+BAA8B;AACjF,OAAO,MAAMoB,2CACX,sCAAqC;AAEvC,yDAAyD,GACzD,OAAO,MAAMC,sCAAsC,KAAK,KAAK,KAAK,KAAK,KAAI;AAoB3E,SAASC,aAAaf,KAAc;IAClC,OAAO,OAAOA,UAAU,YAAYgB,OAAOC,QAAQ,CAACjB,SAASA,QAAQ;AACvE;AAEA;;;;;;CAMC,GACD,OAAO,SAASkB,6BACdC,QAAoD;IAEpD,MAAMC,UAAUD,4BAAAA,QAAU,CAACT,6BAA6B;IACxD,MAAMW,YAAYF,4BAAAA,QAAU,CAACP,oCAAoC;IACjE,MAAMU,SACJD,aAAa,OAAOA,cAAc,YAAY,CAACE,MAAMC,OAAO,CAACH,aACxDA,YACD;IACN,MAAMI,cAAcH,0BAAAA,MAAQ,CAAC,cAAc;IAC3C,OAAO;QACLI,UAAUN,YAAY,OAAO,YAAYA,YAAY,QAAQ,aAAa;QAC1EO,MAAMZ,aAAaI,4BAAAA,QAAU,CAACR,gCAAgC;QAC9Dc,aACE,OAAOA,gBAAgB,YAAYA,cAAcA,cAAc;QACjEG,YAAY7B,qCAAqCuB,0BAAAA,MAAQ,CAAC,OAAO,IAC7DA,MAAM,CAAC,OAAO,GACd;QACJO,qBAAqBd,aACnBI,4BAAAA,QAAU,CAACN,yCAAyC;IAExD;AACF;AAEA;;;;;;CAMC,GACD,OAAO,SAASiB,2BACdC,KAA8E,EAC9EC,KAAa;IAEb,IAAID,MAAML,QAAQ,KAAK,MAAM,OAAO;IACpC,IAAIK,MAAMF,mBAAmB,KAAK,MAAM,OAAO;IAC/C,OAAOG,QAAQD,MAAMF,mBAAmB,IAAIf;AAC9C;AAEA;;;;;CAKC,GACD,MAAMmB,qBAGF;IACF,kBAAkB;QAChBC,SAAS;QACTC,UAAU;IACZ;IACA,uBAAuB;QACrBD,SAAS;QACTC,UAAU;IACZ;IACA,kBAAkB;QAChBD,SAAS;QACTC,UAAU;IACZ;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,+BAA+BC,KAM9C;IACC,OAAO;QACLC,MAAMD,MAAMC,IAAI;QAChBC,IAAIF,MAAMG,GAAG;QACbb,MAAMU,MAAMV,IAAI;QAChBc,QAAQR,kBAAkB,CAACI,MAAMC,IAAI,CAAC,CAACD,MAAMX,QAAQ,CAAC;QACtDD,aAAaY,MAAMZ,WAAW;QAC9BiB,OAAO;IACT;AACF;AAeA,MAAMC,2BAEF;IACF,8BACE;IACF,8BACE;IACF,6BACE;IACF,6BACE;AACJ;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,6BACdP,KAAqE;IAErE,OAAO;QACLC,MAAMD,MAAMC,IAAI;QAChBC,IAAIF,MAAMG,GAAG;QACbb,MAAMU,MAAMV,IAAI;QAChB,wEAAwE;QACxE,yEAAyE;QACzEc,QAAQE,wBAAwB,CAC9B,GAAGN,MAAMC,IAAI,CAAC,CAAC,EAAED,MAAMX,QAAQ,EAAE,CAClC;QACDgB,OAAO;IACT;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASG,4BAA4BR,KAI3C;IACC,OAAO;QACL,CAAC3B,6BAA6B,EAAE2B,MAAMX,QAAQ,KAAK;QACnD,CAACf,gCAAgC,EAAE0B,MAAMV,IAAI;QAC7C,CAACf,oCAAoC,EAAEyB,MAAMf,MAAM;IACrD;AACF"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/platform-marketing-consent.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 * Marketing consent for the PLATFORM's own product email (AGL-3185).\n *\n * The strict policy (`marketing-consent.ts`) means a person with no declared\n * basis is unsendable, and until this module the only doors that declared\n * one were the marketing site's forms. Somebody who created a console account\n * — the warmest audience the platform has — was never asked. Three console\n * doors ask now: a checkbox on the sign-up form, a switch in the account\n * preferences, and a one-time prompt for accounts that predate the checkbox.\n *\n * ## One wording, versioned\n *\n * Every door shows {@link PLATFORM_MARKETING_CONSENT_TEXT} and every record\n * stores {@link PLATFORM_MARKETING_CONSENT_TEXT_VERSION} beside the exact\n * timestamp, so \"what did this person agree to\" is answered by a version and\n * not by whatever the checkbox says today. Changing the sentence is a bump of\n * the version in the same edit.\n *\n * ## The record lives in two places, and the two do not disagree\n *\n * The decision is written by the server onto the person's own `users/{uid}`\n * document, so the console can show a preference without reading the\n * operator's CRM, and onto the operator's marketing contact for the same\n * person, so the send path sees a declared basis with provenance. The\n * contact half is written only when the deployment names a marketing host\n * (`PLATFORM_MARKETING_HOST_ID`, read in `@aglyn/tenant-data-admin`); a\n * self-hosted install with no audience of its own records the preference\n * and skips the contact — and keeps asking, so the answers are already on\n * file the day the operator names a marketing host.\n *\n * ## An unsubscribe reaches the account, for its own list only (AGL-3305)\n *\n * The marketing site's emails carry their own way out, and a person who uses\n * it has answered the console's question too — but only if what they left\n * is the list the question was about. Leaving\n * {@link PLATFORM_MARKETING_TOPIC_ID}, or everything the site sends, turns\n * the account's answer to No through an email door; leaving the newsletter\n * leaves it alone. The email doors write the account and nothing else: the\n * site's lists already say what was left, and declining the contact's basis\n * would stop every list, not the one the person chose.\n *\n * ## Absence is not refusal\n *\n * An unticked sign-up checkbox records nothing. A switch turned off and a\n * prompt answered \"no\" record a refusal, which the strict policy already\n * honors. A prompt dismissed without an answer records only the dismissal,\n * and the prompt stays away for {@link PLATFORM_MARKETING_PROMPT_SNOOZE_MS}.\n *\n * Pure and dependency-light on purpose: the sign-up page renders the text,\n * the console route validates the body, and the server library writes the\n * record, all from this one module.\n */\n\nimport {\n MARKETING_CONSENT_FIELD,\n MARKETING_CONSENT_SOURCE_FIELD,\n type MarketingConsentSource,\n} from './marketing-consent'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\n\n/**\n * The list the account's answer stands for on the marketing site (AGL-3305):\n * the built-in \"Product updates\" stream, which is what the platform's own\n * announcements are sent on. A wire value like every topic id — it keys the\n * opt-outs already recorded on the marketing host — and the same stream the\n * built-in catalog offers every site as \"Product updates\".\n *\n * Named so that \"which unsubscribe is about this answer\" has one spelling.\n * Leaving this list, or leaving everything the site sends, is a No to the\n * question the console asked. Leaving any other list is not, and must not\n * touch the answer: someone who stops the newsletter has said nothing about\n * product updates.\n */\nexport const PLATFORM_MARKETING_TOPIC_ID = 'product-updates'\n\n/** The console doors a decision can come through, as the record names them. */\nexport const PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS = [\n 'console-signup',\n 'console-preferences',\n 'console-prompt',\n] as const\n\nexport type PlatformMarketingConsoleSourceKind =\n (typeof PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS)[number]\n\n/**\n * The email doors (AGL-3305): the marketing site's own unsubscribe,\n * preference and resubscribe pages, when what the person changed there was\n * {@link PLATFORM_MARKETING_TOPIC_ID} or everything.\n *\n * Kept apart from the console doors because nothing the console sends may\n * claim one. The server records these from the signed link a recipient\n * followed; a console request naming one is refused, because a click in the\n * console is not a click in a mailbox.\n */\nexport const PLATFORM_MARKETING_EMAIL_SOURCE_KINDS = [\n 'email-unsubscribe',\n 'email-preferences',\n 'email-resubscribe',\n] as const\n\nexport type PlatformMarketingEmailSourceKind =\n (typeof PLATFORM_MARKETING_EMAIL_SOURCE_KINDS)[number]\n\n/** Every door a stored decision can name. */\nexport const PLATFORM_MARKETING_CONSENT_SOURCE_KINDS = [\n ...PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS,\n ...PLATFORM_MARKETING_EMAIL_SOURCE_KINDS,\n] as const\n\nexport type PlatformMarketingConsentSourceKind =\n (typeof PLATFORM_MARKETING_CONSENT_SOURCE_KINDS)[number]\n\n/** Any door, for READING a stored decision. */\nexport function isPlatformMarketingConsentSourceKind(\n value: unknown,\n): value is PlatformMarketingConsentSourceKind {\n return (PLATFORM_MARKETING_CONSENT_SOURCE_KINDS as readonly unknown[]).includes(\n value,\n )\n}\n\n/** A console door: the only kind a console request may record. */\nexport function isPlatformMarketingConsoleSourceKind(\n value: unknown,\n): value is PlatformMarketingConsoleSourceKind {\n return (PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS as readonly unknown[]).includes(\n value,\n )\n}\n\n/** An email door: a decision made from a link in one of the site's emails. */\nexport function isPlatformMarketingEmailSourceKind(\n value: unknown,\n): value is PlatformMarketingEmailSourceKind {\n return (PLATFORM_MARKETING_EMAIL_SOURCE_KINDS as readonly unknown[]).includes(\n value,\n )\n}\n\n/** What a door records: a grant or a refusal. Absence is never recorded. */\nexport const PLATFORM_MARKETING_CONSENT_DECISIONS = ['granted', 'declined'] as const\n\nexport type PlatformMarketingConsentDecision =\n (typeof PLATFORM_MARKETING_CONSENT_DECISIONS)[number]\n\nexport function isPlatformMarketingConsentDecision(\n value: unknown,\n): value is PlatformMarketingConsentDecision {\n return (PLATFORM_MARKETING_CONSENT_DECISIONS as readonly unknown[]).includes(\n value,\n )\n}\n\n/**\n * The version of the consent wording every door shows. Bumped in the same\n * edit as {@link platformMarketingConsentText}; a record stores the version\n * it was shown under, so the two must move together.\n */\nexport const PLATFORM_MARKETING_CONSENT_TEXT_VERSION = '2026-09-20'\n\n/**\n * The sentence the person agrees to, with the deployment's brand in it.\n *\n * Affirmative and standalone: it names what is sent, who sends it, and that\n * it can be stopped — never bundled with the terms acceptance, and never\n * pre-ticked anywhere it is rendered.\n */\nexport function platformMarketingConsentText(brandName: string): string {\n return `Send me product updates from ${brandName}. You can opt out any time.`\n}\n\n/** {@link platformMarketingConsentText} for the brand this deployment wears. */\nexport const PLATFORM_MARKETING_CONSENT_TEXT = platformMarketingConsentText(\n PLATFORM_BRAND_NAME,\n)\n\n/**\n * The `users/{uid}` fields the decision is mirrored onto.\n *\n * The same names the contact record uses for the same facts, so one reader\n * can parse either document. The prompt-dismissal stamp has no contact\n * counterpart: it is about what the console asked, not what the person\n * agreed to.\n */\nexport const USER_MARKETING_CONSENT_FIELD = MARKETING_CONSENT_FIELD\nexport const USER_MARKETING_CONSENT_AT_FIELD = 'marketingConsentAtMs'\nexport const USER_MARKETING_CONSENT_SOURCE_FIELD = MARKETING_CONSENT_SOURCE_FIELD\nexport const USER_MARKETING_PROMPT_DISMISSED_AT_FIELD =\n 'marketingConsentPromptDismissedAtMs'\n\n/** How long a dismissed prompt stays away: ninety days. */\nexport const PLATFORM_MARKETING_PROMPT_SNOOZE_MS = 90 * 24 * 60 * 60 * 1000\n\n/** What the person's own document says about product email. */\nexport interface PlatformMarketingConsentState {\n /** `null` when nothing has been recorded — asked, but not answered. */\n decision: PlatformMarketingConsentDecision | null\n /** When the decision was recorded. */\n atMs: number | null\n /** The wording version the decision was made under. */\n textVersion: string | null\n /**\n * Which door recorded it — a console door, or an email door for a decision\n * mirrored from the marketing site's own pages. `null` for a record another\n * writer left.\n */\n sourceKind: PlatformMarketingConsentSourceKind | null\n /** When the prompt was last dismissed without an answer. */\n promptDismissedAtMs: number | null\n}\n\nfunction finiteOrNull(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * Reads the decision off a `users/{uid}` document, or off no document.\n *\n * Every unusable value reads as its absent case rather than throwing: a\n * malformed field must degrade to \"nothing recorded\", which asks again,\n * never to a grant.\n */\nexport function readPlatformMarketingConsent(\n document: Record<string, unknown> | null | undefined,\n): PlatformMarketingConsentState {\n const consent = document?.[USER_MARKETING_CONSENT_FIELD]\n const rawSource = document?.[USER_MARKETING_CONSENT_SOURCE_FIELD]\n const source =\n rawSource && typeof rawSource === 'object' && !Array.isArray(rawSource)\n ? (rawSource as Record<string, unknown>)\n : null\n const textVersion = source?.['textVersion']\n return {\n decision: consent === true ? 'granted' : consent === false ? 'declined' : null,\n atMs: finiteOrNull(document?.[USER_MARKETING_CONSENT_AT_FIELD]),\n textVersion:\n typeof textVersion === 'string' && textVersion ? textVersion : null,\n sourceKind: isPlatformMarketingConsentSourceKind(source?.['kind'])\n ? source['kind']\n : null,\n promptDismissedAtMs: finiteOrNull(\n document?.[USER_MARKETING_PROMPT_DISMISSED_AT_FIELD],\n ),\n }\n}\n\n/**\n * Whether the console should ask this person now.\n *\n * Never after a decision, in either direction: a \"no\" is an answer and a\n * \"yes\" needs no repeating. A dismissal is not an answer, so the prompt\n * returns once the snooze has run out.\n */\nexport function platformMarketingPromptDue(\n state: Pick<PlatformMarketingConsentState, 'decision' | 'promptDismissedAtMs'>,\n nowMs: number,\n): boolean {\n if (state.decision !== null) return false\n if (state.promptDismissedAtMs === null) return true\n return nowMs - state.promptDismissedAtMs >= PLATFORM_MARKETING_PROMPT_SNOOZE_MS\n}\n\n/**\n * What each door did, in prose, for whoever audits the record later.\n *\n * Keyed by door and decision so the sentence is decided once, here, and the\n * three doors cannot describe the same act three different ways.\n */\nconst PROVENANCE_REASONS: Record<\n PlatformMarketingConsoleSourceKind,\n Record<PlatformMarketingConsentDecision, string>\n> = {\n 'console-signup': {\n granted: 'Ticked the product-updates checkbox on the console sign-up form.',\n declined: 'Declined product updates on the console sign-up form.',\n },\n 'console-preferences': {\n granted: 'Turned product updates on in the console account preferences.',\n declined: 'Turned product updates off in the console account preferences.',\n },\n 'console-prompt': {\n granted: 'Answered yes to the product-updates prompt in the console.',\n declined: 'Answered no to the product-updates prompt in the console.',\n },\n}\n\n/**\n * The provenance stored with a console decision, on both documents.\n *\n * `actor: 'person'` is what keeps {@link MarketingConsentSource} honest here:\n * the reader treats provenance as an operator's assertion unless it says\n * otherwise, and this is the person's own click, carrying which door and\n * which wording. `by` is the account that clicked — the subject and the\n * actor are the same human, which is the fact the field records.\n */\nexport function platformMarketingConsentSource(input: {\n kind: PlatformMarketingConsoleSourceKind\n decision: PlatformMarketingConsentDecision\n uid: string\n atMs: number\n textVersion: string\n}): MarketingConsentSource {\n return {\n kind: input.kind,\n by: input.uid,\n atMs: input.atMs,\n reason: PROVENANCE_REASONS[input.kind][input.decision],\n textVersion: input.textVersion,\n actor: 'person',\n }\n}\n\n/**\n * What an email door did, and which decisions each door can make.\n *\n * The pairs are the whole vocabulary: an unsubscribe only ever refuses, a\n * resubscribe only ever restores, and only the preference page does both.\n * Typed as the union rather than a free kind and decision, so an\n * \"unsubscribe that granted\" cannot be written down at all.\n */\nexport type PlatformMarketingEmailDecision =\n | { kind: 'email-unsubscribe'; decision: 'declined' }\n | { kind: 'email-preferences'; decision: PlatformMarketingConsentDecision }\n | { kind: 'email-resubscribe'; decision: 'granted' }\n\nconst EMAIL_PROVENANCE_REASONS: {\n [K in PlatformMarketingEmailDecision as `${K['kind']}:${K['decision']}`]: string\n} = {\n 'email-unsubscribe:declined':\n 'Unsubscribed from every email the marketing site sends, from a link in one of its emails.',\n 'email-preferences:declined':\n 'Left product updates on the marketing site’s email preference page.',\n 'email-preferences:granted':\n 'Chose product updates again on the marketing site’s email preference page.',\n 'email-resubscribe:granted':\n 'Resubscribed from the link in one of the marketing site’s emails.',\n}\n\n/**\n * The provenance stored with a decision mirrored from the marketing site's\n * own pages (AGL-3305), on the account only.\n *\n * Still `actor: 'person'`: the click was the recipient's, on a link signed\n * for their address. No `textVersion`, because those pages do not version\n * their wording; the field is absent rather than borrowing the console's\n * version for a sentence the person never saw.\n */\nexport function platformMarketingEmailSource(\n input: PlatformMarketingEmailDecision & { uid: string; atMs: number },\n): MarketingConsentSource {\n return {\n kind: input.kind,\n by: input.uid,\n atMs: input.atMs,\n // The union narrows the pair; the template type cannot see that, so the\n // key is asserted to the table's own keys rather than widened to string.\n reason: EMAIL_PROVENANCE_REASONS[\n `${input.kind}:${input.decision}` as keyof typeof EMAIL_PROVENANCE_REASONS\n ],\n actor: 'person',\n }\n}\n\n/**\n * The `users/{uid}` fields a decision writes, as one merge-able object.\n *\n * `false` for a refusal, not a deleted field: absence is the third state and\n * means \"never asked\", which a person who said no must never read as again.\n */\nexport function platformMarketingUserFields(input: {\n decision: PlatformMarketingConsentDecision\n atMs: number\n source: MarketingConsentSource\n}): Record<string, unknown> {\n return {\n [USER_MARKETING_CONSENT_FIELD]: input.decision === 'granted',\n [USER_MARKETING_CONSENT_AT_FIELD]: input.atMs,\n [USER_MARKETING_CONSENT_SOURCE_FIELD]: input.source,\n }\n}\n"],"names":["MARKETING_CONSENT_FIELD","MARKETING_CONSENT_SOURCE_FIELD","PLATFORM_BRAND_NAME","PLATFORM_MARKETING_TOPIC_ID","PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS","PLATFORM_MARKETING_EMAIL_SOURCE_KINDS","PLATFORM_MARKETING_CONSENT_SOURCE_KINDS","isPlatformMarketingConsentSourceKind","value","includes","isPlatformMarketingConsoleSourceKind","isPlatformMarketingEmailSourceKind","PLATFORM_MARKETING_CONSENT_DECISIONS","isPlatformMarketingConsentDecision","PLATFORM_MARKETING_CONSENT_TEXT_VERSION","platformMarketingConsentText","brandName","PLATFORM_MARKETING_CONSENT_TEXT","USER_MARKETING_CONSENT_FIELD","USER_MARKETING_CONSENT_AT_FIELD","USER_MARKETING_CONSENT_SOURCE_FIELD","USER_MARKETING_PROMPT_DISMISSED_AT_FIELD","PLATFORM_MARKETING_PROMPT_SNOOZE_MS","finiteOrNull","Number","isFinite","readPlatformMarketingConsent","document","consent","rawSource","source","Array","isArray","textVersion","decision","atMs","sourceKind","promptDismissedAtMs","platformMarketingPromptDue","state","nowMs","PROVENANCE_REASONS","granted","declined","platformMarketingConsentSource","input","kind","by","uid","reason","actor","EMAIL_PROVENANCE_REASONS","platformMarketingEmailSource","platformMarketingUserFields"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmDC,GAED,SACEA,uBAAuB,EACvBC,8BAA8B,QAEzB,yBAAqB;AAC5B,SAASC,mBAAmB,QAAQ,sBAAkB;AAEtD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,8BAA8B,kBAAiB;AAE5D,6EAA6E,GAC7E,OAAO,MAAMC,0CAA0C;IACrD;IACA;IACA;CACD,CAAS;AAKV;;;;;;;;;CASC,GACD,OAAO,MAAMC,wCAAwC;IACnD;IACA;IACA;CACD,CAAS;AAKV,2CAA2C,GAC3C,OAAO,MAAMC,0CAA0C;OAClDF;OACAC;CACJ,CAAS;AAKV,6CAA6C,GAC7C,OAAO,SAASE,qCACdC,KAAc;IAEd,OAAO,AAACF,wCAA+DG,QAAQ,CAC7ED;AAEJ;AAEA,gEAAgE,GAChE,OAAO,SAASE,qCACdF,KAAc;IAEd,OAAO,AAACJ,wCAA+DK,QAAQ,CAC7ED;AAEJ;AAEA,4EAA4E,GAC5E,OAAO,SAASG,mCACdH,KAAc;IAEd,OAAO,AAACH,sCAA6DI,QAAQ,CAC3ED;AAEJ;AAEA,0EAA0E,GAC1E,OAAO,MAAMI,uCAAuC;IAAC;IAAW;CAAW,CAAS;AAKpF,OAAO,SAASC,mCACdL,KAAc;IAEd,OAAO,AAACI,qCAA4DH,QAAQ,CAC1ED;AAEJ;AAEA;;;;CAIC,GACD,OAAO,MAAMM,0CAA0C,aAAY;AAEnE;;;;;;CAMC,GACD,OAAO,SAASC,6BAA6BC,SAAiB;IAC5D,OAAO,CAAC,6BAA6B,EAAEA,UAAU,2BAA2B,CAAC;AAC/E;AAEA,8EAA8E,GAC9E,OAAO,MAAMC,kCAAkCF,6BAC7Cb,qBACD;AAED;;;;;;;CAOC,GACD,OAAO,MAAMgB,+BAA+BlB,wBAAuB;AACnE,OAAO,MAAMmB,kCAAkC,uBAAsB;AACrE,OAAO,MAAMC,sCAAsCnB,+BAA8B;AACjF,OAAO,MAAMoB,2CACX,sCAAqC;AAEvC,yDAAyD,GACzD,OAAO,MAAMC,sCAAsC,KAAK,KAAK,KAAK,KAAK,KAAI;AAoB3E,SAASC,aAAaf,KAAc;IAClC,OAAO,OAAOA,UAAU,YAAYgB,OAAOC,QAAQ,CAACjB,SAASA,QAAQ;AACvE;AAEA;;;;;;CAMC,GACD,OAAO,SAASkB,6BACdC,QAAoD;IAEpD,MAAMC,UAAUD,4BAAAA,QAAU,CAACT,6BAA6B;IACxD,MAAMW,YAAYF,4BAAAA,QAAU,CAACP,oCAAoC;IACjE,MAAMU,SACJD,aAAa,OAAOA,cAAc,YAAY,CAACE,MAAMC,OAAO,CAACH,aACxDA,YACD;IACN,MAAMI,cAAcH,0BAAAA,MAAQ,CAAC,cAAc;IAC3C,OAAO;QACLI,UAAUN,YAAY,OAAO,YAAYA,YAAY,QAAQ,aAAa;QAC1EO,MAAMZ,aAAaI,4BAAAA,QAAU,CAACR,gCAAgC;QAC9Dc,aACE,OAAOA,gBAAgB,YAAYA,cAAcA,cAAc;QACjEG,YAAY7B,qCAAqCuB,0BAAAA,MAAQ,CAAC,OAAO,IAC7DA,MAAM,CAAC,OAAO,GACd;QACJO,qBAAqBd,aACnBI,4BAAAA,QAAU,CAACN,yCAAyC;IAExD;AACF;AAEA;;;;;;CAMC,GACD,OAAO,SAASiB,2BACdC,KAA8E,EAC9EC,KAAa;IAEb,IAAID,MAAML,QAAQ,KAAK,MAAM,OAAO;IACpC,IAAIK,MAAMF,mBAAmB,KAAK,MAAM,OAAO;IAC/C,OAAOG,QAAQD,MAAMF,mBAAmB,IAAIf;AAC9C;AAEA;;;;;CAKC,GACD,MAAMmB,qBAGF;IACF,kBAAkB;QAChBC,SAAS;QACTC,UAAU;IACZ;IACA,uBAAuB;QACrBD,SAAS;QACTC,UAAU;IACZ;IACA,kBAAkB;QAChBD,SAAS;QACTC,UAAU;IACZ;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,+BAA+BC,KAM9C;IACC,OAAO;QACLC,MAAMD,MAAMC,IAAI;QAChBC,IAAIF,MAAMG,GAAG;QACbb,MAAMU,MAAMV,IAAI;QAChBc,QAAQR,kBAAkB,CAACI,MAAMC,IAAI,CAAC,CAACD,MAAMX,QAAQ,CAAC;QACtDD,aAAaY,MAAMZ,WAAW;QAC9BiB,OAAO;IACT;AACF;AAeA,MAAMC,2BAEF;IACF,8BACE;IACF,8BACE;IACF,6BACE;IACF,6BACE;AACJ;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,6BACdP,KAAqE;IAErE,OAAO;QACLC,MAAMD,MAAMC,IAAI;QAChBC,IAAIF,MAAMG,GAAG;QACbb,MAAMU,MAAMV,IAAI;QAChB,wEAAwE;QACxE,yEAAyE;QACzEc,QAAQE,wBAAwB,CAC9B,GAAGN,MAAMC,IAAI,CAAC,CAAC,EAAED,MAAMX,QAAQ,EAAE,CAClC;QACDgB,OAAO;IACT;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASG,4BAA4BR,KAI3C;IACC,OAAO;QACL,CAAC3B,6BAA6B,EAAE2B,MAAMX,QAAQ,KAAK;QACnD,CAACf,gCAAgC,EAAE0B,MAAMV,IAAI;QAC7C,CAACf,oCAAoC,EAAEyB,MAAMf,MAAM;IACrD;AACF"}
@@ -16,7 +16,8 @@
16
16
  */
17
17
  /**
18
18
  * Policy for the tenant plugin-API dispatcher's visitor-write rate limit
19
- * (AGL-1770). Pure and dependency-free so it is unit-testable without a route
19
+ * (AGL-1770). Pure but for one read of the route registry, for a route its
20
+ * plugin registered as a machine's, so it is unit-testable without a route
20
21
  * harness — the durable half lives in `@aglyn/tenant-data-admin` beside
21
22
  * `visitorWriteRefusal`, exactly like the lockdown split.
22
23
  *
@@ -76,6 +77,11 @@ export declare const VISITOR_WRITE_RATE_WINDOW_MS = 60000;
76
77
  /**
77
78
  * Is this dispatcher path a credentialed machine surface (exempt), rather
78
79
  * than a visitor one (limited)? Unknown paths are visitor by default.
80
+ *
81
+ * A path is a machine's when it is on the list above, or when the plugin
82
+ * that registered it declared it one (`machine: true`, AGL-3080) — the same
83
+ * polarity as the list: a route is exempt only by saying so, and one that
84
+ * forgot is limited, which fails loudly as a 429 its scheduler retries.
79
85
  */
80
86
  export declare function isMachinePluginApiPath(path: string): boolean;
81
87
  /**
@@ -13,9 +13,11 @@
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
- */ /**
16
+ */ import { isPluginMachineRoute } from "./api-plugins.js";
17
+ /**
17
18
  * Policy for the tenant plugin-API dispatcher's visitor-write rate limit
18
- * (AGL-1770). Pure and dependency-free so it is unit-testable without a route
19
+ * (AGL-1770). Pure but for one read of the route registry, for a route its
20
+ * plugin registered as a machine's, so it is unit-testable without a route
19
21
  * harness — the durable half lives in `@aglyn/tenant-data-admin` beside
20
22
  * `visitorWriteRefusal`, exactly like the lockdown split.
21
23
  *
@@ -150,10 +152,16 @@
150
152
  /**
151
153
  * Is this dispatcher path a credentialed machine surface (exempt), rather
152
154
  * than a visitor one (limited)? Unknown paths are visitor by default.
155
+ *
156
+ * A path is a machine's when it is on the list above, or when the plugin
157
+ * that registered it declared it one (`machine: true`, AGL-3080) — the same
158
+ * polarity as the list: a route is exempt only by saying so, and one that
159
+ * forgot is limited, which fails loudly as a 429 its scheduler retries.
153
160
  */ export function isMachinePluginApiPath(path) {
154
161
  const normalized = normalize(path);
155
162
  if (MACHINE_API_PATHS.has(normalized)) return true;
156
- return MACHINE_API_PREFIXES.some((prefix)=>normalized.startsWith(prefix));
163
+ if (MACHINE_API_PREFIXES.some((prefix)=>normalized.startsWith(prefix))) return true;
164
+ return isPluginMachineRoute(normalized);
157
165
  }
158
166
  /**
159
167
  * The limiter key: one bucket per (site, client IP).
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/plugin-api-rate-limit.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 * Policy for the tenant plugin-API dispatcher's visitor-write rate limit\n * (AGL-1770). Pure and dependency-free so it is unit-testable without a route\n * harness — the durable half lives in `@aglyn/tenant-data-admin` beside\n * `visitorWriteRefusal`, exactly like the lockdown split.\n *\n * ## What this bounds\n *\n * `apps/tenant/app/api/[...pluginApi]/route.ts` dispatches every plugin's\n * visitor-facing handler and, before this, applied no rate limit of any kind.\n * The gates it did have each refuse something narrower: per-site enablement is\n * skipped by an unresolvable `hostId`, the release gate only checks that the\n * plugin is on, and `visitorWriteRefusal` refuses only a paused or suspended\n * site. None of them bounds volume from a working, enabled site — which is\n * every live merchant.\n *\n * AGL-1769 closed the *shape* of the unauthenticated cart write (the caller\n * chose the document path) and was explicit that it did not bound the\n * *quantity*: \"id control never multiplied volume; it bought shape\". This is\n * the other half. The cost of the unbounded half lands in the merchant's own\n * Firestore — document count and storage on a tenant who did nothing wrong.\n */\n\n/**\n * 120 writes per minute per (site, client IP).\n *\n * Deliberately generous, and the reasoning matters more than the number: a\n * limit that trips on genuine use is worse than no limit, because it gets\n * raised until it means nothing. Measured against what the storefront actually\n * emits:\n *\n * - `cart.tsx:214` puts the line-quantity `TextField`'s `onChange` straight\n * onto a POST with no debounce, so *typing* `100` into a quantity box is\n * three cart writes. Editing a few lines is a genuine ten-to-twenty-write\n * burst in a couple of seconds.\n * - `experiments/track` is an A/B beacon: one exposure write per experiment\n * per page view, so a visitor clicking through pages at ~1/s with two live\n * experiments sustains ~2 writes/s legitimately.\n * - Every visitor write on a site shares ONE budget (see the key below), so\n * these add rather than each getting their own headroom.\n *\n * What a tighter number would actually buy is small. At 120/min a single\n * source can still mint ~172k documents a day against one site; at 30/min it\n * is ~43k. Neither is \"safe\" in absolute terms — what the cap buys is turning\n * a one-line flood script from *millions* per day into something that needs a\n * distributed botnet to stay interesting, and turning an incident from\n * instantaneous into detectable. That property is bought at 120 as well as at\n * 30, and 30 would refuse real shoppers.\n *\n * 120/60s is also `DEFAULT_RATE_LIMIT` / `DEFAULT_RATE_WINDOW_MS` — the same\n * pair the public REST API enforces — so this adds no bespoke number.\n */\nexport const VISITOR_WRITE_RATE_LIMIT = 120\n\n/**\n * Fixed 60s window, matching every other limiter in the codebase rather than\n * improving on it. A fixed window admits up to 2× the cap across a boundary;\n * consistency with the shared bucket ids, the degradation markers and\n * `/api/health/rate-limits` is worth more here than closing that.\n */\nexport const VISITOR_WRITE_RATE_WINDOW_MS = 60_000\n\n/**\n * Machine-caller paths on EITHER plugin-API surface: neither dispatcher rate\n * limits these.\n *\n * The membership rule, so this list is decidable rather than a taste call: the\n * caller proves a shared secret, a webhook signature or a console session that\n * a visitor has no way to obtain, and its request volume is set by campaign\n * size or cron schedule rather than by human behaviour. A 120/min cap does not\n * merely inconvenience these — Resend delivering open/click webhooks for one\n * 50k-recipient campaign arrives from a handful of IPs for a single host and\n * would be shredded by any shopper-sized ceiling.\n *\n * ### Why a path list is right here when the dispatcher argues against them\n *\n * The lockdown gate two lines below is keyed by METHOD precisely to avoid a\n * path list, \"for the same reason `useSiteFetch` draws the Preview boundary\n * that way: a per-path list means thirteen handlers each remembering and the\n * fourteenth silently not.\"\n *\n * That argument turns on POLARITY, and this list has the opposite one. The\n * lockdown list would have enumerated what to *protect*, so a forgotten entry\n * is silently unprotected — the failure you never find. This enumerates what\n * to *exempt*, so the default is limited: a new visitor endpoint added\n * tomorrow is covered by doing nothing, and a forgotten machine endpoint\n * fails loudly and recoverably as 429s with `Retry-After` on a caller that\n * retries. Safe default, noisy mistake.\n *\n * Each entry, with the credential that makes it a machine caller:\n *\n * - `email/events` — Resend's webhook; `verifySvix` over `svix-id`/\n * `svix-timestamp`/`svix-signature` against `RESEND_WEBHOOK_SECRET`.\n * - `campaigns/send` — a Firebase ID token with a host member role, or\n * `CRON_SECRET` for the scheduled-send path.\n * - `bookings/reminders` — `x-cron-secret` against `CRON_SECRET`; refuses\n * outright when it is unset.\n * - `hooks/…` — the merchant's per-hook secret in `x-aglyn-secret`, compared\n * with `timingSafeEqual`. It already carries its own 30/min per-hook\n * limiter, so exempting it here removes nothing.\n *\n * The four below are registered on the CONSOLE surface only, and each is a\n * chunked cron sweep rather than a single beat — `sweepConsoleCron` follows\n * `nextCursor` up to `CRON_SWEEP_MAX_CHUNKS` (50) POSTs per route per run,\n * from one Cloud Functions address, and the key below carries no path. Three\n * such routes on one beat is 150 requests in a minute against a single\n * bucket, so this exemption is load-bearing and not decorative:\n *\n * - `campaigns/process-scheduled` — `x-cron-secret` against `CRON_SECRET`;\n * refuses outright when it is unset.\n * - `lists/materialize` — the same header, the same refusal.\n * - `commerce/process-abandoned` — the same header, the same refusal.\n * - `commerce/process-restock` — the same header, the same refusal.\n *\n * Note what is deliberately NOT exempt: presence of an `authorization` or\n * `svix-signature` HEADER. Header presence is not a credential — the\n * dispatcher cannot verify signatures it does not own, so exempting on the\n * header would let any caller skip the limiter by attaching a garbage one to\n * a cart POST.\n *\n * Nor is `commerce/supplier-update`, whose HMAC token makes it a machine\n * caller but whose volume is set by a supplier's own system rather than by a\n * schedule. It fails the second half of the membership rule, and the polarity\n * above says which way to resolve a borderline case: leave it limited, where\n * a mistake is a recoverable 429.\n */\nconst MACHINE_API_PATHS: ReadonlySet<string> = new Set([\n 'email/events',\n 'campaigns/send',\n 'bookings/reminders',\n 'campaigns/process-scheduled',\n 'lists/materialize',\n 'commerce/process-abandoned',\n 'commerce/process-restock',\n])\n\n/** Prefixes whose whole subtree is a machine surface (`hooks/{host}/{hook}`). */\nconst MACHINE_API_PREFIXES: readonly string[] = ['hooks/']\n\n/** Leading/trailing slashes stripped, matching `normalizeApiPath`. */\nfunction normalize(path: string): string {\n return String(path ?? '').replace(/^\\/+|\\/+$/g, '')\n}\n\n/**\n * Is this dispatcher path a credentialed machine surface (exempt), rather\n * than a visitor one (limited)? Unknown paths are visitor by default.\n */\nexport function isMachinePluginApiPath(path: string): boolean {\n const normalized = normalize(path)\n if (MACHINE_API_PATHS.has(normalized)) return true\n return MACHINE_API_PREFIXES.some((prefix) => normalized.startsWith(prefix))\n}\n\n/**\n * The limiter key: one bucket per (site, client IP).\n *\n * ## Why compound, and why NOT a second limiter in either direction\n *\n * **Per-site alone is rejected.** A single attacker would consume the whole\n * merchant's allowance and every real shopper would then be refused — an\n * attacker-triggered denial of service against the victim, cheaper to mount\n * than the flood it replaces and costing the merchant *sales* rather than\n * storage. That is strictly worse than the growth it guards.\n *\n * **Per-IP alone is rejected.** Trivially distributed, and it would let one\n * merchant's traffic spend another's budget.\n *\n * **Compound is what \"per-host and per-IP together\" should mean here.** Each\n * source is bounded on each site, and no source can affect another's budget,\n * so the ceiling a site is exposed to is (distinct source IPs) × 120/min —\n * the same shape legitimate traffic has. What it does not stop is a genuine\n * botnet, and that is stated rather than papered over: bounding *that* needs\n * an edge WAF, not an application limiter, and every application-level scheme\n * that would catch it (a site-wide ceiling) reintroduces the self-DoS above.\n *\n * **A second, platform-wide per-IP limiter was considered and dropped.** It\n * would bound one source sweeping many merchants, but costs a second\n * Firestore transaction on every storefront write — tripling the ops on a\n * legitimate add-to-cart — to bound an attack that the compound key already\n * prices at one full budget per merchant. One transaction per visitor write\n * is the established cost here (`forms/submit`, `protection/unlock`); two\n * would be novel and is not paid for by what it buys.\n *\n * The path is **not** in the key, on purpose: one shared budget per (site,\n * IP) across cart, reviews, newsletter, bookings and beacons. Keying per path\n * would let a caller multiply its allowance by cycling endpoints, which is\n * free to do and defeats the point.\n *\n * `hostId` is frequently `''` — the dispatcher only resolves one from the\n * query or a JSON body, and a handler that self-gates may supply none. It is\n * kept in the key rather than skipped: AGL-1769 named \"an unresolvable\n * `hostId` skips the gate\" as an existing hole, and a limiter that a caller\n * turns off by declining to name a site is not a limiter. Host-less writes\n * simply share one bucket per IP.\n */\nexport function visitorWriteRateLimitKey(hostId: string, ip: string): string {\n return `pluginwrite:${hostId || '-'}:${ip || 'unknown'}`\n}\n\n/**\n * Policy for the CONSOLE plugin-API dispatcher's write rate limit.\n *\n * `apps/console/app/api/[...pluginApi]/route.ts` dispatches every plugin's\n * console-facing handler — the marketplace installs and publishes, `ai/assist`,\n * gift cards, POS orders, refunds, the email list previews and the suppression\n * writes — and it carries no limiter of any kind. Its gates each refuse\n * something narrower: per-site enablement needs a resolvable `hostId`, the\n * release flag only asks whether the plugin is on, and lockdown only refuses\n * during an incident. None of them bounds volume from an ordinary signed-in\n * operator, which is every request the surface serves.\n *\n * The exposure is not the same one the visitor limiter closes. These handlers\n * are authenticated, so nobody is minting documents anonymously; what they do\n * instead is spend REAL money per call — a contact scan with a read budget, a\n * bundle download and verify, a model call to a paid vendor — on a surface\n * where a stuck retry loop in one console tab is indistinguishable from abuse.\n */\n\n/**\n * 120 requests per minute per console subject.\n *\n * `DEFAULT_RATE_LIMIT` / `DEFAULT_RATE_WINDOW_MS` again, and the same pair the\n * visitor limiter and the public REST API use, so this introduces no bespoke\n * number to reason about. It is far above a person pressing buttons: the\n * costly routes on this surface are one-per-click, and the console's own\n * page-load traffic reaches Firestore through the client SDK rather than\n * through this dispatcher.\n *\n * Deliberately not tighter even though each call here costs more than a cart\n * write. A limit that trips on genuine use gets raised until it means nothing,\n * and the per-call cost is the wrong lever for that anyway — an entitlement or\n * a quota bounds spend per org over a month, where this bounds a runaway loop\n * over a minute. Two different jobs; this one only has to make the loop stop.\n */\nexport const CONSOLE_API_RATE_LIMIT = 120\n\n/** Fixed 60s window, matching every other limiter in the codebase. */\nexport const CONSOLE_API_RATE_WINDOW_MS = 60_000\n\n/**\n * The limiter key: one bucket per authenticated subject.\n *\n * ## Why this is NOT the visitor key\n *\n * `visitorWriteRateLimitKey` is compound because its surface is\n * unauthenticated, and both halves of that argument are answers to anonymity.\n * Per-site alone was rejected there because one attacker would spend a whole\n * merchant's allowance and refuse real shoppers — an attacker-triggered denial\n * of service against the victim. On the console the subject is a verified\n * `uid`, so a caller can only ever refuse ITSELF: the self-DoS the compound key\n * exists to prevent cannot be aimed at anyone else, and the isolation it buys\n * is already bought by the identity.\n *\n * `hostId` is therefore left OUT rather than added, which is the opposite of\n * the visitor key and for the reason the visitor key leaves the PATH out: an\n * operator with fifty sites would otherwise hold fifty budgets and could\n * multiply its allowance by cycling a field it controls. One person is one\n * budget across every site they can reach.\n *\n * ## The unauthenticated fallback\n *\n * A caller whose bearer token did not decode — a plugin key, a POS device, an\n * anonymous probe — has no `uid`, and its client address stands in. That\n * bucket is shared by every such caller behind one address, which is the\n * conservative direction: an unidentified caller on an authenticated surface\n * should not get a private allowance for declining to identify itself. Keeping\n * it counted at all is the point. A limiter a caller switches off by sending\n * no credential is not a limiter, and this surface's whole exposure is what\n * runs BEFORE a handler decides who is asking.\n */\nexport function consoleApiRateLimitKey(subject: string): string {\n return `consoleapi:${subject || 'unknown'}`\n}\n"],"names":["VISITOR_WRITE_RATE_LIMIT","VISITOR_WRITE_RATE_WINDOW_MS","MACHINE_API_PATHS","Set","MACHINE_API_PREFIXES","normalize","path","String","replace","isMachinePluginApiPath","normalized","has","some","prefix","startsWith","visitorWriteRateLimitKey","hostId","ip","CONSOLE_API_RATE_LIMIT","CONSOLE_API_RATE_WINDOW_MS","consoleApiRateLimitKey","subject"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;CAqBC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMA,2BAA2B,IAAG;AAE3C;;;;;CAKC,GACD,OAAO,MAAMC,+BAA+B,MAAM;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+DC,GACD,MAAMC,oBAAyC,IAAIC,IAAI;IACrD;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,+EAA+E,GAC/E,MAAMC,uBAA0C;IAAC;CAAS;AAE1D,oEAAoE,GACpE,SAASC,UAAUC,IAAY;IAC7B,OAAOC,OAAOD,eAAAA,OAAQ,IAAIE,OAAO,CAAC,cAAc;AAClD;AAEA;;;CAGC,GACD,OAAO,SAASC,uBAAuBH,IAAY;IACjD,MAAMI,aAAaL,UAAUC;IAC7B,IAAIJ,kBAAkBS,GAAG,CAACD,aAAa,OAAO;IAC9C,OAAON,qBAAqBQ,IAAI,CAAC,CAACC,SAAWH,WAAWI,UAAU,CAACD;AACrE;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCC,GACD,OAAO,SAASE,yBAAyBC,MAAc,EAAEC,EAAU;IACjE,OAAO,CAAC,YAAY,EAAED,UAAU,IAAI,CAAC,EAAEC,MAAM,WAAW;AAC1D;AAEA;;;;;;;;;;;;;;;;;CAiBC,GAED;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,yBAAyB,IAAG;AAEzC,oEAAoE,GACpE,OAAO,MAAMC,6BAA6B,MAAM;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASC,uBAAuBC,OAAe;IACpD,OAAO,CAAC,WAAW,EAAEA,WAAW,WAAW;AAC7C"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/plugin-api-rate-limit.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 { isPluginMachineRoute } from './api-plugins'\n\n/**\n * Policy for the tenant plugin-API dispatcher's visitor-write rate limit\n * (AGL-1770). Pure but for one read of the route registry, for a route its\n * plugin registered as a machine's, so it is unit-testable without a route\n * harness — the durable half lives in `@aglyn/tenant-data-admin` beside\n * `visitorWriteRefusal`, exactly like the lockdown split.\n *\n * ## What this bounds\n *\n * `apps/tenant/app/api/[...pluginApi]/route.ts` dispatches every plugin's\n * visitor-facing handler and, before this, applied no rate limit of any kind.\n * The gates it did have each refuse something narrower: per-site enablement is\n * skipped by an unresolvable `hostId`, the release gate only checks that the\n * plugin is on, and `visitorWriteRefusal` refuses only a paused or suspended\n * site. None of them bounds volume from a working, enabled site — which is\n * every live merchant.\n *\n * AGL-1769 closed the *shape* of the unauthenticated cart write (the caller\n * chose the document path) and was explicit that it did not bound the\n * *quantity*: \"id control never multiplied volume; it bought shape\". This is\n * the other half. The cost of the unbounded half lands in the merchant's own\n * Firestore — document count and storage on a tenant who did nothing wrong.\n */\n\n/**\n * 120 writes per minute per (site, client IP).\n *\n * Deliberately generous, and the reasoning matters more than the number: a\n * limit that trips on genuine use is worse than no limit, because it gets\n * raised until it means nothing. Measured against what the storefront actually\n * emits:\n *\n * - `cart.tsx:214` puts the line-quantity `TextField`'s `onChange` straight\n * onto a POST with no debounce, so *typing* `100` into a quantity box is\n * three cart writes. Editing a few lines is a genuine ten-to-twenty-write\n * burst in a couple of seconds.\n * - `experiments/track` is an A/B beacon: one exposure write per experiment\n * per page view, so a visitor clicking through pages at ~1/s with two live\n * experiments sustains ~2 writes/s legitimately.\n * - Every visitor write on a site shares ONE budget (see the key below), so\n * these add rather than each getting their own headroom.\n *\n * What a tighter number would actually buy is small. At 120/min a single\n * source can still mint ~172k documents a day against one site; at 30/min it\n * is ~43k. Neither is \"safe\" in absolute terms — what the cap buys is turning\n * a one-line flood script from *millions* per day into something that needs a\n * distributed botnet to stay interesting, and turning an incident from\n * instantaneous into detectable. That property is bought at 120 as well as at\n * 30, and 30 would refuse real shoppers.\n *\n * 120/60s is also `DEFAULT_RATE_LIMIT` / `DEFAULT_RATE_WINDOW_MS` — the same\n * pair the public REST API enforces — so this adds no bespoke number.\n */\nexport const VISITOR_WRITE_RATE_LIMIT = 120\n\n/**\n * Fixed 60s window, matching every other limiter in the codebase rather than\n * improving on it. A fixed window admits up to 2× the cap across a boundary;\n * consistency with the shared bucket ids, the degradation markers and\n * `/api/health/rate-limits` is worth more here than closing that.\n */\nexport const VISITOR_WRITE_RATE_WINDOW_MS = 60_000\n\n/**\n * Machine-caller paths on EITHER plugin-API surface: neither dispatcher rate\n * limits these.\n *\n * The membership rule, so this list is decidable rather than a taste call: the\n * caller proves a shared secret, a webhook signature or a console session that\n * a visitor has no way to obtain, and its request volume is set by campaign\n * size or cron schedule rather than by human behaviour. A 120/min cap does not\n * merely inconvenience these — Resend delivering open/click webhooks for one\n * 50k-recipient campaign arrives from a handful of IPs for a single host and\n * would be shredded by any shopper-sized ceiling.\n *\n * ### Why a path list is right here when the dispatcher argues against them\n *\n * The lockdown gate two lines below is keyed by METHOD precisely to avoid a\n * path list, \"for the same reason `useSiteFetch` draws the Preview boundary\n * that way: a per-path list means thirteen handlers each remembering and the\n * fourteenth silently not.\"\n *\n * That argument turns on POLARITY, and this list has the opposite one. The\n * lockdown list would have enumerated what to *protect*, so a forgotten entry\n * is silently unprotected — the failure you never find. This enumerates what\n * to *exempt*, so the default is limited: a new visitor endpoint added\n * tomorrow is covered by doing nothing, and a forgotten machine endpoint\n * fails loudly and recoverably as 429s with `Retry-After` on a caller that\n * retries. Safe default, noisy mistake.\n *\n * Each entry, with the credential that makes it a machine caller:\n *\n * - `email/events` — Resend's webhook; `verifySvix` over `svix-id`/\n * `svix-timestamp`/`svix-signature` against `RESEND_WEBHOOK_SECRET`.\n * - `campaigns/send` — a Firebase ID token with a host member role, or\n * `CRON_SECRET` for the scheduled-send path.\n * - `bookings/reminders` — `x-cron-secret` against `CRON_SECRET`; refuses\n * outright when it is unset.\n * - `hooks/…` — the merchant's per-hook secret in `x-aglyn-secret`, compared\n * with `timingSafeEqual`. It already carries its own 30/min per-hook\n * limiter, so exempting it here removes nothing.\n *\n * The four below are registered on the CONSOLE surface only, and each is a\n * chunked cron sweep rather than a single beat — `sweepConsoleCron` follows\n * `nextCursor` up to `CRON_SWEEP_MAX_CHUNKS` (50) POSTs per route per run,\n * from one Cloud Functions address, and the key below carries no path. Three\n * such routes on one beat is 150 requests in a minute against a single\n * bucket, so this exemption is load-bearing and not decorative:\n *\n * - `campaigns/process-scheduled` — `x-cron-secret` against `CRON_SECRET`;\n * refuses outright when it is unset.\n * - `lists/materialize` — the same header, the same refusal.\n * - `commerce/process-abandoned` — the same header, the same refusal.\n * - `commerce/process-restock` — the same header, the same refusal.\n *\n * Note what is deliberately NOT exempt: presence of an `authorization` or\n * `svix-signature` HEADER. Header presence is not a credential — the\n * dispatcher cannot verify signatures it does not own, so exempting on the\n * header would let any caller skip the limiter by attaching a garbage one to\n * a cart POST.\n *\n * Nor is `commerce/supplier-update`, whose HMAC token makes it a machine\n * caller but whose volume is set by a supplier's own system rather than by a\n * schedule. It fails the second half of the membership rule, and the polarity\n * above says which way to resolve a borderline case: leave it limited, where\n * a mistake is a recoverable 429.\n */\nconst MACHINE_API_PATHS: ReadonlySet<string> = new Set([\n 'email/events',\n 'campaigns/send',\n 'bookings/reminders',\n 'campaigns/process-scheduled',\n 'lists/materialize',\n 'commerce/process-abandoned',\n 'commerce/process-restock',\n])\n\n/** Prefixes whose whole subtree is a machine surface (`hooks/{host}/{hook}`). */\nconst MACHINE_API_PREFIXES: readonly string[] = ['hooks/']\n\n/** Leading/trailing slashes stripped, matching `normalizeApiPath`. */\nfunction normalize(path: string): string {\n return String(path ?? '').replace(/^\\/+|\\/+$/g, '')\n}\n\n/**\n * Is this dispatcher path a credentialed machine surface (exempt), rather\n * than a visitor one (limited)? Unknown paths are visitor by default.\n *\n * A path is a machine's when it is on the list above, or when the plugin\n * that registered it declared it one (`machine: true`, AGL-3080) — the same\n * polarity as the list: a route is exempt only by saying so, and one that\n * forgot is limited, which fails loudly as a 429 its scheduler retries.\n */\nexport function isMachinePluginApiPath(path: string): boolean {\n const normalized = normalize(path)\n if (MACHINE_API_PATHS.has(normalized)) return true\n if (MACHINE_API_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return true\n return isPluginMachineRoute(normalized)\n}\n\n/**\n * The limiter key: one bucket per (site, client IP).\n *\n * ## Why compound, and why NOT a second limiter in either direction\n *\n * **Per-site alone is rejected.** A single attacker would consume the whole\n * merchant's allowance and every real shopper would then be refused — an\n * attacker-triggered denial of service against the victim, cheaper to mount\n * than the flood it replaces and costing the merchant *sales* rather than\n * storage. That is strictly worse than the growth it guards.\n *\n * **Per-IP alone is rejected.** Trivially distributed, and it would let one\n * merchant's traffic spend another's budget.\n *\n * **Compound is what \"per-host and per-IP together\" should mean here.** Each\n * source is bounded on each site, and no source can affect another's budget,\n * so the ceiling a site is exposed to is (distinct source IPs) × 120/min —\n * the same shape legitimate traffic has. What it does not stop is a genuine\n * botnet, and that is stated rather than papered over: bounding *that* needs\n * an edge WAF, not an application limiter, and every application-level scheme\n * that would catch it (a site-wide ceiling) reintroduces the self-DoS above.\n *\n * **A second, platform-wide per-IP limiter was considered and dropped.** It\n * would bound one source sweeping many merchants, but costs a second\n * Firestore transaction on every storefront write — tripling the ops on a\n * legitimate add-to-cart — to bound an attack that the compound key already\n * prices at one full budget per merchant. One transaction per visitor write\n * is the established cost here (`forms/submit`, `protection/unlock`); two\n * would be novel and is not paid for by what it buys.\n *\n * The path is **not** in the key, on purpose: one shared budget per (site,\n * IP) across cart, reviews, newsletter, bookings and beacons. Keying per path\n * would let a caller multiply its allowance by cycling endpoints, which is\n * free to do and defeats the point.\n *\n * `hostId` is frequently `''` — the dispatcher only resolves one from the\n * query or a JSON body, and a handler that self-gates may supply none. It is\n * kept in the key rather than skipped: AGL-1769 named \"an unresolvable\n * `hostId` skips the gate\" as an existing hole, and a limiter that a caller\n * turns off by declining to name a site is not a limiter. Host-less writes\n * simply share one bucket per IP.\n */\nexport function visitorWriteRateLimitKey(hostId: string, ip: string): string {\n return `pluginwrite:${hostId || '-'}:${ip || 'unknown'}`\n}\n\n/**\n * Policy for the CONSOLE plugin-API dispatcher's write rate limit.\n *\n * `apps/console/app/api/[...pluginApi]/route.ts` dispatches every plugin's\n * console-facing handler — the marketplace installs and publishes, `ai/assist`,\n * gift cards, POS orders, refunds, the email list previews and the suppression\n * writes — and it carries no limiter of any kind. Its gates each refuse\n * something narrower: per-site enablement needs a resolvable `hostId`, the\n * release flag only asks whether the plugin is on, and lockdown only refuses\n * during an incident. None of them bounds volume from an ordinary signed-in\n * operator, which is every request the surface serves.\n *\n * The exposure is not the same one the visitor limiter closes. These handlers\n * are authenticated, so nobody is minting documents anonymously; what they do\n * instead is spend REAL money per call — a contact scan with a read budget, a\n * bundle download and verify, a model call to a paid vendor — on a surface\n * where a stuck retry loop in one console tab is indistinguishable from abuse.\n */\n\n/**\n * 120 requests per minute per console subject.\n *\n * `DEFAULT_RATE_LIMIT` / `DEFAULT_RATE_WINDOW_MS` again, and the same pair the\n * visitor limiter and the public REST API use, so this introduces no bespoke\n * number to reason about. It is far above a person pressing buttons: the\n * costly routes on this surface are one-per-click, and the console's own\n * page-load traffic reaches Firestore through the client SDK rather than\n * through this dispatcher.\n *\n * Deliberately not tighter even though each call here costs more than a cart\n * write. A limit that trips on genuine use gets raised until it means nothing,\n * and the per-call cost is the wrong lever for that anyway — an entitlement or\n * a quota bounds spend per org over a month, where this bounds a runaway loop\n * over a minute. Two different jobs; this one only has to make the loop stop.\n */\nexport const CONSOLE_API_RATE_LIMIT = 120\n\n/** Fixed 60s window, matching every other limiter in the codebase. */\nexport const CONSOLE_API_RATE_WINDOW_MS = 60_000\n\n/**\n * The limiter key: one bucket per authenticated subject.\n *\n * ## Why this is NOT the visitor key\n *\n * `visitorWriteRateLimitKey` is compound because its surface is\n * unauthenticated, and both halves of that argument are answers to anonymity.\n * Per-site alone was rejected there because one attacker would spend a whole\n * merchant's allowance and refuse real shoppers — an attacker-triggered denial\n * of service against the victim. On the console the subject is a verified\n * `uid`, so a caller can only ever refuse ITSELF: the self-DoS the compound key\n * exists to prevent cannot be aimed at anyone else, and the isolation it buys\n * is already bought by the identity.\n *\n * `hostId` is therefore left OUT rather than added, which is the opposite of\n * the visitor key and for the reason the visitor key leaves the PATH out: an\n * operator with fifty sites would otherwise hold fifty budgets and could\n * multiply its allowance by cycling a field it controls. One person is one\n * budget across every site they can reach.\n *\n * ## The unauthenticated fallback\n *\n * A caller whose bearer token did not decode — a plugin key, a POS device, an\n * anonymous probe — has no `uid`, and its client address stands in. That\n * bucket is shared by every such caller behind one address, which is the\n * conservative direction: an unidentified caller on an authenticated surface\n * should not get a private allowance for declining to identify itself. Keeping\n * it counted at all is the point. A limiter a caller switches off by sending\n * no credential is not a limiter, and this surface's whole exposure is what\n * runs BEFORE a handler decides who is asking.\n */\nexport function consoleApiRateLimitKey(subject: string): string {\n return `consoleapi:${subject || 'unknown'}`\n}\n"],"names":["isPluginMachineRoute","VISITOR_WRITE_RATE_LIMIT","VISITOR_WRITE_RATE_WINDOW_MS","MACHINE_API_PATHS","Set","MACHINE_API_PREFIXES","normalize","path","String","replace","isMachinePluginApiPath","normalized","has","some","prefix","startsWith","visitorWriteRateLimitKey","hostId","ip","CONSOLE_API_RATE_LIMIT","CONSOLE_API_RATE_WINDOW_MS","consoleApiRateLimitKey","subject"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,mBAAe;AAEpD;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMC,2BAA2B,IAAG;AAE3C;;;;;CAKC,GACD,OAAO,MAAMC,+BAA+B,MAAM;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+DC,GACD,MAAMC,oBAAyC,IAAIC,IAAI;IACrD;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,+EAA+E,GAC/E,MAAMC,uBAA0C;IAAC;CAAS;AAE1D,oEAAoE,GACpE,SAASC,UAAUC,IAAY;IAC7B,OAAOC,OAAOD,eAAAA,OAAQ,IAAIE,OAAO,CAAC,cAAc;AAClD;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,uBAAuBH,IAAY;IACjD,MAAMI,aAAaL,UAAUC;IAC7B,IAAIJ,kBAAkBS,GAAG,CAACD,aAAa,OAAO;IAC9C,IAAIN,qBAAqBQ,IAAI,CAAC,CAACC,SAAWH,WAAWI,UAAU,CAACD,UAAU,OAAO;IACjF,OAAOd,qBAAqBW;AAC9B;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCC,GACD,OAAO,SAASK,yBAAyBC,MAAc,EAAEC,EAAU;IACjE,OAAO,CAAC,YAAY,EAAED,UAAU,IAAI,CAAC,EAAEC,MAAM,WAAW;AAC1D;AAEA;;;;;;;;;;;;;;;;;CAiBC,GAED;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,yBAAyB,IAAG;AAEzC,oEAAoE,GACpE,OAAO,MAAMC,6BAA6B,MAAM;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASC,uBAAuBC,OAAe;IACpD,OAAO,CAAC,WAAW,EAAEA,WAAW,WAAW;AAC7C"}
@@ -496,11 +496,11 @@ const BASE64_BLOB = /^[A-Za-z0-9+/\r\n]+={0,2}$/;
496
496
  };
497
497
  missing('the site component', (_found_site = found.site) == null ? void 0 : _found_site.components, (_declared_site = declared.site) == null ? void 0 : _declared_site.components, 'a published page that places it would never load the plugin');
498
498
  missing('the site feature', (_found_site1 = found.site) == null ? void 0 : _found_site1.features, (_declared_site1 = declared.site) == null ? void 0 : _declared_site1.features, 'no published page would ever load the plugin to run it');
499
- missing('a widget in the console slot', (_found_console = found.console) == null ? void 0 : _found_console.slots, (_declared_console = declared.console) == null ? void 0 : _declared_console.slots, 'a screen that renders the slot would never load the plugin');
499
+ missing('a widget in the console slot', (_found_console = found.console) == null ? void 0 : _found_console.slots, (_declared_console = declared.console) == null ? void 0 : _declared_console.slots, 'a console page that renders the slot would never load the plugin');
500
500
  missing('the console route', (_found_console1 = found.console) == null ? void 0 : _found_console1.routes, (_declared_console1 = declared.console) == null ? void 0 : _declared_console1.routes, 'the route would not find the plugin that serves it');
501
501
  missing('the console organization route', (_found_console2 = found.console) == null ? void 0 : _found_console2.orgRoutes, (_declared_console2 = declared.console) == null ? void 0 : _declared_console2.orgRoutes, 'the route would not find the plugin that serves it');
502
502
  if (((_found_console3 = found.console) == null ? void 0 : _found_console3.shell) && !((_declared_console3 = declared.console) == null ? void 0 : _declared_console3.shell)) {
503
- add('error', 'contributions', 'register() adds a nav tab, a provider or another entry the console ' + 'shell draws on every screen, and the manifest does not declare ' + '"console": { "shell": true } — the shell would draw it only on ' + 'screens that happened to load the plugin for something else');
503
+ add('error', 'contributions', 'register() adds a nav tab, a provider or another entry the console ' + 'shell draws on every page, and the manifest does not declare ' + '"console": { "shell": true } — the shell would draw it only on ' + 'pages that happened to load the plugin for something else');
504
504
  }
505
505
  if (detected.unresolved.length) {
506
506
  add('warning', 'contributions', `the checker could not read ${detected.unresolved.join('; ')} — ` + 'confirm the declaration covers what it registers');