@aglyn/tenant-data-admin 1.0.0-beta.200 → 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.
- package/package.json +8 -7
- package/src/lib/server/card-payment-velocity.d.ts +85 -0
- package/src/lib/server/card-payment-velocity.js +202 -0
- package/src/lib/server/card-payment-velocity.js.map +1 -0
- package/src/lib/server/configured-url-fetch.d.ts +42 -0
- package/src/lib/server/configured-url-fetch.js +118 -0
- package/src/lib/server/configured-url-fetch.js.map +1 -0
- package/src/lib/server/dns-probe.d.ts +7 -0
- package/src/lib/server/dns-probe.js +52 -0
- package/src/lib/server/dns-probe.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +150 -0
- package/src/lib/server/email-deliverability.js +538 -0
- package/src/lib/server/email-deliverability.js.map +1 -0
- package/src/lib/server/email-sender-reputation.d.ts +2 -11
- package/src/lib/server/email-sender-reputation.js +8 -25
- package/src/lib/server/email-sender-reputation.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +10 -1
- package/src/lib/server/email-suppression.js +4 -3
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/erase-person.js +31 -3
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +16 -2
- package/src/lib/server/host-visitor-records.js +31 -6
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +112 -0
- package/src/lib/server/hosted-page-review.js +378 -0
- package/src/lib/server/hosted-page-review.js.map +1 -0
- package/src/lib/server/member-email-aliases.js +18 -7
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/notifications.d.ts +8 -0
- package/src/lib/server/notifications.js +92 -17
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/org-age.d.ts +27 -0
- package/src/lib/server/org-age.js +44 -0
- package/src/lib/server/org-age.js.map +1 -0
- package/src/lib/server/org-member-notice.js +20 -5
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/organizations.d.ts +10 -1
- package/src/lib/server/organizations.js +12 -4
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +224 -0
- package/src/lib/server/outbound-send-review.js +479 -0
- package/src/lib/server/outbound-send-review.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +156 -0
- package/src/lib/server/payment-fraud-signal.js +368 -0
- package/src/lib/server/payment-fraud-signal.js.map +1 -0
- package/src/lib/server/payment-risk-record.d.ts +59 -0
- package/src/lib/server/payment-risk-record.js +76 -0
- package/src/lib/server/payment-risk-record.js.map +1 -0
- package/src/lib/server/render-system-email.d.ts +176 -0
- package/src/lib/server/render-system-email.js +315 -0
- package/src/lib/server/render-system-email.js.map +1 -0
- package/src/lib/server/sending-domains.d.ts +6 -0
- package/src/lib/server/sending-domains.js +98 -6
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/staff-alert-email.d.ts +2 -1
- package/src/lib/server/staff-alert-email.js +16 -5
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/stripe-card-authentication.d.ts +35 -0
- package/src/lib/server/stripe-card-authentication.js +63 -0
- package/src/lib/server/stripe-card-authentication.js.map +1 -0
- package/src/lib/server/tenant-write-lockdown.d.ts +32 -2
- package/src/lib/server/tenant-write-lockdown.js +33 -3
- package/src/lib/server/tenant-write-lockdown.js.map +1 -1
- package/src/tenancy.d.ts +5 -0
- package/src/tenancy.js +10 -0
- package/src/tenancy.js.map +1 -1
|
@@ -0,0 +1,378 @@
|
|
|
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
|
+
* HELD FOR REVIEW: a published page (AGL-3362).
|
|
19
|
+
*
|
|
20
|
+
* ## Why the check sits where a page is COMPOSED, not where it is published
|
|
21
|
+
*
|
|
22
|
+
* A publish is a browser write. The besigner toolbar, the versions panel,
|
|
23
|
+
* the screen details page, page-from-template, the layout and component
|
|
24
|
+
* besigners and the form besigner each move a pointer (`versionId`, the
|
|
25
|
+
* host's `screens` map, a component's own `nodes`) straight from the
|
|
26
|
+
* client SDK; the scheduled-publish beat moves it from the server; an
|
|
27
|
+
* experiment serves a version no pointer names; and an author may rewrite
|
|
28
|
+
* the LIVE version document itself without moving any pointer at all. A
|
|
29
|
+
* check on any one of those writes is a check the next one walks around.
|
|
30
|
+
*
|
|
31
|
+
* They all meet in one place: `composeScreenNodes` (`@aglyn/tenant-runtime`),
|
|
32
|
+
* which every published render, the password unlock, the collection and
|
|
33
|
+
* product templates and every experiment variant go through, and which
|
|
34
|
+
* returns the page exactly as a visitor will receive it — the screen, its
|
|
35
|
+
* layout chrome, its grafted components and its forms. So the screen is read
|
|
36
|
+
* there, and a held page is one that is never composed for a visitor. For a
|
|
37
|
+
* visitor, "publish" is the first render after the pointer moved; this is
|
|
38
|
+
* that moment.
|
|
39
|
+
*
|
|
40
|
+
* ## What a hold does
|
|
41
|
+
*
|
|
42
|
+
* - The version is not served. The last version this page served clean
|
|
43
|
+
* ({@link servedPageVersion}) is composed instead — screened again, since
|
|
44
|
+
* a version document can be edited in place — and if there is none, or it
|
|
45
|
+
* holds too, the page is not found. A first publish that holds therefore
|
|
46
|
+
* serves nothing.
|
|
47
|
+
* - One review row per (page, signal set) lands in the abuse queue at
|
|
48
|
+
* `/admin/abuse-reports`, the same `phishing` row a held email files
|
|
49
|
+
* (`fileOutboundHold`), with `heldSend.kind: 'page'`, the page's URL and
|
|
50
|
+
* the held version. Staff dismiss it to RELEASE (the admin route drops the
|
|
51
|
+
* site's cache and the next render serves it) or mark it actioned to
|
|
52
|
+
* REJECT (it stays unserved; any later version carrying the same signals
|
|
53
|
+
* is refused without a new row).
|
|
54
|
+
* - Keyed on the SIGNALS, as the send seam's rows are: a composed page
|
|
55
|
+
* carries per-render values (dates, collection rows) that no content hash
|
|
56
|
+
* would survive, while what makes it phishing does not change between
|
|
57
|
+
* renders.
|
|
58
|
+
*
|
|
59
|
+
* ## Tiers
|
|
60
|
+
*
|
|
61
|
+
* `signalsThatHold`: a lookalike link or embed and an author-defined
|
|
62
|
+
* credential field hold for EVERY workspace; a brand's call to action only
|
|
63
|
+
* for a workspace in its first fortnight. The workspace's documents are read
|
|
64
|
+
* only when the pure screen has found something, so a clean page costs no
|
|
65
|
+
* read for this.
|
|
66
|
+
*
|
|
67
|
+
* Fails CLOSED, unlike the send path. Composition itself reads the store, so
|
|
68
|
+
* an outage here is an outage on the render already, and the only cost of
|
|
69
|
+
* not serving a page the screen flagged is a false positive's wait; serving
|
|
70
|
+
* it is the phishing page this exists to stop.
|
|
71
|
+
*=========================================*/ import { ABUSE_REPORT_COLLECTION } from "@aglyn/aglyn/app-utils/abuse-report";
|
|
72
|
+
import { TENANT_APEX } from "@aglyn/aglyn/app-utils/host-naming";
|
|
73
|
+
import { screenHostedPage } from "@aglyn/shared-util-email/hosted-page-screen";
|
|
74
|
+
import { lookalikeBrandForHost, phishingScreenBrandLabel, signalsThatHold } from "@aglyn/shared-util-email/outbound-phishing-screen";
|
|
75
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
76
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
77
|
+
import { notifyHostManagers, notifyStaff } from "./notifications.js";
|
|
78
|
+
import { orgAgeDays } from "./org-age.js";
|
|
79
|
+
import { getHostDocAdmin, getOrgForHost } from "./organizations.js";
|
|
80
|
+
import { canonicalJson, fileOutboundHold, flaggedHostOf, heldOutboundReference, heldOutboundReviewId, outboundContentHash, workspaceScreenIdentity } from "./outbound-send-review.js";
|
|
81
|
+
/** Where the last version a page served clean is noted: a hint, re-screened before use. */ export const PAGE_REVIEW_SUBCOLLECTION = 'pageReviews';
|
|
82
|
+
/** The review row's id for a page and the signals that held. */ export function pageReviewId(hostId, screenId, signals) {
|
|
83
|
+
const key = signals.map((signal)=>canonicalJson(signal)).sort().join('\n');
|
|
84
|
+
return heldOutboundReviewId(`hosts/${hostId}/screens/${screenId}`, outboundContentHash([
|
|
85
|
+
key
|
|
86
|
+
]));
|
|
87
|
+
}
|
|
88
|
+
/** How long this process trusts a page row's state — the send seam's minute. */ const PAGE_MEMO_TTL_MS = 60000;
|
|
89
|
+
const pageMemo = new Map();
|
|
90
|
+
/** The version each page last served clean, as this process last wrote it. */ const servedMemo = new Map();
|
|
91
|
+
/** Test seam: forget what this process learned. */ export function resetHostedPageReviewMemoForTests() {
|
|
92
|
+
pageMemo.clear();
|
|
93
|
+
servedMemo.clear();
|
|
94
|
+
}
|
|
95
|
+
/** The page's public URL, for the review row. Best effort; the row stands without it. */ function pageUrl(host, screenId) {
|
|
96
|
+
var _ref;
|
|
97
|
+
const subdomain = typeof (host == null ? void 0 : host['subdomain']) === 'string' ? host['subdomain'] : '';
|
|
98
|
+
const cname = typeof (host == null ? void 0 : host['cname']) === 'string' ? host['cname'] : '';
|
|
99
|
+
const origin = cname ? `https://${cname}` : subdomain ? `https://${subdomain}.${TENANT_APEX}` : '';
|
|
100
|
+
if (!origin) return null;
|
|
101
|
+
const screens = (_ref = host == null ? void 0 : host['screens']) != null ? _ref : {};
|
|
102
|
+
const path = typeof screens[screenId] === 'string' ? screens[screenId] : '';
|
|
103
|
+
const clean = path.replace(/^\/+|\/+$/g, '');
|
|
104
|
+
return `${origin}/${clean === 'index' ? '' : clean}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Screen one page as a visitor would receive it, and say whether it may be
|
|
108
|
+
* served. See the module header.
|
|
109
|
+
*/ export async function reviewHostedPage(request) {
|
|
110
|
+
var _request_nowMs;
|
|
111
|
+
const found = screenHostedPage({
|
|
112
|
+
nodes: request.nodes
|
|
113
|
+
});
|
|
114
|
+
if (!found.signals.length) return {
|
|
115
|
+
outcome: 'serve'
|
|
116
|
+
};
|
|
117
|
+
const nowMs = (_request_nowMs = request.nowMs) != null ? _request_nowMs : Date.now();
|
|
118
|
+
let reviewId = '';
|
|
119
|
+
try {
|
|
120
|
+
var _request_orgId, _ref, _ref1;
|
|
121
|
+
const [host, owner] = await Promise.all([
|
|
122
|
+
request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),
|
|
123
|
+
request.org !== undefined ? {
|
|
124
|
+
orgId: (_request_orgId = request.orgId) != null ? _request_orgId : null,
|
|
125
|
+
org: request.org
|
|
126
|
+
} : getOrgForHost(request.hostId)
|
|
127
|
+
]);
|
|
128
|
+
const org = (_ref = owner == null ? void 0 : owner.org) != null ? _ref : null;
|
|
129
|
+
const orgId = (_ref1 = owner == null ? void 0 : owner.orgId) != null ? _ref1 : null;
|
|
130
|
+
// Again, now knowing whose page it is: a brand in the workspace's own
|
|
131
|
+
// name or domain is its own, and a link to its own site is not away.
|
|
132
|
+
const identity = workspaceScreenIdentity({
|
|
133
|
+
org,
|
|
134
|
+
host
|
|
135
|
+
});
|
|
136
|
+
const verdict = screenHostedPage(_extends({
|
|
137
|
+
nodes: request.nodes
|
|
138
|
+
}, identity));
|
|
139
|
+
const ageDays = orgAgeDays(org == null ? void 0 : org.createdAt, nowMs);
|
|
140
|
+
const signals = signalsThatHold(verdict.signals, {
|
|
141
|
+
ageDays
|
|
142
|
+
});
|
|
143
|
+
if (!signals.length) return {
|
|
144
|
+
outcome: 'serve'
|
|
145
|
+
};
|
|
146
|
+
reviewId = pageReviewId(request.hostId, request.screenId, signals);
|
|
147
|
+
const reference = heldOutboundReference(reviewId);
|
|
148
|
+
const memo = pageMemo.get(reviewId);
|
|
149
|
+
if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {
|
|
150
|
+
return memo.state === 'released' ? {
|
|
151
|
+
outcome: 'serve'
|
|
152
|
+
} : {
|
|
153
|
+
outcome: memo.state === 'rejected' ? 'rejected' : 'held',
|
|
154
|
+
reviewId,
|
|
155
|
+
reference
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const url = pageUrl(host, request.screenId);
|
|
159
|
+
const flagged = flaggedHostOf(signals);
|
|
160
|
+
const pageName = typeof (host == null ? void 0 : host['name']) === 'string' && host['name'] ? `"${host['name']}"` : 'a site';
|
|
161
|
+
const state = await fileOutboundHold({
|
|
162
|
+
reviewId,
|
|
163
|
+
heldSend: {
|
|
164
|
+
kind: 'page',
|
|
165
|
+
path: `hosts/${request.hostId}/screens/${request.screenId}`,
|
|
166
|
+
hostId: request.hostId,
|
|
167
|
+
orgId,
|
|
168
|
+
contentHash: outboundContentHash([
|
|
169
|
+
signals
|
|
170
|
+
]),
|
|
171
|
+
subject: (url != null ? url : `screen ${request.screenId}`).slice(0, 300),
|
|
172
|
+
fromName: null,
|
|
173
|
+
signals,
|
|
174
|
+
state: 'held',
|
|
175
|
+
heldAtMs: nowMs,
|
|
176
|
+
ageDays
|
|
177
|
+
},
|
|
178
|
+
extra: {
|
|
179
|
+
heldPage: {
|
|
180
|
+
screenId: request.screenId,
|
|
181
|
+
versionId: request.versionId,
|
|
182
|
+
url
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
headline: `Held a published page of ${pageName}${url ? ` (${url})` : ''}, version ` + `${request.versionId}, from a workspace ${ageDays != null ? ageDays : '?'} day(s) old. The page ` + 'serves its last clean version, or nothing, until this is decided.',
|
|
186
|
+
alertTitle: 'Published page held for review — possible phishing',
|
|
187
|
+
alertBody: `A page ${url ? `at ${url} ` : ''}was held before it went live.`,
|
|
188
|
+
// The page itself is what was reported; the flagged host, when there
|
|
189
|
+
// is one, is named in the details and on `reportedHostname`.
|
|
190
|
+
url,
|
|
191
|
+
reportedHostname: flagged
|
|
192
|
+
});
|
|
193
|
+
pageMemo.set(reviewId, {
|
|
194
|
+
state,
|
|
195
|
+
atMs: nowMs
|
|
196
|
+
});
|
|
197
|
+
return state === 'released' ? {
|
|
198
|
+
outcome: 'serve'
|
|
199
|
+
} : {
|
|
200
|
+
outcome: state === 'rejected' ? 'rejected' : 'held',
|
|
201
|
+
reviewId,
|
|
202
|
+
reference
|
|
203
|
+
};
|
|
204
|
+
} catch (error) {
|
|
205
|
+
// Closed only for what holds whoever the workspace is: a soft signal
|
|
206
|
+
// needs the workspace's age to hold at all, and that is what failed.
|
|
207
|
+
if (!signalsThatHold(found.signals, {
|
|
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);
|
|
216
|
+
return {
|
|
217
|
+
outcome: 'held',
|
|
218
|
+
reviewId,
|
|
219
|
+
reference: reviewId ? heldOutboundReference(reviewId) : ''
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function pageReviewRef(hostId, screenId) {
|
|
224
|
+
return firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection(PAGE_REVIEW_SUBCOLLECTION).doc(screenId);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The version this page last served clean, or null. A HINT, not a
|
|
228
|
+
* clearance: the caller composes and screens it again before serving it,
|
|
229
|
+
* because a version document can be edited after it was served.
|
|
230
|
+
*/ export async function servedPageVersion(hostId, screenId) {
|
|
231
|
+
try {
|
|
232
|
+
const snapshot = await pageReviewRef(hostId, screenId).get();
|
|
233
|
+
const value = snapshot.get('servedVersionId');
|
|
234
|
+
return typeof value === 'string' && value ? value : null;
|
|
235
|
+
} catch (unused) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Note that this page served `versionId` clean. One read the first time a
|
|
241
|
+
* process renders a page, a write only when the version moved; nothing
|
|
242
|
+
* after that, however often the page is rendered.
|
|
243
|
+
*/ export async function recordServedPageVersion(hostId, screenId, versionId) {
|
|
244
|
+
const key = `${hostId}/${screenId}`;
|
|
245
|
+
if (!versionId || servedMemo.get(key) === versionId) return;
|
|
246
|
+
try {
|
|
247
|
+
const ref = pageReviewRef(hostId, screenId);
|
|
248
|
+
const snapshot = await ref.get();
|
|
249
|
+
if (snapshot.get('servedVersionId') !== versionId) {
|
|
250
|
+
await ref.set({
|
|
251
|
+
servedVersionId: versionId,
|
|
252
|
+
servedAtMs: Date.now()
|
|
253
|
+
}, {
|
|
254
|
+
merge: true
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
servedMemo.set(key, versionId);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.warn('[page-review] the served version could not be noted', error);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A domain a workspace attached that wears a brand (AGL-3356, AGL-3362):
|
|
264
|
+
* filed in the abuse queue as an urgent `phishing` row for staff, not
|
|
265
|
+
* refused. Two kinds, one rule:
|
|
266
|
+
*
|
|
267
|
+
* - `site` — a custom domain a site is served on (the attach route);
|
|
268
|
+
* - `sending` — a custom domain a workspace sends email as, when it is
|
|
269
|
+
* added and again when it verifies (`sending-domains.ts`);
|
|
270
|
+
* - `link` — a link a merchant configured that a buyer or a server would
|
|
271
|
+
* follow: a product's paid download or video hotlink, a supplier's order
|
|
272
|
+
* webhook (AGL-3363). The link itself is refused where it is followed; this
|
|
273
|
+
* files it, and tells the site's managers once ({@link notifyLookalikeLink}).
|
|
274
|
+
*
|
|
275
|
+
* Not refused, because the domain is the merchant's own registration and
|
|
276
|
+
* the platform cannot tell a brand's own agency attaching `paypal-promo.com`
|
|
277
|
+
* from a kit — but a brand's lookalike on a site or in a `From:` line is
|
|
278
|
+
* exactly what staff look at first. The site's pages are still screened as
|
|
279
|
+
* they are served, and mail FROM a lookalike domain is held at the send seam
|
|
280
|
+
* for every workspace. The test is the phishing screen's own lookalike rule
|
|
281
|
+
* (`lookalikeBrandForHost`), the one that holds a link in an email. One row
|
|
282
|
+
* per (owner, kind, domain), so a re-attach or a re-verify counts rather than
|
|
283
|
+
* re-alerts.
|
|
284
|
+
*
|
|
285
|
+
* Never throws: the write it rides on has already happened.
|
|
286
|
+
*/ export async function flagLookalikeDomain(input) {
|
|
287
|
+
var _input_domain;
|
|
288
|
+
const domain = String((_input_domain = input.domain) != null ? _input_domain : '').trim().toLowerCase();
|
|
289
|
+
const brand = lookalikeBrandForHost(domain);
|
|
290
|
+
if (!brand) return 'clean';
|
|
291
|
+
const label = phishingScreenBrandLabel(brand.id);
|
|
292
|
+
const sending = input.kind === 'sending';
|
|
293
|
+
const link = input.kind === 'link';
|
|
294
|
+
try {
|
|
295
|
+
var _input_orgId, _input_hostId, _input_hostId1, _input_where;
|
|
296
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
297
|
+
const owner = sending ? `orgs/${(_input_orgId = input.orgId) != null ? _input_orgId : ''}/sendingDomains` : link ? `hosts/${(_input_hostId = input.hostId) != null ? _input_hostId : ''}/links` : `hosts/${(_input_hostId1 = input.hostId) != null ? _input_hostId1 : ''}/cname`;
|
|
298
|
+
const reviewId = heldOutboundReviewId(owner, domain);
|
|
299
|
+
const reference = heldOutboundReference(reviewId);
|
|
300
|
+
const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId);
|
|
301
|
+
const first = !(await ref.get()).exists;
|
|
302
|
+
await ref.set(_extends({
|
|
303
|
+
reference,
|
|
304
|
+
category: 'phishing',
|
|
305
|
+
severity: 'urgent',
|
|
306
|
+
source: 'outbound-screen',
|
|
307
|
+
url: `https://${domain}/`,
|
|
308
|
+
reportedHostname: domain,
|
|
309
|
+
hostId: input.hostId,
|
|
310
|
+
orgId: input.orgId,
|
|
311
|
+
details: [
|
|
312
|
+
sending ? `A workspace added ${domain} as a domain to send email from.` : link ? `A site configured a link to ${domain} (${(_input_where = input.where) != null ? _input_where : 'a commerce link'}).` : `A site attached the custom domain ${domain}.`,
|
|
313
|
+
`${domain} wears the ${label} name but is not ${label}'s domain.`,
|
|
314
|
+
sending ? 'Email from it is held at the send seam for every workspace. Mark this actioned ' + 'and lock the workspace if it is impersonation; dismiss it if the brand is theirs.' : link ? 'Nobody is sent to it: the link is refused wherever it is followed. Mark this ' + 'actioned and lock the site if it is impersonation.' : 'Its pages are still screened as they are served. Mark this actioned ' + 'and lock the site if it is impersonation; dismiss it if the brand is theirs.'
|
|
315
|
+
].join('\n'),
|
|
316
|
+
reporterEmail: null,
|
|
317
|
+
reporterName: null,
|
|
318
|
+
dmca: null,
|
|
319
|
+
reportCount: FieldValue.increment(1),
|
|
320
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
321
|
+
}, first ? {
|
|
322
|
+
status: 'open',
|
|
323
|
+
createdAt: FieldValue.serverTimestamp()
|
|
324
|
+
} : {}), {
|
|
325
|
+
merge: true
|
|
326
|
+
});
|
|
327
|
+
if (first && link) {
|
|
328
|
+
var _input_where1;
|
|
329
|
+
await notifyLookalikeLink({
|
|
330
|
+
hostId: input.hostId,
|
|
331
|
+
domain,
|
|
332
|
+
label,
|
|
333
|
+
where: (_input_where1 = input.where) != null ? _input_where1 : 'a commerce link',
|
|
334
|
+
reference
|
|
335
|
+
});
|
|
336
|
+
} else if (first) {
|
|
337
|
+
await notifyStaff({
|
|
338
|
+
type: 'system.abuseReportUrgent',
|
|
339
|
+
title: sending ? 'Sending domain flagged — possible brand impersonation' : 'Custom domain flagged — possible brand impersonation',
|
|
340
|
+
body: `A ${sending ? 'workspace added the sending domain' : 'site attached'} ${domain}, ` + `which looks like ${label}. Reference ${reference}.`,
|
|
341
|
+
link: '/admin/abuse-reports'
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return 'flagged';
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.error('[page-review] a lookalike domain could not be flagged', error);
|
|
347
|
+
return 'failed';
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Tell staff and the site's managers, once per (site, domain), that a link
|
|
352
|
+
* the merchant configured was refused because it wears another brand's name
|
|
353
|
+
* (AGL-3363). The one notifier for this kind of signal, so a shared
|
|
354
|
+
* risk-notice seam can take it over whole. The merchant is told what was
|
|
355
|
+
* refused and how to fix it; never how the check works. Never throws.
|
|
356
|
+
*/ export async function notifyLookalikeLink(input) {
|
|
357
|
+
await Promise.all([
|
|
358
|
+
notifyStaff({
|
|
359
|
+
type: 'system.abuseReportUrgent',
|
|
360
|
+
title: 'Commerce link flagged — possible brand impersonation',
|
|
361
|
+
body: `A site configured ${input.where} to ${input.domain}, which looks like ${input.label}. ` + `It is refused wherever it is followed. Reference ${input.reference}.`,
|
|
362
|
+
link: '/admin/abuse-reports'
|
|
363
|
+
}).catch(()=>undefined),
|
|
364
|
+
input.hostId ? notifyHostManagers(input.hostId, {
|
|
365
|
+
type: 'content.order',
|
|
366
|
+
title: 'A link on your store was blocked',
|
|
367
|
+
body: `${input.where[0].toUpperCase()}${input.where.slice(1)} points to ${input.domain}, ` + `which looks like another company's website, so nobody is sent there. ` + 'Replace it with a file from your media library or a link on your own domain. ' + `If you think this is a mistake, contact support with reference ${input.reference}.`,
|
|
368
|
+
link: `/${input.hostId}/products`
|
|
369
|
+
}).catch(()=>undefined) : Promise.resolve()
|
|
370
|
+
]);
|
|
371
|
+
}
|
|
372
|
+
/** A site's custom domain, through {@link flagLookalikeDomain}. */ export function flagLookalikeCustomDomain(input) {
|
|
373
|
+
return flagLookalikeDomain(_extends({
|
|
374
|
+
kind: 'site'
|
|
375
|
+
}, input));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
//# sourceMappingURL=hosted-page-review.js.map
|
|
@@ -0,0 +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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"}
|
|
@@ -19,6 +19,7 @@ import { resolveBrandingProfile } from "@aglyn/aglyn/server";
|
|
|
19
19
|
import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
|
|
20
20
|
import { createHmac } from "crypto";
|
|
21
21
|
import { tokenSigningSecret } from "./media-signing.js";
|
|
22
|
+
import { renderSystemEmailContent, systemEmailBrand } from "./render-system-email.js";
|
|
22
23
|
import { safeEqual } from "./safe-equal.js";
|
|
23
24
|
/**
|
|
24
25
|
* A MEMBER'S OWN ADDRESSES (AGL-2975): the half with Firestore.
|
|
@@ -350,8 +351,8 @@ const signature = (payload)=>createHmac('sha256', tokenSigningSecret()).update(`
|
|
|
350
351
|
* sent (see the two ceilings above), and metered to the workspace, whose
|
|
351
352
|
* member asked for it.
|
|
352
353
|
*/ export async function sendMemberEmailAliasConfirmation(input) {
|
|
353
|
-
var _input_org, _ref, _input_memberName;
|
|
354
|
-
var
|
|
354
|
+
var _input_org, _ref, _input_memberName, _input_org1;
|
|
355
|
+
var _input_org2;
|
|
355
356
|
if (!isEmailConfigured()) {
|
|
356
357
|
return {
|
|
357
358
|
sent: false,
|
|
@@ -383,7 +384,7 @@ const signature = (payload)=>createHmac('sha256', tokenSigningSecret()).update(`
|
|
|
383
384
|
};
|
|
384
385
|
}
|
|
385
386
|
const branding = resolveBrandingProfile((_input_org = input.org) != null ? _input_org : null);
|
|
386
|
-
const workspace = String((_ref = (
|
|
387
|
+
const workspace = String((_ref = (_input_org2 = input.org) == null ? void 0 : _input_org2['name']) != null ? _ref : '').trim() || 'your workspace';
|
|
387
388
|
const member = String((_input_memberName = input.memberName) != null ? _input_memberName : '').trim();
|
|
388
389
|
const text = [
|
|
389
390
|
`${member || 'A member'} asked to add ${input.address} as one of their own email ` + `addresses in the ${workspace} workspace on ${branding.productName}.`,
|
|
@@ -396,13 +397,23 @@ const signature = (payload)=>createHmac('sha256', tokenSigningSecret()).update(`
|
|
|
396
397
|
'',
|
|
397
398
|
'If you did not expect this email, ignore it. Nothing changes unless the member who asked opens the link.'
|
|
398
399
|
].join('\n');
|
|
399
|
-
|
|
400
|
-
|
|
400
|
+
// The `member-email-alias-confirmation` system email (AGL-3367), in the
|
|
401
|
+
// workspace's brand; `text` above is its last resort.
|
|
402
|
+
const content = await renderSystemEmailContent('member-email-alias-confirmation', {
|
|
403
|
+
'member.name': member || 'A member',
|
|
404
|
+
address: input.address,
|
|
405
|
+
'org.name': workspace,
|
|
406
|
+
confirmUrl: input.confirmUrl
|
|
407
|
+
}, systemEmailBrand((_input_org1 = input.org) != null ? _input_org1 : null), {
|
|
401
408
|
subject: `Confirm your address for ${workspace}`,
|
|
402
|
-
text
|
|
409
|
+
text
|
|
410
|
+
});
|
|
411
|
+
const result = await sendEmail(_extends({
|
|
412
|
+
to: input.address
|
|
413
|
+
}, content, {
|
|
403
414
|
fromName: branding.fromName,
|
|
404
415
|
context: 'member-email-alias-confirmation'
|
|
405
|
-
});
|
|
416
|
+
}));
|
|
406
417
|
if (!result.sent) {
|
|
407
418
|
return {
|
|
408
419
|
sent: false,
|