@aglyn/aglyn 1.0.0-beta.219 → 1.0.0-beta.221

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 (188) hide show
  1. package/package.json +12 -12
  2. package/src/lib/app-utils/actions.d.ts +1 -59
  3. package/src/lib/app-utils/actions.js +7 -72
  4. package/src/lib/app-utils/actions.js.map +1 -1
  5. package/src/lib/app-utils/consent-groups.d.ts +1 -1
  6. package/src/lib/app-utils/consent-groups.js.map +1 -1
  7. package/src/lib/app-utils/console-routes.d.ts +0 -36
  8. package/src/lib/app-utils/console-routes.js +2 -37
  9. package/src/lib/app-utils/console-routes.js.map +1 -1
  10. package/src/lib/app-utils/contacts.d.ts +1 -1
  11. package/src/lib/app-utils/contacts.js.map +1 -1
  12. package/src/lib/app-utils/container-membership.d.ts +93 -0
  13. package/src/lib/app-utils/container-membership.js +165 -0
  14. package/src/lib/app-utils/container-membership.js.map +1 -0
  15. package/src/lib/app-utils/crm-task-reminders.d.ts +23 -6
  16. package/src/lib/app-utils/crm-task-reminders.js +32 -11
  17. package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
  18. package/src/lib/app-utils/crm.d.ts +24 -4
  19. package/src/lib/app-utils/crm.js +23 -0
  20. package/src/lib/app-utils/crm.js.map +1 -1
  21. package/src/lib/app-utils/csv-upload.d.ts +1 -1
  22. package/src/lib/app-utils/csv-upload.js +1 -1
  23. package/src/lib/app-utils/csv-upload.js.map +1 -1
  24. package/src/lib/app-utils/{dataset-csv.d.ts → csv.d.ts} +10 -43
  25. package/src/lib/app-utils/{dataset-csv.js → csv.js} +10 -81
  26. package/src/lib/app-utils/csv.js.map +1 -0
  27. package/src/lib/app-utils/datasets.d.ts +0 -19
  28. package/src/lib/app-utils/datasets.js +0 -13
  29. package/src/lib/app-utils/datasets.js.map +1 -1
  30. package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
  31. package/src/lib/app-utils/docs-help.generated.js +5 -0
  32. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  33. package/src/lib/app-utils/docs-index.generated.js +71 -36
  34. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  35. package/src/lib/app-utils/dynamic-list-rule.js +3 -3
  36. package/src/lib/app-utils/dynamic-list-rule.js.map +1 -1
  37. package/src/lib/app-utils/email-text.d.ts +80 -0
  38. package/src/lib/app-utils/email-text.js +189 -0
  39. package/src/lib/app-utils/email-text.js.map +1 -0
  40. package/src/lib/app-utils/{list-assignment-policy.d.ts → enrollment-basis.d.ts} +4 -0
  41. package/src/lib/app-utils/{list-assignment-policy.js → enrollment-basis.js} +5 -1
  42. package/src/lib/app-utils/enrollment-basis.js.map +1 -0
  43. package/src/lib/app-utils/expand-repeatables.js +4 -4
  44. package/src/lib/app-utils/expand-repeatables.js.map +1 -1
  45. package/src/lib/app-utils/forms.js +2 -2
  46. package/src/lib/app-utils/forms.js.map +1 -1
  47. package/src/lib/app-utils/health-report.d.ts +2 -3
  48. package/src/lib/app-utils/health-report.js +30 -6
  49. package/src/lib/app-utils/health-report.js.map +1 -1
  50. package/src/lib/app-utils/inbound-mail-domain.d.ts +27 -0
  51. package/src/lib/app-utils/inbound-mail-domain.js +31 -0
  52. package/src/lib/app-utils/inbound-mail-domain.js.map +1 -0
  53. package/src/lib/app-utils/leaving-notice.d.ts +96 -0
  54. package/src/lib/app-utils/leaving-notice.js +157 -0
  55. package/src/lib/app-utils/leaving-notice.js.map +1 -0
  56. package/src/lib/app-utils/lockdown.d.ts +11 -0
  57. package/src/lib/app-utils/lockdown.js +33 -3
  58. package/src/lib/app-utils/lockdown.js.map +1 -1
  59. package/src/lib/app-utils/member-email-aliases.d.ts +25 -0
  60. package/src/lib/app-utils/member-email-aliases.js +29 -0
  61. package/src/lib/app-utils/member-email-aliases.js.map +1 -1
  62. package/src/lib/app-utils/notifications.d.ts +69 -0
  63. package/src/lib/app-utils/notifications.js +119 -0
  64. package/src/lib/app-utils/notifications.js.map +1 -1
  65. package/src/lib/app-utils/operator-alerts.d.ts +15 -1
  66. package/src/lib/app-utils/operator-alerts.js +64 -10
  67. package/src/lib/app-utils/operator-alerts.js.map +1 -1
  68. package/src/lib/app-utils/plan-entitlements.d.ts +164 -178
  69. package/src/lib/app-utils/plan-entitlements.js +200 -224
  70. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  71. package/src/lib/app-utils/platform-marketing-consent.d.ts +56 -2
  72. package/src/lib/app-utils/platform-marketing-consent.js +6 -5
  73. package/src/lib/app-utils/platform-marketing-consent.js.map +1 -1
  74. package/src/lib/app-utils/plugin-starter-templates.generated.d.ts +12 -0
  75. package/src/lib/app-utils/plugin-starter-templates.generated.js +711 -0
  76. package/src/lib/app-utils/plugin-starter-templates.generated.js.map +1 -0
  77. package/src/lib/app-utils/repeat-query.d.ts +56 -0
  78. package/src/lib/app-utils/{dataset-query.js → repeat-query.js} +16 -16
  79. package/src/lib/app-utils/repeat-query.js.map +1 -0
  80. package/src/lib/app-utils/screen-link-context-value.d.ts +7 -0
  81. package/src/lib/app-utils/screen-link-context-value.js.map +1 -1
  82. package/src/lib/app-utils/screen-link-context.js +14 -2
  83. package/src/lib/app-utils/screen-link-context.js.map +1 -1
  84. package/src/lib/app-utils/screen-route.d.ts +3 -0
  85. package/src/lib/app-utils/screen-route.js +6 -1
  86. package/src/lib/app-utils/screen-route.js.map +1 -1
  87. package/src/lib/app-utils/server.d.ts +7 -9
  88. package/src/lib/app-utils/server.js +18 -26
  89. package/src/lib/app-utils/server.js.map +1 -1
  90. package/src/lib/app-utils/starter-template-nodes.d.ts +137 -0
  91. package/src/lib/app-utils/starter-template-nodes.js +129 -0
  92. package/src/lib/app-utils/starter-template-nodes.js.map +1 -0
  93. package/src/lib/app-utils/starter-templates.d.ts +11 -34
  94. package/src/lib/app-utils/starter-templates.js +19 -230
  95. package/src/lib/app-utils/starter-templates.js.map +1 -1
  96. package/src/lib/app-utils/{email-topics.d.ts → subscription-topics.d.ts} +64 -48
  97. package/src/lib/app-utils/{email-topics.js → subscription-topics.js} +39 -109
  98. package/src/lib/app-utils/subscription-topics.js.map +1 -0
  99. package/src/lib/app-utils/usage-budget.d.ts +73 -188
  100. package/src/lib/app-utils/usage-budget.js +58 -194
  101. package/src/lib/app-utils/usage-budget.js.map +1 -1
  102. package/src/lib/app-utils/utm-attribution.d.ts +1 -1
  103. package/src/lib/app-utils/utm-attribution.js +1 -1
  104. package/src/lib/app-utils/utm-attribution.js.map +1 -1
  105. package/src/lib/app-utils/{campaign-touch.d.ts → utm-touch.d.ts} +21 -13
  106. package/src/lib/app-utils/{campaign-touch.js → utm-touch.js} +30 -22
  107. package/src/lib/app-utils/utm-touch.js.map +1 -0
  108. package/src/lib/app-utils/variables.d.ts +9 -9
  109. package/src/lib/app-utils/variables.js.map +1 -1
  110. package/src/lib/app-utils/visitor-record-ceiling.d.ts +32 -0
  111. package/src/lib/app-utils/visitor-record-ceiling.js +39 -0
  112. package/src/lib/app-utils/visitor-record-ceiling.js.map +1 -1
  113. package/src/lib/foundation/definitions/org-billing.types.d.ts +2 -2
  114. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  115. package/src/lib/plugin-manager/feature-plugins.d.ts +80 -79
  116. package/src/lib/plugin-manager/feature-plugins.js +22 -35
  117. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  118. package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +18 -1
  119. package/src/lib/plugin-manager/first-party-plugins.generated.js +62 -2
  120. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  121. package/src/lib/plugin-manager/index.d.ts +0 -1
  122. package/src/lib/plugin-manager/index.js +0 -1
  123. package/src/lib/plugin-manager/index.js.map +1 -1
  124. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +9 -4
  125. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  126. package/src/lib/plugin-manager/plugin-containers.d.ts +68 -0
  127. package/src/lib/plugin-manager/plugin-containers.generated.d.ts +10 -0
  128. package/src/lib/plugin-manager/plugin-containers.generated.js +20 -0
  129. package/src/lib/plugin-manager/plugin-containers.generated.js.map +1 -0
  130. package/src/lib/plugin-manager/plugin-containers.js +27 -0
  131. package/src/lib/plugin-manager/plugin-containers.js.map +1 -0
  132. package/src/lib/plugin-manager/plugin-entitlements.d.ts +8 -0
  133. package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
  134. package/src/lib/plugin-manager/plugin-membership-detach.js +1 -1
  135. package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
  136. package/src/lib/plugin-manager/plugin-record-email-state.d.ts +69 -0
  137. package/src/lib/plugin-manager/plugin-record-email-state.js +32 -0
  138. package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
  139. package/src/lib/plugin-manager/plugin-record-lists.d.ts +115 -0
  140. package/src/lib/plugin-manager/plugin-record-lists.js +75 -0
  141. package/src/lib/plugin-manager/plugin-record-lists.js.map +1 -0
  142. package/src/lib/plugin-manager/plugin-record-timeline.d.ts +42 -8
  143. package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
  144. package/src/lib/plugin-manager/plugin-server-steps.d.ts +170 -0
  145. package/src/lib/plugin-manager/plugin-server-steps.js +116 -0
  146. package/src/lib/plugin-manager/plugin-server-steps.js.map +1 -0
  147. package/src/lib/plugin-manager/plugin-site-beacons.d.ts +71 -0
  148. package/src/lib/plugin-manager/plugin-site-beacons.js +54 -0
  149. package/src/lib/plugin-manager/plugin-site-beacons.js.map +1 -0
  150. package/src/lib/plugin-manager/plugin-subscription-topics.generated.d.ts +11 -0
  151. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js +41 -0
  152. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js.map +1 -0
  153. package/src/lib/plugin-manager/plugin-usage-axes.d.ts +212 -0
  154. package/src/lib/plugin-manager/plugin-usage-axes.js +82 -3
  155. package/src/lib/plugin-manager/plugin-usage-axes.js.map +1 -1
  156. package/src/lib/plugin-manager/plugin-usage-meters.d.ts +108 -0
  157. package/src/lib/plugin-manager/plugin-usage-meters.js +105 -0
  158. package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -0
  159. package/src/lib/plugin-manager/usage-alert-contributors.d.ts +76 -6
  160. package/src/lib/plugin-manager/usage-alert-contributors.js +23 -10
  161. package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
  162. package/src/lib/app-utils/assist-credits.d.ts +0 -482
  163. package/src/lib/app-utils/assist-credits.js +0 -510
  164. package/src/lib/app-utils/assist-credits.js.map +0 -1
  165. package/src/lib/app-utils/campaign-membership.d.ts +0 -121
  166. package/src/lib/app-utils/campaign-membership.js +0 -189
  167. package/src/lib/app-utils/campaign-membership.js.map +0 -1
  168. package/src/lib/app-utils/campaign-touch.js.map +0 -1
  169. package/src/lib/app-utils/console-record-links.d.ts +0 -84
  170. package/src/lib/app-utils/console-record-links.js +0 -76
  171. package/src/lib/app-utils/console-record-links.js.map +0 -1
  172. package/src/lib/app-utils/crm-booking.d.ts +0 -75
  173. package/src/lib/app-utils/crm-booking.js +0 -133
  174. package/src/lib/app-utils/crm-booking.js.map +0 -1
  175. package/src/lib/app-utils/crm-inbound.d.ts +0 -245
  176. package/src/lib/app-utils/crm-inbound.js +0 -407
  177. package/src/lib/app-utils/crm-inbound.js.map +0 -1
  178. package/src/lib/app-utils/crm-reports.d.ts +0 -479
  179. package/src/lib/app-utils/crm-reports.js +0 -680
  180. package/src/lib/app-utils/crm-reports.js.map +0 -1
  181. package/src/lib/app-utils/dataset-csv.js.map +0 -1
  182. package/src/lib/app-utils/dataset-query.d.ts +0 -57
  183. package/src/lib/app-utils/dataset-query.js.map +0 -1
  184. package/src/lib/app-utils/email-topics.js.map +0 -1
  185. package/src/lib/app-utils/list-assignment-policy.js.map +0 -1
  186. package/src/lib/plugin-manager/plugin-metered-lines.d.ts +0 -97
  187. package/src/lib/plugin-manager/plugin-metered-lines.js +0 -123
  188. package/src/lib/plugin-manager/plugin-metered-lines.js.map +0 -1
@@ -15,8 +15,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ /**
18
- * THE VISITOR'S HALF OF CAMPAIGN ATTRIBUTION — the touch that has to survive
19
- * the gap between arriving from a campaign and becoming somebody.
18
+ * THE VISITOR'S HALF OF ATTRIBUTION — the UTM touch that has to survive the
19
+ * gap between arriving from a labeled link and becoming somebody.
20
+ *
21
+ * The labels are the web's (`utm_source`/`utm_medium`/`utm_campaign`), not
22
+ * any plugin's model: every door that identifies a visitor — a form, a
23
+ * booking, a signup — attaches the touch, and whichever plugin credits its
24
+ * campaigns reads it on the server.
20
25
  *
21
26
  * ## The gap this exists to cross
22
27
  *
@@ -152,7 +157,10 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
152
157
  const age = convertedAtMs - touchedAtMs;
153
158
  return age >= 0 && age <= ATTRIBUTION_WINDOW_MS;
154
159
  }
155
- /** Where the touch is held. Namespaced like every other key this app sets. */ export const CAMPAIGN_TOUCH_STORAGE_KEY = 'aglyn:campaign-touch';
160
+ /**
161
+ * Where the touch is held. Namespaced like every other key this app sets, and
162
+ * spelled as visitors' devices already hold it.
163
+ */ export const UTM_TOUCH_STORAGE_KEY = 'aglyn:campaign-touch';
156
164
  /**
157
165
  * The parameter the touch's instant rides under inside the wire form.
158
166
  *
@@ -160,7 +168,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
160
168
  * allowlist that reads a URL, and a fourth `utm_` key would either be dropped
161
169
  * by it or have to widen it. `t` is outside the allowlist and read
162
170
  * separately.
163
- */ export const CAMPAIGN_TOUCH_TIME_KEY = 't';
171
+ */ export const UTM_TOUCH_TIME_KEY = 't';
164
172
  /**
165
173
  * Whether the visitor has granted analytics storage.
166
174
  *
@@ -180,14 +188,14 @@ function localStore() {
180
188
  }
181
189
  /**
182
190
  * The canonical stored form of a touch — the ONLY place it is written, so it
183
- * cannot drift from what {@link parseCampaignTouch} reads back.
184
- */ export function campaignTouchWire(touch) {
191
+ * cannot drift from what {@link parseUtmTouch} reads back.
192
+ */ export function utmTouchWire(touch) {
185
193
  if (!touch) return '';
186
194
  const labels = utmAttributionQuery(touch);
187
195
  if (!labels) return '';
188
196
  const atMs = Math.round(Number(touch.atMs));
189
197
  if (!Number.isFinite(atMs) || atMs <= 0) return '';
190
- return `${labels}&${CAMPAIGN_TOUCH_TIME_KEY}=${atMs}`;
198
+ return `${labels}&${UTM_TOUCH_TIME_KEY}=${atMs}`;
191
199
  }
192
200
  /**
193
201
  * Read a stored or transmitted touch back, or `null`.
@@ -197,7 +205,7 @@ function localStore() {
197
205
  * agrees about what an expired touch is: nothing at all. A touch dated in the
198
206
  * future is refused for the reason the revenue join refuses one: a click
199
207
  * after the conversion is the receipt, not the cause.
200
- */ export function parseCampaignTouch(wire, nowMs = Date.now()) {
208
+ */ export function parseUtmTouch(wire, nowMs = Date.now()) {
201
209
  if (typeof wire !== 'string' || !wire) return null;
202
210
  let params;
203
211
  try {
@@ -207,7 +215,7 @@ function localStore() {
207
215
  }
208
216
  const campaign = parseUtmAttribution(params);
209
217
  if (!campaign) return null;
210
- const atMs = Number(params.get(CAMPAIGN_TOUCH_TIME_KEY));
218
+ const atMs = Number(params.get(UTM_TOUCH_TIME_KEY));
211
219
  if (!touchIsInWindow(atMs, nowMs)) return null;
212
220
  return _extends({}, campaign, {
213
221
  atMs
@@ -219,17 +227,17 @@ function localStore() {
219
227
  * Called by whoever already computed it for the tag, on every render, so a
220
228
  * grant remembers the arrival the moment it is given and a withdrawal drops
221
229
  * the stored touch immediately.
222
- */ export function setCampaignTouchConsent(allowed) {
230
+ */ export function setUtmTouchConsent(allowed) {
223
231
  storageConsent = allowed === true ? true : allowed === false ? false : null;
224
232
  if (storageConsent === true) {
225
- rememberCampaignTouch();
233
+ rememberUtmTouch();
226
234
  return;
227
235
  }
228
236
  if (storageConsent === false) {
229
237
  const store = localStore();
230
238
  if (!store) return;
231
239
  try {
232
- store.removeItem(CAMPAIGN_TOUCH_STORAGE_KEY);
240
+ store.removeItem(UTM_TOUCH_STORAGE_KEY);
233
241
  } catch (unused) {
234
242
  // Nothing else to try, and a failed cleanup must not break the page.
235
243
  }
@@ -244,7 +252,7 @@ function localStore() {
244
252
  * second pageview.
245
253
  *
246
254
  * @returns what is now remembered, or `null`.
247
- */ export function rememberCampaignTouch(search, nowMs = Date.now()) {
255
+ */ export function rememberUtmTouch(search, nowMs = Date.now()) {
248
256
  if (storageConsent !== true) return null;
249
257
  const store = localStore();
250
258
  if (!store) return null;
@@ -254,10 +262,10 @@ function localStore() {
254
262
  const touch = _extends({}, campaign, {
255
263
  atMs: nowMs
256
264
  });
257
- const wire = campaignTouchWire(touch);
265
+ const wire = utmTouchWire(touch);
258
266
  if (!wire) return null;
259
267
  try {
260
- store.setItem(CAMPAIGN_TOUCH_STORAGE_KEY, wire);
268
+ store.setItem(UTM_TOUCH_STORAGE_KEY, wire);
261
269
  } catch (unused) {
262
270
  // A store that refuses the write costs the walk from the landing page to
263
271
  // the conversion, never a conversion on the landing page itself — the
@@ -281,7 +289,7 @@ function localStore() {
281
289
  * A touch past the window can never be credited again, so leaving it on the
282
290
  * device would be keeping a record of where somebody came from for no purpose
283
291
  * anything reads. The read that finds it removes it.
284
- */ export function readCampaignTouch(nowMs = Date.now()) {
292
+ */ export function readUtmTouch(nowMs = Date.now()) {
285
293
  const live = typeof window === 'undefined' ? null : parseUtmAttribution(new URLSearchParams(window.location.search));
286
294
  if (live) return _extends({}, live, {
287
295
  atMs: nowMs
@@ -291,15 +299,15 @@ function localStore() {
291
299
  if (!store) return null;
292
300
  let raw;
293
301
  try {
294
- raw = store.getItem(CAMPAIGN_TOUCH_STORAGE_KEY);
302
+ raw = store.getItem(UTM_TOUCH_STORAGE_KEY);
295
303
  } catch (unused) {
296
304
  return null;
297
305
  }
298
306
  if (!raw) return null;
299
- const touch = parseCampaignTouch(raw, nowMs);
307
+ const touch = parseUtmTouch(raw, nowMs);
300
308
  if (!touch) {
301
309
  try {
302
- store.removeItem(CAMPAIGN_TOUCH_STORAGE_KEY);
310
+ store.removeItem(UTM_TOUCH_STORAGE_KEY);
303
311
  } catch (unused) {
304
312
  // The entry stays until the next read finds it again; it is already
305
313
  // uncreditable, so nothing downstream is affected.
@@ -316,11 +324,11 @@ function localStore() {
316
324
  * that produced none. Empty when there is no touch — never a placeholder,
317
325
  * never `campaignTouch: undefined`, so that "arrived from nowhere" and "this
318
326
  * door does not report" stay distinguishable on the wire.
319
- */ export function campaignTouchField(nowMs = Date.now()) {
320
- const wire = campaignTouchWire(readCampaignTouch(nowMs));
327
+ */ export function utmTouchField(nowMs = Date.now()) {
328
+ const wire = utmTouchWire(readUtmTouch(nowMs));
321
329
  return wire ? {
322
330
  campaignTouch: wire
323
331
  } : {};
324
332
  }
325
333
 
326
- //# sourceMappingURL=campaign-touch.js.map
334
+ //# sourceMappingURL=utm-touch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/utm-touch.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE VISITOR'S HALF OF ATTRIBUTION — the UTM touch that has to survive the\n * gap between arriving from a labeled link and becoming somebody.\n *\n * The labels are the web's (`utm_source`/`utm_medium`/`utm_campaign`), not\n * any plugin's model: every door that identifies a visitor — a form, a\n * booking, a signup — attaches the touch, and whichever plugin credits its\n * campaigns reads it on the server.\n *\n * ## The gap this exists to cross\n *\n * A known recipient can be joined on their address: they clicked a campaign's\n * mail, the delivery webhook stamped the click on their person document, and\n * `email-revenue-attribution.ts` reads it back when their order settles. An\n * ANONYMOUS visitor has no such handle. They arrive from an ad, a partner\n * link or a social post, browse for a while, and only become identifiable at\n * the moment they submit a form, sign up, book or check out. Between the\n * arrival and that moment there is nothing on the server that connects them.\n *\n * The site's own collector already reads `utm_source`/`utm_medium`/\n * `utm_campaign` off the landing URL and increments a per-day label counter\n * with them. That answers \"how many views did this campaign send us\" and\n * stops there — the labels are never carried to the moment the visitor says\n * who they are, so every form, lead, contact and booking a campaign caused is\n * recorded as though nobody caused it.\n *\n * This carries them. The touch is held on the VISITOR, and every identify\n * moment attaches it.\n *\n * ## What holds it, and why it outlives the tab\n *\n * `localStorage`, expiring at {@link ATTRIBUTION_WINDOW_DAYS}.\n *\n * `utm-forwarding.ts` holds its own campaign in `sessionStorage` and\n * argues for it: a first touch that outlived the visit would start\n * attributing next week's organic return to this week's ad. That argument is\n * correct for the question it answers — which campaign produced THIS SIGNUP,\n * asked of one uninterrupted visit to the marketing site — and it is the\n * wrong shape here, because this question already has a stated answer to\n * \"how long may a touch be credited\": the attribution window, seven days,\n * stamped onto every record so the rule is readable off the data.\n *\n * A store that dies with the tab cannot express a seven-day window. It would\n * credit only the visitors who convert without ever closing the tab and\n * silently record every other conversion as organic — a measured zero, which\n * is the failure mode this whole area exists to end. So the touch lives as\n * long as the window says it may and not one millisecond longer: an expired\n * entry is DELETED on the read that finds it, rather than ignored, so a\n * device never holds a campaign it can no longer be credited with.\n *\n * ## Last touch, not first\n *\n * The revenue join credits the LAST click, and a product where a lead and an\n * order attribute by different rules is worse than one that is uniformly\n * approximate. So a new campaign arrival OVERWRITES the stored one. This is\n * the deliberate opposite of {@link rememberVisitUtm}, which keeps the\n * first — that one describes a single visit's origin and this one describes\n * which campaign most recently brought a person back.\n *\n * ## Consent\n *\n * Two tiers, exactly as the console hop splits them, and the split is the\n * same because the acts are the same:\n *\n * - **The live URL at the moment of conversion** touches no storage. The\n * parameters are already in the page the visitor asked for, nothing is\n * written to their device, and there is accordingly nothing to consent to.\n * A visitor who converts on the page the ad landed them on is attributed\n * with no grant of any kind.\n * - **The remembered touch** is written to the visitor's device for an\n * analytics purpose, which is `analytics_storage`, and it waits for the\n * same grant the analytics tag waits for. The gate is not re-derived here:\n * the caller already computed it for the tag and hands the same boolean\n * down, so there is one gate and it cannot drift from itself.\n *\n * `null` is UNRESOLVED and is not `false`. With `strictNullChecks` off\n * repo-wide that distinction evaporates unless it is carried explicitly, so\n * it is: until the visitor's state is actually settled, nothing is read and\n * nothing is written. Failing closed costs an attribution; failing open\n * writes to a device before the visitor answered.\n *\n * On a WITHDRAWAL the stored touch is removed rather than merely ignored.\n * Somebody who changes their mind should not leave the thing they withdrew\n * consent for sitting on their device.\n *\n * ⛔ **A host whose posture requires prior consent and whose visitor has not\n * given it produces no remembered touch at all.** Their conversion attributes\n * to nothing, exactly as direct traffic does. That is the degradation, stated\n * rather than worked around: there is no fallback identifier, no fingerprint\n * and no server-side cookie, because every one of those is the durable\n * identifier the analytics posture on this runtime deliberately does not set.\n *\n * ## What is never carried\n *\n * Only the three allowlisted `utm_` labels, scrubbed by\n * {@link parseUtmAttribution} — which refuses an email-shaped value\n * outright. No address, no person's identifier, no click id, nothing that\n * names the visitor. A campaign link is exactly where putting a recipient in\n * a query string would be tempting, and the parser is what makes it\n * impossible rather than merely discouraged: the stored string is re-parsed\n * through the same allowlist that wrote it, so a hand-edited `localStorage`\n * entry can claim no more than a hand-edited URL could.\n *\n * ⛔ **Nothing here depends on a parameter surviving the mail provider's\n * click wrapper.** A campaign link is rewritten by the provider and redirects\n * to the authored URL, so whatever the marketer put on it arrives — but the\n * email channel does not rely on that, because an email click is already\n * recorded server-side against the recipient's address hash. The labels below\n * are the WEB channel's touch, and a campaign that carries none is joined by\n * address instead.\n */\n\nimport {\n utmAttributionQuery,\n parseUtmAttribution,\n type UtmAttribution,\n} from './utm-attribution'\n\n/*==========================================\n * THE WINDOW, AND WHY THIS IS THE SECOND COPY OF A NUMBER.\n *\n * `email-revenue-window.ts` in `shared-util-email` declares the same seven\n * days, and its docblock is right that a window defined twice is a window\n * that drifts. Importing it here is not available: `shared-util-email` is\n * tagged `scope:shared` and reaches back into `@aglyn/aglyn`, so an edge in\n * this direction closes a project cycle and the module-boundary rule refuses\n * it. That is the same wall `email-media-src.ts` hit, and the answer is the\n * same one it took — a deliberate copy, pinned by a drift guard in an app\n * spec that can import both sides (`attribution-window-drift.spec.ts`).\n *\n * The copy is the WINDOW only. The model name is stamped onto records, which\n * happens server-side, so it stays in one place and never comes near this\n * file.\n *=========================================*/\n\n/** Days between a campaign touch and a conversion it may be credited with. */\nexport const ATTRIBUTION_WINDOW_DAYS = 7\n\n/** The same window in milliseconds. */\nexport const ATTRIBUTION_WINDOW_MS =\n ATTRIBUTION_WINDOW_DAYS * 24 * 60 * 60 * 1000\n\n/**\n * Whether a touch may be credited with a conversion at `convertedAtMs`.\n *\n * Both bounds matter and they fail differently, exactly as the revenue join\n * describes them: a touch AFTER the conversion is the receipt rather than the\n * cause, and a touch older than the window is one nobody can argue caused\n * anything. Inclusive at both ends.\n */\nfunction touchIsInWindow(touchedAtMs: number, convertedAtMs: number): boolean {\n if (!Number.isFinite(touchedAtMs) || !Number.isFinite(convertedAtMs)) {\n return false\n }\n if (touchedAtMs <= 0 || convertedAtMs <= 0) return false\n const age = convertedAtMs - touchedAtMs\n return age >= 0 && age <= ATTRIBUTION_WINDOW_MS\n}\n\n/**\n * Where the touch is held. Namespaced like every other key this app sets, and\n * spelled as visitors' devices already hold it.\n */\nexport const UTM_TOUCH_STORAGE_KEY = 'aglyn:campaign-touch'\n\n/**\n * The parameter the touch's instant rides under inside the wire form.\n *\n * Deliberately not a `utm_` name: the wire form is parsed by the same\n * allowlist that reads a URL, and a fourth `utm_` key would either be dropped\n * by it or have to widen it. `t` is outside the allowlist and read\n * separately.\n */\nexport const UTM_TOUCH_TIME_KEY = 't'\n\n/** A campaign the visitor arrived from, and when they arrived from it. */\nexport interface UtmTouch extends UtmAttribution {\n /** When the visitor followed the campaign link, epoch ms. */\n atMs: number\n}\n\n/**\n * Whether the visitor has granted analytics storage.\n *\n * `null` means NOT YET RESOLVED and is not the same as `false` — see the\n * consent section above.\n */\nlet storageConsent: boolean | null = null\n\nfunction localStore(): Storage | null {\n if (typeof window === 'undefined') return null\n // Touching the property itself throws in some privacy modes, not just its\n // methods.\n try {\n return window.localStorage ?? null\n } catch {\n return null\n }\n}\n\n/**\n * The canonical stored form of a touch — the ONLY place it is written, so it\n * cannot drift from what {@link parseUtmTouch} reads back.\n */\nexport function utmTouchWire(touch: UtmTouch | null | undefined): string {\n if (!touch) return ''\n const labels = utmAttributionQuery(touch)\n if (!labels) return ''\n const atMs = Math.round(Number(touch.atMs))\n if (!Number.isFinite(atMs) || atMs <= 0) return ''\n return `${labels}&${UTM_TOUCH_TIME_KEY}=${atMs}`\n}\n\n/**\n * Read a stored or transmitted touch back, or `null`.\n *\n * The window is enforced HERE rather than at the call sites, so every reader\n * — the browser deciding what to send, the server deciding what to credit —\n * agrees about what an expired touch is: nothing at all. A touch dated in the\n * future is refused for the reason the revenue join refuses one: a click\n * after the conversion is the receipt, not the cause.\n */\nexport function parseUtmTouch(\n wire: unknown,\n nowMs: number = Date.now(),\n): UtmTouch | null {\n if (typeof wire !== 'string' || !wire) return null\n let params: URLSearchParams\n try {\n params = new URLSearchParams(wire)\n } catch {\n return null\n }\n const campaign = parseUtmAttribution(params)\n if (!campaign) return null\n const atMs = Number(params.get(UTM_TOUCH_TIME_KEY))\n if (!touchIsInWindow(atMs, nowMs)) return null\n return { ...campaign, atMs }\n}\n\n/**\n * Tell this module what the visitor's analytics consent state is.\n *\n * Called by whoever already computed it for the tag, on every render, so a\n * grant remembers the arrival the moment it is given and a withdrawal drops\n * the stored touch immediately.\n */\nexport function setUtmTouchConsent(allowed: boolean | null): void {\n storageConsent = allowed === true ? true : allowed === false ? false : null\n if (storageConsent === true) {\n rememberUtmTouch()\n return\n }\n if (storageConsent === false) {\n const store = localStore()\n if (!store) return\n try {\n store.removeItem(UTM_TOUCH_STORAGE_KEY)\n } catch {\n // Nothing else to try, and a failed cleanup must not break the page.\n }\n }\n}\n\n/**\n * Remember the campaign on the current URL as this visitor's latest touch.\n *\n * Overwrites, because the model is last touch. A URL naming no campaign\n * writes NOTHING and clears nothing: a visitor who arrives from an ad and\n * then browses ten organic pages has one touch, not one touch erased by the\n * second pageview.\n *\n * @returns what is now remembered, or `null`.\n */\nexport function rememberUtmTouch(\n search?: string,\n nowMs: number = Date.now(),\n): UtmTouch | null {\n if (storageConsent !== true) return null\n const store = localStore()\n if (!store) return null\n const source =\n typeof search === 'string'\n ? search\n : typeof window === 'undefined'\n ? ''\n : window.location.search\n const campaign = parseUtmAttribution(new URLSearchParams(source))\n if (!campaign) return null\n const touch: UtmTouch = { ...campaign, atMs: nowMs }\n const wire = utmTouchWire(touch)\n if (!wire) return null\n try {\n store.setItem(UTM_TOUCH_STORAGE_KEY, wire)\n } catch {\n // A store that refuses the write costs the walk from the landing page to\n // the conversion, never a conversion on the landing page itself — the\n // live URL below still carries that visitor.\n return null\n }\n return touch\n}\n\n/**\n * The touch to credit a conversion happening right now, or `null`.\n *\n * ## The order is the model\n *\n * The live URL is consulted FIRST and wins whenever it names a campaign,\n * because a campaign on the address bar at the moment of conversion is by\n * definition the most recent touch there is. Only when the current page names\n * none does this fall back to what was remembered.\n *\n * ## An expired entry is deleted, not skipped\n *\n * A touch past the window can never be credited again, so leaving it on the\n * device would be keeping a record of where somebody came from for no purpose\n * anything reads. The read that finds it removes it.\n */\nexport function readUtmTouch(\n nowMs: number = Date.now(),\n): UtmTouch | null {\n const live =\n typeof window === 'undefined'\n ? null\n : parseUtmAttribution(new URLSearchParams(window.location.search))\n if (live) return { ...live, atMs: nowMs }\n\n if (storageConsent !== true) return null\n const store = localStore()\n if (!store) return null\n let raw: string | null\n try {\n raw = store.getItem(UTM_TOUCH_STORAGE_KEY)\n } catch {\n return null\n }\n if (!raw) return null\n const touch = parseUtmTouch(raw, nowMs)\n if (!touch) {\n try {\n store.removeItem(UTM_TOUCH_STORAGE_KEY)\n } catch {\n // The entry stays until the next read finds it again; it is already\n // uncreditable, so nothing downstream is affected.\n }\n return null\n }\n return touch\n}\n\n/**\n * The fragment a conversion request spreads into its body, or `{}`.\n *\n * One helper rather than four hand-written spreads: a door that forgets it\n * reports its conversions as organic, which looks exactly like a campaign\n * that produced none. Empty when there is no touch — never a placeholder,\n * never `campaignTouch: undefined`, so that \"arrived from nowhere\" and \"this\n * door does not report\" stay distinguishable on the wire.\n */\nexport function utmTouchField(\n nowMs: number = Date.now(),\n): { campaignTouch?: string } {\n const wire = utmTouchWire(readUtmTouch(nowMs))\n return wire ? { campaignTouch: wire } : {}\n}\n"],"names":["utmAttributionQuery","parseUtmAttribution","ATTRIBUTION_WINDOW_DAYS","ATTRIBUTION_WINDOW_MS","touchIsInWindow","touchedAtMs","convertedAtMs","Number","isFinite","age","UTM_TOUCH_STORAGE_KEY","UTM_TOUCH_TIME_KEY","storageConsent","localStore","window","localStorage","utmTouchWire","touch","labels","atMs","Math","round","parseUtmTouch","wire","nowMs","Date","now","params","URLSearchParams","campaign","get","setUtmTouchConsent","allowed","rememberUtmTouch","store","removeItem","search","source","location","setItem","readUtmTouch","live","raw","getItem","utmTouchField","campaignTouch"],"mappings":"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"}
@@ -24,21 +24,21 @@ import { type HostFunction } from './functions';
24
24
  /** Types from the mockup's Edit Variable dialog ("Function" lands with AGL-92). */
25
25
  export type HostVariableType = 'date' | 'time' | 'text' | 'number' | 'boolean' | 'dictionary' | 'collection';
26
26
  export declare const HOST_VARIABLE_TYPE_LABELS: Record<HostVariableType, string>;
27
- /** `hosts/{hostId}/variables/{id}` doc. */
27
+ /**
28
+ * `hosts/{hostId}/variables/{id}` doc, as far as binding reads it.
29
+ *
30
+ * A COMPUTED variable also carries the reference its computing plugin reads
31
+ * — which record produces its value — and `value` is then the fallback the
32
+ * page shows when that record is gone or fails. Binding never reads the
33
+ * reference; the plugin that computes the value types and reads its own
34
+ * (`plugin-manager/computed-variables`).
35
+ */
28
36
  export interface HostVariable {
29
37
  /** Binding identifier — `{{name}}`. */
30
38
  name: string;
31
39
  type: HostVariableType;
32
40
  /** Persisted as a string; formatted per type at resolve time. */
33
41
  value?: string;
34
- /**
35
- * Workflow (by name) whose result becomes this variable's value at
36
- * compose time (AGL-129); `value` acts as the fallback when the
37
- * workflow is missing or fails.
38
- */
39
- workflowName?: string;
40
- /** Computed source by workflow doc id (AGL-261); wins over the name. */
41
- workflowId?: string;
42
42
  }
43
43
  export declare const VARIABLE_NAME_PATTERN: RegExp;
44
44
  /** Human-readable rendering of a variable's value for text interpolation. */
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/variables.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// Token grammar shared with the picker/besigner (AGL-185/194): id-form\n// tokens only — legacy name resolution retired after the production\n// content migration ran clean.\nimport {\n FUNCTION_TOKEN_PATTERN,\n VARIABLE_ID_TOKEN_PATTERN,\n} from './binding-tokens'\nimport {\n evaluateExpression,\n evaluateHostFunction,\n functionReferencedNames,\n type HostFunction,\n} from './functions'\n\n/**\n * Host variables (Component Builder, AGL-91): typed values authored in the\n * console and bound into component text props with `{{name}}` — and, since\n * AGL-93, function results with `{{fn:name(args)}}`. Pure data module\n * shared by the console editor UI and the tenant compose pipeline.\n */\n\n/** Types from the mockup's Edit Variable dialog (\"Function\" lands with AGL-92). */\nexport type HostVariableType =\n | 'date'\n | 'time'\n | 'text'\n | 'number'\n | 'boolean'\n | 'dictionary'\n | 'collection'\n\nexport const HOST_VARIABLE_TYPE_LABELS: Record<HostVariableType, string> = {\n date: 'Date',\n time: 'Time',\n text: 'Plain text',\n number: 'Number',\n boolean: 'True/false (boolean)',\n dictionary: 'Dictionary (key: value)',\n collection: 'Collection list (values)',\n}\n\n/** `hosts/{hostId}/variables/{id}` doc. */\nexport interface HostVariable {\n /** Binding identifier — `{{name}}`. */\n name: string\n type: HostVariableType\n /** Persisted as a string; formatted per type at resolve time. */\n value?: string\n /**\n * Workflow (by name) whose result becomes this variable's value at\n * compose time (AGL-129); `value` acts as the fallback when the\n * workflow is missing or fails.\n */\n workflowName?: string\n /** Computed source by workflow doc id (AGL-261); wins over the name. */\n workflowId?: string\n}\n\nexport const VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]{0,39}$/\n\n/** Human-readable rendering of a variable's value for text interpolation. */\nexport function formatVariableValue(variable: HostVariable): string {\n const raw = variable.value ?? ''\n switch (variable.type) {\n case 'boolean':\n return raw === 'true' ? 'true' : 'false'\n case 'number': {\n const parsed = Number(raw)\n return Number.isFinite(parsed) ? String(parsed) : ''\n }\n case 'date': {\n const parsed = new Date(raw)\n return Number.isNaN(parsed.getTime())\n ? raw\n : parsed.toLocaleDateString(undefined, {\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n timeZone: 'UTC',\n })\n }\n case 'dictionary':\n case 'collection': {\n // Stored as JSON; render collections comma-joined and dictionaries\n // as `key: value` pairs so a bare {{name}} stays readable.\n try {\n const parsed = JSON.parse(raw)\n if (Array.isArray(parsed)) return parsed.map(String).join(', ')\n if (parsed && typeof parsed === 'object') {\n return Object.entries(parsed)\n .map(([key, value]) => `${key}: ${value}`)\n .join(', ')\n }\n } catch {\n // fall through to the raw string\n }\n return raw\n }\n case 'time':\n case 'text':\n default:\n return raw\n }\n}\n\n/** A function definition lookup keyed by function name (AGL-93). */\nexport type HostFunctionLookup = Record<string, HostFunction>\n\n/**\n * Variable values as an expression scope (numbers stay numeric). Keyed by\n * each doc's NAME regardless of how the lookup map is keyed (AGL-194:\n * maps are id-keyed now, but expressions reference variables by name).\n */\nfunction variableScope(\n variables: Record<string, HostVariable>,\n): Record<string, number | string | boolean> {\n const scope: Record<string, number | string | boolean> = {}\n for (const variable of Object.values(variables)) {\n if (!variable?.name) continue\n if (variable.type === 'number') {\n scope[variable.name] = Number(variable.value ?? 0)\n } else if (variable.type === 'boolean') {\n scope[variable.name] = variable.value === 'true'\n } else {\n scope[variable.name] = variable.value ?? ''\n }\n }\n return scope\n}\n\n/**\n * Replaces binding tokens with values (AGL-91/93/185/194): id forms\n * `{{var:id}}` / `{{fn:id(args)}}` resolve through id-keyed lookup maps.\n * Function args are expressions over literals and variable NAMES (the\n * scope keys by each doc's name). Unknown function refs keep the literal\n * token; unknown variable ids render empty; bare `{{name}}` strings are\n * plain text since the legacy fallback retired.\n */\nexport function resolveBindings(\n text: string,\n variables: Record<string, HostVariable>,\n functions: HostFunctionLookup = {},\n): string {\n const withVariableIds = text.replace(\n VARIABLE_ID_TOKEN_PATTERN,\n (_token, id) => {\n const variable = variables[String(id)]\n // Unknown ids render EMPTY, not literal (AGL-186): a raw doc id is\n // meaningless to visitors, unlike a readable legacy {{name}} token.\n return variable ? formatVariableValue(variable) : ''\n },\n )\n const withFunctions = withVariableIds.replace(\n FUNCTION_TOKEN_PATTERN,\n (token, rawName, rawArgs) => {\n const definition = functions[String(rawName).trim()]\n if (!definition) return token\n try {\n const scope = variableScope(variables)\n const argValues = String(rawArgs)\n .split(',')\n .map((argument) => argument.trim())\n .filter((argument) => argument.length > 0)\n .map((argument) => evaluateExpression(argument, scope))\n const args: Record<string, unknown> = {}\n definition.parameters?.forEach((parameter, index) => {\n args[parameter.name] = argValues[index]\n })\n // The site's variables are the function's outer scope (AGL-3202),\n // so `{{fn:id(25)}}` can price 25 sites without every rate being\n // passed in by position. Arguments still win over a variable of the\n // same name, as every parameter does.\n const result = evaluateHostFunction(definition, args, {\n globals: scope,\n })\n return result.ok ? String(result.value) : token\n } catch {\n return token\n }\n },\n )\n // Legacy {{name}} resolution retired (AGL-194): the production content\n // migration ran clean, so bare-name tokens are plain text now. Typing\n // them in the editor still works — save-time normalization converts\n // known names to id tokens before they persist.\n return withFunctions\n}\n\n/** Fast pre-check so binding-free content skips the walk entirely. */\nexport function hasBindings(text: string): boolean {\n return /\\{\\{/.test(text)\n}\n\n/**\n * The site variables ONE function reads, by name (AGL-3202).\n *\n * A widget runs in the visitor's browser, so whatever it may read has to be\n * on the page. Handing it the whole variable list would publish every value\n * a site holds to every page that places a calculator; this hands over the\n * names the function's own expressions use and nothing else. A name the\n * function declares itself is not a variable read — its parameter or local\n * shadows the variable — so it is not shipped either.\n */\nexport function functionGlobals(\n definition: HostFunction,\n variables: Record<string, HostVariable>,\n): Record<string, number | string | boolean> {\n const scope = variableScope(variables)\n const globals: Record<string, number | string | boolean> = {}\n for (const name of functionReferencedNames(definition)) {\n if (Object.prototype.hasOwnProperty.call(scope, name)) {\n globals[name] = scope[name]\n }\n }\n return globals\n}\n\n/**\n * Injects each function-running element's definition into its props at\n * compose time (AGL-93): the client runs the shared evaluator locally, so the\n * published page carries the definition instead of calling home. Unknown\n * names leave the node untouched (the element renders its editor\n * placeholder).\n *\n * Which elements run a function is DECLARED (AGL-3393): `bindings` maps a\n * component id to the prop that names its function, collected from each\n * plugin's `contributes.site.functionBindings`. Core names no element, so a\n * marketplace plugin's element is bound exactly as a first-party one is, and\n * an element nobody declared is never handed a definition.\n *\n * With `variables`, the site variables that function reads ride along as\n * `globals` (AGL-3202) — see {@link functionGlobals} for why it is only\n * those. The key is omitted when the function reads none, so a page built\n * before this existed serializes exactly as it did.\n */\nexport function attachFunctionDefinitions<T extends Record<string, any>>(\n nodes: T,\n functions: HostFunctionLookup,\n variables: Record<string, HostVariable>,\n bindings: Readonly<Record<string, string>>,\n): T {\n if (!Object.keys(functions).length || !Object.keys(bindings).length) {\n return nodes\n }\n const next: Record<string, any> = {}\n for (const [id, node] of Object.entries(nodes)) {\n const componentId = node?.componentId\n const prop =\n typeof componentId === 'string' &&\n Object.prototype.hasOwnProperty.call(bindings, componentId)\n ? bindings[componentId]\n : undefined\n const name = prop ? node?.props?.[prop] : undefined\n const definition = name ? functions[String(name).trim()] : undefined\n if (!definition) {\n next[id] = node\n continue\n }\n const globals = functionGlobals(definition, variables)\n next[id] = {\n ...node,\n props: {\n ...node.props,\n definition,\n ...(Object.keys(globals).length ? { globals } : {}),\n },\n }\n }\n return next as T\n}\n\n/**\n * Applies `resolveBindings` to every string prop across a normalized nodes\n * map (mutating a shallow copy). String props only — objects/arrays (sx,\n * option lists) pass through untouched; unsafe values in hrefs remain\n * covered by the render-time SAFE_HREF/sanitizer checks.\n */\nexport function resolveNodesBindings<T extends Record<string, any>>(\n nodes: T,\n variables: Record<string, HostVariable>,\n functions: HostFunctionLookup = {},\n): T {\n if (!Object.keys(variables).length && !Object.keys(functions).length) {\n return nodes\n }\n const next: Record<string, any> = {}\n for (const [id, node] of Object.entries(nodes)) {\n const props = node?.props\n if (!props) {\n next[id] = node\n continue\n }\n let changed = false\n const nextProps: Record<string, unknown> = { ...props }\n for (const [key, value] of Object.entries(props)) {\n if (typeof value === 'string' && hasBindings(value)) {\n nextProps[key] = resolveBindings(value, variables, functions)\n changed = true\n }\n }\n next[id] = changed ? { ...node, props: nextProps } : node\n }\n return next as T\n}\n"],"names":["FUNCTION_TOKEN_PATTERN","VARIABLE_ID_TOKEN_PATTERN","evaluateExpression","evaluateHostFunction","functionReferencedNames","HOST_VARIABLE_TYPE_LABELS","date","time","text","number","boolean","dictionary","collection","VARIABLE_NAME_PATTERN","formatVariableValue","variable","raw","value","type","parsed","Number","isFinite","String","Date","isNaN","getTime","toLocaleDateString","undefined","year","month","day","timeZone","JSON","parse","Array","isArray","map","join","Object","entries","key","variableScope","variables","scope","values","name","resolveBindings","functions","withVariableIds","replace","_token","id","withFunctions","token","rawName","rawArgs","definition","trim","argValues","split","argument","filter","length","args","parameters","forEach","parameter","index","result","globals","ok","hasBindings","test","functionGlobals","prototype","hasOwnProperty","call","attachFunctionDefinitions","nodes","bindings","keys","next","node","componentId","prop","props","resolveNodesBindings","changed","nextProps"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/variables.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// Token grammar shared with the picker/besigner (AGL-185/194): id-form\n// tokens only — legacy name resolution retired after the production\n// content migration ran clean.\nimport {\n FUNCTION_TOKEN_PATTERN,\n VARIABLE_ID_TOKEN_PATTERN,\n} from './binding-tokens'\nimport {\n evaluateExpression,\n evaluateHostFunction,\n functionReferencedNames,\n type HostFunction,\n} from './functions'\n\n/**\n * Host variables (Component Builder, AGL-91): typed values authored in the\n * console and bound into component text props with `{{name}}` — and, since\n * AGL-93, function results with `{{fn:name(args)}}`. Pure data module\n * shared by the console editor UI and the tenant compose pipeline.\n */\n\n/** Types from the mockup's Edit Variable dialog (\"Function\" lands with AGL-92). */\nexport type HostVariableType =\n | 'date'\n | 'time'\n | 'text'\n | 'number'\n | 'boolean'\n | 'dictionary'\n | 'collection'\n\nexport const HOST_VARIABLE_TYPE_LABELS: Record<HostVariableType, string> = {\n date: 'Date',\n time: 'Time',\n text: 'Plain text',\n number: 'Number',\n boolean: 'True/false (boolean)',\n dictionary: 'Dictionary (key: value)',\n collection: 'Collection list (values)',\n}\n\n/**\n * `hosts/{hostId}/variables/{id}` doc, as far as binding reads it.\n *\n * A COMPUTED variable also carries the reference its computing plugin reads\n * — which record produces its value — and `value` is then the fallback the\n * page shows when that record is gone or fails. Binding never reads the\n * reference; the plugin that computes the value types and reads its own\n * (`plugin-manager/computed-variables`).\n */\nexport interface HostVariable {\n /** Binding identifier — `{{name}}`. */\n name: string\n type: HostVariableType\n /** Persisted as a string; formatted per type at resolve time. */\n value?: string\n}\n\nexport const VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]{0,39}$/\n\n/** Human-readable rendering of a variable's value for text interpolation. */\nexport function formatVariableValue(variable: HostVariable): string {\n const raw = variable.value ?? ''\n switch (variable.type) {\n case 'boolean':\n return raw === 'true' ? 'true' : 'false'\n case 'number': {\n const parsed = Number(raw)\n return Number.isFinite(parsed) ? String(parsed) : ''\n }\n case 'date': {\n const parsed = new Date(raw)\n return Number.isNaN(parsed.getTime())\n ? raw\n : parsed.toLocaleDateString(undefined, {\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n timeZone: 'UTC',\n })\n }\n case 'dictionary':\n case 'collection': {\n // Stored as JSON; render collections comma-joined and dictionaries\n // as `key: value` pairs so a bare {{name}} stays readable.\n try {\n const parsed = JSON.parse(raw)\n if (Array.isArray(parsed)) return parsed.map(String).join(', ')\n if (parsed && typeof parsed === 'object') {\n return Object.entries(parsed)\n .map(([key, value]) => `${key}: ${value}`)\n .join(', ')\n }\n } catch {\n // fall through to the raw string\n }\n return raw\n }\n case 'time':\n case 'text':\n default:\n return raw\n }\n}\n\n/** A function definition lookup keyed by function name (AGL-93). */\nexport type HostFunctionLookup = Record<string, HostFunction>\n\n/**\n * Variable values as an expression scope (numbers stay numeric). Keyed by\n * each doc's NAME regardless of how the lookup map is keyed (AGL-194:\n * maps are id-keyed now, but expressions reference variables by name).\n */\nfunction variableScope(\n variables: Record<string, HostVariable>,\n): Record<string, number | string | boolean> {\n const scope: Record<string, number | string | boolean> = {}\n for (const variable of Object.values(variables)) {\n if (!variable?.name) continue\n if (variable.type === 'number') {\n scope[variable.name] = Number(variable.value ?? 0)\n } else if (variable.type === 'boolean') {\n scope[variable.name] = variable.value === 'true'\n } else {\n scope[variable.name] = variable.value ?? ''\n }\n }\n return scope\n}\n\n/**\n * Replaces binding tokens with values (AGL-91/93/185/194): id forms\n * `{{var:id}}` / `{{fn:id(args)}}` resolve through id-keyed lookup maps.\n * Function args are expressions over literals and variable NAMES (the\n * scope keys by each doc's name). Unknown function refs keep the literal\n * token; unknown variable ids render empty; bare `{{name}}` strings are\n * plain text since the legacy fallback retired.\n */\nexport function resolveBindings(\n text: string,\n variables: Record<string, HostVariable>,\n functions: HostFunctionLookup = {},\n): string {\n const withVariableIds = text.replace(\n VARIABLE_ID_TOKEN_PATTERN,\n (_token, id) => {\n const variable = variables[String(id)]\n // Unknown ids render EMPTY, not literal (AGL-186): a raw doc id is\n // meaningless to visitors, unlike a readable legacy {{name}} token.\n return variable ? formatVariableValue(variable) : ''\n },\n )\n const withFunctions = withVariableIds.replace(\n FUNCTION_TOKEN_PATTERN,\n (token, rawName, rawArgs) => {\n const definition = functions[String(rawName).trim()]\n if (!definition) return token\n try {\n const scope = variableScope(variables)\n const argValues = String(rawArgs)\n .split(',')\n .map((argument) => argument.trim())\n .filter((argument) => argument.length > 0)\n .map((argument) => evaluateExpression(argument, scope))\n const args: Record<string, unknown> = {}\n definition.parameters?.forEach((parameter, index) => {\n args[parameter.name] = argValues[index]\n })\n // The site's variables are the function's outer scope (AGL-3202),\n // so `{{fn:id(25)}}` can price 25 sites without every rate being\n // passed in by position. Arguments still win over a variable of the\n // same name, as every parameter does.\n const result = evaluateHostFunction(definition, args, {\n globals: scope,\n })\n return result.ok ? String(result.value) : token\n } catch {\n return token\n }\n },\n )\n // Legacy {{name}} resolution retired (AGL-194): the production content\n // migration ran clean, so bare-name tokens are plain text now. Typing\n // them in the editor still works — save-time normalization converts\n // known names to id tokens before they persist.\n return withFunctions\n}\n\n/** Fast pre-check so binding-free content skips the walk entirely. */\nexport function hasBindings(text: string): boolean {\n return /\\{\\{/.test(text)\n}\n\n/**\n * The site variables ONE function reads, by name (AGL-3202).\n *\n * A widget runs in the visitor's browser, so whatever it may read has to be\n * on the page. Handing it the whole variable list would publish every value\n * a site holds to every page that places a calculator; this hands over the\n * names the function's own expressions use and nothing else. A name the\n * function declares itself is not a variable read — its parameter or local\n * shadows the variable — so it is not shipped either.\n */\nexport function functionGlobals(\n definition: HostFunction,\n variables: Record<string, HostVariable>,\n): Record<string, number | string | boolean> {\n const scope = variableScope(variables)\n const globals: Record<string, number | string | boolean> = {}\n for (const name of functionReferencedNames(definition)) {\n if (Object.prototype.hasOwnProperty.call(scope, name)) {\n globals[name] = scope[name]\n }\n }\n return globals\n}\n\n/**\n * Injects each function-running element's definition into its props at\n * compose time (AGL-93): the client runs the shared evaluator locally, so the\n * published page carries the definition instead of calling home. Unknown\n * names leave the node untouched (the element renders its editor\n * placeholder).\n *\n * Which elements run a function is DECLARED (AGL-3393): `bindings` maps a\n * component id to the prop that names its function, collected from each\n * plugin's `contributes.site.functionBindings`. Core names no element, so a\n * marketplace plugin's element is bound exactly as a first-party one is, and\n * an element nobody declared is never handed a definition.\n *\n * With `variables`, the site variables that function reads ride along as\n * `globals` (AGL-3202) — see {@link functionGlobals} for why it is only\n * those. The key is omitted when the function reads none, so a page built\n * before this existed serializes exactly as it did.\n */\nexport function attachFunctionDefinitions<T extends Record<string, any>>(\n nodes: T,\n functions: HostFunctionLookup,\n variables: Record<string, HostVariable>,\n bindings: Readonly<Record<string, string>>,\n): T {\n if (!Object.keys(functions).length || !Object.keys(bindings).length) {\n return nodes\n }\n const next: Record<string, any> = {}\n for (const [id, node] of Object.entries(nodes)) {\n const componentId = node?.componentId\n const prop =\n typeof componentId === 'string' &&\n Object.prototype.hasOwnProperty.call(bindings, componentId)\n ? bindings[componentId]\n : undefined\n const name = prop ? node?.props?.[prop] : undefined\n const definition = name ? functions[String(name).trim()] : undefined\n if (!definition) {\n next[id] = node\n continue\n }\n const globals = functionGlobals(definition, variables)\n next[id] = {\n ...node,\n props: {\n ...node.props,\n definition,\n ...(Object.keys(globals).length ? { globals } : {}),\n },\n }\n }\n return next as T\n}\n\n/**\n * Applies `resolveBindings` to every string prop across a normalized nodes\n * map (mutating a shallow copy). String props only — objects/arrays (sx,\n * option lists) pass through untouched; unsafe values in hrefs remain\n * covered by the render-time SAFE_HREF/sanitizer checks.\n */\nexport function resolveNodesBindings<T extends Record<string, any>>(\n nodes: T,\n variables: Record<string, HostVariable>,\n functions: HostFunctionLookup = {},\n): T {\n if (!Object.keys(variables).length && !Object.keys(functions).length) {\n return nodes\n }\n const next: Record<string, any> = {}\n for (const [id, node] of Object.entries(nodes)) {\n const props = node?.props\n if (!props) {\n next[id] = node\n continue\n }\n let changed = false\n const nextProps: Record<string, unknown> = { ...props }\n for (const [key, value] of Object.entries(props)) {\n if (typeof value === 'string' && hasBindings(value)) {\n nextProps[key] = resolveBindings(value, variables, functions)\n changed = true\n }\n }\n next[id] = changed ? { ...node, props: nextProps } : node\n }\n return next as T\n}\n"],"names":["FUNCTION_TOKEN_PATTERN","VARIABLE_ID_TOKEN_PATTERN","evaluateExpression","evaluateHostFunction","functionReferencedNames","HOST_VARIABLE_TYPE_LABELS","date","time","text","number","boolean","dictionary","collection","VARIABLE_NAME_PATTERN","formatVariableValue","variable","raw","value","type","parsed","Number","isFinite","String","Date","isNaN","getTime","toLocaleDateString","undefined","year","month","day","timeZone","JSON","parse","Array","isArray","map","join","Object","entries","key","variableScope","variables","scope","values","name","resolveBindings","functions","withVariableIds","replace","_token","id","withFunctions","token","rawName","rawArgs","definition","trim","argValues","split","argument","filter","length","args","parameters","forEach","parameter","index","result","globals","ok","hasBindings","test","functionGlobals","prototype","hasOwnProperty","call","attachFunctionDefinitions","nodes","bindings","keys","next","node","componentId","prop","props","resolveNodesBindings","changed","nextProps"],"mappings":"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"}
@@ -193,6 +193,38 @@ export type VisitorRecordKind = 'siteMembers' | 'leads';
193
193
  * spell it two different ways.
194
194
  */
195
195
  export declare function visitorRecordRefusedCounterId(kind: VisitorRecordKind): string;
196
+ /**
197
+ * The field on the refusal counter that says whether the site's managers have
198
+ * been told about the crossing the site is in: when the notice went out, or
199
+ * `null` once the site has been seen below the ceiling again (AGL-3442).
200
+ */
201
+ export declare const VISITOR_RECORD_NOTICE_SENT_FIELD = "noticeSentAtMs";
202
+ /**
203
+ * Whether the managers have already been told this site is at the ceiling.
204
+ *
205
+ * The notice goes out once per CROSSING (AGL-3442): on the first refusal after
206
+ * the site reaches the ceiling, never again while it stays there, and again
207
+ * only after it has been seen below the ceiling and reached it once more. A
208
+ * site can sit at this ceiling for months, and a notice every month would be
209
+ * an alarm about a state nobody needs telling about twice.
210
+ *
211
+ * A counter written before {@link VISITOR_RECORD_NOTICE_SENT_FIELD} existed
212
+ * does not carry it. Its writer sent the notice on the first refusal of each
213
+ * month, so any month with a refusal is a month the notice went out, and such
214
+ * a counter reads as announced. Otherwise every site already at its ceiling
215
+ * would be notified again on its next refusal.
216
+ */
217
+ export declare function visitorRecordCeilingAnnounced(counter: Record<string, unknown> | null | undefined): boolean;
218
+ /**
219
+ * Whether accepting one more record, with `used` already held, takes the
220
+ * site's last free slot under `ceiling`.
221
+ *
222
+ * Every new record is judged against the ceiling one at a time, so a site that
223
+ * dropped below the ceiling can only reach it again through this accept. That
224
+ * makes it the one accept where the notice is re-armed, and the only one that
225
+ * pays a read for it.
226
+ */
227
+ export declare function visitorRecordAcceptFillsCeiling(used: number, ceiling: number): boolean;
196
228
  /** What the site's owner is shown. Shaped like `FormsPausedOwnerNotice`. */
197
229
  export interface VisitorRecordsPausedNotice {
198
230
  title: string;
@@ -186,6 +186,45 @@
186
186
  */ export function visitorRecordRefusedCounterId(kind) {
187
187
  return kind === 'leads' ? 'leadsRefused' : 'siteMembersRefused';
188
188
  }
189
+ /**
190
+ * The field on the refusal counter that says whether the site's managers have
191
+ * been told about the crossing the site is in: when the notice went out, or
192
+ * `null` once the site has been seen below the ceiling again (AGL-3442).
193
+ */ export const VISITOR_RECORD_NOTICE_SENT_FIELD = 'noticeSentAtMs';
194
+ /** A month key on the refusal counter — `submissionMonthKey`'s `YYYY-MM`. */ const REFUSAL_MONTH_KEY = /^\d{4}-\d{2}$/;
195
+ /**
196
+ * Whether the managers have already been told this site is at the ceiling.
197
+ *
198
+ * The notice goes out once per CROSSING (AGL-3442): on the first refusal after
199
+ * the site reaches the ceiling, never again while it stays there, and again
200
+ * only after it has been seen below the ceiling and reached it once more. A
201
+ * site can sit at this ceiling for months, and a notice every month would be
202
+ * an alarm about a state nobody needs telling about twice.
203
+ *
204
+ * A counter written before {@link VISITOR_RECORD_NOTICE_SENT_FIELD} existed
205
+ * does not carry it. Its writer sent the notice on the first refusal of each
206
+ * month, so any month with a refusal is a month the notice went out, and such
207
+ * a counter reads as announced. Otherwise every site already at its ceiling
208
+ * would be notified again on its next refusal.
209
+ */ export function visitorRecordCeilingAnnounced(counter) {
210
+ if (!counter) return false;
211
+ const sent = counter[VISITOR_RECORD_NOTICE_SENT_FIELD];
212
+ if (sent === null) return false;
213
+ if (sent !== undefined) return true;
214
+ return Object.entries(counter).some(([key, value])=>REFUSAL_MONTH_KEY.test(key) && Number(value) > 0);
215
+ }
216
+ /**
217
+ * Whether accepting one more record, with `used` already held, takes the
218
+ * site's last free slot under `ceiling`.
219
+ *
220
+ * Every new record is judged against the ceiling one at a time, so a site that
221
+ * dropped below the ceiling can only reach it again through this accept. That
222
+ * makes it the one accept where the notice is re-armed, and the only one that
223
+ * pays a read for it.
224
+ */ export function visitorRecordAcceptFillsCeiling(used, ceiling) {
225
+ const resolvedUsed = Math.max(0, Math.floor(Number(used) || 0));
226
+ return checkVisitorRecordCeiling(resolvedUsed + 1, ceiling).exceeded;
227
+ }
189
228
  /**
190
229
  * The owner's notice — the half deliberately withheld from the visitor.
191
230
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/visitor-record-ceiling.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 * Flat platform ceilings on the two host subcollections an ANONYMOUS VISITOR\n * can create documents in (AGL-1529).\n *\n * ## What this is\n *\n * A platform-wide ceiling, and deliberately NOT a plan dimension — so\n * \"unlimited member accounts on every plan\" stays true, because an abuse\n * control is not something we sell. The same instrument is already in place\n * twice: AGL-1655 for forms and AGL-2155 for bandwidth.\n *\n * ## Why it is not a plan dimension, and must never become one\n *\n * AGL-889 decided member accounts are UNLIMITED on every plan as a PRICING\n * matter, and `/pricing` says so. A cap that varied by plan would make that\n * sentence false the moment it shipped. So there is no `OrgEntitlements` key\n * here, no `PLAN_ENTITLEMENTS` row, no band and nothing to re-price: every\n * plan, free through enterprise, gets the same number, and the number exists\n * only to answer the question `checkFormSubmissionAbuseCeiling`'s docblock\n * poses — *\"is this still a customer's traffic at all?\"* A plan gate asks\n * whether the customer bought this; this asks whether a customer is involved.\n *\n * ## The hole this closes\n *\n * `hosts/{hostId}/siteMembers` and `hosts/{hostId}/leads` were bounded by\n * NOTHING. Both are written on behalf of anonymous visitors on a public site\n * (`membershipRegisterHandler`, the two bookings lead paths), and the only\n * bound was `visitorWriteRateLimitRefusal` — keyed on (host, IP), fails soft,\n * and bounds the RATE rather than the TOTAL. That is verbatim the sentence\n * AGL-2265 used as its reason for adding the free-workspace ceiling, and the\n * shape AGL-2266 closed for `actions` and `entries`: unbounded Firestore\n * documents behind a $0 subscription.\n *\n * ## Why the shape here is the FLAT family, not the MULTIPLE family\n *\n * The two ceilings named as precedent — `FORM_ABUSE_CEILING_*` and\n * `BANDWIDTH_ABUSE_CEILING_*` — are both `max(floor, included × 10)`. They\n * can be, because forms and bandwidth each have an *included band* on the\n * price list to multiply. Members and leads have no included band by\n * construction (that is the whole point of AGL-889), so there is nothing to\n * multiply and a multiple would have to invent a plan dimension to have an\n * operand — the exact thing the decision forbids.\n *\n * The repo already has the family for that case, and those same docblocks\n * point at it: `WEBHOOK_MAX_PER_HOST` (AGL-1360), `NON_PAGE_SCREEN_MAX_PER_HOST`\n * (AGL-1399), `ACTIONS_MAX_PER_HOST` / `ENTRIES_MAX_PER_COLLECTION` (AGL-2266),\n * `AUTHORS_MAX_PER_HOST` (AGL-2486) — *\"a flat PLATFORM cap with no\n * `OrgEntitlements` key, no variation by plan, and nothing on the price list\n * to explain.\"* So the two named precedents supply the POSTURE (containment,\n * not capacity; a trip is an incident, not a quota; the refusal must be\n * visible to the host and opaque to the visitor) and the flat family supplies\n * the SHAPE. They do not disagree; they answer different halves.\n *\n * ## A TOTAL, not a monthly count — the one place the posture differs\n *\n * `FORM_ABUSE_CEILING` and `BANDWIDTH_ABUSE_CEILING` both count a month and\n * therefore self-clear at the month boundary with nobody doing anything. These\n * count LIVE DOCUMENTS, which do not. The refusal counters below are still\n * month-keyed — that is a rate of refusals, and it is what the owner's notice\n * reports — but the ceiling itself is cleared by deleting members or leads (or\n * by support raising it), and the owner's notice must not promise a date. A\n * notice that said \"accepting again on 1 September\" would be a promise the\n * platform does not keep.\n */\n\n/**\n * How many member accounts one site may hold (AGL-1529). the figure.\n *\n * Sized to the busiest plausible membership site rather than to today's data,\n * which is the only property a flat ceiling needs. A site member is a human\n * who chose a password on ONE site; 50,000 of them is a large membership\n * roster — larger than anything on the platform and larger than anything the\n * beta will produce — while a script wants millions and reaches this in an\n * afternoon. Every account past it is refused, none is billed, and none of\n * it changes what any plan includes.\n *\n * Counted as LIVE documents, so removing a member in the inbox frees the\n * slot. Counting tombstones would be AGL-1173's bug one collection over.\n */\nexport const SITE_MEMBERS_MAX_PER_HOST = 50_000\n\n/**\n * How many lead records one site may hold (AGL-1529).\n *\n * **Deliberately NOT the same number as {@link SITE_MEMBERS_MAX_PER_HOST}**,\n * and the reason changed when leads were deduped.\n *\n * A lead used to be APPEND-ONLY, keyed by nothing: every sign-up wrote one\n * and so did every booking, so one returning customer legitimately produced\n * many rows. 4× was sized to keep the MEMBER ceiling the binding one on the\n * path they share, rather than refusing a record on a site doing nothing\n * wrong. Since `docs/specs/reusable-forms.md` §4 a lead is one document per\n * person per host, keyed on `personKey`, so leads and members now count the\n * same population and that arithmetic no longer applies.\n *\n * **The number stays where it is.** Lowering it because the collection stopped\n * growing per event would convert a safety margin into a plan limit, which\n * `docs/DECISION_LOG.md` forbids — the 2026-08-23 entry made this ceiling a\n * flat platform abuse control precisely so \"unlimited leads on every plan\"\n * stays literally true. What the headroom now buys is a ceiling that is very\n * unlikely to trip at all, which is the right shape for a control that\n * refuses a person: 200,000 lead rows at ~200 bytes is ~40 MB, which is a\n * number, and unbounded is not.\n *\n * ⚠️ It must stay ABOVE {@link SITE_MEMBERS_MAX_PER_HOST}. A site member is\n * also a lead, so an equal or lower lead ceiling would refuse the side effect\n * of a sign-up the member ceiling had just allowed.\n */\nexport const LEADS_MAX_PER_HOST = 200_000\n\n/**\n * Refusal code carried by the sign-up 429 (AGL-1529).\n *\n * The dispatcher's per-(host, IP) rate limiter answers 429 too, so the STATUS\n * discriminates nothing — this code is the whole discriminator, and one means\n * \"slow down\" while the other means \"this site is not taking new accounts\".\n * Shared rather than restated so producer and consumer cannot drift, exactly\n * as `FORM_ABUSE_CEILING_CODE` is.\n */\nexport const SITE_MEMBER_CEILING_CODE = 'site-member-ceiling'\n\n/**\n * The visitor's sentence, under the three rules `FORM_UNAVAILABLE_MESSAGE`\n * established (AGL-1666) and for the identical reason: the person reading it\n * is a stranger to the site and NOT our customer.\n *\n * 1. **It does not blame them.** They filled the form in correctly. Nothing\n * about their address, their password or their retry is the reason.\n * 2. **It does not explain.** \"Unusual volume\", \"this site is at its member\n * limit\", or anything naming Aglyn, tells a stranger something about the\n * site owner's account that the owner never chose to publish — and names\n * a platform the visitor did not come here to hear about.\n * 3. **It does not sound successful.** It says, in as many words, that the\n * account was not created, so nobody walks away believing they have one\n * and waiting for an email that is never coming.\n */\nexport const SITE_MEMBER_UNAVAILABLE_MESSAGE =\n 'This site isn’t accepting new accounts right now, so your account was ' +\n 'not created. Please try again later.'\n\n/** The verdict every enforcement point below asks for. */\nexport interface VisitorRecordCeilingResult {\n /** True once the live document count has REACHED the ceiling. */\n exceeded: boolean\n /** The ceiling that was compared against. Always a finite integer. */\n ceiling: number\n /** The count that was compared, floored at 0. */\n used: number\n}\n\n/**\n * Is this site at a flat visitor-record ceiling?\n *\n * The ceiling is an explicit argument rather than read from a table, so the\n * suite can re-drive the SAME usage against a ceiling one higher and require\n * it to succeed — the causation leg of `free-tier-caps-refuse.spec.ts`, which\n * is the only thing that distinguishes \"refused by THIS cap\" from \"refuses\n * everything\". No production caller passes anything but the two constants\n * above; {@link checkSiteMemberCeiling} and {@link checkLeadCeiling} bind them.\n *\n * `strictNullChecks` is OFF repo-wide, so both inputs are normalised rather\n * than trusted, and the two directions are deliberately NOT symmetric:\n *\n * - `used` folds a missing/NaN count to 0, matching `checkBandwidthAbuseCeiling`.\n * An unreadable count must not refuse a site; the count is read inside a\n * transaction that throws rather than returning nothing, so this is a\n * belt, not a path.\n * - `ceiling` folds a missing/NaN ceiling to 0, and a ceiling of 0 REFUSES\n * EVERYTHING. `0` is a legitimate ceiling meaning \"none allowed\" and must\n * never read as unlimited — the `if (!count)` trap this repo keeps being\n * bitten by, in the argument that decides the refusal.\n */\nexport function checkVisitorRecordCeiling(\n used: number,\n ceiling: number,\n): VisitorRecordCeilingResult {\n const resolvedCeiling = Math.max(0, Math.floor(Number(ceiling) || 0))\n const resolvedUsed = Math.max(0, Math.floor(Number(used) || 0))\n return {\n exceeded: resolvedUsed >= resolvedCeiling,\n ceiling: resolvedCeiling,\n used: resolvedUsed,\n }\n}\n\n/** {@link checkVisitorRecordCeiling} bound to {@link SITE_MEMBERS_MAX_PER_HOST}. */\nexport function checkSiteMemberCeiling(\n used: number,\n): VisitorRecordCeilingResult {\n return checkVisitorRecordCeiling(used, SITE_MEMBERS_MAX_PER_HOST)\n}\n\n/** {@link checkVisitorRecordCeiling} bound to {@link LEADS_MAX_PER_HOST}. */\nexport function checkLeadCeiling(used: number): VisitorRecordCeilingResult {\n return checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST)\n}\n\n/** Which collection a trip belongs to — the counter id and the notice differ. */\nexport type VisitorRecordKind = 'siteMembers' | 'leads'\n\n/**\n * `hosts/{hostId}/counters/{…}` — the durable, client-unwritable record of a\n * trip, in the same counters-document shape `formSubmissionsRefused` uses\n * (AGL-1367/1655). Named through a function so the writer in\n * `@aglyn/tenant-data-admin` and the reader in the inbox console page cannot\n * spell it two different ways.\n */\nexport function visitorRecordRefusedCounterId(kind: VisitorRecordKind): string {\n return kind === 'leads' ? 'leadsRefused' : 'siteMembersRefused'\n}\n\n/** What the site's owner is shown. Shaped like `FormsPausedOwnerNotice`. */\nexport interface VisitorRecordsPausedNotice {\n title: string\n /** What happened, how many, and what actually clears it. */\n message: string\n}\n\n/**\n * The owner's notice — the half deliberately withheld from the visitor.\n *\n * Two `{title, message}` lines rather than `LockdownRefusalNotice`'s three:\n * there is **no `until`**, and its absence is the point. The form and\n * bandwidth ceilings count a month and genuinely do lift on the 1st, so their\n * notices name a date. This ceiling counts live documents, so no date lifts\n * it — deleting records does, or support raising it does. Borrowing the third\n * line for symmetry would print a date that means nothing.\n *\n * Returns `null` below one refusal, the same rule as\n * `formSubmissionsPausedNotice` and for the same reason: the counter document\n * persists from its first trip forever, and \"0 refused\" on every later month\n * trains the owner to ignore the row that will one day be real.\n */\nexport function visitorRecordsPausedNotice(input: {\n kind: VisitorRecordKind\n /** Refusals recorded for the month being displayed. */\n refused: number\n /** The ceiling that was crossed, from the counter document. */\n ceiling?: number\n}): VisitorRecordsPausedNotice | null {\n const refused = Math.floor(Number(input.refused) || 0)\n if (refused < 1) return null\n const ceiling =\n typeof input.ceiling === 'number' && Number.isFinite(input.ceiling)\n ? Math.floor(input.ceiling)\n : undefined\n const leads = input.kind === 'leads'\n const noun = leads ? 'lead' : 'sign-up'\n return {\n title: leads\n ? 'Lead capture is paused'\n : 'New member sign-ups are paused',\n message:\n `${refused.toLocaleString()} ${noun}${refused === 1 ? '' : 's'} on ` +\n `this site ${refused === 1 ? 'has' : 'have'} been refused this month` +\n (ceiling\n ? ` after it reached ${ceiling.toLocaleString()} ` +\n `${leads ? 'leads' : 'member accounts'}`\n : '') +\n '. Refused records are not stored and are not billed. This is a ' +\n 'platform safety limit, not part of your plan — every plan includes ' +\n `unlimited ${leads ? 'leads' : 'member accounts'}. Removing ` +\n `${leads ? 'leads' : 'members'} below the limit starts acceptance ` +\n 'again immediately; if this is real traffic, contact support and we ' +\n 'will raise it.',\n }\n}\n"],"names":["SITE_MEMBERS_MAX_PER_HOST","LEADS_MAX_PER_HOST","SITE_MEMBER_CEILING_CODE","SITE_MEMBER_UNAVAILABLE_MESSAGE","checkVisitorRecordCeiling","used","ceiling","resolvedCeiling","Math","max","floor","Number","resolvedUsed","exceeded","checkSiteMemberCeiling","checkLeadCeiling","visitorRecordRefusedCounterId","kind","visitorRecordsPausedNotice","input","refused","isFinite","undefined","leads","noun","title","message","toLocaleString"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+DC,GAED;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAMA,4BAA4B,MAAM;AAE/C;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,MAAMC,qBAAqB,OAAO;AAEzC;;;;;;;;CAQC,GACD,OAAO,MAAMC,2BAA2B,sBAAqB;AAE7D;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMC,kCACX,2EACA,uCAAsC;AAYxC;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,SAASC,0BACdC,IAAY,EACZC,OAAe;IAEf,MAAMC,kBAAkBC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACC,OAAOL,YAAY;IAClE,MAAMM,eAAeJ,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACC,OAAON,SAAS;IAC5D,OAAO;QACLQ,UAAUD,gBAAgBL;QAC1BD,SAASC;QACTF,MAAMO;IACR;AACF;AAEA,kFAAkF,GAClF,OAAO,SAASE,uBACdT,IAAY;IAEZ,OAAOD,0BAA0BC,MAAML;AACzC;AAEA,2EAA2E,GAC3E,OAAO,SAASe,iBAAiBV,IAAY;IAC3C,OAAOD,0BAA0BC,MAAMJ;AACzC;AAKA;;;;;;CAMC,GACD,OAAO,SAASe,8BAA8BC,IAAuB;IACnE,OAAOA,SAAS,UAAU,iBAAiB;AAC7C;AASA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,2BAA2BC,KAM1C;IACC,MAAMC,UAAUZ,KAAKE,KAAK,CAACC,OAAOQ,MAAMC,OAAO,KAAK;IACpD,IAAIA,UAAU,GAAG,OAAO;IACxB,MAAMd,UACJ,OAAOa,MAAMb,OAAO,KAAK,YAAYK,OAAOU,QAAQ,CAACF,MAAMb,OAAO,IAC9DE,KAAKE,KAAK,CAACS,MAAMb,OAAO,IACxBgB;IACN,MAAMC,QAAQJ,MAAMF,IAAI,KAAK;IAC7B,MAAMO,OAAOD,QAAQ,SAAS;IAC9B,OAAO;QACLE,OAAOF,QACH,2BACA;QACJG,SACE,GAAGN,QAAQO,cAAc,GAAG,CAAC,EAAEH,OAAOJ,YAAY,IAAI,KAAK,IAAI,IAAI,CAAC,GACpE,CAAC,UAAU,EAAEA,YAAY,IAAI,QAAQ,OAAO,wBAAwB,CAAC,GACpEd,CAAAA,UACG,CAAC,kBAAkB,EAAEA,QAAQqB,cAAc,GAAG,CAAC,CAAC,GAChD,GAAGJ,QAAQ,UAAU,mBAAmB,GACxC,EAAC,IACL,oEACA,wEACA,CAAC,UAAU,EAAEA,QAAQ,UAAU,kBAAkB,WAAW,CAAC,GAC7D,GAAGA,QAAQ,UAAU,UAAU,mCAAmC,CAAC,GACnE,wEACA;IACJ;AACF"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/visitor-record-ceiling.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 * Flat platform ceilings on the two host subcollections an ANONYMOUS VISITOR\n * can create documents in (AGL-1529).\n *\n * ## What this is\n *\n * A platform-wide ceiling, and deliberately NOT a plan dimension — so\n * \"unlimited member accounts on every plan\" stays true, because an abuse\n * control is not something we sell. The same instrument is already in place\n * twice: AGL-1655 for forms and AGL-2155 for bandwidth.\n *\n * ## Why it is not a plan dimension, and must never become one\n *\n * AGL-889 decided member accounts are UNLIMITED on every plan as a PRICING\n * matter, and `/pricing` says so. A cap that varied by plan would make that\n * sentence false the moment it shipped. So there is no `OrgEntitlements` key\n * here, no `PLAN_ENTITLEMENTS` row, no band and nothing to re-price: every\n * plan, free through enterprise, gets the same number, and the number exists\n * only to answer the question `checkFormSubmissionAbuseCeiling`'s docblock\n * poses — *\"is this still a customer's traffic at all?\"* A plan gate asks\n * whether the customer bought this; this asks whether a customer is involved.\n *\n * ## The hole this closes\n *\n * `hosts/{hostId}/siteMembers` and `hosts/{hostId}/leads` were bounded by\n * NOTHING. Both are written on behalf of anonymous visitors on a public site\n * (`membershipRegisterHandler`, the two bookings lead paths), and the only\n * bound was `visitorWriteRateLimitRefusal` — keyed on (host, IP), fails soft,\n * and bounds the RATE rather than the TOTAL. That is verbatim the sentence\n * AGL-2265 used as its reason for adding the free-workspace ceiling, and the\n * shape AGL-2266 closed for `actions` and `entries`: unbounded Firestore\n * documents behind a $0 subscription.\n *\n * ## Why the shape here is the FLAT family, not the MULTIPLE family\n *\n * The two ceilings named as precedent — `FORM_ABUSE_CEILING_*` and\n * `BANDWIDTH_ABUSE_CEILING_*` — are both `max(floor, included × 10)`. They\n * can be, because forms and bandwidth each have an *included band* on the\n * price list to multiply. Members and leads have no included band by\n * construction (that is the whole point of AGL-889), so there is nothing to\n * multiply and a multiple would have to invent a plan dimension to have an\n * operand — the exact thing the decision forbids.\n *\n * The repo already has the family for that case, and those same docblocks\n * point at it: `WEBHOOK_MAX_PER_HOST` (AGL-1360), `NON_PAGE_SCREEN_MAX_PER_HOST`\n * (AGL-1399), `ACTIONS_MAX_PER_HOST` / `ENTRIES_MAX_PER_COLLECTION` (AGL-2266),\n * `AUTHORS_MAX_PER_HOST` (AGL-2486) — *\"a flat PLATFORM cap with no\n * `OrgEntitlements` key, no variation by plan, and nothing on the price list\n * to explain.\"* So the two named precedents supply the POSTURE (containment,\n * not capacity; a trip is an incident, not a quota; the refusal must be\n * visible to the host and opaque to the visitor) and the flat family supplies\n * the SHAPE. They do not disagree; they answer different halves.\n *\n * ## A TOTAL, not a monthly count — the one place the posture differs\n *\n * `FORM_ABUSE_CEILING` and `BANDWIDTH_ABUSE_CEILING` both count a month and\n * therefore self-clear at the month boundary with nobody doing anything. These\n * count LIVE DOCUMENTS, which do not. The refusal counters below are still\n * month-keyed — that is a rate of refusals, and it is what the owner's notice\n * reports — but the ceiling itself is cleared by deleting members or leads (or\n * by support raising it), and the owner's notice must not promise a date. A\n * notice that said \"accepting again on 1 September\" would be a promise the\n * platform does not keep.\n */\n\n/**\n * How many member accounts one site may hold (AGL-1529). the figure.\n *\n * Sized to the busiest plausible membership site rather than to today's data,\n * which is the only property a flat ceiling needs. A site member is a human\n * who chose a password on ONE site; 50,000 of them is a large membership\n * roster — larger than anything on the platform and larger than anything the\n * beta will produce — while a script wants millions and reaches this in an\n * afternoon. Every account past it is refused, none is billed, and none of\n * it changes what any plan includes.\n *\n * Counted as LIVE documents, so removing a member in the inbox frees the\n * slot. Counting tombstones would be AGL-1173's bug one collection over.\n */\nexport const SITE_MEMBERS_MAX_PER_HOST = 50_000\n\n/**\n * How many lead records one site may hold (AGL-1529).\n *\n * **Deliberately NOT the same number as {@link SITE_MEMBERS_MAX_PER_HOST}**,\n * and the reason changed when leads were deduped.\n *\n * A lead used to be APPEND-ONLY, keyed by nothing: every sign-up wrote one\n * and so did every booking, so one returning customer legitimately produced\n * many rows. 4× was sized to keep the MEMBER ceiling the binding one on the\n * path they share, rather than refusing a record on a site doing nothing\n * wrong. Since `docs/specs/reusable-forms.md` §4 a lead is one document per\n * person per host, keyed on `personKey`, so leads and members now count the\n * same population and that arithmetic no longer applies.\n *\n * **The number stays where it is.** Lowering it because the collection stopped\n * growing per event would convert a safety margin into a plan limit, which\n * `docs/DECISION_LOG.md` forbids — the 2026-08-23 entry made this ceiling a\n * flat platform abuse control precisely so \"unlimited leads on every plan\"\n * stays literally true. What the headroom now buys is a ceiling that is very\n * unlikely to trip at all, which is the right shape for a control that\n * refuses a person: 200,000 lead rows at ~200 bytes is ~40 MB, which is a\n * number, and unbounded is not.\n *\n * ⚠️ It must stay ABOVE {@link SITE_MEMBERS_MAX_PER_HOST}. A site member is\n * also a lead, so an equal or lower lead ceiling would refuse the side effect\n * of a sign-up the member ceiling had just allowed.\n */\nexport const LEADS_MAX_PER_HOST = 200_000\n\n/**\n * Refusal code carried by the sign-up 429 (AGL-1529).\n *\n * The dispatcher's per-(host, IP) rate limiter answers 429 too, so the STATUS\n * discriminates nothing — this code is the whole discriminator, and one means\n * \"slow down\" while the other means \"this site is not taking new accounts\".\n * Shared rather than restated so producer and consumer cannot drift, exactly\n * as `FORM_ABUSE_CEILING_CODE` is.\n */\nexport const SITE_MEMBER_CEILING_CODE = 'site-member-ceiling'\n\n/**\n * The visitor's sentence, under the three rules `FORM_UNAVAILABLE_MESSAGE`\n * established (AGL-1666) and for the identical reason: the person reading it\n * is a stranger to the site and NOT our customer.\n *\n * 1. **It does not blame them.** They filled the form in correctly. Nothing\n * about their address, their password or their retry is the reason.\n * 2. **It does not explain.** \"Unusual volume\", \"this site is at its member\n * limit\", or anything naming Aglyn, tells a stranger something about the\n * site owner's account that the owner never chose to publish — and names\n * a platform the visitor did not come here to hear about.\n * 3. **It does not sound successful.** It says, in as many words, that the\n * account was not created, so nobody walks away believing they have one\n * and waiting for an email that is never coming.\n */\nexport const SITE_MEMBER_UNAVAILABLE_MESSAGE =\n 'This site isn’t accepting new accounts right now, so your account was ' +\n 'not created. Please try again later.'\n\n/** The verdict every enforcement point below asks for. */\nexport interface VisitorRecordCeilingResult {\n /** True once the live document count has REACHED the ceiling. */\n exceeded: boolean\n /** The ceiling that was compared against. Always a finite integer. */\n ceiling: number\n /** The count that was compared, floored at 0. */\n used: number\n}\n\n/**\n * Is this site at a flat visitor-record ceiling?\n *\n * The ceiling is an explicit argument rather than read from a table, so the\n * suite can re-drive the SAME usage against a ceiling one higher and require\n * it to succeed — the causation leg of `free-tier-caps-refuse.spec.ts`, which\n * is the only thing that distinguishes \"refused by THIS cap\" from \"refuses\n * everything\". No production caller passes anything but the two constants\n * above; {@link checkSiteMemberCeiling} and {@link checkLeadCeiling} bind them.\n *\n * `strictNullChecks` is OFF repo-wide, so both inputs are normalised rather\n * than trusted, and the two directions are deliberately NOT symmetric:\n *\n * - `used` folds a missing/NaN count to 0, matching `checkBandwidthAbuseCeiling`.\n * An unreadable count must not refuse a site; the count is read inside a\n * transaction that throws rather than returning nothing, so this is a\n * belt, not a path.\n * - `ceiling` folds a missing/NaN ceiling to 0, and a ceiling of 0 REFUSES\n * EVERYTHING. `0` is a legitimate ceiling meaning \"none allowed\" and must\n * never read as unlimited — the `if (!count)` trap this repo keeps being\n * bitten by, in the argument that decides the refusal.\n */\nexport function checkVisitorRecordCeiling(\n used: number,\n ceiling: number,\n): VisitorRecordCeilingResult {\n const resolvedCeiling = Math.max(0, Math.floor(Number(ceiling) || 0))\n const resolvedUsed = Math.max(0, Math.floor(Number(used) || 0))\n return {\n exceeded: resolvedUsed >= resolvedCeiling,\n ceiling: resolvedCeiling,\n used: resolvedUsed,\n }\n}\n\n/** {@link checkVisitorRecordCeiling} bound to {@link SITE_MEMBERS_MAX_PER_HOST}. */\nexport function checkSiteMemberCeiling(\n used: number,\n): VisitorRecordCeilingResult {\n return checkVisitorRecordCeiling(used, SITE_MEMBERS_MAX_PER_HOST)\n}\n\n/** {@link checkVisitorRecordCeiling} bound to {@link LEADS_MAX_PER_HOST}. */\nexport function checkLeadCeiling(used: number): VisitorRecordCeilingResult {\n return checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST)\n}\n\n/** Which collection a trip belongs to — the counter id and the notice differ. */\nexport type VisitorRecordKind = 'siteMembers' | 'leads'\n\n/**\n * `hosts/{hostId}/counters/{…}` — the durable, client-unwritable record of a\n * trip, in the same counters-document shape `formSubmissionsRefused` uses\n * (AGL-1367/1655). Named through a function so the writer in\n * `@aglyn/tenant-data-admin` and the reader in the inbox console page cannot\n * spell it two different ways.\n */\nexport function visitorRecordRefusedCounterId(kind: VisitorRecordKind): string {\n return kind === 'leads' ? 'leadsRefused' : 'siteMembersRefused'\n}\n\n/**\n * The field on the refusal counter that says whether the site's managers have\n * been told about the crossing the site is in: when the notice went out, or\n * `null` once the site has been seen below the ceiling again (AGL-3442).\n */\nexport const VISITOR_RECORD_NOTICE_SENT_FIELD = 'noticeSentAtMs'\n\n/** A month key on the refusal counter — `submissionMonthKey`'s `YYYY-MM`. */\nconst REFUSAL_MONTH_KEY = /^\\d{4}-\\d{2}$/\n\n/**\n * Whether the managers have already been told this site is at the ceiling.\n *\n * The notice goes out once per CROSSING (AGL-3442): on the first refusal after\n * the site reaches the ceiling, never again while it stays there, and again\n * only after it has been seen below the ceiling and reached it once more. A\n * site can sit at this ceiling for months, and a notice every month would be\n * an alarm about a state nobody needs telling about twice.\n *\n * A counter written before {@link VISITOR_RECORD_NOTICE_SENT_FIELD} existed\n * does not carry it. Its writer sent the notice on the first refusal of each\n * month, so any month with a refusal is a month the notice went out, and such\n * a counter reads as announced. Otherwise every site already at its ceiling\n * would be notified again on its next refusal.\n */\nexport function visitorRecordCeilingAnnounced(\n counter: Record<string, unknown> | null | undefined,\n): boolean {\n if (!counter) return false\n const sent = counter[VISITOR_RECORD_NOTICE_SENT_FIELD]\n if (sent === null) return false\n if (sent !== undefined) return true\n return Object.entries(counter).some(\n ([key, value]) => REFUSAL_MONTH_KEY.test(key) && Number(value) > 0,\n )\n}\n\n/**\n * Whether accepting one more record, with `used` already held, takes the\n * site's last free slot under `ceiling`.\n *\n * Every new record is judged against the ceiling one at a time, so a site that\n * dropped below the ceiling can only reach it again through this accept. That\n * makes it the one accept where the notice is re-armed, and the only one that\n * pays a read for it.\n */\nexport function visitorRecordAcceptFillsCeiling(\n used: number,\n ceiling: number,\n): boolean {\n const resolvedUsed = Math.max(0, Math.floor(Number(used) || 0))\n return checkVisitorRecordCeiling(resolvedUsed + 1, ceiling).exceeded\n}\n\n/** What the site's owner is shown. Shaped like `FormsPausedOwnerNotice`. */\nexport interface VisitorRecordsPausedNotice {\n title: string\n /** What happened, how many, and what actually clears it. */\n message: string\n}\n\n/**\n * The owner's notice — the half deliberately withheld from the visitor.\n *\n * Two `{title, message}` lines rather than `LockdownRefusalNotice`'s three:\n * there is **no `until`**, and its absence is the point. The form and\n * bandwidth ceilings count a month and genuinely do lift on the 1st, so their\n * notices name a date. This ceiling counts live documents, so no date lifts\n * it — deleting records does, or support raising it does. Borrowing the third\n * line for symmetry would print a date that means nothing.\n *\n * Returns `null` below one refusal, the same rule as\n * `formSubmissionsPausedNotice` and for the same reason: the counter document\n * persists from its first trip forever, and \"0 refused\" on every later month\n * trains the owner to ignore the row that will one day be real.\n */\nexport function visitorRecordsPausedNotice(input: {\n kind: VisitorRecordKind\n /** Refusals recorded for the month being displayed. */\n refused: number\n /** The ceiling that was crossed, from the counter document. */\n ceiling?: number\n}): VisitorRecordsPausedNotice | null {\n const refused = Math.floor(Number(input.refused) || 0)\n if (refused < 1) return null\n const ceiling =\n typeof input.ceiling === 'number' && Number.isFinite(input.ceiling)\n ? Math.floor(input.ceiling)\n : undefined\n const leads = input.kind === 'leads'\n const noun = leads ? 'lead' : 'sign-up'\n return {\n title: leads\n ? 'Lead capture is paused'\n : 'New member sign-ups are paused',\n message:\n `${refused.toLocaleString()} ${noun}${refused === 1 ? '' : 's'} on ` +\n `this site ${refused === 1 ? 'has' : 'have'} been refused this month` +\n (ceiling\n ? ` after it reached ${ceiling.toLocaleString()} ` +\n `${leads ? 'leads' : 'member accounts'}`\n : '') +\n '. Refused records are not stored and are not billed. This is a ' +\n 'platform safety limit, not part of your plan — every plan includes ' +\n `unlimited ${leads ? 'leads' : 'member accounts'}. Removing ` +\n `${leads ? 'leads' : 'members'} below the limit starts acceptance ` +\n 'again immediately; if this is real traffic, contact support and we ' +\n 'will raise it.',\n }\n}\n"],"names":["SITE_MEMBERS_MAX_PER_HOST","LEADS_MAX_PER_HOST","SITE_MEMBER_CEILING_CODE","SITE_MEMBER_UNAVAILABLE_MESSAGE","checkVisitorRecordCeiling","used","ceiling","resolvedCeiling","Math","max","floor","Number","resolvedUsed","exceeded","checkSiteMemberCeiling","checkLeadCeiling","visitorRecordRefusedCounterId","kind","VISITOR_RECORD_NOTICE_SENT_FIELD","REFUSAL_MONTH_KEY","visitorRecordCeilingAnnounced","counter","sent","undefined","Object","entries","some","key","value","test","visitorRecordAcceptFillsCeiling","visitorRecordsPausedNotice","input","refused","isFinite","leads","noun","title","message","toLocaleString"],"mappings":"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"}
@@ -813,8 +813,8 @@ export interface OrgStorageOverage {
813
813
  * could set it on another org would switch that org's assistant off at the
814
814
  * band. So the rules deny it to every client and only
815
815
  * `/api/billing/assist-overage` (Admin SDK, `billing.manage`) writes it. Read
816
- * through `resolveAssistHardCap` in `app-utils/assist-credits`, never
817
- * directly.
816
+ * through `resolveAssistHardCap` in the AI plugin's `usage/assist-credits`,
817
+ * never directly.
818
818
  *
819
819
  * `report-usage` never reads this map — it bills whatever landed past the
820
820
  * band, the way storage bills stored bytes. With the cap on that is at most