@aglyn/tenant-data-admin 1.0.0-beta.202 → 1.0.0-beta.204
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.
- package/package.json +7 -7
- package/src/lib/server/capture-email-check.d.ts +109 -0
- package/src/lib/server/capture-email-check.js +362 -0
- package/src/lib/server/capture-email-check.js.map +1 -0
- package/src/lib/server/card-payment-velocity.d.ts +11 -16
- package/src/lib/server/card-payment-velocity.js +24 -28
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +19 -3
- package/src/lib/server/email-deliverability.js +34 -16
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/held-page-subject.d.ts +93 -0
- package/src/lib/server/held-page-subject.js +206 -0
- package/src/lib/server/held-page-subject.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +42 -1
- package/src/lib/server/host-email-tokens.js +100 -12
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-visitor-records.js +21 -5
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +16 -5
- package/src/lib/server/hosted-page-review.js +242 -61
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/list-members.js +16 -0
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +22 -0
- package/src/lib/server/outbound-send-review.js +74 -6
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.d.ts +20 -12
- package/src/lib/server/payment-fraud-signal.js +41 -11
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/payment-risk-record.d.ts +37 -13
- package/src/lib/server/payment-risk-record.js +23 -8
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/render-system-email.d.ts +3 -0
- package/src/lib/server/render-system-email.js +39 -11
- package/src/lib/server/render-system-email.js.map +1 -1
- package/src/lib/server/risk-notice.d.ts +327 -0
- package/src/lib/server/risk-notice.js +1240 -0
- package/src/lib/server/risk-notice.js.map +1 -0
- package/src/lib/server/sender-readiness.d.ts +36 -0
- package/src/lib/server/sender-readiness.js +99 -0
- package/src/lib/server/sender-readiness.js.map +1 -0
- package/src/lib/server/stripe-transfer-reversal.d.ts +63 -0
- package/src/lib/server/stripe-transfer-reversal.js +177 -0
- package/src/lib/server/stripe-transfer-reversal.js.map +1 -0
- package/src/lib/server/test-firestore.js +1 -0
- package/src/lib/server/test-firestore.js.map +1 -1
- package/src/lib/server/upsert-contact.js +19 -3
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +2 -0
- package/src/tenancy.js +3 -0
- package/src/tenancy.js.map +1 -1
|
@@ -69,12 +69,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
69
69
|
* not serving a page the screen flagged is a false positive's wait; serving
|
|
70
70
|
* it is the phishing page this exists to stop.
|
|
71
71
|
*=========================================*/ import { ABUSE_REPORT_COLLECTION } from "@aglyn/aglyn/app-utils/abuse-report";
|
|
72
|
-
import {
|
|
72
|
+
import { heldPageConsolePath, heldPageDetails, heldPageLabel, heldPageVisitorSentence } from "@aglyn/shared-util-email/held-page";
|
|
73
73
|
import { screenHostedPage } from "@aglyn/shared-util-email/hosted-page-screen";
|
|
74
|
-
import { lookalikeBrandForHost, phishingScreenBrandLabel, signalsThatHold } from "@aglyn/shared-util-email/outbound-phishing-screen";
|
|
74
|
+
import { lookalikeBrandForHost, phishingScreenBrandLabel, describePhishingScreenSignals, isYoungWorkspaceAge, signalsThatHold } from "@aglyn/shared-util-email/outbound-phishing-screen";
|
|
75
75
|
import { FieldValue } from "firebase-admin/firestore";
|
|
76
76
|
import firebaseAdmin from "./firebase-admin.js";
|
|
77
|
-
import {
|
|
77
|
+
import { describeHeldPage } from "./held-page-subject.js";
|
|
78
|
+
import { notifyRiskEvent } from "./risk-notice.js";
|
|
78
79
|
import { orgAgeDays } from "./org-age.js";
|
|
79
80
|
import { getHostDocAdmin, getOrgForHost } from "./organizations.js";
|
|
80
81
|
import { canonicalJson, fileOutboundHold, flaggedHostOf, heldOutboundReference, heldOutboundReviewId, outboundContentHash, workspaceScreenIdentity } from "./outbound-send-review.js";
|
|
@@ -88,20 +89,30 @@ import { canonicalJson, fileOutboundHold, flaggedHostOf, heldOutboundReference,
|
|
|
88
89
|
/** How long this process trusts a page row's state — the send seam's minute. */ const PAGE_MEMO_TTL_MS = 60000;
|
|
89
90
|
const pageMemo = new Map();
|
|
90
91
|
/** The version each page last served clean, as this process last wrote it. */ const servedMemo = new Map();
|
|
92
|
+
/** Lookalike hosts this process already flagged for an established page, and when. */ const flaggedMemo = new Map();
|
|
93
|
+
const FLAGGED_MEMO_TTL_MS = 60 * 60000;
|
|
91
94
|
/** Test seam: forget what this process learned. */ export function resetHostedPageReviewMemoForTests() {
|
|
92
95
|
pageMemo.clear();
|
|
93
96
|
servedMemo.clear();
|
|
97
|
+
flaggedMemo.clear();
|
|
94
98
|
}
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The subject a row already stored, or a fresh description (AGL-3374). A row
|
|
101
|
+
* that names its page is not described again, so the reads the description
|
|
102
|
+
* may make are paid once per row, not once per render.
|
|
103
|
+
*/ async function subjectForRow(reviewId, describe) {
|
|
104
|
+
const stored = await firebaseAdmin.app().firestore().collection(ABUSE_REPORT_COLLECTION).doc(reviewId).get().then((row)=>storedSubject(row.get('heldPage'))).catch(()=>null);
|
|
105
|
+
return stored != null ? stored : describe();
|
|
106
|
+
}
|
|
107
|
+
/** A row's stored subject, when it has one. */ function storedSubject(heldPage) {
|
|
108
|
+
const subject = heldPage == null ? void 0 : heldPage.subject;
|
|
109
|
+
return subject && typeof subject === 'object' && subject.screenId ? subject : null;
|
|
110
|
+
}
|
|
111
|
+
/** The row's first line, naming the page and where it was seen. */ function pageHeadline(subject, siteName) {
|
|
112
|
+
return [
|
|
113
|
+
`${heldPageLabel(subject)} of ${siteName}.`,
|
|
114
|
+
...heldPageDetails(subject)
|
|
115
|
+
].join(' ');
|
|
105
116
|
}
|
|
106
117
|
/**
|
|
107
118
|
* Screen one page as a visitor would receive it, and say whether it may be
|
|
@@ -115,9 +126,8 @@ const pageMemo = new Map();
|
|
|
115
126
|
outcome: 'serve'
|
|
116
127
|
};
|
|
117
128
|
const nowMs = (_request_nowMs = request.nowMs) != null ? _request_nowMs : Date.now();
|
|
118
|
-
let reviewId = '';
|
|
119
129
|
try {
|
|
120
|
-
var _request_orgId, _ref, _ref1;
|
|
130
|
+
var _request_orgId, _ref, _ref1, _subject_entryUrl;
|
|
121
131
|
const [host, owner] = await Promise.all([
|
|
122
132
|
request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),
|
|
123
133
|
request.org !== undefined ? {
|
|
@@ -137,13 +147,50 @@ const pageMemo = new Map();
|
|
|
137
147
|
nodes: request.nodes
|
|
138
148
|
}, identity));
|
|
139
149
|
const ageDays = orgAgeDays(org == null ? void 0 : org.createdAt, nowMs);
|
|
150
|
+
// Every workspace is screened, however old: an established account can
|
|
151
|
+
// be compromised, and a compromised account publishing a lure is the
|
|
152
|
+
// same harm as a new one. What differs is what a hold may take DOWN.
|
|
153
|
+
// Holding content that is already live serves its last clean version or
|
|
154
|
+
// nothing, and on a real business's home page that is an outage the
|
|
155
|
+
// screen caused (aglyn.com, 2026-09-28, a page that had served clean
|
|
156
|
+
// until a rule changed). So for an established workspace:
|
|
157
|
+
// - NEW content (a new version of a live page, or a page that has
|
|
158
|
+
// never been live) holds, exactly as for a young workspace; the live
|
|
159
|
+
// page keeps serving its last clean version, or the new page waits;
|
|
160
|
+
// - content ALREADY LIVE keeps serving, and is flagged to staff as
|
|
161
|
+
// urgent, who lock the site if it is impersonation.
|
|
162
|
+
if (!isYoungWorkspaceAge(ageDays)) {
|
|
163
|
+
const strong = signalsThatHold(verdict.signals, {
|
|
164
|
+
ageDays
|
|
165
|
+
});
|
|
166
|
+
if (!strong.length) return {
|
|
167
|
+
outcome: 'serve'
|
|
168
|
+
};
|
|
169
|
+
if (await isAlreadyLive(request.hostId, request.screenId, request.versionId, nowMs)) {
|
|
170
|
+
var _request_page;
|
|
171
|
+
await flagLivePage({
|
|
172
|
+
hostId: request.hostId,
|
|
173
|
+
screenId: request.screenId,
|
|
174
|
+
versionId: request.versionId,
|
|
175
|
+
orgId,
|
|
176
|
+
host,
|
|
177
|
+
signals: strong,
|
|
178
|
+
nowMs,
|
|
179
|
+
page: (_request_page = request.page) != null ? _request_page : null,
|
|
180
|
+
identity
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
outcome: 'serve'
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
140
187
|
const signals = signalsThatHold(verdict.signals, {
|
|
141
188
|
ageDays
|
|
142
189
|
});
|
|
143
190
|
if (!signals.length) return {
|
|
144
191
|
outcome: 'serve'
|
|
145
192
|
};
|
|
146
|
-
reviewId = pageReviewId(request.hostId, request.screenId, signals);
|
|
193
|
+
const reviewId = pageReviewId(request.hostId, request.screenId, signals);
|
|
147
194
|
const reference = heldOutboundReference(reviewId);
|
|
148
195
|
const memo = pageMemo.get(reviewId);
|
|
149
196
|
if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {
|
|
@@ -155,9 +202,23 @@ const pageMemo = new Map();
|
|
|
155
202
|
reference
|
|
156
203
|
};
|
|
157
204
|
}
|
|
158
|
-
|
|
205
|
+
// The page by its real name and route (AGL-3374): a template, a list,
|
|
206
|
+
// a variant, or a page whose flagged content lives in its layout.
|
|
207
|
+
const subject = await subjectForRow(reviewId, async ()=>describeHeldPage({
|
|
208
|
+
hostId: request.hostId,
|
|
209
|
+
screenId: request.screenId,
|
|
210
|
+
versionId: request.versionId,
|
|
211
|
+
host,
|
|
212
|
+
page: request.page,
|
|
213
|
+
signals,
|
|
214
|
+
identity,
|
|
215
|
+
live: false,
|
|
216
|
+
previousVersionId: await servedPageVersion(request.hostId, request.screenId)
|
|
217
|
+
}));
|
|
218
|
+
const url = (_subject_entryUrl = subject.entryUrl) != null ? _subject_entryUrl : subject.url;
|
|
159
219
|
const flagged = flaggedHostOf(signals);
|
|
160
|
-
const
|
|
220
|
+
const siteName = typeof (host == null ? void 0 : host['name']) === 'string' && host['name'] ? `"${host['name']}"` : 'a site';
|
|
221
|
+
const label = heldPageLabel(subject);
|
|
161
222
|
const state = await fileOutboundHold({
|
|
162
223
|
reviewId,
|
|
163
224
|
heldSend: {
|
|
@@ -168,7 +229,7 @@ const pageMemo = new Map();
|
|
|
168
229
|
contentHash: outboundContentHash([
|
|
169
230
|
signals
|
|
170
231
|
]),
|
|
171
|
-
subject:
|
|
232
|
+
subject: label.slice(0, 300),
|
|
172
233
|
fromName: null,
|
|
173
234
|
signals,
|
|
174
235
|
state: 'held',
|
|
@@ -179,12 +240,19 @@ const pageMemo = new Map();
|
|
|
179
240
|
heldPage: {
|
|
180
241
|
screenId: request.screenId,
|
|
181
242
|
versionId: request.versionId,
|
|
182
|
-
url
|
|
243
|
+
url,
|
|
244
|
+
subject
|
|
183
245
|
}
|
|
184
246
|
},
|
|
185
|
-
headline: `Held
|
|
247
|
+
headline: `Held ${pageHeadline(subject, siteName)} Version ${request.versionId}, from a ` + `workspace ${ageDays != null ? ageDays : '?'} day(s) old. ${heldPageVisitorSentence(subject)}`,
|
|
186
248
|
alertTitle: 'Published page held for review — possible phishing',
|
|
187
|
-
alertBody:
|
|
249
|
+
alertBody: `${label.charAt(0).toUpperCase()}${label.slice(1)} was held before it went live.`,
|
|
250
|
+
// The owners' words for it, and where they fix it (AGL-3368, AGL-3374).
|
|
251
|
+
item: {
|
|
252
|
+
label,
|
|
253
|
+
path: heldPageConsolePath(subject, request.hostId)
|
|
254
|
+
},
|
|
255
|
+
page: subject,
|
|
188
256
|
// The page itself is what was reported; the flagged host, when there
|
|
189
257
|
// is one, is named in the details and on `reportedHostname`.
|
|
190
258
|
url,
|
|
@@ -202,27 +270,129 @@ const pageMemo = new Map();
|
|
|
202
270
|
reference
|
|
203
271
|
};
|
|
204
272
|
} catch (error) {
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
ageDays: null
|
|
209
|
-
}).length) {
|
|
210
|
-
console.error('[page-review] a page could not be reviewed — serving it', error);
|
|
211
|
-
return {
|
|
212
|
-
outcome: 'serve'
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
console.error('[page-review] a flagged page could not be reviewed — not serving it', error);
|
|
273
|
+
// Open: a review that could not run must not take a live page down —
|
|
274
|
+
// the screen failing is not evidence about the page.
|
|
275
|
+
console.error('[page-review] a flagged page could not be reviewed — serving it', error);
|
|
216
276
|
return {
|
|
217
|
-
outcome: '
|
|
218
|
-
reviewId,
|
|
219
|
-
reference: reviewId ? heldOutboundReference(reviewId) : ''
|
|
277
|
+
outcome: 'serve'
|
|
220
278
|
};
|
|
221
279
|
}
|
|
222
280
|
}
|
|
223
281
|
function pageReviewRef(hostId, screenId) {
|
|
224
282
|
return firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection(PAGE_REVIEW_SUBCOLLECTION).doc(screenId);
|
|
225
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Is this version of the page already what visitors receive? It is when it
|
|
286
|
+
* is the version the page last served clean, or when the page has no record
|
|
287
|
+
* at all and is not new: it went live before the screen existed. A page
|
|
288
|
+
* created in the last {@link NEW_PAGE_DAYS} days with no record has never
|
|
289
|
+
* been live, so holding it takes nothing down.
|
|
290
|
+
*/ const NEW_PAGE_DAYS = 7;
|
|
291
|
+
async function isAlreadyLive(hostId, screenId, versionId, nowMs) {
|
|
292
|
+
const lastClean = await servedPageVersion(hostId, screenId);
|
|
293
|
+
if (lastClean) return lastClean === versionId;
|
|
294
|
+
try {
|
|
295
|
+
const screen = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection('screens').doc(screenId).get();
|
|
296
|
+
const created = screen.get('createdAt');
|
|
297
|
+
const createdMs = typeof created === 'number' ? created : typeof (created == null ? void 0 : created.toMillis) === 'function' ? created.toMillis() : null;
|
|
298
|
+
// No readable creation time: an old page, so live.
|
|
299
|
+
return createdMs === null || nowMs - createdMs >= NEW_PAGE_DAYS * 24 * 60 * 60 * 1000;
|
|
300
|
+
} catch (unused) {
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* A page ALREADY LIVE carries a strong signal: file one urgent abuse row for
|
|
306
|
+
* it (keyed on the page and its signals, so a busy page is one row) and, the
|
|
307
|
+
* first time, tell staff and the owners through the `page-flagged` notice.
|
|
308
|
+
* The page keeps serving; staff lock the site if it is impersonation or the
|
|
309
|
+
* account looks compromised. Once per page and signal set per process.
|
|
310
|
+
*/ async function flagLivePage(input) {
|
|
311
|
+
const key = `${input.hostId}\n${input.screenId}\n${input.signals.map((signal)=>canonicalJson(signal)).sort().join('\n')}`;
|
|
312
|
+
const at = flaggedMemo.get(key);
|
|
313
|
+
if (at !== undefined && input.nowMs - at < FLAGGED_MEMO_TTL_MS) return;
|
|
314
|
+
flaggedMemo.set(key, input.nowMs);
|
|
315
|
+
try {
|
|
316
|
+
var _ref, _input_identity, _subject_entryUrl;
|
|
317
|
+
var _input_host;
|
|
318
|
+
const reviewId = pageReviewId(input.hostId, `${input.screenId}/live`, input.signals);
|
|
319
|
+
const reference = heldOutboundReference(reviewId);
|
|
320
|
+
const evidence = describePhishingScreenSignals(input.signals).join(' ');
|
|
321
|
+
const flagged = flaggedHostOf(input.signals);
|
|
322
|
+
const ref = firebaseAdmin.app().firestore().collection(ABUSE_REPORT_COLLECTION).doc(reviewId);
|
|
323
|
+
const existing = await ref.get();
|
|
324
|
+
const first = !existing.exists;
|
|
325
|
+
// Named once, when the row is new (AGL-3374).
|
|
326
|
+
const subject = (_ref = existing.exists ? storedSubject(existing.get('heldPage')) : null) != null ? _ref : await describeHeldPage({
|
|
327
|
+
hostId: input.hostId,
|
|
328
|
+
screenId: input.screenId,
|
|
329
|
+
versionId: input.versionId,
|
|
330
|
+
host: input.host,
|
|
331
|
+
page: input.page,
|
|
332
|
+
signals: input.signals,
|
|
333
|
+
identity: (_input_identity = input.identity) != null ? _input_identity : workspaceScreenIdentity({
|
|
334
|
+
host: input.host
|
|
335
|
+
}),
|
|
336
|
+
live: true,
|
|
337
|
+
previousVersionId: null
|
|
338
|
+
});
|
|
339
|
+
const url = (_subject_entryUrl = subject.entryUrl) != null ? _subject_entryUrl : subject.url;
|
|
340
|
+
const siteName = typeof ((_input_host = input.host) == null ? void 0 : _input_host['name']) === 'string' && input.host['name'] ? `"${input.host['name']}"` : 'a site';
|
|
341
|
+
await ref.set(_extends({
|
|
342
|
+
reference,
|
|
343
|
+
category: 'phishing',
|
|
344
|
+
severity: 'urgent',
|
|
345
|
+
source: 'outbound-screen',
|
|
346
|
+
url,
|
|
347
|
+
reportedHostname: flagged,
|
|
348
|
+
hostId: input.hostId,
|
|
349
|
+
orgId: input.orgId,
|
|
350
|
+
details: [
|
|
351
|
+
`LIVE: ${pageHeadline(subject, siteName)} It was flagged by the phishing screen and is still serving.`,
|
|
352
|
+
evidence,
|
|
353
|
+
'Lock the site if it is impersonation or the account looks compromised; dismiss this if the content is theirs.'
|
|
354
|
+
].join('\n'),
|
|
355
|
+
reporterEmail: null,
|
|
356
|
+
reporterName: null,
|
|
357
|
+
dmca: null,
|
|
358
|
+
heldPage: {
|
|
359
|
+
screenId: input.screenId,
|
|
360
|
+
versionId: input.versionId,
|
|
361
|
+
url,
|
|
362
|
+
subject
|
|
363
|
+
},
|
|
364
|
+
riskNotice: {
|
|
365
|
+
kind: 'page-flagged'
|
|
366
|
+
},
|
|
367
|
+
reportCount: FieldValue.increment(1),
|
|
368
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
369
|
+
}, first ? {
|
|
370
|
+
status: 'open',
|
|
371
|
+
createdAt: FieldValue.serverTimestamp()
|
|
372
|
+
} : {}), {
|
|
373
|
+
merge: true
|
|
374
|
+
});
|
|
375
|
+
if (!first) return;
|
|
376
|
+
await notifyRiskEvent({
|
|
377
|
+
kind: 'page-flagged',
|
|
378
|
+
orgId: input.orgId,
|
|
379
|
+
hostId: input.hostId,
|
|
380
|
+
reviewId,
|
|
381
|
+
reference,
|
|
382
|
+
item: {
|
|
383
|
+
label: heldPageLabel(subject),
|
|
384
|
+
path: heldPageConsolePath(subject, input.hostId)
|
|
385
|
+
},
|
|
386
|
+
page: subject,
|
|
387
|
+
staffEvidence: [
|
|
388
|
+
...heldPageDetails(subject),
|
|
389
|
+
evidence
|
|
390
|
+
].join(' ')
|
|
391
|
+
});
|
|
392
|
+
} catch (error) {
|
|
393
|
+
console.error('[page-review] a live page could not be flagged', error);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
226
396
|
/**
|
|
227
397
|
* The version this page last served clean, or null. A HINT, not a
|
|
228
398
|
* clearance: the caller composes and screens it again before serving it,
|
|
@@ -328,17 +498,28 @@ function pageReviewRef(hostId, screenId) {
|
|
|
328
498
|
var _input_where1;
|
|
329
499
|
await notifyLookalikeLink({
|
|
330
500
|
hostId: input.hostId,
|
|
501
|
+
orgId: input.orgId,
|
|
331
502
|
domain,
|
|
332
503
|
label,
|
|
333
504
|
where: (_input_where1 = input.where) != null ? _input_where1 : 'a commerce link',
|
|
334
|
-
reference
|
|
505
|
+
reference,
|
|
506
|
+
reviewId
|
|
335
507
|
});
|
|
336
508
|
} else if (first) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
509
|
+
// The owners hear that the domain is waiting on a routine review — not
|
|
510
|
+
// which name it resembles; staff get the brand and a link to the row.
|
|
511
|
+
await notifyRiskEvent({
|
|
512
|
+
kind: 'domain-flagged',
|
|
513
|
+
orgId: input.orgId,
|
|
514
|
+
hostId: input.hostId,
|
|
515
|
+
reviewId,
|
|
516
|
+
reference,
|
|
517
|
+
item: {
|
|
518
|
+
label: sending ? `the sending domain ${domain}` : `the custom domain ${domain}`,
|
|
519
|
+
// Emails → Sending, or the site's Domain settings.
|
|
520
|
+
path: sending ? '/org/emails/sending' : input.hostId ? `/${input.hostId}/admin/domain` : null
|
|
521
|
+
},
|
|
522
|
+
staffEvidence: `${sending ? 'Sending domain' : 'Custom domain'} ${domain} looks like ${label}.`
|
|
342
523
|
});
|
|
343
524
|
}
|
|
344
525
|
return 'flagged';
|
|
@@ -348,26 +529,26 @@ function pageReviewRef(hostId, screenId) {
|
|
|
348
529
|
}
|
|
349
530
|
}
|
|
350
531
|
/**
|
|
351
|
-
* Tell staff and the site's managers
|
|
352
|
-
* the merchant configured was refused because it
|
|
353
|
-
* (AGL-3363)
|
|
354
|
-
*
|
|
355
|
-
* refused and how to fix it
|
|
532
|
+
* Tell staff and the site's managers and the workspace's owners, once per
|
|
533
|
+
* (site, domain), that a link the merchant configured was refused because it
|
|
534
|
+
* wears another brand's name (AGL-3363), through the risk notice seam
|
|
535
|
+
* (AGL-3368). The merchant reads the catalog's `link-blocked` words — what
|
|
536
|
+
* was refused and how to fix it, never how the check works; staff get the
|
|
537
|
+
* brand and a link to the row. Never throws.
|
|
356
538
|
*/ export async function notifyLookalikeLink(input) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
]);
|
|
539
|
+
var _input_orgId, _input_reviewId;
|
|
540
|
+
await notifyRiskEvent({
|
|
541
|
+
kind: 'link-blocked',
|
|
542
|
+
orgId: (_input_orgId = input.orgId) != null ? _input_orgId : null,
|
|
543
|
+
hostId: input.hostId,
|
|
544
|
+
reviewId: (_input_reviewId = input.reviewId) != null ? _input_reviewId : null,
|
|
545
|
+
reference: input.reference,
|
|
546
|
+
item: {
|
|
547
|
+
label: `${input.where} pointing to ${input.domain}`,
|
|
548
|
+
path: input.hostId ? `/${input.hostId}/products` : null
|
|
549
|
+
},
|
|
550
|
+
staffEvidence: `A site configured ${input.where} to ${input.domain}, which looks like ${input.label}. ` + 'It is refused wherever it is followed.'
|
|
551
|
+
}).catch(()=>undefined);
|
|
371
552
|
}
|
|
372
553
|
/** A site's custom domain, through {@link flagLookalikeDomain}. */ export function flagLookalikeCustomDomain(input) {
|
|
373
554
|
return flagLookalikeDomain(_extends({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/hosted-page-review.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 * HELD FOR REVIEW: a published page (AGL-3362).\n *\n * ## Why the check sits where a page is COMPOSED, not where it is published\n *\n * A publish is a browser write. The besigner toolbar, the versions panel,\n * the screen details page, page-from-template, the layout and component\n * besigners and the form besigner each move a pointer (`versionId`, the\n * host's `screens` map, a component's own `nodes`) straight from the\n * client SDK; the scheduled-publish beat moves it from the server; an\n * experiment serves a version no pointer names; and an author may rewrite\n * the LIVE version document itself without moving any pointer at all. A\n * check on any one of those writes is a check the next one walks around.\n *\n * They all meet in one place: `composeScreenNodes` (`@aglyn/tenant-runtime`),\n * which every published render, the password unlock, the collection and\n * product templates and every experiment variant go through, and which\n * returns the page exactly as a visitor will receive it — the screen, its\n * layout chrome, its grafted components and its forms. So the screen is read\n * there, and a held page is one that is never composed for a visitor. For a\n * visitor, \"publish\" is the first render after the pointer moved; this is\n * that moment.\n *\n * ## What a hold does\n *\n * - The version is not served. The last version this page served clean\n * ({@link servedPageVersion}) is composed instead — screened again, since\n * a version document can be edited in place — and if there is none, or it\n * holds too, the page is not found. A first publish that holds therefore\n * serves nothing.\n * - One review row per (page, signal set) lands in the abuse queue at\n * `/admin/abuse-reports`, the same `phishing` row a held email files\n * (`fileOutboundHold`), with `heldSend.kind: 'page'`, the page's URL and\n * the held version. Staff dismiss it to RELEASE (the admin route drops the\n * site's cache and the next render serves it) or mark it actioned to\n * REJECT (it stays unserved; any later version carrying the same signals\n * is refused without a new row).\n * - Keyed on the SIGNALS, as the send seam's rows are: a composed page\n * carries per-render values (dates, collection rows) that no content hash\n * would survive, while what makes it phishing does not change between\n * renders.\n *\n * ## Tiers\n *\n * `signalsThatHold`: a lookalike link or embed and an author-defined\n * credential field hold for EVERY workspace; a brand's call to action only\n * for a workspace in its first fortnight. The workspace's documents are read\n * only when the pure screen has found something, so a clean page costs no\n * read for this.\n *\n * Fails CLOSED, unlike the send path. Composition itself reads the store, so\n * an outage here is an outage on the render already, and the only cost of\n * not serving a page the screen flagged is a false positive's wait; serving\n * it is the phishing page this exists to stop.\n *=========================================*/\n\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/host-naming'\nimport { screenHostedPage } from '@aglyn/shared-util-email/hosted-page-screen'\nimport {\n lookalikeBrandForHost,\n phishingScreenBrandLabel,\n type PhishingScreenSignal,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyHostManagers, notifyStaff } from './notifications'\nimport { orgAgeDays } from './org-age'\nimport { getHostDocAdmin, getOrgForHost } from './organizations'\nimport {\n canonicalJson,\n fileOutboundHold,\n flaggedHostOf,\n heldOutboundReference,\n heldOutboundReviewId,\n outboundContentHash,\n type HeldOutboundSendState,\n workspaceScreenIdentity,\n} from './outbound-send-review'\n\n/** Where the last version a page served clean is noted: a hint, re-screened before use. */\nexport const PAGE_REVIEW_SUBCOLLECTION = 'pageReviews'\n\nexport interface HostedPageReviewRequest {\n hostId: string\n screenId: string\n versionId: string\n /** The page as composed for a visitor. */\n nodes: unknown\n /** The host and org documents, when the caller already holds them. */\n host?: Record<string, unknown> | null\n org?: Record<string, unknown> | null\n orgId?: string | null\n nowMs?: number\n}\n\nexport type HostedPageReviewOutcome =\n | { outcome: 'serve' }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string }\n\n/** The review row's id for a page and the signals that held. */\nexport function pageReviewId(\n hostId: string,\n screenId: string,\n signals: readonly PhishingScreenSignal[],\n): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/screens/${screenId}`, outboundContentHash([key]))\n}\n\n/** How long this process trusts a page row's state — the send seam's minute. */\nconst PAGE_MEMO_TTL_MS = 60_000\nconst pageMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n/** The version each page last served clean, as this process last wrote it. */\nconst servedMemo = new Map<string, string>()\n\n/** Test seam: forget what this process learned. */\nexport function resetHostedPageReviewMemoForTests(): void {\n pageMemo.clear()\n servedMemo.clear()\n}\n\n/** The page's public URL, for the review row. Best effort; the row stands without it. */\nfunction pageUrl(host: Record<string, unknown> | null, screenId: string): string | null {\n const subdomain = typeof host?.['subdomain'] === 'string' ? (host['subdomain'] as string) : ''\n const cname = typeof host?.['cname'] === 'string' ? (host['cname'] as string) : ''\n const origin = cname ? `https://${cname}` : subdomain ? `https://${subdomain}.${TENANT_APEX}` : ''\n if (!origin) return null\n const screens = (host?.['screens'] ?? {}) as Record<string, unknown>\n const path = typeof screens[screenId] === 'string' ? (screens[screenId] as string) : ''\n const clean = path.replace(/^\\/+|\\/+$/g, '')\n return `${origin}/${clean === 'index' ? '' : clean}`\n}\n\n/**\n * Screen one page as a visitor would receive it, and say whether it may be\n * served. See the module header.\n */\nexport async function reviewHostedPage(\n request: HostedPageReviewRequest,\n): Promise<HostedPageReviewOutcome> {\n const found = screenHostedPage({ nodes: request.nodes })\n if (!found.signals.length) return { outcome: 'serve' }\n\n const nowMs = request.nowMs ?? Date.now()\n let reviewId = ''\n try {\n const [host, owner] = await Promise.all([\n request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),\n request.org !== undefined\n ? { orgId: request.orgId ?? null, org: request.org }\n : getOrgForHost(request.hostId),\n ])\n const org = (owner?.org as Record<string, unknown> | null | undefined) ?? null\n const orgId = owner?.orgId ?? null\n // Again, now knowing whose page it is: a brand in the workspace's own\n // name or domain is its own, and a link to its own site is not away.\n const identity = workspaceScreenIdentity({ org, host })\n const verdict = screenHostedPage({ nodes: request.nodes, ...identity })\n const ageDays = orgAgeDays((org as { createdAt?: unknown } | null)?.createdAt, nowMs)\n const signals = signalsThatHold(verdict.signals, { ageDays })\n if (!signals.length) return { outcome: 'serve' }\n\n reviewId = pageReviewId(request.hostId, request.screenId, signals)\n const reference = heldOutboundReference(reviewId)\n const memo = pageMemo.get(reviewId)\n if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'serve' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n }\n const url = pageUrl(host, request.screenId)\n const flagged = flaggedHostOf(signals)\n const pageName =\n typeof host?.['name'] === 'string' && host['name'] ? `\"${host['name']}\"` : 'a site'\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'page',\n path: `hosts/${request.hostId}/screens/${request.screenId}`,\n hostId: request.hostId,\n orgId,\n contentHash: outboundContentHash([signals]),\n subject: (url ?? `screen ${request.screenId}`).slice(0, 300),\n fromName: null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n },\n extra: { heldPage: { screenId: request.screenId, versionId: request.versionId, url } },\n headline:\n `Held a published page of ${pageName}${url ? ` (${url})` : ''}, version ` +\n `${request.versionId}, from a workspace ${ageDays ?? '?'} day(s) old. The page ` +\n 'serves its last clean version, or nothing, until this is decided.',\n alertTitle: 'Published page held for review — possible phishing',\n alertBody: `A page ${url ? `at ${url} ` : ''}was held before it went live.`,\n // The page itself is what was reported; the flagged host, when there\n // is one, is named in the details and on `reportedHostname`.\n url,\n reportedHostname: flagged,\n })\n pageMemo.set(reviewId, { state, atMs: nowMs })\n return state === 'released'\n ? { outcome: 'serve' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n } catch (error) {\n // Closed only for what holds whoever the workspace is: a soft signal\n // needs the workspace's age to hold at all, and that is what failed.\n if (!signalsThatHold(found.signals, { ageDays: null }).length) {\n console.error('[page-review] a page could not be reviewed — serving it', error)\n return { outcome: 'serve' }\n }\n console.error('[page-review] a flagged page could not be reviewed — not serving it', error)\n return {\n outcome: 'held',\n reviewId,\n reference: reviewId ? heldOutboundReference(reviewId) : '',\n }\n }\n}\n\nfunction pageReviewRef(hostId: string, screenId: string) {\n return firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection(PAGE_REVIEW_SUBCOLLECTION)\n .doc(screenId)\n}\n\n/**\n * The version this page last served clean, or null. A HINT, not a\n * clearance: the caller composes and screens it again before serving it,\n * because a version document can be edited after it was served.\n */\nexport async function servedPageVersion(hostId: string, screenId: string): Promise<string | null> {\n try {\n const snapshot = await pageReviewRef(hostId, screenId).get()\n const value = snapshot.get('servedVersionId')\n return typeof value === 'string' && value ? value : null\n } catch {\n return null\n }\n}\n\n/**\n * Note that this page served `versionId` clean. One read the first time a\n * process renders a page, a write only when the version moved; nothing\n * after that, however often the page is rendered.\n */\nexport async function recordServedPageVersion(\n hostId: string,\n screenId: string,\n versionId: string,\n): Promise<void> {\n const key = `${hostId}/${screenId}`\n if (!versionId || servedMemo.get(key) === versionId) return\n try {\n const ref = pageReviewRef(hostId, screenId)\n const snapshot = await ref.get()\n if (snapshot.get('servedVersionId') !== versionId) {\n await ref.set({ servedVersionId: versionId, servedAtMs: Date.now() }, { merge: true })\n }\n servedMemo.set(key, versionId)\n } catch (error) {\n console.warn('[page-review] the served version could not be noted', error)\n }\n}\n\n/**\n * A domain a workspace attached that wears a brand (AGL-3356, AGL-3362):\n * filed in the abuse queue as an urgent `phishing` row for staff, not\n * refused. Two kinds, one rule:\n *\n * - `site` — a custom domain a site is served on (the attach route);\n * - `sending` — a custom domain a workspace sends email as, when it is\n * added and again when it verifies (`sending-domains.ts`);\n * - `link` — a link a merchant configured that a buyer or a server would\n * follow: a product's paid download or video hotlink, a supplier's order\n * webhook (AGL-3363). The link itself is refused where it is followed; this\n * files it, and tells the site's managers once ({@link notifyLookalikeLink}).\n *\n * Not refused, because the domain is the merchant's own registration and\n * the platform cannot tell a brand's own agency attaching `paypal-promo.com`\n * from a kit — but a brand's lookalike on a site or in a `From:` line is\n * exactly what staff look at first. The site's pages are still screened as\n * they are served, and mail FROM a lookalike domain is held at the send seam\n * for every workspace. The test is the phishing screen's own lookalike rule\n * (`lookalikeBrandForHost`), the one that holds a link in an email. One row\n * per (owner, kind, domain), so a re-attach or a re-verify counts rather than\n * re-alerts.\n *\n * Never throws: the write it rides on has already happened.\n */\nexport async function flagLookalikeDomain(input: {\n kind: 'site' | 'sending' | 'link'\n hostId: string | null\n orgId: string | null\n domain: string\n /** For a `link`: where it was configured, as staff read it. */\n where?: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n const domain = String(input.domain ?? '').trim().toLowerCase()\n const brand = lookalikeBrandForHost(domain)\n if (!brand) return 'clean'\n const label = phishingScreenBrandLabel(brand.id)\n const sending = input.kind === 'sending'\n const link = input.kind === 'link'\n try {\n const firestore = firebaseAdmin.app().firestore()\n const owner = sending\n ? `orgs/${input.orgId ?? ''}/sendingDomains`\n : link\n ? `hosts/${input.hostId ?? ''}/links`\n : `hosts/${input.hostId ?? ''}/cname`\n const reviewId = heldOutboundReviewId(owner, domain)\n const reference = heldOutboundReference(reviewId)\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url: `https://${domain}/`,\n reportedHostname: domain,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n sending\n ? `A workspace added ${domain} as a domain to send email from.`\n : link\n ? `A site configured a link to ${domain} (${input.where ?? 'a commerce link'}).`\n : `A site attached the custom domain ${domain}.`,\n `${domain} wears the ${label} name but is not ${label}'s domain.`,\n sending\n ? 'Email from it is held at the send seam for every workspace. Mark this actioned ' +\n 'and lock the workspace if it is impersonation; dismiss it if the brand is theirs.'\n : link\n ? 'Nobody is sent to it: the link is refused wherever it is followed. Mark this ' +\n 'actioned and lock the site if it is impersonation.'\n : 'Its pages are still screened as they are served. Mark this actioned ' +\n 'and lock the site if it is impersonation; dismiss it if the brand is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (first && link) {\n await notifyLookalikeLink({\n hostId: input.hostId,\n domain,\n label,\n where: input.where ?? 'a commerce link',\n reference,\n })\n } else if (first) {\n await notifyStaff({\n type: 'system.abuseReportUrgent',\n title: sending\n ? 'Sending domain flagged — possible brand impersonation'\n : 'Custom domain flagged — possible brand impersonation',\n body:\n `A ${sending ? 'workspace added the sending domain' : 'site attached'} ${domain}, ` +\n `which looks like ${label}. Reference ${reference}.`,\n link: '/admin/abuse-reports',\n })\n }\n return 'flagged'\n } catch (error) {\n console.error('[page-review] a lookalike domain could not be flagged', error)\n return 'failed'\n }\n}\n\n/**\n * Tell staff and the site's managers, once per (site, domain), that a link\n * the merchant configured was refused because it wears another brand's name\n * (AGL-3363). The one notifier for this kind of signal, so a shared\n * risk-notice seam can take it over whole. The merchant is told what was\n * refused and how to fix it; never how the check works. Never throws.\n */\nexport async function notifyLookalikeLink(input: {\n hostId: string | null\n domain: string\n label: string\n where: string\n reference: string\n}): Promise<void> {\n await Promise.all([\n notifyStaff({\n type: 'system.abuseReportUrgent',\n title: 'Commerce link flagged — possible brand impersonation',\n body:\n `A site configured ${input.where} to ${input.domain}, which looks like ${input.label}. ` +\n `It is refused wherever it is followed. Reference ${input.reference}.`,\n link: '/admin/abuse-reports',\n }).catch(() => undefined),\n input.hostId\n ? notifyHostManagers(input.hostId, {\n type: 'content.order',\n title: 'A link on your store was blocked',\n body:\n `${input.where[0].toUpperCase()}${input.where.slice(1)} points to ${input.domain}, ` +\n `which looks like another company's website, so nobody is sent there. ` +\n 'Replace it with a file from your media library or a link on your own domain. ' +\n `If you think this is a mistake, contact support with reference ${input.reference}.`,\n link: `/${input.hostId}/products`,\n }).catch(() => undefined)\n : Promise.resolve(),\n ])\n}\n\n/** A site's custom domain, through {@link flagLookalikeDomain}. */\nexport function flagLookalikeCustomDomain(input: {\n hostId: string\n orgId: string | null\n domain: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n return flagLookalikeDomain({ kind: 'site', ...input })\n}\n"],"names":["ABUSE_REPORT_COLLECTION","TENANT_APEX","screenHostedPage","lookalikeBrandForHost","phishingScreenBrandLabel","signalsThatHold","FieldValue","firebaseAdmin","notifyHostManagers","notifyStaff","orgAgeDays","getHostDocAdmin","getOrgForHost","canonicalJson","fileOutboundHold","flaggedHostOf","heldOutboundReference","heldOutboundReviewId","outboundContentHash","workspaceScreenIdentity","PAGE_REVIEW_SUBCOLLECTION","pageReviewId","hostId","screenId","signals","key","map","signal","sort","join","PAGE_MEMO_TTL_MS","pageMemo","Map","servedMemo","resetHostedPageReviewMemoForTests","clear","pageUrl","host","subdomain","cname","origin","screens","path","clean","replace","reviewHostedPage","request","found","nodes","length","outcome","nowMs","Date","now","reviewId","owner","Promise","all","undefined","org","orgId","identity","verdict","ageDays","createdAt","reference","memo","get","atMs","state","url","flagged","pageName","heldSend","kind","contentHash","subject","slice","fromName","heldAtMs","extra","heldPage","versionId","headline","alertTitle","alertBody","reportedHostname","set","error","console","pageReviewRef","app","firestore","collection","doc","servedPageVersion","snapshot","value","recordServedPageVersion","ref","servedVersionId","servedAtMs","merge","warn","flagLookalikeDomain","input","domain","String","trim","toLowerCase","brand","label","id","sending","link","first","exists","category","severity","source","details","where","reporterEmail","reporterName","dmca","reportCount","increment","updatedAt","serverTimestamp","status","notifyLookalikeLink","type","title","body","catch","toUpperCase","resolve","flagLookalikeCustomDomain"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/hosted-page-review.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 * HELD FOR REVIEW: a published page (AGL-3362).\n *\n * ## Why the check sits where a page is COMPOSED, not where it is published\n *\n * A publish is a browser write. The besigner toolbar, the versions panel,\n * the screen details page, page-from-template, the layout and component\n * besigners and the form besigner each move a pointer (`versionId`, the\n * host's `screens` map, a component's own `nodes`) straight from the\n * client SDK; the scheduled-publish beat moves it from the server; an\n * experiment serves a version no pointer names; and an author may rewrite\n * the LIVE version document itself without moving any pointer at all. A\n * check on any one of those writes is a check the next one walks around.\n *\n * They all meet in one place: `composeScreenNodes` (`@aglyn/tenant-runtime`),\n * which every published render, the password unlock, the collection and\n * product templates and every experiment variant go through, and which\n * returns the page exactly as a visitor will receive it — the screen, its\n * layout chrome, its grafted components and its forms. So the screen is read\n * there, and a held page is one that is never composed for a visitor. For a\n * visitor, \"publish\" is the first render after the pointer moved; this is\n * that moment.\n *\n * ## What a hold does\n *\n * - The version is not served. The last version this page served clean\n * ({@link servedPageVersion}) is composed instead — screened again, since\n * a version document can be edited in place — and if there is none, or it\n * holds too, the page is not found. A first publish that holds therefore\n * serves nothing.\n * - One review row per (page, signal set) lands in the abuse queue at\n * `/admin/abuse-reports`, the same `phishing` row a held email files\n * (`fileOutboundHold`), with `heldSend.kind: 'page'`, the page's URL and\n * the held version. Staff dismiss it to RELEASE (the admin route drops the\n * site's cache and the next render serves it) or mark it actioned to\n * REJECT (it stays unserved; any later version carrying the same signals\n * is refused without a new row).\n * - Keyed on the SIGNALS, as the send seam's rows are: a composed page\n * carries per-render values (dates, collection rows) that no content hash\n * would survive, while what makes it phishing does not change between\n * renders.\n *\n * ## Tiers\n *\n * `signalsThatHold`: a lookalike link or embed and an author-defined\n * credential field hold for EVERY workspace; a brand's call to action only\n * for a workspace in its first fortnight. The workspace's documents are read\n * only when the pure screen has found something, so a clean page costs no\n * read for this.\n *\n * Fails CLOSED, unlike the send path. Composition itself reads the store, so\n * an outage here is an outage on the render already, and the only cost of\n * not serving a page the screen flagged is a false positive's wait; serving\n * it is the phishing page this exists to stop.\n *=========================================*/\n\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n heldPageConsolePath,\n heldPageDetails,\n heldPageLabel,\n heldPageVisitorSentence,\n type HeldPageSubject,\n} from '@aglyn/shared-util-email/held-page'\nimport { screenHostedPage } from '@aglyn/shared-util-email/hosted-page-screen'\nimport {\n lookalikeBrandForHost,\n phishingScreenBrandLabel,\n type PhishingScreenSignal,\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { describeHeldPage, type HostedPageContext } from './held-page-subject'\nimport { notifyRiskEvent } from './risk-notice'\nimport { orgAgeDays } from './org-age'\nimport { getHostDocAdmin, getOrgForHost } from './organizations'\nimport {\n canonicalJson,\n fileOutboundHold,\n flaggedHostOf,\n heldOutboundReference,\n heldOutboundReviewId,\n outboundContentHash,\n type HeldOutboundSendState,\n workspaceScreenIdentity,\n} from './outbound-send-review'\n\n/** Where the last version a page served clean is noted: a hint, re-screened before use. */\nexport const PAGE_REVIEW_SUBCOLLECTION = 'pageReviews'\n\nexport interface HostedPageReviewRequest {\n hostId: string\n screenId: string\n versionId: string\n /** The page as composed for a visitor. */\n nodes: unknown\n /** The host and org documents, when the caller already holds them. */\n host?: Record<string, unknown> | null\n org?: Record<string, unknown> | null\n orgId?: string | null\n nowMs?: number\n /**\n * What the composer knows about the page (AGL-3374): the screen document,\n * the template route it is rendering, the variant, and a way to read the\n * layouts and components composed into it. Read only when a row is filed,\n * to name the page — never to decide whether it serves.\n */\n page?: HostedPageContext | null\n}\n\nexport type HostedPageReviewOutcome =\n | { outcome: 'serve' }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string }\n\n/** The review row's id for a page and the signals that held. */\nexport function pageReviewId(\n hostId: string,\n screenId: string,\n signals: readonly PhishingScreenSignal[],\n): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/screens/${screenId}`, outboundContentHash([key]))\n}\n\n/** How long this process trusts a page row's state — the send seam's minute. */\nconst PAGE_MEMO_TTL_MS = 60_000\nconst pageMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n/** The version each page last served clean, as this process last wrote it. */\nconst servedMemo = new Map<string, string>()\n/** Lookalike hosts this process already flagged for an established page, and when. */\nconst flaggedMemo = new Map<string, number>()\nconst FLAGGED_MEMO_TTL_MS = 60 * 60_000\n\n/** Test seam: forget what this process learned. */\nexport function resetHostedPageReviewMemoForTests(): void {\n pageMemo.clear()\n servedMemo.clear()\n flaggedMemo.clear()\n}\n\n/**\n * The subject a row already stored, or a fresh description (AGL-3374). A row\n * that names its page is not described again, so the reads the description\n * may make are paid once per row, not once per render.\n */\nasync function subjectForRow(\n reviewId: string,\n describe: () => Promise<HeldPageSubject>,\n): Promise<HeldPageSubject> {\n const stored = await firebaseAdmin\n .app()\n .firestore()\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .get()\n .then((row) => storedSubject(row.get('heldPage')))\n .catch(() => null)\n return stored ?? describe()\n}\n\n/** A row's stored subject, when it has one. */\nfunction storedSubject(heldPage: unknown): HeldPageSubject | null {\n const subject = (heldPage as { subject?: HeldPageSubject } | null | undefined)?.subject\n return subject && typeof subject === 'object' && subject.screenId ? subject : null\n}\n\n/** The row's first line, naming the page and where it was seen. */\nfunction pageHeadline(subject: HeldPageSubject, siteName: string): string {\n return [`${heldPageLabel(subject)} of ${siteName}.`, ...heldPageDetails(subject)].join(' ')\n}\n\n/**\n * Screen one page as a visitor would receive it, and say whether it may be\n * served. See the module header.\n */\nexport async function reviewHostedPage(\n request: HostedPageReviewRequest,\n): Promise<HostedPageReviewOutcome> {\n const found = screenHostedPage({ nodes: request.nodes })\n if (!found.signals.length) return { outcome: 'serve' }\n\n const nowMs = request.nowMs ?? Date.now()\n try {\n const [host, owner] = await Promise.all([\n request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),\n request.org !== undefined\n ? { orgId: request.orgId ?? null, org: request.org }\n : getOrgForHost(request.hostId),\n ])\n const org = (owner?.org as Record<string, unknown> | null | undefined) ?? null\n const orgId = owner?.orgId ?? null\n // Again, now knowing whose page it is: a brand in the workspace's own\n // name or domain is its own, and a link to its own site is not away.\n const identity = workspaceScreenIdentity({ org, host })\n const verdict = screenHostedPage({ nodes: request.nodes, ...identity })\n const ageDays = orgAgeDays((org as { createdAt?: unknown } | null)?.createdAt, nowMs)\n // Every workspace is screened, however old: an established account can\n // be compromised, and a compromised account publishing a lure is the\n // same harm as a new one. What differs is what a hold may take DOWN.\n // Holding content that is already live serves its last clean version or\n // nothing, and on a real business's home page that is an outage the\n // screen caused (aglyn.com, 2026-09-28, a page that had served clean\n // until a rule changed). So for an established workspace:\n // - NEW content (a new version of a live page, or a page that has\n // never been live) holds, exactly as for a young workspace; the live\n // page keeps serving its last clean version, or the new page waits;\n // - content ALREADY LIVE keeps serving, and is flagged to staff as\n // urgent, who lock the site if it is impersonation.\n if (!isYoungWorkspaceAge(ageDays)) {\n const strong = signalsThatHold(verdict.signals, { ageDays })\n if (!strong.length) return { outcome: 'serve' }\n if (await isAlreadyLive(request.hostId, request.screenId, request.versionId, nowMs)) {\n await flagLivePage({\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n orgId,\n host,\n signals: strong,\n nowMs,\n page: request.page ?? null,\n identity,\n })\n return { outcome: 'serve' }\n }\n }\n const signals = signalsThatHold(verdict.signals, { ageDays })\n if (!signals.length) return { outcome: 'serve' }\n\n const reviewId = pageReviewId(request.hostId, request.screenId, signals)\n const reference = heldOutboundReference(reviewId)\n const memo = pageMemo.get(reviewId)\n if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'serve' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n }\n // The page by its real name and route (AGL-3374): a template, a list,\n // a variant, or a page whose flagged content lives in its layout.\n const subject = await subjectForRow(reviewId, async () =>\n describeHeldPage({\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n host,\n page: request.page,\n signals,\n identity,\n live: false,\n previousVersionId: await servedPageVersion(request.hostId, request.screenId),\n }),\n )\n const url = subject.entryUrl ?? subject.url\n const flagged = flaggedHostOf(signals)\n const siteName =\n typeof host?.['name'] === 'string' && host['name'] ? `\"${host['name']}\"` : 'a site'\n const label = heldPageLabel(subject)\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'page',\n path: `hosts/${request.hostId}/screens/${request.screenId}`,\n hostId: request.hostId,\n orgId,\n contentHash: outboundContentHash([signals]),\n subject: label.slice(0, 300),\n fromName: null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n },\n extra: {\n heldPage: { screenId: request.screenId, versionId: request.versionId, url, subject },\n },\n headline:\n `Held ${pageHeadline(subject, siteName)} Version ${request.versionId}, from a ` +\n `workspace ${ageDays ?? '?'} day(s) old. ${heldPageVisitorSentence(subject)}`,\n alertTitle: 'Published page held for review — possible phishing',\n alertBody: `${label.charAt(0).toUpperCase()}${label.slice(1)} was held before it went live.`,\n // The owners' words for it, and where they fix it (AGL-3368, AGL-3374).\n item: { label, path: heldPageConsolePath(subject, request.hostId) },\n page: subject,\n // The page itself is what was reported; the flagged host, when there\n // is one, is named in the details and on `reportedHostname`.\n url,\n reportedHostname: flagged,\n })\n pageMemo.set(reviewId, { state, atMs: nowMs })\n return state === 'released'\n ? { outcome: 'serve' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n } catch (error) {\n // Open: a review that could not run must not take a live page down —\n // the screen failing is not evidence about the page.\n console.error('[page-review] a flagged page could not be reviewed — serving it', error)\n return { outcome: 'serve' }\n }\n}\n\nfunction pageReviewRef(hostId: string, screenId: string) {\n return firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection(PAGE_REVIEW_SUBCOLLECTION)\n .doc(screenId)\n}\n\n/**\n * Is this version of the page already what visitors receive? It is when it\n * is the version the page last served clean, or when the page has no record\n * at all and is not new: it went live before the screen existed. A page\n * created in the last {@link NEW_PAGE_DAYS} days with no record has never\n * been live, so holding it takes nothing down.\n */\nconst NEW_PAGE_DAYS = 7\nasync function isAlreadyLive(\n hostId: string,\n screenId: string,\n versionId: string,\n nowMs: number,\n): Promise<boolean> {\n const lastClean = await servedPageVersion(hostId, screenId)\n if (lastClean) return lastClean === versionId\n try {\n const screen = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('screens')\n .doc(screenId)\n .get()\n const created = screen.get('createdAt') as { toMillis?: () => number } | number | undefined\n const createdMs =\n typeof created === 'number' ? created : typeof created?.toMillis === 'function' ? created.toMillis() : null\n // No readable creation time: an old page, so live.\n return createdMs === null || nowMs - createdMs >= NEW_PAGE_DAYS * 24 * 60 * 60 * 1000\n } catch {\n return true\n }\n}\n\n/**\n * A page ALREADY LIVE carries a strong signal: file one urgent abuse row for\n * it (keyed on the page and its signals, so a busy page is one row) and, the\n * first time, tell staff and the owners through the `page-flagged` notice.\n * The page keeps serving; staff lock the site if it is impersonation or the\n * account looks compromised. Once per page and signal set per process.\n */\nasync function flagLivePage(input: {\n hostId: string\n screenId: string\n versionId: string\n orgId: string | null\n host: Record<string, unknown> | null\n signals: readonly PhishingScreenSignal[]\n nowMs: number\n page?: HostedPageContext | null\n identity?: ReturnType<typeof workspaceScreenIdentity>\n}): Promise<void> {\n const key = `${input.hostId}\\n${input.screenId}\\n${input.signals.map((signal) => canonicalJson(signal)).sort().join('\\n')}`\n const at = flaggedMemo.get(key)\n if (at !== undefined && input.nowMs - at < FLAGGED_MEMO_TTL_MS) return\n flaggedMemo.set(key, input.nowMs)\n try {\n const reviewId = pageReviewId(input.hostId, `${input.screenId}/live`, input.signals)\n const reference = heldOutboundReference(reviewId)\n const evidence = describePhishingScreenSignals(input.signals).join(' ')\n const flagged = flaggedHostOf(input.signals)\n const ref = firebaseAdmin.app().firestore().collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const existing = await ref.get()\n const first = !existing.exists\n // Named once, when the row is new (AGL-3374).\n const subject =\n (existing.exists ? storedSubject(existing.get('heldPage')) : null) ??\n (await describeHeldPage({\n hostId: input.hostId,\n screenId: input.screenId,\n versionId: input.versionId,\n host: input.host,\n page: input.page,\n signals: input.signals,\n identity: input.identity ?? workspaceScreenIdentity({ host: input.host }),\n live: true,\n previousVersionId: null,\n }))\n const url = subject.entryUrl ?? subject.url\n const siteName =\n typeof input.host?.['name'] === 'string' && input.host['name']\n ? `\"${input.host['name']}\"`\n : 'a site'\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url,\n reportedHostname: flagged,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `LIVE: ${pageHeadline(subject, siteName)} It was flagged by the phishing screen and is still serving.`,\n evidence,\n 'Lock the site if it is impersonation or the account looks compromised; dismiss this if the content is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n heldPage: { screenId: input.screenId, versionId: input.versionId, url, subject },\n riskNotice: { kind: 'page-flagged' },\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (!first) return\n await notifyRiskEvent({\n kind: 'page-flagged',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId,\n reference,\n item: { label: heldPageLabel(subject), path: heldPageConsolePath(subject, input.hostId) },\n page: subject,\n staffEvidence: [...heldPageDetails(subject), evidence].join(' '),\n })\n } catch (error) {\n console.error('[page-review] a live page could not be flagged', error)\n }\n}\n\n/**\n * The version this page last served clean, or null. A HINT, not a\n * clearance: the caller composes and screens it again before serving it,\n * because a version document can be edited after it was served.\n */\nexport async function servedPageVersion(hostId: string, screenId: string): Promise<string | null> {\n try {\n const snapshot = await pageReviewRef(hostId, screenId).get()\n const value = snapshot.get('servedVersionId')\n return typeof value === 'string' && value ? value : null\n } catch {\n return null\n }\n}\n\n/**\n * Note that this page served `versionId` clean. One read the first time a\n * process renders a page, a write only when the version moved; nothing\n * after that, however often the page is rendered.\n */\nexport async function recordServedPageVersion(\n hostId: string,\n screenId: string,\n versionId: string,\n): Promise<void> {\n const key = `${hostId}/${screenId}`\n if (!versionId || servedMemo.get(key) === versionId) return\n try {\n const ref = pageReviewRef(hostId, screenId)\n const snapshot = await ref.get()\n if (snapshot.get('servedVersionId') !== versionId) {\n await ref.set({ servedVersionId: versionId, servedAtMs: Date.now() }, { merge: true })\n }\n servedMemo.set(key, versionId)\n } catch (error) {\n console.warn('[page-review] the served version could not be noted', error)\n }\n}\n\n/**\n * A domain a workspace attached that wears a brand (AGL-3356, AGL-3362):\n * filed in the abuse queue as an urgent `phishing` row for staff, not\n * refused. Two kinds, one rule:\n *\n * - `site` — a custom domain a site is served on (the attach route);\n * - `sending` — a custom domain a workspace sends email as, when it is\n * added and again when it verifies (`sending-domains.ts`);\n * - `link` — a link a merchant configured that a buyer or a server would\n * follow: a product's paid download or video hotlink, a supplier's order\n * webhook (AGL-3363). The link itself is refused where it is followed; this\n * files it, and tells the site's managers once ({@link notifyLookalikeLink}).\n *\n * Not refused, because the domain is the merchant's own registration and\n * the platform cannot tell a brand's own agency attaching `paypal-promo.com`\n * from a kit — but a brand's lookalike on a site or in a `From:` line is\n * exactly what staff look at first. The site's pages are still screened as\n * they are served, and mail FROM a lookalike domain is held at the send seam\n * for every workspace. The test is the phishing screen's own lookalike rule\n * (`lookalikeBrandForHost`), the one that holds a link in an email. One row\n * per (owner, kind, domain), so a re-attach or a re-verify counts rather than\n * re-alerts.\n *\n * Never throws: the write it rides on has already happened.\n */\nexport async function flagLookalikeDomain(input: {\n kind: 'site' | 'sending' | 'link'\n hostId: string | null\n orgId: string | null\n domain: string\n /** For a `link`: where it was configured, as staff read it. */\n where?: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n const domain = String(input.domain ?? '').trim().toLowerCase()\n const brand = lookalikeBrandForHost(domain)\n if (!brand) return 'clean'\n const label = phishingScreenBrandLabel(brand.id)\n const sending = input.kind === 'sending'\n const link = input.kind === 'link'\n try {\n const firestore = firebaseAdmin.app().firestore()\n const owner = sending\n ? `orgs/${input.orgId ?? ''}/sendingDomains`\n : link\n ? `hosts/${input.hostId ?? ''}/links`\n : `hosts/${input.hostId ?? ''}/cname`\n const reviewId = heldOutboundReviewId(owner, domain)\n const reference = heldOutboundReference(reviewId)\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url: `https://${domain}/`,\n reportedHostname: domain,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n sending\n ? `A workspace added ${domain} as a domain to send email from.`\n : link\n ? `A site configured a link to ${domain} (${input.where ?? 'a commerce link'}).`\n : `A site attached the custom domain ${domain}.`,\n `${domain} wears the ${label} name but is not ${label}'s domain.`,\n sending\n ? 'Email from it is held at the send seam for every workspace. Mark this actioned ' +\n 'and lock the workspace if it is impersonation; dismiss it if the brand is theirs.'\n : link\n ? 'Nobody is sent to it: the link is refused wherever it is followed. Mark this ' +\n 'actioned and lock the site if it is impersonation.'\n : 'Its pages are still screened as they are served. Mark this actioned ' +\n 'and lock the site if it is impersonation; dismiss it if the brand is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (first && link) {\n await notifyLookalikeLink({\n hostId: input.hostId,\n orgId: input.orgId,\n domain,\n label,\n where: input.where ?? 'a commerce link',\n reference,\n reviewId,\n })\n } else if (first) {\n // The owners hear that the domain is waiting on a routine review — not\n // which name it resembles; staff get the brand and a link to the row.\n await notifyRiskEvent({\n kind: 'domain-flagged',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId,\n reference,\n item: {\n label: sending ? `the sending domain ${domain}` : `the custom domain ${domain}`,\n // Emails → Sending, or the site's Domain settings.\n path: sending ? '/org/emails/sending' : input.hostId ? `/${input.hostId}/admin/domain` : null,\n },\n staffEvidence:\n `${sending ? 'Sending domain' : 'Custom domain'} ${domain} looks like ${label}.`,\n })\n }\n return 'flagged'\n } catch (error) {\n console.error('[page-review] a lookalike domain could not be flagged', error)\n return 'failed'\n }\n}\n\n/**\n * Tell staff and the site's managers and the workspace's owners, once per\n * (site, domain), that a link the merchant configured was refused because it\n * wears another brand's name (AGL-3363), through the risk notice seam\n * (AGL-3368). The merchant reads the catalog's `link-blocked` words — what\n * was refused and how to fix it, never how the check works; staff get the\n * brand and a link to the row. Never throws.\n */\nexport async function notifyLookalikeLink(input: {\n hostId: string | null\n orgId?: string | null\n domain: string\n label: string\n where: string\n reference: string\n reviewId?: string | null\n}): Promise<void> {\n await notifyRiskEvent({\n kind: 'link-blocked',\n orgId: input.orgId ?? null,\n hostId: input.hostId,\n reviewId: input.reviewId ?? null,\n reference: input.reference,\n item: {\n label: `${input.where} pointing to ${input.domain}`,\n path: input.hostId ? `/${input.hostId}/products` : null,\n },\n staffEvidence:\n `A site configured ${input.where} to ${input.domain}, which looks like ${input.label}. ` +\n 'It is refused wherever it is followed.',\n }).catch(() => undefined)\n}\n\n/** A site's custom domain, through {@link flagLookalikeDomain}. */\nexport function flagLookalikeCustomDomain(input: {\n hostId: string\n orgId: string | null\n domain: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n return flagLookalikeDomain({ kind: 'site', ...input })\n}\n"],"names":["ABUSE_REPORT_COLLECTION","heldPageConsolePath","heldPageDetails","heldPageLabel","heldPageVisitorSentence","screenHostedPage","lookalikeBrandForHost","phishingScreenBrandLabel","describePhishingScreenSignals","isYoungWorkspaceAge","signalsThatHold","FieldValue","firebaseAdmin","describeHeldPage","notifyRiskEvent","orgAgeDays","getHostDocAdmin","getOrgForHost","canonicalJson","fileOutboundHold","flaggedHostOf","heldOutboundReference","heldOutboundReviewId","outboundContentHash","workspaceScreenIdentity","PAGE_REVIEW_SUBCOLLECTION","pageReviewId","hostId","screenId","signals","key","map","signal","sort","join","PAGE_MEMO_TTL_MS","pageMemo","Map","servedMemo","flaggedMemo","FLAGGED_MEMO_TTL_MS","resetHostedPageReviewMemoForTests","clear","subjectForRow","reviewId","describe","stored","app","firestore","collection","doc","get","then","row","storedSubject","catch","heldPage","subject","pageHeadline","siteName","reviewHostedPage","request","found","nodes","length","outcome","nowMs","Date","now","host","owner","Promise","all","undefined","org","orgId","identity","verdict","ageDays","createdAt","strong","isAlreadyLive","versionId","flagLivePage","page","reference","memo","atMs","state","live","previousVersionId","servedPageVersion","url","entryUrl","flagged","label","heldSend","kind","path","contentHash","slice","fromName","heldAtMs","extra","headline","alertTitle","alertBody","charAt","toUpperCase","item","reportedHostname","set","error","console","pageReviewRef","NEW_PAGE_DAYS","lastClean","screen","created","createdMs","toMillis","input","at","existing","evidence","ref","first","exists","category","severity","source","details","reporterEmail","reporterName","dmca","riskNotice","reportCount","increment","updatedAt","serverTimestamp","status","merge","staffEvidence","snapshot","value","recordServedPageVersion","servedVersionId","servedAtMs","warn","flagLookalikeDomain","domain","String","trim","toLowerCase","brand","id","sending","link","where","notifyLookalikeLink","flagLookalikeCustomDomain"],"mappings":"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"}
|
|
@@ -40,6 +40,7 @@ import { consentGroupForGrant } from "@aglyn/aglyn/app-utils/consent-groups";
|
|
|
40
40
|
import { nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
|
|
41
41
|
import { createHash, createHmac } from "node:crypto";
|
|
42
42
|
import { emailSearchTokens } from "./email-suppression.js";
|
|
43
|
+
import { scheduleCapturedEmailCheck } from "./capture-email-check.js";
|
|
43
44
|
/**
|
|
44
45
|
* What a list's membership table searches a member by (AGL-3321): the word
|
|
45
46
|
* prefixes of their name and of their address (`emailSearchTokens`), one
|
|
@@ -189,6 +190,21 @@ import { emailSearchTokens } from "./email-suppression.js";
|
|
|
189
190
|
}), {
|
|
190
191
|
merge: true
|
|
191
192
|
});
|
|
193
|
+
/*
|
|
194
|
+
* A person NEW to the list has their address's domain checked after the
|
|
195
|
+
* response (AGL-3328), so the CRM record they are reads "Would bounce"
|
|
196
|
+
* when it takes no mail. The list's owner names whose records those are:
|
|
197
|
+
* an organization's list its own, a site's list the site's organization.
|
|
198
|
+
*/ if (!existing) {
|
|
199
|
+
var _input_listRef_parent, _owner_parent, _input_group;
|
|
200
|
+
const owner = (_input_listRef_parent = input.listRef.parent) == null ? void 0 : _input_listRef_parent.parent;
|
|
201
|
+
const ownerKind = owner == null ? void 0 : (_owner_parent = owner.parent) == null ? void 0 : _owner_parent.id;
|
|
202
|
+
scheduleCapturedEmailCheck({
|
|
203
|
+
orgId: ownerKind === 'orgs' ? owner == null ? void 0 : owner.id : null,
|
|
204
|
+
hostId: ownerKind === 'hosts' ? owner == null ? void 0 : owner.id : (_input_group = input.group) == null ? void 0 : _input_group.hostId,
|
|
205
|
+
email
|
|
206
|
+
});
|
|
207
|
+
}
|
|
192
208
|
return {
|
|
193
209
|
enrolled: true,
|
|
194
210
|
memberId: target.id,
|