@aglyn/tenant-data-admin 1.0.0-beta.219 → 1.0.0-beta.220

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/package.json +8 -8
  2. package/src/lib/server/api-http.js +5 -1
  3. package/src/lib/server/api-http.js.map +1 -1
  4. package/src/lib/server/api-v1-kit.d.ts +7 -0
  5. package/src/lib/server/api-v1-kit.js.map +1 -1
  6. package/src/lib/server/api-v1-resources.d.ts +1 -2
  7. package/src/lib/server/api-v1-resources.js +0 -1
  8. package/src/lib/server/api-v1-resources.js.map +1 -1
  9. package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
  10. package/src/lib/server/campaign-conversion-attribution.js +3 -3
  11. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  12. package/src/lib/server/card-payment-velocity.d.ts +9 -0
  13. package/src/lib/server/card-payment-velocity.js +28 -2
  14. package/src/lib/server/card-payment-velocity.js.map +1 -1
  15. package/src/lib/server/consent-group-carry.js +1 -1
  16. package/src/lib/server/consent-group-carry.js.map +1 -1
  17. package/src/lib/server/consent-group-change.js +2 -2
  18. package/src/lib/server/consent-group-change.js.map +1 -1
  19. package/src/lib/server/dynamic-list-materialize.js +3 -3
  20. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  21. package/src/lib/server/email-flow-gate.js +2 -2
  22. package/src/lib/server/email-flow-gate.js.map +1 -1
  23. package/src/lib/server/email-suppression.js +1 -1
  24. package/src/lib/server/email-suppression.js.map +1 -1
  25. package/src/lib/server/host-visitor-records.d.ts +32 -4
  26. package/src/lib/server/host-visitor-records.js +73 -14
  27. package/src/lib/server/host-visitor-records.js.map +1 -1
  28. package/src/lib/server/hosted-page-review.d.ts +27 -0
  29. package/src/lib/server/hosted-page-review.js +149 -12
  30. package/src/lib/server/hosted-page-review.js.map +1 -1
  31. package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
  32. package/src/lib/server/media-cdn-rate-limit.js +6 -3
  33. package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
  34. package/src/lib/server/member-email-aliases.d.ts +9 -1
  35. package/src/lib/server/member-email-aliases.js +25 -1
  36. package/src/lib/server/member-email-aliases.js.map +1 -1
  37. package/src/lib/server/notifications.d.ts +14 -0
  38. package/src/lib/server/notifications.js +29 -0
  39. package/src/lib/server/notifications.js.map +1 -1
  40. package/src/lib/server/operator-alerts.d.ts +7 -1
  41. package/src/lib/server/operator-alerts.js +5 -3
  42. package/src/lib/server/operator-alerts.js.map +1 -1
  43. package/src/lib/server/org-containers.d.ts +66 -0
  44. package/src/lib/server/org-containers.js +95 -0
  45. package/src/lib/server/org-containers.js.map +1 -0
  46. package/src/lib/server/org-member-notice.js +3 -2
  47. package/src/lib/server/org-member-notice.js.map +1 -1
  48. package/src/lib/server/payment-fraud-signal.js +5 -1
  49. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  50. package/src/lib/server/payment-provider-stripe-connect-payouts.js +35 -1
  51. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -1
  52. package/src/lib/server/payment-risk-record.d.ts +1 -1
  53. package/src/lib/server/payment-risk-record.js +3 -1
  54. package/src/lib/server/payment-risk-record.js.map +1 -1
  55. package/src/lib/server/platform-marketing-consent.d.ts +1 -1
  56. package/src/lib/server/platform-marketing-consent.js +1 -1
  57. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  58. package/src/lib/server/risk-notice.d.ts +32 -1
  59. package/src/lib/server/risk-notice.js +90 -28
  60. package/src/lib/server/risk-notice.js.map +1 -1
  61. package/src/lib/server/sending-domains.d.ts +17 -0
  62. package/src/lib/server/sending-domains.js +28 -4
  63. package/src/lib/server/sending-domains.js.map +1 -1
  64. package/src/lib/server/sso-enforcement.js +7 -2
  65. package/src/lib/server/sso-enforcement.js.map +1 -1
  66. package/src/lib/server/staff-alert-email.js +4 -2
  67. package/src/lib/server/staff-alert-email.js.map +1 -1
  68. package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
  69. package/src/lib/server/topic-subscriptions.js.map +1 -0
  70. package/src/lib/server/upsert-contact.js +2 -2
  71. package/src/lib/server/upsert-contact.js.map +1 -1
  72. package/src/tenancy.d.ts +1 -3
  73. package/src/tenancy.js +1 -7
  74. package/src/tenancy.js.map +1 -1
  75. package/src/lib/server/contact-email-engagement.d.ts +0 -32
  76. package/src/lib/server/contact-email-engagement.js +0 -145
  77. package/src/lib/server/contact-email-engagement.js.map +0 -1
  78. package/src/lib/server/crm-booking-activity.d.ts +0 -86
  79. package/src/lib/server/crm-booking-activity.js +0 -257
  80. package/src/lib/server/crm-booking-activity.js.map +0 -1
  81. package/src/lib/server/crm-inbound-email.d.ts +0 -168
  82. package/src/lib/server/crm-inbound-email.js +0 -282
  83. package/src/lib/server/crm-inbound-email.js.map +0 -1
  84. package/src/lib/server/email-topic-confirmation.js.map +0 -1
  85. /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
@@ -57,6 +57,31 @@ function refusal(resetMs, nowMs) {
57
57
  /** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */ export function cardPaymentAlarmReviewId(hostId, day) {
58
58
  return createHash('sha256').update(`payment-velocity:${hostId}:${day}`).digest('hex').slice(0, 40);
59
59
  }
60
+ /**
61
+ * What the alarm is about, in the owner's words:
62
+ * `checkout on the site "Harbor View"`. The label rides on the row, so the
63
+ * review's closing notice and the staff alert name the site too — a
64
+ * workspace with several stores reads which orders to look at. Pure.
65
+ */ export function cardTestingItemLabel(siteName) {
66
+ const name = String(siteName != null ? siteName : '').trim();
67
+ return name ? `checkout on the site "${name.replace(/"/g, '”')}"` : 'checkout on your site';
68
+ }
69
+ /** The site's name for the label, from its document. Never throws. */ async function readSiteName(firestore, hostId) {
70
+ try {
71
+ var _host_get, _host_get1, _host_get2;
72
+ const host = await firestore.collection('hosts').doc(hostId).get();
73
+ for (const value of [
74
+ host == null ? void 0 : (_host_get = host.get) == null ? void 0 : _host_get.call(host, 'displayName'),
75
+ host == null ? void 0 : (_host_get1 = host.get) == null ? void 0 : _host_get1.call(host, 'name'),
76
+ host == null ? void 0 : (_host_get2 = host.get) == null ? void 0 : _host_get2.call(host, 'subdomain')
77
+ ]){
78
+ if (typeof value === 'string' && value.trim()) return value.trim();
79
+ }
80
+ } catch (unused) {
81
+ // The notice still goes, naming "your site".
82
+ }
83
+ return null;
84
+ }
60
85
  /**
61
86
  * Tell staff and the site's managers and the workspace's owners that a site
62
87
  * crossed the card-payment window (AGL-3363), through the risk notice seam
@@ -72,7 +97,7 @@ function refusal(resetMs, nowMs) {
72
97
  reviewId: (_input_reviewId = input.reviewId) != null ? _input_reviewId : null,
73
98
  reference: input.reference,
74
99
  item: {
75
- label: 'your site',
100
+ label: cardTestingItemLabel(input.siteName),
76
101
  path: `/${input.hostId}/products/orders`
77
102
  },
78
103
  staffEvidence: 'An unusual number of card payments opened on one site.'
@@ -131,7 +156,8 @@ function refusal(resetMs, nowMs) {
131
156
  hostId: input.hostId,
132
157
  orgId: input.orgId,
133
158
  reference,
134
- reviewId
159
+ reviewId,
160
+ siteName: await readSiteName(input.firestore, input.hostId)
135
161
  }, {
136
162
  notifyRisk: input.notifyRisk
137
163
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/card-payment-velocity.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 * CARD-TESTING VELOCITY, THE DURABLE HALF (AGL-3363).\n *\n * The policy — the three counters, their numbers and why — is\n * `@aglyn/aglyn/app-utils/card-payment-velocity`. This counts them in the\n * shared durable store (`consumeRateLimit`) and answers the dispatcher the\n * way the visitor-write limiter beside it does: a `Response` to return, or\n * null to carry on. The tenant dispatcher calls it for a route registered\n * with `{ cardPayment: true }`, so every plugin's payment door meets it by\n * declaring itself, and none carries its own copy.\n *\n * The site counter refuses nothing. Crossing it files ONE urgent row per\n * site per day in the abuse queue (`source: 'payment-velocity'`), beside the\n * seller-pattern row, and tells staff once.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n CARD_PAYMENT_VELOCITY,\n cardPaymentAddressKey,\n cardPaymentAlarmDay,\n cardPaymentSiteKey,\n cardPaymentVisitorKey,\n} from '@aglyn/aglyn/app-utils/card-payment-velocity'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { consumeRateLimit } from './rate-limit-store'\nimport { notifyRiskEvent, type RiskEventInput } from './risk-notice'\n\nexport interface CardPaymentVelocityOptions {\n /** Dispatcher path, for the staff row. */\n path: string\n /** The site the payment is for; `''` when none resolved (still counted). */\n hostId: string\n /** The workspace owning it, when the dispatcher read one. */\n orgId?: string | null\n /** True for a workspace younger than the screen's young-workspace age. */\n young?: boolean\n /** Read for its client-address headers. */\n request: { headers?: { get?: (name: string) => unknown } }\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n /** Injectable for tests; defaults to `notifyRiskEvent` (AGL-3368). */\n notifyRisk?: (input: RiskEventInput) => Promise<unknown>\n}\n\nfunction clientIp(request: CardPaymentVelocityOptions['request']): string {\n const headers = request?.headers\n if (typeof headers?.get !== 'function') return NO_CLIENT_ADDRESS_BUCKET\n return (\n readClientIp(headers as { get(name: string): string | null }) ??\n NO_CLIENT_ADDRESS_BUCKET\n )\n}\n\nfunction refusal(resetMs: number, nowMs: number): Response {\n // The same bare 429 as the visitor-write limiter: no remaining-count\n // headers for a stranger to pace a script by.\n return Response.json(\n { error: 'Too many payment attempts. Please wait a few minutes and try again.' },\n {\n status: 429,\n headers: {\n 'Retry-After': String(Math.max(1, Math.ceil((resetMs - nowMs) / 1000))),\n },\n },\n )\n}\n\n/** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */\nexport function cardPaymentAlarmReviewId(hostId: string, day: string): string {\n return createHash('sha256')\n .update(`payment-velocity:${hostId}:${day}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/**\n * Tell staff and the site's managers and the workspace's owners that a site\n * crossed the card-payment window (AGL-3363), through the risk notice seam\n * (AGL-3368): the owners read the catalog's `card-testing` words — what was\n * seen and what to do, never the window or its numbers; staff get the row.\n * Never throws.\n */\nexport async function notifyCardTestingVelocity(\n input: { hostId: string; orgId: string | null; reference: string; reviewId?: string | null },\n deps: { notifyRisk: (input: RiskEventInput) => Promise<unknown> },\n): Promise<void> {\n await deps\n .notifyRisk({\n kind: 'card-testing',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId: input.reviewId ?? null,\n reference: input.reference,\n item: { label: 'your site', path: `/${input.hostId}/products/orders` },\n staffEvidence: 'An unusual number of card payments opened on one site.',\n })\n .catch(() => undefined)\n}\n\n/**\n * File (or count) the day's alarm row for a site whose payment doors crossed\n * the site counter, and tell staff the first time. Never throws: an alarm\n * that fails must not turn a shopper's checkout into a 500.\n */\nexport async function recordCardPaymentAlarm(input: {\n hostId: string\n orgId: string | null\n path: string\n young: boolean\n nowMs: number\n firestore: any\n notifyRisk: (input: RiskEventInput) => Promise<unknown>\n}): Promise<{ reviewId: string; first: boolean } | null> {\n try {\n const day = cardPaymentAlarmDay(input.nowMs)\n const reviewId = cardPaymentAlarmReviewId(input.hostId, day)\n const reference = `PV-${reviewId.slice(0, 10).toUpperCase()}`\n const ref = input.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const window = CARD_PAYMENT_VELOCITY.perSite\n const minutes = Math.round(window.windowMs / 60_000)\n await ref.set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'payment-velocity',\n url: null,\n reportedHostname: null,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `Site ${input.hostId} opened more than ${window.limit} card payments in ${minutes} minutes (first overage through ${input.path}).`,\n input.young\n ? 'The workspace is young. Each address on this site is held to the tighter per-visitor limit.'\n : 'Each address is held to the per-visitor limit; nothing site-wide was refused.',\n 'A script testing stolen cards leaves this shape, from one address or many; so does a merchant testing cards through their own shop, and so does a busy launch.',\n 'Nothing has been refunded, canceled or paused. Look at the site and its orders; if it is card testing, lock the workspace and pause the connected account\\'s payouts in Stripe, then close this row with what you did.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n paymentVelocity: {\n hostId: input.hostId,\n orgId: input.orgId,\n day,\n threshold: window.limit,\n windowMinutes: minutes,\n young: input.young,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n await notifyCardTestingVelocity(\n { hostId: input.hostId, orgId: input.orgId, reference, reviewId },\n { notifyRisk: input.notifyRisk },\n )\n }\n return { reviewId, first }\n } catch (error) {\n console.error('[card-payment-velocity] alarm row failed', error)\n return null\n }\n}\n\n/** The (site, day) alarms this process has filed. */\nconst filedAlarms = new Set<string>()\n\n/** Forget which alarms this process filed. */\nexport function resetCardPaymentAlarmsForTests(): void {\n filedAlarms.clear()\n}\n\n/**\n * Count one opened card payment and return a 429 once a visitor's or an\n * address's window is spent. See the policy module for the three counters.\n */\nexport async function cardPaymentVelocityRefusal(\n options: CardPaymentVelocityOptions,\n): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const ip = clientIp(options.request)\n const visitorWindow = options.young\n ? CARD_PAYMENT_VELOCITY.perVisitorYoung\n : CARD_PAYMENT_VELOCITY.perVisitor\n const count = (key: string, window: { limit: number; windowMs: number }) =>\n consumeRateLimit(key, {\n limit: window.limit,\n windowMs: window.windowMs,\n now: nowMs,\n firestore: options.firestore,\n })\n\n const siteKey = cardPaymentSiteKey(options.hostId)\n const [visitor, address, site] = await Promise.all([\n count(cardPaymentVisitorKey(options.hostId, ip), visitorWindow),\n count(cardPaymentAddressKey(ip), CARD_PAYMENT_VELOCITY.perAddress),\n siteKey ? count(siteKey, CARD_PAYMENT_VELOCITY.perSite) : Promise.resolve(null),\n ])\n\n // The site counter's overage files the day's alarm. Each process files a\n // (site, day) once — an attack is thousands of overages, and one row that\n // says so is the whole message; the row id makes a second process's filing\n // count on the same row rather than add one.\n const day = cardPaymentAlarmDay(nowMs)\n const filedKey = `${options.hostId}:${day}`\n if (site && !site.allowed && !site.contended && options.hostId && !filedAlarms.has(filedKey)) {\n filedAlarms.add(filedKey)\n const firestore =\n (options.firestore as any) ?? firebaseAdmin.app().firestore()\n await recordCardPaymentAlarm({\n hostId: options.hostId,\n orgId: options.orgId ?? null,\n path: options.path,\n young: options.young === true,\n nowMs,\n firestore,\n notifyRisk: options.notifyRisk ?? notifyRiskEvent,\n })\n }\n\n if (!visitor.allowed) return refusal(visitor.resetMs, nowMs)\n if (!address.allowed) return refusal(address.resetMs, nowMs)\n return null\n}\n\nexport default cardPaymentVelocityRefusal\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","CARD_PAYMENT_VELOCITY","cardPaymentAddressKey","cardPaymentAlarmDay","cardPaymentSiteKey","cardPaymentVisitorKey","NO_CLIENT_ADDRESS_BUCKET","readClientIp","FieldValue","firebaseAdmin","consumeRateLimit","notifyRiskEvent","clientIp","request","headers","get","refusal","resetMs","nowMs","Response","json","error","status","String","Math","max","ceil","cardPaymentAlarmReviewId","hostId","day","update","digest","slice","notifyCardTestingVelocity","input","deps","notifyRisk","kind","orgId","reviewId","reference","item","label","path","staffEvidence","catch","undefined","recordCardPaymentAlarm","toUpperCase","ref","firestore","collection","doc","first","exists","window","perSite","minutes","round","windowMs","set","category","severity","source","url","reportedHostname","details","limit","young","join","reporterEmail","reporterName","dmca","reportCount","increment","paymentVelocity","threshold","windowMinutes","updatedAt","serverTimestamp","createdAt","merge","console","filedAlarms","Set","resetCardPaymentAlarmsForTests","clear","cardPaymentVelocityRefusal","options","Date","now","ip","visitorWindow","perVisitorYoung","perVisitor","count","key","siteKey","visitor","address","site","Promise","all","perAddress","resolve","filedKey","allowed","contended","has","add","app"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;2CAc2C,GAE3C,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,uBAAuB,QAAQ,sCAAqC;AAC7E,SACEC,qBAAqB,EACrBC,qBAAqB,EACrBC,mBAAmB,EACnBC,kBAAkB,EAClBC,qBAAqB,QAChB,+CAA8C;AACrD,SACEC,wBAAwB,EACxBC,YAAY,QACP,oCAAmC;AAC1C,SAASC,UAAU,QAAQ,2BAA0B;AACrD,OAAOC,mBAAmB,sBAAkB;AAC5C,SAASC,gBAAgB,QAAQ,wBAAoB;AACrD,SAASC,eAAe,QAA6B,mBAAe;AAoBpE,SAASC,SAASC,OAA8C;QAI5DN;IAHF,MAAMO,UAAUD,2BAAAA,QAASC,OAAO;IAChC,IAAI,QAAOA,2BAAAA,QAASC,GAAG,MAAK,YAAY,OAAOT;IAC/C,QACEC,gBAAAA,aAAaO,oBAAbP,gBACAD;AAEJ;AAEA,SAASU,QAAQC,OAAe,EAAEC,KAAa;IAC7C,qEAAqE;IACrE,8CAA8C;IAC9C,OAAOC,SAASC,IAAI,CAClB;QAAEC,OAAO;IAAsE,GAC/E;QACEC,QAAQ;QACRR,SAAS;YACP,eAAeS,OAAOC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,IAAI,CAAC,AAACT,CAAAA,UAAUC,KAAI,IAAK;QAClE;IACF;AAEJ;AAEA,+EAA+E,GAC/E,OAAO,SAASS,yBAAyBC,MAAc,EAAEC,GAAW;IAClE,OAAO9B,WAAW,UACf+B,MAAM,CAAC,CAAC,iBAAiB,EAAEF,OAAO,CAAC,EAAEC,KAAK,EAC1CE,MAAM,CAAC,OACPC,KAAK,CAAC,GAAG;AACd;AAEA;;;;;;CAMC,GACD,OAAO,eAAeC,0BACpBC,KAA4F,EAC5FC,IAAiE;QAOnDD;IALd,MAAMC,KACHC,UAAU,CAAC;QACVC,MAAM;QACNC,OAAOJ,MAAMI,KAAK;QAClBV,QAAQM,MAAMN,MAAM;QACpBW,QAAQ,GAAEL,kBAAAA,MAAMK,QAAQ,YAAdL,kBAAkB;QAC5BM,WAAWN,MAAMM,SAAS;QAC1BC,MAAM;YAAEC,OAAO;YAAaC,MAAM,CAAC,CAAC,EAAET,MAAMN,MAAM,CAAC,gBAAgB,CAAC;QAAC;QACrEgB,eAAe;IACjB,GACCC,KAAK,CAAC,IAAMC;AACjB;AAEA;;;;CAIC,GACD,OAAO,eAAeC,uBAAuBb,KAQ5C;IACC,IAAI;QACF,MAAML,MAAM1B,oBAAoB+B,MAAMhB,KAAK;QAC3C,MAAMqB,WAAWZ,yBAAyBO,MAAMN,MAAM,EAAEC;QACxD,MAAMW,YAAY,CAAC,GAAG,EAAED,SAASP,KAAK,CAAC,GAAG,IAAIgB,WAAW,IAAI;QAC7D,MAAMC,MAAMf,MAAMgB,SAAS,CAACC,UAAU,CAACnD,yBAAyBoD,GAAG,CAACb;QACpE,MAAMc,QAAQ,CAAC,AAAC,CAAA,MAAMJ,IAAIlC,GAAG,EAAC,EAAGuC,MAAM;QACvC,MAAMC,SAAStD,sBAAsBuD,OAAO;QAC5C,MAAMC,UAAUjC,KAAKkC,KAAK,CAACH,OAAOI,QAAQ,GAAG;QAC7C,MAAMV,IAAIW,GAAG,CACX;YACEpB;YACA,2DAA2D;YAC3DqB,UAAU;YACVC,UAAU;YACVC,QAAQ;YACRC,KAAK;YACLC,kBAAkB;YAClBrC,QAAQM,MAAMN,MAAM;YACpBU,OAAOJ,MAAMI,KAAK;YAClB4B,SAAS;gBACP,CAAC,KAAK,EAAEhC,MAAMN,MAAM,CAAC,kBAAkB,EAAE2B,OAAOY,KAAK,CAAC,kBAAkB,EAAEV,QAAQ,gCAAgC,EAAEvB,MAAMS,IAAI,CAAC,EAAE,CAAC;gBAClIT,MAAMkC,KAAK,GACP,gGACA;gBACJ;gBACA;aACD,CAACC,IAAI,CAAC;YACPC,eAAe;YACfC,cAAc;YACdC,MAAM;YACNC,aAAajE,WAAWkE,SAAS,CAAC;YAClCC,iBAAiB;gBACf/C,QAAQM,MAAMN,MAAM;gBACpBU,OAAOJ,MAAMI,KAAK;gBAClBT;gBACA+C,WAAWrB,OAAOY,KAAK;gBACvBU,eAAepB;gBACfW,OAAOlC,MAAMkC,KAAK;YACpB;YACAU,WAAWtE,WAAWuE,eAAe;WACjC1B,QACA;YAAE/B,QAAQ;YAAQ0D,WAAWxE,WAAWuE,eAAe;QAAG,IAC1D,CAAC,IAEP;YAAEE,OAAO;QAAK;QAEhB,IAAI5B,OAAO;YACT,MAAMpB,0BACJ;gBAAEL,QAAQM,MAAMN,MAAM;gBAAEU,OAAOJ,MAAMI,KAAK;gBAAEE;gBAAWD;YAAS,GAChE;gBAAEH,YAAYF,MAAME,UAAU;YAAC;QAEnC;QACA,OAAO;YAAEG;YAAUc;QAAM;IAC3B,EAAE,OAAOhC,OAAO;QACd6D,QAAQ7D,KAAK,CAAC,4CAA4CA;QAC1D,OAAO;IACT;AACF;AAEA,mDAAmD,GACnD,MAAM8D,cAAc,IAAIC;AAExB,4CAA4C,GAC5C,OAAO,SAASC;IACdF,YAAYG,KAAK;AACnB;AAEA;;;CAGC,GACD,OAAO,eAAeC,2BACpBC,OAAmC;QAErBA;IAAd,MAAMtE,SAAQsE,iBAAAA,QAAQtE,KAAK,YAAbsE,iBAAiBC,KAAKC,GAAG;IACvC,MAAMC,KAAK/E,SAAS4E,QAAQ3E,OAAO;IACnC,MAAM+E,gBAAgBJ,QAAQpB,KAAK,GAC/BnE,sBAAsB4F,eAAe,GACrC5F,sBAAsB6F,UAAU;IACpC,MAAMC,QAAQ,CAACC,KAAazC,SAC1B7C,iBAAiBsF,KAAK;YACpB7B,OAAOZ,OAAOY,KAAK;YACnBR,UAAUJ,OAAOI,QAAQ;YACzB+B,KAAKxE;YACLgC,WAAWsC,QAAQtC,SAAS;QAC9B;IAEF,MAAM+C,UAAU7F,mBAAmBoF,QAAQ5D,MAAM;IACjD,MAAM,CAACsE,SAASC,SAASC,KAAK,GAAG,MAAMC,QAAQC,GAAG,CAAC;QACjDP,MAAM1F,sBAAsBmF,QAAQ5D,MAAM,EAAE+D,KAAKC;QACjDG,MAAM7F,sBAAsByF,KAAK1F,sBAAsBsG,UAAU;QACjEN,UAAUF,MAAME,SAAShG,sBAAsBuD,OAAO,IAAI6C,QAAQG,OAAO,CAAC;KAC3E;IAED,yEAAyE;IACzE,0EAA0E;IAC1E,2EAA2E;IAC3E,6CAA6C;IAC7C,MAAM3E,MAAM1B,oBAAoBe;IAChC,MAAMuF,WAAW,GAAGjB,QAAQ5D,MAAM,CAAC,CAAC,EAAEC,KAAK;IAC3C,IAAIuE,QAAQ,CAACA,KAAKM,OAAO,IAAI,CAACN,KAAKO,SAAS,IAAInB,QAAQ5D,MAAM,IAAI,CAACuD,YAAYyB,GAAG,CAACH,WAAW;YAGzFjB,oBAGMA,gBAKKA;QAVdL,YAAY0B,GAAG,CAACJ;QAChB,MAAMvD,aACHsC,qBAAAA,QAAQtC,SAAS,YAAjBsC,qBAA6B/E,cAAcqG,GAAG,GAAG5D,SAAS;QAC7D,MAAMH,uBAAuB;YAC3BnB,QAAQ4D,QAAQ5D,MAAM;YACtBU,KAAK,GAAEkD,iBAAAA,QAAQlD,KAAK,YAAbkD,iBAAiB;YACxB7C,MAAM6C,QAAQ7C,IAAI;YAClByB,OAAOoB,QAAQpB,KAAK,KAAK;YACzBlD;YACAgC;YACAd,UAAU,GAAEoD,sBAAAA,QAAQpD,UAAU,YAAlBoD,sBAAsB7E;QACpC;IACF;IAEA,IAAI,CAACuF,QAAQQ,OAAO,EAAE,OAAO1F,QAAQkF,QAAQjF,OAAO,EAAEC;IACtD,IAAI,CAACiF,QAAQO,OAAO,EAAE,OAAO1F,QAAQmF,QAAQlF,OAAO,EAAEC;IACtD,OAAO;AACT;AAEA,eAAeqE,2BAA0B"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/card-payment-velocity.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 * CARD-TESTING VELOCITY, THE DURABLE HALF (AGL-3363).\n *\n * The policy — the three counters, their numbers and why — is\n * `@aglyn/aglyn/app-utils/card-payment-velocity`. This counts them in the\n * shared durable store (`consumeRateLimit`) and answers the dispatcher the\n * way the visitor-write limiter beside it does: a `Response` to return, or\n * null to carry on. The tenant dispatcher calls it for a route registered\n * with `{ cardPayment: true }`, so every plugin's payment door meets it by\n * declaring itself, and none carries its own copy.\n *\n * The site counter refuses nothing. Crossing it files ONE urgent row per\n * site per day in the abuse queue (`source: 'payment-velocity'`), beside the\n * seller-pattern row, and tells staff once.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n CARD_PAYMENT_VELOCITY,\n cardPaymentAddressKey,\n cardPaymentAlarmDay,\n cardPaymentSiteKey,\n cardPaymentVisitorKey,\n} from '@aglyn/aglyn/app-utils/card-payment-velocity'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { consumeRateLimit } from './rate-limit-store'\nimport { notifyRiskEvent, type RiskEventInput } from './risk-notice'\n\nexport interface CardPaymentVelocityOptions {\n /** Dispatcher path, for the staff row. */\n path: string\n /** The site the payment is for; `''` when none resolved (still counted). */\n hostId: string\n /** The workspace owning it, when the dispatcher read one. */\n orgId?: string | null\n /** True for a workspace younger than the screen's young-workspace age. */\n young?: boolean\n /** Read for its client-address headers. */\n request: { headers?: { get?: (name: string) => unknown } }\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n /** Injectable for tests; defaults to `notifyRiskEvent` (AGL-3368). */\n notifyRisk?: (input: RiskEventInput) => Promise<unknown>\n}\n\nfunction clientIp(request: CardPaymentVelocityOptions['request']): string {\n const headers = request?.headers\n if (typeof headers?.get !== 'function') return NO_CLIENT_ADDRESS_BUCKET\n return (\n readClientIp(headers as { get(name: string): string | null }) ??\n NO_CLIENT_ADDRESS_BUCKET\n )\n}\n\nfunction refusal(resetMs: number, nowMs: number): Response {\n // The same bare 429 as the visitor-write limiter: no remaining-count\n // headers for a stranger to pace a script by.\n return Response.json(\n { error: 'Too many payment attempts. Please wait a few minutes and try again.' },\n {\n status: 429,\n headers: {\n 'Retry-After': String(Math.max(1, Math.ceil((resetMs - nowMs) / 1000))),\n },\n },\n )\n}\n\n/** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */\nexport function cardPaymentAlarmReviewId(hostId: string, day: string): string {\n return createHash('sha256')\n .update(`payment-velocity:${hostId}:${day}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/**\n * What the alarm is about, in the owner's words:\n * `checkout on the site \"Harbor View\"`. The label rides on the row, so the\n * review's closing notice and the staff alert name the site too — a\n * workspace with several stores reads which orders to look at. Pure.\n */\nexport function cardTestingItemLabel(siteName: string | null | undefined): string {\n const name = String(siteName ?? '').trim()\n return name ? `checkout on the site \"${name.replace(/\"/g, '”')}\"` : 'checkout on your site'\n}\n\n/** The site's name for the label, from its document. Never throws. */\nasync function readSiteName(firestore: any, hostId: string): Promise<string | null> {\n try {\n const host = await firestore.collection('hosts').doc(hostId).get()\n for (const value of [host?.get?.('displayName'), host?.get?.('name'), host?.get?.('subdomain')]) {\n if (typeof value === 'string' && value.trim()) return value.trim()\n }\n } catch {\n // The notice still goes, naming \"your site\".\n }\n return null\n}\n\n/**\n * Tell staff and the site's managers and the workspace's owners that a site\n * crossed the card-payment window (AGL-3363), through the risk notice seam\n * (AGL-3368): the owners read the catalog's `card-testing` words — what was\n * seen and what to do, never the window or its numbers; staff get the row.\n * Never throws.\n */\nexport async function notifyCardTestingVelocity(\n input: {\n hostId: string\n orgId: string | null\n reference: string\n reviewId?: string | null\n /** The site's display name, for the label. */\n siteName?: string | null\n },\n deps: { notifyRisk: (input: RiskEventInput) => Promise<unknown> },\n): Promise<void> {\n await deps\n .notifyRisk({\n kind: 'card-testing',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId: input.reviewId ?? null,\n reference: input.reference,\n item: { label: cardTestingItemLabel(input.siteName), path: `/${input.hostId}/products/orders` },\n staffEvidence: 'An unusual number of card payments opened on one site.',\n })\n .catch(() => undefined)\n}\n\n/**\n * File (or count) the day's alarm row for a site whose payment doors crossed\n * the site counter, and tell staff the first time. Never throws: an alarm\n * that fails must not turn a shopper's checkout into a 500.\n */\nexport async function recordCardPaymentAlarm(input: {\n hostId: string\n orgId: string | null\n path: string\n young: boolean\n nowMs: number\n firestore: any\n notifyRisk: (input: RiskEventInput) => Promise<unknown>\n}): Promise<{ reviewId: string; first: boolean } | null> {\n try {\n const day = cardPaymentAlarmDay(input.nowMs)\n const reviewId = cardPaymentAlarmReviewId(input.hostId, day)\n const reference = `PV-${reviewId.slice(0, 10).toUpperCase()}`\n const ref = input.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const window = CARD_PAYMENT_VELOCITY.perSite\n const minutes = Math.round(window.windowMs / 60_000)\n await ref.set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'payment-velocity',\n url: null,\n reportedHostname: null,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `Site ${input.hostId} opened more than ${window.limit} card payments in ${minutes} minutes (first overage through ${input.path}).`,\n input.young\n ? 'The workspace is young. Each address on this site is held to the tighter per-visitor limit.'\n : 'Each address is held to the per-visitor limit; nothing site-wide was refused.',\n 'A script testing stolen cards leaves this shape, from one address or many; so does a merchant testing cards through their own shop, and so does a busy launch.',\n 'Nothing has been refunded, canceled or paused. Look at the site and its orders; if it is card testing, lock the workspace and pause the connected account\\'s payouts in Stripe, then close this row with what you did.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n paymentVelocity: {\n hostId: input.hostId,\n orgId: input.orgId,\n day,\n threshold: window.limit,\n windowMinutes: minutes,\n young: input.young,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n await notifyCardTestingVelocity(\n {\n hostId: input.hostId,\n orgId: input.orgId,\n reference,\n reviewId,\n siteName: await readSiteName(input.firestore, input.hostId),\n },\n { notifyRisk: input.notifyRisk },\n )\n }\n return { reviewId, first }\n } catch (error) {\n console.error('[card-payment-velocity] alarm row failed', error)\n return null\n }\n}\n\n/** The (site, day) alarms this process has filed. */\nconst filedAlarms = new Set<string>()\n\n/** Forget which alarms this process filed. */\nexport function resetCardPaymentAlarmsForTests(): void {\n filedAlarms.clear()\n}\n\n/**\n * Count one opened card payment and return a 429 once a visitor's or an\n * address's window is spent. See the policy module for the three counters.\n */\nexport async function cardPaymentVelocityRefusal(\n options: CardPaymentVelocityOptions,\n): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const ip = clientIp(options.request)\n const visitorWindow = options.young\n ? CARD_PAYMENT_VELOCITY.perVisitorYoung\n : CARD_PAYMENT_VELOCITY.perVisitor\n const count = (key: string, window: { limit: number; windowMs: number }) =>\n consumeRateLimit(key, {\n limit: window.limit,\n windowMs: window.windowMs,\n now: nowMs,\n firestore: options.firestore,\n })\n\n const siteKey = cardPaymentSiteKey(options.hostId)\n const [visitor, address, site] = await Promise.all([\n count(cardPaymentVisitorKey(options.hostId, ip), visitorWindow),\n count(cardPaymentAddressKey(ip), CARD_PAYMENT_VELOCITY.perAddress),\n siteKey ? count(siteKey, CARD_PAYMENT_VELOCITY.perSite) : Promise.resolve(null),\n ])\n\n // The site counter's overage files the day's alarm. Each process files a\n // (site, day) once — an attack is thousands of overages, and one row that\n // says so is the whole message; the row id makes a second process's filing\n // count on the same row rather than add one.\n const day = cardPaymentAlarmDay(nowMs)\n const filedKey = `${options.hostId}:${day}`\n if (site && !site.allowed && !site.contended && options.hostId && !filedAlarms.has(filedKey)) {\n filedAlarms.add(filedKey)\n const firestore =\n (options.firestore as any) ?? firebaseAdmin.app().firestore()\n await recordCardPaymentAlarm({\n hostId: options.hostId,\n orgId: options.orgId ?? null,\n path: options.path,\n young: options.young === true,\n nowMs,\n firestore,\n notifyRisk: options.notifyRisk ?? notifyRiskEvent,\n })\n }\n\n if (!visitor.allowed) return refusal(visitor.resetMs, nowMs)\n if (!address.allowed) return refusal(address.resetMs, nowMs)\n return null\n}\n\nexport default cardPaymentVelocityRefusal\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","CARD_PAYMENT_VELOCITY","cardPaymentAddressKey","cardPaymentAlarmDay","cardPaymentSiteKey","cardPaymentVisitorKey","NO_CLIENT_ADDRESS_BUCKET","readClientIp","FieldValue","firebaseAdmin","consumeRateLimit","notifyRiskEvent","clientIp","request","headers","get","refusal","resetMs","nowMs","Response","json","error","status","String","Math","max","ceil","cardPaymentAlarmReviewId","hostId","day","update","digest","slice","cardTestingItemLabel","siteName","name","trim","replace","readSiteName","firestore","host","collection","doc","value","notifyCardTestingVelocity","input","deps","notifyRisk","kind","orgId","reviewId","reference","item","label","path","staffEvidence","catch","undefined","recordCardPaymentAlarm","toUpperCase","ref","first","exists","window","perSite","minutes","round","windowMs","set","category","severity","source","url","reportedHostname","details","limit","young","join","reporterEmail","reporterName","dmca","reportCount","increment","paymentVelocity","threshold","windowMinutes","updatedAt","serverTimestamp","createdAt","merge","console","filedAlarms","Set","resetCardPaymentAlarmsForTests","clear","cardPaymentVelocityRefusal","options","Date","now","ip","visitorWindow","perVisitorYoung","perVisitor","count","key","siteKey","visitor","address","site","Promise","all","perAddress","resolve","filedKey","allowed","contended","has","add","app"],"mappings":"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"}
@@ -52,7 +52,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
52
52
  * `sinceMs` narrows a page to what changed since then — the catch-up, which
53
53
  * reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written
54
54
  * without one of those is left to the sweep's full pass.
55
- */ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
55
+ */ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/subscription-topics";
56
56
  import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
57
57
  import { EMAIL_FREQUENCY_SUBCOLLECTION } from "./email-marketing-gate.js";
58
58
  import { HOST_SUPPRESSIONS_SUBCOLLECTION } from "./email-suppression.js";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/consent-group-carry.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 * CARRYING A SITE'S REFUSALS ONTO A SITE THAT IS ABOUT TO STOP READING THEM\n * (AGL-3320).\n *\n * Three per-site stores are read across the CURRENT consent group by every\n * send path (`email-suppression.ts`, `email-marketing-gate.ts`), so a site\n * leaving a group stops seeing its former siblings' — and they stop seeing\n * its. Before the declaration flips, each carry `(to ← from)` of the plan\n * copies `from`'s refusals onto `to`:\n *\n * - **C1, the suppression list** (`hosts/{h}/suppressions/{key}`). A row\n * `to` does not have is CREATED with `from`'s fields and provenance; a row\n * `to` has is never touched. `reason` is kept, so only an unsubscribe stays\n * liftable by the preference page's resubscribe, and a group-wide\n * resubscribe lifts a carried row like an original.\n * - **C2, the topic opt-outs** (`hosts/{h}/topicOptOuts/{key}`). Every\n * stream `from` has LEFT, and `to` has not, becomes an opt-out on `to`\n * dated when the person left `from`. `to`'s other entries — its own\n * confirmations, its own opt-outs — are kept as they are.\n * - **C3, the pace** (`hosts/{h}/emailFrequency/{key}`). The choice made\n * most recently wins, as `groupPace` reads it within a group, and a site\n * keeping a slower pace learns when `from` last mailed the person, so the\n * separation cannot loosen the pace they asked for. The send counters are\n * never touched.\n *\n * ## One page per call, and every page idempotent\n *\n * The executor calls this until it answers `done`, handing back the cursor.\n * A page reads `from`'s documents in id order and `to`'s matching documents\n * in one `getAll`, and writes only what `to` lacks — so a page re-run after a\n * crash, a catch-up pass and the sweep all write nothing that was already\n * carried. A write that raced another writer (a `lastUpdateTime` precondition\n * or a create that found the document) is re-read and decided again once;\n * failing twice throws, and the executor retries the page.\n *\n * `sinceMs` narrows a page to what changed since then — the catch-up, which\n * reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written\n * without one of those is left to the sweep's full pass.\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { EMAIL_FREQUENCY_SUBCOLLECTION } from './email-marketing-gate'\nimport { HOST_SUPPRESSIONS_SUBCOLLECTION } from './email-suppression'\n\n/** The three stores a carry copies, as the job counts them. */\nexport type ConsentGroupCarryStore = 'siteSuppressions' | 'topicOptOuts' | 'paces'\n\nexport const CONSENT_GROUP_CARRY_STORES: readonly ConsentGroupCarryStore[] = [\n 'siteSuppressions',\n 'topicOptOuts',\n 'paces',\n]\n\n/** Each store's subcollection under `hosts/{hostId}`. */\nexport const CONSENT_GROUP_CARRY_SUBCOLLECTIONS: Readonly<Record<ConsentGroupCarryStore, string>> = {\n siteSuppressions: HOST_SUPPRESSIONS_SUBCOLLECTION,\n topicOptOuts: TOPIC_OPT_OUTS_SUBCOLLECTION,\n paces: EMAIL_FREQUENCY_SUBCOLLECTION,\n}\n\n/** The field a catch-up reads each store by, and whether it is a timestamp. */\nconst CATCH_UP_FIELDS: Readonly<Record<ConsentGroupCarryStore, { field: string; timestamp: boolean }>> = {\n siteSuppressions: { field: 'suppressedAt', timestamp: true },\n topicOptOuts: { field: 'updatedAt', timestamp: true },\n paces: { field: 'cadenceSetAtMs', timestamp: false },\n}\n\n/**\n * The suppression row's fields a carry copies. Everything else a row could\n * carry is either the carry's own provenance or not a fact about the refusal.\n */\nconst SUPPRESSION_ROW_FIELDS = [\n 'reason',\n 'email',\n // The address's search tokens travel with it (AGL-3321).\n 'emailTokens',\n 'createdAt',\n 'suppressedAt',\n 'note',\n 'suppressedByUid',\n 'campaignId',\n 'topicId',\n] as const\n\n/** Documents one page reads from the source site. */\nexport const CONSENT_GROUP_CARRY_PAGE = 300\n\nexport interface ConsentGroupCarryPage {\n /** The source site has nothing after the cursor. */\n done: boolean\n /** Where the next page starts, or `null` once `done`. */\n cursor: string | null\n /** Source documents read. */\n read: number\n /** Documents written on the receiving site (counted, not written, on a dry run). */\n written: number\n}\n\nexport interface CarryConsentGroupRefusalsOptions {\n firestore: FirebaseFirestore.Firestore\n store: ConsentGroupCarryStore\n carry: ConsentGroupCarry\n changeId: string\n cursor: string | null\n /** Only documents changed at or after this instant — the catch-up. */\n sinceMs?: number | null\n pageSize?: number\n dryRun?: boolean\n}\n\ninterface Cursor {\n id: string\n /** The catch-up field's value on the last document read. */\n at?: { seconds: number; nanoseconds: number } | number\n}\n\nfunction readCursor(raw: string | null): Cursor | null {\n if (!raw) return null\n try {\n const parsed = JSON.parse(raw) as Cursor\n return typeof parsed?.id === 'string' ? parsed : null\n } catch {\n return null\n }\n}\n\n/** The error codes that mean \"somebody wrote this document since I read it\". */\nconst RACED = new Set([5, 6, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\n/** Milliseconds from a stored number or timestamp, or `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * When the site last mailed the person, as the gate reads it: the stored\n * instant, or the newest entry of a window written before it existed.\n */\nfunction lastSentMs(data: Record<string, unknown> | undefined): number | null {\n if (!data) return null\n const stored = Number(data['lastSentAtMs'])\n if (Number.isFinite(stored) && stored > 0) return stored\n const window = Array.isArray(data['sentAtMs'])\n ? (data['sentAtMs'] as unknown[]).map(Number).filter(Number.isFinite)\n : []\n return window.length ? Math.max(...window) : null\n}\n\n/**\n * What `to` must become for one source document, or `null` when it already\n * honors everything `from` does. `create` says the document is new.\n */\ntype Decision = { create: boolean; data: Record<string, unknown> } | null\n\nfunction decideSuppression(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (target.exists) return null\n const row = source.data() ?? {}\n const data: Record<string, unknown> = {}\n for (const field of SUPPRESSION_ROW_FIELDS) {\n if (field in row) data[field] = row[field]\n }\n data['carriedFromHostId'] = carry.fromHostId\n data['carriedByChangeId'] = changeId\n data['carriedAt'] = FieldValue.serverTimestamp()\n return { create: true, data }\n}\n\nfunction decideTopics(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const sourceTopics = (source.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry>\n const targetTopics = ((target.exists ? target.get('topics') : null) ?? {}) as Record<\n string,\n TopicSubscriptionEntry & Record<string, unknown>\n >\n const next: Record<string, unknown> = { ...targetTopics }\n let changed = false\n for (const [topicId, entry] of Object.entries(sourceTopics)) {\n if (readTopicSubscriptionState(entry) !== 'opted-out') continue\n if (readTopicSubscriptionState(targetTopics[topicId]) === 'opted-out') continue\n next[topicId] = {\n ...(targetTopics[topicId] ?? {}),\n optedOutAt: entry.optedOutAt,\n resubscribedAt: null,\n carriedFromHostId: carry.fromHostId,\n }\n changed = true\n }\n if (!changed) return null\n const email = (target.exists ? target.get('email') : null) ?? source.get('email') ?? null\n return {\n create: !target.exists,\n data: {\n email,\n topics: next,\n updatedAt: FieldValue.serverTimestamp(),\n ...(target.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n }\n}\n\nfunction decidePace(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const from = source.data() ?? {}\n const to = target.exists ? (target.data() ?? {}) : undefined\n const patch: Record<string, unknown> = {}\n const sourceChoice = from['cadence']\n const targetChoice = to?.['cadence']\n const sourceSetAt = Number(from['cadenceSetAtMs'])\n const targetSetAt = Number(to?.['cadenceSetAtMs'])\n const carried =\n sourceChoice != null &&\n (targetChoice == null ||\n (Number.isFinite(targetSetAt) ? targetSetAt : 0) <\n (Number.isFinite(sourceSetAt) ? sourceSetAt : 0))\n if (carried) {\n patch['cadence'] = sourceChoice\n if (Number.isFinite(sourceSetAt)) patch['cadenceSetAtMs'] = sourceSetAt\n patch['cadenceCarriedFromHostId'] = carry.fromHostId\n }\n // The pace in force afterwards decides whether the last send matters: `all`\n // sends as they come and never asks when the last one went.\n const choice = carried ? sourceChoice : targetChoice\n if (choice != null && choice !== 'all') {\n const sourceLast = lastSentMs(from)\n const targetLast = lastSentMs(to)\n if (sourceLast !== null && (targetLast === null || sourceLast > targetLast)) {\n patch['lastSentAtMs'] = sourceLast\n }\n }\n if (!Object.keys(patch).length) return null\n return {\n create: !target.exists,\n data: target.exists ? patch : { email: from['email'] ?? null, ...patch },\n }\n}\n\nfunction decide(\n store: ConsentGroupCarryStore,\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (store === 'siteSuppressions') return decideSuppression(source, target, carry, changeId)\n if (store === 'topicOptOuts') return decideTopics(source, target, carry)\n return decidePace(source, target, carry)\n}\n\n/**\n * One page of one store of one carry. See the module note for what each\n * store copies and why a re-run writes nothing.\n */\nexport async function carryConsentGroupRefusals(\n options: CarryConsentGroupRefusalsOptions,\n): Promise<ConsentGroupCarryPage> {\n const { firestore, store, carry, changeId } = options\n const subcollection = CONSENT_GROUP_CARRY_SUBCOLLECTIONS[store]\n const sourceList = firestore.collection('hosts').doc(carry.fromHostId).collection(subcollection)\n const targetList = firestore.collection('hosts').doc(carry.toHostId).collection(subcollection)\n const limit = options.pageSize ?? CONSENT_GROUP_CARRY_PAGE\n const cursor = readCursor(options.cursor)\n const catchUp = CATCH_UP_FIELDS[store]\n const since = options.sinceMs ?? null\n\n let query: FirebaseFirestore.Query = sourceList\n if (since !== null) {\n query = query\n .where(catchUp.field, '>=', catchUp.timestamp ? Timestamp.fromMillis(since) : since)\n .orderBy(catchUp.field)\n .orderBy(FieldPath.documentId())\n if (cursor && cursor.at !== undefined) {\n const at =\n typeof cursor.at === 'number' ? cursor.at : new Timestamp(cursor.at.seconds, cursor.at.nanoseconds)\n query = query.startAfter(at, cursor.id)\n }\n } else {\n query = query.orderBy(FieldPath.documentId())\n if (cursor) query = query.startAfter(cursor.id)\n }\n const page = await query.limit(limit).get()\n if (page.empty) return { done: true, cursor: null, read: 0, written: 0 }\n\n const targets = await firestore.getAll(...page.docs.map((doc) => targetList.doc(doc.id)))\n const decisions = page.docs.map((source, index) => ({\n source,\n target: targets[index],\n decision: decide(store, source, targets[index], carry, changeId),\n }))\n const due = decisions.filter((entry) => entry.decision !== null)\n const written = options.dryRun\n ? due.length\n : await writeDecisions(firestore, targetList, store, carry, changeId, due)\n\n const last = page.docs[page.docs.length - 1]\n const next: Cursor = { id: last.id }\n if (since !== null) {\n const value = last.get(catchUp.field)\n next.at =\n value instanceof Timestamp\n ? { seconds: value.seconds, nanoseconds: value.nanoseconds }\n : (storedMs(value) ?? 0)\n }\n const done = page.size < limit\n return { done, cursor: done ? null : JSON.stringify(next), read: page.size, written }\n}\n\n/**\n * Writes one page's decisions through a `BulkWriter`, each guarded against\n * the document having moved since it was read, and decides the ones that\n * raced once more from a fresh read.\n */\nasync function writeDecisions(\n firestore: FirebaseFirestore.Firestore,\n targetList: FirebaseFirestore.CollectionReference,\n store: ConsentGroupCarryStore,\n carry: ConsentGroupCarry,\n changeId: string,\n due: Array<{\n source: FirebaseFirestore.DocumentSnapshot\n target: FirebaseFirestore.DocumentSnapshot\n decision: Decision\n }>,\n): Promise<number> {\n let written = 0\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map(({ source, target, decision }) => {\n const ref = targetList.doc(source.id)\n const write = (decision as NonNullable<Decision>).create\n ? writer.create(ref, (decision as NonNullable<Decision>).data)\n : writer.update(ref, (decision as NonNullable<Decision>).data, {\n lastUpdateTime: target.updateTime as FirebaseFirestore.Timestamp,\n })\n return write.then(\n () => ({ source, error: null as unknown }),\n (error: unknown) => ({ source, error: error ?? new Error('The write failed') }),\n )\n })\n await writer.close()\n const settled = await Promise.all(outcomes)\n const retry: FirebaseFirestore.DocumentSnapshot[] = []\n for (const { source, error } of settled) {\n if (error === null) {\n written += 1\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n // The row a suppression carry would create exists now: `to` has its own.\n if (store === 'siteSuppressions' && code === 6) continue\n retry.push(source)\n }\n if (!retry.length) return written\n const fresh = await firestore.getAll(...retry.map((source) => targetList.doc(source.id)))\n due = retry\n .map((source, index) => ({\n source,\n target: fresh[index],\n decision: decide(store, source, fresh[index], carry, changeId),\n }))\n .filter((entry) => entry.decision !== null)\n }\n if (due.length) {\n throw new Error(\n `[consent-group-carry] ${due.length} document(s) on ${carry.toHostId} kept changing under the carry`,\n )\n }\n return written\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","FieldPath","FieldValue","Timestamp","EMAIL_FREQUENCY_SUBCOLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","CONSENT_GROUP_CARRY_STORES","CONSENT_GROUP_CARRY_SUBCOLLECTIONS","siteSuppressions","topicOptOuts","paces","CATCH_UP_FIELDS","field","timestamp","SUPPRESSION_ROW_FIELDS","CONSENT_GROUP_CARRY_PAGE","readCursor","raw","parsed","JSON","parse","id","RACED","Set","codeOf","error","code","storedMs","value","Number","isFinite","toMillis","call","lastSentMs","data","stored","window","Array","isArray","map","filter","length","Math","max","decideSuppression","source","target","carry","changeId","exists","row","fromHostId","serverTimestamp","create","decideTopics","sourceTopics","get","targetTopics","next","changed","topicId","entry","Object","entries","optedOutAt","resubscribedAt","carriedFromHostId","email","topics","updatedAt","createdAt","decidePace","from","to","undefined","patch","sourceChoice","targetChoice","sourceSetAt","targetSetAt","carried","choice","sourceLast","targetLast","keys","decide","store","carryConsentGroupRefusals","options","firestore","subcollection","sourceList","collection","doc","targetList","toHostId","limit","pageSize","cursor","catchUp","since","sinceMs","query","where","fromMillis","orderBy","documentId","at","seconds","nanoseconds","startAfter","page","empty","done","read","written","targets","getAll","docs","decisions","index","decision","due","dryRun","writeDecisions","last","size","stringify","round","writer","bulkWriter","outcomes","ref","write","update","lastUpdateTime","updateTime","then","Error","close","settled","Promise","all","retry","has","push","fresh"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/consent-group-carry.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 * CARRYING A SITE'S REFUSALS ONTO A SITE THAT IS ABOUT TO STOP READING THEM\n * (AGL-3320).\n *\n * Three per-site stores are read across the CURRENT consent group by every\n * send path (`email-suppression.ts`, `email-marketing-gate.ts`), so a site\n * leaving a group stops seeing its former siblings' — and they stop seeing\n * its. Before the declaration flips, each carry `(to ← from)` of the plan\n * copies `from`'s refusals onto `to`:\n *\n * - **C1, the suppression list** (`hosts/{h}/suppressions/{key}`). A row\n * `to` does not have is CREATED with `from`'s fields and provenance; a row\n * `to` has is never touched. `reason` is kept, so only an unsubscribe stays\n * liftable by the preference page's resubscribe, and a group-wide\n * resubscribe lifts a carried row like an original.\n * - **C2, the topic opt-outs** (`hosts/{h}/topicOptOuts/{key}`). Every\n * stream `from` has LEFT, and `to` has not, becomes an opt-out on `to`\n * dated when the person left `from`. `to`'s other entries — its own\n * confirmations, its own opt-outs — are kept as they are.\n * - **C3, the pace** (`hosts/{h}/emailFrequency/{key}`). The choice made\n * most recently wins, as `groupPace` reads it within a group, and a site\n * keeping a slower pace learns when `from` last mailed the person, so the\n * separation cannot loosen the pace they asked for. The send counters are\n * never touched.\n *\n * ## One page per call, and every page idempotent\n *\n * The executor calls this until it answers `done`, handing back the cursor.\n * A page reads `from`'s documents in id order and `to`'s matching documents\n * in one `getAll`, and writes only what `to` lacks — so a page re-run after a\n * crash, a catch-up pass and the sweep all write nothing that was already\n * carried. A write that raced another writer (a `lastUpdateTime` precondition\n * or a create that found the document) is re-read and decided again once;\n * failing twice throws, and the executor retries the page.\n *\n * `sinceMs` narrows a page to what changed since then — the catch-up, which\n * reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written\n * without one of those is left to the sweep's full pass.\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\nimport type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { EMAIL_FREQUENCY_SUBCOLLECTION } from './email-marketing-gate'\nimport { HOST_SUPPRESSIONS_SUBCOLLECTION } from './email-suppression'\n\n/** The three stores a carry copies, as the job counts them. */\nexport type ConsentGroupCarryStore = 'siteSuppressions' | 'topicOptOuts' | 'paces'\n\nexport const CONSENT_GROUP_CARRY_STORES: readonly ConsentGroupCarryStore[] = [\n 'siteSuppressions',\n 'topicOptOuts',\n 'paces',\n]\n\n/** Each store's subcollection under `hosts/{hostId}`. */\nexport const CONSENT_GROUP_CARRY_SUBCOLLECTIONS: Readonly<Record<ConsentGroupCarryStore, string>> = {\n siteSuppressions: HOST_SUPPRESSIONS_SUBCOLLECTION,\n topicOptOuts: TOPIC_OPT_OUTS_SUBCOLLECTION,\n paces: EMAIL_FREQUENCY_SUBCOLLECTION,\n}\n\n/** The field a catch-up reads each store by, and whether it is a timestamp. */\nconst CATCH_UP_FIELDS: Readonly<Record<ConsentGroupCarryStore, { field: string; timestamp: boolean }>> = {\n siteSuppressions: { field: 'suppressedAt', timestamp: true },\n topicOptOuts: { field: 'updatedAt', timestamp: true },\n paces: { field: 'cadenceSetAtMs', timestamp: false },\n}\n\n/**\n * The suppression row's fields a carry copies. Everything else a row could\n * carry is either the carry's own provenance or not a fact about the refusal.\n */\nconst SUPPRESSION_ROW_FIELDS = [\n 'reason',\n 'email',\n // The address's search tokens travel with it (AGL-3321).\n 'emailTokens',\n 'createdAt',\n 'suppressedAt',\n 'note',\n 'suppressedByUid',\n 'campaignId',\n 'topicId',\n] as const\n\n/** Documents one page reads from the source site. */\nexport const CONSENT_GROUP_CARRY_PAGE = 300\n\nexport interface ConsentGroupCarryPage {\n /** The source site has nothing after the cursor. */\n done: boolean\n /** Where the next page starts, or `null` once `done`. */\n cursor: string | null\n /** Source documents read. */\n read: number\n /** Documents written on the receiving site (counted, not written, on a dry run). */\n written: number\n}\n\nexport interface CarryConsentGroupRefusalsOptions {\n firestore: FirebaseFirestore.Firestore\n store: ConsentGroupCarryStore\n carry: ConsentGroupCarry\n changeId: string\n cursor: string | null\n /** Only documents changed at or after this instant — the catch-up. */\n sinceMs?: number | null\n pageSize?: number\n dryRun?: boolean\n}\n\ninterface Cursor {\n id: string\n /** The catch-up field's value on the last document read. */\n at?: { seconds: number; nanoseconds: number } | number\n}\n\nfunction readCursor(raw: string | null): Cursor | null {\n if (!raw) return null\n try {\n const parsed = JSON.parse(raw) as Cursor\n return typeof parsed?.id === 'string' ? parsed : null\n } catch {\n return null\n }\n}\n\n/** The error codes that mean \"somebody wrote this document since I read it\". */\nconst RACED = new Set([5, 6, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\n/** Milliseconds from a stored number or timestamp, or `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * When the site last mailed the person, as the gate reads it: the stored\n * instant, or the newest entry of a window written before it existed.\n */\nfunction lastSentMs(data: Record<string, unknown> | undefined): number | null {\n if (!data) return null\n const stored = Number(data['lastSentAtMs'])\n if (Number.isFinite(stored) && stored > 0) return stored\n const window = Array.isArray(data['sentAtMs'])\n ? (data['sentAtMs'] as unknown[]).map(Number).filter(Number.isFinite)\n : []\n return window.length ? Math.max(...window) : null\n}\n\n/**\n * What `to` must become for one source document, or `null` when it already\n * honors everything `from` does. `create` says the document is new.\n */\ntype Decision = { create: boolean; data: Record<string, unknown> } | null\n\nfunction decideSuppression(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (target.exists) return null\n const row = source.data() ?? {}\n const data: Record<string, unknown> = {}\n for (const field of SUPPRESSION_ROW_FIELDS) {\n if (field in row) data[field] = row[field]\n }\n data['carriedFromHostId'] = carry.fromHostId\n data['carriedByChangeId'] = changeId\n data['carriedAt'] = FieldValue.serverTimestamp()\n return { create: true, data }\n}\n\nfunction decideTopics(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const sourceTopics = (source.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry>\n const targetTopics = ((target.exists ? target.get('topics') : null) ?? {}) as Record<\n string,\n TopicSubscriptionEntry & Record<string, unknown>\n >\n const next: Record<string, unknown> = { ...targetTopics }\n let changed = false\n for (const [topicId, entry] of Object.entries(sourceTopics)) {\n if (readTopicSubscriptionState(entry) !== 'opted-out') continue\n if (readTopicSubscriptionState(targetTopics[topicId]) === 'opted-out') continue\n next[topicId] = {\n ...(targetTopics[topicId] ?? {}),\n optedOutAt: entry.optedOutAt,\n resubscribedAt: null,\n carriedFromHostId: carry.fromHostId,\n }\n changed = true\n }\n if (!changed) return null\n const email = (target.exists ? target.get('email') : null) ?? source.get('email') ?? null\n return {\n create: !target.exists,\n data: {\n email,\n topics: next,\n updatedAt: FieldValue.serverTimestamp(),\n ...(target.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n }\n}\n\nfunction decidePace(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const from = source.data() ?? {}\n const to = target.exists ? (target.data() ?? {}) : undefined\n const patch: Record<string, unknown> = {}\n const sourceChoice = from['cadence']\n const targetChoice = to?.['cadence']\n const sourceSetAt = Number(from['cadenceSetAtMs'])\n const targetSetAt = Number(to?.['cadenceSetAtMs'])\n const carried =\n sourceChoice != null &&\n (targetChoice == null ||\n (Number.isFinite(targetSetAt) ? targetSetAt : 0) <\n (Number.isFinite(sourceSetAt) ? sourceSetAt : 0))\n if (carried) {\n patch['cadence'] = sourceChoice\n if (Number.isFinite(sourceSetAt)) patch['cadenceSetAtMs'] = sourceSetAt\n patch['cadenceCarriedFromHostId'] = carry.fromHostId\n }\n // The pace in force afterwards decides whether the last send matters: `all`\n // sends as they come and never asks when the last one went.\n const choice = carried ? sourceChoice : targetChoice\n if (choice != null && choice !== 'all') {\n const sourceLast = lastSentMs(from)\n const targetLast = lastSentMs(to)\n if (sourceLast !== null && (targetLast === null || sourceLast > targetLast)) {\n patch['lastSentAtMs'] = sourceLast\n }\n }\n if (!Object.keys(patch).length) return null\n return {\n create: !target.exists,\n data: target.exists ? patch : { email: from['email'] ?? null, ...patch },\n }\n}\n\nfunction decide(\n store: ConsentGroupCarryStore,\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (store === 'siteSuppressions') return decideSuppression(source, target, carry, changeId)\n if (store === 'topicOptOuts') return decideTopics(source, target, carry)\n return decidePace(source, target, carry)\n}\n\n/**\n * One page of one store of one carry. See the module note for what each\n * store copies and why a re-run writes nothing.\n */\nexport async function carryConsentGroupRefusals(\n options: CarryConsentGroupRefusalsOptions,\n): Promise<ConsentGroupCarryPage> {\n const { firestore, store, carry, changeId } = options\n const subcollection = CONSENT_GROUP_CARRY_SUBCOLLECTIONS[store]\n const sourceList = firestore.collection('hosts').doc(carry.fromHostId).collection(subcollection)\n const targetList = firestore.collection('hosts').doc(carry.toHostId).collection(subcollection)\n const limit = options.pageSize ?? CONSENT_GROUP_CARRY_PAGE\n const cursor = readCursor(options.cursor)\n const catchUp = CATCH_UP_FIELDS[store]\n const since = options.sinceMs ?? null\n\n let query: FirebaseFirestore.Query = sourceList\n if (since !== null) {\n query = query\n .where(catchUp.field, '>=', catchUp.timestamp ? Timestamp.fromMillis(since) : since)\n .orderBy(catchUp.field)\n .orderBy(FieldPath.documentId())\n if (cursor && cursor.at !== undefined) {\n const at =\n typeof cursor.at === 'number' ? cursor.at : new Timestamp(cursor.at.seconds, cursor.at.nanoseconds)\n query = query.startAfter(at, cursor.id)\n }\n } else {\n query = query.orderBy(FieldPath.documentId())\n if (cursor) query = query.startAfter(cursor.id)\n }\n const page = await query.limit(limit).get()\n if (page.empty) return { done: true, cursor: null, read: 0, written: 0 }\n\n const targets = await firestore.getAll(...page.docs.map((doc) => targetList.doc(doc.id)))\n const decisions = page.docs.map((source, index) => ({\n source,\n target: targets[index],\n decision: decide(store, source, targets[index], carry, changeId),\n }))\n const due = decisions.filter((entry) => entry.decision !== null)\n const written = options.dryRun\n ? due.length\n : await writeDecisions(firestore, targetList, store, carry, changeId, due)\n\n const last = page.docs[page.docs.length - 1]\n const next: Cursor = { id: last.id }\n if (since !== null) {\n const value = last.get(catchUp.field)\n next.at =\n value instanceof Timestamp\n ? { seconds: value.seconds, nanoseconds: value.nanoseconds }\n : (storedMs(value) ?? 0)\n }\n const done = page.size < limit\n return { done, cursor: done ? null : JSON.stringify(next), read: page.size, written }\n}\n\n/**\n * Writes one page's decisions through a `BulkWriter`, each guarded against\n * the document having moved since it was read, and decides the ones that\n * raced once more from a fresh read.\n */\nasync function writeDecisions(\n firestore: FirebaseFirestore.Firestore,\n targetList: FirebaseFirestore.CollectionReference,\n store: ConsentGroupCarryStore,\n carry: ConsentGroupCarry,\n changeId: string,\n due: Array<{\n source: FirebaseFirestore.DocumentSnapshot\n target: FirebaseFirestore.DocumentSnapshot\n decision: Decision\n }>,\n): Promise<number> {\n let written = 0\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map(({ source, target, decision }) => {\n const ref = targetList.doc(source.id)\n const write = (decision as NonNullable<Decision>).create\n ? writer.create(ref, (decision as NonNullable<Decision>).data)\n : writer.update(ref, (decision as NonNullable<Decision>).data, {\n lastUpdateTime: target.updateTime as FirebaseFirestore.Timestamp,\n })\n return write.then(\n () => ({ source, error: null as unknown }),\n (error: unknown) => ({ source, error: error ?? new Error('The write failed') }),\n )\n })\n await writer.close()\n const settled = await Promise.all(outcomes)\n const retry: FirebaseFirestore.DocumentSnapshot[] = []\n for (const { source, error } of settled) {\n if (error === null) {\n written += 1\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n // The row a suppression carry would create exists now: `to` has its own.\n if (store === 'siteSuppressions' && code === 6) continue\n retry.push(source)\n }\n if (!retry.length) return written\n const fresh = await firestore.getAll(...retry.map((source) => targetList.doc(source.id)))\n due = retry\n .map((source, index) => ({\n source,\n target: fresh[index],\n decision: decide(store, source, fresh[index], carry, changeId),\n }))\n .filter((entry) => entry.decision !== null)\n }\n if (due.length) {\n throw new Error(\n `[consent-group-carry] ${due.length} document(s) on ${carry.toHostId} kept changing under the carry`,\n )\n }\n return written\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","FieldPath","FieldValue","Timestamp","EMAIL_FREQUENCY_SUBCOLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","CONSENT_GROUP_CARRY_STORES","CONSENT_GROUP_CARRY_SUBCOLLECTIONS","siteSuppressions","topicOptOuts","paces","CATCH_UP_FIELDS","field","timestamp","SUPPRESSION_ROW_FIELDS","CONSENT_GROUP_CARRY_PAGE","readCursor","raw","parsed","JSON","parse","id","RACED","Set","codeOf","error","code","storedMs","value","Number","isFinite","toMillis","call","lastSentMs","data","stored","window","Array","isArray","map","filter","length","Math","max","decideSuppression","source","target","carry","changeId","exists","row","fromHostId","serverTimestamp","create","decideTopics","sourceTopics","get","targetTopics","next","changed","topicId","entry","Object","entries","optedOutAt","resubscribedAt","carriedFromHostId","email","topics","updatedAt","createdAt","decidePace","from","to","undefined","patch","sourceChoice","targetChoice","sourceSetAt","targetSetAt","carried","choice","sourceLast","targetLast","keys","decide","store","carryConsentGroupRefusals","options","firestore","subcollection","sourceList","collection","doc","targetList","toHostId","limit","pageSize","cursor","catchUp","since","sinceMs","query","where","fromMillis","orderBy","documentId","at","seconds","nanoseconds","startAfter","page","empty","done","read","written","targets","getAll","docs","decisions","index","decision","due","dryRun","writeDecisions","last","size","stringify","round","writer","bulkWriter","outcomes","ref","write","update","lastUpdateTime","updateTime","then","Error","close","settled","Promise","all","retry","has","push","fresh"],"mappings":"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"}
@@ -67,7 +67,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
67
67
  */ import { CONSENT_GROUP_CHANGE_STEPS, CONSENT_GROUP_CHANGES_COLLECTION, CONSENT_GROUP_RENAME_STEPS, CONSENT_GROUP_SWEEP_DELAY_MS, CONSENT_GROUPS_CHANGE_FIELD, consentGroupChangePhaseOf, consentGroupDisclosureChanges, consentGroupsFingerprint, describeConsentGroupChangeLine, discardedConsentGroupIds, estimateConsentGroupChange, orgSiteIds, planConsentGroupChange, readConsentGroupsChange, validateConsentGroupDeclaration } from "@aglyn/aglyn/app-utils/consent-group-change";
68
68
  import { CONSENT_GROUPS_FIELD, consentGroupsAwaitConfirmation, readConsentGroups } from "@aglyn/aglyn/app-utils/consent-groups";
69
69
  import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
70
- import { DEFAULT_EMAIL_TOPICS, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
70
+ import { DECLARED_SUBSCRIPTION_TOPICS, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/subscription-topics";
71
71
  import { MARKETING_CONSENT_BY_HOST_FIELD, MARKETING_CONSENT_FIELD, resolveMarketingConsentPolicy } from "@aglyn/aglyn/app-utils/marketing-consent";
72
72
  import { isOrgWideMember } from "@aglyn/aglyn/app-utils/organizations";
73
73
  import { listPluginConsentGroupParticipants, previewPluginConsentGroupChange } from "@aglyn/aglyn/plugin-manager/plugin-consent-group-change";
@@ -326,7 +326,7 @@ const describeLines = (lines, names)=>lines.map((line)=>describeConsentGroupChan
326
326
  for (const [hostId, releasedHostIds] of [
327
327
  ...released.entries()
328
328
  ].sort()){
329
- for (const topic of DEFAULT_EMAIL_TOPICS){
329
+ for (const topic of DECLARED_SUBSCRIPTION_TOPICS){
330
330
  const count = await countOf(db.collection('hosts').doc(hostId).collection(TOPIC_OPT_OUTS_SUBCOLLECTION).where(`topics.${topic.id}.confirmedAt`, '==', null));
331
331
  if (count > 0) pendingHolds.push({
332
332
  hostId,