@aglyn/tenant-data-admin 1.0.0-beta.220 → 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.
- package/package.json +7 -7
- package/src/lib/server/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +12 -4
- package/src/lib/server/host-visitor-records.js +20 -10
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +7 -0
- package/src/lib/server/hosted-page-review.js +144 -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 +58 -0
- package/src/lib/server/link-reputation-review.js +185 -0
- package/src/lib/server/link-reputation-review.js.map +1 -0
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- 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/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +24 -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/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +139 -0
- package/src/lib/server/web-risk.js +521 -0
- package/src/lib/server/web-risk.js.map +1 -0
- package/src/tenancy.d.ts +3 -1
- package/src/tenancy.js +5 -1
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- package/src/lib/server/email-revenue-attribution.js.map +0 -1
|
@@ -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,58 @@
|
|
|
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
|
+
/** Distinct addresses looked up on them — none unless the lookup is in `'url'` mode. */
|
|
26
|
+
addresses: number;
|
|
27
|
+
/** Of the hosts and addresses, how many Web Risk lists. */
|
|
28
|
+
listed: number;
|
|
29
|
+
/** Hosts and addresses nobody could answer for: not evidence, looked up again next time. */
|
|
30
|
+
unknown: number;
|
|
31
|
+
/** Pages sent back through the review with a listed host. */
|
|
32
|
+
reviewed: number;
|
|
33
|
+
/** Of those, how many the review held (or found already rejected). */
|
|
34
|
+
held: number;
|
|
35
|
+
/** The sites a hold was placed on, whose cached pages need dropping. */
|
|
36
|
+
heldHostIds: string[];
|
|
37
|
+
/** Addresses' stored answers deleted a day after they expired (first chunk of a walk only). */
|
|
38
|
+
reapedAddressVerdicts: number;
|
|
39
|
+
nextCursor: string | null;
|
|
40
|
+
done: boolean;
|
|
41
|
+
}
|
|
42
|
+
/** Sites walked per chunk. */
|
|
43
|
+
export declare const LINK_RECHECK_SITES_PER_CHUNK = 200;
|
|
44
|
+
/**
|
|
45
|
+
* Re-check the foreign hosts of every live page, a chunk of sites at a time
|
|
46
|
+
* (`cursor` resumes after the last site of the previous chunk). A site that
|
|
47
|
+
* is locked is skipped: nothing of it serves. A page with a newly listed host
|
|
48
|
+
* goes back through {@link reviewHostedPage} with that listing as an extra
|
|
49
|
+
* signal, so what a hold does is decided in one place.
|
|
50
|
+
*/
|
|
51
|
+
export declare function recheckLivePageLinks(options?: {
|
|
52
|
+
cursor?: string | null;
|
|
53
|
+
sitesPerChunk?: number;
|
|
54
|
+
nowMs?: number;
|
|
55
|
+
firestore?: FirebaseFirestore.Firestore;
|
|
56
|
+
/** Walk and count only: no lookup, no review. */
|
|
57
|
+
dryRun?: boolean;
|
|
58
|
+
}): Promise<LivePageLinkRecheck>;
|
|
@@ -0,0 +1,185 @@
|
|
|
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, in
|
|
29
|
+
* `'url'` mode (AGL-3459), the addresses on them noted beside the hosts
|
|
30
|
+
* (`foreignLinks`, never a query string) — and sends each page with a newly
|
|
31
|
+
* listed host or address back through `reviewHostedPage` with the listing as
|
|
32
|
+
* an extra signal — the same path, so it is held or flagged, its row filed,
|
|
33
|
+
* and a young workspace's security hold requested (AGL-3450), exactly as at
|
|
34
|
+
* render. The console's `/api/admin/web-risk-recheck` runs it a chunk of
|
|
35
|
+
* sites at a time; its first chunk of a walk also deletes the addresses'
|
|
36
|
+
* stored answers that expired a day ago.
|
|
37
|
+
*=========================================*/ import { MAX_REPUTATION_HOSTS_PER_LOOKUP, MAX_REPUTATION_URLS_PER_LOOKUP, normalizeReputationLink, webRiskSignals } from "@aglyn/shared-util-email/link-reputation";
|
|
38
|
+
import { FieldPath } from "firebase-admin/firestore";
|
|
39
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
40
|
+
import { PAGE_REVIEW_SUBCOLLECTION, reviewHostedPage } from "./hosted-page-review.js";
|
|
41
|
+
import { lookupLinkReputation, reapExpiredWebRiskUrlVerdicts, webRiskLookupMode } from "./web-risk.js";
|
|
42
|
+
/** Sites walked per chunk. */ export const LINK_RECHECK_SITES_PER_CHUNK = 200;
|
|
43
|
+
/** Sites whose page notes are read at once. */ const SITE_READ_CONCURRENCY = 20;
|
|
44
|
+
/** The budget of each group of hosts looked up within a chunk. */ const RECHECK_DEADLINE_MS = 15000;
|
|
45
|
+
/**
|
|
46
|
+
* Re-check the foreign hosts of every live page, a chunk of sites at a time
|
|
47
|
+
* (`cursor` resumes after the last site of the previous chunk). A site that
|
|
48
|
+
* is locked is skipped: nothing of it serves. A page with a newly listed host
|
|
49
|
+
* goes back through {@link reviewHostedPage} with that listing as an extra
|
|
50
|
+
* signal, so what a hold does is decided in one place.
|
|
51
|
+
*/ export async function recheckLivePageLinks(options = {}) {
|
|
52
|
+
var _options_firestore, _options_nowMs, _options_sitesPerChunk, _ref;
|
|
53
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
54
|
+
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
55
|
+
const limit = (_options_sitesPerChunk = options.sitesPerChunk) != null ? _options_sitesPerChunk : LINK_RECHECK_SITES_PER_CHUNK;
|
|
56
|
+
let query = firestore.collection('hosts').orderBy(FieldPath.documentId()).select('suspendedAt').limit(limit);
|
|
57
|
+
if (options.cursor) query = query.startAfter(options.cursor);
|
|
58
|
+
const sites = await query.get();
|
|
59
|
+
const pages = [];
|
|
60
|
+
const live = sites.docs.filter((site)=>site.get('suspendedAt') == null);
|
|
61
|
+
for(let at = 0; at < live.length; at += SITE_READ_CONCURRENCY){
|
|
62
|
+
const batch = live.slice(at, at + SITE_READ_CONCURRENCY);
|
|
63
|
+
const notes = await Promise.all(batch.map((site)=>site.ref.collection(PAGE_REVIEW_SUBCOLLECTION).where('foreignHostCount', '>', 0).limit(500).get().then((reviews)=>({
|
|
64
|
+
site,
|
|
65
|
+
reviews
|
|
66
|
+
}))));
|
|
67
|
+
for (const { site, reviews } of notes){
|
|
68
|
+
for (const review of reviews.docs){
|
|
69
|
+
const versionId = review.get('servedVersionId');
|
|
70
|
+
const hosts = review.get('foreignHosts');
|
|
71
|
+
const links = review.get('foreignLinks');
|
|
72
|
+
if (typeof versionId !== 'string' || !versionId || !Array.isArray(hosts)) continue;
|
|
73
|
+
pages.push({
|
|
74
|
+
hostId: site.id,
|
|
75
|
+
screenId: review.id,
|
|
76
|
+
versionId,
|
|
77
|
+
hosts: hosts.filter((host)=>typeof host === 'string'),
|
|
78
|
+
links: Array.isArray(links) ? links.filter((link)=>typeof link === 'string') : []
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const distinct = [
|
|
84
|
+
...new Set(pages.flatMap((page)=>page.hosts))
|
|
85
|
+
];
|
|
86
|
+
const last = sites.docs[sites.docs.length - 1];
|
|
87
|
+
const chunk = {
|
|
88
|
+
sites: sites.size,
|
|
89
|
+
pages: pages.length,
|
|
90
|
+
hosts: distinct.length,
|
|
91
|
+
nextCursor: sites.size < limit ? null : (_ref = last == null ? void 0 : last.id) != null ? _ref : null,
|
|
92
|
+
done: sites.size < limit
|
|
93
|
+
};
|
|
94
|
+
if (options.dryRun) {
|
|
95
|
+
return _extends({}, chunk, {
|
|
96
|
+
addresses: 0,
|
|
97
|
+
listed: 0,
|
|
98
|
+
unknown: 0,
|
|
99
|
+
reviewed: 0,
|
|
100
|
+
held: 0,
|
|
101
|
+
heldHostIds: [],
|
|
102
|
+
reapedAddressVerdicts: 0
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// Once a walk, before its first chunk: addresses' answers long expired.
|
|
106
|
+
const reapedAddressVerdicts = options.cursor ? 0 : await reapExpiredWebRiskUrlVerdicts({
|
|
107
|
+
nowMs,
|
|
108
|
+
firestore
|
|
109
|
+
});
|
|
110
|
+
// The addresses go with their hosts, and only in 'url' mode.
|
|
111
|
+
const mode = await webRiskLookupMode(nowMs);
|
|
112
|
+
const addressHost = new Map();
|
|
113
|
+
if (mode === 'url') {
|
|
114
|
+
for (const link of new Set(pages.flatMap((page)=>page.links))){
|
|
115
|
+
var _normalizeReputationLink;
|
|
116
|
+
const host = (_normalizeReputationLink = normalizeReputationLink(link)) == null ? void 0 : _normalizeReputationLink.host;
|
|
117
|
+
if (host) addressHost.set(link, host);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Keyed by host for a host's listing, by address for an address's: an
|
|
121
|
+
// address always holds `://`, a host never does.
|
|
122
|
+
const listed = new Map();
|
|
123
|
+
const unknown = new Set();
|
|
124
|
+
for(let at = 0; at < distinct.length; at += MAX_REPUTATION_HOSTS_PER_LOOKUP){
|
|
125
|
+
const group = distinct.slice(at, at + MAX_REPUTATION_HOSTS_PER_LOOKUP);
|
|
126
|
+
const inGroup = new Set(group);
|
|
127
|
+
const urls = [
|
|
128
|
+
...addressHost
|
|
129
|
+
].filter(([, host])=>inGroup.has(host)).map(([url])=>url);
|
|
130
|
+
// A later slice of addresses finds the group's answered hosts in memory.
|
|
131
|
+
let offset = 0;
|
|
132
|
+
do {
|
|
133
|
+
var _hit_url;
|
|
134
|
+
const answer = await lookupLinkReputation(group, {
|
|
135
|
+
urls: urls.slice(offset, offset + MAX_REPUTATION_URLS_PER_LOOKUP),
|
|
136
|
+
deadlineMs: RECHECK_DEADLINE_MS,
|
|
137
|
+
nowMs
|
|
138
|
+
});
|
|
139
|
+
for (const hit of answer.hits)listed.set((_hit_url = hit.url) != null ? _hit_url : hit.host, hit);
|
|
140
|
+
for (const key of answer.unknown)unknown.add(key);
|
|
141
|
+
offset += MAX_REPUTATION_URLS_PER_LOOKUP;
|
|
142
|
+
}while (offset < urls.length)
|
|
143
|
+
}
|
|
144
|
+
let reviewed = 0;
|
|
145
|
+
let held = 0;
|
|
146
|
+
const heldHostIds = new Set();
|
|
147
|
+
for (const page of pages){
|
|
148
|
+
const hits = [
|
|
149
|
+
...page.hosts,
|
|
150
|
+
...page.links
|
|
151
|
+
].map((key)=>listed.get(key)).filter(Boolean);
|
|
152
|
+
if (!hits.length) continue;
|
|
153
|
+
reviewed += 1;
|
|
154
|
+
try {
|
|
155
|
+
const outcome = await reviewHostedPage({
|
|
156
|
+
hostId: page.hostId,
|
|
157
|
+
screenId: page.screenId,
|
|
158
|
+
versionId: page.versionId,
|
|
159
|
+
// The page itself is not composed here: the listing is the finding.
|
|
160
|
+
nodes: {},
|
|
161
|
+
extraSignals: webRiskSignals(hits),
|
|
162
|
+
nowMs
|
|
163
|
+
});
|
|
164
|
+
if (outcome.outcome !== 'serve') {
|
|
165
|
+
held += 1;
|
|
166
|
+
heldHostIds.add(page.hostId);
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error('[link-reputation] a live page could not be re-reviewed', page.hostId, page.screenId, error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return _extends({}, chunk, {
|
|
173
|
+
addresses: addressHost.size,
|
|
174
|
+
listed: listed.size,
|
|
175
|
+
unknown: unknown.size,
|
|
176
|
+
reviewed,
|
|
177
|
+
held,
|
|
178
|
+
heldHostIds: [
|
|
179
|
+
...heldHostIds
|
|
180
|
+
],
|
|
181
|
+
reapedAddressVerdicts
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
//# 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, in\n * `'url'` mode (AGL-3459), the addresses on them noted beside the hosts\n * (`foreignLinks`, never a query string) — and sends each page with a newly\n * listed host or address back through `reviewHostedPage` with the listing as\n * an extra signal — the same path, so it is held or flagged, its row filed,\n * and a young workspace's security hold requested (AGL-3450), exactly as at\n * render. The console's `/api/admin/web-risk-recheck` runs it a chunk of\n * sites at a time; its first chunk of a walk also deletes the addresses'\n * stored answers that expired a day ago.\n *=========================================*/\n\nimport {\n type HostReputationHit,\n MAX_REPUTATION_HOSTS_PER_LOOKUP,\n MAX_REPUTATION_URLS_PER_LOOKUP,\n normalizeReputationLink,\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 { lookupLinkReputation, reapExpiredWebRiskUrlVerdicts, webRiskLookupMode } 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 /** The addresses on those hosts the review noted (AGL-3459); empty before it did. */\n links: 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 /** Distinct addresses looked up on them — none unless the lookup is in `'url'` mode. */\n addresses: number\n /** Of the hosts and addresses, how many Web Risk lists. */\n listed: number\n /** Hosts and addresses 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 /** Addresses' stored answers deleted a day after they expired (first chunk of a walk only). */\n reapedAddressVerdicts: number\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 const links = review.get('foreignLinks')\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 links: Array.isArray(links) ? links.filter((link): link is string => typeof link === '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 {\n ...chunk,\n addresses: 0,\n listed: 0,\n unknown: 0,\n reviewed: 0,\n held: 0,\n heldHostIds: [],\n reapedAddressVerdicts: 0,\n }\n }\n // Once a walk, before its first chunk: addresses' answers long expired.\n const reapedAddressVerdicts = options.cursor\n ? 0\n : await reapExpiredWebRiskUrlVerdicts({ nowMs, firestore })\n\n // The addresses go with their hosts, and only in 'url' mode.\n const mode = await webRiskLookupMode(nowMs)\n const addressHost = new Map<string, string>()\n if (mode === 'url') {\n for (const link of new Set(pages.flatMap((page) => page.links))) {\n const host = normalizeReputationLink(link)?.host\n if (host) addressHost.set(link, host)\n }\n }\n // Keyed by host for a host's listing, by address for an address's: an\n // address always holds `://`, a host never does.\n const listed = new Map<string, HostReputationHit>()\n const unknown = new Set<string>()\n for (let at = 0; at < distinct.length; at += MAX_REPUTATION_HOSTS_PER_LOOKUP) {\n const group = distinct.slice(at, at + MAX_REPUTATION_HOSTS_PER_LOOKUP)\n const inGroup = new Set(group)\n const urls = [...addressHost].filter(([, host]) => inGroup.has(host)).map(([url]) => url)\n // A later slice of addresses finds the group's answered hosts in memory.\n let offset = 0\n do {\n const answer = await lookupLinkReputation(group, {\n urls: urls.slice(offset, offset + MAX_REPUTATION_URLS_PER_LOOKUP),\n deadlineMs: RECHECK_DEADLINE_MS,\n nowMs,\n })\n for (const hit of answer.hits) listed.set(hit.url ?? hit.host, hit)\n for (const key of answer.unknown) unknown.add(key)\n offset += MAX_REPUTATION_URLS_PER_LOOKUP\n } while (offset < urls.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, ...page.links]\n .map((key) => listed.get(key))\n .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 addresses: addressHost.size,\n listed: listed.size,\n unknown: unknown.size,\n reviewed,\n held,\n heldHostIds: [...heldHostIds],\n reapedAddressVerdicts,\n }\n}\n"],"names":["MAX_REPUTATION_HOSTS_PER_LOOKUP","MAX_REPUTATION_URLS_PER_LOOKUP","normalizeReputationLink","webRiskSignals","FieldPath","firebaseAdmin","PAGE_REVIEW_SUBCOLLECTION","reviewHostedPage","lookupLinkReputation","reapExpiredWebRiskUrlVerdicts","webRiskLookupMode","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","links","Array","isArray","push","hostId","id","screenId","host","link","distinct","Set","flatMap","page","last","chunk","size","nextCursor","done","dryRun","addresses","listed","unknown","reviewed","held","heldHostIds","reapedAddressVerdicts","mode","addressHost","Map","set","group","inGroup","urls","has","url","offset","hit","answer","deadlineMs","hits","key","add","Boolean","outcome","nodes","extraSignals","error","console"],"mappings":"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"}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 THIS PROCESS DROPS ONE SITE'S CACHED PAGES, for a write that worked out
|
|
19
|
+
* which of them it made stale (AGL-3113, AGL-3080).
|
|
20
|
+
*
|
|
21
|
+
* A record write — a dataset row, a collection entry — refreshes the pages
|
|
22
|
+
* that show it, and nothing else. Working out WHICH pages is the writer's: the
|
|
23
|
+
* data plugin walks the pages that repeat over a dataset, the collection scan
|
|
24
|
+
* the pages that list an entry. HOW a page is dropped is the runtime's, and
|
|
25
|
+
* the two runtimes do it differently: the tenant drops its own caches in
|
|
26
|
+
* process, and the console announces over the shared secret and passes its own
|
|
27
|
+
* dropper at the call site. So the deployment that owns the caches registers
|
|
28
|
+
* the dropper here, and a writer reads it; neither names the other.
|
|
29
|
+
*
|
|
30
|
+
* ## One per process, on `globalThis`
|
|
31
|
+
*
|
|
32
|
+
* The tenant registers from `instrumentation.ts`, by name, at boot. Next
|
|
33
|
+
* compiles that file apart from the routes, so a dropper held in a module
|
|
34
|
+
* `let` was set in the boot's copy of this module and read as missing by
|
|
35
|
+
* every route's copy — a form submission or an automation step that wrote a
|
|
36
|
+
* row logged `no-live-page-dropper` and left the pages stale for the rest of
|
|
37
|
+
* their window (AGL-3412 was the same split for the plugin services). Keyed on
|
|
38
|
+
* `globalThis` under `Symbol.for`, every copy in a realm reads the one slot.
|
|
39
|
+
*/
|
|
40
|
+
/** One site's share of a change: the pages on it that must be dropped. */
|
|
41
|
+
export interface LivePageTarget {
|
|
42
|
+
hostId: string;
|
|
43
|
+
/** The tenant keys its cache on this, never on `hostId`. */
|
|
44
|
+
subdomain: string;
|
|
45
|
+
/** The attached custom domain, whose pages live under a second cache key. */
|
|
46
|
+
cname?: string;
|
|
47
|
+
/** Site-absolute addresses (`/`, `/team`) the change makes stale. */
|
|
48
|
+
paths: string[];
|
|
49
|
+
/** The site held more documents than the scan read, so `paths` is partial. */
|
|
50
|
+
truncated: boolean;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Drops one site's cached pages. Returns whether the drop landed. It must
|
|
54
|
+
* never throw: everything above it has already been written.
|
|
55
|
+
*/
|
|
56
|
+
export type LivePageDropper = (target: LivePageTarget) => Promise<boolean>;
|
|
57
|
+
/**
|
|
58
|
+
* Registers the dropper for this process, replacing any previous one. Returns
|
|
59
|
+
* the unregister, which removes it only if it is still the one registered.
|
|
60
|
+
*
|
|
61
|
+
* Called from a function the platform calls BY NAME at boot, never from a
|
|
62
|
+
* module's top level: a bundler deletes a module imported only for its side
|
|
63
|
+
* effect when its package says it has none (AGL-3025).
|
|
64
|
+
*/
|
|
65
|
+
export declare function registerLivePageDropper(drop: LivePageDropper): () => void;
|
|
66
|
+
/** The dropper this process registered, or `undefined` when none did. */
|
|
67
|
+
export declare function registeredLivePageDropper(): LivePageDropper | undefined;
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
* HOW THIS PROCESS DROPS ONE SITE'S CACHED PAGES, for a write that worked out
|
|
18
|
+
* which of them it made stale (AGL-3113, AGL-3080).
|
|
19
|
+
*
|
|
20
|
+
* A record write — a dataset row, a collection entry — refreshes the pages
|
|
21
|
+
* that show it, and nothing else. Working out WHICH pages is the writer's: the
|
|
22
|
+
* data plugin walks the pages that repeat over a dataset, the collection scan
|
|
23
|
+
* the pages that list an entry. HOW a page is dropped is the runtime's, and
|
|
24
|
+
* the two runtimes do it differently: the tenant drops its own caches in
|
|
25
|
+
* process, and the console announces over the shared secret and passes its own
|
|
26
|
+
* dropper at the call site. So the deployment that owns the caches registers
|
|
27
|
+
* the dropper here, and a writer reads it; neither names the other.
|
|
28
|
+
*
|
|
29
|
+
* ## One per process, on `globalThis`
|
|
30
|
+
*
|
|
31
|
+
* The tenant registers from `instrumentation.ts`, by name, at boot. Next
|
|
32
|
+
* compiles that file apart from the routes, so a dropper held in a module
|
|
33
|
+
* `let` was set in the boot's copy of this module and read as missing by
|
|
34
|
+
* every route's copy — a form submission or an automation step that wrote a
|
|
35
|
+
* row logged `no-live-page-dropper` and left the pages stale for the rest of
|
|
36
|
+
* their window (AGL-3412 was the same split for the plugin services). Keyed on
|
|
37
|
+
* `globalThis` under `Symbol.for`, every copy in a realm reads the one slot.
|
|
38
|
+
*/ /** One site's share of a change: the pages on it that must be dropped. */ const DROPPER_KEY = Symbol.for('@aglyn/tenant-data-admin:live-page-dropper');
|
|
39
|
+
const globalScope = globalThis;
|
|
40
|
+
/**
|
|
41
|
+
* Registers the dropper for this process, replacing any previous one. Returns
|
|
42
|
+
* the unregister, which removes it only if it is still the one registered.
|
|
43
|
+
*
|
|
44
|
+
* Called from a function the platform calls BY NAME at boot, never from a
|
|
45
|
+
* module's top level: a bundler deletes a module imported only for its side
|
|
46
|
+
* effect when its package says it has none (AGL-3025).
|
|
47
|
+
*/ export function registerLivePageDropper(drop) {
|
|
48
|
+
globalScope[DROPPER_KEY] = drop;
|
|
49
|
+
return ()=>{
|
|
50
|
+
if (globalScope[DROPPER_KEY] === drop) delete globalScope[DROPPER_KEY];
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The dropper this process registered, or `undefined` when none did. */ export function registeredLivePageDropper() {
|
|
54
|
+
return globalScope[DROPPER_KEY];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
//# sourceMappingURL=live-page-drops.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/live-page-drops.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 * HOW THIS PROCESS DROPS ONE SITE'S CACHED PAGES, for a write that worked out\n * which of them it made stale (AGL-3113, AGL-3080).\n *\n * A record write — a dataset row, a collection entry — refreshes the pages\n * that show it, and nothing else. Working out WHICH pages is the writer's: the\n * data plugin walks the pages that repeat over a dataset, the collection scan\n * the pages that list an entry. HOW a page is dropped is the runtime's, and\n * the two runtimes do it differently: the tenant drops its own caches in\n * process, and the console announces over the shared secret and passes its own\n * dropper at the call site. So the deployment that owns the caches registers\n * the dropper here, and a writer reads it; neither names the other.\n *\n * ## One per process, on `globalThis`\n *\n * The tenant registers from `instrumentation.ts`, by name, at boot. Next\n * compiles that file apart from the routes, so a dropper held in a module\n * `let` was set in the boot's copy of this module and read as missing by\n * every route's copy — a form submission or an automation step that wrote a\n * row logged `no-live-page-dropper` and left the pages stale for the rest of\n * their window (AGL-3412 was the same split for the plugin services). Keyed on\n * `globalThis` under `Symbol.for`, every copy in a realm reads the one slot.\n */\n\n/** One site's share of a change: the pages on it that must be dropped. */\nexport interface LivePageTarget {\n hostId: string\n /** The tenant keys its cache on this, never on `hostId`. */\n subdomain: string\n /** The attached custom domain, whose pages live under a second cache key. */\n cname?: string\n /** Site-absolute addresses (`/`, `/team`) the change makes stale. */\n paths: string[]\n /** The site held more documents than the scan read, so `paths` is partial. */\n truncated: boolean\n}\n\n/**\n * Drops one site's cached pages. Returns whether the drop landed. It must\n * never throw: everything above it has already been written.\n */\nexport type LivePageDropper = (target: LivePageTarget) => Promise<boolean>\n\nconst DROPPER_KEY = Symbol.for('@aglyn/tenant-data-admin:live-page-dropper')\n\nconst globalScope = globalThis as typeof globalThis & {\n [DROPPER_KEY]?: LivePageDropper\n}\n\n/**\n * Registers the dropper for this process, replacing any previous one. Returns\n * the unregister, which removes it only if it is still the one registered.\n *\n * Called from a function the platform calls BY NAME at boot, never from a\n * module's top level: a bundler deletes a module imported only for its side\n * effect when its package says it has none (AGL-3025).\n */\nexport function registerLivePageDropper(drop: LivePageDropper): () => void {\n globalScope[DROPPER_KEY] = drop\n return () => {\n if (globalScope[DROPPER_KEY] === drop) delete globalScope[DROPPER_KEY]\n }\n}\n\n/** The dropper this process registered, or `undefined` when none did. */\nexport function registeredLivePageDropper(): LivePageDropper | undefined {\n return globalScope[DROPPER_KEY]\n}\n"],"names":["DROPPER_KEY","Symbol","for","globalScope","globalThis","registerLivePageDropper","drop","registeredLivePageDropper"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED,wEAAwE,GAmBxE,MAAMA,cAAcC,OAAOC,GAAG,CAAC;AAE/B,MAAMC,cAAcC;AAIpB;;;;;;;CAOC,GACD,OAAO,SAASC,wBAAwBC,IAAqB;IAC3DH,WAAW,CAACH,YAAY,GAAGM;IAC3B,OAAO;QACL,IAAIH,WAAW,CAACH,YAAY,KAAKM,MAAM,OAAOH,WAAW,CAACH,YAAY;IACxE;AACF;AAEA,uEAAuE,GACvE,OAAO,SAASO;IACd,OAAOJ,WAAW,CAACH,YAAY;AACjC"}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
* The Free cap, engaged by the media CDN (AGL-3474).
|
|
19
|
+
*
|
|
20
|
+
* The cap's marker (`orgs/{id}.bandwidthCap`) had two writers, and both are
|
|
21
|
+
* driven by PAGES: the analytics beacon, on a sample of page views, and the
|
|
22
|
+
* daily usage sweep. Video and files count against the same band now, but a
|
|
23
|
+
* film or a PDF linked from somewhere else is served with no page view at
|
|
24
|
+
* all — so a Free org whose traffic is its media would cross its band and
|
|
25
|
+
* keep serving until the next morning's sweep. A day of one popular clip is
|
|
26
|
+
* hundreds of gigabytes.
|
|
27
|
+
*
|
|
28
|
+
* So the CDN is a third writer, on the same terms as the other two: the same
|
|
29
|
+
* `bandwidthCapShouldEngage` predicate, the same marker fields, the same UTC
|
|
30
|
+
* month key, and a write only when the marker does not already name this
|
|
31
|
+
* month. A reader cannot tell which writer stamped a marker.
|
|
32
|
+
*
|
|
33
|
+
* ## The month it totals
|
|
34
|
+
*
|
|
35
|
+
* Org-wide, because the band is: every site's page views and counted media,
|
|
36
|
+
* plus the org library's media, read from the same analytics day documents
|
|
37
|
+
* the invoice reads, through the same {@link analyticsBandwidthReading}.
|
|
38
|
+
*
|
|
39
|
+
* ## What it costs
|
|
40
|
+
*
|
|
41
|
+
* Nothing for a paying org: the CDN asks only for an org whose plan the cap
|
|
42
|
+
* stops (`bandwidthCapApplies`, decided off the org document its delivery
|
|
43
|
+
* verdict already holds). For those, one evaluation per
|
|
44
|
+
* {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES} an instance serves — the org
|
|
45
|
+
* document, its site ids and the month's day documents, a few dozen reads
|
|
46
|
+
* against about three cents of delivery.
|
|
47
|
+
*
|
|
48
|
+
* ## The slop, stated
|
|
49
|
+
*
|
|
50
|
+
* Each instance evaluates on its first counted request for an org and then
|
|
51
|
+
* once per {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES}, so the cap engages at
|
|
52
|
+
* most that much per instance past the band, and the CDN's own verdict cache
|
|
53
|
+
* takes up to fifteen seconds to see the marker. The beacon's slop is the
|
|
54
|
+
* same shape, measured in views.
|
|
55
|
+
*/
|
|
56
|
+
/** How many counted bytes an instance serves an org between evaluations. */
|
|
57
|
+
export declare const MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES: number;
|
|
58
|
+
/** Drop the per-instance state. Tests need it between cases. */
|
|
59
|
+
export declare function invalidateMediaBandwidthCapState(): void;
|
|
60
|
+
/**
|
|
61
|
+
* Note `bytes` counted for `orgId`, and say whether this instance should now
|
|
62
|
+
* total the org's month.
|
|
63
|
+
*
|
|
64
|
+
* Due on the first counted request an instance sees for the org — a cold
|
|
65
|
+
* instance under a spike evaluates at once, which is when it matters — and
|
|
66
|
+
* then each time another {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES} has
|
|
67
|
+
* gone out. Never due again in a month this instance has seen it engaged.
|
|
68
|
+
*/
|
|
69
|
+
export declare function mediaBandwidthEvaluationDue(orgId: string, bytes: number, now?: Date): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Total `orgId`'s month and engage the cap if it is past the band. Answers
|
|
72
|
+
* whether THIS call engaged it.
|
|
73
|
+
*
|
|
74
|
+
* Best-effort, like the beacon's writer: it runs beside a delivery that has
|
|
75
|
+
* already been decided, and a failure here is logged and costs the request
|
|
76
|
+
* nothing.
|
|
77
|
+
*/
|
|
78
|
+
export declare function engageMediaBandwidthCap(orgId: string, now?: Date): Promise<boolean>;
|