@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.222

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-keys.js +3 -3
  3. package/src/lib/server/api-keys.js.map +1 -1
  4. package/src/lib/server/api-v1-resources.d.ts +44 -3
  5. package/src/lib/server/api-v1-resources.js +82 -5
  6. package/src/lib/server/api-v1-resources.js.map +1 -1
  7. package/src/lib/server/collection-live-pages.d.ts +2 -2
  8. package/src/lib/server/collection-live-pages.js +1 -1
  9. package/src/lib/server/collection-live-pages.js.map +1 -1
  10. package/src/lib/server/email-delivery-log.d.ts +17 -73
  11. package/src/lib/server/email-delivery-log.js +44 -222
  12. package/src/lib/server/email-delivery-log.js.map +1 -1
  13. package/src/lib/server/email-flow-gate.js +27 -38
  14. package/src/lib/server/email-flow-gate.js.map +1 -1
  15. package/src/lib/server/erase-person.d.ts +34 -54
  16. package/src/lib/server/erase-person.js +44 -239
  17. package/src/lib/server/erase-person.js.map +1 -1
  18. package/src/lib/server/host-visitor-records.d.ts +12 -4
  19. package/src/lib/server/host-visitor-records.js +20 -10
  20. package/src/lib/server/host-visitor-records.js.map +1 -1
  21. package/src/lib/server/hosted-page-review.js +46 -33
  22. package/src/lib/server/hosted-page-review.js.map +1 -1
  23. package/src/lib/server/link-reputation-review.d.ts +6 -2
  24. package/src/lib/server/link-reputation-review.js +61 -20
  25. package/src/lib/server/link-reputation-review.js.map +1 -1
  26. package/src/lib/server/live-page-drops.d.ts +67 -0
  27. package/src/lib/server/live-page-drops.js +57 -0
  28. package/src/lib/server/live-page-drops.js.map +1 -0
  29. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  30. package/src/lib/server/media-bandwidth-cap.js +180 -0
  31. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  32. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  33. package/src/lib/server/media-cdn-icon.js +140 -0
  34. package/src/lib/server/media-cdn-icon.js.map +1 -0
  35. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  36. package/src/lib/server/media-delivery-regeneration.js +216 -0
  37. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  38. package/src/lib/server/media-delivery.d.ts +2 -0
  39. package/src/lib/server/media-delivery.js +2 -1
  40. package/src/lib/server/media-delivery.js.map +1 -1
  41. package/src/lib/server/media-serve-count.d.ts +50 -0
  42. package/src/lib/server/media-serve-count.js +62 -0
  43. package/src/lib/server/media-serve-count.js.map +1 -0
  44. package/src/lib/server/media-signing.d.ts +13 -2
  45. package/src/lib/server/media-signing.js +12 -8
  46. package/src/lib/server/media-signing.js.map +1 -1
  47. package/src/lib/server/media-tombstone.js +7 -5
  48. package/src/lib/server/media-tombstone.js.map +1 -1
  49. package/src/lib/server/media-variants.d.ts +151 -20
  50. package/src/lib/server/media-variants.js +204 -27
  51. package/src/lib/server/media-variants.js.map +1 -1
  52. package/src/lib/server/organizations.d.ts +102 -2
  53. package/src/lib/server/organizations.js +388 -58
  54. package/src/lib/server/organizations.js.map +1 -1
  55. package/src/lib/server/outbound-send-review.js +5 -4
  56. package/src/lib/server/outbound-send-review.js.map +1 -1
  57. package/src/lib/server/paged-sweeps.d.ts +51 -0
  58. package/src/lib/server/paged-sweeps.js +79 -0
  59. package/src/lib/server/paged-sweeps.js.map +1 -0
  60. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  61. package/src/lib/server/platform-marketing-consent.js +27 -11
  62. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  63. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  64. package/src/lib/server/serve-media-cdn.js +424 -70
  65. package/src/lib/server/serve-media-cdn.js.map +1 -1
  66. package/src/lib/server/site-icon-render.d.ts +68 -0
  67. package/src/lib/server/site-icon-render.js +157 -0
  68. package/src/lib/server/site-icon-render.js.map +1 -0
  69. package/src/lib/server/staff-seat.d.ts +50 -0
  70. package/src/lib/server/staff-seat.js +118 -0
  71. package/src/lib/server/staff-seat.js.map +1 -0
  72. package/src/lib/server/transfer-export.d.ts +46 -0
  73. package/src/lib/server/transfer-export.js +221 -0
  74. package/src/lib/server/transfer-export.js.map +1 -0
  75. package/src/lib/server/transfer-jobs.d.ts +385 -0
  76. package/src/lib/server/transfer-jobs.js +2368 -0
  77. package/src/lib/server/transfer-jobs.js.map +1 -0
  78. package/src/lib/server/transfer-packages.d.ts +127 -0
  79. package/src/lib/server/transfer-packages.js +1036 -0
  80. package/src/lib/server/transfer-packages.js.map +1 -0
  81. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  82. package/src/lib/server/upgrade-proposal.js +312 -0
  83. package/src/lib/server/upgrade-proposal.js.map +1 -0
  84. package/src/lib/server/upsert-contact.d.ts +17 -8
  85. package/src/lib/server/upsert-contact.js +82 -16
  86. package/src/lib/server/upsert-contact.js.map +1 -1
  87. package/src/lib/server/web-risk.d.ts +48 -9
  88. package/src/lib/server/web-risk.js +281 -143
  89. package/src/lib/server/web-risk.js.map +1 -1
  90. package/src/tenancy.d.ts +3 -1
  91. package/src/tenancy.js +5 -1
  92. package/src/tenancy.js.map +1 -1
  93. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  94. package/src/lib/server/campaign-attribution-store.js +0 -95
  95. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  96. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  97. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  98. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  99. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  100. package/src/lib/server/dataset-live-pages.js +0 -421
  101. package/src/lib/server/dataset-live-pages.js.map +0 -1
  102. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  103. package/src/lib/server/email-revenue-attribution.js +0 -321
  104. package/src/lib/server/email-revenue-attribution.js.map +0 -1
@@ -19,7 +19,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
19
19
  *
20
20
  * `@aglyn/shared-util-email/link-reputation` decides which hosts are foreign
21
21
  * and turns a listing into a strong `web-risk-link` signal. This is the store
22
- * half: the `uris.search` client, the cache, the kill switch.
22
+ * half: the `uris.search` client, the cache, the switches.
23
23
  *
24
24
  * ## The client
25
25
  *
@@ -29,52 +29,81 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
29
29
  * SOCIAL_ENGINEERING, MALWARE and UNWANTED_SOFTWARE as the platform's own
30
30
  * service account — the firebase-admin credential every other server-side
31
31
  * Google call here already uses (`client-error-report.ts`), so there is no
32
- * key of its own. Only `https://<host>/` is ever sent: see the module header
33
- * of `link-reputation.ts`.
32
+ * key of its own. What is sent depends on the lookup mode, below.
33
+ *
34
+ * ## The lookup mode (AGL-3459)
35
+ *
36
+ * `platformSettings/webRisk.lookupMode`:
37
+ *
38
+ * - `'host'` — the default, and anything that is not `'url'`. Only
39
+ * `https://<host>/` is ever sent.
40
+ * - `'url'` — the host first, from the cache or a call; then, for a host
41
+ * that is not itself listed, each of its links' addresses as
42
+ * `normalizeReputationLink` reduced them: scheme, host and path, never a
43
+ * query string, a fragment or a `user:password@`. A listed address is a
44
+ * hit on a clean host, so a kit at a path on a compromised site holds the
45
+ * page. At most `MAX_REPUTATION_URLS_PER_LOOKUP` addresses per lookup,
46
+ * inside the same deadline as the hosts.
47
+ *
48
+ * `'url'` sends more than a host to Google, so it stays off until the
49
+ * Subprocessors page names that purpose — see the `webrisk.googleapis.com`
50
+ * entry in `apps/console/constants/subprocessor-inventory.ts`.
34
51
  *
35
52
  * ## Not evidence when it fails
36
53
  *
37
54
  * A call is given {@link WEB_RISK_TIMEOUT_MS}, and a whole lookup
38
- * {@link WEB_RISK_DEADLINE_MS}. A host whose answer did not arrive, or whose
39
- * call failed, is UNKNOWN: it holds nothing and logs a warning. A credential
40
- * the API refuses (the API is not enabled on a self-hosted project, say) backs
41
- * the lookup off for a quarter of an hour, so a misconfiguration is one log
42
- * line rather than one per page.
55
+ * {@link WEB_RISK_DEADLINE_MS} — hosts and addresses together. A host or an
56
+ * address whose answer did not arrive, or whose call failed, is UNKNOWN: it
57
+ * holds nothing and logs a warning. A credential the API refuses (the API is
58
+ * not enabled on a self-hosted project, say) backs the lookup off for a
59
+ * quarter of an hour, so a misconfiguration is one log line rather than one
60
+ * per page.
43
61
  *
44
62
  * ## The cache
45
63
  *
46
- * Per host, in two layers:
64
+ * Per host and per address, in two layers, with the same rules for both:
47
65
  *
48
- * - this process's memory — a listed host until the answer's `expireTime`,
49
- * which Web Risk sets and which may not be exceeded; a clean one for
66
+ * - this process's memory — a listing until the answer's `expireTime`,
67
+ * which Web Risk sets and which may not be exceeded; a clean answer for
50
68
  * {@link WEB_RISK_MEMORY_CLEAN_TTL_MS}, short so a listing the daily
51
- * re-check writes reaches every warm render within minutes;
52
- * - `webRiskVerdicts/{host}` — server-only, one document per host, a clean
53
- * answer kept {@link WEB_RISK_CLEAN_TTL_MS}. Bounded by the number of
54
- * distinct foreign hosts and rewritten in place, so it needs no TTL policy.
69
+ * re-check writes reaches every warm render within minutes. Capped at
70
+ * {@link WEB_RISK_MEMORY_MAX_ENTRIES}, then started again;
71
+ * - the store, server-only, a clean answer kept {@link WEB_RISK_CLEAN_TTL_MS}:
72
+ * `webRiskVerdicts/{host}`, bounded by the number of distinct foreign
73
+ * hosts and rewritten in place; and `webRiskUrlVerdicts/{sha256(address)}`,
74
+ * whose answers the daily re-check deletes a day after they expire
75
+ * ({@link reapExpiredWebRiskUrlVerdicts}), because addresses are many more
76
+ * than hosts and a path is worth keeping no longer than it is used.
55
77
  *
56
- * Read in one `getAll` per lookup, so a page's twenty hosts cost one round
57
- * trip, and nothing at all once they are in memory.
78
+ * Read in one `getAll` per lookup, so a page's hosts and addresses cost one
79
+ * round trip, and nothing at all once they are in memory.
58
80
  *
59
81
  * ## The kill switch
60
82
  *
61
83
  * `platformSettings/webRisk` `{ enabled: false }` stops every lookup within a
62
84
  * minute — every host reads unknown, so nothing holds on it — without a
63
85
  * deploy. Absent or anything else means on.
64
- *=========================================*/ import { TENANT_APEX } from "@aglyn/aglyn/app-utils/tenant-apex";
65
- import { LINK_THREAT_TYPES, MAX_REPUTATION_HOSTS_PER_LOOKUP, normalizeReputationHost, setLinkReputationLookup } from "@aglyn/shared-util-email/link-reputation";
86
+ *=========================================*/ import { createHash } from "crypto";
87
+ import { TENANT_APEX } from "@aglyn/aglyn/app-utils/tenant-apex";
88
+ import { LINK_THREAT_TYPES, MAX_REPUTATION_HOSTS_PER_LOOKUP, MAX_REPUTATION_URLS_PER_LOOKUP, normalizeReputationHost, normalizeReputationLink, pickReputationUrls, setLinkReputationLookup } from "@aglyn/shared-util-email/link-reputation";
66
89
  import { getApp } from "firebase-admin/app";
67
90
  import firebaseAdmin from "./firebase-admin.js";
68
91
  export const WEB_RISK_SEARCH_ENDPOINT = 'https://webrisk.googleapis.com/v1/uris:search';
69
- export const WEB_RISK_CACHE_COLLECTION = 'webRiskVerdicts';
70
- /** The kill switch: `{ enabled: false }` turns every lookup off. */ export const WEB_RISK_SETTINGS_COLLECTION = 'platformSettings';
92
+ /** One answer per host. */ export const WEB_RISK_CACHE_COLLECTION = 'webRiskVerdicts';
93
+ /** One answer per address, in `'url'` mode (AGL-3459), keyed by the address's SHA-256. */ export const WEB_RISK_URL_CACHE_COLLECTION = 'webRiskUrlVerdicts';
94
+ /**
95
+ * The switches: `{ enabled: false }` turns every lookup off;
96
+ * `lookupMode: 'url'` looks up links' addresses as well as their hosts.
97
+ */ export const WEB_RISK_SETTINGS_COLLECTION = 'platformSettings';
71
98
  export const WEB_RISK_SETTINGS_DOC = 'webRisk';
72
99
  /** One call's budget. */ export const WEB_RISK_TIMEOUT_MS = 1000;
73
- /** One lookup's budget, however many hosts it asks about. */ export const WEB_RISK_DEADLINE_MS = 1200;
100
+ /** One lookup's budget, however many hosts and addresses it asks about. */ export const WEB_RISK_DEADLINE_MS = 1200;
74
101
  /** How long a clean answer is kept in the store. */ export const WEB_RISK_CLEAN_TTL_MS = 12 * 60 * 60000;
75
102
  /** How long a clean answer is trusted from this process's memory. */ export const WEB_RISK_MEMORY_CLEAN_TTL_MS = 10 * 60000;
76
103
  /** A listing whose answer named no `expireTime` is kept this long. */ export const WEB_RISK_LISTED_FALLBACK_TTL_MS = 5 * 60000;
77
- /** How long the switch's answer is trusted. */ const SWITCH_TTL_MS = 60000;
104
+ /** The most answers this process remembers before it starts again. */ export const WEB_RISK_MEMORY_MAX_ENTRIES = 20000;
105
+ /** How long past its expiry an address's stored answer is deleted. */ export const WEB_RISK_URL_VERDICT_GRACE_MS = 24 * 60 * 60000;
106
+ /** How long the switches' answer is trusted. */ const SWITCH_TTL_MS = 60000;
78
107
  /** How long a refused credential stops the lookup. */ const REFUSED_BACKOFF_MS = 15 * 60000;
79
108
  /** How long a rate limit stops the lookup. */ const THROTTLED_BACKOFF_MS = 60000;
80
109
  /** At most one warning per this long, so an outage is not a log flood. */ const WARN_EVERY_MS = 60000;
@@ -163,13 +192,13 @@ function activeClient() {
163
192
  });
164
193
  return defaultClient;
165
194
  }
166
- const memory = new Map();
167
- let switchMemo = null;
195
+ /** Hosts and addresses share one memory: an address always holds `://`, a host never does. */ const memory = new Map();
196
+ let settingsMemo = null;
168
197
  let backoffUntilMs = 0;
169
198
  let lastWarnAtMs = 0;
170
- /** Test seam: forget the caches, the switch, the back-off; optionally stand in a client. */ export function resetWebRiskForTests(client) {
199
+ /** Test seam: forget the caches, the switches, the back-off; optionally stand in a client. */ export function resetWebRiskForTests(client) {
171
200
  memory.clear();
172
- switchMemo = null;
201
+ settingsMemo = null;
173
202
  backoffUntilMs = 0;
174
203
  lastWarnAtMs = 0;
175
204
  clientOverride = client;
@@ -178,16 +207,31 @@ let lastWarnAtMs = 0;
178
207
  function warn(nowMs, message, error) {
179
208
  if (nowMs - lastWarnAtMs < WARN_EVERY_MS) return;
180
209
  lastWarnAtMs = nowMs;
181
- console.warn(`[web-risk] ${message} — the host reads unknown, which is not evidence`, error != null ? error : '');
210
+ console.warn(`[web-risk] ${message} — it reads unknown, which is not evidence`, error != null ? error : '');
182
211
  }
183
- /** Is the lookup switched off? Read at most once a minute; an unreadable switch is on. */ async function switchedOff(nowMs) {
184
- if (switchMemo && nowMs - switchMemo.atMs < SWITCH_TTL_MS) return switchMemo.off;
185
- const off = await firebaseAdmin.app().firestore().collection(WEB_RISK_SETTINGS_COLLECTION).doc(WEB_RISK_SETTINGS_DOC).get().then((snapshot)=>snapshot.get('enabled') === false, ()=>false);
186
- switchMemo = {
187
- off,
212
+ /**
213
+ * The switches, read at most once a minute. An unreadable document is on,
214
+ * in `'host'` mode; a mode that is not exactly `'url'` is `'host'`.
215
+ */ async function webRiskSettings(nowMs) {
216
+ if (settingsMemo && nowMs - settingsMemo.atMs < SWITCH_TTL_MS) return settingsMemo;
217
+ const settings = await firebaseAdmin.app().firestore().collection(WEB_RISK_SETTINGS_COLLECTION).doc(WEB_RISK_SETTINGS_DOC).get().then((snapshot)=>({
218
+ off: snapshot.get('enabled') === false,
219
+ mode: snapshot.get('lookupMode') === 'url' ? 'url' : 'host'
220
+ }), ()=>({
221
+ off: false,
222
+ mode: 'host'
223
+ }));
224
+ settingsMemo = _extends({}, settings, {
188
225
  atMs: nowMs
189
- };
190
- return off;
226
+ });
227
+ return settings;
228
+ }
229
+ /**
230
+ * The lookup mode in force (AGL-3459): `'url'` only while the lookup is on
231
+ * and `platformSettings/webRisk.lookupMode` is exactly `'url'`.
232
+ */ export async function webRiskLookupMode(nowMs = Date.now()) {
233
+ const settings = await webRiskSettings(nowMs);
234
+ return settings.off ? 'host' : settings.mode;
191
235
  }
192
236
  /**
193
237
  * Hosts that are the platform's own: every site's subdomain on the tenant
@@ -200,8 +244,15 @@ function warn(nowMs, message, error) {
200
244
  function isPlatformHost(host) {
201
245
  return platformReputationExclusions().some((domain)=>host === domain || host.endsWith(`.${domain}`));
202
246
  }
203
- function verdictFrom(data) {
247
+ /** An address's document id in {@link WEB_RISK_URL_CACHE_COLLECTION}: an address holds `/`, a hash does not. */ export function webRiskUrlVerdictId(url) {
248
+ return createHash('sha256').update(url).digest('hex');
249
+ }
250
+ function isAddress(key) {
251
+ return key.includes('://');
252
+ }
253
+ /** A stored answer, or null. An address's must name the address it was asked for. */ function verdictFrom(data, url) {
204
254
  if (!data) return null;
255
+ if (url !== undefined && data['url'] !== url) return null;
205
256
  const threats = Array.isArray(data['threats']) ? LINK_THREAT_TYPES.filter((type)=>data['threats'].includes(type)) : [];
206
257
  const checkedAtMs = Number(data['checkedAtMs']);
207
258
  const expiresAtMs = Number(data['expiresAtMs']);
@@ -212,25 +263,31 @@ function verdictFrom(data) {
212
263
  expiresAtMs
213
264
  };
214
265
  }
215
- function remember(host, verdict, nowMs) {
216
- memory.set(host, _extends({}, verdict, {
266
+ function remember(key, verdict, nowMs) {
267
+ if (memory.size >= WEB_RISK_MEMORY_MAX_ENTRIES && !memory.has(key)) memory.clear();
268
+ memory.set(key, _extends({}, verdict, {
217
269
  memoryUntilMs: verdict.threats.length ? verdict.expiresAtMs : Math.min(verdict.expiresAtMs, nowMs + WEB_RISK_MEMORY_CLEAN_TTL_MS)
218
270
  }));
219
271
  }
272
+ function fromMemory(key, nowMs) {
273
+ const known = memory.get(key);
274
+ return known && nowMs < known.memoryUntilMs ? known : null;
275
+ }
220
276
  /**
221
- * The reputation of each host: listed, clean, or unknown. Platform hosts are
222
- * clean without asking. Never throws, and never waits longer than the
223
- * deadline: a call still running then finishes in the background and fills
224
- * the caches for the next asker.
277
+ * The reputation of each host — and, in `'url'` mode, of each address on a
278
+ * host that is not itself listed: listed, clean, or unknown. Platform hosts
279
+ * are clean without asking. Never throws, and never waits on the API longer
280
+ * than the deadline: a call still running then finishes in the background
281
+ * and fills the caches for the next asker.
225
282
  *
226
283
  * "Never throws" covers the store as well as the API: a missing app, a
227
284
  * settings read that fails before it is a promise, or a cache reference
228
285
  * that cannot be built all read every host as unknown. The page review
229
286
  * calls this before its own fail-closed `try`, so a throw here would take
230
287
  * down every page with a link off the site.
231
- */ export async function lookupHostReputation(hosts, options = {}) {
288
+ */ export async function lookupLinkReputation(hosts, options = {}) {
232
289
  try {
233
- return await lookupHostReputationOrThrow(hosts, options);
290
+ return await lookupLinkReputationOrThrow(hosts, options);
234
291
  } catch (error) {
235
292
  var _options_nowMs;
236
293
  warn((_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now(), 'a lookup failed before it could answer', error);
@@ -245,8 +302,9 @@ function remember(host, verdict, nowMs) {
245
302
  };
246
303
  }
247
304
  }
248
- async function lookupHostReputationOrThrow(hosts, options) {
249
- var _options_nowMs;
305
+ async function lookupLinkReputationOrThrow(hosts, options) {
306
+ var _options_nowMs, _options_maxUrls;
307
+ var _options_urls;
250
308
  const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
251
309
  const result = {
252
310
  hits: [],
@@ -264,119 +322,199 @@ async function lookupHostReputationOrThrow(hosts, options) {
264
322
  else result.unknown.push(host);
265
323
  }
266
324
  if (!wanted.length) return result;
267
- const settle = (host, verdict)=>{
268
- if (verdict.threats.length) result.hits.push({
269
- host,
270
- threats: verdict.threats
325
+ let urls = ((_options_urls = options.urls) == null ? void 0 : _options_urls.length) ? pickReputationUrls(options.urls, wanted, (_options_maxUrls = options.maxUrls) != null ? _options_maxUrls : MAX_REPUTATION_URLS_PER_LOOKUP) : [];
326
+ const hostOf = new Map(urls.map((url)=>{
327
+ var _ref;
328
+ var _normalizeReputationLink;
329
+ return [
330
+ url,
331
+ (_ref = (_normalizeReputationLink = normalizeReputationLink(url)) == null ? void 0 : _normalizeReputationLink.host) != null ? _ref : ''
332
+ ];
333
+ }));
334
+ // What is known before the API is asked, host or address.
335
+ const known = new Map();
336
+ const unknownTo = (keys)=>keys.filter((key)=>{
337
+ const verdict = fromMemory(key, nowMs);
338
+ if (verdict) known.set(key, verdict);
339
+ return !verdict;
271
340
  });
272
- else result.clean.push(host);
273
- };
274
- // 1. This process's memory.
275
- let missing = wanted.filter((host)=>{
276
- const known = memory.get(host);
277
- if (known && nowMs < known.memoryUntilMs) {
278
- settle(host, known);
279
- return false;
280
- }
281
- return true;
282
- });
283
- if (!missing.length) return result;
284
- // 2. The switch. Off reads every remaining host as unknown.
285
- if (await switchedOff(nowMs)) {
286
- result.unknown.push(...missing);
287
- return result;
341
+ // 1. This process's memory, for the hosts.
342
+ let missingHosts = unknownTo(wanted);
343
+ let missingUrls = [];
344
+ let mayAsk = true;
345
+ // 2. The switches — read only when a host is left to ask about, or
346
+ // addresses wait on the mode. Off reads everything left as unknown.
347
+ if (missingHosts.length || urls.length) {
348
+ const settings = await webRiskSettings(nowMs);
349
+ if (settings.off || settings.mode !== 'url') urls = [];
350
+ if (settings.off) mayAsk = false;
351
+ // An address on a host already known to be listed adds nothing.
352
+ urls = urls.filter((url)=>{
353
+ var _hostOf_get;
354
+ var _known_get;
355
+ return !((_known_get = known.get((_hostOf_get = hostOf.get(url)) != null ? _hostOf_get : '')) == null ? void 0 : _known_get.threats.length);
356
+ });
357
+ // 1b. This process's memory, for the addresses.
358
+ missingUrls = unknownTo(urls);
288
359
  }
289
- // 3. The store, in one round trip.
360
+ // 3. The store, hosts and addresses in one round trip.
290
361
  const firestore = firebaseAdmin.app().firestore();
291
- const collection = firestore.collection(WEB_RISK_CACHE_COLLECTION);
292
- try {
293
- const snapshots = await firestore.getAll(...missing.map((host)=>collection.doc(host)));
294
- const stored = new Map();
295
- for (const snapshot of snapshots){
296
- const verdict = verdictFrom(snapshot.exists ? snapshot.data() : undefined);
297
- if (verdict && nowMs < verdict.expiresAtMs) stored.set(snapshot.id, verdict);
362
+ const hostCollection = firestore.collection(WEB_RISK_CACHE_COLLECTION);
363
+ const urlCollection = firestore.collection(WEB_RISK_URL_CACHE_COLLECTION);
364
+ if (mayAsk && (missingHosts.length || missingUrls.length)) {
365
+ const keys = [
366
+ ...missingHosts,
367
+ ...missingUrls
368
+ ];
369
+ try {
370
+ const snapshots = await firestore.getAll(...keys.map((key)=>isAddress(key) ? urlCollection.doc(webRiskUrlVerdictId(key)) : hostCollection.doc(key)));
371
+ snapshots.forEach((snapshot, index)=>{
372
+ const key = keys[index];
373
+ const data = snapshot.exists ? snapshot.data() : undefined;
374
+ const verdict = verdictFrom(data, isAddress(key) ? key : undefined);
375
+ if (!verdict || nowMs >= verdict.expiresAtMs) return;
376
+ remember(key, verdict, nowMs);
377
+ known.set(key, verdict);
378
+ });
379
+ missingHosts = missingHosts.filter((host)=>!known.has(host));
380
+ missingUrls = missingUrls.filter((url)=>!known.has(url));
381
+ } catch (error) {
382
+ warn(nowMs, 'the verdict cache could not be read', error);
298
383
  }
299
- missing = missing.filter((host)=>{
300
- const verdict = stored.get(host);
301
- if (!verdict) return true;
302
- remember(host, verdict, nowMs);
303
- settle(host, verdict);
304
- return false;
305
- });
306
- } catch (error) {
307
- warn(nowMs, 'the verdict cache could not be read', error);
308
384
  }
309
- if (!missing.length) return result;
310
- // 4. The API, every remaining host at once, within the deadline.
385
+ // 4. The API, everything left at once, within the deadline: each host
386
+ // first, then — unless the host itself is listed — its addresses.
387
+ const answered = new Map();
311
388
  const client = activeClient();
312
- if (!client || nowMs < backoffUntilMs) {
313
- result.unknown.push(...missing);
314
- return result;
315
- }
316
- result.looked = missing.length;
317
- const answers = new Map();
318
- const calls = missing.map(async (host)=>{
319
- try {
320
- var _answer_expireTimeMs;
321
- const answer = await client.searchUri(webRiskUriForHost(host));
322
- const checkedAtMs = Date.now();
323
- const verdict = {
324
- threats: answer.threats,
325
- checkedAtMs,
326
- expiresAtMs: answer.threats.length ? (_answer_expireTimeMs = answer.expireTimeMs) != null ? _answer_expireTimeMs : checkedAtMs + WEB_RISK_LISTED_FALLBACK_TTL_MS : checkedAtMs + WEB_RISK_CLEAN_TTL_MS
327
- };
328
- answers.set(host, verdict);
329
- remember(host, verdict, checkedAtMs);
330
- await collection.doc(host).set(_extends({
331
- host
332
- }, verdict, {
333
- listed: verdict.threats.length > 0
334
- })).catch((error)=>warn(checkedAtMs, 'a verdict could not be cached', error));
335
- } catch (error) {
336
- if (error instanceof WebRiskHttpError && [
337
- 401,
338
- 403,
339
- 404
340
- ].includes(error.status)) {
341
- backoffUntilMs = Date.now() + REFUSED_BACKOFF_MS;
342
- console.error('[web-risk] the API refused the credential — lookups paused for 15 minutes', error);
343
- } else if (error instanceof WebRiskHttpError && error.status === 429) {
344
- backoffUntilMs = Date.now() + THROTTLED_BACKOFF_MS;
345
- warn(Date.now(), 'the API is rate limiting', error);
346
- } else if (error instanceof WebRiskUnavailableError) {
347
- backoffUntilMs = Date.now() + REFUSED_BACKOFF_MS;
348
- warn(Date.now(), 'no credential to call the API with', error);
349
- } else {
350
- warn(Date.now(), `the lookup of ${host} failed or timed out`, error);
389
+ if (mayAsk && client && nowMs >= backoffUntilMs && (missingHosts.length || missingUrls.length)) {
390
+ let looked = 0;
391
+ const ask = async (key)=>{
392
+ if (Date.now() < backoffUntilMs) return null;
393
+ looked += 1;
394
+ try {
395
+ var _answer_expireTimeMs, _hostOf_get;
396
+ const answer = await client.searchUri(isAddress(key) ? key : webRiskUriForHost(key));
397
+ const checkedAtMs = Date.now();
398
+ const verdict = {
399
+ threats: answer.threats,
400
+ checkedAtMs,
401
+ expiresAtMs: answer.threats.length ? (_answer_expireTimeMs = answer.expireTimeMs) != null ? _answer_expireTimeMs : checkedAtMs + WEB_RISK_LISTED_FALLBACK_TTL_MS : checkedAtMs + WEB_RISK_CLEAN_TTL_MS
402
+ };
403
+ answered.set(key, verdict);
404
+ remember(key, verdict, checkedAtMs);
405
+ const document = isAddress(key) ? urlCollection.doc(webRiskUrlVerdictId(key)).set(_extends({
406
+ host: (_hostOf_get = hostOf.get(key)) != null ? _hostOf_get : '',
407
+ url: key
408
+ }, verdict, {
409
+ listed: verdict.threats.length > 0
410
+ })) : hostCollection.doc(key).set(_extends({
411
+ host: key
412
+ }, verdict, {
413
+ listed: verdict.threats.length > 0
414
+ }));
415
+ await document.catch((error)=>warn(checkedAtMs, 'a verdict could not be cached', error));
416
+ return verdict;
417
+ } catch (error) {
418
+ if (error instanceof WebRiskHttpError && [
419
+ 401,
420
+ 403,
421
+ 404
422
+ ].includes(error.status)) {
423
+ backoffUntilMs = Date.now() + REFUSED_BACKOFF_MS;
424
+ console.error('[web-risk] the API refused the credential — lookups paused for 15 minutes', error);
425
+ } else if (error instanceof WebRiskHttpError && error.status === 429) {
426
+ backoffUntilMs = Date.now() + THROTTLED_BACKOFF_MS;
427
+ warn(Date.now(), 'the API is rate limiting', error);
428
+ } else if (error instanceof WebRiskUnavailableError) {
429
+ backoffUntilMs = Date.now() + REFUSED_BACKOFF_MS;
430
+ warn(Date.now(), 'no credential to call the API with', error);
431
+ } else {
432
+ warn(Date.now(), `the lookup of ${key} failed or timed out`, error);
433
+ }
434
+ return null;
351
435
  }
352
- }
353
- });
354
- let timer;
355
- await Promise.race([
356
- Promise.allSettled(calls),
357
- new Promise((resolve)=>{
358
- var _options_deadlineMs;
359
- timer = setTimeout(resolve, (_options_deadlineMs = options.deadlineMs) != null ? _options_deadlineMs : WEB_RISK_DEADLINE_MS);
360
- })
361
- ]);
362
- if (timer) clearTimeout(timer);
363
- const late = missing.filter((host)=>!answers.has(host));
364
- for (const host of missing){
365
- const verdict = answers.get(host);
366
- if (verdict) settle(host, verdict);
436
+ };
437
+ const pipelines = wanted.map(async (host)=>{
438
+ var _known_get;
439
+ const hostVerdict = (_known_get = known.get(host)) != null ? _known_get : missingHosts.includes(host) ? await ask(host) : null;
440
+ if (hostVerdict == null ? void 0 : hostVerdict.threats.length) return;
441
+ await Promise.all(missingUrls.filter((url)=>hostOf.get(url) === host).map((url)=>ask(url)));
442
+ });
443
+ let timer;
444
+ await Promise.race([
445
+ Promise.allSettled(pipelines),
446
+ new Promise((resolve)=>{
447
+ var _options_deadlineMs;
448
+ timer = setTimeout(resolve, (_options_deadlineMs = options.deadlineMs) != null ? _options_deadlineMs : WEB_RISK_DEADLINE_MS);
449
+ })
450
+ ]);
451
+ if (timer) clearTimeout(timer);
452
+ result.looked = looked;
453
+ const late = [
454
+ ...missingHosts,
455
+ ...missingUrls
456
+ ].filter((key)=>!answered.has(key)).length;
457
+ if (late) warn(Date.now(), `${late} host(s) or address(es) had no answer within the deadline`);
458
+ }
459
+ // 5. The answer. An address on a listed host is not reported: the host's
460
+ // listing already names it.
461
+ const verdictOf = (key)=>{
462
+ var _known_get;
463
+ return (_known_get = known.get(key)) != null ? _known_get : answered.get(key);
464
+ };
465
+ for (const host of wanted){
466
+ const verdict = verdictOf(host);
467
+ if (!verdict) result.unknown.push(host);
468
+ else if (verdict.threats.length) result.hits.push({
469
+ host,
470
+ threats: verdict.threats
471
+ });
472
+ else result.clean.push(host);
473
+ }
474
+ for (const url of urls){
475
+ var _hostOf_get;
476
+ var _verdictOf;
477
+ const host = (_hostOf_get = hostOf.get(url)) != null ? _hostOf_get : '';
478
+ if ((_verdictOf = verdictOf(host)) == null ? void 0 : _verdictOf.threats.length) continue;
479
+ const verdict = verdictOf(url);
480
+ if (!verdict) result.unknown.push(url);
481
+ else if (verdict.threats.length) result.hits.push({
482
+ host,
483
+ url,
484
+ threats: verdict.threats
485
+ });
486
+ else result.clean.push(url);
367
487
  }
368
- result.unknown.push(...late);
369
- if (late.length) warn(Date.now(), `${late.length} host(s) had no answer within the deadline`);
370
488
  return result;
371
489
  }
490
+ /**
491
+ * Delete the addresses' stored answers that expired more than
492
+ * {@link WEB_RISK_URL_VERDICT_GRACE_MS} ago (AGL-3459), at most `limit` at a
493
+ * time. The daily re-check calls it once per walk. A host's answers are not
494
+ * reaped: they are few and rewritten in place. Never throws.
495
+ */ export async function reapExpiredWebRiskUrlVerdicts(options = {}) {
496
+ var _options_nowMs, _options_firestore;
497
+ const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
498
+ const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
499
+ try {
500
+ var _options_limit;
501
+ const stale = await firestore.collection(WEB_RISK_URL_CACHE_COLLECTION).where('expiresAtMs', '<', nowMs - WEB_RISK_URL_VERDICT_GRACE_MS).limit((_options_limit = options.limit) != null ? _options_limit : 500).get();
502
+ await Promise.all(stale.docs.map((snapshot)=>snapshot.ref.delete()));
503
+ return stale.docs.length;
504
+ } catch (error) {
505
+ console.warn('[web-risk] expired address verdicts could not be deleted', error);
506
+ return 0;
507
+ }
508
+ }
372
509
  /**
373
510
  * Puts this lookup behind the shared screen's reputation seam, so the send
374
511
  * seam (`screenTenantMessage`) asks it. Called by
375
512
  * `installOutboundScreenGate`, at that module's load.
376
513
  */ export function installLinkReputationLookup() {
377
514
  if (typeof setLinkReputationLookup !== 'function') return;
378
- setLinkReputationLookup((hosts, options)=>lookupHostReputation(hosts, {
379
- deadlineMs: options == null ? void 0 : options.deadlineMs
515
+ setLinkReputationLookup((hosts, options)=>lookupLinkReputation(hosts, {
516
+ deadlineMs: options == null ? void 0 : options.deadlineMs,
517
+ urls: options == null ? void 0 : options.urls
380
518
  }));
381
519
  }
382
520