@aglyn/tenant-data-admin 1.0.0-beta.200 → 1.0.0-beta.201
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/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 +92 -0
- package/src/lib/server/hosted-page-review.js +342 -0
- package/src/lib/server/hosted-page-review.js.map +1 -0
- 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/outbound-send-review.d.ts +222 -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/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/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 +3 -0
- package/src/tenancy.js +8 -0
- package/src/tenancy.js.map +1 -1
|
@@ -0,0 +1,342 @@
|
|
|
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 { 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
|
+
*
|
|
271
|
+
* Not refused, because the domain is the merchant's own registration and
|
|
272
|
+
* the platform cannot tell a brand's own agency attaching `paypal-promo.com`
|
|
273
|
+
* from a kit — but a brand's lookalike on a site or in a `From:` line is
|
|
274
|
+
* exactly what staff look at first. The site's pages are still screened as
|
|
275
|
+
* they are served, and mail FROM a lookalike domain is held at the send seam
|
|
276
|
+
* for every workspace. The test is the phishing screen's own lookalike rule
|
|
277
|
+
* (`lookalikeBrandForHost`), the one that holds a link in an email. One row
|
|
278
|
+
* per (owner, kind, domain), so a re-attach or a re-verify counts rather than
|
|
279
|
+
* re-alerts.
|
|
280
|
+
*
|
|
281
|
+
* Never throws: the write it rides on has already happened.
|
|
282
|
+
*/ export async function flagLookalikeDomain(input) {
|
|
283
|
+
var _input_domain;
|
|
284
|
+
const domain = String((_input_domain = input.domain) != null ? _input_domain : '').trim().toLowerCase();
|
|
285
|
+
const brand = lookalikeBrandForHost(domain);
|
|
286
|
+
if (!brand) return 'clean';
|
|
287
|
+
const label = phishingScreenBrandLabel(brand.id);
|
|
288
|
+
const sending = input.kind === 'sending';
|
|
289
|
+
try {
|
|
290
|
+
var _input_orgId, _input_hostId;
|
|
291
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
292
|
+
const owner = sending ? `orgs/${(_input_orgId = input.orgId) != null ? _input_orgId : ''}/sendingDomains` : `hosts/${(_input_hostId = input.hostId) != null ? _input_hostId : ''}/cname`;
|
|
293
|
+
const reviewId = heldOutboundReviewId(owner, domain);
|
|
294
|
+
const reference = heldOutboundReference(reviewId);
|
|
295
|
+
const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId);
|
|
296
|
+
const first = !(await ref.get()).exists;
|
|
297
|
+
await ref.set(_extends({
|
|
298
|
+
reference,
|
|
299
|
+
category: 'phishing',
|
|
300
|
+
severity: 'urgent',
|
|
301
|
+
source: 'outbound-screen',
|
|
302
|
+
url: `https://${domain}/`,
|
|
303
|
+
reportedHostname: domain,
|
|
304
|
+
hostId: input.hostId,
|
|
305
|
+
orgId: input.orgId,
|
|
306
|
+
details: [
|
|
307
|
+
sending ? `A workspace added ${domain} as a domain to send email from.` : `A site attached the custom domain ${domain}.`,
|
|
308
|
+
`${domain} wears the ${label} name but is not ${label}'s domain.`,
|
|
309
|
+
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.' : '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.'
|
|
310
|
+
].join('\n'),
|
|
311
|
+
reporterEmail: null,
|
|
312
|
+
reporterName: null,
|
|
313
|
+
dmca: null,
|
|
314
|
+
reportCount: FieldValue.increment(1),
|
|
315
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
316
|
+
}, first ? {
|
|
317
|
+
status: 'open',
|
|
318
|
+
createdAt: FieldValue.serverTimestamp()
|
|
319
|
+
} : {}), {
|
|
320
|
+
merge: true
|
|
321
|
+
});
|
|
322
|
+
if (first) {
|
|
323
|
+
await notifyStaff({
|
|
324
|
+
type: 'system.abuseReportUrgent',
|
|
325
|
+
title: sending ? 'Sending domain flagged — possible brand impersonation' : 'Custom domain flagged — possible brand impersonation',
|
|
326
|
+
body: `A ${sending ? 'workspace added the sending domain' : 'site attached'} ${domain}, ` + `which looks like ${label}. Reference ${reference}.`,
|
|
327
|
+
link: '/admin/abuse-reports'
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return 'flagged';
|
|
331
|
+
} catch (error) {
|
|
332
|
+
console.error('[page-review] a lookalike domain could not be flagged', error);
|
|
333
|
+
return 'failed';
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** A site's custom domain, through {@link flagLookalikeDomain}. */ export function flagLookalikeCustomDomain(input) {
|
|
337
|
+
return flagLookalikeDomain(_extends({
|
|
338
|
+
kind: 'site'
|
|
339
|
+
}, input));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
//# 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 { 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 *\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'\n hostId: string | null\n orgId: string | null\n domain: 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 try {\n const firestore = firebaseAdmin.app().firestore()\n const owner = sending ? `orgs/${input.orgId ?? ''}/sendingDomains` : `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 : `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 : '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) {\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/** 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","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","first","exists","category","severity","source","details","reporterEmail","reporterName","dmca","reportCount","increment","updatedAt","serverTimestamp","status","type","title","body","link","flagLookalikeCustomDomain"],"mappings":"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"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* How old a workspace is, in days, from whatever its `createdAt` turns out to
|
|
19
|
+
* be.
|
|
20
|
+
*
|
|
21
|
+
* Firestore hands back a `Timestamp`, a restore or a fixture can leave a
|
|
22
|
+
* number, and an org written before the field existed has nothing. All three
|
|
23
|
+
* are handled and the last one answers `null`, which
|
|
24
|
+
* `claimOrgEmailSendDay` reads as graduated — the only safe direction,
|
|
25
|
+
* for the reason stated there.
|
|
26
|
+
*/
|
|
27
|
+
export declare function orgAgeDays(createdAt: unknown, now?: number): number | null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ // The leaf, not the barrel: a spec that substitutes the barrel must still get
|
|
17
|
+
// the real day arithmetic here.
|
|
18
|
+
import { daysBetween } from "@aglyn/shared-util-email/sender-reputation";
|
|
19
|
+
/**
|
|
20
|
+
* How old a workspace is, in days, from whatever its `createdAt` turns out to
|
|
21
|
+
* be.
|
|
22
|
+
*
|
|
23
|
+
* Firestore hands back a `Timestamp`, a restore or a fixture can leave a
|
|
24
|
+
* number, and an org written before the field existed has nothing. All three
|
|
25
|
+
* are handled and the last one answers `null`, which
|
|
26
|
+
* `claimOrgEmailSendDay` reads as graduated — the only safe direction,
|
|
27
|
+
* for the reason stated there.
|
|
28
|
+
*/ export function orgAgeDays(createdAt, now = Date.now()) {
|
|
29
|
+
const raw = createdAt;
|
|
30
|
+
let createdMs = 0;
|
|
31
|
+
if (typeof raw === 'number') createdMs = raw;
|
|
32
|
+
else if (typeof raw === 'string') createdMs = Date.parse(raw);
|
|
33
|
+
else if (typeof (raw == null ? void 0 : raw.toMillis) === 'function') createdMs = raw.toMillis();
|
|
34
|
+
else if (typeof (raw == null ? void 0 : raw.seconds) === 'number') createdMs = raw.seconds * 1000;
|
|
35
|
+
/*
|
|
36
|
+
* The one check, and it has to answer `null` rather than 0. A creation date
|
|
37
|
+
* that could not be read is an org whose record predates the field, which
|
|
38
|
+
* is an EXISTING customer; `0` would say "created today" and ramp every
|
|
39
|
+
* paying tenant on the platform down to the first step.
|
|
40
|
+
*/ if (!Number.isFinite(createdMs) || createdMs <= 0) return null;
|
|
41
|
+
return daysBetween(createdMs, now);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//# sourceMappingURL=org-age.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/org-age.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// The leaf, not the barrel: a spec that substitutes the barrel must still get\n// the real day arithmetic here.\nimport { daysBetween } from '@aglyn/shared-util-email/sender-reputation'\n\n/**\n * How old a workspace is, in days, from whatever its `createdAt` turns out to\n * be.\n *\n * Firestore hands back a `Timestamp`, a restore or a fixture can leave a\n * number, and an org written before the field existed has nothing. All three\n * are handled and the last one answers `null`, which\n * `claimOrgEmailSendDay` reads as graduated — the only safe direction,\n * for the reason stated there.\n */\nexport function orgAgeDays(\n createdAt: unknown,\n now: number = Date.now(),\n): number | null {\n const raw = createdAt as\n | { toMillis?: () => number; seconds?: number }\n | number\n | string\n | null\n | undefined\n let createdMs = 0\n if (typeof raw === 'number') createdMs = raw\n else if (typeof raw === 'string') createdMs = Date.parse(raw)\n else if (typeof raw?.toMillis === 'function') createdMs = raw.toMillis()\n else if (typeof raw?.seconds === 'number') createdMs = raw.seconds * 1000\n /*\n * The one check, and it has to answer `null` rather than 0. A creation date\n * that could not be read is an org whose record predates the field, which\n * is an EXISTING customer; `0` would say \"created today\" and ramp every\n * paying tenant on the platform down to the first step.\n */\n if (!Number.isFinite(createdMs) || createdMs <= 0) return null\n return daysBetween(createdMs, now)\n}\n"],"names":["daysBetween","orgAgeDays","createdAt","now","Date","raw","createdMs","parse","toMillis","seconds","Number","isFinite"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,gCAAgC;AAChC,SAASA,WAAW,QAAQ,6CAA4C;AAExE;;;;;;;;;CASC,GACD,OAAO,SAASC,WACdC,SAAkB,EAClBC,MAAcC,KAAKD,GAAG,EAAE;IAExB,MAAME,MAAMH;IAMZ,IAAII,YAAY;IAChB,IAAI,OAAOD,QAAQ,UAAUC,YAAYD;SACpC,IAAI,OAAOA,QAAQ,UAAUC,YAAYF,KAAKG,KAAK,CAACF;SACpD,IAAI,QAAOA,uBAAAA,IAAKG,QAAQ,MAAK,YAAYF,YAAYD,IAAIG,QAAQ;SACjE,IAAI,QAAOH,uBAAAA,IAAKI,OAAO,MAAK,UAAUH,YAAYD,IAAII,OAAO,GAAG;IACrE;;;;;GAKC,GACD,IAAI,CAACC,OAAOC,QAAQ,CAACL,cAAcA,aAAa,GAAG,OAAO;IAC1D,OAAON,YAAYM,WAAWH;AAChC"}
|