@aglyn/tenant-data-admin 1.0.0-beta.219 → 1.0.0-beta.221
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/lib/server/api-http.js +5 -1
- package/src/lib/server/api-http.js.map +1 -1
- package/src/lib/server/api-v1-kit.d.ts +7 -0
- package/src/lib/server/api-v1-kit.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +1 -2
- package/src/lib/server/api-v1-resources.js +0 -1
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
- package/src/lib/server/campaign-conversion-attribution.js +3 -3
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/card-payment-velocity.d.ts +9 -0
- package/src/lib/server/card-payment-velocity.js +28 -2
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +1 -1
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/consent-group-change.js +2 -2
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +3 -3
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +2 -2
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/email-suppression.js +1 -1
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +32 -4
- package/src/lib/server/host-visitor-records.js +73 -14
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +34 -0
- package/src/lib/server/hosted-page-review.js +270 -22
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/leaving-notice.d.ts +98 -0
- package/src/lib/server/leaving-notice.js +250 -0
- package/src/lib/server/leaving-notice.js.map +1 -0
- package/src/lib/server/link-reputation-review.d.ts +54 -0
- package/src/lib/server/link-reputation-review.js +144 -0
- package/src/lib/server/link-reputation-review.js.map +1 -0
- package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
- package/src/lib/server/media-cdn-rate-limit.js +6 -3
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +9 -1
- package/src/lib/server/member-email-aliases.js +25 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/notifications.d.ts +14 -0
- package/src/lib/server/notifications.js +29 -0
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -1
- package/src/lib/server/operator-alerts.js +5 -3
- package/src/lib/server/operator-alerts.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/org-containers.d.ts +66 -0
- package/src/lib/server/org-containers.js +95 -0
- package/src/lib/server/org-containers.js.map +1 -0
- package/src/lib/server/org-member-notice.js +3 -2
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +23 -6
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/page-security-hold.d.ts +93 -0
- package/src/lib/server/page-security-hold.js +180 -0
- package/src/lib/server/page-security-hold.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.js +5 -1
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js +35 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -1
- package/src/lib/server/payment-risk-record.d.ts +1 -1
- package/src/lib/server/payment-risk-record.js +3 -1
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +1 -1
- package/src/lib/server/platform-marketing-consent.js +1 -1
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/risk-notice.d.ts +32 -1
- package/src/lib/server/risk-notice.js +90 -28
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +17 -0
- package/src/lib/server/sending-domains.js +28 -4
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +7 -2
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/staff-alert-email.js +4 -2
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
- package/src/lib/server/topic-subscriptions.js.map +1 -0
- package/src/lib/server/upsert-contact.js +2 -2
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +100 -0
- package/src/lib/server/web-risk.js +383 -0
- package/src/lib/server/web-risk.js.map +1 -0
- package/src/tenancy.d.ts +1 -3
- package/src/tenancy.js +1 -7
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/contact-email-engagement.d.ts +0 -32
- package/src/lib/server/contact-email-engagement.js +0 -145
- package/src/lib/server/contact-email-engagement.js.map +0 -1
- package/src/lib/server/crm-booking-activity.d.ts +0 -86
- package/src/lib/server/crm-booking-activity.js +0 -257
- package/src/lib/server/crm-booking-activity.js.map +0 -1
- package/src/lib/server/crm-inbound-email.d.ts +0 -168
- package/src/lib/server/crm-inbound-email.js +0 -282
- package/src/lib/server/crm-inbound-email.js.map +0 -1
- package/src/lib/server/email-topic-confirmation.js.map +0 -1
- /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/leaving-notice.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE \"YOU'RE LEAVING THIS SITE\" NOTICE, SERVER HALF (AGL-3452).\n *\n * Who gets it, which hosts are exempt, and the signature that keeps the\n * notice route from becoming an open redirect. The client half — rewriting\n * an href, the click interceptor — is `@aglyn/aglyn/app-utils/leaving-notice`.\n *\n * ## Who\n *\n * A site whose workspace is on the FREE plan (the effective plan, so a dead\n * subscription counts as free, as it does for every entitlement) and in its\n * first {@link LEAVING_NOTICE_DAYS} days — the same window, read through the\n * same `isYoungWorkspaceAge`, as the soft phishing rules. Paid and older\n * workspaces are untouched, and a workspace whose creation date cannot be\n * read is an existing customer, never a new one.\n *\n * ## Why signed\n *\n * The notice lives on the site's own host and its Continue button goes\n * wherever `?to=` says. Unsigned, any site's notice would be a page that\n * vouches for any address anyone puts in a link — the hop this feature exists\n * to remove. So the server signs each outside address it finds on a page it\n * renders, bound to that site, and the route shows a Continue button only for\n * an address whose signature checks out. Everything else is refused.\n *\n * The signature carries no expiry on purpose. The route never redirects on\n * its own, so a signature that outlives its page buys nothing but one more\n * view of the same warning — while an expiring one would break the links an\n * ISR-cached page is still serving the moment the window closes.\n *=========================================*/\n\nimport { createHmac } from 'crypto'\nimport { DOCS_BASE_URL } from '@aglyn/aglyn/app-utils/docs-help'\nimport {\n type LeavingNoticeConfig,\n leavingDestination,\n normalizeLeavingDestination,\n} from '@aglyn/aglyn/app-utils/leaving-notice'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n PLATFORM_HOME_URL,\n PLATFORM_SUPPORT_URL,\n platformConsoleOrigin,\n} from '@aglyn/aglyn/app-utils/platform-brand'\nimport {\n type SiteReturnHost,\n siteOwnHosts,\n} from '@aglyn/aglyn/app-utils/site-return-url'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/tenant-apex'\nimport {\n isYoungWorkspaceAge,\n OUTBOUND_REVIEW_YOUNG_DAYS,\n registrableDomain,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { tokenSigningSecret } from './media-signing'\nimport { orgAgeDays, orgCreatedMs } from './org-age'\nimport { safeEqual } from './safe-equal'\n\n/** How long a new free workspace's sites carry the notice. */\nexport const LEAVING_NOTICE_DAYS = OUTBOUND_REVIEW_YOUNG_DAYS\n\nconst DAY_MS = 86_400_000\n\n/**\n * The most outside addresses one page is signed for. A page with more is a\n * link farm, and the ones past the cap still go through the notice — which\n * refuses them — rather than around it.\n */\nexport const LEAVING_NOTICE_MAX_DESTINATIONS = 500\n\n/** Deepest a page payload is walked; composed node maps sit far above it. */\nconst MAX_WALK_DEPTH = 64\n\n/** Longest string scanned for embedded addresses (a long entry body). */\nconst MAX_SCANNED_STRING = 200_000\n\n/**\n * When a workspace's notice window closes (epoch ms), or `null` when its\n * sites carry no notice: a paid plan, a workspace past its first\n * {@link LEAVING_NOTICE_DAYS} days, or one whose creation date is unreadable.\n *\n * Takes the org as loaded anywhere — a live document, or the render cache's\n * JSON copy, whose `createdAt` has lost its methods (`orgCreatedMs`).\n */\nexport function leavingNoticeEndsAt(\n org: unknown,\n nowMs: number = Date.now(),\n): number | null {\n if (!org || typeof org !== 'object') return null\n if (resolveEffectivePlan(org as never) !== 'free') return null\n const createdAt = (org as { createdAt?: unknown }).createdAt\n if (!isYoungWorkspaceAge(orgAgeDays(createdAt, nowMs))) return null\n const createdMs = orgCreatedMs(createdAt)\n return createdMs === null ? null : createdMs + LEAVING_NOTICE_DAYS * DAY_MS\n}\n\nfunction hostnameOf(raw: string | null | undefined): string | null {\n if (!raw) return null\n try {\n const url = new URL(raw)\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n return url.hostname.toLowerCase() || null\n } catch {\n return null\n }\n}\n\n/**\n * The hosts a link may reach without the notice: the site's own (its\n * subdomain, its custom domain and `www.` of it) and the platform's own — the\n * marketing site, console, support and docs, each with every name under its\n * registrable domain, plus the bare tenant apex.\n *\n * Other sites on the tenant apex are NOT exempt. They are other customers'\n * sites, and a chain of two new free sites is the same hop as one.\n */\nexport function leavingNoticeHosts(\n site: SiteReturnHost | null | undefined,\n): string[] {\n const hosts = new Set<string>(siteOwnHosts(site))\n const apex = TENANT_APEX.toLowerCase()\n hosts.add(apex)\n const apexRegistrable = registrableDomain(apex)\n for (const raw of [\n PLATFORM_HOME_URL,\n platformConsoleOrigin(),\n PLATFORM_SUPPORT_URL,\n DOCS_BASE_URL,\n ]) {\n const hostname = hostnameOf(raw)\n if (!hostname) continue\n hosts.add(hostname)\n const registrable = registrableDomain(hostname)\n // A platform name under the tenant apex itself must not exempt the apex:\n // that would exempt every customer's site with it.\n if (registrable.includes('.') && registrable !== apexRegistrable) {\n hosts.add(`.${registrable}`)\n }\n }\n return [...hosts]\n}\n\n/** An address as it appears in text: scheme, then anything a URL may hold. */\nconst EMBEDDED_URL = /https?:\\/\\/[^\\s\"'<>()[\\]{}\\\\^`|]+/gi\n\n/** Characters prose puts after an address that the address does not own. */\nconst TRAILING_PROSE = /[.,;:!?'\"*_~]+$/\n\n/** A whole value shaped like an absolute or protocol-relative address. */\nconst ADDRESS_SHAPE = /^\\s*(?:https?:|[\\\\/]{2})/i\n\n/**\n * Every outside address a rendered page could link to: each string in the\n * payload that is one, and each one embedded in a string (Markdown, rich\n * text HTML, a plain sentence). Over-collecting is harmless — a signature\n * nobody follows is never read — and under-collecting only sends a link to a\n * refusal, never around the notice.\n *\n * Returned normalized, the spelling the client looks signatures up by.\n */\nexport function collectLeavingDestinations(\n value: unknown,\n hosts: readonly string[],\n limit: number = LEAVING_NOTICE_MAX_DESTINATIONS,\n): string[] {\n const found = new Set<string>()\n const consider = (candidate: string) => {\n if (found.size >= limit) return\n const destination = leavingDestination(candidate, hosts)\n if (destination) found.add(destination)\n }\n const scan = (text: string) => {\n if (ADDRESS_SHAPE.test(text)) consider(text)\n if (!text.includes('http')) return\n const scanned =\n text.length > MAX_SCANNED_STRING ? text.slice(0, MAX_SCANNED_STRING) : text\n for (const match of scanned.matchAll(EMBEDDED_URL)) {\n const raw = match[0]\n consider(raw)\n const trimmed = raw.replace(TRAILING_PROSE, '')\n if (trimmed !== raw) consider(trimmed)\n // An href inside stored HTML is entity-encoded; the DOM decodes it.\n if (trimmed.includes('&')) consider(trimmed.replace(/&/g, '&'))\n }\n }\n const walk = (node: unknown, depth: number) => {\n if (found.size >= limit || depth > MAX_WALK_DEPTH) return\n if (typeof node === 'string') {\n scan(node)\n } else if (Array.isArray(node)) {\n for (const item of node) walk(item, depth + 1)\n } else if (node && typeof node === 'object') {\n for (const item of Object.values(node)) walk(item, depth + 1)\n }\n }\n walk(value, 0)\n return [...found]\n}\n\nfunction leavingSignature(hostId: string, destination: string): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`leaving-notice:v1:${hostId}:${destination}`)\n .digest('base64url')\n}\n\n/**\n * The signature for one destination on one site. Throws when the deployment\n * has no signing secret, like every signer of it.\n */\nexport function signLeavingDestination(\n hostId: string,\n destination: string,\n): string {\n const normalized = normalizeLeavingDestination(destination)\n if (!hostId || !normalized) {\n throw new Error('A leaving-notice signature needs a site and an address')\n }\n return leavingSignature(hostId, normalized)\n}\n\n/**\n * Whether `signature` was issued for `destination` on this site. False for\n * anything else — another site's signature, an edited address, a missing\n * secret (fail closed).\n */\nexport function verifyLeavingDestination(\n hostId: string,\n destination: unknown,\n signature: unknown,\n): boolean {\n if (!hostId || typeof signature !== 'string' || signature.length > 128) {\n return false\n }\n const normalized = normalizeLeavingDestination(destination)\n if (!normalized) return false\n let expected: string\n try {\n expected = leavingSignature(hostId, normalized)\n } catch {\n return false\n }\n return safeEqual(signature, expected)\n}\n\n/** Signs a page's destinations for one site. */\nexport function signLeavingDestinations(\n hostId: string,\n destinations: readonly string[],\n): Record<string, string> {\n const sigs: Record<string, string> = {}\n for (const destination of destinations) {\n sigs[destination] = leavingSignature(hostId, destination)\n }\n return sigs\n}\n\n/** Whether this deployment can sign at all. */\nfunction canSign(): boolean {\n try {\n tokenSigningSecret()\n return true\n } catch {\n return false\n }\n}\n\n/**\n * What a published page of this site carries, or `null` when the site is not\n * in its window.\n *\n * `content` is everything the page renders from — its props, its nodes —\n * walked for outside addresses to sign.\n *\n * A deployment with no signing secret gets no notice, loudly. The route\n * could verify nothing there and would refuse every link on every new free\n * site; an outage of every outbound link is not a safer failure than the\n * links working as they did before this existed.\n */\nexport function leavingNoticeConfig(options: {\n hostId: string | null | undefined\n site: SiteReturnHost | null | undefined\n org: unknown\n content?: unknown\n nowMs?: number\n}): LeavingNoticeConfig | null {\n const { hostId, site, org, content } = options\n if (!hostId || !site) return null\n const until = leavingNoticeEndsAt(org, options.nowMs)\n if (until === null) return null\n if (!canSign()) {\n console.error(\n '[leaving-notice] TOKEN_SIGNING_SECRET is not configured; the notice is off',\n )\n return null\n }\n const hosts = leavingNoticeHosts(site)\n return {\n until,\n hosts,\n sigs: signLeavingDestinations(\n hostId,\n collectLeavingDestinations(content, hosts),\n ),\n }\n}\n\n/**\n * Only the signatures, for a payload that arrives after its page — a gated\n * screen's nodes (`enrichGatedScreenPage`). `null` outside the window.\n */\nexport function leavingNoticeSignatures(options: {\n hostId: string | null | undefined\n site: SiteReturnHost | null | undefined\n org: unknown\n content: unknown\n nowMs?: number\n}): Record<string, string> | null {\n const config = leavingNoticeConfig(options)\n return config && Object.keys(config.sigs).length ? config.sigs : null\n}\n"],"names":["createHmac","DOCS_BASE_URL","leavingDestination","normalizeLeavingDestination","resolveEffectivePlan","PLATFORM_HOME_URL","PLATFORM_SUPPORT_URL","platformConsoleOrigin","siteOwnHosts","TENANT_APEX","isYoungWorkspaceAge","OUTBOUND_REVIEW_YOUNG_DAYS","registrableDomain","tokenSigningSecret","orgAgeDays","orgCreatedMs","safeEqual","LEAVING_NOTICE_DAYS","DAY_MS","LEAVING_NOTICE_MAX_DESTINATIONS","MAX_WALK_DEPTH","MAX_SCANNED_STRING","leavingNoticeEndsAt","org","nowMs","Date","now","createdAt","createdMs","hostnameOf","raw","url","URL","protocol","hostname","toLowerCase","leavingNoticeHosts","site","hosts","Set","apex","add","apexRegistrable","registrable","includes","EMBEDDED_URL","TRAILING_PROSE","ADDRESS_SHAPE","collectLeavingDestinations","value","limit","found","consider","candidate","size","destination","scan","text","test","scanned","length","slice","match","matchAll","trimmed","replace","walk","node","depth","Array","isArray","item","Object","values","leavingSignature","hostId","update","digest","signLeavingDestination","normalized","Error","verifyLeavingDestination","signature","expected","signLeavingDestinations","destinations","sigs","canSign","leavingNoticeConfig","options","content","until","console","error","leavingNoticeSignatures","config","keys"],"mappings":"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"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/** What one chunk of the re-check did. */
|
|
18
|
+
export interface LivePageLinkRecheck {
|
|
19
|
+
/** Sites this chunk walked. */
|
|
20
|
+
sites: number;
|
|
21
|
+
/** Live pages with foreign links among them. */
|
|
22
|
+
pages: number;
|
|
23
|
+
/** Distinct foreign hosts looked up. */
|
|
24
|
+
hosts: number;
|
|
25
|
+
/** Of those, how many Web Risk lists. */
|
|
26
|
+
listed: number;
|
|
27
|
+
/** Hosts nobody could answer for: not evidence, looked up again next time. */
|
|
28
|
+
unknown: number;
|
|
29
|
+
/** Pages sent back through the review with a listed host. */
|
|
30
|
+
reviewed: number;
|
|
31
|
+
/** Of those, how many the review held (or found already rejected). */
|
|
32
|
+
held: number;
|
|
33
|
+
/** The sites a hold was placed on, whose cached pages need dropping. */
|
|
34
|
+
heldHostIds: string[];
|
|
35
|
+
nextCursor: string | null;
|
|
36
|
+
done: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** Sites walked per chunk. */
|
|
39
|
+
export declare const LINK_RECHECK_SITES_PER_CHUNK = 200;
|
|
40
|
+
/**
|
|
41
|
+
* Re-check the foreign hosts of every live page, a chunk of sites at a time
|
|
42
|
+
* (`cursor` resumes after the last site of the previous chunk). A site that
|
|
43
|
+
* is locked is skipped: nothing of it serves. A page with a newly listed host
|
|
44
|
+
* goes back through {@link reviewHostedPage} with that listing as an extra
|
|
45
|
+
* signal, so what a hold does is decided in one place.
|
|
46
|
+
*/
|
|
47
|
+
export declare function recheckLivePageLinks(options?: {
|
|
48
|
+
cursor?: string | null;
|
|
49
|
+
sitesPerChunk?: number;
|
|
50
|
+
nowMs?: number;
|
|
51
|
+
firestore?: FirebaseFirestore.Firestore;
|
|
52
|
+
/** Walk and count only: no lookup, no review. */
|
|
53
|
+
dryRun?: boolean;
|
|
54
|
+
}): Promise<LivePageLinkRecheck>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /*==========================================
|
|
18
|
+
* THE DAILY LINK RE-CHECK (AGL-3451).
|
|
19
|
+
*
|
|
20
|
+
* The page review (`hosted-page-review.ts`), the redirect review and the send
|
|
21
|
+
* seam look up every foreign host a page, a redirect or a message points at
|
|
22
|
+
* as it goes out. What is already live is not re-reviewed until somebody
|
|
23
|
+
* publishes it again — and a harvester is often listed only after the page
|
|
24
|
+
* linking to it went up.
|
|
25
|
+
*
|
|
26
|
+
* So {@link recheckLivePageLinks} walks every live page's foreign hosts, as
|
|
27
|
+
* the review noted them beside the version it served
|
|
28
|
+
* (`pageReviews/{screenId}.foreignHosts`), looks them up again, and sends
|
|
29
|
+
* each page with a newly listed host back through `reviewHostedPage` with the
|
|
30
|
+
* listing as an extra signal — the same path, so it is held or flagged, its
|
|
31
|
+
* row filed, and a young workspace's security hold requested (AGL-3450),
|
|
32
|
+
* exactly as at render. The console's `/api/admin/web-risk-recheck` runs it
|
|
33
|
+
* a chunk of sites at a time.
|
|
34
|
+
*=========================================*/ import { MAX_REPUTATION_HOSTS_PER_LOOKUP, webRiskSignals } from "@aglyn/shared-util-email/link-reputation";
|
|
35
|
+
import { FieldPath } from "firebase-admin/firestore";
|
|
36
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
37
|
+
import { PAGE_REVIEW_SUBCOLLECTION, reviewHostedPage } from "./hosted-page-review.js";
|
|
38
|
+
import { lookupHostReputation } from "./web-risk.js";
|
|
39
|
+
/** Sites walked per chunk. */ export const LINK_RECHECK_SITES_PER_CHUNK = 200;
|
|
40
|
+
/** Sites whose page notes are read at once. */ const SITE_READ_CONCURRENCY = 20;
|
|
41
|
+
/** The budget of each group of hosts looked up within a chunk. */ const RECHECK_DEADLINE_MS = 15000;
|
|
42
|
+
/**
|
|
43
|
+
* Re-check the foreign hosts of every live page, a chunk of sites at a time
|
|
44
|
+
* (`cursor` resumes after the last site of the previous chunk). A site that
|
|
45
|
+
* is locked is skipped: nothing of it serves. A page with a newly listed host
|
|
46
|
+
* goes back through {@link reviewHostedPage} with that listing as an extra
|
|
47
|
+
* signal, so what a hold does is decided in one place.
|
|
48
|
+
*/ export async function recheckLivePageLinks(options = {}) {
|
|
49
|
+
var _options_firestore, _options_nowMs, _options_sitesPerChunk, _ref;
|
|
50
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
51
|
+
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
52
|
+
const limit = (_options_sitesPerChunk = options.sitesPerChunk) != null ? _options_sitesPerChunk : LINK_RECHECK_SITES_PER_CHUNK;
|
|
53
|
+
let query = firestore.collection('hosts').orderBy(FieldPath.documentId()).select('suspendedAt').limit(limit);
|
|
54
|
+
if (options.cursor) query = query.startAfter(options.cursor);
|
|
55
|
+
const sites = await query.get();
|
|
56
|
+
const pages = [];
|
|
57
|
+
const live = sites.docs.filter((site)=>site.get('suspendedAt') == null);
|
|
58
|
+
for(let at = 0; at < live.length; at += SITE_READ_CONCURRENCY){
|
|
59
|
+
const batch = live.slice(at, at + SITE_READ_CONCURRENCY);
|
|
60
|
+
const notes = await Promise.all(batch.map((site)=>site.ref.collection(PAGE_REVIEW_SUBCOLLECTION).where('foreignHostCount', '>', 0).limit(500).get().then((reviews)=>({
|
|
61
|
+
site,
|
|
62
|
+
reviews
|
|
63
|
+
}))));
|
|
64
|
+
for (const { site, reviews } of notes){
|
|
65
|
+
for (const review of reviews.docs){
|
|
66
|
+
const versionId = review.get('servedVersionId');
|
|
67
|
+
const hosts = review.get('foreignHosts');
|
|
68
|
+
if (typeof versionId !== 'string' || !versionId || !Array.isArray(hosts)) continue;
|
|
69
|
+
pages.push({
|
|
70
|
+
hostId: site.id,
|
|
71
|
+
screenId: review.id,
|
|
72
|
+
versionId,
|
|
73
|
+
hosts: hosts.filter((host)=>typeof host === 'string')
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const distinct = [
|
|
79
|
+
...new Set(pages.flatMap((page)=>page.hosts))
|
|
80
|
+
];
|
|
81
|
+
const last = sites.docs[sites.docs.length - 1];
|
|
82
|
+
const chunk = {
|
|
83
|
+
sites: sites.size,
|
|
84
|
+
pages: pages.length,
|
|
85
|
+
hosts: distinct.length,
|
|
86
|
+
nextCursor: sites.size < limit ? null : (_ref = last == null ? void 0 : last.id) != null ? _ref : null,
|
|
87
|
+
done: sites.size < limit
|
|
88
|
+
};
|
|
89
|
+
if (options.dryRun) {
|
|
90
|
+
return _extends({}, chunk, {
|
|
91
|
+
listed: 0,
|
|
92
|
+
unknown: 0,
|
|
93
|
+
reviewed: 0,
|
|
94
|
+
held: 0,
|
|
95
|
+
heldHostIds: []
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const listed = new Map();
|
|
99
|
+
let unknown = 0;
|
|
100
|
+
for(let at = 0; at < distinct.length; at += MAX_REPUTATION_HOSTS_PER_LOOKUP){
|
|
101
|
+
const answer = await lookupHostReputation(distinct.slice(at, at + MAX_REPUTATION_HOSTS_PER_LOOKUP), {
|
|
102
|
+
deadlineMs: RECHECK_DEADLINE_MS,
|
|
103
|
+
nowMs
|
|
104
|
+
});
|
|
105
|
+
for (const hit of answer.hits)listed.set(hit.host, hit);
|
|
106
|
+
unknown += answer.unknown.length;
|
|
107
|
+
}
|
|
108
|
+
let reviewed = 0;
|
|
109
|
+
let held = 0;
|
|
110
|
+
const heldHostIds = new Set();
|
|
111
|
+
for (const page of pages){
|
|
112
|
+
const hits = page.hosts.map((host)=>listed.get(host)).filter(Boolean);
|
|
113
|
+
if (!hits.length) continue;
|
|
114
|
+
reviewed += 1;
|
|
115
|
+
try {
|
|
116
|
+
const outcome = await reviewHostedPage({
|
|
117
|
+
hostId: page.hostId,
|
|
118
|
+
screenId: page.screenId,
|
|
119
|
+
versionId: page.versionId,
|
|
120
|
+
// The page itself is not composed here: the listing is the finding.
|
|
121
|
+
nodes: {},
|
|
122
|
+
extraSignals: webRiskSignals(hits),
|
|
123
|
+
nowMs
|
|
124
|
+
});
|
|
125
|
+
if (outcome.outcome !== 'serve') {
|
|
126
|
+
held += 1;
|
|
127
|
+
heldHostIds.add(page.hostId);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error('[link-reputation] a live page could not be re-reviewed', page.hostId, page.screenId, error);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return _extends({}, chunk, {
|
|
134
|
+
listed: listed.size,
|
|
135
|
+
unknown,
|
|
136
|
+
reviewed,
|
|
137
|
+
held,
|
|
138
|
+
heldHostIds: [
|
|
139
|
+
...heldHostIds
|
|
140
|
+
]
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
//# sourceMappingURL=link-reputation-review.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/link-reputation-review.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE DAILY LINK RE-CHECK (AGL-3451).\n *\n * The page review (`hosted-page-review.ts`), the redirect review and the send\n * seam look up every foreign host a page, a redirect or a message points at\n * as it goes out. What is already live is not re-reviewed until somebody\n * publishes it again — and a harvester is often listed only after the page\n * linking to it went up.\n *\n * So {@link recheckLivePageLinks} walks every live page's foreign hosts, as\n * the review noted them beside the version it served\n * (`pageReviews/{screenId}.foreignHosts`), looks them up again, and sends\n * each page with a newly listed host back through `reviewHostedPage` with the\n * listing as an extra signal — the same path, so it is held or flagged, its\n * row filed, and a young workspace's security hold requested (AGL-3450),\n * exactly as at render. The console's `/api/admin/web-risk-recheck` runs it\n * a chunk of sites at a time.\n *=========================================*/\n\nimport {\n type HostReputationHit,\n MAX_REPUTATION_HOSTS_PER_LOOKUP,\n webRiskSignals,\n} from '@aglyn/shared-util-email/link-reputation'\nimport { FieldPath } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { PAGE_REVIEW_SUBCOLLECTION, reviewHostedPage } from './hosted-page-review'\nimport { lookupHostReputation } from './web-risk'\n\n/** One live page whose foreign hosts the review noted. */\ninterface LivePageLinks {\n hostId: string\n screenId: string\n versionId: string\n hosts: string[]\n}\n\n/** What one chunk of the re-check did. */\nexport interface LivePageLinkRecheck {\n /** Sites this chunk walked. */\n sites: number\n /** Live pages with foreign links among them. */\n pages: number\n /** Distinct foreign hosts looked up. */\n hosts: number\n /** Of those, how many Web Risk lists. */\n listed: number\n /** Hosts nobody could answer for: not evidence, looked up again next time. */\n unknown: number\n /** Pages sent back through the review with a listed host. */\n reviewed: number\n /** Of those, how many the review held (or found already rejected). */\n held: number\n /** The sites a hold was placed on, whose cached pages need dropping. */\n heldHostIds: string[]\n nextCursor: string | null\n done: boolean\n}\n\n/** Sites walked per chunk. */\nexport const LINK_RECHECK_SITES_PER_CHUNK = 200\n/** Sites whose page notes are read at once. */\nconst SITE_READ_CONCURRENCY = 20\n/** The budget of each group of hosts looked up within a chunk. */\nconst RECHECK_DEADLINE_MS = 15_000\n\n/**\n * Re-check the foreign hosts of every live page, a chunk of sites at a time\n * (`cursor` resumes after the last site of the previous chunk). A site that\n * is locked is skipped: nothing of it serves. A page with a newly listed host\n * goes back through {@link reviewHostedPage} with that listing as an extra\n * signal, so what a hold does is decided in one place.\n */\nexport async function recheckLivePageLinks(\n options: {\n cursor?: string | null\n sitesPerChunk?: number\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n /** Walk and count only: no lookup, no review. */\n dryRun?: boolean\n } = {},\n): Promise<LivePageLinkRecheck> {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const nowMs = options.nowMs ?? Date.now()\n const limit = options.sitesPerChunk ?? LINK_RECHECK_SITES_PER_CHUNK\n let query = firestore\n .collection('hosts')\n .orderBy(FieldPath.documentId())\n .select('suspendedAt')\n .limit(limit)\n if (options.cursor) query = query.startAfter(options.cursor)\n const sites = await query.get()\n\n const pages: LivePageLinks[] = []\n const live = sites.docs.filter((site) => site.get('suspendedAt') == null)\n for (let at = 0; at < live.length; at += SITE_READ_CONCURRENCY) {\n const batch = live.slice(at, at + SITE_READ_CONCURRENCY)\n const notes = await Promise.all(\n batch.map((site) =>\n site.ref\n .collection(PAGE_REVIEW_SUBCOLLECTION)\n .where('foreignHostCount', '>', 0)\n .limit(500)\n .get()\n .then((reviews) => ({ site, reviews })),\n ),\n )\n for (const { site, reviews } of notes) {\n for (const review of reviews.docs) {\n const versionId = review.get('servedVersionId')\n const hosts = review.get('foreignHosts')\n if (typeof versionId !== 'string' || !versionId || !Array.isArray(hosts)) continue\n pages.push({\n hostId: site.id,\n screenId: review.id,\n versionId,\n hosts: hosts.filter((host): host is string => typeof host === 'string'),\n })\n }\n }\n }\n\n const distinct = [...new Set(pages.flatMap((page) => page.hosts))]\n const last = sites.docs[sites.docs.length - 1]\n const chunk = {\n sites: sites.size,\n pages: pages.length,\n hosts: distinct.length,\n nextCursor: sites.size < limit ? null : (last?.id ?? null),\n done: sites.size < limit,\n }\n if (options.dryRun) {\n return { ...chunk, listed: 0, unknown: 0, reviewed: 0, held: 0, heldHostIds: [] }\n }\n const listed = new Map<string, HostReputationHit>()\n let unknown = 0\n for (let at = 0; at < distinct.length; at += MAX_REPUTATION_HOSTS_PER_LOOKUP) {\n const answer = await lookupHostReputation(\n distinct.slice(at, at + MAX_REPUTATION_HOSTS_PER_LOOKUP),\n { deadlineMs: RECHECK_DEADLINE_MS, nowMs },\n )\n for (const hit of answer.hits) listed.set(hit.host, hit)\n unknown += answer.unknown.length\n }\n\n let reviewed = 0\n let held = 0\n const heldHostIds = new Set<string>()\n for (const page of pages) {\n const hits = page.hosts.map((host) => listed.get(host)).filter(Boolean) as HostReputationHit[]\n if (!hits.length) continue\n reviewed += 1\n try {\n const outcome = await reviewHostedPage({\n hostId: page.hostId,\n screenId: page.screenId,\n versionId: page.versionId,\n // The page itself is not composed here: the listing is the finding.\n nodes: {},\n extraSignals: webRiskSignals(hits),\n nowMs,\n })\n if (outcome.outcome !== 'serve') {\n held += 1\n heldHostIds.add(page.hostId)\n }\n } catch (error) {\n console.error('[link-reputation] a live page could not be re-reviewed', page.hostId, page.screenId, error)\n }\n }\n\n return {\n ...chunk,\n listed: listed.size,\n unknown,\n reviewed,\n held,\n heldHostIds: [...heldHostIds],\n }\n}\n"],"names":["MAX_REPUTATION_HOSTS_PER_LOOKUP","webRiskSignals","FieldPath","firebaseAdmin","PAGE_REVIEW_SUBCOLLECTION","reviewHostedPage","lookupHostReputation","LINK_RECHECK_SITES_PER_CHUNK","SITE_READ_CONCURRENCY","RECHECK_DEADLINE_MS","recheckLivePageLinks","options","firestore","app","nowMs","Date","now","limit","sitesPerChunk","query","collection","orderBy","documentId","select","cursor","startAfter","sites","get","pages","live","docs","filter","site","at","length","batch","slice","notes","Promise","all","map","ref","where","then","reviews","review","versionId","hosts","Array","isArray","push","hostId","id","screenId","host","distinct","Set","flatMap","page","last","chunk","size","nextCursor","done","dryRun","listed","unknown","reviewed","held","heldHostIds","Map","answer","deadlineMs","hit","hits","set","Boolean","outcome","nodes","extraSignals","add","error","console"],"mappings":"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"}
|
|
@@ -22,9 +22,12 @@ import { type ClientIpHeaders } from '@aglyn/aglyn/app-utils/request-ip';
|
|
|
22
22
|
* much of it one caller could take. The firewall bypasses the route on
|
|
23
23
|
* purpose, because link-preview crawlers and Gmail's image proxy cannot solve
|
|
24
24
|
* a challenge ("Public asset delivery bypass" in
|
|
25
|
-
* `tools/scripts/lib/firewall-posture.mjs`). Both middlewares exclude `/api
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* `tools/scripts/lib/firewall-posture.mjs`). Both middlewares exclude `/api/*`.
|
|
26
|
+
* The edge counts the path too, ahead of that bypass ("Media CDN per-IP rate
|
|
27
|
+
* limit", 1,500 requests a minute per address), but it sees one stream of
|
|
28
|
+
* requests per address: only `serveMediaCdn` can tell an image from a film,
|
|
29
|
+
* skip a 304, and fold an IPv6 address to its /64, so the ceilings on what one
|
|
30
|
+
* caller is served live here.
|
|
28
31
|
*
|
|
29
32
|
* ## What is counted
|
|
30
33
|
*
|
|
@@ -22,9 +22,12 @@ import { consumeRateLimit } from "./rate-limit-store.js";
|
|
|
22
22
|
* much of it one caller could take. The firewall bypasses the route on
|
|
23
23
|
* purpose, because link-preview crawlers and Gmail's image proxy cannot solve
|
|
24
24
|
* a challenge ("Public asset delivery bypass" in
|
|
25
|
-
* `tools/scripts/lib/firewall-posture.mjs`). Both middlewares exclude `/api
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* `tools/scripts/lib/firewall-posture.mjs`). Both middlewares exclude `/api/*`.
|
|
26
|
+
* The edge counts the path too, ahead of that bypass ("Media CDN per-IP rate
|
|
27
|
+
* limit", 1,500 requests a minute per address), but it sees one stream of
|
|
28
|
+
* requests per address: only `serveMediaCdn` can tell an image from a film,
|
|
29
|
+
* skip a 304, and fold an IPv6 address to its /64, so the ceilings on what one
|
|
30
|
+
* caller is served live here.
|
|
28
31
|
*
|
|
29
32
|
* ## What is counted
|
|
30
33
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-cdn-rate-limit.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\nimport {\n type ClientIpHeaders,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\nimport { consumeRateLimit } from './rate-limit-store'\n\n/**\n * The media CDN's per-caller limit (AGL-2812).\n *\n * `/api/media/cdn/*` is anonymous public delivery, and nothing bounded how\n * much of it one caller could take. The firewall bypasses the route on\n * purpose, because link-preview crawlers and Gmail's image proxy cannot solve\n * a challenge (\"Public asset delivery bypass\" in\n * `tools/scripts/lib/firewall-posture.mjs`). Both middlewares exclude `/api/*`,\n * and no WAF rule covers the path. `serveMediaCdn` is the one piece of code\n * every request passes through, so the limit lives there.\n *\n * ## What is counted\n *\n * A GET that gets past every access gate, the lockdown and quarantine\n * refusals and the 304 exit, which is where the Storage reads begin. Those\n * refusals, a 304 and a HEAD send no file, so a revalidating browser, a\n * crawler's HEAD and a probe for a deleted id spend nothing. The counter's\n * Firestore write is paid only where the delivery it guards is about to cost\n * more. The count starts beside the metadata read, so a request that then\n * finds no object in Storage, or asks for an unsatisfiable range, has been\n * counted too.\n *\n * ## Two budgets, split where the edge splits them\n *\n * Images and everything else are counted separately, on the line\n * `mediaCdnEdgeCacheable` already draws (AGL-1515):\n *\n * - **Images** are held by the edge, so only a miss reaches the function, and\n * images are what link-preview crawlers and Gmail's image proxy fetch.\n * - **Everything else** (video, audio, PDFs, documents) is `private`, so every\n * request reaches the function, and it is where the bytes are: a video\n * master is a single request for up to 200 MB.\n *\n * Separate keys also mean a DAM grid's burst of tiles cannot spend the budget\n * the video on the same page needs, or the reverse.\n *\n * ## Thresholds, from measured traffic\n *\n * Read from both mounts' production request logs on 2026-09-11: the tenant's\n * last seven days (2026-09-04 08:01 to 2026-09-11 07:56 UTC), and the\n * console's last sixteen hours, which is all it retains. Only requests that\n * reached the function count, since an edge hit never runs this code.\n *\n * | measured | busiest minute, whole route |\n * | -- | -- |\n * | image deliveries (200 and 206) | 98 |\n * | every other delivery (200 and 206) | 31 |\n * | every request, with 304s, 404s and edge hits | 236 |\n * | link-preview crawlers and other bots, together | 32 |\n * | Gmail's image proxy | none in the sample |\n *\n * Each of those minutes was one browser: the image peak was a single Chrome\n * session pulling 129 distinct assets, and the non-image peak a single session\n * following 31 `?download=1` links. No asset took more than 4 range requests\n * in a minute. Over thirty days the day documents count 30,855 origin serves\n * in all, and 4,234 on the busiest day.\n *\n * Each ceiling is at least five times the busiest minute the whole route\n * served in its class. No single address can reach it with a pattern the route\n * has actually seen, even if every request in that minute had been its own:\n *\n * - {@link MEDIA_CDN_IMAGE_RATE_LIMIT}, 600: 6.1 times the image peak, and more\n * than 18 times every crawler together.\n * - {@link MEDIA_CDN_NON_IMAGE_RATE_LIMIT}, 180: 5.8 times the non-image peak.\n * Tenant video uploads are paused (AGL-2830), so film playback is barely in\n * the sample. A page of twelve films at four ranges a minute each still fits\n * more than three times over.\n *\n * ## Keyed on the caller alone\n *\n * The key is the caller's address and the class: not the site, the asset or\n * the query string. Cycling media ids, cache-busting a URL with a junk query\n * parameter, or switching between the tenant and console mounts all land in\n * the same counter.\n *\n * The address comes from `readClientIp`, which reads the hop the platform\n * wrote rather than one a caller can prepend. An IPv6 address counts by its\n * `/64`: one subscriber is routinely handed a whole `/64`, so keying on the\n * full address would give that subscriber 2^64 budgets.\n *\n * A request with no readable address is not counted at all. One shared bucket\n * for every unaddressable caller would refuse all of them together the moment\n * a proxy stopped naming callers, which is media delivery taken down by its\n * own limiter (see `NO_CLIENT_ADDRESS_BUCKET`). Vercel always names the caller,\n * so production does not reach that branch.\n *\n * ## Fails open\n *\n * Only a durable count past the ceiling refuses. Everything else admits:\n *\n * - **Contended, or no answer inside the budget.** `consumeRateLimit` reports\n * both as `allowed: false` with `contended: true`. That is the store saying\n * it could not count, not that the caller is over, and reading it as a\n * refusal is how a slow counter fails every request closed.\n * - **The store is down.** `consumeRateLimit` falls back to its per-instance\n * count and flags `degraded`. The episode is still recorded and still\n * reaches `/api/health/rate-limits`; the request is admitted.\n * - **Anything thrown.** Counting may never be the reason a delivery fails.\n *\n * Delivery is the product, and the counter and the media document share one\n * Firestore. While the counter cannot answer, the document read in front of it\n * is usually failing too, and refusing would add a second outage to the first.\n *\n * ## Latency\n *\n * `serveMediaCdn` starts the count beside the Storage metadata read, so its\n * two round trips overlap one the request already pays. It gets\n * {@link MEDIA_CDN_RATE_LIMIT_BUDGET_MS} rather than the store's 2.5 s\n * default, and a request whose count has not come back by then is admitted.\n *\n * ## What it does not bound\n *\n * The bytes in one request, a caller spread across many addresses, and an\n * org's total delivery. A WAF rule, a signed or referrer-checked public URL and\n * a per-org byte cap are the controls for those. Each one changes what an\n * `og:image`, a mailed image or an embed is able to fetch, so none of them is\n * decided here.\n */\n\n/** Fixed 60 s window, the same as every other limiter on the store. */\nexport const MEDIA_CDN_RATE_WINDOW_MS = 60_000\n\n/** Image deliveries one caller may start in a window; see \"Thresholds\". */\nexport const MEDIA_CDN_IMAGE_RATE_LIMIT = 600\n\n/** Non-image deliveries one caller may start in a window; see \"Thresholds\". */\nexport const MEDIA_CDN_NON_IMAGE_RATE_LIMIT = 180\n\n/**\n * How long a delivery waits for its count before it is admitted uncounted.\n *\n * The store's default is 2.5 s, sized for a password attempt that can afford\n * to wait. A video seek cannot. The count's two round trips were measured at\n * about 0.6 s end to end including the request itself (`rate-limit-store.ts`),\n * and they run beside the metadata read, so 1 s is ample for an answer and\n * short enough that a stalled store costs a player one short pause.\n */\nexport const MEDIA_CDN_RATE_LIMIT_BUDGET_MS = 1_000\n\n/** Which budget a delivery spends: `mediaCdnEdgeCacheable` of what it serves. */\nexport type MediaCdnRateClass = 'image' | 'non-image'\n\n/**\n * The address a caller is counted under: an IPv4 address as it is, an IPv6\n * address as its `/64`.\n *\n * `readClientIp` has already normalized the address, so an IPv6 value here is\n * lower-case hex and colons. Anything that does not expand to eight groups is\n * counted under the value as given, which can only be narrower than a `/64`.\n */\nexport function mediaCdnRateLimitCaller(address: string): string {\n if (!address.includes(':')) return address\n const halves = address.split('::')\n if (halves.length > 2) return address\n const head = halves[0] ? halves[0].split(':') : []\n const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : []\n const elided = halves.length === 2 ? 8 - head.length - tail.length : 0\n if (elided < 0 || (halves.length === 2 && elided === 0)) return address\n const groups = [...head, ...Array<string>(elided).fill('0'), ...tail]\n if (groups.length !== 8 || !groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) {\n return address\n }\n return `${groups\n .slice(0, 4)\n .map((group) => group.replace(/^0+(?=[0-9a-f])/, ''))\n .join(':')}::/64`\n}\n\n/** The counter key for one caller's budget in one class. */\nexport function mediaCdnRateLimitKey(\n rateClass: MediaCdnRateClass,\n address: string,\n): string {\n return `media-cdn:${rateClass}:${mediaCdnRateLimitCaller(address)}`\n}\n\nexport interface MediaCdnRateLimitOptions {\n /** The request's headers, node-style or `Headers`-shaped. */\n headers: ClientIpHeaders\n /** `req.socket.remoteAddress`, which `readClientIp` consults last. */\n remoteAddress?: string | null\n rateClass: MediaCdnRateClass\n limit?: number\n windowMs?: number\n budgetMs?: number\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n}\n\nexport interface MediaCdnRateLimitRefusal {\n /** Whole seconds until the caller's window ends, never less than 1. */\n retryAfterSeconds: number\n}\n\n/**\n * Counts one delivery against its caller, and answers with a refusal only\n * when the durable count is past the ceiling. `null` means serve.\n *\n * Never rejects: see \"Fails open\" above.\n */\nexport async function mediaCdnRateLimitRefusal(\n options: MediaCdnRateLimitOptions,\n): Promise<MediaCdnRateLimitRefusal | null> {\n try {\n const address = readClientIp(options.headers, {\n remoteAddress: options.remoteAddress,\n })\n if (!address) return null\n const nowMs = options.nowMs ?? Date.now()\n const rate = await consumeRateLimit(\n mediaCdnRateLimitKey(options.rateClass, address),\n {\n limit:\n options.limit ??\n (options.rateClass === 'image'\n ? MEDIA_CDN_IMAGE_RATE_LIMIT\n : MEDIA_CDN_NON_IMAGE_RATE_LIMIT),\n windowMs: options.windowMs ?? MEDIA_CDN_RATE_WINDOW_MS,\n budgetMs: options.budgetMs ?? MEDIA_CDN_RATE_LIMIT_BUDGET_MS,\n now: nowMs,\n firestore: options.firestore,\n },\n )\n if (rate.allowed || rate.contended || rate.degraded) return null\n return {\n retryAfterSeconds: Math.max(1, Math.ceil((rate.resetMs - nowMs) / 1000)),\n }\n } catch {\n return null\n }\n}\n"],"names":["readClientIp","consumeRateLimit","MEDIA_CDN_RATE_WINDOW_MS","MEDIA_CDN_IMAGE_RATE_LIMIT","MEDIA_CDN_NON_IMAGE_RATE_LIMIT","MEDIA_CDN_RATE_LIMIT_BUDGET_MS","mediaCdnRateLimitCaller","address","includes","halves","split","length","head","tail","elided","groups","Array","fill","every","group","test","slice","map","replace","join","mediaCdnRateLimitKey","rateClass","mediaCdnRateLimitRefusal","options","headers","remoteAddress","nowMs","Date","now","rate","limit","windowMs","budgetMs","firestore","allowed","contended","degraded","retryAfterSeconds","Math","max","ceil","resetMs"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-cdn-rate-limit.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\nimport {\n type ClientIpHeaders,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\nimport { consumeRateLimit } from './rate-limit-store'\n\n/**\n * The media CDN's per-caller limit (AGL-2812).\n *\n * `/api/media/cdn/*` is anonymous public delivery, and nothing bounded how\n * much of it one caller could take. The firewall bypasses the route on\n * purpose, because link-preview crawlers and Gmail's image proxy cannot solve\n * a challenge (\"Public asset delivery bypass\" in\n * `tools/scripts/lib/firewall-posture.mjs`). Both middlewares exclude `/api/*`.\n * The edge counts the path too, ahead of that bypass (\"Media CDN per-IP rate\n * limit\", 1,500 requests a minute per address), but it sees one stream of\n * requests per address: only `serveMediaCdn` can tell an image from a film,\n * skip a 304, and fold an IPv6 address to its /64, so the ceilings on what one\n * caller is served live here.\n *\n * ## What is counted\n *\n * A GET that gets past every access gate, the lockdown and quarantine\n * refusals and the 304 exit, which is where the Storage reads begin. Those\n * refusals, a 304 and a HEAD send no file, so a revalidating browser, a\n * crawler's HEAD and a probe for a deleted id spend nothing. The counter's\n * Firestore write is paid only where the delivery it guards is about to cost\n * more. The count starts beside the metadata read, so a request that then\n * finds no object in Storage, or asks for an unsatisfiable range, has been\n * counted too.\n *\n * ## Two budgets, split where the edge splits them\n *\n * Images and everything else are counted separately, on the line\n * `mediaCdnEdgeCacheable` already draws (AGL-1515):\n *\n * - **Images** are held by the edge, so only a miss reaches the function, and\n * images are what link-preview crawlers and Gmail's image proxy fetch.\n * - **Everything else** (video, audio, PDFs, documents) is `private`, so every\n * request reaches the function, and it is where the bytes are: a video\n * master is a single request for up to 200 MB.\n *\n * Separate keys also mean a DAM grid's burst of tiles cannot spend the budget\n * the video on the same page needs, or the reverse.\n *\n * ## Thresholds, from measured traffic\n *\n * Read from both mounts' production request logs on 2026-09-11: the tenant's\n * last seven days (2026-09-04 08:01 to 2026-09-11 07:56 UTC), and the\n * console's last sixteen hours, which is all it retains. Only requests that\n * reached the function count, since an edge hit never runs this code.\n *\n * | measured | busiest minute, whole route |\n * | -- | -- |\n * | image deliveries (200 and 206) | 98 |\n * | every other delivery (200 and 206) | 31 |\n * | every request, with 304s, 404s and edge hits | 236 |\n * | link-preview crawlers and other bots, together | 32 |\n * | Gmail's image proxy | none in the sample |\n *\n * Each of those minutes was one browser: the image peak was a single Chrome\n * session pulling 129 distinct assets, and the non-image peak a single session\n * following 31 `?download=1` links. No asset took more than 4 range requests\n * in a minute. Over thirty days the day documents count 30,855 origin serves\n * in all, and 4,234 on the busiest day.\n *\n * Each ceiling is at least five times the busiest minute the whole route\n * served in its class. No single address can reach it with a pattern the route\n * has actually seen, even if every request in that minute had been its own:\n *\n * - {@link MEDIA_CDN_IMAGE_RATE_LIMIT}, 600: 6.1 times the image peak, and more\n * than 18 times every crawler together.\n * - {@link MEDIA_CDN_NON_IMAGE_RATE_LIMIT}, 180: 5.8 times the non-image peak.\n * Tenant video uploads are paused (AGL-2830), so film playback is barely in\n * the sample. A page of twelve films at four ranges a minute each still fits\n * more than three times over.\n *\n * ## Keyed on the caller alone\n *\n * The key is the caller's address and the class: not the site, the asset or\n * the query string. Cycling media ids, cache-busting a URL with a junk query\n * parameter, or switching between the tenant and console mounts all land in\n * the same counter.\n *\n * The address comes from `readClientIp`, which reads the hop the platform\n * wrote rather than one a caller can prepend. An IPv6 address counts by its\n * `/64`: one subscriber is routinely handed a whole `/64`, so keying on the\n * full address would give that subscriber 2^64 budgets.\n *\n * A request with no readable address is not counted at all. One shared bucket\n * for every unaddressable caller would refuse all of them together the moment\n * a proxy stopped naming callers, which is media delivery taken down by its\n * own limiter (see `NO_CLIENT_ADDRESS_BUCKET`). Vercel always names the caller,\n * so production does not reach that branch.\n *\n * ## Fails open\n *\n * Only a durable count past the ceiling refuses. Everything else admits:\n *\n * - **Contended, or no answer inside the budget.** `consumeRateLimit` reports\n * both as `allowed: false` with `contended: true`. That is the store saying\n * it could not count, not that the caller is over, and reading it as a\n * refusal is how a slow counter fails every request closed.\n * - **The store is down.** `consumeRateLimit` falls back to its per-instance\n * count and flags `degraded`. The episode is still recorded and still\n * reaches `/api/health/rate-limits`; the request is admitted.\n * - **Anything thrown.** Counting may never be the reason a delivery fails.\n *\n * Delivery is the product, and the counter and the media document share one\n * Firestore. While the counter cannot answer, the document read in front of it\n * is usually failing too, and refusing would add a second outage to the first.\n *\n * ## Latency\n *\n * `serveMediaCdn` starts the count beside the Storage metadata read, so its\n * two round trips overlap one the request already pays. It gets\n * {@link MEDIA_CDN_RATE_LIMIT_BUDGET_MS} rather than the store's 2.5 s\n * default, and a request whose count has not come back by then is admitted.\n *\n * ## What it does not bound\n *\n * The bytes in one request, a caller spread across many addresses, and an\n * org's total delivery. A WAF rule, a signed or referrer-checked public URL and\n * a per-org byte cap are the controls for those. Each one changes what an\n * `og:image`, a mailed image or an embed is able to fetch, so none of them is\n * decided here.\n */\n\n/** Fixed 60 s window, the same as every other limiter on the store. */\nexport const MEDIA_CDN_RATE_WINDOW_MS = 60_000\n\n/** Image deliveries one caller may start in a window; see \"Thresholds\". */\nexport const MEDIA_CDN_IMAGE_RATE_LIMIT = 600\n\n/** Non-image deliveries one caller may start in a window; see \"Thresholds\". */\nexport const MEDIA_CDN_NON_IMAGE_RATE_LIMIT = 180\n\n/**\n * How long a delivery waits for its count before it is admitted uncounted.\n *\n * The store's default is 2.5 s, sized for a password attempt that can afford\n * to wait. A video seek cannot. The count's two round trips were measured at\n * about 0.6 s end to end including the request itself (`rate-limit-store.ts`),\n * and they run beside the metadata read, so 1 s is ample for an answer and\n * short enough that a stalled store costs a player one short pause.\n */\nexport const MEDIA_CDN_RATE_LIMIT_BUDGET_MS = 1_000\n\n/** Which budget a delivery spends: `mediaCdnEdgeCacheable` of what it serves. */\nexport type MediaCdnRateClass = 'image' | 'non-image'\n\n/**\n * The address a caller is counted under: an IPv4 address as it is, an IPv6\n * address as its `/64`.\n *\n * `readClientIp` has already normalized the address, so an IPv6 value here is\n * lower-case hex and colons. Anything that does not expand to eight groups is\n * counted under the value as given, which can only be narrower than a `/64`.\n */\nexport function mediaCdnRateLimitCaller(address: string): string {\n if (!address.includes(':')) return address\n const halves = address.split('::')\n if (halves.length > 2) return address\n const head = halves[0] ? halves[0].split(':') : []\n const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : []\n const elided = halves.length === 2 ? 8 - head.length - tail.length : 0\n if (elided < 0 || (halves.length === 2 && elided === 0)) return address\n const groups = [...head, ...Array<string>(elided).fill('0'), ...tail]\n if (groups.length !== 8 || !groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) {\n return address\n }\n return `${groups\n .slice(0, 4)\n .map((group) => group.replace(/^0+(?=[0-9a-f])/, ''))\n .join(':')}::/64`\n}\n\n/** The counter key for one caller's budget in one class. */\nexport function mediaCdnRateLimitKey(\n rateClass: MediaCdnRateClass,\n address: string,\n): string {\n return `media-cdn:${rateClass}:${mediaCdnRateLimitCaller(address)}`\n}\n\nexport interface MediaCdnRateLimitOptions {\n /** The request's headers, node-style or `Headers`-shaped. */\n headers: ClientIpHeaders\n /** `req.socket.remoteAddress`, which `readClientIp` consults last. */\n remoteAddress?: string | null\n rateClass: MediaCdnRateClass\n limit?: number\n windowMs?: number\n budgetMs?: number\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n}\n\nexport interface MediaCdnRateLimitRefusal {\n /** Whole seconds until the caller's window ends, never less than 1. */\n retryAfterSeconds: number\n}\n\n/**\n * Counts one delivery against its caller, and answers with a refusal only\n * when the durable count is past the ceiling. `null` means serve.\n *\n * Never rejects: see \"Fails open\" above.\n */\nexport async function mediaCdnRateLimitRefusal(\n options: MediaCdnRateLimitOptions,\n): Promise<MediaCdnRateLimitRefusal | null> {\n try {\n const address = readClientIp(options.headers, {\n remoteAddress: options.remoteAddress,\n })\n if (!address) return null\n const nowMs = options.nowMs ?? Date.now()\n const rate = await consumeRateLimit(\n mediaCdnRateLimitKey(options.rateClass, address),\n {\n limit:\n options.limit ??\n (options.rateClass === 'image'\n ? MEDIA_CDN_IMAGE_RATE_LIMIT\n : MEDIA_CDN_NON_IMAGE_RATE_LIMIT),\n windowMs: options.windowMs ?? MEDIA_CDN_RATE_WINDOW_MS,\n budgetMs: options.budgetMs ?? MEDIA_CDN_RATE_LIMIT_BUDGET_MS,\n now: nowMs,\n firestore: options.firestore,\n },\n )\n if (rate.allowed || rate.contended || rate.degraded) return null\n return {\n retryAfterSeconds: Math.max(1, Math.ceil((rate.resetMs - nowMs) / 1000)),\n }\n } catch {\n return null\n }\n}\n"],"names":["readClientIp","consumeRateLimit","MEDIA_CDN_RATE_WINDOW_MS","MEDIA_CDN_IMAGE_RATE_LIMIT","MEDIA_CDN_NON_IMAGE_RATE_LIMIT","MEDIA_CDN_RATE_LIMIT_BUDGET_MS","mediaCdnRateLimitCaller","address","includes","halves","split","length","head","tail","elided","groups","Array","fill","every","group","test","slice","map","replace","join","mediaCdnRateLimitKey","rateClass","mediaCdnRateLimitRefusal","options","headers","remoteAddress","nowMs","Date","now","rate","limit","windowMs","budgetMs","firestore","allowed","contended","degraded","retryAfterSeconds","Math","max","ceil","resetMs"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type MemberEmailAlias, type MemberEmailAliasAddRefusal } from '@aglyn/aglyn/app-utils/member-email-aliases';
|
|
17
|
+
import { type MemberEmailAlias, type MemberEmailAliasAddRefusal, type RosterMember } from '@aglyn/aglyn/app-utils/member-email-aliases';
|
|
18
18
|
/** Confirmation emails one member may cause per hour, across addresses. */
|
|
19
19
|
export declare const MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR = 10;
|
|
20
20
|
/**
|
|
@@ -33,6 +33,14 @@ export interface MemberEmailAliasRefused {
|
|
|
33
33
|
}
|
|
34
34
|
/** The addresses one member has added in one workspace, confirmed or not. */
|
|
35
35
|
export declare function loadMemberEmailAliases(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string): Promise<MemberEmailAlias[]>;
|
|
36
|
+
/**
|
|
37
|
+
* The workspace's people and every address that is theirs — the member
|
|
38
|
+
* documents and every member's stored alias document, two reads under the
|
|
39
|
+
* org, reduced by `memberAddressRoster` to each member's sign-in address and
|
|
40
|
+
* the aliases they have confirmed (AGL-2975). What a received message is
|
|
41
|
+
* matched against to tell the team's own mail from a correspondent's.
|
|
42
|
+
*/
|
|
43
|
+
export declare function loadMemberAddressRoster(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<RosterMember[]>;
|
|
36
44
|
export type MemberEmailAliasAdded = {
|
|
37
45
|
ok: true;
|
|
38
46
|
alias: MemberEmailAlias;
|
|
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import { evaluateMemberEmailAliasAdd, isVerifiedMemberEmailAlias, MEMBER_EMAIL_ALIAS_CONFIRM_PARAM, MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS, MEMBER_EMAIL_ALIASES_COLLECTION, normalizeMemberEmailAlias, readMemberEmailAliases } from "@aglyn/aglyn/app-utils/member-email-aliases";
|
|
17
|
+
*/ import { evaluateMemberEmailAliasAdd, isVerifiedMemberEmailAlias, MEMBER_EMAIL_ALIAS_CONFIRM_PARAM, MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS, MEMBER_EMAIL_ALIASES_COLLECTION, memberAddressRoster, normalizeMemberEmailAlias, readMemberEmailAliases } from "@aglyn/aglyn/app-utils/member-email-aliases";
|
|
18
18
|
import { resolveBrandingProfile } from "@aglyn/aglyn/server";
|
|
19
19
|
import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
|
|
20
20
|
import { createHmac } from "crypto";
|
|
@@ -88,6 +88,30 @@ function memberRef(firestore, orgId, uid) {
|
|
|
88
88
|
const snapshot = await aliasesRef(firestore, orgId, uid).get();
|
|
89
89
|
return readMemberEmailAliases(snapshot.exists ? snapshot.data() : null);
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The workspace's people and every address that is theirs — the member
|
|
93
|
+
* documents and every member's stored alias document, two reads under the
|
|
94
|
+
* org, reduced by `memberAddressRoster` to each member's sign-in address and
|
|
95
|
+
* the aliases they have confirmed (AGL-2975). What a received message is
|
|
96
|
+
* matched against to tell the team's own mail from a correspondent's.
|
|
97
|
+
*/ export async function loadMemberAddressRoster(firestore, orgId) {
|
|
98
|
+
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
99
|
+
const [members, aliases] = await Promise.all([
|
|
100
|
+
orgRef.collection('members').get(),
|
|
101
|
+
orgRef.collection(MEMBER_EMAIL_ALIASES_COLLECTION).get()
|
|
102
|
+
]);
|
|
103
|
+
const rows = members.docs.map((doc)=>{
|
|
104
|
+
var _doc_data;
|
|
105
|
+
return _extends({}, (_doc_data = doc.data()) != null ? _doc_data : {}, {
|
|
106
|
+
$id: doc.id
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
const aliasDocuments = new Map(aliases.docs.map((doc)=>[
|
|
110
|
+
String(doc.id),
|
|
111
|
+
doc.data()
|
|
112
|
+
]));
|
|
113
|
+
return memberAddressRoster(rows, aliasDocuments);
|
|
114
|
+
}
|
|
91
115
|
/**
|
|
92
116
|
* Adds an address, unconfirmed, for a member of the workspace — or answers
|
|
93
117
|
* the entry already waiting for it, so the caller sends another link. One
|