@aglyn/tenant-data-admin 1.0.0-beta.201 → 1.0.0-beta.202

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 (32) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/card-payment-velocity.d.ts +85 -0
  3. package/src/lib/server/card-payment-velocity.js +202 -0
  4. package/src/lib/server/card-payment-velocity.js.map +1 -0
  5. package/src/lib/server/configured-url-fetch.d.ts +42 -0
  6. package/src/lib/server/configured-url-fetch.js +118 -0
  7. package/src/lib/server/configured-url-fetch.js.map +1 -0
  8. package/src/lib/server/hosted-page-review.d.ts +22 -2
  9. package/src/lib/server/hosted-page-review.js +43 -7
  10. package/src/lib/server/hosted-page-review.js.map +1 -1
  11. package/src/lib/server/member-email-aliases.js +18 -7
  12. package/src/lib/server/member-email-aliases.js.map +1 -1
  13. package/src/lib/server/notifications.d.ts +8 -0
  14. package/src/lib/server/notifications.js +92 -17
  15. package/src/lib/server/notifications.js.map +1 -1
  16. package/src/lib/server/org-member-notice.js +20 -5
  17. package/src/lib/server/org-member-notice.js.map +1 -1
  18. package/src/lib/server/organizations.d.ts +10 -1
  19. package/src/lib/server/organizations.js +12 -4
  20. package/src/lib/server/organizations.js.map +1 -1
  21. package/src/lib/server/outbound-send-review.d.ts +4 -2
  22. package/src/lib/server/outbound-send-review.js +1 -1
  23. package/src/lib/server/outbound-send-review.js.map +1 -1
  24. package/src/lib/server/render-system-email.d.ts +176 -0
  25. package/src/lib/server/render-system-email.js +315 -0
  26. package/src/lib/server/render-system-email.js.map +1 -0
  27. package/src/lib/server/staff-alert-email.d.ts +2 -1
  28. package/src/lib/server/staff-alert-email.js +16 -5
  29. package/src/lib/server/staff-alert-email.js.map +1 -1
  30. package/src/tenancy.d.ts +2 -0
  31. package/src/tenancy.js +2 -0
  32. package/src/tenancy.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.201",
3
+ "version": "1.0.0-beta.202",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.201",
29
- "@aglyn/shared-util-email": "1.0.0-beta.201",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.201",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.201",
32
- "@aglyn/shared-util-http": "1.0.0-beta.201",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.201",
28
+ "@aglyn/aglyn": "1.0.0-beta.202",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.202",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.202",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.202",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.202",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.202",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
36
  "sharp": "^0.35.3",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { notifyHostManagers, notifyStaff } from './notifications';
18
+ export interface CardPaymentVelocityOptions {
19
+ /** Dispatcher path, for the staff row. */
20
+ path: string;
21
+ /** The site the payment is for; `''` when none resolved (still counted). */
22
+ hostId: string;
23
+ /** The workspace owning it, when the dispatcher read one. */
24
+ orgId?: string | null;
25
+ /** True for a workspace younger than the screen's young-workspace age. */
26
+ young?: boolean;
27
+ /** Read for its client-address headers. */
28
+ request: {
29
+ headers?: {
30
+ get?: (name: string) => unknown;
31
+ };
32
+ };
33
+ nowMs?: number;
34
+ /** Injectable for tests; defaults to the Admin SDK's Firestore. */
35
+ firestore?: unknown;
36
+ /** Injectable for tests; defaults to `notifyStaff`. */
37
+ notify?: typeof notifyStaff;
38
+ /** Injectable for tests; defaults to `notifyHostManagers`. */
39
+ notifyManagers?: typeof notifyHostManagers;
40
+ }
41
+ /** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */
42
+ export declare function cardPaymentAlarmReviewId(hostId: string, day: string): string;
43
+ /**
44
+ * Tell staff and the site's managers, once per site per day, that its payment
45
+ * doors crossed the card-testing alarm (AGL-3363). The one notifier for this
46
+ * kind of signal, so a shared risk-notice seam can take it over whole.
47
+ *
48
+ * The merchant's sentence names the outcome and what to do, never the rule
49
+ * or its numbers: a merchant who is the card tester must not learn how to
50
+ * pace under it. Never throws.
51
+ */
52
+ export declare function notifyCardTestingVelocity(input: {
53
+ hostId: string;
54
+ orgId: string | null;
55
+ reference: string;
56
+ }, deps: {
57
+ notifyStaff: typeof notifyStaff;
58
+ notifyManagers: typeof notifyHostManagers;
59
+ }): Promise<void>;
60
+ /**
61
+ * File (or count) the day's alarm row for a site whose payment doors crossed
62
+ * the site counter, and tell staff the first time. Never throws: an alarm
63
+ * that fails must not turn a shopper's checkout into a 500.
64
+ */
65
+ export declare function recordCardPaymentAlarm(input: {
66
+ hostId: string;
67
+ orgId: string | null;
68
+ path: string;
69
+ young: boolean;
70
+ nowMs: number;
71
+ firestore: any;
72
+ notify: typeof notifyStaff;
73
+ notifyManagers: typeof notifyHostManagers;
74
+ }): Promise<{
75
+ reviewId: string;
76
+ first: boolean;
77
+ } | null>;
78
+ /** Forget which alarms this process filed. */
79
+ export declare function resetCardPaymentAlarmsForTests(): void;
80
+ /**
81
+ * Count one opened card payment and return a 429 once a visitor's or an
82
+ * address's window is spent. See the policy module for the three counters.
83
+ */
84
+ export declare function cardPaymentVelocityRefusal(options: CardPaymentVelocityOptions): Promise<Response | null>;
85
+ export default cardPaymentVelocityRefusal;
@@ -0,0 +1,202 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /*==========================================
18
+ * CARD-TESTING VELOCITY, THE DURABLE HALF (AGL-3363).
19
+ *
20
+ * The policy — the three counters, their numbers and why — is
21
+ * `@aglyn/aglyn/app-utils/card-payment-velocity`. This counts them in the
22
+ * shared durable store (`consumeRateLimit`) and answers the dispatcher the
23
+ * way the visitor-write limiter beside it does: a `Response` to return, or
24
+ * null to carry on. The tenant dispatcher calls it for a route registered
25
+ * with `{ cardPayment: true }`, so every plugin's payment door meets it by
26
+ * declaring itself, and none carries its own copy.
27
+ *
28
+ * The site counter refuses nothing. Crossing it files ONE urgent row per
29
+ * site per day in the abuse queue (`source: 'payment-velocity'`), beside the
30
+ * seller-pattern row, and tells staff once.
31
+ *=========================================*/ import { createHash } from "crypto";
32
+ import { ABUSE_REPORT_COLLECTION } from "@aglyn/aglyn/app-utils/abuse-report";
33
+ import { CARD_PAYMENT_VELOCITY, cardPaymentAddressKey, cardPaymentAlarmDay, cardPaymentSiteKey, cardPaymentVisitorKey } from "@aglyn/aglyn/app-utils/card-payment-velocity";
34
+ import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
35
+ import { FieldValue } from "firebase-admin/firestore";
36
+ import firebaseAdmin from "./firebase-admin.js";
37
+ import { notifyHostManagers, notifyStaff } from "./notifications.js";
38
+ import { consumeRateLimit } from "./rate-limit-store.js";
39
+ function clientIp(request) {
40
+ var _readClientIp;
41
+ const headers = request == null ? void 0 : request.headers;
42
+ if (typeof (headers == null ? void 0 : headers.get) !== 'function') return NO_CLIENT_ADDRESS_BUCKET;
43
+ return (_readClientIp = readClientIp(headers)) != null ? _readClientIp : NO_CLIENT_ADDRESS_BUCKET;
44
+ }
45
+ function refusal(resetMs, nowMs) {
46
+ // The same bare 429 as the visitor-write limiter: no remaining-count
47
+ // headers for a stranger to pace a script by.
48
+ return Response.json({
49
+ error: 'Too many payment attempts. Please wait a few minutes and try again.'
50
+ }, {
51
+ status: 429,
52
+ headers: {
53
+ 'Retry-After': String(Math.max(1, Math.ceil((resetMs - nowMs) / 1000)))
54
+ }
55
+ });
56
+ }
57
+ /** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */ export function cardPaymentAlarmReviewId(hostId, day) {
58
+ return createHash('sha256').update(`payment-velocity:${hostId}:${day}`).digest('hex').slice(0, 40);
59
+ }
60
+ /**
61
+ * Tell staff and the site's managers, once per site per day, that its payment
62
+ * doors crossed the card-testing alarm (AGL-3363). The one notifier for this
63
+ * kind of signal, so a shared risk-notice seam can take it over whole.
64
+ *
65
+ * The merchant's sentence names the outcome and what to do, never the rule
66
+ * or its numbers: a merchant who is the card tester must not learn how to
67
+ * pace under it. Never throws.
68
+ */ export async function notifyCardTestingVelocity(input, deps) {
69
+ await Promise.all([
70
+ deps.notifyStaff({
71
+ type: 'system.abuseReportUrgent',
72
+ title: `Card-testing velocity — site ${input.hostId}`,
73
+ body: 'An unusual number of card payments opened on one site' + (input.orgId ? ` (workspace ${input.orgId})` : '') + '. Nothing has been refused site-wide or refunded. Check the site and its ' + 'recent orders; if it is card testing, lock the workspace and pause the ' + `connected account's payouts. Reference ${input.reference}.`,
74
+ link: '/admin/abuse-reports'
75
+ }).catch(()=>undefined),
76
+ deps.notifyManagers(input.hostId, {
77
+ type: 'content.order',
78
+ title: 'Unusual checkout activity on your site',
79
+ body: 'Your checkout saw an unusual burst of payment attempts, which can be ' + 'someone testing stolen cards. Checkout is still open; repeated attempts ' + 'from one source are being slowed. Review recent orders before you ' + 'fulfill them, and refund any you do not recognize. If this was a launch ' + `or sale, no action is needed. Questions: contact support with reference ${input.reference}.`,
80
+ link: `/${input.hostId}/orders`
81
+ }).catch(()=>undefined)
82
+ ]);
83
+ }
84
+ /**
85
+ * File (or count) the day's alarm row for a site whose payment doors crossed
86
+ * the site counter, and tell staff the first time. Never throws: an alarm
87
+ * that fails must not turn a shopper's checkout into a 500.
88
+ */ export async function recordCardPaymentAlarm(input) {
89
+ try {
90
+ const day = cardPaymentAlarmDay(input.nowMs);
91
+ const reviewId = cardPaymentAlarmReviewId(input.hostId, day);
92
+ const reference = `PV-${reviewId.slice(0, 10).toUpperCase()}`;
93
+ const ref = input.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId);
94
+ const first = !(await ref.get()).exists;
95
+ const window = CARD_PAYMENT_VELOCITY.perSite;
96
+ const minutes = Math.round(window.windowMs / 60000);
97
+ await ref.set(_extends({
98
+ reference,
99
+ // "Phishing or fraud" — the queue's urgent fraud category.
100
+ category: 'phishing',
101
+ severity: 'urgent',
102
+ source: 'payment-velocity',
103
+ url: null,
104
+ reportedHostname: null,
105
+ hostId: input.hostId,
106
+ orgId: input.orgId,
107
+ details: [
108
+ `Site ${input.hostId} opened more than ${window.limit} card payments in ${minutes} minutes (first overage through ${input.path}).`,
109
+ input.young ? 'The workspace is young. Each address on this site is held to the tighter per-visitor limit.' : 'Each address is held to the per-visitor limit; nothing site-wide was refused.',
110
+ '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.',
111
+ '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.'
112
+ ].join('\n'),
113
+ reporterEmail: null,
114
+ reporterName: null,
115
+ dmca: null,
116
+ reportCount: FieldValue.increment(1),
117
+ paymentVelocity: {
118
+ hostId: input.hostId,
119
+ orgId: input.orgId,
120
+ day,
121
+ threshold: window.limit,
122
+ windowMinutes: minutes,
123
+ young: input.young
124
+ },
125
+ updatedAt: FieldValue.serverTimestamp()
126
+ }, first ? {
127
+ status: 'open',
128
+ createdAt: FieldValue.serverTimestamp()
129
+ } : {}), {
130
+ merge: true
131
+ });
132
+ if (first) {
133
+ await notifyCardTestingVelocity({
134
+ hostId: input.hostId,
135
+ orgId: input.orgId,
136
+ reference
137
+ }, {
138
+ notifyStaff: input.notify,
139
+ notifyManagers: input.notifyManagers
140
+ });
141
+ }
142
+ return {
143
+ reviewId,
144
+ first
145
+ };
146
+ } catch (error) {
147
+ console.error('[card-payment-velocity] alarm row failed', error);
148
+ return null;
149
+ }
150
+ }
151
+ /** The (site, day) alarms this process has filed. */ const filedAlarms = new Set();
152
+ /** Forget which alarms this process filed. */ export function resetCardPaymentAlarmsForTests() {
153
+ filedAlarms.clear();
154
+ }
155
+ /**
156
+ * Count one opened card payment and return a 429 once a visitor's or an
157
+ * address's window is spent. See the policy module for the three counters.
158
+ */ export async function cardPaymentVelocityRefusal(options) {
159
+ var _options_nowMs;
160
+ const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
161
+ const ip = clientIp(options.request);
162
+ const visitorWindow = options.young ? CARD_PAYMENT_VELOCITY.perVisitorYoung : CARD_PAYMENT_VELOCITY.perVisitor;
163
+ const count = (key, window)=>consumeRateLimit(key, {
164
+ limit: window.limit,
165
+ windowMs: window.windowMs,
166
+ now: nowMs,
167
+ firestore: options.firestore
168
+ });
169
+ const siteKey = cardPaymentSiteKey(options.hostId);
170
+ const [visitor, address, site] = await Promise.all([
171
+ count(cardPaymentVisitorKey(options.hostId, ip), visitorWindow),
172
+ count(cardPaymentAddressKey(ip), CARD_PAYMENT_VELOCITY.perAddress),
173
+ siteKey ? count(siteKey, CARD_PAYMENT_VELOCITY.perSite) : Promise.resolve(null)
174
+ ]);
175
+ // The site counter's overage files the day's alarm. Each process files a
176
+ // (site, day) once — an attack is thousands of overages, and one row that
177
+ // says so is the whole message; the row id makes a second process's filing
178
+ // count on the same row rather than add one.
179
+ const day = cardPaymentAlarmDay(nowMs);
180
+ const filedKey = `${options.hostId}:${day}`;
181
+ if (site && !site.allowed && !site.contended && options.hostId && !filedAlarms.has(filedKey)) {
182
+ var _options_firestore, _options_orgId, _options_notify, _options_notifyManagers;
183
+ filedAlarms.add(filedKey);
184
+ const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
185
+ await recordCardPaymentAlarm({
186
+ hostId: options.hostId,
187
+ orgId: (_options_orgId = options.orgId) != null ? _options_orgId : null,
188
+ path: options.path,
189
+ young: options.young === true,
190
+ nowMs,
191
+ firestore,
192
+ notify: (_options_notify = options.notify) != null ? _options_notify : notifyStaff,
193
+ notifyManagers: (_options_notifyManagers = options.notifyManagers) != null ? _options_notifyManagers : notifyHostManagers
194
+ });
195
+ }
196
+ if (!visitor.allowed) return refusal(visitor.resetMs, nowMs);
197
+ if (!address.allowed) return refusal(address.resetMs, nowMs);
198
+ return null;
199
+ }
200
+ export default cardPaymentVelocityRefusal;
201
+
202
+ //# sourceMappingURL=card-payment-velocity.js.map
@@ -0,0 +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 { notifyHostManagers, notifyStaff } from './notifications'\nimport { consumeRateLimit } from './rate-limit-store'\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 `notifyStaff`. */\n notify?: typeof notifyStaff\n /** Injectable for tests; defaults to `notifyHostManagers`. */\n notifyManagers?: typeof notifyHostManagers\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, once per site per day, that its payment\n * doors crossed the card-testing alarm (AGL-3363). The one notifier for this\n * kind of signal, so a shared risk-notice seam can take it over whole.\n *\n * The merchant's sentence names the outcome and what to do, never the rule\n * or its numbers: a merchant who is the card tester must not learn how to\n * pace under it. Never throws.\n */\nexport async function notifyCardTestingVelocity(\n input: { hostId: string; orgId: string | null; reference: string },\n deps: {\n notifyStaff: typeof notifyStaff\n notifyManagers: typeof notifyHostManagers\n },\n): Promise<void> {\n await Promise.all([\n deps\n .notifyStaff({\n type: 'system.abuseReportUrgent',\n title: `Card-testing velocity — site ${input.hostId}`,\n body:\n 'An unusual number of card payments opened on one site' +\n (input.orgId ? ` (workspace ${input.orgId})` : '') +\n '. Nothing has been refused site-wide or refunded. Check the site and its ' +\n 'recent orders; if it is card testing, lock the workspace and pause the ' +\n `connected account's payouts. Reference ${input.reference}.`,\n link: '/admin/abuse-reports',\n })\n .catch(() => undefined),\n deps\n .notifyManagers(input.hostId, {\n type: 'content.order',\n title: 'Unusual checkout activity on your site',\n body:\n 'Your checkout saw an unusual burst of payment attempts, which can be ' +\n 'someone testing stolen cards. Checkout is still open; repeated attempts ' +\n 'from one source are being slowed. Review recent orders before you ' +\n 'fulfill them, and refund any you do not recognize. If this was a launch ' +\n `or sale, no action is needed. Questions: contact support with reference ${input.reference}.`,\n link: `/${input.hostId}/orders`,\n })\n .catch(() => undefined),\n ])\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 notify: typeof notifyStaff\n notifyManagers: typeof notifyHostManagers\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 },\n { notifyStaff: input.notify, notifyManagers: input.notifyManagers },\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 notify: options.notify ?? notifyStaff,\n notifyManagers: options.notifyManagers ?? notifyHostManagers,\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","notifyHostManagers","notifyStaff","consumeRateLimit","clientIp","request","headers","get","refusal","resetMs","nowMs","Response","json","error","status","String","Math","max","ceil","cardPaymentAlarmReviewId","hostId","day","update","digest","slice","notifyCardTestingVelocity","input","deps","Promise","all","type","title","body","orgId","reference","link","catch","undefined","notifyManagers","recordCardPaymentAlarm","reviewId","toUpperCase","ref","firestore","collection","doc","first","exists","window","perSite","minutes","round","windowMs","set","category","severity","source","url","reportedHostname","details","limit","path","young","join","reporterEmail","reporterName","dmca","reportCount","increment","paymentVelocity","threshold","windowMinutes","updatedAt","serverTimestamp","createdAt","merge","notify","console","filedAlarms","Set","resetCardPaymentAlarmsForTests","clear","cardPaymentVelocityRefusal","options","Date","now","ip","visitorWindow","perVisitorYoung","perVisitor","count","key","siteKey","visitor","address","site","perAddress","resolve","filedKey","allowed","contended","has","add","app"],"mappings":"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"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { resolvePublicIp } from './serve-plugin-fetch';
18
+ /** Why a configured URL was not called. */
19
+ export type ConfiguredUrlRefusal = 'malformed' | 'not-https' | 'credentials' | 'port' | 'lookalike' | 'private-address';
20
+ /** The checks that need no network: shape, scheme, port, credentials, brand. */
21
+ export declare function configuredUrlRefusal(url: unknown): ConfiguredUrlRefusal | null;
22
+ /** What the merchant is told a refusal means, in their words. */
23
+ export declare function describeConfiguredUrlRefusal(refusal: ConfiguredUrlRefusal): string;
24
+ export type ConfiguredUrlFetchResult =
25
+ /** Called: the status it answered. The body is discarded. */
26
+ {
27
+ ok: true;
28
+ status: number;
29
+ } | {
30
+ ok: false;
31
+ refusal: ConfiguredUrlRefusal;
32
+ host: string;
33
+ };
34
+ /**
35
+ * Call `url` under the rules above and answer its status; the body is
36
+ * discarded, because a notification's receiver answers with a status and
37
+ * nothing a caller should read. Never follows a redirect. Throws only what
38
+ * `fetch` throws (a timeout, a reset), for the caller to retry.
39
+ */
40
+ export declare function fetchConfiguredPublicUrl(url: string, init: Omit<RequestInit, 'redirect'>, deps?: {
41
+ resolve?: typeof resolvePublicIp;
42
+ }): Promise<ConfiguredUrlFetchResult>;
@@ -0,0 +1,118 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /*==========================================
18
+ * A SERVER CALL TO A URL A TENANT TYPED (AGL-3363).
19
+ *
20
+ * Some features call an address the merchant configured, from the
21
+ * platform's own servers: a dropship supplier's order webhook is the first.
22
+ * That address is author data, so it gets the checks the plugin fetch proxy
23
+ * (`serve-plugin-fetch.ts`, AGL-515/1881) gives a plugin's declared origin,
24
+ * through the same two primitives, rather than a bare `fetch`:
25
+ *
26
+ * - https only, on the default port, with no credentials in the authority;
27
+ * - a host that wears a brand it is not (`lookalikeBrandForHost`) is refused
28
+ * for every workspace, the phishing screen's strong tier;
29
+ * - the name must resolve to PUBLIC addresses only, and the connection is
30
+ * pinned to the address that was checked (`resolvePublicIp` +
31
+ * `createPinnedDispatcher`), so a name cannot be rebound to the cloud
32
+ * metadata endpoint or a private range between the check and the connect;
33
+ * - a redirect is not followed (`redirect: 'manual'`): a 3xx is the answer,
34
+ * which a caller that wants a 2xx treats as a failure.
35
+ *
36
+ * This sits beside the plugin fetch proxy and is not it: that one proxies a
37
+ * browser's request to an allowlisted origin and streams the reply; this is
38
+ * a server's own call with no allowlist but the rules above.
39
+ *=========================================*/ import { lookalikeBrandForHost } from "@aglyn/shared-util-email/outbound-phishing-screen";
40
+ import { createPinnedDispatcher, resolvePublicIp } from "./serve-plugin-fetch.js";
41
+ /** The checks that need no network: shape, scheme, port, credentials, brand. */ export function configuredUrlRefusal(url) {
42
+ let parsed;
43
+ try {
44
+ parsed = new URL(String(url != null ? url : ''));
45
+ } catch (unused) {
46
+ return 'malformed';
47
+ }
48
+ if (parsed.protocol !== 'https:') return 'not-https';
49
+ if (parsed.username || parsed.password) return 'credentials';
50
+ if (parsed.port && parsed.port !== '443') return 'port';
51
+ if (lookalikeBrandForHost(parsed.hostname)) return 'lookalike';
52
+ return null;
53
+ }
54
+ /** What the merchant is told a refusal means, in their words. */ export function describeConfiguredUrlRefusal(refusal) {
55
+ switch(refusal){
56
+ case 'malformed':
57
+ return 'the address is not a valid web address';
58
+ case 'not-https':
59
+ return 'the address must start with https://';
60
+ case 'credentials':
61
+ return 'the address must not contain a user name or password';
62
+ case 'port':
63
+ return 'the address must use the standard https port';
64
+ case 'lookalike':
65
+ return 'the address looks like another company’s website';
66
+ case 'private-address':
67
+ return 'the address does not lead to a public server';
68
+ }
69
+ }
70
+ /**
71
+ * Call `url` under the rules above and answer its status; the body is
72
+ * discarded, because a notification's receiver answers with a status and
73
+ * nothing a caller should read. Never follows a redirect. Throws only what
74
+ * `fetch` throws (a timeout, a reset), for the caller to retry.
75
+ */ export async function fetchConfiguredPublicUrl(url, init, deps = {}) {
76
+ var _deps_resolve;
77
+ const refusal = configuredUrlRefusal(url);
78
+ let host;
79
+ try {
80
+ host = new URL(url).hostname;
81
+ } catch (unused) {
82
+ host = '';
83
+ }
84
+ if (refusal) return {
85
+ ok: false,
86
+ refusal,
87
+ host
88
+ };
89
+ const pinned = await ((_deps_resolve = deps.resolve) != null ? _deps_resolve : resolvePublicIp)(host);
90
+ if (!pinned) return {
91
+ ok: false,
92
+ refusal: 'private-address',
93
+ host
94
+ };
95
+ const dispatcher = createPinnedDispatcher(pinned);
96
+ try {
97
+ var _response_status;
98
+ var _response_body;
99
+ // `dispatcher` is undici's, which the global fetch honors; spread from a
100
+ // variable so the DOM `RequestInit` type does not reject the field.
101
+ const pinnedInit = _extends({}, init, {
102
+ redirect: 'manual',
103
+ dispatcher
104
+ });
105
+ const response = await fetch(url, pinnedInit);
106
+ // Released before the dispatcher closes: `close` waits for requests in
107
+ // flight, and an unread body is one.
108
+ await ((_response_body = response.body) == null ? void 0 : _response_body.cancel().catch(()=>undefined));
109
+ return {
110
+ ok: true,
111
+ status: Number((_response_status = response.status) != null ? _response_status : 0)
112
+ };
113
+ } finally{
114
+ await dispatcher.close().catch(()=>undefined);
115
+ }
116
+ }
117
+
118
+ //# sourceMappingURL=configured-url-fetch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/configured-url-fetch.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 * A SERVER CALL TO A URL A TENANT TYPED (AGL-3363).\n *\n * Some features call an address the merchant configured, from the\n * platform's own servers: a dropship supplier's order webhook is the first.\n * That address is author data, so it gets the checks the plugin fetch proxy\n * (`serve-plugin-fetch.ts`, AGL-515/1881) gives a plugin's declared origin,\n * through the same two primitives, rather than a bare `fetch`:\n *\n * - https only, on the default port, with no credentials in the authority;\n * - a host that wears a brand it is not (`lookalikeBrandForHost`) is refused\n * for every workspace, the phishing screen's strong tier;\n * - the name must resolve to PUBLIC addresses only, and the connection is\n * pinned to the address that was checked (`resolvePublicIp` +\n * `createPinnedDispatcher`), so a name cannot be rebound to the cloud\n * metadata endpoint or a private range between the check and the connect;\n * - a redirect is not followed (`redirect: 'manual'`): a 3xx is the answer,\n * which a caller that wants a 2xx treats as a failure.\n *\n * This sits beside the plugin fetch proxy and is not it: that one proxies a\n * browser's request to an allowlisted origin and streams the reply; this is\n * a server's own call with no allowlist but the rules above.\n *=========================================*/\n\nimport { lookalikeBrandForHost } from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { createPinnedDispatcher, resolvePublicIp } from './serve-plugin-fetch'\n\n/** Why a configured URL was not called. */\nexport type ConfiguredUrlRefusal =\n | 'malformed'\n | 'not-https'\n | 'credentials'\n | 'port'\n | 'lookalike'\n | 'private-address'\n\n/** The checks that need no network: shape, scheme, port, credentials, brand. */\nexport function configuredUrlRefusal(url: unknown): ConfiguredUrlRefusal | null {\n let parsed: URL\n try {\n parsed = new URL(String(url ?? ''))\n } catch {\n return 'malformed'\n }\n if (parsed.protocol !== 'https:') return 'not-https'\n if (parsed.username || parsed.password) return 'credentials'\n if (parsed.port && parsed.port !== '443') return 'port'\n if (lookalikeBrandForHost(parsed.hostname)) return 'lookalike'\n return null\n}\n\n/** What the merchant is told a refusal means, in their words. */\nexport function describeConfiguredUrlRefusal(refusal: ConfiguredUrlRefusal): string {\n switch (refusal) {\n case 'malformed':\n return 'the address is not a valid web address'\n case 'not-https':\n return 'the address must start with https://'\n case 'credentials':\n return 'the address must not contain a user name or password'\n case 'port':\n return 'the address must use the standard https port'\n case 'lookalike':\n return 'the address looks like another company’s website'\n case 'private-address':\n return 'the address does not lead to a public server'\n }\n}\n\nexport type ConfiguredUrlFetchResult =\n /** Called: the status it answered. The body is discarded. */\n | { ok: true; status: number }\n | { ok: false; refusal: ConfiguredUrlRefusal; host: string }\n\n/**\n * Call `url` under the rules above and answer its status; the body is\n * discarded, because a notification's receiver answers with a status and\n * nothing a caller should read. Never follows a redirect. Throws only what\n * `fetch` throws (a timeout, a reset), for the caller to retry.\n */\nexport async function fetchConfiguredPublicUrl(\n url: string,\n init: Omit<RequestInit, 'redirect'>,\n deps: { resolve?: typeof resolvePublicIp } = {},\n): Promise<ConfiguredUrlFetchResult> {\n const refusal = configuredUrlRefusal(url)\n let host: string\n try {\n host = new URL(url).hostname\n } catch {\n host = ''\n }\n if (refusal) return { ok: false, refusal, host }\n const pinned = await (deps.resolve ?? resolvePublicIp)(host)\n if (!pinned) return { ok: false, refusal: 'private-address', host }\n const dispatcher = createPinnedDispatcher(pinned)\n try {\n // `dispatcher` is undici's, which the global fetch honors; spread from a\n // variable so the DOM `RequestInit` type does not reject the field.\n const pinnedInit = { ...init, redirect: 'manual' as const, dispatcher }\n const response = await fetch(url, pinnedInit as RequestInit)\n // Released before the dispatcher closes: `close` waits for requests in\n // flight, and an unread body is one.\n await response.body?.cancel().catch(() => undefined)\n return { ok: true, status: Number(response.status ?? 0) }\n } finally {\n await dispatcher.close().catch(() => undefined)\n }\n}\n"],"names":["lookalikeBrandForHost","createPinnedDispatcher","resolvePublicIp","configuredUrlRefusal","url","parsed","URL","String","protocol","username","password","port","hostname","describeConfiguredUrlRefusal","refusal","fetchConfiguredPublicUrl","init","deps","host","ok","pinned","resolve","dispatcher","response","pinnedInit","redirect","fetch","body","cancel","catch","undefined","status","Number","close"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;2CAsB2C,GAE3C,SAASA,qBAAqB,QAAQ,oDAAmD;AACzF,SAASC,sBAAsB,EAAEC,eAAe,QAAQ,0BAAsB;AAW9E,8EAA8E,GAC9E,OAAO,SAASC,qBAAqBC,GAAY;IAC/C,IAAIC;IACJ,IAAI;QACFA,SAAS,IAAIC,IAAIC,OAAOH,cAAAA,MAAO;IACjC,EAAE,eAAM;QACN,OAAO;IACT;IACA,IAAIC,OAAOG,QAAQ,KAAK,UAAU,OAAO;IACzC,IAAIH,OAAOI,QAAQ,IAAIJ,OAAOK,QAAQ,EAAE,OAAO;IAC/C,IAAIL,OAAOM,IAAI,IAAIN,OAAOM,IAAI,KAAK,OAAO,OAAO;IACjD,IAAIX,sBAAsBK,OAAOO,QAAQ,GAAG,OAAO;IACnD,OAAO;AACT;AAEA,+DAA+D,GAC/D,OAAO,SAASC,6BAA6BC,OAA6B;IACxE,OAAQA;QACN,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;IACX;AACF;AAOA;;;;;CAKC,GACD,OAAO,eAAeC,yBACpBX,GAAW,EACXY,IAAmC,EACnCC,OAA6C,CAAC,CAAC;QAUzBA;IARtB,MAAMH,UAAUX,qBAAqBC;IACrC,IAAIc;IACJ,IAAI;QACFA,OAAO,IAAIZ,IAAIF,KAAKQ,QAAQ;IAC9B,EAAE,eAAM;QACNM,OAAO;IACT;IACA,IAAIJ,SAAS,OAAO;QAAEK,IAAI;QAAOL;QAASI;IAAK;IAC/C,MAAME,SAAS,MAAM,EAACH,gBAAAA,KAAKI,OAAO,YAAZJ,gBAAgBf,iBAAiBgB;IACvD,IAAI,CAACE,QAAQ,OAAO;QAAED,IAAI;QAAOL,SAAS;QAAmBI;IAAK;IAClE,MAAMI,aAAarB,uBAAuBmB;IAC1C,IAAI;YAQgCG;YAD5BA;QANN,yEAAyE;QACzE,oEAAoE;QACpE,MAAMC,aAAa,aAAKR;YAAMS,UAAU;YAAmBH;;QAC3D,MAAMC,WAAW,MAAMG,MAAMtB,KAAKoB;QAClC,uEAAuE;QACvE,qCAAqC;QACrC,QAAMD,iBAAAA,SAASI,IAAI,qBAAbJ,eAAeK,MAAM,GAAGC,KAAK,CAAC,IAAMC;QAC1C,OAAO;YAAEX,IAAI;YAAMY,QAAQC,QAAOT,mBAAAA,SAASQ,MAAM,YAAfR,mBAAmB;QAAG;IAC1D,SAAU;QACR,MAAMD,WAAWW,KAAK,GAAGJ,KAAK,CAAC,IAAMC;IACvC;AACF"}
@@ -64,7 +64,11 @@ export declare function recordServedPageVersion(hostId: string, screenId: string
64
64
  *
65
65
  * - `site` — a custom domain a site is served on (the attach route);
66
66
  * - `sending` — a custom domain a workspace sends email as, when it is
67
- * added and again when it verifies (`sending-domains.ts`).
67
+ * added and again when it verifies (`sending-domains.ts`);
68
+ * - `link` — a link a merchant configured that a buyer or a server would
69
+ * follow: a product's paid download or video hotlink, a supplier's order
70
+ * webhook (AGL-3363). The link itself is refused where it is followed; this
71
+ * files it, and tells the site's managers once ({@link notifyLookalikeLink}).
68
72
  *
69
73
  * Not refused, because the domain is the merchant's own registration and
70
74
  * the platform cannot tell a brand's own agency attaching `paypal-promo.com`
@@ -79,11 +83,27 @@ export declare function recordServedPageVersion(hostId: string, screenId: string
79
83
  * Never throws: the write it rides on has already happened.
80
84
  */
81
85
  export declare function flagLookalikeDomain(input: {
82
- kind: 'site' | 'sending';
86
+ kind: 'site' | 'sending' | 'link';
83
87
  hostId: string | null;
84
88
  orgId: string | null;
85
89
  domain: string;
90
+ /** For a `link`: where it was configured, as staff read it. */
91
+ where?: string;
86
92
  }): Promise<'flagged' | 'clean' | 'failed'>;
93
+ /**
94
+ * Tell staff and the site's managers, once per (site, domain), that a link
95
+ * the merchant configured was refused because it wears another brand's name
96
+ * (AGL-3363). The one notifier for this kind of signal, so a shared
97
+ * risk-notice seam can take it over whole. The merchant is told what was
98
+ * refused and how to fix it; never how the check works. Never throws.
99
+ */
100
+ export declare function notifyLookalikeLink(input: {
101
+ hostId: string | null;
102
+ domain: string;
103
+ label: string;
104
+ where: string;
105
+ reference: string;
106
+ }): Promise<void>;
87
107
  /** A site's custom domain, through {@link flagLookalikeDomain}. */
88
108
  export declare function flagLookalikeCustomDomain(input: {
89
109
  hostId: string;