@aglyn/tenant-data-admin 1.0.0-beta.220 → 1.0.0-beta.221
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/hosted-page-review.d.ts +7 -0
- package/src/lib/server/hosted-page-review.js +131 -20
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/leaving-notice.d.ts +98 -0
- package/src/lib/server/leaving-notice.js +250 -0
- package/src/lib/server/leaving-notice.js.map +1 -0
- package/src/lib/server/link-reputation-review.d.ts +54 -0
- package/src/lib/server/link-reputation-review.js +144 -0
- package/src/lib/server/link-reputation-review.js.map +1 -0
- package/src/lib/server/org-age.d.ts +15 -6
- package/src/lib/server/org-age.js +20 -8
- package/src/lib/server/org-age.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +23 -6
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/page-security-hold.d.ts +93 -0
- package/src/lib/server/page-security-hold.js +180 -0
- package/src/lib/server/page-security-hold.js.map +1 -0
- package/src/lib/server/web-risk.d.ts +100 -0
- package/src/lib/server/web-risk.js +383 -0
- package/src/lib/server/web-risk.js.map +1 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type LeavingNoticeConfig } from '@aglyn/aglyn/app-utils/leaving-notice';
|
|
18
|
+
import { type SiteReturnHost } from '@aglyn/aglyn/app-utils/site-return-url';
|
|
19
|
+
/** How long a new free workspace's sites carry the notice. */
|
|
20
|
+
export declare const LEAVING_NOTICE_DAYS = 14;
|
|
21
|
+
/**
|
|
22
|
+
* The most outside addresses one page is signed for. A page with more is a
|
|
23
|
+
* link farm, and the ones past the cap still go through the notice — which
|
|
24
|
+
* refuses them — rather than around it.
|
|
25
|
+
*/
|
|
26
|
+
export declare const LEAVING_NOTICE_MAX_DESTINATIONS = 500;
|
|
27
|
+
/**
|
|
28
|
+
* When a workspace's notice window closes (epoch ms), or `null` when its
|
|
29
|
+
* sites carry no notice: a paid plan, a workspace past its first
|
|
30
|
+
* {@link LEAVING_NOTICE_DAYS} days, or one whose creation date is unreadable.
|
|
31
|
+
*
|
|
32
|
+
* Takes the org as loaded anywhere — a live document, or the render cache's
|
|
33
|
+
* JSON copy, whose `createdAt` has lost its methods (`orgCreatedMs`).
|
|
34
|
+
*/
|
|
35
|
+
export declare function leavingNoticeEndsAt(org: unknown, nowMs?: number): number | null;
|
|
36
|
+
/**
|
|
37
|
+
* The hosts a link may reach without the notice: the site's own (its
|
|
38
|
+
* subdomain, its custom domain and `www.` of it) and the platform's own — the
|
|
39
|
+
* marketing site, console, support and docs, each with every name under its
|
|
40
|
+
* registrable domain, plus the bare tenant apex.
|
|
41
|
+
*
|
|
42
|
+
* Other sites on the tenant apex are NOT exempt. They are other customers'
|
|
43
|
+
* sites, and a chain of two new free sites is the same hop as one.
|
|
44
|
+
*/
|
|
45
|
+
export declare function leavingNoticeHosts(site: SiteReturnHost | null | undefined): string[];
|
|
46
|
+
/**
|
|
47
|
+
* Every outside address a rendered page could link to: each string in the
|
|
48
|
+
* payload that is one, and each one embedded in a string (Markdown, rich
|
|
49
|
+
* text HTML, a plain sentence). Over-collecting is harmless — a signature
|
|
50
|
+
* nobody follows is never read — and under-collecting only sends a link to a
|
|
51
|
+
* refusal, never around the notice.
|
|
52
|
+
*
|
|
53
|
+
* Returned normalized, the spelling the client looks signatures up by.
|
|
54
|
+
*/
|
|
55
|
+
export declare function collectLeavingDestinations(value: unknown, hosts: readonly string[], limit?: number): string[];
|
|
56
|
+
/**
|
|
57
|
+
* The signature for one destination on one site. Throws when the deployment
|
|
58
|
+
* has no signing secret, like every signer of it.
|
|
59
|
+
*/
|
|
60
|
+
export declare function signLeavingDestination(hostId: string, destination: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Whether `signature` was issued for `destination` on this site. False for
|
|
63
|
+
* anything else — another site's signature, an edited address, a missing
|
|
64
|
+
* secret (fail closed).
|
|
65
|
+
*/
|
|
66
|
+
export declare function verifyLeavingDestination(hostId: string, destination: unknown, signature: unknown): boolean;
|
|
67
|
+
/** Signs a page's destinations for one site. */
|
|
68
|
+
export declare function signLeavingDestinations(hostId: string, destinations: readonly string[]): Record<string, string>;
|
|
69
|
+
/**
|
|
70
|
+
* What a published page of this site carries, or `null` when the site is not
|
|
71
|
+
* in its window.
|
|
72
|
+
*
|
|
73
|
+
* `content` is everything the page renders from — its props, its nodes —
|
|
74
|
+
* walked for outside addresses to sign.
|
|
75
|
+
*
|
|
76
|
+
* A deployment with no signing secret gets no notice, loudly. The route
|
|
77
|
+
* could verify nothing there and would refuse every link on every new free
|
|
78
|
+
* site; an outage of every outbound link is not a safer failure than the
|
|
79
|
+
* links working as they did before this existed.
|
|
80
|
+
*/
|
|
81
|
+
export declare function leavingNoticeConfig(options: {
|
|
82
|
+
hostId: string | null | undefined;
|
|
83
|
+
site: SiteReturnHost | null | undefined;
|
|
84
|
+
org: unknown;
|
|
85
|
+
content?: unknown;
|
|
86
|
+
nowMs?: number;
|
|
87
|
+
}): LeavingNoticeConfig | null;
|
|
88
|
+
/**
|
|
89
|
+
* Only the signatures, for a payload that arrives after its page — a gated
|
|
90
|
+
* screen's nodes (`enrichGatedScreenPage`). `null` outside the window.
|
|
91
|
+
*/
|
|
92
|
+
export declare function leavingNoticeSignatures(options: {
|
|
93
|
+
hostId: string | null | undefined;
|
|
94
|
+
site: SiteReturnHost | null | undefined;
|
|
95
|
+
org: unknown;
|
|
96
|
+
content: unknown;
|
|
97
|
+
nowMs?: number;
|
|
98
|
+
}): Record<string, string> | null;
|
|
@@ -0,0 +1,250 @@
|
|
|
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
|
+
* THE "YOU'RE LEAVING THIS SITE" NOTICE, SERVER HALF (AGL-3452).
|
|
18
|
+
*
|
|
19
|
+
* Who gets it, which hosts are exempt, and the signature that keeps the
|
|
20
|
+
* notice route from becoming an open redirect. The client half — rewriting
|
|
21
|
+
* an href, the click interceptor — is `@aglyn/aglyn/app-utils/leaving-notice`.
|
|
22
|
+
*
|
|
23
|
+
* ## Who
|
|
24
|
+
*
|
|
25
|
+
* A site whose workspace is on the FREE plan (the effective plan, so a dead
|
|
26
|
+
* subscription counts as free, as it does for every entitlement) and in its
|
|
27
|
+
* first {@link LEAVING_NOTICE_DAYS} days — the same window, read through the
|
|
28
|
+
* same `isYoungWorkspaceAge`, as the soft phishing rules. Paid and older
|
|
29
|
+
* workspaces are untouched, and a workspace whose creation date cannot be
|
|
30
|
+
* read is an existing customer, never a new one.
|
|
31
|
+
*
|
|
32
|
+
* ## Why signed
|
|
33
|
+
*
|
|
34
|
+
* The notice lives on the site's own host and its Continue button goes
|
|
35
|
+
* wherever `?to=` says. Unsigned, any site's notice would be a page that
|
|
36
|
+
* vouches for any address anyone puts in a link — the hop this feature exists
|
|
37
|
+
* to remove. So the server signs each outside address it finds on a page it
|
|
38
|
+
* renders, bound to that site, and the route shows a Continue button only for
|
|
39
|
+
* an address whose signature checks out. Everything else is refused.
|
|
40
|
+
*
|
|
41
|
+
* The signature carries no expiry on purpose. The route never redirects on
|
|
42
|
+
* its own, so a signature that outlives its page buys nothing but one more
|
|
43
|
+
* view of the same warning — while an expiring one would break the links an
|
|
44
|
+
* ISR-cached page is still serving the moment the window closes.
|
|
45
|
+
*=========================================*/ import { createHmac } from "crypto";
|
|
46
|
+
import { DOCS_BASE_URL } from "@aglyn/aglyn/app-utils/docs-help";
|
|
47
|
+
import { leavingDestination, normalizeLeavingDestination } from "@aglyn/aglyn/app-utils/leaving-notice";
|
|
48
|
+
import { resolveEffectivePlan } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
49
|
+
import { PLATFORM_HOME_URL, PLATFORM_SUPPORT_URL, platformConsoleOrigin } from "@aglyn/aglyn/app-utils/platform-brand";
|
|
50
|
+
import { siteOwnHosts } from "@aglyn/aglyn/app-utils/site-return-url";
|
|
51
|
+
import { TENANT_APEX } from "@aglyn/aglyn/app-utils/tenant-apex";
|
|
52
|
+
import { isYoungWorkspaceAge, OUTBOUND_REVIEW_YOUNG_DAYS, registrableDomain } from "@aglyn/shared-util-email/outbound-phishing-screen";
|
|
53
|
+
import { tokenSigningSecret } from "./media-signing.js";
|
|
54
|
+
import { orgAgeDays, orgCreatedMs } from "./org-age.js";
|
|
55
|
+
import { safeEqual } from "./safe-equal.js";
|
|
56
|
+
/** How long a new free workspace's sites carry the notice. */ export const LEAVING_NOTICE_DAYS = OUTBOUND_REVIEW_YOUNG_DAYS;
|
|
57
|
+
const DAY_MS = 86400000;
|
|
58
|
+
/**
|
|
59
|
+
* The most outside addresses one page is signed for. A page with more is a
|
|
60
|
+
* link farm, and the ones past the cap still go through the notice — which
|
|
61
|
+
* refuses them — rather than around it.
|
|
62
|
+
*/ export const LEAVING_NOTICE_MAX_DESTINATIONS = 500;
|
|
63
|
+
/** Deepest a page payload is walked; composed node maps sit far above it. */ const MAX_WALK_DEPTH = 64;
|
|
64
|
+
/** Longest string scanned for embedded addresses (a long entry body). */ const MAX_SCANNED_STRING = 200000;
|
|
65
|
+
/**
|
|
66
|
+
* When a workspace's notice window closes (epoch ms), or `null` when its
|
|
67
|
+
* sites carry no notice: a paid plan, a workspace past its first
|
|
68
|
+
* {@link LEAVING_NOTICE_DAYS} days, or one whose creation date is unreadable.
|
|
69
|
+
*
|
|
70
|
+
* Takes the org as loaded anywhere — a live document, or the render cache's
|
|
71
|
+
* JSON copy, whose `createdAt` has lost its methods (`orgCreatedMs`).
|
|
72
|
+
*/ export function leavingNoticeEndsAt(org, nowMs = Date.now()) {
|
|
73
|
+
if (!org || typeof org !== 'object') return null;
|
|
74
|
+
if (resolveEffectivePlan(org) !== 'free') return null;
|
|
75
|
+
const createdAt = org.createdAt;
|
|
76
|
+
if (!isYoungWorkspaceAge(orgAgeDays(createdAt, nowMs))) return null;
|
|
77
|
+
const createdMs = orgCreatedMs(createdAt);
|
|
78
|
+
return createdMs === null ? null : createdMs + LEAVING_NOTICE_DAYS * DAY_MS;
|
|
79
|
+
}
|
|
80
|
+
function hostnameOf(raw) {
|
|
81
|
+
if (!raw) return null;
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(raw);
|
|
84
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
85
|
+
return url.hostname.toLowerCase() || null;
|
|
86
|
+
} catch (unused) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The hosts a link may reach without the notice: the site's own (its
|
|
92
|
+
* subdomain, its custom domain and `www.` of it) and the platform's own — the
|
|
93
|
+
* marketing site, console, support and docs, each with every name under its
|
|
94
|
+
* registrable domain, plus the bare tenant apex.
|
|
95
|
+
*
|
|
96
|
+
* Other sites on the tenant apex are NOT exempt. They are other customers'
|
|
97
|
+
* sites, and a chain of two new free sites is the same hop as one.
|
|
98
|
+
*/ export function leavingNoticeHosts(site) {
|
|
99
|
+
const hosts = new Set(siteOwnHosts(site));
|
|
100
|
+
const apex = TENANT_APEX.toLowerCase();
|
|
101
|
+
hosts.add(apex);
|
|
102
|
+
const apexRegistrable = registrableDomain(apex);
|
|
103
|
+
for (const raw of [
|
|
104
|
+
PLATFORM_HOME_URL,
|
|
105
|
+
platformConsoleOrigin(),
|
|
106
|
+
PLATFORM_SUPPORT_URL,
|
|
107
|
+
DOCS_BASE_URL
|
|
108
|
+
]){
|
|
109
|
+
const hostname = hostnameOf(raw);
|
|
110
|
+
if (!hostname) continue;
|
|
111
|
+
hosts.add(hostname);
|
|
112
|
+
const registrable = registrableDomain(hostname);
|
|
113
|
+
// A platform name under the tenant apex itself must not exempt the apex:
|
|
114
|
+
// that would exempt every customer's site with it.
|
|
115
|
+
if (registrable.includes('.') && registrable !== apexRegistrable) {
|
|
116
|
+
hosts.add(`.${registrable}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [
|
|
120
|
+
...hosts
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
/** An address as it appears in text: scheme, then anything a URL may hold. */ const EMBEDDED_URL = /https?:\/\/[^\s"'<>()[\]{}\\^`|]+/gi;
|
|
124
|
+
/** Characters prose puts after an address that the address does not own. */ const TRAILING_PROSE = /[.,;:!?'"*_~]+$/;
|
|
125
|
+
/** A whole value shaped like an absolute or protocol-relative address. */ const ADDRESS_SHAPE = /^\s*(?:https?:|[\\/]{2})/i;
|
|
126
|
+
/**
|
|
127
|
+
* Every outside address a rendered page could link to: each string in the
|
|
128
|
+
* payload that is one, and each one embedded in a string (Markdown, rich
|
|
129
|
+
* text HTML, a plain sentence). Over-collecting is harmless — a signature
|
|
130
|
+
* nobody follows is never read — and under-collecting only sends a link to a
|
|
131
|
+
* refusal, never around the notice.
|
|
132
|
+
*
|
|
133
|
+
* Returned normalized, the spelling the client looks signatures up by.
|
|
134
|
+
*/ export function collectLeavingDestinations(value, hosts, limit = LEAVING_NOTICE_MAX_DESTINATIONS) {
|
|
135
|
+
const found = new Set();
|
|
136
|
+
const consider = (candidate)=>{
|
|
137
|
+
if (found.size >= limit) return;
|
|
138
|
+
const destination = leavingDestination(candidate, hosts);
|
|
139
|
+
if (destination) found.add(destination);
|
|
140
|
+
};
|
|
141
|
+
const scan = (text)=>{
|
|
142
|
+
if (ADDRESS_SHAPE.test(text)) consider(text);
|
|
143
|
+
if (!text.includes('http')) return;
|
|
144
|
+
const scanned = text.length > MAX_SCANNED_STRING ? text.slice(0, MAX_SCANNED_STRING) : text;
|
|
145
|
+
for (const match of scanned.matchAll(EMBEDDED_URL)){
|
|
146
|
+
const raw = match[0];
|
|
147
|
+
consider(raw);
|
|
148
|
+
const trimmed = raw.replace(TRAILING_PROSE, '');
|
|
149
|
+
if (trimmed !== raw) consider(trimmed);
|
|
150
|
+
// An href inside stored HTML is entity-encoded; the DOM decodes it.
|
|
151
|
+
if (trimmed.includes('&')) consider(trimmed.replace(/&/g, '&'));
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const walk = (node, depth)=>{
|
|
155
|
+
if (found.size >= limit || depth > MAX_WALK_DEPTH) return;
|
|
156
|
+
if (typeof node === 'string') {
|
|
157
|
+
scan(node);
|
|
158
|
+
} else if (Array.isArray(node)) {
|
|
159
|
+
for (const item of node)walk(item, depth + 1);
|
|
160
|
+
} else if (node && typeof node === 'object') {
|
|
161
|
+
for (const item of Object.values(node))walk(item, depth + 1);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
walk(value, 0);
|
|
165
|
+
return [
|
|
166
|
+
...found
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
function leavingSignature(hostId, destination) {
|
|
170
|
+
return createHmac('sha256', tokenSigningSecret()).update(`leaving-notice:v1:${hostId}:${destination}`).digest('base64url');
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The signature for one destination on one site. Throws when the deployment
|
|
174
|
+
* has no signing secret, like every signer of it.
|
|
175
|
+
*/ export function signLeavingDestination(hostId, destination) {
|
|
176
|
+
const normalized = normalizeLeavingDestination(destination);
|
|
177
|
+
if (!hostId || !normalized) {
|
|
178
|
+
throw new Error('A leaving-notice signature needs a site and an address');
|
|
179
|
+
}
|
|
180
|
+
return leavingSignature(hostId, normalized);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Whether `signature` was issued for `destination` on this site. False for
|
|
184
|
+
* anything else — another site's signature, an edited address, a missing
|
|
185
|
+
* secret (fail closed).
|
|
186
|
+
*/ export function verifyLeavingDestination(hostId, destination, signature) {
|
|
187
|
+
if (!hostId || typeof signature !== 'string' || signature.length > 128) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
const normalized = normalizeLeavingDestination(destination);
|
|
191
|
+
if (!normalized) return false;
|
|
192
|
+
let expected;
|
|
193
|
+
try {
|
|
194
|
+
expected = leavingSignature(hostId, normalized);
|
|
195
|
+
} catch (unused) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
return safeEqual(signature, expected);
|
|
199
|
+
}
|
|
200
|
+
/** Signs a page's destinations for one site. */ export function signLeavingDestinations(hostId, destinations) {
|
|
201
|
+
const sigs = {};
|
|
202
|
+
for (const destination of destinations){
|
|
203
|
+
sigs[destination] = leavingSignature(hostId, destination);
|
|
204
|
+
}
|
|
205
|
+
return sigs;
|
|
206
|
+
}
|
|
207
|
+
/** Whether this deployment can sign at all. */ function canSign() {
|
|
208
|
+
try {
|
|
209
|
+
tokenSigningSecret();
|
|
210
|
+
return true;
|
|
211
|
+
} catch (unused) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* What a published page of this site carries, or `null` when the site is not
|
|
217
|
+
* in its window.
|
|
218
|
+
*
|
|
219
|
+
* `content` is everything the page renders from — its props, its nodes —
|
|
220
|
+
* walked for outside addresses to sign.
|
|
221
|
+
*
|
|
222
|
+
* A deployment with no signing secret gets no notice, loudly. The route
|
|
223
|
+
* could verify nothing there and would refuse every link on every new free
|
|
224
|
+
* site; an outage of every outbound link is not a safer failure than the
|
|
225
|
+
* links working as they did before this existed.
|
|
226
|
+
*/ export function leavingNoticeConfig(options) {
|
|
227
|
+
const { hostId, site, org, content } = options;
|
|
228
|
+
if (!hostId || !site) return null;
|
|
229
|
+
const until = leavingNoticeEndsAt(org, options.nowMs);
|
|
230
|
+
if (until === null) return null;
|
|
231
|
+
if (!canSign()) {
|
|
232
|
+
console.error('[leaving-notice] TOKEN_SIGNING_SECRET is not configured; the notice is off');
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
const hosts = leavingNoticeHosts(site);
|
|
236
|
+
return {
|
|
237
|
+
until,
|
|
238
|
+
hosts,
|
|
239
|
+
sigs: signLeavingDestinations(hostId, collectLeavingDestinations(content, hosts))
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Only the signatures, for a payload that arrives after its page — a gated
|
|
244
|
+
* screen's nodes (`enrichGatedScreenPage`). `null` outside the window.
|
|
245
|
+
*/ export function leavingNoticeSignatures(options) {
|
|
246
|
+
const config = leavingNoticeConfig(options);
|
|
247
|
+
return config && Object.keys(config.sigs).length ? config.sigs : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
//# sourceMappingURL=leaving-notice.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/leaving-notice.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 * THE \"YOU'RE LEAVING THIS SITE\" NOTICE, SERVER HALF (AGL-3452).\n *\n * Who gets it, which hosts are exempt, and the signature that keeps the\n * notice route from becoming an open redirect. The client half — rewriting\n * an href, the click interceptor — is `@aglyn/aglyn/app-utils/leaving-notice`.\n *\n * ## Who\n *\n * A site whose workspace is on the FREE plan (the effective plan, so a dead\n * subscription counts as free, as it does for every entitlement) and in its\n * first {@link LEAVING_NOTICE_DAYS} days — the same window, read through the\n * same `isYoungWorkspaceAge`, as the soft phishing rules. Paid and older\n * workspaces are untouched, and a workspace whose creation date cannot be\n * read is an existing customer, never a new one.\n *\n * ## Why signed\n *\n * The notice lives on the site's own host and its Continue button goes\n * wherever `?to=` says. Unsigned, any site's notice would be a page that\n * vouches for any address anyone puts in a link — the hop this feature exists\n * to remove. So the server signs each outside address it finds on a page it\n * renders, bound to that site, and the route shows a Continue button only for\n * an address whose signature checks out. Everything else is refused.\n *\n * The signature carries no expiry on purpose. The route never redirects on\n * its own, so a signature that outlives its page buys nothing but one more\n * view of the same warning — while an expiring one would break the links an\n * ISR-cached page is still serving the moment the window closes.\n *=========================================*/\n\nimport { createHmac } from 'crypto'\nimport { DOCS_BASE_URL } from '@aglyn/aglyn/app-utils/docs-help'\nimport {\n type LeavingNoticeConfig,\n leavingDestination,\n normalizeLeavingDestination,\n} from '@aglyn/aglyn/app-utils/leaving-notice'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n PLATFORM_HOME_URL,\n PLATFORM_SUPPORT_URL,\n platformConsoleOrigin,\n} from '@aglyn/aglyn/app-utils/platform-brand'\nimport {\n type SiteReturnHost,\n siteOwnHosts,\n} from '@aglyn/aglyn/app-utils/site-return-url'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/tenant-apex'\nimport {\n isYoungWorkspaceAge,\n OUTBOUND_REVIEW_YOUNG_DAYS,\n registrableDomain,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { tokenSigningSecret } from './media-signing'\nimport { orgAgeDays, orgCreatedMs } from './org-age'\nimport { safeEqual } from './safe-equal'\n\n/** How long a new free workspace's sites carry the notice. */\nexport const LEAVING_NOTICE_DAYS = OUTBOUND_REVIEW_YOUNG_DAYS\n\nconst DAY_MS = 86_400_000\n\n/**\n * The most outside addresses one page is signed for. A page with more is a\n * link farm, and the ones past the cap still go through the notice — which\n * refuses them — rather than around it.\n */\nexport const LEAVING_NOTICE_MAX_DESTINATIONS = 500\n\n/** Deepest a page payload is walked; composed node maps sit far above it. */\nconst MAX_WALK_DEPTH = 64\n\n/** Longest string scanned for embedded addresses (a long entry body). */\nconst MAX_SCANNED_STRING = 200_000\n\n/**\n * When a workspace's notice window closes (epoch ms), or `null` when its\n * sites carry no notice: a paid plan, a workspace past its first\n * {@link LEAVING_NOTICE_DAYS} days, or one whose creation date is unreadable.\n *\n * Takes the org as loaded anywhere — a live document, or the render cache's\n * JSON copy, whose `createdAt` has lost its methods (`orgCreatedMs`).\n */\nexport function leavingNoticeEndsAt(\n org: unknown,\n nowMs: number = Date.now(),\n): number | null {\n if (!org || typeof org !== 'object') return null\n if (resolveEffectivePlan(org as never) !== 'free') return null\n const createdAt = (org as { createdAt?: unknown }).createdAt\n if (!isYoungWorkspaceAge(orgAgeDays(createdAt, nowMs))) return null\n const createdMs = orgCreatedMs(createdAt)\n return createdMs === null ? null : createdMs + LEAVING_NOTICE_DAYS * DAY_MS\n}\n\nfunction hostnameOf(raw: string | null | undefined): string | null {\n if (!raw) return null\n try {\n const url = new URL(raw)\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n return url.hostname.toLowerCase() || null\n } catch {\n return null\n }\n}\n\n/**\n * The hosts a link may reach without the notice: the site's own (its\n * subdomain, its custom domain and `www.` of it) and the platform's own — the\n * marketing site, console, support and docs, each with every name under its\n * registrable domain, plus the bare tenant apex.\n *\n * Other sites on the tenant apex are NOT exempt. They are other customers'\n * sites, and a chain of two new free sites is the same hop as one.\n */\nexport function leavingNoticeHosts(\n site: SiteReturnHost | null | undefined,\n): string[] {\n const hosts = new Set<string>(siteOwnHosts(site))\n const apex = TENANT_APEX.toLowerCase()\n hosts.add(apex)\n const apexRegistrable = registrableDomain(apex)\n for (const raw of [\n PLATFORM_HOME_URL,\n platformConsoleOrigin(),\n PLATFORM_SUPPORT_URL,\n DOCS_BASE_URL,\n ]) {\n const hostname = hostnameOf(raw)\n if (!hostname) continue\n hosts.add(hostname)\n const registrable = registrableDomain(hostname)\n // A platform name under the tenant apex itself must not exempt the apex:\n // that would exempt every customer's site with it.\n if (registrable.includes('.') && registrable !== apexRegistrable) {\n hosts.add(`.${registrable}`)\n }\n }\n return [...hosts]\n}\n\n/** An address as it appears in text: scheme, then anything a URL may hold. */\nconst EMBEDDED_URL = /https?:\\/\\/[^\\s\"'<>()[\\]{}\\\\^`|]+/gi\n\n/** Characters prose puts after an address that the address does not own. */\nconst TRAILING_PROSE = /[.,;:!?'\"*_~]+$/\n\n/** A whole value shaped like an absolute or protocol-relative address. */\nconst ADDRESS_SHAPE = /^\\s*(?:https?:|[\\\\/]{2})/i\n\n/**\n * Every outside address a rendered page could link to: each string in the\n * payload that is one, and each one embedded in a string (Markdown, rich\n * text HTML, a plain sentence). Over-collecting is harmless — a signature\n * nobody follows is never read — and under-collecting only sends a link to a\n * refusal, never around the notice.\n *\n * Returned normalized, the spelling the client looks signatures up by.\n */\nexport function collectLeavingDestinations(\n value: unknown,\n hosts: readonly string[],\n limit: number = LEAVING_NOTICE_MAX_DESTINATIONS,\n): string[] {\n const found = new Set<string>()\n const consider = (candidate: string) => {\n if (found.size >= limit) return\n const destination = leavingDestination(candidate, hosts)\n if (destination) found.add(destination)\n }\n const scan = (text: string) => {\n if (ADDRESS_SHAPE.test(text)) consider(text)\n if (!text.includes('http')) return\n const scanned =\n text.length > MAX_SCANNED_STRING ? text.slice(0, MAX_SCANNED_STRING) : text\n for (const match of scanned.matchAll(EMBEDDED_URL)) {\n const raw = match[0]\n consider(raw)\n const trimmed = raw.replace(TRAILING_PROSE, '')\n if (trimmed !== raw) consider(trimmed)\n // An href inside stored HTML is entity-encoded; the DOM decodes it.\n if (trimmed.includes('&')) consider(trimmed.replace(/&/g, '&'))\n }\n }\n const walk = (node: unknown, depth: number) => {\n if (found.size >= limit || depth > MAX_WALK_DEPTH) return\n if (typeof node === 'string') {\n scan(node)\n } else if (Array.isArray(node)) {\n for (const item of node) walk(item, depth + 1)\n } else if (node && typeof node === 'object') {\n for (const item of Object.values(node)) walk(item, depth + 1)\n }\n }\n walk(value, 0)\n return [...found]\n}\n\nfunction leavingSignature(hostId: string, destination: string): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`leaving-notice:v1:${hostId}:${destination}`)\n .digest('base64url')\n}\n\n/**\n * The signature for one destination on one site. Throws when the deployment\n * has no signing secret, like every signer of it.\n */\nexport function signLeavingDestination(\n hostId: string,\n destination: string,\n): string {\n const normalized = normalizeLeavingDestination(destination)\n if (!hostId || !normalized) {\n throw new Error('A leaving-notice signature needs a site and an address')\n }\n return leavingSignature(hostId, normalized)\n}\n\n/**\n * Whether `signature` was issued for `destination` on this site. False for\n * anything else — another site's signature, an edited address, a missing\n * secret (fail closed).\n */\nexport function verifyLeavingDestination(\n hostId: string,\n destination: unknown,\n signature: unknown,\n): boolean {\n if (!hostId || typeof signature !== 'string' || signature.length > 128) {\n return false\n }\n const normalized = normalizeLeavingDestination(destination)\n if (!normalized) return false\n let expected: string\n try {\n expected = leavingSignature(hostId, normalized)\n } catch {\n return false\n }\n return safeEqual(signature, expected)\n}\n\n/** Signs a page's destinations for one site. */\nexport function signLeavingDestinations(\n hostId: string,\n destinations: readonly string[],\n): Record<string, string> {\n const sigs: Record<string, string> = {}\n for (const destination of destinations) {\n sigs[destination] = leavingSignature(hostId, destination)\n }\n return sigs\n}\n\n/** Whether this deployment can sign at all. */\nfunction canSign(): boolean {\n try {\n tokenSigningSecret()\n return true\n } catch {\n return false\n }\n}\n\n/**\n * What a published page of this site carries, or `null` when the site is not\n * in its window.\n *\n * `content` is everything the page renders from — its props, its nodes —\n * walked for outside addresses to sign.\n *\n * A deployment with no signing secret gets no notice, loudly. The route\n * could verify nothing there and would refuse every link on every new free\n * site; an outage of every outbound link is not a safer failure than the\n * links working as they did before this existed.\n */\nexport function leavingNoticeConfig(options: {\n hostId: string | null | undefined\n site: SiteReturnHost | null | undefined\n org: unknown\n content?: unknown\n nowMs?: number\n}): LeavingNoticeConfig | null {\n const { hostId, site, org, content } = options\n if (!hostId || !site) return null\n const until = leavingNoticeEndsAt(org, options.nowMs)\n if (until === null) return null\n if (!canSign()) {\n console.error(\n '[leaving-notice] TOKEN_SIGNING_SECRET is not configured; the notice is off',\n )\n return null\n }\n const hosts = leavingNoticeHosts(site)\n return {\n until,\n hosts,\n sigs: signLeavingDestinations(\n hostId,\n collectLeavingDestinations(content, hosts),\n ),\n }\n}\n\n/**\n * Only the signatures, for a payload that arrives after its page — a gated\n * screen's nodes (`enrichGatedScreenPage`). `null` outside the window.\n */\nexport function leavingNoticeSignatures(options: {\n hostId: string | null | undefined\n site: SiteReturnHost | null | undefined\n org: unknown\n content: unknown\n nowMs?: number\n}): Record<string, string> | null {\n const config = leavingNoticeConfig(options)\n return config && Object.keys(config.sigs).length ? config.sigs : null\n}\n"],"names":["createHmac","DOCS_BASE_URL","leavingDestination","normalizeLeavingDestination","resolveEffectivePlan","PLATFORM_HOME_URL","PLATFORM_SUPPORT_URL","platformConsoleOrigin","siteOwnHosts","TENANT_APEX","isYoungWorkspaceAge","OUTBOUND_REVIEW_YOUNG_DAYS","registrableDomain","tokenSigningSecret","orgAgeDays","orgCreatedMs","safeEqual","LEAVING_NOTICE_DAYS","DAY_MS","LEAVING_NOTICE_MAX_DESTINATIONS","MAX_WALK_DEPTH","MAX_SCANNED_STRING","leavingNoticeEndsAt","org","nowMs","Date","now","createdAt","createdMs","hostnameOf","raw","url","URL","protocol","hostname","toLowerCase","leavingNoticeHosts","site","hosts","Set","apex","add","apexRegistrable","registrable","includes","EMBEDDED_URL","TRAILING_PROSE","ADDRESS_SHAPE","collectLeavingDestinations","value","limit","found","consider","candidate","size","destination","scan","text","test","scanned","length","slice","match","matchAll","trimmed","replace","walk","node","depth","Array","isArray","item","Object","values","leavingSignature","hostId","update","digest","signLeavingDestination","normalized","Error","verifyLeavingDestination","signature","expected","signLeavingDestinations","destinations","sigs","canSign","leavingNoticeConfig","options","content","until","console","error","leavingNoticeSignatures","config","keys"],"mappings":"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"}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
/** What one chunk of the re-check did. */
|
|
18
|
+
export interface LivePageLinkRecheck {
|
|
19
|
+
/** Sites this chunk walked. */
|
|
20
|
+
sites: number;
|
|
21
|
+
/** Live pages with foreign links among them. */
|
|
22
|
+
pages: number;
|
|
23
|
+
/** Distinct foreign hosts looked up. */
|
|
24
|
+
hosts: number;
|
|
25
|
+
/** Of those, how many Web Risk lists. */
|
|
26
|
+
listed: number;
|
|
27
|
+
/** Hosts nobody could answer for: not evidence, looked up again next time. */
|
|
28
|
+
unknown: number;
|
|
29
|
+
/** Pages sent back through the review with a listed host. */
|
|
30
|
+
reviewed: number;
|
|
31
|
+
/** Of those, how many the review held (or found already rejected). */
|
|
32
|
+
held: number;
|
|
33
|
+
/** The sites a hold was placed on, whose cached pages need dropping. */
|
|
34
|
+
heldHostIds: string[];
|
|
35
|
+
nextCursor: string | null;
|
|
36
|
+
done: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** Sites walked per chunk. */
|
|
39
|
+
export declare const LINK_RECHECK_SITES_PER_CHUNK = 200;
|
|
40
|
+
/**
|
|
41
|
+
* Re-check the foreign hosts of every live page, a chunk of sites at a time
|
|
42
|
+
* (`cursor` resumes after the last site of the previous chunk). A site that
|
|
43
|
+
* is locked is skipped: nothing of it serves. A page with a newly listed host
|
|
44
|
+
* goes back through {@link reviewHostedPage} with that listing as an extra
|
|
45
|
+
* signal, so what a hold does is decided in one place.
|
|
46
|
+
*/
|
|
47
|
+
export declare function recheckLivePageLinks(options?: {
|
|
48
|
+
cursor?: string | null;
|
|
49
|
+
sitesPerChunk?: number;
|
|
50
|
+
nowMs?: number;
|
|
51
|
+
firestore?: FirebaseFirestore.Firestore;
|
|
52
|
+
/** Walk and count only: no lookup, no review. */
|
|
53
|
+
dryRun?: boolean;
|
|
54
|
+
}): Promise<LivePageLinkRecheck>;
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
* THE DAILY LINK RE-CHECK (AGL-3451).
|
|
19
|
+
*
|
|
20
|
+
* The page review (`hosted-page-review.ts`), the redirect review and the send
|
|
21
|
+
* seam look up every foreign host a page, a redirect or a message points at
|
|
22
|
+
* as it goes out. What is already live is not re-reviewed until somebody
|
|
23
|
+
* publishes it again — and a harvester is often listed only after the page
|
|
24
|
+
* linking to it went up.
|
|
25
|
+
*
|
|
26
|
+
* So {@link recheckLivePageLinks} walks every live page's foreign hosts, as
|
|
27
|
+
* the review noted them beside the version it served
|
|
28
|
+
* (`pageReviews/{screenId}.foreignHosts`), looks them up again, and sends
|
|
29
|
+
* each page with a newly listed host back through `reviewHostedPage` with the
|
|
30
|
+
* listing as an extra signal — the same path, so it is held or flagged, its
|
|
31
|
+
* row filed, and a young workspace's security hold requested (AGL-3450),
|
|
32
|
+
* exactly as at render. The console's `/api/admin/web-risk-recheck` runs it
|
|
33
|
+
* a chunk of sites at a time.
|
|
34
|
+
*=========================================*/ import { MAX_REPUTATION_HOSTS_PER_LOOKUP, webRiskSignals } from "@aglyn/shared-util-email/link-reputation";
|
|
35
|
+
import { FieldPath } from "firebase-admin/firestore";
|
|
36
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
37
|
+
import { PAGE_REVIEW_SUBCOLLECTION, reviewHostedPage } from "./hosted-page-review.js";
|
|
38
|
+
import { lookupHostReputation } from "./web-risk.js";
|
|
39
|
+
/** Sites walked per chunk. */ export const LINK_RECHECK_SITES_PER_CHUNK = 200;
|
|
40
|
+
/** Sites whose page notes are read at once. */ const SITE_READ_CONCURRENCY = 20;
|
|
41
|
+
/** The budget of each group of hosts looked up within a chunk. */ const RECHECK_DEADLINE_MS = 15000;
|
|
42
|
+
/**
|
|
43
|
+
* Re-check the foreign hosts of every live page, a chunk of sites at a time
|
|
44
|
+
* (`cursor` resumes after the last site of the previous chunk). A site that
|
|
45
|
+
* is locked is skipped: nothing of it serves. A page with a newly listed host
|
|
46
|
+
* goes back through {@link reviewHostedPage} with that listing as an extra
|
|
47
|
+
* signal, so what a hold does is decided in one place.
|
|
48
|
+
*/ export async function recheckLivePageLinks(options = {}) {
|
|
49
|
+
var _options_firestore, _options_nowMs, _options_sitesPerChunk, _ref;
|
|
50
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
51
|
+
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
52
|
+
const limit = (_options_sitesPerChunk = options.sitesPerChunk) != null ? _options_sitesPerChunk : LINK_RECHECK_SITES_PER_CHUNK;
|
|
53
|
+
let query = firestore.collection('hosts').orderBy(FieldPath.documentId()).select('suspendedAt').limit(limit);
|
|
54
|
+
if (options.cursor) query = query.startAfter(options.cursor);
|
|
55
|
+
const sites = await query.get();
|
|
56
|
+
const pages = [];
|
|
57
|
+
const live = sites.docs.filter((site)=>site.get('suspendedAt') == null);
|
|
58
|
+
for(let at = 0; at < live.length; at += SITE_READ_CONCURRENCY){
|
|
59
|
+
const batch = live.slice(at, at + SITE_READ_CONCURRENCY);
|
|
60
|
+
const notes = await Promise.all(batch.map((site)=>site.ref.collection(PAGE_REVIEW_SUBCOLLECTION).where('foreignHostCount', '>', 0).limit(500).get().then((reviews)=>({
|
|
61
|
+
site,
|
|
62
|
+
reviews
|
|
63
|
+
}))));
|
|
64
|
+
for (const { site, reviews } of notes){
|
|
65
|
+
for (const review of reviews.docs){
|
|
66
|
+
const versionId = review.get('servedVersionId');
|
|
67
|
+
const hosts = review.get('foreignHosts');
|
|
68
|
+
if (typeof versionId !== 'string' || !versionId || !Array.isArray(hosts)) continue;
|
|
69
|
+
pages.push({
|
|
70
|
+
hostId: site.id,
|
|
71
|
+
screenId: review.id,
|
|
72
|
+
versionId,
|
|
73
|
+
hosts: hosts.filter((host)=>typeof host === 'string')
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const distinct = [
|
|
79
|
+
...new Set(pages.flatMap((page)=>page.hosts))
|
|
80
|
+
];
|
|
81
|
+
const last = sites.docs[sites.docs.length - 1];
|
|
82
|
+
const chunk = {
|
|
83
|
+
sites: sites.size,
|
|
84
|
+
pages: pages.length,
|
|
85
|
+
hosts: distinct.length,
|
|
86
|
+
nextCursor: sites.size < limit ? null : (_ref = last == null ? void 0 : last.id) != null ? _ref : null,
|
|
87
|
+
done: sites.size < limit
|
|
88
|
+
};
|
|
89
|
+
if (options.dryRun) {
|
|
90
|
+
return _extends({}, chunk, {
|
|
91
|
+
listed: 0,
|
|
92
|
+
unknown: 0,
|
|
93
|
+
reviewed: 0,
|
|
94
|
+
held: 0,
|
|
95
|
+
heldHostIds: []
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const listed = new Map();
|
|
99
|
+
let unknown = 0;
|
|
100
|
+
for(let at = 0; at < distinct.length; at += MAX_REPUTATION_HOSTS_PER_LOOKUP){
|
|
101
|
+
const answer = await lookupHostReputation(distinct.slice(at, at + MAX_REPUTATION_HOSTS_PER_LOOKUP), {
|
|
102
|
+
deadlineMs: RECHECK_DEADLINE_MS,
|
|
103
|
+
nowMs
|
|
104
|
+
});
|
|
105
|
+
for (const hit of answer.hits)listed.set(hit.host, hit);
|
|
106
|
+
unknown += answer.unknown.length;
|
|
107
|
+
}
|
|
108
|
+
let reviewed = 0;
|
|
109
|
+
let held = 0;
|
|
110
|
+
const heldHostIds = new Set();
|
|
111
|
+
for (const page of pages){
|
|
112
|
+
const hits = page.hosts.map((host)=>listed.get(host)).filter(Boolean);
|
|
113
|
+
if (!hits.length) continue;
|
|
114
|
+
reviewed += 1;
|
|
115
|
+
try {
|
|
116
|
+
const outcome = await reviewHostedPage({
|
|
117
|
+
hostId: page.hostId,
|
|
118
|
+
screenId: page.screenId,
|
|
119
|
+
versionId: page.versionId,
|
|
120
|
+
// The page itself is not composed here: the listing is the finding.
|
|
121
|
+
nodes: {},
|
|
122
|
+
extraSignals: webRiskSignals(hits),
|
|
123
|
+
nowMs
|
|
124
|
+
});
|
|
125
|
+
if (outcome.outcome !== 'serve') {
|
|
126
|
+
held += 1;
|
|
127
|
+
heldHostIds.add(page.hostId);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error('[link-reputation] a live page could not be re-reviewed', page.hostId, page.screenId, error);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return _extends({}, chunk, {
|
|
134
|
+
listed: listed.size,
|
|
135
|
+
unknown,
|
|
136
|
+
reviewed,
|
|
137
|
+
held,
|
|
138
|
+
heldHostIds: [
|
|
139
|
+
...heldHostIds
|
|
140
|
+
]
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
//# sourceMappingURL=link-reputation-review.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/link-reputation-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 * THE DAILY LINK RE-CHECK (AGL-3451).\n *\n * The page review (`hosted-page-review.ts`), the redirect review and the send\n * seam look up every foreign host a page, a redirect or a message points at\n * as it goes out. What is already live is not re-reviewed until somebody\n * publishes it again — and a harvester is often listed only after the page\n * linking to it went up.\n *\n * So {@link recheckLivePageLinks} walks every live page's foreign hosts, as\n * the review noted them beside the version it served\n * (`pageReviews/{screenId}.foreignHosts`), looks them up again, and sends\n * each page with a newly listed host back through `reviewHostedPage` with the\n * listing as an extra signal — the same path, so it is held or flagged, its\n * row filed, and a young workspace's security hold requested (AGL-3450),\n * exactly as at render. The console's `/api/admin/web-risk-recheck` runs it\n * a chunk of sites at a time.\n *=========================================*/\n\nimport {\n type HostReputationHit,\n MAX_REPUTATION_HOSTS_PER_LOOKUP,\n webRiskSignals,\n} from '@aglyn/shared-util-email/link-reputation'\nimport { FieldPath } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { PAGE_REVIEW_SUBCOLLECTION, reviewHostedPage } from './hosted-page-review'\nimport { lookupHostReputation } from './web-risk'\n\n/** One live page whose foreign hosts the review noted. */\ninterface LivePageLinks {\n hostId: string\n screenId: string\n versionId: string\n hosts: string[]\n}\n\n/** What one chunk of the re-check did. */\nexport interface LivePageLinkRecheck {\n /** Sites this chunk walked. */\n sites: number\n /** Live pages with foreign links among them. */\n pages: number\n /** Distinct foreign hosts looked up. */\n hosts: number\n /** Of those, how many Web Risk lists. */\n listed: number\n /** Hosts nobody could answer for: not evidence, looked up again next time. */\n unknown: number\n /** Pages sent back through the review with a listed host. */\n reviewed: number\n /** Of those, how many the review held (or found already rejected). */\n held: number\n /** The sites a hold was placed on, whose cached pages need dropping. */\n heldHostIds: string[]\n nextCursor: string | null\n done: boolean\n}\n\n/** Sites walked per chunk. */\nexport const LINK_RECHECK_SITES_PER_CHUNK = 200\n/** Sites whose page notes are read at once. */\nconst SITE_READ_CONCURRENCY = 20\n/** The budget of each group of hosts looked up within a chunk. */\nconst RECHECK_DEADLINE_MS = 15_000\n\n/**\n * Re-check the foreign hosts of every live page, a chunk of sites at a time\n * (`cursor` resumes after the last site of the previous chunk). A site that\n * is locked is skipped: nothing of it serves. A page with a newly listed host\n * goes back through {@link reviewHostedPage} with that listing as an extra\n * signal, so what a hold does is decided in one place.\n */\nexport async function recheckLivePageLinks(\n options: {\n cursor?: string | null\n sitesPerChunk?: number\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n /** Walk and count only: no lookup, no review. */\n dryRun?: boolean\n } = {},\n): Promise<LivePageLinkRecheck> {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const nowMs = options.nowMs ?? Date.now()\n const limit = options.sitesPerChunk ?? LINK_RECHECK_SITES_PER_CHUNK\n let query = firestore\n .collection('hosts')\n .orderBy(FieldPath.documentId())\n .select('suspendedAt')\n .limit(limit)\n if (options.cursor) query = query.startAfter(options.cursor)\n const sites = await query.get()\n\n const pages: LivePageLinks[] = []\n const live = sites.docs.filter((site) => site.get('suspendedAt') == null)\n for (let at = 0; at < live.length; at += SITE_READ_CONCURRENCY) {\n const batch = live.slice(at, at + SITE_READ_CONCURRENCY)\n const notes = await Promise.all(\n batch.map((site) =>\n site.ref\n .collection(PAGE_REVIEW_SUBCOLLECTION)\n .where('foreignHostCount', '>', 0)\n .limit(500)\n .get()\n .then((reviews) => ({ site, reviews })),\n ),\n )\n for (const { site, reviews } of notes) {\n for (const review of reviews.docs) {\n const versionId = review.get('servedVersionId')\n const hosts = review.get('foreignHosts')\n if (typeof versionId !== 'string' || !versionId || !Array.isArray(hosts)) continue\n pages.push({\n hostId: site.id,\n screenId: review.id,\n versionId,\n hosts: hosts.filter((host): host is string => typeof host === 'string'),\n })\n }\n }\n }\n\n const distinct = [...new Set(pages.flatMap((page) => page.hosts))]\n const last = sites.docs[sites.docs.length - 1]\n const chunk = {\n sites: sites.size,\n pages: pages.length,\n hosts: distinct.length,\n nextCursor: sites.size < limit ? null : (last?.id ?? null),\n done: sites.size < limit,\n }\n if (options.dryRun) {\n return { ...chunk, listed: 0, unknown: 0, reviewed: 0, held: 0, heldHostIds: [] }\n }\n const listed = new Map<string, HostReputationHit>()\n let unknown = 0\n for (let at = 0; at < distinct.length; at += MAX_REPUTATION_HOSTS_PER_LOOKUP) {\n const answer = await lookupHostReputation(\n distinct.slice(at, at + MAX_REPUTATION_HOSTS_PER_LOOKUP),\n { deadlineMs: RECHECK_DEADLINE_MS, nowMs },\n )\n for (const hit of answer.hits) listed.set(hit.host, hit)\n unknown += answer.unknown.length\n }\n\n let reviewed = 0\n let held = 0\n const heldHostIds = new Set<string>()\n for (const page of pages) {\n const hits = page.hosts.map((host) => listed.get(host)).filter(Boolean) as HostReputationHit[]\n if (!hits.length) continue\n reviewed += 1\n try {\n const outcome = await reviewHostedPage({\n hostId: page.hostId,\n screenId: page.screenId,\n versionId: page.versionId,\n // The page itself is not composed here: the listing is the finding.\n nodes: {},\n extraSignals: webRiskSignals(hits),\n nowMs,\n })\n if (outcome.outcome !== 'serve') {\n held += 1\n heldHostIds.add(page.hostId)\n }\n } catch (error) {\n console.error('[link-reputation] a live page could not be re-reviewed', page.hostId, page.screenId, error)\n }\n }\n\n return {\n ...chunk,\n listed: listed.size,\n unknown,\n reviewed,\n held,\n heldHostIds: [...heldHostIds],\n }\n}\n"],"names":["MAX_REPUTATION_HOSTS_PER_LOOKUP","webRiskSignals","FieldPath","firebaseAdmin","PAGE_REVIEW_SUBCOLLECTION","reviewHostedPage","lookupHostReputation","LINK_RECHECK_SITES_PER_CHUNK","SITE_READ_CONCURRENCY","RECHECK_DEADLINE_MS","recheckLivePageLinks","options","firestore","app","nowMs","Date","now","limit","sitesPerChunk","query","collection","orderBy","documentId","select","cursor","startAfter","sites","get","pages","live","docs","filter","site","at","length","batch","slice","notes","Promise","all","map","ref","where","then","reviews","review","versionId","hosts","Array","isArray","push","hostId","id","screenId","host","distinct","Set","flatMap","page","last","chunk","size","nextCursor","done","dryRun","listed","unknown","reviewed","held","heldHostIds","Map","answer","deadlineMs","hit","hits","set","Boolean","outcome","nodes","extraSignals","add","error","console"],"mappings":"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"}
|
|
@@ -15,13 +15,22 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
* be.
|
|
18
|
+
* When a workspace was created, in epoch ms, from whatever its `createdAt`
|
|
19
|
+
* turns out to be — or `null` when it cannot be read.
|
|
20
20
|
*
|
|
21
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.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* number, and an org written before the field existed has nothing. A
|
|
23
|
+
* `Timestamp` that has been through JSON — the render cache stores plain
|
|
24
|
+
* values, so the tenant's copy of an org arrives this way — is a bare
|
|
25
|
+
* `{ _seconds, _nanoseconds }` with no methods left, and is read like the
|
|
26
|
+
* live one rather than as missing (AGL-3452).
|
|
27
|
+
*/
|
|
28
|
+
export declare function orgCreatedMs(createdAt: unknown): number | null;
|
|
29
|
+
/**
|
|
30
|
+
* How old a workspace is, in days, from whatever its `createdAt` turns out to
|
|
31
|
+
* be (see {@link orgCreatedMs}).
|
|
32
|
+
*
|
|
33
|
+
* An unreadable date answers `null`, which `claimOrgEmailSendDay` reads as
|
|
34
|
+
* graduated — the only safe direction, for the reason stated there.
|
|
26
35
|
*/
|
|
27
36
|
export declare function orgAgeDays(createdAt: unknown, now?: number): number | null;
|