@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.220
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-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +163 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +143 -0
- package/src/lib/server/api-v1-resources.js +125 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- 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.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +43 -18
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +6 -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.d.ts +11 -0
- package/src/lib/server/email-suppression.js +37 -2
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.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 +27 -0
- package/src/lib/server/hosted-page-review.js +149 -12
- package/src/lib/server/hosted-page-review.js.map +1 -1
- 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 +10 -2
- package/src/lib/server/member-email-aliases.js +26 -2
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +22 -2
- package/src/lib/server/notifications.js +40 -3
- 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-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/payment-fraud-signal.js +5 -1
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- 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/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.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/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- 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/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.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.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +87 -20
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +5 -8
- package/src/tenancy.js +12 -13
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -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/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.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 -145
- package/src/lib/server/crm-inbound-email.js +0 -297
- 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/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
- /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/hosted-page-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 * HELD FOR REVIEW: a published page (AGL-3362).\n *\n * ## Why the check sits where a page is COMPOSED, not where it is published\n *\n * A publish is a browser write. The besigner toolbar, the versions panel,\n * the screen details page, page-from-template, the layout and component\n * besigners and the form besigner each move a pointer (`versionId`, the\n * host's `screens` map, a component's own `nodes`) straight from the\n * client SDK; the scheduled-publish beat moves it from the server; an\n * experiment serves a version no pointer names; and an author may rewrite\n * the LIVE version document itself without moving any pointer at all. A\n * check on any one of those writes is a check the next one walks around.\n *\n * They all meet in one place: `composeScreenNodes` (`@aglyn/tenant-runtime`),\n * which every published render, the password unlock, the collection and\n * product templates and every experiment variant go through, and which\n * returns the page exactly as a visitor will receive it — the screen, its\n * layout chrome, its grafted components and its forms. So the screen is read\n * there, and a held page is one that is never composed for a visitor. For a\n * visitor, \"publish\" is the first render after the pointer moved; this is\n * that moment.\n *\n * ## What a hold does\n *\n * - The version is not served. The last version this page served clean\n * ({@link servedPageVersion}) is composed instead — screened again, since\n * a version document can be edited in place — and if there is none, or it\n * holds too, the page is not found. A first publish that holds therefore\n * serves nothing.\n * - One review row per (page, signal set) lands in the abuse queue at\n * `/admin/abuse-reports`, the same `phishing` row a held email files\n * (`fileOutboundHold`), with `heldSend.kind: 'page'`, the page's URL and\n * the held version. Staff dismiss it to RELEASE (the admin route drops the\n * site's cache and the next render serves it) or mark it actioned to\n * REJECT (it stays unserved; any later version carrying the same signals\n * is refused without a new row).\n * - Keyed on the SIGNALS, as the send seam's rows are: a composed page\n * carries per-render values (dates, collection rows) that no content hash\n * would survive, while what makes it phishing does not change between\n * renders.\n *\n * ## Tiers\n *\n * `signalsThatHold`: a lookalike link or embed and an author-defined\n * credential field hold for EVERY workspace; a brand's call to action only\n * for a workspace in its first fortnight. The workspace's documents are read\n * only when the pure screen has found something, so a clean page costs no\n * read for this.\n *\n * Fails CLOSED, unlike the send path. Composition itself reads the store, so\n * an outage here is an outage on the render already, and the only cost of\n * not serving a page the screen flagged is a false positive's wait; serving\n * it is the phishing page this exists to stop.\n *=========================================*/\n\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n heldPageConsolePath,\n heldPageDetails,\n heldPageLabel,\n heldPageVisitorSentence,\n type HeldPageSubject,\n} from '@aglyn/shared-util-email/held-page'\nimport { screenHostedPage } from '@aglyn/shared-util-email/hosted-page-screen'\nimport {\n lookalikeBrandForHost,\n phishingScreenBrandLabel,\n type PhishingScreenSignal,\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { describeHeldPage, type HostedPageContext } from './held-page-subject'\nimport { notifyRiskEvent } from './risk-notice'\nimport { orgAgeDays } from './org-age'\nimport { getHostDocAdmin, getOrgForHost } from './organizations'\nimport {\n canonicalJson,\n fileOutboundHold,\n flaggedHostOf,\n heldOutboundReference,\n heldOutboundReviewId,\n outboundContentHash,\n type HeldOutboundSendState,\n workspaceScreenIdentity,\n} from './outbound-send-review'\n\n/** Where the last version a page served clean is noted: a hint, re-screened before use. */\nexport const PAGE_REVIEW_SUBCOLLECTION = 'pageReviews'\n\nexport interface HostedPageReviewRequest {\n hostId: string\n screenId: string\n versionId: string\n /** The page as composed for a visitor. */\n nodes: unknown\n /** The host and org documents, when the caller already holds them. */\n host?: Record<string, unknown> | null\n org?: Record<string, unknown> | null\n orgId?: string | null\n nowMs?: number\n /**\n * What the composer knows about the page (AGL-3374): the screen document,\n * the template route it is rendering, the variant, and a way to read the\n * layouts and components composed into it. Read only when a row is filed,\n * to name the page — never to decide whether it serves.\n */\n page?: HostedPageContext | null\n}\n\nexport type HostedPageReviewOutcome =\n | { outcome: 'serve' }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string }\n\n/** The review row's id for a page and the signals that held. */\nexport function pageReviewId(\n hostId: string,\n screenId: string,\n signals: readonly PhishingScreenSignal[],\n): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/screens/${screenId}`, outboundContentHash([key]))\n}\n\n/** How long this process trusts a page row's state — the send seam's minute. */\nconst PAGE_MEMO_TTL_MS = 60_000\nconst pageMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n/** The version each page last served clean, as this process last wrote it. */\nconst servedMemo = new Map<string, string>()\n/** Lookalike hosts this process already flagged for an established page, and when. */\nconst flaggedMemo = new Map<string, number>()\nconst FLAGGED_MEMO_TTL_MS = 60 * 60_000\n\n/** Test seam: forget what this process learned. */\nexport function resetHostedPageReviewMemoForTests(): void {\n pageMemo.clear()\n servedMemo.clear()\n flaggedMemo.clear()\n}\n\n/**\n * The subject a row already stored, or a fresh description (AGL-3374). A row\n * that names its page is not described again, so the reads the description\n * may make are paid once per row, not once per render.\n */\nasync function subjectForRow(\n reviewId: string,\n describe: () => Promise<HeldPageSubject>,\n): Promise<HeldPageSubject> {\n const stored = await firebaseAdmin\n .app()\n .firestore()\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .get()\n .then((row) => storedSubject(row.get('heldPage')))\n .catch(() => null)\n return stored ?? describe()\n}\n\n/** A row's stored subject, when it has one. */\nfunction storedSubject(heldPage: unknown): HeldPageSubject | null {\n const subject = (heldPage as { subject?: HeldPageSubject } | null | undefined)?.subject\n return subject && typeof subject === 'object' && subject.screenId ? subject : null\n}\n\n/** The row's first line, naming the page and where it was seen. */\nfunction pageHeadline(subject: HeldPageSubject, siteName: string): string {\n return [`${heldPageLabel(subject)} of ${siteName}.`, ...heldPageDetails(subject)].join(' ')\n}\n\n/**\n * Screen one page as a visitor would receive it, and say whether it may be\n * served. See the module header.\n */\nexport async function reviewHostedPage(\n request: HostedPageReviewRequest,\n): Promise<HostedPageReviewOutcome> {\n const found = screenHostedPage({ nodes: request.nodes })\n if (!found.signals.length) return { outcome: 'serve' }\n\n const nowMs = request.nowMs ?? Date.now()\n try {\n const [host, owner] = await Promise.all([\n request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),\n request.org !== undefined\n ? { orgId: request.orgId ?? null, org: request.org }\n : getOrgForHost(request.hostId),\n ])\n const org = (owner?.org as Record<string, unknown> | null | undefined) ?? null\n const orgId = owner?.orgId ?? null\n // Again, now knowing whose page it is: a brand in the workspace's own\n // name or domain is its own, and a link to its own site is not away.\n const identity = workspaceScreenIdentity({ org, host })\n const verdict = screenHostedPage({ nodes: request.nodes, ...identity })\n const ageDays = orgAgeDays((org as { createdAt?: unknown } | null)?.createdAt, nowMs)\n // Every workspace is screened, however old: an established account can\n // be compromised, and a compromised account publishing a lure is the\n // same harm as a new one. What differs is what a hold may take DOWN.\n // Holding content that is already live serves its last clean version or\n // nothing, and on a real business's home page that is an outage the\n // screen caused (aglyn.com, 2026-09-28, a page that had served clean\n // until a rule changed). So for an established workspace:\n // - NEW content (a new version of a live page, or a page that has\n // never been live) holds, exactly as for a young workspace; the live\n // page keeps serving its last clean version, or the new page waits;\n // - content ALREADY LIVE keeps serving, and is flagged to staff as\n // urgent, who lock the site if it is impersonation.\n if (!isYoungWorkspaceAge(ageDays)) {\n const strong = signalsThatHold(verdict.signals, { ageDays })\n if (!strong.length) return { outcome: 'serve' }\n if (await isAlreadyLive(request.hostId, request.screenId, request.versionId, nowMs)) {\n await flagLivePage({\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n orgId,\n host,\n signals: strong,\n nowMs,\n page: request.page ?? null,\n identity,\n })\n return { outcome: 'serve' }\n }\n }\n const signals = signalsThatHold(verdict.signals, { ageDays })\n if (!signals.length) return { outcome: 'serve' }\n\n const reviewId = pageReviewId(request.hostId, request.screenId, signals)\n const reference = heldOutboundReference(reviewId)\n const memo = pageMemo.get(reviewId)\n if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'serve' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n }\n // The page by its real name and route (AGL-3374): a template, a list,\n // a variant, or a page whose flagged content lives in its layout.\n const subject = await subjectForRow(reviewId, async () =>\n describeHeldPage({\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n host,\n page: request.page,\n signals,\n identity,\n live: false,\n previousVersionId: await servedPageVersion(request.hostId, request.screenId),\n }),\n )\n const url = subject.entryUrl ?? subject.url\n const flagged = flaggedHostOf(signals)\n const siteName =\n typeof host?.['name'] === 'string' && host['name'] ? `\"${host['name']}\"` : 'a site'\n const label = heldPageLabel(subject)\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'page',\n path: `hosts/${request.hostId}/screens/${request.screenId}`,\n hostId: request.hostId,\n orgId,\n contentHash: outboundContentHash([signals]),\n subject: label.slice(0, 300),\n fromName: null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n },\n extra: {\n heldPage: { screenId: request.screenId, versionId: request.versionId, url, subject },\n },\n headline:\n `Held ${pageHeadline(subject, siteName)} Version ${request.versionId}, from a ` +\n `workspace ${ageDays ?? '?'} day(s) old. ${heldPageVisitorSentence(subject)}`,\n alertTitle: 'Published page held for review — possible phishing',\n alertBody: `${label.charAt(0).toUpperCase()}${label.slice(1)} was held before it went live.`,\n // The owners' words for it, and where they fix it (AGL-3368, AGL-3374).\n item: { label, path: heldPageConsolePath(subject, request.hostId) },\n page: subject,\n // The page itself is what was reported; the flagged host, when there\n // is one, is named in the details and on `reportedHostname`.\n url,\n reportedHostname: flagged,\n })\n pageMemo.set(reviewId, { state, atMs: nowMs })\n return state === 'released'\n ? { outcome: 'serve' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n } catch (error) {\n // Open: a review that could not run must not take a live page down —\n // the screen failing is not evidence about the page.\n console.error('[page-review] a flagged page could not be reviewed — serving it', error)\n return { outcome: 'serve' }\n }\n}\n\nfunction pageReviewRef(hostId: string, screenId: string) {\n return firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection(PAGE_REVIEW_SUBCOLLECTION)\n .doc(screenId)\n}\n\n/**\n * Is this version of the page already what visitors receive? It is when it\n * is the version the page last served clean, or when the page has no record\n * at all and is not new: it went live before the screen existed. A page\n * created in the last {@link NEW_PAGE_DAYS} days with no record has never\n * been live, so holding it takes nothing down.\n */\nconst NEW_PAGE_DAYS = 7\nasync function isAlreadyLive(\n hostId: string,\n screenId: string,\n versionId: string,\n nowMs: number,\n): Promise<boolean> {\n const lastClean = await servedPageVersion(hostId, screenId)\n if (lastClean) return lastClean === versionId\n try {\n const screen = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('screens')\n .doc(screenId)\n .get()\n const created = screen.get('createdAt') as { toMillis?: () => number } | number | undefined\n const createdMs =\n typeof created === 'number' ? created : typeof created?.toMillis === 'function' ? created.toMillis() : null\n // No readable creation time: an old page, so live.\n return createdMs === null || nowMs - createdMs >= NEW_PAGE_DAYS * 24 * 60 * 60 * 1000\n } catch {\n return true\n }\n}\n\n/**\n * A page ALREADY LIVE carries a strong signal: file one urgent abuse row for\n * it (keyed on the page and its signals, so a busy page is one row) and, the\n * first time, tell staff and the owners through the `page-flagged` notice.\n * The page keeps serving; staff lock the site if it is impersonation or the\n * account looks compromised. Once per page and signal set per process.\n */\nasync function flagLivePage(input: {\n hostId: string\n screenId: string\n versionId: string\n orgId: string | null\n host: Record<string, unknown> | null\n signals: readonly PhishingScreenSignal[]\n nowMs: number\n page?: HostedPageContext | null\n identity?: ReturnType<typeof workspaceScreenIdentity>\n}): Promise<void> {\n const key = `${input.hostId}\\n${input.screenId}\\n${input.signals.map((signal) => canonicalJson(signal)).sort().join('\\n')}`\n const at = flaggedMemo.get(key)\n if (at !== undefined && input.nowMs - at < FLAGGED_MEMO_TTL_MS) return\n flaggedMemo.set(key, input.nowMs)\n try {\n const reviewId = pageReviewId(input.hostId, `${input.screenId}/live`, input.signals)\n const reference = heldOutboundReference(reviewId)\n const evidence = describePhishingScreenSignals(input.signals).join(' ')\n const flagged = flaggedHostOf(input.signals)\n const ref = firebaseAdmin.app().firestore().collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const existing = await ref.get()\n const first = !existing.exists\n // Named once, when the row is new (AGL-3374).\n const subject =\n (existing.exists ? storedSubject(existing.get('heldPage')) : null) ??\n (await describeHeldPage({\n hostId: input.hostId,\n screenId: input.screenId,\n versionId: input.versionId,\n host: input.host,\n page: input.page,\n signals: input.signals,\n identity: input.identity ?? workspaceScreenIdentity({ host: input.host }),\n live: true,\n previousVersionId: null,\n }))\n const url = subject.entryUrl ?? subject.url\n const siteName =\n typeof input.host?.['name'] === 'string' && input.host['name']\n ? `\"${input.host['name']}\"`\n : 'a site'\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url,\n reportedHostname: flagged,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `LIVE: ${pageHeadline(subject, siteName)} It was flagged by the phishing screen and is still serving.`,\n evidence,\n 'Lock the site if it is impersonation or the account looks compromised; dismiss this if the content is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n heldPage: { screenId: input.screenId, versionId: input.versionId, url, subject },\n riskNotice: { kind: 'page-flagged' },\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (!first) return\n await notifyRiskEvent({\n kind: 'page-flagged',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId,\n reference,\n item: { label: heldPageLabel(subject), path: heldPageConsolePath(subject, input.hostId) },\n page: subject,\n staffEvidence: [...heldPageDetails(subject), evidence].join(' '),\n })\n } catch (error) {\n console.error('[page-review] a live page could not be flagged', error)\n }\n}\n\n/**\n * The version this page last served clean, or null. A HINT, not a\n * clearance: the caller composes and screens it again before serving it,\n * because a version document can be edited after it was served.\n */\nexport async function servedPageVersion(hostId: string, screenId: string): Promise<string | null> {\n try {\n const snapshot = await pageReviewRef(hostId, screenId).get()\n const value = snapshot.get('servedVersionId')\n return typeof value === 'string' && value ? value : null\n } catch {\n return null\n }\n}\n\n/**\n * Note that this page served `versionId` clean. One read the first time a\n * process renders a page, a write only when the version moved; nothing\n * after that, however often the page is rendered.\n */\nexport async function recordServedPageVersion(\n hostId: string,\n screenId: string,\n versionId: string,\n): Promise<void> {\n const key = `${hostId}/${screenId}`\n if (!versionId || servedMemo.get(key) === versionId) return\n try {\n const ref = pageReviewRef(hostId, screenId)\n const snapshot = await ref.get()\n if (snapshot.get('servedVersionId') !== versionId) {\n await ref.set({ servedVersionId: versionId, servedAtMs: Date.now() }, { merge: true })\n }\n servedMemo.set(key, versionId)\n } catch (error) {\n console.warn('[page-review] the served version could not be noted', error)\n }\n}\n\n/**\n * A domain a workspace attached that wears a brand (AGL-3356, AGL-3362):\n * filed in the abuse queue as an urgent `phishing` row for staff, not\n * refused. Two kinds, one rule:\n *\n * - `site` — a custom domain a site is served on (the attach route);\n * - `sending` — a custom domain a workspace sends email as, when it is\n * added and again when it verifies (`sending-domains.ts`);\n * - `link` — a link a merchant configured that a buyer or a server would\n * follow: a product's paid download or video hotlink, a supplier's order\n * webhook (AGL-3363). The link itself is refused where it is followed; this\n * files it, and tells the site's managers once ({@link notifyLookalikeLink}).\n *\n * Not refused, because the domain is the merchant's own registration and\n * the platform cannot tell a brand's own agency attaching `paypal-promo.com`\n * from a kit — but a brand's lookalike on a site or in a `From:` line is\n * exactly what staff look at first. The site's pages are still screened as\n * they are served, and mail FROM a lookalike domain is held at the send seam\n * for every workspace. The test is the phishing screen's own lookalike rule\n * (`lookalikeBrandForHost`), the one that holds a link in an email. One row\n * per (owner, kind, domain), so a re-attach or a re-verify counts rather than\n * re-alerts.\n *\n * Never throws: the write it rides on has already happened.\n */\nexport async function flagLookalikeDomain(input: {\n kind: 'site' | 'sending' | 'link'\n hostId: string | null\n orgId: string | null\n domain: string\n /** For a `link`: where it was configured, as staff read it. */\n where?: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n const domain = String(input.domain ?? '').trim().toLowerCase()\n const brand = lookalikeBrandForHost(domain)\n if (!brand) return 'clean'\n const label = phishingScreenBrandLabel(brand.id)\n const sending = input.kind === 'sending'\n const link = input.kind === 'link'\n try {\n const firestore = firebaseAdmin.app().firestore()\n const owner = sending\n ? `orgs/${input.orgId ?? ''}/sendingDomains`\n : link\n ? `hosts/${input.hostId ?? ''}/links`\n : `hosts/${input.hostId ?? ''}/cname`\n const reviewId = heldOutboundReviewId(owner, domain)\n const reference = heldOutboundReference(reviewId)\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url: `https://${domain}/`,\n reportedHostname: domain,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n sending\n ? `A workspace added ${domain} as a domain to send email from.`\n : link\n ? `A site configured a link to ${domain} (${input.where ?? 'a commerce link'}).`\n : `A site attached the custom domain ${domain}.`,\n `${domain} wears the ${label} name but is not ${label}'s domain.`,\n sending\n ? 'Email from it is held at the send seam for every workspace. Mark this actioned ' +\n 'and lock the workspace if it is impersonation; dismiss it if the brand is theirs.'\n : link\n ? 'Nobody is sent to it: the link is refused wherever it is followed. Mark this ' +\n 'actioned and lock the site if it is impersonation.'\n : 'Its pages are still screened as they are served. Mark this actioned ' +\n 'and lock the site if it is impersonation; dismiss it if the brand is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (first && link) {\n await notifyLookalikeLink({\n hostId: input.hostId,\n orgId: input.orgId,\n domain,\n label,\n where: input.where ?? 'a commerce link',\n reference,\n reviewId,\n })\n } else if (first) {\n // The owners hear that the domain is waiting on a routine review — not\n // which name it resembles; staff get the brand and a link to the row.\n await notifyRiskEvent({\n kind: 'domain-flagged',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId,\n reference,\n item: {\n label: sending ? `the sending domain ${domain}` : `the custom domain ${domain}`,\n // Emails → Sending, or the site's Domain settings.\n path: sending ? '/org/emails/sending' : input.hostId ? `/${input.hostId}/admin/domain` : null,\n },\n staffEvidence:\n `${sending ? 'Sending domain' : 'Custom domain'} ${domain} looks like ${label}.`,\n })\n }\n return 'flagged'\n } catch (error) {\n console.error('[page-review] a lookalike domain could not be flagged', error)\n return 'failed'\n }\n}\n\n/**\n * Tell staff and the site's managers and the workspace's owners, once per\n * (site, domain), that a link the merchant configured was refused because it\n * wears another brand's name (AGL-3363), through the risk notice seam\n * (AGL-3368). The merchant reads the catalog's `link-blocked` words — what\n * was refused and how to fix it, never how the check works; staff get the\n * brand and a link to the row. Never throws.\n */\nexport async function notifyLookalikeLink(input: {\n hostId: string | null\n orgId?: string | null\n domain: string\n label: string\n where: string\n reference: string\n reviewId?: string | null\n}): Promise<void> {\n await notifyRiskEvent({\n kind: 'link-blocked',\n orgId: input.orgId ?? null,\n hostId: input.hostId,\n reviewId: input.reviewId ?? null,\n reference: input.reference,\n item: {\n label: `${input.where} pointing to ${input.domain}`,\n path: input.hostId ? `/${input.hostId}/products` : null,\n },\n staffEvidence:\n `A site configured ${input.where} to ${input.domain}, which looks like ${input.label}. ` +\n 'It is refused wherever it is followed.',\n }).catch(() => undefined)\n}\n\n/** A site's custom domain, through {@link flagLookalikeDomain}. */\nexport function flagLookalikeCustomDomain(input: {\n hostId: string\n orgId: string | null\n domain: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n return flagLookalikeDomain({ kind: 'site', ...input })\n}\n"],"names":["ABUSE_REPORT_COLLECTION","heldPageConsolePath","heldPageDetails","heldPageLabel","heldPageVisitorSentence","screenHostedPage","lookalikeBrandForHost","phishingScreenBrandLabel","describePhishingScreenSignals","isYoungWorkspaceAge","signalsThatHold","FieldValue","firebaseAdmin","describeHeldPage","notifyRiskEvent","orgAgeDays","getHostDocAdmin","getOrgForHost","canonicalJson","fileOutboundHold","flaggedHostOf","heldOutboundReference","heldOutboundReviewId","outboundContentHash","workspaceScreenIdentity","PAGE_REVIEW_SUBCOLLECTION","pageReviewId","hostId","screenId","signals","key","map","signal","sort","join","PAGE_MEMO_TTL_MS","pageMemo","Map","servedMemo","flaggedMemo","FLAGGED_MEMO_TTL_MS","resetHostedPageReviewMemoForTests","clear","subjectForRow","reviewId","describe","stored","app","firestore","collection","doc","get","then","row","storedSubject","catch","heldPage","subject","pageHeadline","siteName","reviewHostedPage","request","found","nodes","length","outcome","nowMs","Date","now","host","owner","Promise","all","undefined","org","orgId","identity","verdict","ageDays","createdAt","strong","isAlreadyLive","versionId","flagLivePage","page","reference","memo","atMs","state","live","previousVersionId","servedPageVersion","url","entryUrl","flagged","label","heldSend","kind","path","contentHash","slice","fromName","heldAtMs","extra","headline","alertTitle","alertBody","charAt","toUpperCase","item","reportedHostname","set","error","console","pageReviewRef","NEW_PAGE_DAYS","lastClean","screen","created","createdMs","toMillis","input","at","existing","evidence","ref","first","exists","category","severity","source","details","reporterEmail","reporterName","dmca","riskNotice","reportCount","increment","updatedAt","serverTimestamp","status","merge","staffEvidence","snapshot","value","recordServedPageVersion","servedVersionId","servedAtMs","warn","flagLookalikeDomain","domain","String","trim","toLowerCase","brand","id","sending","link","where","notifyLookalikeLink","flagLookalikeCustomDomain"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/hosted-page-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 * HELD FOR REVIEW: a published page (AGL-3362).\n *\n * ## Why the check sits where a page is COMPOSED, not where it is published\n *\n * A publish is a browser write. The besigner toolbar, the versions panel,\n * the screen details page, page-from-template, the layout and component\n * besigners and the form besigner each move a pointer (`versionId`, the\n * host's `screens` map, a component's own `nodes`) straight from the\n * client SDK; the scheduled-publish beat moves it from the server; an\n * experiment serves a version no pointer names; and an author may rewrite\n * the LIVE version document itself without moving any pointer at all. A\n * check on any one of those writes is a check the next one walks around.\n *\n * They all meet in one place: `composeScreenNodes` (`@aglyn/tenant-runtime`),\n * which every published render, the password unlock, the collection and\n * product templates and every experiment variant go through, and which\n * returns the page exactly as a visitor will receive it — the screen, its\n * layout chrome, its grafted components and its forms. So the screen is read\n * there, and a held page is one that is never composed for a visitor. For a\n * visitor, \"publish\" is the first render after the pointer moved; this is\n * that moment.\n *\n * ## What a hold does\n *\n * - The version is not served. The last version this page served clean\n * ({@link servedPageVersion}) is composed instead — screened again, since\n * a version document can be edited in place — and if there is none, or it\n * holds too, the page is not found. A first publish that holds therefore\n * serves nothing.\n * - One review row per (page, signal set) lands in the abuse queue at\n * `/admin/abuse-reports`, the same `phishing` row a held email files\n * (`fileOutboundHold`), with `heldSend.kind: 'page'`, the page's URL and\n * the held version. Staff dismiss it to RELEASE (the admin route drops the\n * site's cache and the next render serves it) or mark it actioned to\n * REJECT (it stays unserved; any later version carrying the same signals\n * is refused without a new row).\n * - Keyed on the SIGNALS, as the send seam's rows are: a composed page\n * carries per-render values (dates, collection rows) that no content hash\n * would survive, while what makes it phishing does not change between\n * renders.\n *\n * ## Tiers\n *\n * `signalsThatHold`: a lookalike link or embed and an author-defined\n * credential field hold for EVERY workspace; a brand's call to action or\n * lure, and a call to action that leaves the site beside a lure, only for a\n * workspace in its first fortnight. The workspace's documents are read only\n * when the pure screen has found something, so a clean page costs no read\n * for this.\n *\n * Fails CLOSED, unlike the send path: a page the pure screen flagged whose\n * review cannot finish — the workspace read, the row write — is answered\n * `held`, and the composer serves its last clean version or nothing. The\n * answer is not remembered, so the next render reviews it again. Composition\n * itself reads the store, so an outage here is an outage on the render\n * already, and the only cost of not serving a page the screen flagged is a\n * false positive's wait; serving it is the phishing page this exists to\n * stop. A page the screen found nothing on never reaches this.\n *\n * ## Site redirects\n *\n * A redirect rule that sends a path off the site is a page whose one call to\n * action fires on arrival, so the redirects plugin asks\n * {@link reviewSiteRedirect} before it serves one. Same screen\n * (`screenSiteRedirect`), same tiers, same row and the same staff decision:\n * a held rule does not fire, and is filed as a `page` hold on its rule's\n * document.\n *=========================================*/\n\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n heldPageConsolePath,\n heldPageDetails,\n heldPageLabel,\n heldPageVisitorSentence,\n type HeldPageSubject,\n} from '@aglyn/shared-util-email/held-page'\nimport { screenHostedPage, screenSiteRedirect } from '@aglyn/shared-util-email/hosted-page-screen'\nimport {\n lookalikeBrandForHost,\n phishingScreenBrandLabel,\n type PhishingScreenSignal,\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { describeHeldPage, type HostedPageContext } from './held-page-subject'\nimport { notifyRiskEvent } from './risk-notice'\nimport { orgAgeDays } from './org-age'\nimport { getHostDocAdmin, getOrgForHost } from './organizations'\nimport {\n canonicalJson,\n fileOutboundHold,\n flaggedHostOf,\n heldOutboundReference,\n heldOutboundReviewId,\n outboundContentHash,\n type HeldOutboundSendState,\n workspaceScreenIdentity,\n} from './outbound-send-review'\n\n/** Where the last version a page served clean is noted: a hint, re-screened before use. */\nexport const PAGE_REVIEW_SUBCOLLECTION = 'pageReviews'\n\nexport interface HostedPageReviewRequest {\n hostId: string\n screenId: string\n versionId: string\n /** The page as composed for a visitor. */\n nodes: unknown\n /** The host and org documents, when the caller already holds them. */\n host?: Record<string, unknown> | null\n org?: Record<string, unknown> | null\n orgId?: string | null\n nowMs?: number\n /**\n * What the composer knows about the page (AGL-3374): the screen document,\n * the template route it is rendering, the variant, and a way to read the\n * layouts and components composed into it. Read only when a row is filed,\n * to name the page — never to decide whether it serves.\n */\n page?: HostedPageContext | null\n}\n\nexport type HostedPageReviewOutcome =\n | { outcome: 'serve' }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string }\n\n/** The review row's id for a page and the signals that held. */\nexport function pageReviewId(\n hostId: string,\n screenId: string,\n signals: readonly PhishingScreenSignal[],\n): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/screens/${screenId}`, outboundContentHash([key]))\n}\n\n/** How long this process trusts a page row's state — the send seam's minute. */\nconst PAGE_MEMO_TTL_MS = 60_000\nconst pageMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n/** The version each page last served clean, as this process last wrote it. */\nconst servedMemo = new Map<string, string>()\n/** Lookalike hosts this process already flagged for an established page, and when. */\nconst flaggedMemo = new Map<string, number>()\nconst FLAGGED_MEMO_TTL_MS = 60 * 60_000\n\n/** Test seam: forget what this process learned. */\nexport function resetHostedPageReviewMemoForTests(): void {\n pageMemo.clear()\n servedMemo.clear()\n flaggedMemo.clear()\n}\n\n/**\n * The subject a row already stored, or a fresh description (AGL-3374). A row\n * that names its page is not described again, so the reads the description\n * may make are paid once per row, not once per render.\n */\nasync function subjectForRow(\n reviewId: string,\n describe: () => Promise<HeldPageSubject>,\n): Promise<HeldPageSubject> {\n const stored = await firebaseAdmin\n .app()\n .firestore()\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .get()\n .then((row) => storedSubject(row.get('heldPage')))\n .catch(() => null)\n return stored ?? describe()\n}\n\n/** A row's stored subject, when it has one. */\nfunction storedSubject(heldPage: unknown): HeldPageSubject | null {\n const subject = (heldPage as { subject?: HeldPageSubject } | null | undefined)?.subject\n return subject && typeof subject === 'object' && subject.screenId ? subject : null\n}\n\n/** The row's first line, naming the page and where it was seen. */\nfunction pageHeadline(subject: HeldPageSubject, siteName: string): string {\n return [`${heldPageLabel(subject)} of ${siteName}.`, ...heldPageDetails(subject)].join(' ')\n}\n\n/**\n * Screen one page as a visitor would receive it, and say whether it may be\n * served. See the module header.\n */\nexport async function reviewHostedPage(\n request: HostedPageReviewRequest,\n): Promise<HostedPageReviewOutcome> {\n const found = screenHostedPage({ nodes: request.nodes })\n if (!found.signals.length) return { outcome: 'serve' }\n\n const nowMs = request.nowMs ?? Date.now()\n try {\n const [host, owner] = await Promise.all([\n request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),\n request.org !== undefined\n ? { orgId: request.orgId ?? null, org: request.org }\n : getOrgForHost(request.hostId),\n ])\n const org = (owner?.org as Record<string, unknown> | null | undefined) ?? null\n const orgId = owner?.orgId ?? null\n // Again, now knowing whose page it is: a brand in the workspace's own\n // name or domain is its own, and a link to its own site is not away.\n const identity = workspaceScreenIdentity({ org, host })\n const verdict = screenHostedPage({ nodes: request.nodes, ...identity })\n const ageDays = orgAgeDays((org as { createdAt?: unknown } | null)?.createdAt, nowMs)\n // Every workspace is screened, however old: an established account can\n // be compromised, and a compromised account publishing a lure is the\n // same harm as a new one. What differs is what a hold may take DOWN.\n // Holding content that is already live serves its last clean version or\n // nothing, and on a real business's home page that is an outage the\n // screen caused (aglyn.com, 2026-09-28, a page that had served clean\n // until a rule changed). So for an established workspace:\n // - NEW content (a new version of a live page, or a page that has\n // never been live) holds, exactly as for a young workspace; the live\n // page keeps serving its last clean version, or the new page waits;\n // - content ALREADY LIVE keeps serving, and is flagged to staff as\n // urgent, who lock the site if it is impersonation.\n if (!isYoungWorkspaceAge(ageDays)) {\n const strong = signalsThatHold(verdict.signals, { ageDays })\n if (!strong.length) return { outcome: 'serve' }\n if (await isAlreadyLive(request.hostId, request.screenId, request.versionId, nowMs)) {\n await flagLivePage({\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n orgId,\n host,\n signals: strong,\n nowMs,\n page: request.page ?? null,\n identity,\n })\n return { outcome: 'serve' }\n }\n }\n const signals = signalsThatHold(verdict.signals, { ageDays })\n if (!signals.length) return { outcome: 'serve' }\n\n const reviewId = pageReviewId(request.hostId, request.screenId, signals)\n const reference = heldOutboundReference(reviewId)\n const memo = pageMemo.get(reviewId)\n if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'serve' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n }\n // The page by its real name and route (AGL-3374): a template, a list,\n // a variant, or a page whose flagged content lives in its layout.\n const subject = await subjectForRow(reviewId, async () =>\n describeHeldPage({\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n host,\n page: request.page,\n signals,\n identity,\n live: false,\n previousVersionId: await servedPageVersion(request.hostId, request.screenId),\n }),\n )\n const url = subject.entryUrl ?? subject.url\n const flagged = flaggedHostOf(signals)\n const siteName =\n typeof host?.['name'] === 'string' && host['name'] ? `\"${host['name']}\"` : 'a site'\n const label = heldPageLabel(subject)\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'page',\n path: `hosts/${request.hostId}/screens/${request.screenId}`,\n hostId: request.hostId,\n orgId,\n contentHash: outboundContentHash([signals]),\n subject: label.slice(0, 300),\n fromName: null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n },\n extra: {\n heldPage: { screenId: request.screenId, versionId: request.versionId, url, subject },\n },\n headline:\n `Held ${pageHeadline(subject, siteName)} Version ${request.versionId}, from a ` +\n `workspace ${ageDays ?? '?'} day(s) old. ${heldPageVisitorSentence(subject)}`,\n alertTitle: 'Published page held for review — possible phishing',\n alertBody: `${label.charAt(0).toUpperCase()}${label.slice(1)} was held before it went live.`,\n // The owners' words for it, and where they fix it (AGL-3368, AGL-3374).\n item: { label, path: heldPageConsolePath(subject, request.hostId) },\n page: subject,\n // The page itself is what was reported; the flagged host, when there\n // is one, is named in the details and on `reportedHostname`.\n url,\n reportedHostname: flagged,\n })\n pageMemo.set(reviewId, { state, atMs: nowMs })\n return state === 'released'\n ? { outcome: 'serve' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n } catch (error) {\n // Closed: the screen has already found a signal on this page, and a\n // review that could not finish has not cleared it. Not remembered, so\n // the next render reviews it again.\n console.error('[page-review] a flagged page could not be reviewed — holding it', error)\n const reviewId = pageReviewId(request.hostId, request.screenId, found.signals)\n return { outcome: 'held', reviewId, reference: heldOutboundReference(reviewId) }\n }\n}\n\nexport interface SiteRedirectReviewRequest {\n hostId: string\n /** The rule's document id under `hosts/{hostId}/redirects`. */\n ruleId: string\n /** The path, or the pattern, the rule answers. */\n source: string\n /** Where it sends the visitor, after any `$n` substitution. */\n destination: string\n /** The host and org documents, when the caller already holds them. */\n host?: Record<string, unknown> | null\n org?: Record<string, unknown> | null\n orgId?: string | null\n nowMs?: number\n}\n\n/** The review row's id for a redirect rule and the signals that held. */\nexport function redirectReviewId(\n hostId: string,\n ruleId: string,\n signals: readonly PhishingScreenSignal[],\n): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/redirects/${ruleId}`, outboundContentHash([key]))\n}\n\n/**\n * Screen one redirect rule before it sends a visitor off the site\n * (AGL-3447), and say whether it may fire. See the module header.\n *\n * Every workspace is screened and the tiers decide, as for a page: a\n * destination that wears a brand holds for all of them, a lure path sent\n * off the site only for a workspace in its first fortnight. A held rule is\n * filed once per (rule, signal set) and decided by staff on that row:\n * dismissed, it fires; actioned, it never does with those signals.\n */\nexport async function reviewSiteRedirect(\n request: SiteRedirectReviewRequest,\n): Promise<HostedPageReviewOutcome> {\n const found = screenSiteRedirect({ source: request.source, destination: request.destination })\n if (!found.signals.length) return { outcome: 'serve' }\n\n const nowMs = request.nowMs ?? Date.now()\n try {\n const [host, owner] = await Promise.all([\n request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),\n request.org !== undefined\n ? { orgId: request.orgId ?? null, org: request.org }\n : getOrgForHost(request.hostId),\n ])\n const org = (owner?.org as Record<string, unknown> | null | undefined) ?? null\n const orgId = owner?.orgId ?? null\n const identity = workspaceScreenIdentity({ org, host })\n const verdict = screenSiteRedirect({\n source: request.source,\n destination: request.destination,\n ...identity,\n })\n const ageDays = orgAgeDays((org as { createdAt?: unknown } | null)?.createdAt, nowMs)\n const signals = signalsThatHold(verdict.signals, { ageDays })\n if (!signals.length) return { outcome: 'serve' }\n\n const reviewId = redirectReviewId(request.hostId, request.ruleId, signals)\n const reference = heldOutboundReference(reviewId)\n const memo = pageMemo.get(reviewId)\n if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'serve' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n }\n const source = request.source.slice(0, 200)\n const destination = request.destination.slice(0, 500)\n const label = `the redirect from ${source}`\n const siteName =\n typeof host?.['name'] === 'string' && host['name'] ? `\"${host['name']}\"` : 'a site'\n const [subdomainHost, customDomain] = identity.ownDomains ?? []\n const siteHost = customDomain || subdomainHost\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'page',\n path: `hosts/${request.hostId}/redirects/${request.ruleId}`,\n hostId: request.hostId,\n orgId,\n contentHash: outboundContentHash([signals]),\n subject: label.slice(0, 300),\n fromName: null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n },\n headline:\n `Held ${label} of ${siteName}, which sends visitors to ${destination}, from a ` +\n `workspace ${ageDays ?? '?'} day(s) old. The rule does not fire until this is decided.`,\n alertTitle: 'Site redirect held for review — possible phishing',\n alertBody: `The redirect from ${source} was held before it sent anyone off the site.`,\n // Where the owners fix it: the site's Redirects page.\n item: { label, path: `/${request.hostId}/redirects` },\n url: siteHost && source.startsWith('/') ? `https://${siteHost}${source}` : destination,\n reportedHostname: flaggedHostOf(signals),\n })\n pageMemo.set(reviewId, { state, atMs: nowMs })\n return state === 'released'\n ? { outcome: 'serve' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n } catch (error) {\n // Closed, as a page is: the screen found a signal and nothing cleared it.\n console.error('[page-review] a flagged redirect could not be reviewed — holding it', error)\n const reviewId = redirectReviewId(request.hostId, request.ruleId, found.signals)\n return { outcome: 'held', reviewId, reference: heldOutboundReference(reviewId) }\n }\n}\n\nfunction pageReviewRef(hostId: string, screenId: string) {\n return firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection(PAGE_REVIEW_SUBCOLLECTION)\n .doc(screenId)\n}\n\n/**\n * Is this version of the page already what visitors receive? It is when it\n * is the version the page last served clean, or when the page has no record\n * at all and is not new: it went live before the screen existed. A page\n * created in the last {@link NEW_PAGE_DAYS} days with no record has never\n * been live, so holding it takes nothing down.\n */\nconst NEW_PAGE_DAYS = 7\nasync function isAlreadyLive(\n hostId: string,\n screenId: string,\n versionId: string,\n nowMs: number,\n): Promise<boolean> {\n const lastClean = await servedPageVersion(hostId, screenId)\n if (lastClean) return lastClean === versionId\n try {\n const screen = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('screens')\n .doc(screenId)\n .get()\n const created = screen.get('createdAt') as { toMillis?: () => number } | number | undefined\n const createdMs =\n typeof created === 'number' ? created : typeof created?.toMillis === 'function' ? created.toMillis() : null\n // No readable creation time: an old page, so live.\n return createdMs === null || nowMs - createdMs >= NEW_PAGE_DAYS * 24 * 60 * 60 * 1000\n } catch {\n return true\n }\n}\n\n/**\n * A page ALREADY LIVE carries a strong signal: file one urgent abuse row for\n * it (keyed on the page and its signals, so a busy page is one row) and, the\n * first time, tell staff and the owners through the `page-flagged` notice.\n * The page keeps serving; staff lock the site if it is impersonation or the\n * account looks compromised. Once per page and signal set per process.\n */\nasync function flagLivePage(input: {\n hostId: string\n screenId: string\n versionId: string\n orgId: string | null\n host: Record<string, unknown> | null\n signals: readonly PhishingScreenSignal[]\n nowMs: number\n page?: HostedPageContext | null\n identity?: ReturnType<typeof workspaceScreenIdentity>\n}): Promise<void> {\n const key = `${input.hostId}\\n${input.screenId}\\n${input.signals.map((signal) => canonicalJson(signal)).sort().join('\\n')}`\n const at = flaggedMemo.get(key)\n if (at !== undefined && input.nowMs - at < FLAGGED_MEMO_TTL_MS) return\n flaggedMemo.set(key, input.nowMs)\n try {\n const reviewId = pageReviewId(input.hostId, `${input.screenId}/live`, input.signals)\n const reference = heldOutboundReference(reviewId)\n const evidence = describePhishingScreenSignals(input.signals).join(' ')\n const flagged = flaggedHostOf(input.signals)\n const ref = firebaseAdmin.app().firestore().collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const existing = await ref.get()\n const first = !existing.exists\n // Named once, when the row is new (AGL-3374).\n const subject =\n (existing.exists ? storedSubject(existing.get('heldPage')) : null) ??\n (await describeHeldPage({\n hostId: input.hostId,\n screenId: input.screenId,\n versionId: input.versionId,\n host: input.host,\n page: input.page,\n signals: input.signals,\n identity: input.identity ?? workspaceScreenIdentity({ host: input.host }),\n live: true,\n previousVersionId: null,\n }))\n const url = subject.entryUrl ?? subject.url\n const siteName =\n typeof input.host?.['name'] === 'string' && input.host['name']\n ? `\"${input.host['name']}\"`\n : 'a site'\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url,\n reportedHostname: flagged,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `LIVE: ${pageHeadline(subject, siteName)} It was flagged by the phishing screen and is still serving.`,\n evidence,\n 'Lock the site if it is impersonation or the account looks compromised; dismiss this if the content is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n heldPage: { screenId: input.screenId, versionId: input.versionId, url, subject },\n riskNotice: { kind: 'page-flagged' },\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (!first) return\n await notifyRiskEvent({\n kind: 'page-flagged',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId,\n reference,\n item: { label: heldPageLabel(subject), path: heldPageConsolePath(subject, input.hostId) },\n page: subject,\n staffEvidence: [...heldPageDetails(subject), evidence].join(' '),\n })\n } catch (error) {\n console.error('[page-review] a live page could not be flagged', error)\n }\n}\n\n/**\n * The version this page last served clean, or null. A HINT, not a\n * clearance: the caller composes and screens it again before serving it,\n * because a version document can be edited after it was served.\n */\nexport async function servedPageVersion(hostId: string, screenId: string): Promise<string | null> {\n try {\n const snapshot = await pageReviewRef(hostId, screenId).get()\n const value = snapshot.get('servedVersionId')\n return typeof value === 'string' && value ? value : null\n } catch {\n return null\n }\n}\n\n/**\n * Note that this page served `versionId` clean. One read the first time a\n * process renders a page, a write only when the version moved; nothing\n * after that, however often the page is rendered.\n */\nexport async function recordServedPageVersion(\n hostId: string,\n screenId: string,\n versionId: string,\n): Promise<void> {\n const key = `${hostId}/${screenId}`\n if (!versionId || servedMemo.get(key) === versionId) return\n try {\n const ref = pageReviewRef(hostId, screenId)\n const snapshot = await ref.get()\n if (snapshot.get('servedVersionId') !== versionId) {\n await ref.set({ servedVersionId: versionId, servedAtMs: Date.now() }, { merge: true })\n }\n servedMemo.set(key, versionId)\n } catch (error) {\n console.warn('[page-review] the served version could not be noted', error)\n }\n}\n\n/**\n * A domain a workspace attached that wears a brand (AGL-3356, AGL-3362):\n * filed in the abuse queue as an urgent `phishing` row for staff, not\n * refused. Two kinds, one rule:\n *\n * - `site` — a custom domain a site is served on (the attach route);\n * - `sending` — a custom domain a workspace sends email as, when it is\n * added and again when it verifies (`sending-domains.ts`);\n * - `link` — a link a merchant configured that a buyer or a server would\n * follow: a product's paid download or video hotlink, a supplier's order\n * webhook (AGL-3363). The link itself is refused where it is followed; this\n * files it, and tells the site's managers once ({@link notifyLookalikeLink}).\n *\n * Not refused, because the domain is the merchant's own registration and\n * the platform cannot tell a brand's own agency attaching `paypal-promo.com`\n * from a kit — but a brand's lookalike on a site or in a `From:` line is\n * exactly what staff look at first. The site's pages are still screened as\n * they are served, and mail FROM a lookalike domain is held at the send seam\n * for every workspace. The test is the phishing screen's own lookalike rule\n * (`lookalikeBrandForHost`), the one that holds a link in an email. One row\n * per (owner, kind, domain), so a re-attach or a re-verify counts rather than\n * re-alerts.\n *\n * Never throws: the write it rides on has already happened.\n */\nexport async function flagLookalikeDomain(input: {\n kind: 'site' | 'sending' | 'link'\n hostId: string | null\n orgId: string | null\n domain: string\n /** For a `link`: where it was configured, as staff read it. */\n where?: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n const domain = String(input.domain ?? '').trim().toLowerCase()\n const brand = lookalikeBrandForHost(domain)\n if (!brand) return 'clean'\n const label = phishingScreenBrandLabel(brand.id)\n const sending = input.kind === 'sending'\n const link = input.kind === 'link'\n try {\n const firestore = firebaseAdmin.app().firestore()\n const owner = sending\n ? `orgs/${input.orgId ?? ''}/sendingDomains`\n : link\n ? `hosts/${input.hostId ?? ''}/links`\n : `hosts/${input.hostId ?? ''}/cname`\n const reviewId = heldOutboundReviewId(owner, domain)\n const reference = heldOutboundReference(reviewId)\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url: `https://${domain}/`,\n reportedHostname: domain,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n sending\n ? `A workspace added ${domain} as a domain to send email from.`\n : link\n ? `A site configured a link to ${domain} (${input.where ?? 'a commerce link'}).`\n : `A site attached the custom domain ${domain}.`,\n `${domain} wears the ${label} name but is not ${label}'s domain.`,\n sending\n ? 'Email from it is held at the send seam for every workspace. Mark this actioned ' +\n 'and lock the workspace if it is impersonation; dismiss it if the brand is theirs.'\n : link\n ? 'Nobody is sent to it: the link is refused wherever it is followed. Mark this ' +\n 'actioned and lock the site if it is impersonation.'\n : 'Its pages are still screened as they are served. Mark this actioned ' +\n 'and lock the site if it is impersonation; dismiss it if the brand is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (first && link) {\n await notifyLookalikeLink({\n hostId: input.hostId,\n orgId: input.orgId,\n domain,\n label,\n where: input.where ?? 'a commerce link',\n reference,\n reviewId,\n })\n } else if (first) {\n // The owners hear that the domain is waiting on a routine review — not\n // which name it resembles; staff get the brand and a link to the row.\n await notifyRiskEvent({\n kind: 'domain-flagged',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId,\n reference,\n item: {\n label: sending ? `the sending domain ${domain}` : `the custom domain ${domain}`,\n // Emails → Sending, or the site's Domain settings.\n path: sending ? '/org/emails/sending' : input.hostId ? `/${input.hostId}/admin/domain` : null,\n },\n staffEvidence:\n `${sending ? 'Sending domain' : 'Custom domain'} ${domain} looks like ${label}.`,\n })\n }\n return 'flagged'\n } catch (error) {\n console.error('[page-review] a lookalike domain could not be flagged', error)\n return 'failed'\n }\n}\n\n/**\n * Tell staff and the site's managers and the workspace's owners, once per\n * (site, domain), that a link the merchant configured was refused because it\n * wears another brand's name (AGL-3363), through the risk notice seam\n * (AGL-3368). The merchant reads the catalog's `link-blocked` words — what\n * was refused and how to fix it, never how the check works; staff get the\n * brand and a link to the row. Never throws.\n */\nexport async function notifyLookalikeLink(input: {\n hostId: string | null\n orgId?: string | null\n domain: string\n label: string\n where: string\n reference: string\n reviewId?: string | null\n}): Promise<void> {\n await notifyRiskEvent({\n kind: 'link-blocked',\n orgId: input.orgId ?? null,\n hostId: input.hostId,\n reviewId: input.reviewId ?? null,\n reference: input.reference,\n item: {\n label: `${input.where} pointing to ${input.domain}`,\n path: input.hostId ? `/${input.hostId}/products` : null,\n },\n staffEvidence:\n `A site configured ${input.where} to ${input.domain}, which looks like ${input.label}. ` +\n 'It is refused wherever it is followed.',\n }).catch(() => undefined)\n}\n\n/** A site's custom domain, through {@link flagLookalikeDomain}. */\nexport function flagLookalikeCustomDomain(input: {\n hostId: string\n orgId: string | null\n domain: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n return flagLookalikeDomain({ kind: 'site', ...input })\n}\n"],"names":["ABUSE_REPORT_COLLECTION","heldPageConsolePath","heldPageDetails","heldPageLabel","heldPageVisitorSentence","screenHostedPage","screenSiteRedirect","lookalikeBrandForHost","phishingScreenBrandLabel","describePhishingScreenSignals","isYoungWorkspaceAge","signalsThatHold","FieldValue","firebaseAdmin","describeHeldPage","notifyRiskEvent","orgAgeDays","getHostDocAdmin","getOrgForHost","canonicalJson","fileOutboundHold","flaggedHostOf","heldOutboundReference","heldOutboundReviewId","outboundContentHash","workspaceScreenIdentity","PAGE_REVIEW_SUBCOLLECTION","pageReviewId","hostId","screenId","signals","key","map","signal","sort","join","PAGE_MEMO_TTL_MS","pageMemo","Map","servedMemo","flaggedMemo","FLAGGED_MEMO_TTL_MS","resetHostedPageReviewMemoForTests","clear","subjectForRow","reviewId","describe","stored","app","firestore","collection","doc","get","then","row","storedSubject","catch","heldPage","subject","pageHeadline","siteName","reviewHostedPage","request","found","nodes","length","outcome","nowMs","Date","now","host","owner","Promise","all","undefined","org","orgId","identity","verdict","ageDays","createdAt","strong","isAlreadyLive","versionId","flagLivePage","page","reference","memo","atMs","state","live","previousVersionId","servedPageVersion","url","entryUrl","flagged","label","heldSend","kind","path","contentHash","slice","fromName","heldAtMs","extra","headline","alertTitle","alertBody","charAt","toUpperCase","item","reportedHostname","set","error","console","redirectReviewId","ruleId","reviewSiteRedirect","source","destination","subdomainHost","customDomain","ownDomains","siteHost","startsWith","pageReviewRef","NEW_PAGE_DAYS","lastClean","screen","created","createdMs","toMillis","input","at","existing","evidence","ref","first","exists","category","severity","details","reporterEmail","reporterName","dmca","riskNotice","reportCount","increment","updatedAt","serverTimestamp","status","merge","staffEvidence","snapshot","value","recordServedPageVersion","servedVersionId","servedAtMs","warn","flagLookalikeDomain","domain","String","trim","toLowerCase","brand","id","sending","link","where","notifyLookalikeLink","flagLookalikeCustomDomain"],"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
|
/**
|
|
@@ -32,7 +32,15 @@ export interface MemberEmailAliasRefused {
|
|
|
32
32
|
message: string;
|
|
33
33
|
}
|
|
34
34
|
/** The addresses one member has added in one workspace, confirmed or not. */
|
|
35
|
-
export declare function
|
|
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";
|
|
@@ -84,10 +84,34 @@ function aliasesRef(firestore, orgId, uid) {
|
|
|
84
84
|
function memberRef(firestore, orgId, uid) {
|
|
85
85
|
return firestore.collection('orgs').doc(orgId).collection('members').doc(uid);
|
|
86
86
|
}
|
|
87
|
-
/** The addresses one member has added in one workspace, confirmed or not. */ export async function
|
|
87
|
+
/** The addresses one member has added in one workspace, confirmed or not. */ export async function loadMemberEmailAliases(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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/member-email-aliases.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 evaluateMemberEmailAliasAdd,\n isVerifiedMemberEmailAlias,\n MEMBER_EMAIL_ALIAS_CONFIRM_PARAM,\n MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n MEMBER_EMAIL_ALIASES_COLLECTION,\n type MemberEmailAlias,\n type MemberEmailAliasAddRefusal,\n normalizeMemberEmailAlias,\n readMemberEmailAliases,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { createHmac } from 'crypto'\nimport { tokenSigningSecret } from './media-signing'\nimport { renderSystemEmailContent, systemEmailBrand } from './render-system-email'\nimport { safeEqual } from './safe-equal'\n\n/**\n * A MEMBER'S OWN ADDRESSES (AGL-2975): the half with Firestore.\n *\n * The pure rules — what an address is, which are confirmed, whose an\n * address is — are `@aglyn/aglyn`'s `member-email-aliases.ts`. This module\n * is the store under `orgs/{orgId}/memberEmailAliases/{uid}`, the signed\n * confirmation link, and the email that carries it.\n *\n * ## Every write is here\n *\n * The rules deny the collection to every client, reads included, so\n * `verifiedAtMs` is only ever set here, by one of two writers, each running\n * behind the member's own verified session:\n * {@link confirmMemberEmailAlias}, for a member who opened the link mailed\n * to the address, and {@link confirmMemberEmailAliasesByProvider}, for a\n * member whose connected mail account lists the address as one its provider\n * has already verified they may send as. A client that could write the\n * document could mark any address it liked as its own, and the capture\n * webhook would then file a stranger's mail as the member's.\n *\n * ## The confirmation link\n *\n * `{ org, uid, address, addedAtMs, exp }`, HMAC-signed with the shared,\n * fail-closed `TOKEN_SIGNING_SECRET` under its own `member-email-alias:`\n * prefix, so it can never be replayed as a media, edit-bar or form token.\n * Nothing is stored for it: the entry's `addedAtMs` is what binds a link to\n * one request, so removing an address and adding it again retires every\n * link sent before, and a link for an address already confirmed confirms\n * nothing new.\n *\n * Confirming takes the session of the member who added the address — not\n * merely the link. Opening the mailbox proves the address receives mail;\n * it does not prove the mailbox is the member's. A prospect whose address\n * was pasted into the wrong field receives the link too, and a link that\n * worked for whoever opened it would let that prospect turn their own\n * address into the member's, after which every reply they sent would be\n * filed as the member writing to nobody.\n *\n * Firestore is a parameter, as in `crm-inbound-email.ts`, so a spec needs a\n * store and nothing else; the rate limiter and the email meter are loaded\n * only when a message is actually sent.\n */\n\n/** The version tag at the head of a token, so a later claim change is refused by shape. */\nconst TOKEN_VERSION = 'mea1'\n\n/** Domain separation from every other token the shared secret signs. */\nconst TOKEN_CONTEXT = 'member-email-alias:v1'\n\n/** Far above any real token, so an oversized value is refused unparsed. */\nconst TOKEN_MAX_CHARS = 2048\n\n/** Confirmation emails one member may cause per hour, across addresses. */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR = 10\n\n/**\n * Confirmation emails one address may receive per hour, from anybody.\n * Per member alone would let many members aim at one mailbox; per address\n * alone would let one member walk a list — the two limits the account\n * address route applies, for the same mailbomb shape.\n */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR = 3\n\n/** Every refusal this module answers with, as a stable code. */\nexport type MemberEmailAliasRefusal =\n | MemberEmailAliasAddRefusal\n | 'not-a-member'\n | 'unknown-address'\n | 'token-invalid'\n | 'token-expired'\n | 'wrong-member'\n | 'link-retired'\n\nexport interface MemberEmailAliasRefused {\n ok: false\n refusal: MemberEmailAliasRefusal\n message: string\n}\n\nconst refused = (refusal: MemberEmailAliasRefusal, message: string): MemberEmailAliasRefused => ({\n ok: false,\n refusal,\n message,\n})\n\nconst NOT_A_MEMBER = 'Only a member of this workspace can add addresses to it.'\n\nfunction aliasesRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(MEMBER_EMAIL_ALIASES_COLLECTION)\n .doc(uid)\n}\n\nfunction memberRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore.collection('orgs').doc(orgId).collection('members').doc(uid)\n}\n\n/** The addresses one member has added in one workspace, confirmed or not. */\nexport async function listMemberEmailAliases(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n): Promise<MemberEmailAlias[]> {\n const snapshot = await aliasesRef(firestore, orgId, uid).get()\n return readMemberEmailAliases(snapshot.exists ? snapshot.data() : null)\n}\n\nexport type MemberEmailAliasAdded =\n | {\n ok: true\n alias: MemberEmailAlias\n /** False when the address was already waiting for confirmation. */\n created: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Adds an address, unconfirmed, for a member of the workspace — or answers\n * the entry already waiting for it, so the caller sends another link. One\n * transaction over the roster row and the member's document, so the\n * ceiling is counted from what is stored at the moment of the write and an\n * address is never added for somebody who is no longer a member.\n */\nexport async function addMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n uid: string\n address: unknown\n /** The address the member signs in with, which is theirs already. */\n signInEmail?: string | null\n /** Domains the deployment receives mail at — the capture domain. */\n reservedDomains?: readonly string[]\n nowMs?: number\n },\n): Promise<MemberEmailAliasAdded> {\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasAdded>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const verdict = evaluateMemberEmailAliasAdd({\n address: input.address,\n signInEmail: input.signInEmail ?? null,\n aliases,\n reservedDomains: input.reservedDomains ?? [],\n })\n if (verdict.ok === false) return refused(verdict.refusal, verdict.message)\n if (verdict.existing) return { ok: true, alias: verdict.existing, created: false }\n const alias: MemberEmailAlias = { address: verdict.address, addedAtMs: nowMs }\n tx.set(ref, { uid: input.uid, aliases: [...aliases, alias], updatedAtMs: nowMs })\n return { ok: true, alias, created: true }\n })\n}\n\n/**\n * Removes one address, confirmed or not; the document goes with its last\n * address. Removal retires every link sent for the address, because a link\n * names the entry's `addedAtMs` and there is no longer an entry to match.\n */\nexport type MemberEmailAliasRemoved =\n | { ok: true; removed: MemberEmailAlias }\n | MemberEmailAliasRefused\n\nexport async function removeMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; address: unknown; nowMs?: number },\n): Promise<MemberEmailAliasRemoved> {\n const address = normalizeMemberEmailAlias(input.address)\n if (!address) return refused('invalid-address', 'Enter a valid email address.')\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasRemoved>(async (tx) => {\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const removed = aliases.find((alias) => alias.address === address)\n if (!removed) return refused('unknown-address', 'That address is not on your list.')\n const remaining = aliases.filter((alias) => alias.address !== address)\n if (remaining.length) {\n tx.set(ref, { uid: input.uid, aliases: remaining, updatedAtMs: nowMs })\n } else {\n tx.delete(ref)\n }\n return { ok: true, removed }\n })\n}\n\n/** What a confirmation link asserts. */\nexport interface MemberEmailAliasClaims {\n orgId: string\n uid: string\n address: string\n /** The `addedAtMs` of the entry the link was sent for. */\n addedAtMs: number\n /** Expiry, epoch ms. */\n exp: number\n}\n\ninterface WireClaims {\n o: string\n u: string\n a: string\n t: number\n e: number\n}\n\nconst signature = (payload: string): string =>\n createHmac('sha256', tokenSigningSecret()).update(`${TOKEN_CONTEXT}:${payload}`).digest('base64url')\n\n/**\n * The token a confirmation link carries for one entry. Throws when\n * `TOKEN_SIGNING_SECRET` is not configured, like every token that secret\n * signs; the route answers that as a failure to send.\n */\nexport function mintMemberEmailAliasToken(\n claims: Omit<MemberEmailAliasClaims, 'exp'>,\n nowMs: number = Date.now(),\n): string {\n const wire: WireClaims = {\n o: claims.orgId,\n u: claims.uid,\n a: claims.address,\n t: claims.addedAtMs,\n e: nowMs + MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n }\n const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url')\n return `${TOKEN_VERSION}.${payload}.${signature(payload)}`\n}\n\n/**\n * The claims a token carries, or why it carries none: `token-expired` for a\n * genuine link past its day, `token-invalid` for anything else — tampered,\n * truncated, another version, or a deployment with no signing secret, whose\n * only safe reading of a link is that it proves nothing.\n */\nexport function readMemberEmailAliasToken(\n token: unknown,\n nowMs: number = Date.now(),\n): { ok: true; claims: MemberEmailAliasClaims } | MemberEmailAliasRefused {\n const invalid = refused('token-invalid', 'That confirmation link is not valid.')\n if (typeof token !== 'string' || !token || token.length > TOKEN_MAX_CHARS) return invalid\n const parts = token.split('.')\n if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return invalid\n const [, payload, signed] = parts\n let expected: string\n try {\n expected = signature(payload)\n } catch {\n return invalid\n }\n if (!safeEqual(signed, expected)) return invalid\n let wire: Partial<WireClaims>\n try {\n wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<WireClaims>\n } catch {\n return invalid\n }\n const address = normalizeMemberEmailAlias(wire?.a)\n const orgId = typeof wire?.o === 'string' ? wire.o : ''\n const uid = typeof wire?.u === 'string' ? wire.u : ''\n const addedAtMs = Number(wire?.t)\n const exp = Number(wire?.e)\n if (!orgId || !uid || !address || !Number.isFinite(addedAtMs) || !Number.isFinite(exp)) {\n return invalid\n }\n if (exp <= nowMs) {\n return refused('token-expired', 'That confirmation link has expired. Send a new one from the list.')\n }\n return { ok: true, claims: { orgId, uid, address, addedAtMs, exp } }\n}\n\nexport type MemberEmailAliasConfirmed =\n | {\n ok: true\n orgId: string\n address: string\n /** True when the address had been confirmed before this call. */\n alreadyConfirmed: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms the address a link was sent for, for the member signed in — who\n * must be the member who added it (see the module comment). A link for an\n * entry that has since been removed or added again is refused as retired,\n * and opening a link twice is not an error.\n */\nexport async function confirmMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { token: unknown; callerUid: string; nowMs?: number },\n): Promise<MemberEmailAliasConfirmed> {\n const nowMs = input.nowMs ?? Date.now()\n const read = readMemberEmailAliasToken(input.token, nowMs)\n if (read.ok === false) return read\n const { claims } = read\n if (!input.callerUid || claims.uid !== input.callerUid) {\n return refused(\n 'wrong-member',\n 'This link confirms an address for another member. Sign in as the member who added it, then open the link again.',\n )\n }\n const ref = aliasesRef(firestore, claims.orgId, claims.uid)\n const roster = memberRef(firestore, claims.orgId, claims.uid)\n return firestore.runTransaction<MemberEmailAliasConfirmed>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', 'You are no longer a member of that workspace.')\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const entry = aliases.find((alias) => alias.address === claims.address)\n if (!entry || entry.addedAtMs !== claims.addedAtMs) {\n return refused(\n 'link-retired',\n 'This link is for an address that has since been removed or added again. Send a new link from the list.',\n )\n }\n if (isVerifiedMemberEmailAlias(entry)) {\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: true }\n }\n const next = aliases.map((alias) =>\n alias.address === entry.address ? { ...alias, verifiedAtMs: nowMs } : alias,\n )\n tx.set(ref, { uid: claims.uid, aliases: next, updatedAtMs: nowMs })\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: false }\n })\n}\n\nexport type MemberEmailAliasesConfirmedByProvider =\n | {\n ok: true\n /** The addresses this call confirmed, normalized, in list order. */\n confirmed: string[]\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms a member's PENDING addresses that their connected mail account\n * already proves are theirs (AGL-2978).\n *\n * The confirmation link proves an address receives mail and asks the member\n * to open it. A provider's verified send-as list proves more: the provider\n * confirmed the account may send as the address, and the member proved they\n * hold that account by connecting it under their own session. So an address\n * the member added and has not yet confirmed counts as confirmed once it\n * appears in that list.\n *\n * Only addresses the member ALREADY added are touched — this never adds one,\n * because an address the member did not ask for is not a claim they made —\n * and an address already confirmed keeps its original `verifiedAtMs`. The\n * caller passes only the provider's VERIFIED addresses; which of a\n * provider's listings count as verified is the caller's rule, not this\n * store's. Nothing is written when nothing changes.\n */\nexport async function confirmMemberEmailAliasesByProvider(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; addresses: readonly unknown[]; nowMs?: number },\n): Promise<MemberEmailAliasesConfirmedByProvider> {\n const nowMs = input.nowMs ?? Date.now()\n const proven = new Set(\n input.addresses\n .map((address) => normalizeMemberEmailAlias(address))\n .filter((address): address is string => Boolean(address)),\n )\n if (!input.orgId || !input.uid) return refused('not-a-member', NOT_A_MEMBER)\n if (!proven.size) return { ok: true, confirmed: [] }\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasesConfirmedByProvider>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const confirmed: string[] = []\n const next = aliases.map((alias) => {\n if (isVerifiedMemberEmailAlias(alias) || !proven.has(alias.address)) return alias\n confirmed.push(alias.address)\n return { ...alias, verifiedAtMs: nowMs }\n })\n if (confirmed.length) {\n tx.set(ref, { uid: input.uid, aliases: next, updatedAtMs: nowMs })\n }\n return { ok: true, confirmed }\n })\n}\n\n/**\n * A console path a link may land on: absolute, on the console's own\n * origin, with no query or fragment of its own. Anything else lands on\n * `/`, which still signs the member in.\n */\nconst RETURN_PATH = /^\\/(?![/\\\\])[^\\s?#\\\\]{0,511}$/\n\n/**\n * The link a confirmation email carries: the console origin the caller\n * resolved from server configuration — never from a request header — the\n * page the member added the address on, and the token.\n */\nexport function memberEmailAliasConfirmUrl(input: {\n origin: string\n returnPath?: unknown\n token: string\n}): string {\n const origin = String(input.origin ?? '').replace(/\\/+$/, '')\n const raw = typeof input.returnPath === 'string' ? input.returnPath : ''\n const path = RETURN_PATH.test(raw) ? raw : '/'\n return `${origin}${path}?${MEMBER_EMAIL_ALIAS_CONFIRM_PARAM}=${encodeURIComponent(input.token)}`\n}\n\nexport type MemberEmailAliasConfirmationSent =\n | { sent: true }\n | { sent: false; status: 429 | 501 | 502; error: string }\n\n/**\n * Emails the confirmation link to the address, in the workspace's brand.\n *\n * Rate-limited per member and per destination address before anything is\n * sent (see the two ceilings above), and metered to the workspace, whose\n * member asked for it.\n */\nexport async function sendMemberEmailAliasConfirmation(input: {\n orgId: string\n /** The org document, for its name and its brand. */\n org: Record<string, unknown> | null\n uid: string\n /** The member's display name, when the roster row carries one. */\n memberName?: string | null\n address: string\n confirmUrl: string\n}): Promise<MemberEmailAliasConfirmationSent> {\n if (!isEmailConfigured()) {\n return { sent: false, status: 501, error: 'Email is not configured on this deployment.' }\n }\n const { consumeRateLimit } = await import('./rate-limit-store')\n const perMember = await consumeRateLimit(`member-email-alias-confirm:${input.uid}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perMember.allowed) {\n return { sent: false, status: 429, error: 'Too many confirmation emails. Try again in an hour.' }\n }\n const perAddress = await consumeRateLimit(`member-email-alias-target:${input.address}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perAddress.allowed) {\n return {\n sent: false,\n status: 429,\n error: 'Too many confirmation emails for that address. Try again later.',\n }\n }\n\n const branding = resolveBrandingProfile((input.org ?? null) as never)\n const workspace = String(input.org?.['name'] ?? '').trim() || 'your workspace'\n const member = String(input.memberName ?? '').trim()\n const text = [\n `${member || 'A member'} asked to add ${input.address} as one of their own email ` +\n `addresses in the ${workspace} workspace on ${branding.productName}.`,\n '',\n `To confirm it, open this link while signed in as ${member || 'that member'}:`,\n '',\n input.confirmUrl,\n '',\n `Once it is confirmed, email sent from ${input.address} with the workspace's capture ` +\n 'address in BCC is filed under the person it was written to. The link works for 24 hours.',\n '',\n 'If you did not expect this email, ignore it. Nothing changes unless the member who asked opens the link.',\n ].join('\\n')\n // The `member-email-alias-confirmation` system email (AGL-3367), in the\n // workspace's brand; `text` above is its last resort.\n const content = await renderSystemEmailContent(\n 'member-email-alias-confirmation',\n {\n 'member.name': member || 'A member',\n address: input.address,\n 'org.name': workspace,\n confirmUrl: input.confirmUrl,\n },\n systemEmailBrand((input.org ?? null) as Record<string, unknown> | null),\n { subject: `Confirm your address for ${workspace}`, text },\n )\n const result = await sendEmail({\n to: input.address,\n ...content,\n fromName: branding.fromName,\n context: 'member-email-alias-confirmation',\n })\n if (!result.sent) {\n return { sent: false, status: 502, error: 'The confirmation email could not be sent.' }\n }\n const { meterOrgEmail } = await import('./email-metering')\n await meterOrgEmail(input.orgId).catch(() => undefined)\n return { sent: true }\n}\n"],"names":["evaluateMemberEmailAliasAdd","isVerifiedMemberEmailAlias","MEMBER_EMAIL_ALIAS_CONFIRM_PARAM","MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS","MEMBER_EMAIL_ALIASES_COLLECTION","normalizeMemberEmailAlias","readMemberEmailAliases","resolveBrandingProfile","isEmailConfigured","sendEmail","createHmac","tokenSigningSecret","renderSystemEmailContent","systemEmailBrand","safeEqual","TOKEN_VERSION","TOKEN_CONTEXT","TOKEN_MAX_CHARS","MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR","MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR","refused","refusal","message","ok","NOT_A_MEMBER","aliasesRef","firestore","orgId","uid","collection","doc","memberRef","listMemberEmailAliases","snapshot","get","exists","data","addMemberEmailAlias","input","nowMs","Date","now","ref","roster","runTransaction","tx","member","stored","aliases","verdict","address","signInEmail","reservedDomains","existing","alias","created","addedAtMs","set","updatedAtMs","removeMemberEmailAlias","removed","find","remaining","filter","length","delete","signature","payload","update","digest","mintMemberEmailAliasToken","claims","wire","o","u","a","t","e","Buffer","from","JSON","stringify","toString","readMemberEmailAliasToken","token","invalid","parts","split","signed","expected","parse","Number","exp","isFinite","confirmMemberEmailAlias","read","callerUid","entry","alreadyConfirmed","next","map","verifiedAtMs","confirmMemberEmailAliasesByProvider","proven","Set","addresses","Boolean","size","confirmed","has","push","RETURN_PATH","memberEmailAliasConfirmUrl","origin","String","replace","raw","returnPath","path","test","encodeURIComponent","sendMemberEmailAliasConfirmation","sent","status","error","consumeRateLimit","perMember","limit","windowMs","allowed","perAddress","branding","org","workspace","trim","memberName","text","productName","confirmUrl","join","content","subject","result","to","fromName","context","meterOrgEmail","catch","undefined"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/member-email-aliases.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 evaluateMemberEmailAliasAdd,\n isVerifiedMemberEmailAlias,\n MEMBER_EMAIL_ALIAS_CONFIRM_PARAM,\n MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n MEMBER_EMAIL_ALIASES_COLLECTION,\n memberAddressRoster,\n type MemberEmailAlias,\n type MemberEmailAliasAddRefusal,\n normalizeMemberEmailAlias,\n readMemberEmailAliases,\n type RosterMember,\n type RosterRow,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { createHmac } from 'crypto'\nimport { tokenSigningSecret } from './media-signing'\nimport { renderSystemEmailContent, systemEmailBrand } from './render-system-email'\nimport { safeEqual } from './safe-equal'\n\n/**\n * A MEMBER'S OWN ADDRESSES (AGL-2975): the half with Firestore.\n *\n * The pure rules — what an address is, which are confirmed, whose an\n * address is — are `@aglyn/aglyn`'s `member-email-aliases.ts`. This module\n * is the store under `orgs/{orgId}/memberEmailAliases/{uid}`, the signed\n * confirmation link, and the email that carries it.\n *\n * ## Every write is here\n *\n * The rules deny the collection to every client, reads included, so\n * `verifiedAtMs` is only ever set here, by one of two writers, each running\n * behind the member's own verified session:\n * {@link confirmMemberEmailAlias}, for a member who opened the link mailed\n * to the address, and {@link confirmMemberEmailAliasesByProvider}, for a\n * member whose connected mail account lists the address as one its provider\n * has already verified they may send as. A client that could write the\n * document could mark any address it liked as its own, and the capture\n * webhook would then file a stranger's mail as the member's.\n *\n * ## The confirmation link\n *\n * `{ org, uid, address, addedAtMs, exp }`, HMAC-signed with the shared,\n * fail-closed `TOKEN_SIGNING_SECRET` under its own `member-email-alias:`\n * prefix, so it can never be replayed as a media, edit-bar or form token.\n * Nothing is stored for it: the entry's `addedAtMs` is what binds a link to\n * one request, so removing an address and adding it again retires every\n * link sent before, and a link for an address already confirmed confirms\n * nothing new.\n *\n * Confirming takes the session of the member who added the address — not\n * merely the link. Opening the mailbox proves the address receives mail;\n * it does not prove the mailbox is the member's. A prospect whose address\n * was pasted into the wrong field receives the link too, and a link that\n * worked for whoever opened it would let that prospect turn their own\n * address into the member's, after which every reply they sent would be\n * filed as the member writing to nobody.\n *\n * Firestore is a parameter, as in `crm-inbound-email.ts`, so a spec needs a\n * store and nothing else; the rate limiter and the email meter are loaded\n * only when a message is actually sent.\n */\n\n/** The version tag at the head of a token, so a later claim change is refused by shape. */\nconst TOKEN_VERSION = 'mea1'\n\n/** Domain separation from every other token the shared secret signs. */\nconst TOKEN_CONTEXT = 'member-email-alias:v1'\n\n/** Far above any real token, so an oversized value is refused unparsed. */\nconst TOKEN_MAX_CHARS = 2048\n\n/** Confirmation emails one member may cause per hour, across addresses. */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR = 10\n\n/**\n * Confirmation emails one address may receive per hour, from anybody.\n * Per member alone would let many members aim at one mailbox; per address\n * alone would let one member walk a list — the two limits the account\n * address route applies, for the same mailbomb shape.\n */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR = 3\n\n/** Every refusal this module answers with, as a stable code. */\nexport type MemberEmailAliasRefusal =\n | MemberEmailAliasAddRefusal\n | 'not-a-member'\n | 'unknown-address'\n | 'token-invalid'\n | 'token-expired'\n | 'wrong-member'\n | 'link-retired'\n\nexport interface MemberEmailAliasRefused {\n ok: false\n refusal: MemberEmailAliasRefusal\n message: string\n}\n\nconst refused = (refusal: MemberEmailAliasRefusal, message: string): MemberEmailAliasRefused => ({\n ok: false,\n refusal,\n message,\n})\n\nconst NOT_A_MEMBER = 'Only a member of this workspace can add addresses to it.'\n\nfunction aliasesRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(MEMBER_EMAIL_ALIASES_COLLECTION)\n .doc(uid)\n}\n\nfunction memberRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore.collection('orgs').doc(orgId).collection('members').doc(uid)\n}\n\n/** The addresses one member has added in one workspace, confirmed or not. */\nexport async function loadMemberEmailAliases(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n): Promise<MemberEmailAlias[]> {\n const snapshot = await aliasesRef(firestore, orgId, uid).get()\n return readMemberEmailAliases(snapshot.exists ? snapshot.data() : null)\n}\n\n/**\n * The workspace's people and every address that is theirs — the member\n * documents and every member's stored alias document, two reads under the\n * org, reduced by `memberAddressRoster` to each member's sign-in address and\n * the aliases they have confirmed (AGL-2975). What a received message is\n * matched against to tell the team's own mail from a correspondent's.\n */\nexport async function loadMemberAddressRoster(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<RosterMember[]> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [members, aliases] = await Promise.all([\n orgRef.collection('members').get(),\n orgRef.collection(MEMBER_EMAIL_ALIASES_COLLECTION).get(),\n ])\n const rows: RosterRow[] = members.docs.map((doc) => ({\n ...((doc.data() ?? {}) as Omit<RosterRow, '$id'>),\n $id: doc.id,\n }))\n const aliasDocuments = new Map<string, unknown>(\n aliases.docs.map((doc) => [String(doc.id), doc.data()]),\n )\n return memberAddressRoster(rows, aliasDocuments)\n}\n\nexport type MemberEmailAliasAdded =\n | {\n ok: true\n alias: MemberEmailAlias\n /** False when the address was already waiting for confirmation. */\n created: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Adds an address, unconfirmed, for a member of the workspace — or answers\n * the entry already waiting for it, so the caller sends another link. One\n * transaction over the roster row and the member's document, so the\n * ceiling is counted from what is stored at the moment of the write and an\n * address is never added for somebody who is no longer a member.\n */\nexport async function addMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n uid: string\n address: unknown\n /** The address the member signs in with, which is theirs already. */\n signInEmail?: string | null\n /** Domains the deployment receives mail at — the capture domain. */\n reservedDomains?: readonly string[]\n nowMs?: number\n },\n): Promise<MemberEmailAliasAdded> {\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasAdded>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const verdict = evaluateMemberEmailAliasAdd({\n address: input.address,\n signInEmail: input.signInEmail ?? null,\n aliases,\n reservedDomains: input.reservedDomains ?? [],\n })\n if (verdict.ok === false) return refused(verdict.refusal, verdict.message)\n if (verdict.existing) return { ok: true, alias: verdict.existing, created: false }\n const alias: MemberEmailAlias = { address: verdict.address, addedAtMs: nowMs }\n tx.set(ref, { uid: input.uid, aliases: [...aliases, alias], updatedAtMs: nowMs })\n return { ok: true, alias, created: true }\n })\n}\n\n/**\n * Removes one address, confirmed or not; the document goes with its last\n * address. Removal retires every link sent for the address, because a link\n * names the entry's `addedAtMs` and there is no longer an entry to match.\n */\nexport type MemberEmailAliasRemoved =\n | { ok: true; removed: MemberEmailAlias }\n | MemberEmailAliasRefused\n\nexport async function removeMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; address: unknown; nowMs?: number },\n): Promise<MemberEmailAliasRemoved> {\n const address = normalizeMemberEmailAlias(input.address)\n if (!address) return refused('invalid-address', 'Enter a valid email address.')\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasRemoved>(async (tx) => {\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const removed = aliases.find((alias) => alias.address === address)\n if (!removed) return refused('unknown-address', 'That address is not on your list.')\n const remaining = aliases.filter((alias) => alias.address !== address)\n if (remaining.length) {\n tx.set(ref, { uid: input.uid, aliases: remaining, updatedAtMs: nowMs })\n } else {\n tx.delete(ref)\n }\n return { ok: true, removed }\n })\n}\n\n/** What a confirmation link asserts. */\nexport interface MemberEmailAliasClaims {\n orgId: string\n uid: string\n address: string\n /** The `addedAtMs` of the entry the link was sent for. */\n addedAtMs: number\n /** Expiry, epoch ms. */\n exp: number\n}\n\ninterface WireClaims {\n o: string\n u: string\n a: string\n t: number\n e: number\n}\n\nconst signature = (payload: string): string =>\n createHmac('sha256', tokenSigningSecret()).update(`${TOKEN_CONTEXT}:${payload}`).digest('base64url')\n\n/**\n * The token a confirmation link carries for one entry. Throws when\n * `TOKEN_SIGNING_SECRET` is not configured, like every token that secret\n * signs; the route answers that as a failure to send.\n */\nexport function mintMemberEmailAliasToken(\n claims: Omit<MemberEmailAliasClaims, 'exp'>,\n nowMs: number = Date.now(),\n): string {\n const wire: WireClaims = {\n o: claims.orgId,\n u: claims.uid,\n a: claims.address,\n t: claims.addedAtMs,\n e: nowMs + MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n }\n const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url')\n return `${TOKEN_VERSION}.${payload}.${signature(payload)}`\n}\n\n/**\n * The claims a token carries, or why it carries none: `token-expired` for a\n * genuine link past its day, `token-invalid` for anything else — tampered,\n * truncated, another version, or a deployment with no signing secret, whose\n * only safe reading of a link is that it proves nothing.\n */\nexport function readMemberEmailAliasToken(\n token: unknown,\n nowMs: number = Date.now(),\n): { ok: true; claims: MemberEmailAliasClaims } | MemberEmailAliasRefused {\n const invalid = refused('token-invalid', 'That confirmation link is not valid.')\n if (typeof token !== 'string' || !token || token.length > TOKEN_MAX_CHARS) return invalid\n const parts = token.split('.')\n if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return invalid\n const [, payload, signed] = parts\n let expected: string\n try {\n expected = signature(payload)\n } catch {\n return invalid\n }\n if (!safeEqual(signed, expected)) return invalid\n let wire: Partial<WireClaims>\n try {\n wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<WireClaims>\n } catch {\n return invalid\n }\n const address = normalizeMemberEmailAlias(wire?.a)\n const orgId = typeof wire?.o === 'string' ? wire.o : ''\n const uid = typeof wire?.u === 'string' ? wire.u : ''\n const addedAtMs = Number(wire?.t)\n const exp = Number(wire?.e)\n if (!orgId || !uid || !address || !Number.isFinite(addedAtMs) || !Number.isFinite(exp)) {\n return invalid\n }\n if (exp <= nowMs) {\n return refused('token-expired', 'That confirmation link has expired. Send a new one from the list.')\n }\n return { ok: true, claims: { orgId, uid, address, addedAtMs, exp } }\n}\n\nexport type MemberEmailAliasConfirmed =\n | {\n ok: true\n orgId: string\n address: string\n /** True when the address had been confirmed before this call. */\n alreadyConfirmed: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms the address a link was sent for, for the member signed in — who\n * must be the member who added it (see the module comment). A link for an\n * entry that has since been removed or added again is refused as retired,\n * and opening a link twice is not an error.\n */\nexport async function confirmMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { token: unknown; callerUid: string; nowMs?: number },\n): Promise<MemberEmailAliasConfirmed> {\n const nowMs = input.nowMs ?? Date.now()\n const read = readMemberEmailAliasToken(input.token, nowMs)\n if (read.ok === false) return read\n const { claims } = read\n if (!input.callerUid || claims.uid !== input.callerUid) {\n return refused(\n 'wrong-member',\n 'This link confirms an address for another member. Sign in as the member who added it, then open the link again.',\n )\n }\n const ref = aliasesRef(firestore, claims.orgId, claims.uid)\n const roster = memberRef(firestore, claims.orgId, claims.uid)\n return firestore.runTransaction<MemberEmailAliasConfirmed>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', 'You are no longer a member of that workspace.')\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const entry = aliases.find((alias) => alias.address === claims.address)\n if (!entry || entry.addedAtMs !== claims.addedAtMs) {\n return refused(\n 'link-retired',\n 'This link is for an address that has since been removed or added again. Send a new link from the list.',\n )\n }\n if (isVerifiedMemberEmailAlias(entry)) {\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: true }\n }\n const next = aliases.map((alias) =>\n alias.address === entry.address ? { ...alias, verifiedAtMs: nowMs } : alias,\n )\n tx.set(ref, { uid: claims.uid, aliases: next, updatedAtMs: nowMs })\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: false }\n })\n}\n\nexport type MemberEmailAliasesConfirmedByProvider =\n | {\n ok: true\n /** The addresses this call confirmed, normalized, in list order. */\n confirmed: string[]\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms a member's PENDING addresses that their connected mail account\n * already proves are theirs (AGL-2978).\n *\n * The confirmation link proves an address receives mail and asks the member\n * to open it. A provider's verified send-as list proves more: the provider\n * confirmed the account may send as the address, and the member proved they\n * hold that account by connecting it under their own session. So an address\n * the member added and has not yet confirmed counts as confirmed once it\n * appears in that list.\n *\n * Only addresses the member ALREADY added are touched — this never adds one,\n * because an address the member did not ask for is not a claim they made —\n * and an address already confirmed keeps its original `verifiedAtMs`. The\n * caller passes only the provider's VERIFIED addresses; which of a\n * provider's listings count as verified is the caller's rule, not this\n * store's. Nothing is written when nothing changes.\n */\nexport async function confirmMemberEmailAliasesByProvider(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; addresses: readonly unknown[]; nowMs?: number },\n): Promise<MemberEmailAliasesConfirmedByProvider> {\n const nowMs = input.nowMs ?? Date.now()\n const proven = new Set(\n input.addresses\n .map((address) => normalizeMemberEmailAlias(address))\n .filter((address): address is string => Boolean(address)),\n )\n if (!input.orgId || !input.uid) return refused('not-a-member', NOT_A_MEMBER)\n if (!proven.size) return { ok: true, confirmed: [] }\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasesConfirmedByProvider>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const confirmed: string[] = []\n const next = aliases.map((alias) => {\n if (isVerifiedMemberEmailAlias(alias) || !proven.has(alias.address)) return alias\n confirmed.push(alias.address)\n return { ...alias, verifiedAtMs: nowMs }\n })\n if (confirmed.length) {\n tx.set(ref, { uid: input.uid, aliases: next, updatedAtMs: nowMs })\n }\n return { ok: true, confirmed }\n })\n}\n\n/**\n * A console path a link may land on: absolute, on the console's own\n * origin, with no query or fragment of its own. Anything else lands on\n * `/`, which still signs the member in.\n */\nconst RETURN_PATH = /^\\/(?![/\\\\])[^\\s?#\\\\]{0,511}$/\n\n/**\n * The link a confirmation email carries: the console origin the caller\n * resolved from server configuration — never from a request header — the\n * page the member added the address on, and the token.\n */\nexport function memberEmailAliasConfirmUrl(input: {\n origin: string\n returnPath?: unknown\n token: string\n}): string {\n const origin = String(input.origin ?? '').replace(/\\/+$/, '')\n const raw = typeof input.returnPath === 'string' ? input.returnPath : ''\n const path = RETURN_PATH.test(raw) ? raw : '/'\n return `${origin}${path}?${MEMBER_EMAIL_ALIAS_CONFIRM_PARAM}=${encodeURIComponent(input.token)}`\n}\n\nexport type MemberEmailAliasConfirmationSent =\n | { sent: true }\n | { sent: false; status: 429 | 501 | 502; error: string }\n\n/**\n * Emails the confirmation link to the address, in the workspace's brand.\n *\n * Rate-limited per member and per destination address before anything is\n * sent (see the two ceilings above), and metered to the workspace, whose\n * member asked for it.\n */\nexport async function sendMemberEmailAliasConfirmation(input: {\n orgId: string\n /** The org document, for its name and its brand. */\n org: Record<string, unknown> | null\n uid: string\n /** The member's display name, when the roster row carries one. */\n memberName?: string | null\n address: string\n confirmUrl: string\n}): Promise<MemberEmailAliasConfirmationSent> {\n if (!isEmailConfigured()) {\n return { sent: false, status: 501, error: 'Email is not configured on this deployment.' }\n }\n const { consumeRateLimit } = await import('./rate-limit-store')\n const perMember = await consumeRateLimit(`member-email-alias-confirm:${input.uid}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perMember.allowed) {\n return { sent: false, status: 429, error: 'Too many confirmation emails. Try again in an hour.' }\n }\n const perAddress = await consumeRateLimit(`member-email-alias-target:${input.address}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perAddress.allowed) {\n return {\n sent: false,\n status: 429,\n error: 'Too many confirmation emails for that address. Try again later.',\n }\n }\n\n const branding = resolveBrandingProfile((input.org ?? null) as never)\n const workspace = String(input.org?.['name'] ?? '').trim() || 'your workspace'\n const member = String(input.memberName ?? '').trim()\n const text = [\n `${member || 'A member'} asked to add ${input.address} as one of their own email ` +\n `addresses in the ${workspace} workspace on ${branding.productName}.`,\n '',\n `To confirm it, open this link while signed in as ${member || 'that member'}:`,\n '',\n input.confirmUrl,\n '',\n `Once it is confirmed, email sent from ${input.address} with the workspace's capture ` +\n 'address in BCC is filed under the person it was written to. The link works for 24 hours.',\n '',\n 'If you did not expect this email, ignore it. Nothing changes unless the member who asked opens the link.',\n ].join('\\n')\n // The `member-email-alias-confirmation` system email (AGL-3367), in the\n // workspace's brand; `text` above is its last resort.\n const content = await renderSystemEmailContent(\n 'member-email-alias-confirmation',\n {\n 'member.name': member || 'A member',\n address: input.address,\n 'org.name': workspace,\n confirmUrl: input.confirmUrl,\n },\n systemEmailBrand((input.org ?? null) as Record<string, unknown> | null),\n { subject: `Confirm your address for ${workspace}`, text },\n )\n const result = await sendEmail({\n to: input.address,\n ...content,\n fromName: branding.fromName,\n context: 'member-email-alias-confirmation',\n })\n if (!result.sent) {\n return { sent: false, status: 502, error: 'The confirmation email could not be sent.' }\n }\n const { meterOrgEmail } = await import('./email-metering')\n await meterOrgEmail(input.orgId).catch(() => undefined)\n return { sent: true }\n}\n"],"names":["evaluateMemberEmailAliasAdd","isVerifiedMemberEmailAlias","MEMBER_EMAIL_ALIAS_CONFIRM_PARAM","MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS","MEMBER_EMAIL_ALIASES_COLLECTION","memberAddressRoster","normalizeMemberEmailAlias","readMemberEmailAliases","resolveBrandingProfile","isEmailConfigured","sendEmail","createHmac","tokenSigningSecret","renderSystemEmailContent","systemEmailBrand","safeEqual","TOKEN_VERSION","TOKEN_CONTEXT","TOKEN_MAX_CHARS","MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR","MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR","refused","refusal","message","ok","NOT_A_MEMBER","aliasesRef","firestore","orgId","uid","collection","doc","memberRef","loadMemberEmailAliases","snapshot","get","exists","data","loadMemberAddressRoster","orgRef","members","aliases","Promise","all","rows","docs","map","$id","id","aliasDocuments","Map","String","addMemberEmailAlias","input","nowMs","Date","now","ref","roster","runTransaction","tx","member","stored","verdict","address","signInEmail","reservedDomains","existing","alias","created","addedAtMs","set","updatedAtMs","removeMemberEmailAlias","removed","find","remaining","filter","length","delete","signature","payload","update","digest","mintMemberEmailAliasToken","claims","wire","o","u","a","t","e","Buffer","from","JSON","stringify","toString","readMemberEmailAliasToken","token","invalid","parts","split","signed","expected","parse","Number","exp","isFinite","confirmMemberEmailAlias","read","callerUid","entry","alreadyConfirmed","next","verifiedAtMs","confirmMemberEmailAliasesByProvider","proven","Set","addresses","Boolean","size","confirmed","has","push","RETURN_PATH","memberEmailAliasConfirmUrl","origin","replace","raw","returnPath","path","test","encodeURIComponent","sendMemberEmailAliasConfirmation","sent","status","error","consumeRateLimit","perMember","limit","windowMs","allowed","perAddress","branding","org","workspace","trim","memberName","text","productName","confirmUrl","join","content","subject","result","to","fromName","context","meterOrgEmail","catch","undefined"],"mappings":"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"}
|