@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.
Files changed (67) hide show
  1. package/package.json +8 -7
  2. package/src/lib/server/card-payment-velocity.d.ts +85 -0
  3. package/src/lib/server/card-payment-velocity.js +202 -0
  4. package/src/lib/server/card-payment-velocity.js.map +1 -0
  5. package/src/lib/server/configured-url-fetch.d.ts +42 -0
  6. package/src/lib/server/configured-url-fetch.js +118 -0
  7. package/src/lib/server/configured-url-fetch.js.map +1 -0
  8. package/src/lib/server/dns-probe.d.ts +7 -0
  9. package/src/lib/server/dns-probe.js +52 -0
  10. package/src/lib/server/dns-probe.js.map +1 -1
  11. package/src/lib/server/email-deliverability.d.ts +150 -0
  12. package/src/lib/server/email-deliverability.js +538 -0
  13. package/src/lib/server/email-deliverability.js.map +1 -0
  14. package/src/lib/server/email-sender-reputation.d.ts +2 -11
  15. package/src/lib/server/email-sender-reputation.js +8 -25
  16. package/src/lib/server/email-sender-reputation.js.map +1 -1
  17. package/src/lib/server/email-suppression.d.ts +10 -1
  18. package/src/lib/server/email-suppression.js +4 -3
  19. package/src/lib/server/email-suppression.js.map +1 -1
  20. package/src/lib/server/erase-person.js +31 -3
  21. package/src/lib/server/erase-person.js.map +1 -1
  22. package/src/lib/server/host-visitor-records.d.ts +16 -2
  23. package/src/lib/server/host-visitor-records.js +31 -6
  24. package/src/lib/server/host-visitor-records.js.map +1 -1
  25. package/src/lib/server/hosted-page-review.d.ts +112 -0
  26. package/src/lib/server/hosted-page-review.js +378 -0
  27. package/src/lib/server/hosted-page-review.js.map +1 -0
  28. package/src/lib/server/member-email-aliases.js +18 -7
  29. package/src/lib/server/member-email-aliases.js.map +1 -1
  30. package/src/lib/server/notifications.d.ts +8 -0
  31. package/src/lib/server/notifications.js +92 -17
  32. package/src/lib/server/notifications.js.map +1 -1
  33. package/src/lib/server/org-age.d.ts +27 -0
  34. package/src/lib/server/org-age.js +44 -0
  35. package/src/lib/server/org-age.js.map +1 -0
  36. package/src/lib/server/org-member-notice.js +20 -5
  37. package/src/lib/server/org-member-notice.js.map +1 -1
  38. package/src/lib/server/organizations.d.ts +10 -1
  39. package/src/lib/server/organizations.js +12 -4
  40. package/src/lib/server/organizations.js.map +1 -1
  41. package/src/lib/server/outbound-send-review.d.ts +224 -0
  42. package/src/lib/server/outbound-send-review.js +479 -0
  43. package/src/lib/server/outbound-send-review.js.map +1 -0
  44. package/src/lib/server/payment-fraud-signal.d.ts +156 -0
  45. package/src/lib/server/payment-fraud-signal.js +368 -0
  46. package/src/lib/server/payment-fraud-signal.js.map +1 -0
  47. package/src/lib/server/payment-risk-record.d.ts +59 -0
  48. package/src/lib/server/payment-risk-record.js +76 -0
  49. package/src/lib/server/payment-risk-record.js.map +1 -0
  50. package/src/lib/server/render-system-email.d.ts +176 -0
  51. package/src/lib/server/render-system-email.js +315 -0
  52. package/src/lib/server/render-system-email.js.map +1 -0
  53. package/src/lib/server/sending-domains.d.ts +6 -0
  54. package/src/lib/server/sending-domains.js +98 -6
  55. package/src/lib/server/sending-domains.js.map +1 -1
  56. package/src/lib/server/staff-alert-email.d.ts +2 -1
  57. package/src/lib/server/staff-alert-email.js +16 -5
  58. package/src/lib/server/staff-alert-email.js.map +1 -1
  59. package/src/lib/server/stripe-card-authentication.d.ts +35 -0
  60. package/src/lib/server/stripe-card-authentication.js +63 -0
  61. package/src/lib/server/stripe-card-authentication.js.map +1 -0
  62. package/src/lib/server/tenant-write-lockdown.d.ts +32 -2
  63. package/src/lib/server/tenant-write-lockdown.js +33 -3
  64. package/src/lib/server/tenant-write-lockdown.js.map +1 -1
  65. package/src/tenancy.d.ts +5 -0
  66. package/src/tenancy.js +10 -0
  67. 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 _input_org1;
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 = (_input_org1 = input.org) == null ? void 0 : _input_org1['name']) != null ? _ref : '').trim() || 'your workspace';
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
- const result = await sendEmail({
400
- to: input.address,
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,