@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.
Files changed (114) 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.d.ts +7 -0
  22. package/src/lib/server/hosted-page-review.js +144 -20
  23. package/src/lib/server/hosted-page-review.js.map +1 -1
  24. package/src/lib/server/leaving-notice.d.ts +98 -0
  25. package/src/lib/server/leaving-notice.js +250 -0
  26. package/src/lib/server/leaving-notice.js.map +1 -0
  27. package/src/lib/server/link-reputation-review.d.ts +58 -0
  28. package/src/lib/server/link-reputation-review.js +185 -0
  29. package/src/lib/server/link-reputation-review.js.map +1 -0
  30. package/src/lib/server/live-page-drops.d.ts +67 -0
  31. package/src/lib/server/live-page-drops.js +57 -0
  32. package/src/lib/server/live-page-drops.js.map +1 -0
  33. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  34. package/src/lib/server/media-bandwidth-cap.js +180 -0
  35. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  36. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  37. package/src/lib/server/media-cdn-icon.js +140 -0
  38. package/src/lib/server/media-cdn-icon.js.map +1 -0
  39. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  40. package/src/lib/server/media-delivery-regeneration.js +216 -0
  41. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  42. package/src/lib/server/media-delivery.d.ts +2 -0
  43. package/src/lib/server/media-delivery.js +2 -1
  44. package/src/lib/server/media-delivery.js.map +1 -1
  45. package/src/lib/server/media-serve-count.d.ts +50 -0
  46. package/src/lib/server/media-serve-count.js +62 -0
  47. package/src/lib/server/media-serve-count.js.map +1 -0
  48. package/src/lib/server/media-signing.d.ts +13 -2
  49. package/src/lib/server/media-signing.js +12 -8
  50. package/src/lib/server/media-signing.js.map +1 -1
  51. package/src/lib/server/media-tombstone.js +7 -5
  52. package/src/lib/server/media-tombstone.js.map +1 -1
  53. package/src/lib/server/media-variants.d.ts +151 -20
  54. package/src/lib/server/media-variants.js +204 -27
  55. package/src/lib/server/media-variants.js.map +1 -1
  56. package/src/lib/server/org-age.d.ts +15 -6
  57. package/src/lib/server/org-age.js +20 -8
  58. package/src/lib/server/org-age.js.map +1 -1
  59. package/src/lib/server/organizations.d.ts +102 -2
  60. package/src/lib/server/organizations.js +388 -58
  61. package/src/lib/server/organizations.js.map +1 -1
  62. package/src/lib/server/outbound-send-review.js +24 -6
  63. package/src/lib/server/outbound-send-review.js.map +1 -1
  64. package/src/lib/server/page-security-hold.d.ts +93 -0
  65. package/src/lib/server/page-security-hold.js +180 -0
  66. package/src/lib/server/page-security-hold.js.map +1 -0
  67. package/src/lib/server/paged-sweeps.d.ts +51 -0
  68. package/src/lib/server/paged-sweeps.js +79 -0
  69. package/src/lib/server/paged-sweeps.js.map +1 -0
  70. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  71. package/src/lib/server/platform-marketing-consent.js +27 -11
  72. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  73. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  74. package/src/lib/server/serve-media-cdn.js +424 -70
  75. package/src/lib/server/serve-media-cdn.js.map +1 -1
  76. package/src/lib/server/site-icon-render.d.ts +68 -0
  77. package/src/lib/server/site-icon-render.js +157 -0
  78. package/src/lib/server/site-icon-render.js.map +1 -0
  79. package/src/lib/server/staff-seat.d.ts +50 -0
  80. package/src/lib/server/staff-seat.js +118 -0
  81. package/src/lib/server/staff-seat.js.map +1 -0
  82. package/src/lib/server/transfer-export.d.ts +46 -0
  83. package/src/lib/server/transfer-export.js +221 -0
  84. package/src/lib/server/transfer-export.js.map +1 -0
  85. package/src/lib/server/transfer-jobs.d.ts +385 -0
  86. package/src/lib/server/transfer-jobs.js +2368 -0
  87. package/src/lib/server/transfer-jobs.js.map +1 -0
  88. package/src/lib/server/transfer-packages.d.ts +127 -0
  89. package/src/lib/server/transfer-packages.js +1036 -0
  90. package/src/lib/server/transfer-packages.js.map +1 -0
  91. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  92. package/src/lib/server/upgrade-proposal.js +312 -0
  93. package/src/lib/server/upgrade-proposal.js.map +1 -0
  94. package/src/lib/server/upsert-contact.d.ts +17 -8
  95. package/src/lib/server/upsert-contact.js +82 -16
  96. package/src/lib/server/upsert-contact.js.map +1 -1
  97. package/src/lib/server/web-risk.d.ts +139 -0
  98. package/src/lib/server/web-risk.js +521 -0
  99. package/src/lib/server/web-risk.js.map +1 -0
  100. package/src/tenancy.d.ts +3 -1
  101. package/src/tenancy.js +5 -1
  102. package/src/tenancy.js.map +1 -1
  103. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  104. package/src/lib/server/campaign-attribution-store.js +0 -95
  105. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  106. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  107. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  108. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  109. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  110. package/src/lib/server/dataset-live-pages.js +0 -421
  111. package/src/lib/server/dataset-live-pages.js.map +0 -1
  112. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  113. package/src/lib/server/email-revenue-attribution.js +0 -321
  114. 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('&amp;')) consider(trimmed.replace(/&amp;/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>;