@aglyn/tenant-data-admin 1.0.0-beta.199 → 1.0.0-beta.201
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 -7
- package/src/lib/server/admin-audit-write.d.ts +28 -0
- package/src/lib/server/admin-audit-write.js +46 -0
- package/src/lib/server/admin-audit-write.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +4 -9
- package/src/lib/server/admin-audit.js +12 -31
- package/src/lib/server/admin-audit.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +2 -0
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/contact-company-link.js +3 -0
- package/src/lib/server/contact-company-link.js.map +1 -1
- package/src/lib/server/contact-lifecycle-floor.js +3 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -1
- package/src/lib/server/contact-merge.js +4 -0
- package/src/lib/server/contact-merge.js.map +1 -1
- package/src/lib/server/crm-booking-activity.js +3 -2
- package/src/lib/server/crm-booking-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +15 -0
- package/src/lib/server/crm-records.js +52 -0
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/csp-aggregate.d.ts +16 -0
- package/src/lib/server/csp-aggregate.js +36 -0
- package/src/lib/server/csp-aggregate.js.map +1 -1
- package/src/lib/server/dns-probe.d.ts +7 -0
- package/src/lib/server/dns-probe.js +52 -0
- package/src/lib/server/dns-probe.js.map +1 -1
- package/src/lib/server/duplicate-resource.js +12 -5
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +150 -0
- package/src/lib/server/email-deliverability.js +538 -0
- package/src/lib/server/email-deliverability.js.map +1 -0
- package/src/lib/server/email-delivery-log.d.ts +33 -0
- package/src/lib/server/email-delivery-log.js +43 -2
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-sender-reputation.d.ts +2 -11
- package/src/lib/server/email-sender-reputation.js +8 -25
- package/src/lib/server/email-sender-reputation.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +21 -15
- package/src/lib/server/email-suppression.js +18 -34
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/erase-person.js +53 -6
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/erase.js +4 -3
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-memberships.d.ts +34 -0
- package/src/lib/server/host-memberships.js +89 -1
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +16 -2
- package/src/lib/server/host-visitor-records.js +52 -7
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +92 -0
- package/src/lib/server/hosted-page-review.js +342 -0
- package/src/lib/server/hosted-page-review.js.map +1 -0
- package/src/lib/server/list-members.d.ts +11 -0
- package/src/lib/server/list-members.js +28 -1
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/list-query.d.ts +36 -0
- package/src/lib/server/list-query.js +49 -0
- package/src/lib/server/list-query.js.map +1 -0
- package/src/lib/server/media-tombstone.js +8 -2
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/org-age.d.ts +27 -0
- package/src/lib/server/org-age.js +44 -0
- package/src/lib/server/org-age.js.map +1 -0
- package/src/lib/server/org-member-list-fields.d.ts +31 -0
- package/src/lib/server/org-member-list-fields.js +38 -0
- package/src/lib/server/org-member-list-fields.js.map +1 -0
- package/src/lib/server/organizations.js +37 -7
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +222 -0
- package/src/lib/server/outbound-send-review.js +479 -0
- package/src/lib/server/outbound-send-review.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +156 -0
- package/src/lib/server/payment-fraud-signal.js +368 -0
- package/src/lib/server/payment-fraud-signal.js.map +1 -0
- package/src/lib/server/payment-risk-record.d.ts +59 -0
- package/src/lib/server/payment-risk-record.js +76 -0
- package/src/lib/server/payment-risk-record.js.map +1 -0
- package/src/lib/server/replay-erasures.js +2 -1
- package/src/lib/server/replay-erasures.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +6 -0
- package/src/lib/server/sending-domains.js +98 -6
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +2 -1
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/stripe-card-authentication.d.ts +35 -0
- package/src/lib/server/stripe-card-authentication.js +63 -0
- package/src/lib/server/stripe-card-authentication.js.map +1 -0
- package/src/lib/server/tenant-write-lockdown.d.ts +32 -2
- package/src/lib/server/tenant-write-lockdown.js +33 -3
- package/src/lib/server/tenant-write-lockdown.js.map +1 -1
- package/src/lib/server/upsert-contact.js +8 -4
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +3 -0
- package/src/tenancy.js +8 -0
- package/src/tenancy.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/csp-aggregate.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 * Durable CSP-violation aggregates (AGL-1799).\n *\n * The collectors were delivering reports into a sink that forgets: the Vercel\n * runtime log retains ~60 minutes and there is no log drain, so AGL-1702's\n * \"a week of signed-in traffic\" and AGL-1726's \"a business week of visitor\n * traffic\" were gated on evidence that structurally could not exist. This\n * module is the durable half — the log lines stay exactly as they were (they\n * are still the right tool for the last hour), and every accepted violation\n * ALSO compounds into a Firestore counter that holds for the retention window.\n *\n * ## Shape: counters, never report bodies\n *\n * One document per (UTC day x app x directive x disposition x blocked origin),\n * with `FieldValue.increment` — NOT one document per report. AGL-523 made a\n * deliberate choice not to write report bodies to Firestore from an\n * unauthenticated route, and AGL-1799 argued the distinction explicitly: \"a\n * counter with a bounded key space is a different proposition from storing\n * report bodies\". The bound is what this module is careful about, because\n * every key ingredient except the day is attacker-influenced:\n *\n * - the DIRECTIVE is normalized into a closed set (a real browser can only\n * ever send a spec directive; anything else lands in `other` rather than\n * minting a document per invented directive);\n * - the ORIGIN is reduced to the host of `blocked-uri` (or its bare keyword\n * — `inline`, `eval`, `data`), sanitized to a fixed character set, and\n * capped: at most {@link MAX_DISTINCT_ORIGINS_PER_DAY} distinct origins per\n * (day x app x directive) may mint documents per instance per day. Beyond\n * that, new origins are dropped while known ones keep counting — a flood\n * of unique URLs (the AGL-1769 lesson: never let request content mint\n * unbounded documents) saturates the cap instead of the collection;\n * - WRITES are budgeted per instance per minute, so a POST flood cannot turn\n * an unauthenticated beacon into a Firestore write bill even against\n * already-minted documents.\n *\n * Every cap here is per instance, like the tenant collector's `keyStore`: the\n * fleet-wide worst case is (instances x cap), which is bounded and small,\n * and an instance recycle merely re-counts an origin against a fresh cap —\n * the merge write is idempotent about which document it lands in.\n *\n * ## Ids are attacker-derived, so they go through `isDocumentId`\n *\n * Key parts are sanitized to `[a-z0-9.:_-]` and joined with `|`, then the\n * whole id is checked with {@link isDocumentId} (the AGL-1771 predicate —\n * imported from its leaf, `./document-id`, which imports nothing). Belt and\n * braces: the sanitizer already removes `/` and the joiner makes a reserved\n * `__x__` form impossible, but the predicate is the contract and it is\n * cheaper to check than to argue.\n *\n * ## Retention is TTL, not a sweep\n *\n * `expiresAt` (a Timestamp — TTL refuses numbers) is set\n * {@link CSP_AGGREGATE_RETENTION_DAYS} days past the day bucket. The TTL\n * policy is a gcloud action plus a `fieldOverrides` entry in\n * `cloud/firebase-firestore.indexes.json` — see\n * docs/FIRESTORE_MANUAL_CONFIG.md (AGL-1793): the entry ships with this\n * module and the gcloud enable is owed at deploy time.\n *\n * ## Fail-soft, like everything on this path\n *\n * A collector must never turn an observed problem into a served one. Every\n * failure here — no credential, Firestore down, a hostile id that survives\n * sanitization — drops the aggregate (one `console.error` line, tagged) and\n * returns. The log lines the collectors already emit are unaffected.\n *\n * No client ever reads or writes this collection: there is no rules `match`\n * for it, and Firestore denies what nothing matches, so the Admin SDK is the\n * only path in. The staff read-back is `/api/admin/csp-reports` on the\n * console.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CspViolation } from '@aglyn/aglyn/app-utils/csp-report'\nimport { isDocumentId } from './document-id'\n\n/** Top-level collection of counter documents. Server-only; no rules match. */\nexport const CSP_AGGREGATE_COLLECTION = 'cspViolationDaily'\n\n/**\n * Long enough that \"a week of traffic\" (AGL-1702 condition 1, AGL-1726\n * condition 3) is readable with room to be read late, short enough that the\n * collection stays a working set rather than an archive.\n */\nexport const CSP_AGGREGATE_RETENTION_DAYS = 60\n\n/**\n * Distinct blocked origins that may mint documents per (day x app x\n * directive), per instance. A legitimate policy violates against a handful of\n * hosts; thirty is generous headroom for a real regression while keeping a\n * flood of attacker-invented origins from minting unbounded documents.\n */\nexport const MAX_DISTINCT_ORIGINS_PER_DAY = 30\n\n/**\n * Aggregate writes allowed per instance per minute. Distinct-origin capping\n * bounds document COUNT; this bounds write OPS, which are billed even when\n * they land on existing documents. Sized far above what real browsers on one\n * instance produce and far below what makes a flood expensive.\n */\nconst WRITE_BUDGET_PER_MINUTE = 120\n\n/**\n * The directives a browser can actually report, per the CSP3 registry. An\n * unrecognized directive is a non-browser caller (or a future spec revision)\n * and shares one bucket rather than minting its own key space.\n */\nconst KNOWN_DIRECTIVES = new Set([\n 'base-uri',\n 'block-all-mixed-content',\n 'child-src',\n 'connect-src',\n 'default-src',\n 'font-src',\n 'form-action',\n 'frame-ancestors',\n 'frame-src',\n 'img-src',\n 'manifest-src',\n 'media-src',\n 'object-src',\n 'prefetch-src',\n 'script-src',\n 'script-src-attr',\n 'script-src-elem',\n 'style-src',\n 'style-src-attr',\n 'style-src-elem',\n 'upgrade-insecure-requests',\n 'worker-src',\n])\n\n/**\n * Per-instance mint tracker: (day|app|directive) to the origin|disposition\n * pairs that have been allowed to mint documents. Cleared wholesale at the\n * cap, like the tenant collector's `keyStore`: the failure mode of clearing\n * is a brief widening of the mint cap, strictly better than an unbounded map\n * fed by attacker-chosen keys.\n */\nconst mintTracker = new Map<string, Set<string>>()\nconst MAX_TRACKED_GROUPS = 512\n\n/** Per-instance write budget window. */\nlet budgetWindowStartMs = 0\nlet budgetUsed = 0\n\n/** Test seam: module-scope state would otherwise leak across cases. */\nexport function resetCspAggregateStateForTests(): void {\n mintTracker.clear()\n budgetWindowStartMs = 0\n budgetUsed = 0\n}\n\n/** One id/field component: lowercase, fixed charset, never empty. */\nfunction sanitizePart(value: string, max: number): string {\n const cleaned = value\n .toLowerCase()\n .replace(/[^a-z0-9.:_-]+/g, '-')\n .slice(0, max)\n return cleaned || 'unknown'\n}\n\n/**\n * `blocked-uri` reduced to the coarsest thing worth counting: the HOST for a\n * URL, the bare keyword (`inline`, `eval`, `data`, ...) otherwise. Paths are\n * deliberately dropped — twelve chunk URLs off one CDN are one origin-level\n * fact (the AGL-1779 shape), and a path would both explode the key space and\n * hand the id to whoever controls the URL.\n */\nexport function cspBlockedOrigin(blockedUri: string): string {\n if (!blockedUri) return 'none'\n try {\n return sanitizePart(new URL(blockedUri).host, 120)\n } catch {\n return sanitizePart(blockedUri, 40)\n }\n}\n\n/** UTC day bucket, `YYYY-MM-DD`. */\nexport function cspAggregateDay(nowMs: number): string {\n return new Date(nowMs).toISOString().slice(0, 10)\n}\n\nfunction normalizeDirective(directive: string): string {\n return KNOWN_DIRECTIVES.has(directive) ? directive : 'other'\n}\n\nexport interface RecordCspViolationsOptions {\n /** Which collector this came through — part of the counter key. */\n app: 'console' | 'tenant'\n /**\n * The reporting site (tenant only), REQUEST-derived and already clamped by\n * the caller. Stored as a sample field, never in the id: distinct sites\n * must not multiply the key space.\n */\n site?: string\n now?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: any\n}\n\n/**\n * Compound `violations` into the daily counters. Never throws; returns how\n * many documents were written (0 on any failure, budget exhaustion included).\n */\nexport async function recordCspViolations(\n violations: readonly CspViolation[],\n options: RecordCspViolationsOptions,\n): Promise<number> {\n try {\n if (!violations.length) return 0\n const now = options.now ?? Date.now()\n const day = cspAggregateDay(now)\n const dayStartMs = Date.parse(`${day}T00:00:00.000Z`)\n\n // Group first: N occurrences of one key in a batch are ONE write carrying\n // increment(N), so a page that repeats itself costs one op, not N.\n const grouped = new Map<\n string,\n { n: number; directive: string; origin: string; violation: CspViolation }\n >()\n for (const violation of violations) {\n const directive = normalizeDirective(violation.effectiveDirective)\n const origin = cspBlockedOrigin(violation.blockedUri)\n const disposition = sanitizePart(violation.disposition || 'report', 16)\n const id = [\n day,\n options.app,\n sanitizePart(directive, 64),\n disposition,\n origin,\n ].join('|')\n // Belt and braces — the AGL-1771 contract for anything handed to\n // `.doc()`. The sanitizer above should make this unreachable.\n if (!isDocumentId(id)) continue\n\n const existing = grouped.get(id)\n if (existing) {\n existing.n += 1\n continue\n }\n\n // Distinct-origin cap, per (day x app x directive). Known pairs keep\n // counting; new pairs beyond the cap are dropped — bounded documents\n // is the property, not perfect counts.\n const groupKey = `${day}|${options.app}|${directive}`\n if (mintTracker.size > MAX_TRACKED_GROUPS) mintTracker.clear()\n let minted = mintTracker.get(groupKey)\n if (!minted) {\n minted = new Set()\n mintTracker.set(groupKey, minted)\n }\n const pair = `${disposition}|${origin}`\n if (!minted.has(pair)) {\n if (minted.size >= MAX_DISTINCT_ORIGINS_PER_DAY) continue\n minted.add(pair)\n }\n\n grouped.set(id, { n: 1, directive, origin, violation })\n }\n if (!grouped.size) return 0\n\n // Write budget: fixed one-minute window per instance.\n if (now - budgetWindowStartMs >= 60_000) {\n budgetWindowStartMs = now\n budgetUsed = 0\n }\n const room = WRITE_BUDGET_PER_MINUTE - budgetUsed\n if (room <= 0) return 0\n const entries = [...grouped.entries()].slice(0, room)\n budgetUsed += entries.length\n\n const firestore =\n options.firestore ??\n // Lazily resolved so this module stays inert at load: route specs and\n // this module's own spec inject a fake, and nothing initializes\n // firebase-admin just by importing the barrel.\n (await import('./firebase-admin')).firebaseAdmin.app().firestore()\n\n await Promise.all(\n entries.map(([id, { n, directive, origin, violation }]) =>\n firestore\n .collection(CSP_AGGREGATE_COLLECTION)\n .doc(id)\n .set(\n {\n day,\n app: options.app,\n directive,\n origin,\n disposition: sanitizePart(violation.disposition || 'report', 16),\n count: FieldValue.increment(n),\n lastSeenMs: now,\n // Samples, not keys: enough to find the surface, no power to\n // mint documents. Already clamped by the parser; clamped again\n // because this module's contract should not depend on that.\n lastPath: violation.documentPath.slice(0, 200),\n ...(options.site ? { lastSite: options.site.slice(0, 253) } : {}),\n // Timestamp-typed via Date — Firestore TTL refuses numbers.\n expiresAt: new Date(\n dayStartMs + CSP_AGGREGATE_RETENTION_DAYS * 86_400_000,\n ),\n },\n { merge: true },\n ),\n ),\n )\n return entries.length\n } catch (error) {\n // Fail-soft: the aggregate is an observer, and `console.error` rather\n // than `console.warn` on purpose — the collectors' own log lines are\n // warns, and their specs count warns.\n console.error(\n JSON.stringify({\n tag: 'AGL-1799:csp-aggregate',\n error: String(error).slice(0, 200),\n }),\n )\n return 0\n }\n}\n"],"names":["FieldValue","isDocumentId","CSP_AGGREGATE_COLLECTION","CSP_AGGREGATE_RETENTION_DAYS","MAX_DISTINCT_ORIGINS_PER_DAY","WRITE_BUDGET_PER_MINUTE","KNOWN_DIRECTIVES","Set","mintTracker","Map","MAX_TRACKED_GROUPS","budgetWindowStartMs","budgetUsed","resetCspAggregateStateForTests","clear","sanitizePart","value","max","cleaned","toLowerCase","replace","slice","cspBlockedOrigin","blockedUri","URL","host","cspAggregateDay","nowMs","Date","toISOString","normalizeDirective","directive","has","recordCspViolations","violations","options","length","now","day","dayStartMs","parse","grouped","violation","effectiveDirective","origin","disposition","id","app","join","existing","get","n","groupKey","size","minted","set","pair","add","room","entries","firestore","firebaseAdmin","Promise","all","map","collection","doc","count","increment","lastSeenMs","lastPath","documentPath","site","lastSite","expiresAt","merge","error","console","JSON","stringify","tag","String"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/csp-aggregate.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 * Durable CSP-violation aggregates (AGL-1799).\n *\n * The collectors were delivering reports into a sink that forgets: the Vercel\n * runtime log retains ~60 minutes and there is no log drain, so AGL-1702's\n * \"a week of signed-in traffic\" and AGL-1726's \"a business week of visitor\n * traffic\" were gated on evidence that structurally could not exist. This\n * module is the durable half — the log lines stay exactly as they were (they\n * are still the right tool for the last hour), and every accepted violation\n * ALSO compounds into a Firestore counter that holds for the retention window.\n *\n * ## Shape: counters, never report bodies\n *\n * One document per (UTC day x app x directive x disposition x blocked origin),\n * with `FieldValue.increment` — NOT one document per report. AGL-523 made a\n * deliberate choice not to write report bodies to Firestore from an\n * unauthenticated route, and AGL-1799 argued the distinction explicitly: \"a\n * counter with a bounded key space is a different proposition from storing\n * report bodies\". The bound is what this module is careful about, because\n * every key ingredient except the day is attacker-influenced:\n *\n * - the DIRECTIVE is normalized into a closed set (a real browser can only\n * ever send a spec directive; anything else lands in `other` rather than\n * minting a document per invented directive);\n * - the ORIGIN is reduced to the host of `blocked-uri` (or its bare keyword\n * — `inline`, `eval`, `data`), sanitized to a fixed character set, and\n * capped: at most {@link MAX_DISTINCT_ORIGINS_PER_DAY} distinct origins per\n * (day x app x directive) may mint documents per instance per day. Beyond\n * that, new origins are dropped while known ones keep counting — a flood\n * of unique URLs (the AGL-1769 lesson: never let request content mint\n * unbounded documents) saturates the cap instead of the collection;\n * - WRITES are budgeted per instance per minute, so a POST flood cannot turn\n * an unauthenticated beacon into a Firestore write bill even against\n * already-minted documents.\n *\n * Every cap here is per instance, like the tenant collector's `keyStore`: the\n * fleet-wide worst case is (instances x cap), which is bounded and small,\n * and an instance recycle merely re-counts an origin against a fresh cap —\n * the merge write is idempotent about which document it lands in.\n *\n * ## Ids are attacker-derived, so they go through `isDocumentId`\n *\n * Key parts are sanitized to `[a-z0-9.:_-]` and joined with `|`, then the\n * whole id is checked with {@link isDocumentId} (the AGL-1771 predicate —\n * imported from its leaf, `./document-id`, which imports nothing). Belt and\n * braces: the sanitizer already removes `/` and the joiner makes a reserved\n * `__x__` form impossible, but the predicate is the contract and it is\n * cheaper to check than to argue.\n *\n * ## Retention is TTL, not a sweep\n *\n * `expiresAt` (a Timestamp — TTL refuses numbers) is set\n * {@link CSP_AGGREGATE_RETENTION_DAYS} days past the day bucket. The TTL\n * policy is a gcloud action plus a `fieldOverrides` entry in\n * `cloud/firebase-firestore.indexes.json` — see\n * docs/FIRESTORE_MANUAL_CONFIG.md (AGL-1793): the entry ships with this\n * module and the gcloud enable is owed at deploy time.\n *\n * ## Fail-soft, like everything on this path\n *\n * A collector must never turn an observed problem into a served one. Every\n * failure here — no credential, Firestore down, a hostile id that survives\n * sanitization — drops the aggregate (one `console.error` line, tagged) and\n * returns. The log lines the collectors already emit are unaffected.\n *\n * No client ever reads or writes this collection: there is no rules `match`\n * for it, and Firestore denies what nothing matches, so the Admin SDK is the\n * only path in. The staff read-back is `/api/admin/csp-reports` on the\n * console.\n *\n * ## The search tokens\n *\n * The staff table's search reads `searchTokens` with `array-contains`\n * (AGL-3321), so every write stamps it, through {@link cspSearchTokens}. It\n * is derived from the counter's KEY — origin, directive, app — which is the\n * document id and never changes, so the array is the same on every write to\n * one document and a merge cannot leave it describing something else.\n * `tools/scripts/backfill-csp-search-tokens.mjs` stamps the counters written\n * before it, held to `csp-search-tokens.fixtures.json` beside it.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CspViolation } from '@aglyn/aglyn/app-utils/csp-report'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { isDocumentId } from './document-id'\n\n/** Top-level collection of counter documents. Server-only; no rules match. */\nexport const CSP_AGGREGATE_COLLECTION = 'cspViolationDaily'\n\n/**\n * Long enough that \"a week of traffic\" (AGL-1702 condition 1, AGL-1726\n * condition 3) is readable with room to be read late, short enough that the\n * collection stays a working set rather than an archive.\n */\nexport const CSP_AGGREGATE_RETENTION_DAYS = 60\n\n/**\n * Distinct blocked origins that may mint documents per (day x app x\n * directive), per instance. A legitimate policy violates against a handful of\n * hosts; thirty is generous headroom for a real regression while keeping a\n * flood of attacker-invented origins from minting unbounded documents.\n */\nexport const MAX_DISTINCT_ORIGINS_PER_DAY = 30\n\n/**\n * Aggregate writes allowed per instance per minute. Distinct-origin capping\n * bounds document COUNT; this bounds write OPS, which are billed even when\n * they land on existing documents. Sized far above what real browsers on one\n * instance produce and far below what makes a flood expensive.\n */\nconst WRITE_BUDGET_PER_MINUTE = 120\n\n/**\n * The directives a browser can actually report, per the CSP3 registry. An\n * unrecognized directive is a non-browser caller (or a future spec revision)\n * and shares one bucket rather than minting its own key space.\n */\nconst KNOWN_DIRECTIVES = new Set([\n 'base-uri',\n 'block-all-mixed-content',\n 'child-src',\n 'connect-src',\n 'default-src',\n 'font-src',\n 'form-action',\n 'frame-ancestors',\n 'frame-src',\n 'img-src',\n 'manifest-src',\n 'media-src',\n 'object-src',\n 'prefetch-src',\n 'script-src',\n 'script-src-attr',\n 'script-src-elem',\n 'style-src',\n 'style-src-attr',\n 'style-src-elem',\n 'upgrade-insecure-requests',\n 'worker-src',\n])\n\n/**\n * Per-instance mint tracker: (day|app|directive) to the origin|disposition\n * pairs that have been allowed to mint documents. Cleared wholesale at the\n * cap, like the tenant collector's `keyStore`: the failure mode of clearing\n * is a brief widening of the mint cap, strictly better than an unbounded map\n * fed by attacker-chosen keys.\n */\nconst mintTracker = new Map<string, Set<string>>()\nconst MAX_TRACKED_GROUPS = 512\n\n/** Per-instance write budget window. */\nlet budgetWindowStartMs = 0\nlet budgetUsed = 0\n\n/** Test seam: module-scope state would otherwise leak across cases. */\nexport function resetCspAggregateStateForTests(): void {\n mintTracker.clear()\n budgetWindowStartMs = 0\n budgetUsed = 0\n}\n\n/** One id/field component: lowercase, fixed charset, never empty. */\nfunction sanitizePart(value: string, max: number): string {\n const cleaned = value\n .toLowerCase()\n .replace(/[^a-z0-9.:_-]+/g, '-')\n .slice(0, max)\n return cleaned || 'unknown'\n}\n\n/**\n * `blocked-uri` reduced to the coarsest thing worth counting: the HOST for a\n * URL, the bare keyword (`inline`, `eval`, `data`, ...) otherwise. Paths are\n * deliberately dropped — twelve chunk URLs off one CDN are one origin-level\n * fact (the AGL-1779 shape), and a path would both explode the key space and\n * hand the id to whoever controls the URL.\n */\nexport function cspBlockedOrigin(blockedUri: string): string {\n if (!blockedUri) return 'none'\n try {\n return sanitizePart(new URL(blockedUri).host, 120)\n } catch {\n return sanitizePart(blockedUri, 40)\n }\n}\n\n/** The written field the staff CSP table's search reads. */\nexport const CSP_SEARCH_TOKENS_PATH = 'searchTokens'\n\n/**\n * The search tokens one counter carries: the word prefixes of its blocked\n * origin, its directive and its app — each whole, and each run of letters\n * and digits in it — so `googletagmanager`, `cdn.example.com`, `inline`,\n * `script`, `elem` and `tenant` all find the counters they name. The\n * lower-casing and the prefixes are `nameSearchTokens`, the normalizer the\n * list query asks with. Not the sampled site and path: they change with every\n * report, and a merge would leave tokens for a site the row no longer shows.\n */\nexport function cspSearchTokens(key: {\n origin?: unknown\n directive?: unknown\n app?: unknown\n}): string[] {\n const words = [key.origin, key.directive, key.app]\n .map((value) => (typeof value === 'string' ? value.trim().toLowerCase() : ''))\n .filter(Boolean)\n .flatMap((value) => [value, ...value.split(/[^a-z0-9]+/)])\n .filter(Boolean)\n return nameSearchTokens(words.join(' '))\n}\n\n/** UTC day bucket, `YYYY-MM-DD`. */\nexport function cspAggregateDay(nowMs: number): string {\n return new Date(nowMs).toISOString().slice(0, 10)\n}\n\nfunction normalizeDirective(directive: string): string {\n return KNOWN_DIRECTIVES.has(directive) ? directive : 'other'\n}\n\nexport interface RecordCspViolationsOptions {\n /** Which collector this came through — part of the counter key. */\n app: 'console' | 'tenant'\n /**\n * The reporting site (tenant only), REQUEST-derived and already clamped by\n * the caller. Stored as a sample field, never in the id: distinct sites\n * must not multiply the key space.\n */\n site?: string\n now?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: any\n}\n\n/**\n * Compound `violations` into the daily counters. Never throws; returns how\n * many documents were written (0 on any failure, budget exhaustion included).\n */\nexport async function recordCspViolations(\n violations: readonly CspViolation[],\n options: RecordCspViolationsOptions,\n): Promise<number> {\n try {\n if (!violations.length) return 0\n const now = options.now ?? Date.now()\n const day = cspAggregateDay(now)\n const dayStartMs = Date.parse(`${day}T00:00:00.000Z`)\n\n // Group first: N occurrences of one key in a batch are ONE write carrying\n // increment(N), so a page that repeats itself costs one op, not N.\n const grouped = new Map<\n string,\n { n: number; directive: string; origin: string; violation: CspViolation }\n >()\n for (const violation of violations) {\n const directive = normalizeDirective(violation.effectiveDirective)\n const origin = cspBlockedOrigin(violation.blockedUri)\n const disposition = sanitizePart(violation.disposition || 'report', 16)\n const id = [\n day,\n options.app,\n sanitizePart(directive, 64),\n disposition,\n origin,\n ].join('|')\n // Belt and braces — the AGL-1771 contract for anything handed to\n // `.doc()`. The sanitizer above should make this unreachable.\n if (!isDocumentId(id)) continue\n\n const existing = grouped.get(id)\n if (existing) {\n existing.n += 1\n continue\n }\n\n // Distinct-origin cap, per (day x app x directive). Known pairs keep\n // counting; new pairs beyond the cap are dropped — bounded documents\n // is the property, not perfect counts.\n const groupKey = `${day}|${options.app}|${directive}`\n if (mintTracker.size > MAX_TRACKED_GROUPS) mintTracker.clear()\n let minted = mintTracker.get(groupKey)\n if (!minted) {\n minted = new Set()\n mintTracker.set(groupKey, minted)\n }\n const pair = `${disposition}|${origin}`\n if (!minted.has(pair)) {\n if (minted.size >= MAX_DISTINCT_ORIGINS_PER_DAY) continue\n minted.add(pair)\n }\n\n grouped.set(id, { n: 1, directive, origin, violation })\n }\n if (!grouped.size) return 0\n\n // Write budget: fixed one-minute window per instance.\n if (now - budgetWindowStartMs >= 60_000) {\n budgetWindowStartMs = now\n budgetUsed = 0\n }\n const room = WRITE_BUDGET_PER_MINUTE - budgetUsed\n if (room <= 0) return 0\n const entries = [...grouped.entries()].slice(0, room)\n budgetUsed += entries.length\n\n const firestore =\n options.firestore ??\n // Lazily resolved so this module stays inert at load: route specs and\n // this module's own spec inject a fake, and nothing initializes\n // firebase-admin just by importing the barrel.\n (await import('./firebase-admin')).firebaseAdmin.app().firestore()\n\n await Promise.all(\n entries.map(([id, { n, directive, origin, violation }]) =>\n firestore\n .collection(CSP_AGGREGATE_COLLECTION)\n .doc(id)\n .set(\n {\n day,\n app: options.app,\n directive,\n origin,\n disposition: sanitizePart(violation.disposition || 'report', 16),\n count: FieldValue.increment(n),\n [CSP_SEARCH_TOKENS_PATH]: cspSearchTokens({\n origin,\n directive,\n app: options.app,\n }),\n lastSeenMs: now,\n // Samples, not keys: enough to find the surface, no power to\n // mint documents. Already clamped by the parser; clamped again\n // because this module's contract should not depend on that.\n lastPath: violation.documentPath.slice(0, 200),\n ...(options.site ? { lastSite: options.site.slice(0, 253) } : {}),\n // Timestamp-typed via Date — Firestore TTL refuses numbers.\n expiresAt: new Date(\n dayStartMs + CSP_AGGREGATE_RETENTION_DAYS * 86_400_000,\n ),\n },\n { merge: true },\n ),\n ),\n )\n return entries.length\n } catch (error) {\n // Fail-soft: the aggregate is an observer, and `console.error` rather\n // than `console.warn` on purpose — the collectors' own log lines are\n // warns, and their specs count warns.\n console.error(\n JSON.stringify({\n tag: 'AGL-1799:csp-aggregate',\n error: String(error).slice(0, 200),\n }),\n )\n return 0\n }\n}\n"],"names":["FieldValue","nameSearchTokens","isDocumentId","CSP_AGGREGATE_COLLECTION","CSP_AGGREGATE_RETENTION_DAYS","MAX_DISTINCT_ORIGINS_PER_DAY","WRITE_BUDGET_PER_MINUTE","KNOWN_DIRECTIVES","Set","mintTracker","Map","MAX_TRACKED_GROUPS","budgetWindowStartMs","budgetUsed","resetCspAggregateStateForTests","clear","sanitizePart","value","max","cleaned","toLowerCase","replace","slice","cspBlockedOrigin","blockedUri","URL","host","CSP_SEARCH_TOKENS_PATH","cspSearchTokens","key","words","origin","directive","app","map","trim","filter","Boolean","flatMap","split","join","cspAggregateDay","nowMs","Date","toISOString","normalizeDirective","has","recordCspViolations","violations","options","length","now","day","dayStartMs","parse","grouped","violation","effectiveDirective","disposition","id","existing","get","n","groupKey","size","minted","set","pair","add","room","entries","firestore","firebaseAdmin","Promise","all","collection","doc","count","increment","lastSeenMs","lastPath","documentPath","site","lastSite","expiresAt","merge","error","console","JSON","stringify","tag","String"],"mappings":"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"}
|
|
@@ -51,3 +51,10 @@ export interface MxRecord {
|
|
|
51
51
|
}
|
|
52
52
|
/** MX records at `host`, exchanges lowercased and stripped of a trailing dot. */
|
|
53
53
|
export declare function lookupMx(host: string): Promise<DnsLookupResult<MxRecord>>;
|
|
54
|
+
/**
|
|
55
|
+
* Whether `host` has an address record, A or AAAA — the implicit MX a domain
|
|
56
|
+
* with no MX is still delivered to (RFC 5321 §5.1). `records` holds the
|
|
57
|
+
* addresses found; empty on a conclusive "none". Unanswered only when
|
|
58
|
+
* neither family could be asked.
|
|
59
|
+
*/
|
|
60
|
+
export declare function lookupAddress(host: string): Promise<DnsLookupResult<string>>;
|
|
@@ -180,5 +180,57 @@
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Whether `host` has an address record, A or AAAA — the implicit MX a domain
|
|
185
|
+
* with no MX is still delivered to (RFC 5321 §5.1). `records` holds the
|
|
186
|
+
* addresses found; empty on a conclusive "none". Unanswered only when
|
|
187
|
+
* neither family could be asked.
|
|
188
|
+
*/ export async function lookupAddress(host) {
|
|
189
|
+
const ask = async (family)=>{
|
|
190
|
+
try {
|
|
191
|
+
const resolver = new CallbackResolver();
|
|
192
|
+
resolver.setServers(PUBLIC_DNS_RESOLVERS);
|
|
193
|
+
const records = await new Promise((resolve, reject)=>{
|
|
194
|
+
const done = (error, addresses)=>error ? reject(error) : resolve(addresses);
|
|
195
|
+
if (family === 4) resolver.resolve4(host, done);
|
|
196
|
+
else resolver.resolve6(host, done);
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
answered: true,
|
|
200
|
+
records
|
|
201
|
+
};
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const code = error == null ? void 0 : error.code;
|
|
204
|
+
if (isConclusiveDnsCode(code)) return {
|
|
205
|
+
answered: true,
|
|
206
|
+
records: []
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
|
+
return {
|
|
210
|
+
answered: true,
|
|
211
|
+
records: family === 4 ? await dns.resolve4(host) : await dns.resolve6(host)
|
|
212
|
+
};
|
|
213
|
+
} catch (fallbackError) {
|
|
214
|
+
const fallbackCode = fallbackError == null ? void 0 : fallbackError.code;
|
|
215
|
+
if (isConclusiveDnsCode(fallbackCode)) return {
|
|
216
|
+
answered: true,
|
|
217
|
+
records: []
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
answered: false,
|
|
221
|
+
records: []
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const v4 = await ask(4);
|
|
227
|
+
if (v4.records.length) return v4;
|
|
228
|
+
const v6 = await ask(6);
|
|
229
|
+
if (v6.records.length) return v6;
|
|
230
|
+
return {
|
|
231
|
+
answered: v4.answered && v6.answered,
|
|
232
|
+
records: []
|
|
233
|
+
};
|
|
234
|
+
}
|
|
183
235
|
|
|
184
236
|
//# sourceMappingURL=dns-probe.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/dns-probe.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 * One DNS lookup, pinned to public resolvers, that can say \"I do not know\".\n *\n * The same shape appeared independently in `/api/domains/verify` (AGL-734) and\n * in `sso-provisioning.ts` (AGL-1210), and a third copy was about to be\n * written for sending domains. It is here once instead.\n *\n * ## Why the resolvers are pinned\n *\n * The runtime's default resolver is not a neutral observer. A stale zone once\n * made the hosting platform's own resolver return NXDOMAIN for records every\n * public resolver could see, which reads as \"the customer deleted their\n * record\" and is indistinguishable from the real thing. Asking a public\n * resolver first, and falling back to the runtime's only when the pinned ones\n * are unreachable, keeps a genuine \"no such record\" answer conclusive.\n *\n * ## Why there are three outcomes and not two\n *\n * A boolean cannot tell \"the record is gone\" from \"nobody answered\", and the\n * difference decides whether a customer's working configuration gets marked\n * broken during someone else's outage. `NXDOMAIN`/`ENOTFOUND`/`ENODATA` are\n * ANSWERS — that name has no such record — and stay conclusive. Anything else,\n * from both the pinned resolvers and the fallback, is `unreachable`, and a\n * caller must treat it as evidence of nothing in either direction.\n */\n\nimport { promises as dns, Resolver as CallbackResolver } from 'dns'\n\n/** Resolvers asked before the runtime's own. */\nexport const PUBLIC_DNS_RESOLVERS = ['1.1.1.1', '8.8.8.8']\n\n/** Whether a resolver error code is an answer rather than a failure to get one. */\nexport function isConclusiveDnsCode(code: string | undefined): boolean {\n return code === 'ENOTFOUND' || code === 'ENODATA' || code === 'NXDOMAIN'\n}\n\nexport interface DnsLookupResult<T> {\n /**\n * True when the lookup got an answer, including the answer \"there is\n * nothing here\". False only when nobody answered.\n */\n answered: boolean\n /** Empty on a conclusive miss and on an unreachable lookup alike. */\n records: T[]\n}\n\n/**\n * TXT records at `host`, each joined from its chunks and trimmed.\n *\n * A TXT answer arrives as an array of string CHUNKS per record, because DNS\n * splits strings longer than 255 bytes. Comparing without joining means a long\n * value — every DKIM public key, for instance — can never match.\n */\nexport async function lookupTxt(host: string): Promise<DnsLookupResult<string>> {\n const flatten = (records: string[][]) =>\n records.map((chunks) => chunks.join('').trim())\n\n try {\n const resolver = new CallbackResolver()\n resolver.setServers(PUBLIC_DNS_RESOLVERS)\n const records = await new Promise<string[][]>((resolve, reject) => {\n resolver.resolveTxt(host, (error, addresses) =>\n error ? reject(error) : resolve(addresses),\n )\n })\n return { answered: true, records: flatten(records) }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(code)) return { answered: true, records: [] }\n try {\n return { answered: true, records: flatten(await dns.resolveTxt(host)) }\n } catch (fallbackError) {\n const fallbackCode = (fallbackError as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(fallbackCode)) return { answered: true, records: [] }\n return { answered: false, records: [] }\n }\n }\n}\n\n/**\n * CAA records at `host`, as `<flags> <tag> \"<value>\"` strings.\n *\n * NOT walked up the tree. The caller asks about one exact name, because the\n * question this exists for is \"does THIS name publish a policy of its own\",\n * and a walk would answer a different one — a parent's policy is inherited by\n * resolution, not by publication, and reporting the parent's records as the\n * child's would say a name restricts issuance when it publishes nothing.\n */\nexport async function lookupCaa(host: string): Promise<DnsLookupResult<string>> {\n const normalize = (records: unknown[]) =>\n records\n .map((entry) => {\n const row = entry as { critical?: number; issue?: string; issuewild?: string; iodef?: string }\n const tag = row?.issue !== undefined\n ? 'issue'\n : row?.issuewild !== undefined\n ? 'issuewild'\n : row?.iodef !== undefined\n ? 'iodef'\n : ''\n if (!tag) return ''\n const value = String(\n row.issue ?? row.issuewild ?? row.iodef ?? '',\n ).trim()\n return `${Number(row?.critical) || 0} ${tag} \"${value}\"`\n })\n .filter(Boolean)\n\n try {\n const resolver = new CallbackResolver()\n resolver.setServers(PUBLIC_DNS_RESOLVERS)\n const records = await new Promise<unknown[]>((resolve, reject) => {\n resolver.resolveCaa(host, (error, addresses) =>\n error ? reject(error) : resolve(addresses as unknown[]),\n )\n })\n return { answered: true, records: normalize(records) }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(code)) return { answered: true, records: [] }\n try {\n return {\n answered: true,\n records: normalize((await dns.resolveCaa(host)) as unknown[]),\n }\n } catch (fallbackError) {\n const fallbackCode = (fallbackError as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(fallbackCode)) return { answered: true, records: [] }\n return { answered: false, records: [] }\n }\n }\n}\n\nexport interface MxRecord {\n exchange: string\n priority: number\n}\n\n/** MX records at `host`, exchanges lowercased and stripped of a trailing dot. */\nexport async function lookupMx(host: string): Promise<DnsLookupResult<MxRecord>> {\n const normalize = (records: { exchange: string; priority: number }[]) =>\n records.map((entry) => ({\n exchange: String(entry?.exchange ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.$/, ''),\n priority: Number(entry?.priority) || 0,\n }))\n\n try {\n const resolver = new CallbackResolver()\n resolver.setServers(PUBLIC_DNS_RESOLVERS)\n const records = await new Promise<{ exchange: string; priority: number }[]>(\n (resolve, reject) => {\n resolver.resolveMx(host, (error, addresses) =>\n error ? reject(error) : resolve(addresses),\n )\n },\n )\n return { answered: true, records: normalize(records) }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(code)) return { answered: true, records: [] }\n try {\n return { answered: true, records: normalize(await dns.resolveMx(host)) }\n } catch (fallbackError) {\n const fallbackCode = (fallbackError as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(fallbackCode)) return { answered: true, records: [] }\n return { answered: false, records: [] }\n }\n }\n}\n"],"names":["promises","dns","Resolver","CallbackResolver","PUBLIC_DNS_RESOLVERS","isConclusiveDnsCode","code","lookupTxt","host","flatten","records","map","chunks","join","trim","resolver","setServers","Promise","resolve","reject","resolveTxt","error","addresses","answered","fallbackError","fallbackCode","lookupCaa","normalize","entry","row","tag","issue","undefined","issuewild","iodef","value","String","Number","critical","filter","Boolean","resolveCaa","lookupMx","exchange","toLowerCase","replace","priority","resolveMx"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GAED,SAASA,YAAYC,GAAG,EAAEC,YAAYC,gBAAgB,QAAQ,MAAK;AAEnE,8CAA8C,GAC9C,OAAO,MAAMC,uBAAuB;IAAC;IAAW;CAAU,CAAA;AAE1D,iFAAiF,GACjF,OAAO,SAASC,oBAAoBC,IAAwB;IAC1D,OAAOA,SAAS,eAAeA,SAAS,aAAaA,SAAS;AAChE;AAYA;;;;;;CAMC,GACD,OAAO,eAAeC,UAAUC,IAAY;IAC1C,MAAMC,UAAU,CAACC,UACfA,QAAQC,GAAG,CAAC,CAACC,SAAWA,OAAOC,IAAI,CAAC,IAAIC,IAAI;IAE9C,IAAI;QACF,MAAMC,WAAW,IAAIZ;QACrBY,SAASC,UAAU,CAACZ;QACpB,MAAMM,UAAU,MAAM,IAAIO,QAAoB,CAACC,SAASC;YACtDJ,SAASK,UAAU,CAACZ,MAAM,CAACa,OAAOC,YAChCD,QAAQF,OAAOE,SAASH,QAAQI;QAEpC;QACA,OAAO;YAAEC,UAAU;YAAMb,SAASD,QAAQC;QAAS;IACrD,EAAE,OAAOW,OAAO;QACd,MAAMf,OAAQe,yBAAD,AAACA,MAAiCf,IAAI;QACnD,IAAID,oBAAoBC,OAAO,OAAO;YAAEiB,UAAU;YAAMb,SAAS,EAAE;QAAC;QACpE,IAAI;YACF,OAAO;gBAAEa,UAAU;gBAAMb,SAASD,QAAQ,MAAMR,IAAImB,UAAU,CAACZ;YAAO;QACxE,EAAE,OAAOgB,eAAe;YACtB,MAAMC,eAAgBD,iCAAD,AAACA,cAAyClB,IAAI;YACnE,IAAID,oBAAoBoB,eAAe,OAAO;gBAAEF,UAAU;gBAAMb,SAAS,EAAE;YAAC;YAC5E,OAAO;gBAAEa,UAAU;gBAAOb,SAAS,EAAE;YAAC;QACxC;IACF;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAegB,UAAUlB,IAAY;IAC1C,MAAMmB,YAAY,CAACjB,UACjBA,QACGC,GAAG,CAAC,CAACiB;gBAWFC,MAAAA,OAAAA;YAVF,MAAMA,MAAMD;YACZ,MAAME,MAAMD,CAAAA,uBAAAA,IAAKE,KAAK,MAAKC,YACvB,UACAH,CAAAA,uBAAAA,IAAKI,SAAS,MAAKD,YACjB,cACAH,CAAAA,uBAAAA,IAAKK,KAAK,MAAKF,YACb,UACA;YACR,IAAI,CAACF,KAAK,OAAO;YACjB,MAAMK,QAAQC,QACZP,QAAAA,SAAAA,aAAAA,IAAIE,KAAK,YAATF,aAAaA,IAAII,SAAS,YAA1BJ,QAA8BA,IAAIK,KAAK,YAAvCL,OAA2C,IAC3Cf,IAAI;YACN,OAAO,GAAGuB,OAAOR,uBAAAA,IAAKS,QAAQ,KAAK,EAAE,CAAC,EAAER,IAAI,EAAE,EAAEK,MAAM,CAAC,CAAC;QAC1D,GACCI,MAAM,CAACC;IAEZ,IAAI;QACF,MAAMzB,WAAW,IAAIZ;QACrBY,SAASC,UAAU,CAACZ;QACpB,MAAMM,UAAU,MAAM,IAAIO,QAAmB,CAACC,SAASC;YACrDJ,SAAS0B,UAAU,CAACjC,MAAM,CAACa,OAAOC,YAChCD,QAAQF,OAAOE,SAASH,QAAQI;QAEpC;QACA,OAAO;YAAEC,UAAU;YAAMb,SAASiB,UAAUjB;QAAS;IACvD,EAAE,OAAOW,OAAO;QACd,MAAMf,OAAQe,yBAAD,AAACA,MAAiCf,IAAI;QACnD,IAAID,oBAAoBC,OAAO,OAAO;YAAEiB,UAAU;YAAMb,SAAS,EAAE;QAAC;QACpE,IAAI;YACF,OAAO;gBACLa,UAAU;gBACVb,SAASiB,UAAW,MAAM1B,IAAIwC,UAAU,CAACjC;YAC3C;QACF,EAAE,OAAOgB,eAAe;YACtB,MAAMC,eAAgBD,iCAAD,AAACA,cAAyClB,IAAI;YACnE,IAAID,oBAAoBoB,eAAe,OAAO;gBAAEF,UAAU;gBAAMb,SAAS,EAAE;YAAC;YAC5E,OAAO;gBAAEa,UAAU;gBAAOb,SAAS,EAAE;YAAC;QACxC;IACF;AACF;AAOA,+EAA+E,GAC/E,OAAO,eAAegC,SAASlC,IAAY;IACzC,MAAMmB,YAAY,CAACjB,UACjBA,QAAQC,GAAG,CAAC,CAACiB;;mBAAW;gBACtBe,UAAUP,eAAOR,yBAAAA,MAAOe,QAAQ,mBAAI,IACjC7B,IAAI,GACJ8B,WAAW,GACXC,OAAO,CAAC,OAAO;gBAClBC,UAAUT,OAAOT,yBAAAA,MAAOkB,QAAQ,KAAK;YACvC;;IAEF,IAAI;QACF,MAAM/B,WAAW,IAAIZ;QACrBY,SAASC,UAAU,CAACZ;QACpB,MAAMM,UAAU,MAAM,IAAIO,QACxB,CAACC,SAASC;YACRJ,SAASgC,SAAS,CAACvC,MAAM,CAACa,OAAOC,YAC/BD,QAAQF,OAAOE,SAASH,QAAQI;QAEpC;QAEF,OAAO;YAAEC,UAAU;YAAMb,SAASiB,UAAUjB;QAAS;IACvD,EAAE,OAAOW,OAAO;QACd,MAAMf,OAAQe,yBAAD,AAACA,MAAiCf,IAAI;QACnD,IAAID,oBAAoBC,OAAO,OAAO;YAAEiB,UAAU;YAAMb,SAAS,EAAE;QAAC;QACpE,IAAI;YACF,OAAO;gBAAEa,UAAU;gBAAMb,SAASiB,UAAU,MAAM1B,IAAI8C,SAAS,CAACvC;YAAO;QACzE,EAAE,OAAOgB,eAAe;YACtB,MAAMC,eAAgBD,iCAAD,AAACA,cAAyClB,IAAI;YACnE,IAAID,oBAAoBoB,eAAe,OAAO;gBAAEF,UAAU;gBAAMb,SAAS,EAAE;YAAC;YAC5E,OAAO;gBAAEa,UAAU;gBAAOb,SAAS,EAAE;YAAC;QACxC;IACF;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/dns-probe.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 * One DNS lookup, pinned to public resolvers, that can say \"I do not know\".\n *\n * The same shape appeared independently in `/api/domains/verify` (AGL-734) and\n * in `sso-provisioning.ts` (AGL-1210), and a third copy was about to be\n * written for sending domains. It is here once instead.\n *\n * ## Why the resolvers are pinned\n *\n * The runtime's default resolver is not a neutral observer. A stale zone once\n * made the hosting platform's own resolver return NXDOMAIN for records every\n * public resolver could see, which reads as \"the customer deleted their\n * record\" and is indistinguishable from the real thing. Asking a public\n * resolver first, and falling back to the runtime's only when the pinned ones\n * are unreachable, keeps a genuine \"no such record\" answer conclusive.\n *\n * ## Why there are three outcomes and not two\n *\n * A boolean cannot tell \"the record is gone\" from \"nobody answered\", and the\n * difference decides whether a customer's working configuration gets marked\n * broken during someone else's outage. `NXDOMAIN`/`ENOTFOUND`/`ENODATA` are\n * ANSWERS — that name has no such record — and stay conclusive. Anything else,\n * from both the pinned resolvers and the fallback, is `unreachable`, and a\n * caller must treat it as evidence of nothing in either direction.\n */\n\nimport { promises as dns, Resolver as CallbackResolver } from 'dns'\n\n/** Resolvers asked before the runtime's own. */\nexport const PUBLIC_DNS_RESOLVERS = ['1.1.1.1', '8.8.8.8']\n\n/** Whether a resolver error code is an answer rather than a failure to get one. */\nexport function isConclusiveDnsCode(code: string | undefined): boolean {\n return code === 'ENOTFOUND' || code === 'ENODATA' || code === 'NXDOMAIN'\n}\n\nexport interface DnsLookupResult<T> {\n /**\n * True when the lookup got an answer, including the answer \"there is\n * nothing here\". False only when nobody answered.\n */\n answered: boolean\n /** Empty on a conclusive miss and on an unreachable lookup alike. */\n records: T[]\n}\n\n/**\n * TXT records at `host`, each joined from its chunks and trimmed.\n *\n * A TXT answer arrives as an array of string CHUNKS per record, because DNS\n * splits strings longer than 255 bytes. Comparing without joining means a long\n * value — every DKIM public key, for instance — can never match.\n */\nexport async function lookupTxt(host: string): Promise<DnsLookupResult<string>> {\n const flatten = (records: string[][]) =>\n records.map((chunks) => chunks.join('').trim())\n\n try {\n const resolver = new CallbackResolver()\n resolver.setServers(PUBLIC_DNS_RESOLVERS)\n const records = await new Promise<string[][]>((resolve, reject) => {\n resolver.resolveTxt(host, (error, addresses) =>\n error ? reject(error) : resolve(addresses),\n )\n })\n return { answered: true, records: flatten(records) }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(code)) return { answered: true, records: [] }\n try {\n return { answered: true, records: flatten(await dns.resolveTxt(host)) }\n } catch (fallbackError) {\n const fallbackCode = (fallbackError as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(fallbackCode)) return { answered: true, records: [] }\n return { answered: false, records: [] }\n }\n }\n}\n\n/**\n * CAA records at `host`, as `<flags> <tag> \"<value>\"` strings.\n *\n * NOT walked up the tree. The caller asks about one exact name, because the\n * question this exists for is \"does THIS name publish a policy of its own\",\n * and a walk would answer a different one — a parent's policy is inherited by\n * resolution, not by publication, and reporting the parent's records as the\n * child's would say a name restricts issuance when it publishes nothing.\n */\nexport async function lookupCaa(host: string): Promise<DnsLookupResult<string>> {\n const normalize = (records: unknown[]) =>\n records\n .map((entry) => {\n const row = entry as { critical?: number; issue?: string; issuewild?: string; iodef?: string }\n const tag = row?.issue !== undefined\n ? 'issue'\n : row?.issuewild !== undefined\n ? 'issuewild'\n : row?.iodef !== undefined\n ? 'iodef'\n : ''\n if (!tag) return ''\n const value = String(\n row.issue ?? row.issuewild ?? row.iodef ?? '',\n ).trim()\n return `${Number(row?.critical) || 0} ${tag} \"${value}\"`\n })\n .filter(Boolean)\n\n try {\n const resolver = new CallbackResolver()\n resolver.setServers(PUBLIC_DNS_RESOLVERS)\n const records = await new Promise<unknown[]>((resolve, reject) => {\n resolver.resolveCaa(host, (error, addresses) =>\n error ? reject(error) : resolve(addresses as unknown[]),\n )\n })\n return { answered: true, records: normalize(records) }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(code)) return { answered: true, records: [] }\n try {\n return {\n answered: true,\n records: normalize((await dns.resolveCaa(host)) as unknown[]),\n }\n } catch (fallbackError) {\n const fallbackCode = (fallbackError as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(fallbackCode)) return { answered: true, records: [] }\n return { answered: false, records: [] }\n }\n }\n}\n\nexport interface MxRecord {\n exchange: string\n priority: number\n}\n\n/** MX records at `host`, exchanges lowercased and stripped of a trailing dot. */\nexport async function lookupMx(host: string): Promise<DnsLookupResult<MxRecord>> {\n const normalize = (records: { exchange: string; priority: number }[]) =>\n records.map((entry) => ({\n exchange: String(entry?.exchange ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.$/, ''),\n priority: Number(entry?.priority) || 0,\n }))\n\n try {\n const resolver = new CallbackResolver()\n resolver.setServers(PUBLIC_DNS_RESOLVERS)\n const records = await new Promise<{ exchange: string; priority: number }[]>(\n (resolve, reject) => {\n resolver.resolveMx(host, (error, addresses) =>\n error ? reject(error) : resolve(addresses),\n )\n },\n )\n return { answered: true, records: normalize(records) }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(code)) return { answered: true, records: [] }\n try {\n return { answered: true, records: normalize(await dns.resolveMx(host)) }\n } catch (fallbackError) {\n const fallbackCode = (fallbackError as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(fallbackCode)) return { answered: true, records: [] }\n return { answered: false, records: [] }\n }\n }\n}\n\n/**\n * Whether `host` has an address record, A or AAAA — the implicit MX a domain\n * with no MX is still delivered to (RFC 5321 §5.1). `records` holds the\n * addresses found; empty on a conclusive \"none\". Unanswered only when\n * neither family could be asked.\n */\nexport async function lookupAddress(host: string): Promise<DnsLookupResult<string>> {\n const ask = async (family: 4 | 6): Promise<DnsLookupResult<string>> => {\n try {\n const resolver = new CallbackResolver()\n resolver.setServers(PUBLIC_DNS_RESOLVERS)\n const records = await new Promise<string[]>((resolve, reject) => {\n const done = (error: NodeJS.ErrnoException | null, addresses: string[]) =>\n error ? reject(error) : resolve(addresses)\n if (family === 4) resolver.resolve4(host, done)\n else resolver.resolve6(host, done)\n })\n return { answered: true, records }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(code)) return { answered: true, records: [] }\n try {\n return {\n answered: true,\n records: family === 4 ? await dns.resolve4(host) : await dns.resolve6(host),\n }\n } catch (fallbackError) {\n const fallbackCode = (fallbackError as NodeJS.ErrnoException)?.code\n if (isConclusiveDnsCode(fallbackCode)) return { answered: true, records: [] }\n return { answered: false, records: [] }\n }\n }\n }\n const v4 = await ask(4)\n if (v4.records.length) return v4\n const v6 = await ask(6)\n if (v6.records.length) return v6\n return { answered: v4.answered && v6.answered, records: [] }\n}\n"],"names":["promises","dns","Resolver","CallbackResolver","PUBLIC_DNS_RESOLVERS","isConclusiveDnsCode","code","lookupTxt","host","flatten","records","map","chunks","join","trim","resolver","setServers","Promise","resolve","reject","resolveTxt","error","addresses","answered","fallbackError","fallbackCode","lookupCaa","normalize","entry","row","tag","issue","undefined","issuewild","iodef","value","String","Number","critical","filter","Boolean","resolveCaa","lookupMx","exchange","toLowerCase","replace","priority","resolveMx","lookupAddress","ask","family","done","resolve4","resolve6","v4","length","v6"],"mappings":"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"}
|
|
@@ -62,7 +62,11 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
62
62
|
* because the copy still renders and the row still lands. That binding is
|
|
63
63
|
* moved onto the copy below. No other kind embeds its own id in its design,
|
|
64
64
|
* so no other kind is decoded.
|
|
65
|
-
*/
|
|
65
|
+
*/ // The leaf module, not the server barrel: specs stage that barrel as a
|
|
66
|
+
// closed world, and the list keys every create stamps (AGL-3321) are
|
|
67
|
+
// nothing they have reason to name.
|
|
68
|
+
import { artifactCreateListKeys } from "@aglyn/aglyn/app-utils/artifact-list-keys";
|
|
69
|
+
import { billableScreenIds, checkEntitlement, checkQuota, claimAttempt, createResourceUid, decodeStoredNodes, encodeStoredNodes, newFormListFields, NON_PAGE_SCREEN_MAX_PER_HOST, nonPageScreenIds, SCREEN_KIND_EMAIL, SCREEN_KIND_TEMPLATE, screenClaimsToBeAPage } from "@aglyn/aglyn/server";
|
|
66
70
|
import { DUPLICABLE_RESOURCE_NOUNS, DUPLICATE_BUSY_MESSAGE, duplicateDisplayName, duplicateVersionNote, uniqueDuplicateName, uniqueDuplicateSlug } from "@aglyn/aglyn/app-utils/duplicate-resource";
|
|
67
71
|
import { formDesignReboundTo } from "@aglyn/aglyn/app-utils/forms";
|
|
68
72
|
import { Timestamp } from "firebase-admin/firestore";
|
|
@@ -271,7 +275,7 @@ async function copy(firestore, kind, recipe, options) {
|
|
|
271
275
|
const id = createResourceUid();
|
|
272
276
|
const versionId = recipe.versionParentField ? createResourceUid() : null;
|
|
273
277
|
return firestore.runTransaction(async (tx)=>{
|
|
274
|
-
var _data_recipe_nameField, _options_name, _recipe_stamps;
|
|
278
|
+
var _data_recipe_nameField, _options_name, _recipe_stamps, _recipe_stamps1;
|
|
275
279
|
// ALL READS BEFORE ANY WRITE, which Firestore requires. The host is read
|
|
276
280
|
// inside the transaction rather than passed in because its routing map
|
|
277
281
|
// is an input to the screen count and is ordinary client-writable.
|
|
@@ -446,9 +450,12 @@ async function copy(firestore, kind, recipe, options) {
|
|
|
446
450
|
// comes back decoded.
|
|
447
451
|
const packed = encodeStoredNodes(rebound(doc['nodes']));
|
|
448
452
|
if (packed) doc['nodes'] = Buffer.from(packed);
|
|
449
|
-
tx.create(collectionRef.doc(id), _extends({}, doc, (_recipe_stamps = recipe.stamps) != null ? _recipe_stamps : {}, recipe.collection === '
|
|
450
|
-
|
|
451
|
-
|
|
453
|
+
tx.create(collectionRef.doc(id), _extends({}, doc, (_recipe_stamps = recipe.stamps) != null ? _recipe_stamps : {}, artifactCreateListKeys(recipe.collection, _extends({}, doc, (_recipe_stamps1 = recipe.stamps) != null ? _recipe_stamps1 : {})), kind === 'form' ? newFormListFields({
|
|
454
|
+
id,
|
|
455
|
+
displayName: name,
|
|
456
|
+
slug: doc['slug'],
|
|
457
|
+
routing: doc['routing']
|
|
458
|
+
}) : {}, versionData && versionId ? {
|
|
452
459
|
versionId
|
|
453
460
|
} : {}, {
|
|
454
461
|
createdAt: Timestamp.now(),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/duplicate-resource.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 * Duplicating a site resource whole, as a draft (AGL-2936).\n *\n * One module for every kind that lives under a site — screens and email\n * designs, reusable components, layouts, templates, forms and workflows —\n * so the console route, the AI runtime's `duplicate_resource` tool and any\n * later door make the same copy: the same fields, the same uniqueness\n * rules, the same band arithmetic, the same activity rows. A caller that\n * has verified who is asking and that they may write the site hands over\n * the ids and gets back the copy's id, or a refusal it can answer with.\n *\n * WHAT MAKES THE COPY A DRAFT is different per kind, and each is the\n * omission of exactly one thing the create paths would otherwise do:\n *\n * - a screen is reachable only through the host's routing map, so the\n * copy gets no entry there — it has a slug of its own (`-copy`) but no\n * address until somebody publishes it;\n * - a collection's entry template renders only for the collection that\n * points at it, and nothing points at the copy;\n * - a layout, component or form is served only where a page places it,\n * and nothing places the copy;\n * - a workflow runs only when its trigger fires, and the copy's trigger\n * is cleared;\n * - a template is inert until it is used.\n *\n * COUNTED LIKE A CREATE. The copy is one more document in a collection the\n * plan bands, so it meets the same counter the create route meets — for a\n * screen that is not a page, the flat platform ceiling rather than a band —\n * and meets it INSIDE the transaction that writes the copy (AGL-2231): a count\n * taken before the write is a count N concurrent copies all pass. The\n * siblings are read once and answer three questions — the count, the name\n * the copy may take, and the slug — because three reads of one collection\n * is the reason a route and its readout come to disagree.\n *\n * THE VERSION TRAVELS WITH THE DOCUMENT. A besigner resource is opened by\n * its version, so the copy of a screen with no version is a screen nobody\n * can open. The source's most recently saved version is copied into the\n * copy's own `versions` as its first — no `versioning` entitlement is\n * owed for a first version — with its stored node bytes carried across\n * untouched: they are msgpack at rest, and re-encoding them is the\n * double-encode `encodeStoredNodes` exists to avoid.\n *\n * THE ONE TREE THAT IS REWRITTEN is a form's (AGL-3024). A form's design\n * names its own form inside itself, so carrying those bytes across hands the\n * copy a design that files every submission under the SOURCE — silently,\n * because the copy still renders and the row still lands. That binding is\n * moved onto the copy below. No other kind embeds its own id in its design,\n * so no other kind is decoded.\n */\n\nimport {\n billableScreenIds,\n checkEntitlement,\n checkQuota,\n claimAttempt,\n createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n nameSearchKey,\n NON_PAGE_SCREEN_MAX_PER_HOST,\n nonPageScreenIds,\n SCREEN_KIND_EMAIL,\n SCREEN_KIND_TEMPLATE,\n screenClaimsToBeAPage,\n type BillableScreenSource,\n type OrgEntitlements,\n type OrgFeatureFlags,\n} from '@aglyn/aglyn/server'\nimport {\n DUPLICABLE_RESOURCE_NOUNS,\n DUPLICATE_BUSY_MESSAGE,\n duplicateDisplayName,\n duplicateVersionNote,\n uniqueDuplicateName,\n uniqueDuplicateSlug,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { formDesignReboundTo } from '@aglyn/aglyn/app-utils/forms'\nimport { Timestamp } from 'firebase-admin/firestore'\nimport { logResourceDuplicated } from './duplicate-activity'\nimport firebaseAdmin from './firebase-admin'\n\n/** How the copy of each kind is made. */\ninterface KindRecipe {\n collection: string\n /** The field the name lives in — `name` on a workflow, `displayName` elsewhere. */\n nameField: 'displayName' | 'name'\n /** The source fields carried onto the copy; everything else stays behind. */\n fields: readonly string[]\n /** The back-pointer a copied version carries, when the kind versions at all. */\n versionParentField?: 'screenId' | 'layoutId' | 'componentId' | 'formId'\n /** The plan counter the copy is charged to. */\n quotaKey?: keyof OrgEntitlements & string\n /** The feature the plan must include for the kind to exist at all. */\n entitlement?: keyof OrgFeatureFlags\n /** Whether a `slug` on the source becomes a unique `-copy` slug. */\n slug?: boolean\n /** What the copy carries beyond the source's fields. */\n stamps?: Record<string, unknown>\n}\n\n/**\n * Per kind, the same fields the create route's allow-list admits, less the\n * ones a copy must not inherit: a screen's `versionId` (the copy mints its\n * own), a template's `source` (stamped `authored` below — a copy of a\n * marketplace template is the customer's own), a workflow's `trigger`\n * (cleared, so the copy runs nothing until armed), and anything the server\n * stamps on every create.\n */\nconst RECIPES: Record<DuplicableHostResourceKind, KindRecipe> = {\n screen: {\n collection: 'screens',\n nameField: 'displayName',\n fields: [\n 'description',\n 'seo',\n 'kind',\n 'visibility',\n 'locale',\n 'protection',\n // The shared-layout binding. A version inherits it from its screen, so\n // a copy without it renders with no header or footer (AGL-3120).\n 'layoutId',\n ],\n versionParentField: 'screenId',\n quotaKey: 'screensPerHost',\n slug: true,\n },\n // No `quotaKey`: an email design is not a page, so it meets the flat\n // platform ceiling on non-page screens below rather than the page band.\n emailDesign: {\n collection: 'screens',\n nameField: 'displayName',\n fields: ['description', 'kind'],\n versionParentField: 'screenId',\n },\n component: {\n collection: 'components',\n nameField: 'displayName',\n // `kind` keeps an email block an email block (AGL-3287): without it the\n // copy reads as a page component, leaves every email editor's palette,\n // and turns up in page editors where its email blocks cannot render.\n fields: ['description', 'icon', 'rootId', 'nodes', 'props', 'kind'],\n versionParentField: 'componentId',\n entitlement: 'reusableComponents',\n },\n layout: {\n collection: 'layouts',\n nameField: 'displayName',\n fields: ['description'],\n versionParentField: 'layoutId',\n quotaKey: 'sharedLayoutsPerHost',\n },\n template: {\n collection: 'templates',\n nameField: 'displayName',\n fields: [\n 'kind',\n // A component template's own kind — email block or page component —\n // so Use template on a copy still makes the right one (AGL-3287).\n 'componentKind',\n 'description',\n 'category',\n 'placeholders',\n 'nodes',\n 'rootId',\n 'props',\n 'slug',\n 'seo',\n 'theme',\n ],\n quotaKey: 'templatesPerHost',\n stamps: { source: { type: 'authored' } },\n },\n form: {\n collection: 'forms',\n nameField: 'displayName',\n fields: [\n 'fields',\n 'consentFieldName',\n 'routing',\n 'legacyMatch',\n 'rootId',\n 'nodes',\n ],\n versionParentField: 'formId',\n quotaKey: 'formsPerHost',\n entitlement: 'reusableComponents',\n slug: true,\n },\n workflow: {\n collection: 'workflows',\n nameField: 'name',\n fields: ['steps', 'returnValue'],\n quotaKey: 'workflowsPerHost',\n entitlement: 'workflows',\n stamps: { trigger: null },\n },\n}\n\nexport interface DuplicateResourceOptions {\n orgId: string\n hostId: string\n sourceId: string\n /** The name the copy takes; blank means `Copy of <source>`. */\n name?: string | null\n /** The verified person making the copy. */\n uid: string\n email?: string | null\n /**\n * The owning org's document, when the caller already holds it; read\n * here otherwise. The bands are resolved from it.\n */\n org?: Record<string, unknown> | null\n /**\n * A client-minted key for this attempt, so a double click makes one copy\n * and a retry of a finished attempt answers what the first did. A caller\n * with no client behind it — the AI runtime — sends none and is not\n * claimed.\n */\n attemptKey?: string | null\n}\n\nexport type DuplicateResourceResult =\n | {\n ok: true\n id: string\n /** The copy's first version, for the kinds that version. */\n versionId: string | null\n name: string\n }\n | { ok: false; status: number; error: string }\n\n/** What a sibling contributes to the three answers the siblings give. */\ninterface SiblingRow {\n id: string\n name: string\n slug: string\n kind: unknown\n deletedAt: unknown\n sourceType: unknown\n}\n\nconst asRecord = (value: unknown): Record<string, unknown> =>\n value && typeof value === 'object' && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {}\n\n/**\n * Makes the copy. The caller has authenticated `uid` and verified that they\n * may write content on `hostId`; this decides everything after that.\n */\nexport async function duplicateResource(\n kind: DuplicableHostResourceKind,\n options: DuplicateResourceOptions,\n): Promise<DuplicateResourceResult> {\n const recipe = RECIPES[kind]\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const sourceId = String(options.sourceId ?? '').trim().slice(0, 64)\n if (!sourceId) return { ok: false, status: 400, error: `Missing the ${noun} to copy` }\n const firestore = firebaseAdmin.app().firestore()\n\n const attemptKey = String(options.attemptKey ?? '').trim().slice(0, 200)\n const claimed = attemptKey\n ? await claimAttempt(firestore as never, {\n kind: `duplicate-${kind}`,\n scopeId: `${options.hostId}:${sourceId}`,\n orgId: options.orgId,\n key: attemptKey,\n busyMessage: DUPLICATE_BUSY_MESSAGE,\n })\n : null\n if (claimed && 'replay' in claimed) {\n return claimed.replay.body as DuplicateResourceResult\n }\n const claim = claimed && 'claim' in claimed ? claimed.claim : null\n\n try {\n const result = await copy(firestore, kind, recipe, { ...options, sourceId })\n if (result.ok) {\n await logResourceDuplicated(\n kind,\n { uid: options.uid, email: options.email ?? null },\n {\n orgId: options.orgId,\n hostId: options.hostId,\n source: { id: sourceId, name: result.sourceName },\n target: { id: result.id, name: result.name, versionId: result.versionId },\n },\n )\n const answer: DuplicateResourceResult = {\n ok: true,\n id: result.id,\n versionId: result.versionId,\n name: result.name,\n }\n await claim?.record(200, answer)\n return answer\n }\n // A refusal releases the key: the person fixes the cause — frees a slot,\n // picks another name — and presses the same button again.\n await claim?.release()\n return result\n } catch (error) {\n await claim?.release()\n throw error\n }\n}\n\ntype CopyOutcome =\n | { ok: true; id: string; versionId: string | null; name: string; sourceName: string }\n | { ok: false; status: number; error: string }\n\nasync function copy(\n firestore: FirebaseFirestore.Firestore,\n kind: DuplicableHostResourceKind,\n recipe: KindRecipe,\n options: DuplicateResourceOptions & { sourceId: string },\n): Promise<CopyOutcome> {\n const { hostId, sourceId, uid } = options\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const hostRef = firestore.collection('hosts').doc(hostId)\n const collectionRef = hostRef.collection(recipe.collection)\n const sourceRef = collectionRef.doc(sourceId)\n const org =\n options.org ??\n (((await firestore.collection('orgs').doc(options.orgId).get()).data() ??\n null) as Record<string, unknown> | null)\n\n if (recipe.entitlement && !checkEntitlement(org as never, recipe.entitlement)) {\n return {\n ok: false,\n status: 403,\n error: 'This feature is not included in your plan — see Billing',\n }\n }\n\n const id = createResourceUid()\n const versionId = recipe.versionParentField ? createResourceUid() : null\n\n return firestore.runTransaction(async (tx): Promise<CopyOutcome> => {\n // ALL READS BEFORE ANY WRITE, which Firestore requires. The host is read\n // inside the transaction rather than passed in because its routing map\n // is an input to the screen count and is ordinary client-writable.\n const siblingFields = [\n recipe.nameField,\n 'slug',\n 'kind',\n 'deletedAt',\n 'source.type',\n ]\n const [hostSnapshot, source, siblingsSnapshot] = await Promise.all([\n tx.get(hostRef),\n tx.get(sourceRef),\n tx.get(collectionRef.select(...siblingFields)),\n ])\n if (!hostSnapshot.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (!source.exists || source.get('deletedAt') != null) {\n return { ok: false, status: 404, error: `Unknown ${noun}` }\n }\n const data = asRecord(source.data())\n\n // The two screen kinds share a collection and are told apart by `kind`:\n // an email design copied as a page would land on the Screens list, and\n // the other way round would put a page on the Emails page.\n //\n // Of the screens that are not pages, only a collection's entry template\n // may be copied as a screen (AGL-3102), and the copy stays a template:\n // `kind` rides across with the recipe's fields, so it composes nothing\n // until a collection points at it — which is how a second collection\n // starts from the first one's design. An error screen is refused:\n // `kind: 'error'` is the slot assignment's to stamp, because that write\n // is what bounds how many a site holds, and a copy would stamp one\n // outside it.\n const sourceKind = data['kind']\n const nonPageScreen =\n recipe.collection === 'screens' &&\n !screenClaimsToBeAPage({ kind: sourceKind as string })\n if (recipe.collection === 'screens') {\n if (kind === 'emailDesign' && sourceKind !== SCREEN_KIND_EMAIL) {\n return { ok: false, status: 400, error: 'That screen is not an email design' }\n }\n if (kind === 'screen' && nonPageScreen && sourceKind !== SCREEN_KIND_TEMPLATE) {\n return {\n ok: false,\n status: 400,\n error: 'Only a page or a collection entry template can be duplicated as a screen',\n }\n }\n }\n\n const siblings: SiblingRow[] = siblingsSnapshot.docs.map((row) => ({\n id: row.id,\n name: String(row.get(recipe.nameField) ?? ''),\n slug: String(row.get('slug') ?? ''),\n kind: row.get('kind'),\n deletedAt: row.get('deletedAt'),\n sourceType: row.get('source.type'),\n }))\n const live = siblings.filter((row) => row.deletedAt == null)\n\n // THE BAND, met inside the transaction (AGL-2231) with the create route's\n // own arithmetic: screens count what the routing map makes billable,\n // templates exclude the platform's starters, everything else counts its\n // live documents. A screen copy that is not a page — an email design or\n // an entry template — meets the flat platform ceiling on non-page screens\n // instead, the one an email design's create meets and a demoted template\n // fills, and never the page band: it spends none of it.\n const screenRows: BillableScreenSource[] = siblings.map((row) => ({\n id: row.id,\n kind: row.kind,\n deletedAt: row.deletedAt,\n }))\n const routingMap = hostSnapshot.get('screens') as never\n if (nonPageScreen) {\n if (nonPageScreenIds(screenRows, routingMap).size >= NON_PAGE_SCREEN_MAX_PER_HOST) {\n return {\n ok: false,\n status: 403,\n error:\n 'This site is at its limit of ' +\n `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template screens — ` +\n 'delete some to make room',\n }\n }\n } else if (recipe.quotaKey) {\n const used =\n kind === 'screen'\n ? billableScreenIds(screenRows, routingMap).size\n : kind === 'template'\n ? live.filter((row) => row.sourceType !== 'starter').length\n : live.length\n const quota = checkQuota(org as never, recipe.quotaKey as never, used)\n if (!quota.allowed) {\n return {\n ok: false,\n status: 403,\n error:\n `Your plan includes ${quota.limit} ${noun}s — ` +\n 'upgrade in Billing for more',\n }\n }\n }\n\n // THE NAME AND THE SLUG, unique among the live siblings. A deleted\n // sibling's name is free: a person who deleted \"Home\" and copies\n // \"About\" as \"Home\" is not confused by a tombstone.\n const sourceName = String(data[recipe.nameField] ?? '').trim()\n const requested = String(options.name ?? '').trim() || duplicateDisplayName(sourceName)\n const name = uniqueDuplicateName(\n requested,\n live.map((row) => row.name),\n )\n const slug = recipe.slug\n ? uniqueDuplicateSlug(\n data['slug'] as string | undefined,\n live.map((row) => row.slug),\n )\n : undefined\n\n // THE VERSION: the most recently saved one, else the one the document\n // points at. Read inside the transaction so the copy is of what was\n // saved when the count was taken.\n let versionData: Record<string, unknown> | null = null\n let sourceVersionNumber: number | null = null\n if (recipe.versionParentField) {\n const versionsRef = sourceRef.collection('versions')\n const [newest, all] = await Promise.all([\n tx.get(versionsRef.orderBy('updatedAt', 'desc').limit(1)),\n tx.get(versionsRef.select()),\n ])\n let found: FirebaseFirestore.DocumentSnapshot | null = newest.docs[0] ?? null\n const pointer = String(data['versionId'] ?? '')\n if (!found && pointer) {\n const pointed = await tx.get(versionsRef.doc(pointer))\n if (pointed.exists) found = pointed\n }\n if (found) {\n versionData = asRecord(found.data())\n sourceVersionNumber = all.size\n }\n }\n\n // THE DOCUMENT: the recipe's fields, the name, the slug, the stamps.\n const doc: Record<string, unknown> = {}\n for (const field of recipe.fields) {\n if (data[field] !== undefined) doc[field] = data[field]\n }\n doc[recipe.nameField] = name\n if (recipe.slug) {\n if (slug) doc['slug'] = slug\n else delete doc['slug']\n }\n /*\n * THE FORM'S BINDING, moved onto the copy (AGL-3024).\n *\n * A form's design names its own form in a prop, and that name is what\n * `/api/forms/submit` files a submission under. Copied verbatim it still\n * names the SOURCE, so the copy renders, collects, and files everything\n * it collects under the form it was copied from — while its own list\n * stays empty and nothing anywhere reports an error. `checkFormContract`\n * is what the console's form page banners it with; `formDesignReboundTo`\n * is the write side of the same fact.\n *\n * This is the one place a copied tree is not carried across untouched,\n * and the exception is narrow on purpose: no other kind here embeds its\n * own id in its design. `formDesignReboundTo` answers `null` when there\n * is nothing to move, so every other kind — and a form whose design is\n * already correct — pays nothing for this.\n */\n const rebound = (raw: unknown): unknown => {\n if (kind !== 'form') return raw\n return (\n formDesignReboundTo(decodeStoredNodes(raw), {\n formId: id,\n formName: name,\n }) ?? raw\n )\n }\n // The stored tree is msgpack at rest; `encodeStoredNodes` passes encoded\n // bytes through and packs a plain map, so a legacy plain source lands\n // compressed like every create does — and so does a rebound one, which\n // comes back decoded.\n const packed = encodeStoredNodes(rebound(doc['nodes']))\n if (packed) doc['nodes'] = Buffer.from(packed)\n tx.create(collectionRef.doc(id), {\n ...doc,\n ...(recipe.stamps ?? {}),\n ...(recipe.collection === 'screens' ? { nameLower: nameSearchKey(name) } : {}),\n ...(versionData && versionId ? { versionId } : {}),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n if (versionData && versionId && recipe.versionParentField) {\n const versionNodes = encodeStoredNodes(rebound(versionData['nodes']))\n tx.create(collectionRef.doc(id).collection('versions').doc(versionId), {\n ...versionData,\n ...(versionNodes ? { nodes: Buffer.from(versionNodes) } : {}),\n [recipe.versionParentField]: id,\n hostId,\n displayName: duplicateVersionNote(sourceName, sourceVersionNumber),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n }\n return {\n ok: true,\n id,\n versionId: versionData && versionId ? versionId : null,\n name,\n sourceName,\n }\n })\n}\n"],"names":["billableScreenIds","checkEntitlement","checkQuota","claimAttempt","createResourceUid","decodeStoredNodes","encodeStoredNodes","nameSearchKey","NON_PAGE_SCREEN_MAX_PER_HOST","nonPageScreenIds","SCREEN_KIND_EMAIL","SCREEN_KIND_TEMPLATE","screenClaimsToBeAPage","DUPLICABLE_RESOURCE_NOUNS","DUPLICATE_BUSY_MESSAGE","duplicateDisplayName","duplicateVersionNote","uniqueDuplicateName","uniqueDuplicateSlug","formDesignReboundTo","Timestamp","logResourceDuplicated","firebaseAdmin","RECIPES","screen","collection","nameField","fields","versionParentField","quotaKey","slug","emailDesign","component","entitlement","layout","template","stamps","source","type","form","workflow","trigger","asRecord","value","Array","isArray","duplicateResource","kind","options","recipe","noun","sourceId","String","trim","slice","ok","status","error","firestore","app","attemptKey","claimed","scopeId","hostId","orgId","key","busyMessage","replay","body","claim","result","copy","uid","email","id","name","sourceName","target","versionId","answer","record","release","hostRef","doc","collectionRef","sourceRef","org","get","data","runTransaction","tx","siblingFields","hostSnapshot","siblingsSnapshot","Promise","all","select","exists","sourceKind","nonPageScreen","siblings","docs","map","row","deletedAt","sourceType","live","filter","screenRows","routingMap","size","used","length","quota","allowed","limit","requested","undefined","versionData","sourceVersionNumber","newest","versionsRef","orderBy","found","pointer","pointed","field","rebound","raw","formId","formName","packed","Buffer","from","create","nameLower","createdAt","now","updatedAt","createdBy","versionNodes","nodes","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/duplicate-resource.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 * Duplicating a site resource whole, as a draft (AGL-2936).\n *\n * One module for every kind that lives under a site — screens and email\n * designs, reusable components, layouts, templates, forms and workflows —\n * so the console route, the AI runtime's `duplicate_resource` tool and any\n * later door make the same copy: the same fields, the same uniqueness\n * rules, the same band arithmetic, the same activity rows. A caller that\n * has verified who is asking and that they may write the site hands over\n * the ids and gets back the copy's id, or a refusal it can answer with.\n *\n * WHAT MAKES THE COPY A DRAFT is different per kind, and each is the\n * omission of exactly one thing the create paths would otherwise do:\n *\n * - a screen is reachable only through the host's routing map, so the\n * copy gets no entry there — it has a slug of its own (`-copy`) but no\n * address until somebody publishes it;\n * - a collection's entry template renders only for the collection that\n * points at it, and nothing points at the copy;\n * - a layout, component or form is served only where a page places it,\n * and nothing places the copy;\n * - a workflow runs only when its trigger fires, and the copy's trigger\n * is cleared;\n * - a template is inert until it is used.\n *\n * COUNTED LIKE A CREATE. The copy is one more document in a collection the\n * plan bands, so it meets the same counter the create route meets — for a\n * screen that is not a page, the flat platform ceiling rather than a band —\n * and meets it INSIDE the transaction that writes the copy (AGL-2231): a count\n * taken before the write is a count N concurrent copies all pass. The\n * siblings are read once and answer three questions — the count, the name\n * the copy may take, and the slug — because three reads of one collection\n * is the reason a route and its readout come to disagree.\n *\n * THE VERSION TRAVELS WITH THE DOCUMENT. A besigner resource is opened by\n * its version, so the copy of a screen with no version is a screen nobody\n * can open. The source's most recently saved version is copied into the\n * copy's own `versions` as its first — no `versioning` entitlement is\n * owed for a first version — with its stored node bytes carried across\n * untouched: they are msgpack at rest, and re-encoding them is the\n * double-encode `encodeStoredNodes` exists to avoid.\n *\n * THE ONE TREE THAT IS REWRITTEN is a form's (AGL-3024). A form's design\n * names its own form inside itself, so carrying those bytes across hands the\n * copy a design that files every submission under the SOURCE — silently,\n * because the copy still renders and the row still lands. That binding is\n * moved onto the copy below. No other kind embeds its own id in its design,\n * so no other kind is decoded.\n */\n\n// The leaf module, not the server barrel: specs stage that barrel as a\n// closed world, and the list keys every create stamps (AGL-3321) are\n// nothing they have reason to name.\nimport { artifactCreateListKeys } from '@aglyn/aglyn/app-utils/artifact-list-keys'\nimport {\n billableScreenIds,\n checkEntitlement,\n checkQuota,\n claimAttempt,\n createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n newFormListFields,\n NON_PAGE_SCREEN_MAX_PER_HOST,\n nonPageScreenIds,\n SCREEN_KIND_EMAIL,\n SCREEN_KIND_TEMPLATE,\n screenClaimsToBeAPage,\n type BillableScreenSource,\n type OrgEntitlements,\n type OrgFeatureFlags,\n} from '@aglyn/aglyn/server'\nimport {\n DUPLICABLE_RESOURCE_NOUNS,\n DUPLICATE_BUSY_MESSAGE,\n duplicateDisplayName,\n duplicateVersionNote,\n uniqueDuplicateName,\n uniqueDuplicateSlug,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { formDesignReboundTo } from '@aglyn/aglyn/app-utils/forms'\nimport { Timestamp } from 'firebase-admin/firestore'\nimport { logResourceDuplicated } from './duplicate-activity'\nimport firebaseAdmin from './firebase-admin'\n\n/** How the copy of each kind is made. */\ninterface KindRecipe {\n collection: string\n /** The field the name lives in — `name` on a workflow, `displayName` elsewhere. */\n nameField: 'displayName' | 'name'\n /** The source fields carried onto the copy; everything else stays behind. */\n fields: readonly string[]\n /** The back-pointer a copied version carries, when the kind versions at all. */\n versionParentField?: 'screenId' | 'layoutId' | 'componentId' | 'formId'\n /** The plan counter the copy is charged to. */\n quotaKey?: keyof OrgEntitlements & string\n /** The feature the plan must include for the kind to exist at all. */\n entitlement?: keyof OrgFeatureFlags\n /** Whether a `slug` on the source becomes a unique `-copy` slug. */\n slug?: boolean\n /** What the copy carries beyond the source's fields. */\n stamps?: Record<string, unknown>\n}\n\n/**\n * Per kind, the same fields the create route's allow-list admits, less the\n * ones a copy must not inherit: a screen's `versionId` (the copy mints its\n * own), a template's `source` (stamped `authored` below — a copy of a\n * marketplace template is the customer's own), a workflow's `trigger`\n * (cleared, so the copy runs nothing until armed), and anything the server\n * stamps on every create.\n */\nconst RECIPES: Record<DuplicableHostResourceKind, KindRecipe> = {\n screen: {\n collection: 'screens',\n nameField: 'displayName',\n fields: [\n 'description',\n 'seo',\n 'kind',\n 'visibility',\n 'locale',\n 'protection',\n // The shared-layout binding. A version inherits it from its screen, so\n // a copy without it renders with no header or footer (AGL-3120).\n 'layoutId',\n ],\n versionParentField: 'screenId',\n quotaKey: 'screensPerHost',\n slug: true,\n },\n // No `quotaKey`: an email design is not a page, so it meets the flat\n // platform ceiling on non-page screens below rather than the page band.\n emailDesign: {\n collection: 'screens',\n nameField: 'displayName',\n fields: ['description', 'kind'],\n versionParentField: 'screenId',\n },\n component: {\n collection: 'components',\n nameField: 'displayName',\n // `kind` keeps an email block an email block (AGL-3287): without it the\n // copy reads as a page component, leaves every email editor's palette,\n // and turns up in page editors where its email blocks cannot render.\n fields: ['description', 'icon', 'rootId', 'nodes', 'props', 'kind'],\n versionParentField: 'componentId',\n entitlement: 'reusableComponents',\n },\n layout: {\n collection: 'layouts',\n nameField: 'displayName',\n fields: ['description'],\n versionParentField: 'layoutId',\n quotaKey: 'sharedLayoutsPerHost',\n },\n template: {\n collection: 'templates',\n nameField: 'displayName',\n fields: [\n 'kind',\n // A component template's own kind — email block or page component —\n // so Use template on a copy still makes the right one (AGL-3287).\n 'componentKind',\n 'description',\n 'category',\n 'placeholders',\n 'nodes',\n 'rootId',\n 'props',\n 'slug',\n 'seo',\n 'theme',\n ],\n quotaKey: 'templatesPerHost',\n stamps: { source: { type: 'authored' } },\n },\n form: {\n collection: 'forms',\n nameField: 'displayName',\n fields: [\n 'fields',\n 'consentFieldName',\n 'routing',\n 'legacyMatch',\n 'rootId',\n 'nodes',\n ],\n versionParentField: 'formId',\n quotaKey: 'formsPerHost',\n entitlement: 'reusableComponents',\n slug: true,\n },\n workflow: {\n collection: 'workflows',\n nameField: 'name',\n fields: ['steps', 'returnValue'],\n quotaKey: 'workflowsPerHost',\n entitlement: 'workflows',\n stamps: { trigger: null },\n },\n}\n\nexport interface DuplicateResourceOptions {\n orgId: string\n hostId: string\n sourceId: string\n /** The name the copy takes; blank means `Copy of <source>`. */\n name?: string | null\n /** The verified person making the copy. */\n uid: string\n email?: string | null\n /**\n * The owning org's document, when the caller already holds it; read\n * here otherwise. The bands are resolved from it.\n */\n org?: Record<string, unknown> | null\n /**\n * A client-minted key for this attempt, so a double click makes one copy\n * and a retry of a finished attempt answers what the first did. A caller\n * with no client behind it — the AI runtime — sends none and is not\n * claimed.\n */\n attemptKey?: string | null\n}\n\nexport type DuplicateResourceResult =\n | {\n ok: true\n id: string\n /** The copy's first version, for the kinds that version. */\n versionId: string | null\n name: string\n }\n | { ok: false; status: number; error: string }\n\n/** What a sibling contributes to the three answers the siblings give. */\ninterface SiblingRow {\n id: string\n name: string\n slug: string\n kind: unknown\n deletedAt: unknown\n sourceType: unknown\n}\n\nconst asRecord = (value: unknown): Record<string, unknown> =>\n value && typeof value === 'object' && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {}\n\n/**\n * Makes the copy. The caller has authenticated `uid` and verified that they\n * may write content on `hostId`; this decides everything after that.\n */\nexport async function duplicateResource(\n kind: DuplicableHostResourceKind,\n options: DuplicateResourceOptions,\n): Promise<DuplicateResourceResult> {\n const recipe = RECIPES[kind]\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const sourceId = String(options.sourceId ?? '').trim().slice(0, 64)\n if (!sourceId) return { ok: false, status: 400, error: `Missing the ${noun} to copy` }\n const firestore = firebaseAdmin.app().firestore()\n\n const attemptKey = String(options.attemptKey ?? '').trim().slice(0, 200)\n const claimed = attemptKey\n ? await claimAttempt(firestore as never, {\n kind: `duplicate-${kind}`,\n scopeId: `${options.hostId}:${sourceId}`,\n orgId: options.orgId,\n key: attemptKey,\n busyMessage: DUPLICATE_BUSY_MESSAGE,\n })\n : null\n if (claimed && 'replay' in claimed) {\n return claimed.replay.body as DuplicateResourceResult\n }\n const claim = claimed && 'claim' in claimed ? claimed.claim : null\n\n try {\n const result = await copy(firestore, kind, recipe, { ...options, sourceId })\n if (result.ok) {\n await logResourceDuplicated(\n kind,\n { uid: options.uid, email: options.email ?? null },\n {\n orgId: options.orgId,\n hostId: options.hostId,\n source: { id: sourceId, name: result.sourceName },\n target: { id: result.id, name: result.name, versionId: result.versionId },\n },\n )\n const answer: DuplicateResourceResult = {\n ok: true,\n id: result.id,\n versionId: result.versionId,\n name: result.name,\n }\n await claim?.record(200, answer)\n return answer\n }\n // A refusal releases the key: the person fixes the cause — frees a slot,\n // picks another name — and presses the same button again.\n await claim?.release()\n return result\n } catch (error) {\n await claim?.release()\n throw error\n }\n}\n\ntype CopyOutcome =\n | { ok: true; id: string; versionId: string | null; name: string; sourceName: string }\n | { ok: false; status: number; error: string }\n\nasync function copy(\n firestore: FirebaseFirestore.Firestore,\n kind: DuplicableHostResourceKind,\n recipe: KindRecipe,\n options: DuplicateResourceOptions & { sourceId: string },\n): Promise<CopyOutcome> {\n const { hostId, sourceId, uid } = options\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const hostRef = firestore.collection('hosts').doc(hostId)\n const collectionRef = hostRef.collection(recipe.collection)\n const sourceRef = collectionRef.doc(sourceId)\n const org =\n options.org ??\n (((await firestore.collection('orgs').doc(options.orgId).get()).data() ??\n null) as Record<string, unknown> | null)\n\n if (recipe.entitlement && !checkEntitlement(org as never, recipe.entitlement)) {\n return {\n ok: false,\n status: 403,\n error: 'This feature is not included in your plan — see Billing',\n }\n }\n\n const id = createResourceUid()\n const versionId = recipe.versionParentField ? createResourceUid() : null\n\n return firestore.runTransaction(async (tx): Promise<CopyOutcome> => {\n // ALL READS BEFORE ANY WRITE, which Firestore requires. The host is read\n // inside the transaction rather than passed in because its routing map\n // is an input to the screen count and is ordinary client-writable.\n const siblingFields = [\n recipe.nameField,\n 'slug',\n 'kind',\n 'deletedAt',\n 'source.type',\n ]\n const [hostSnapshot, source, siblingsSnapshot] = await Promise.all([\n tx.get(hostRef),\n tx.get(sourceRef),\n tx.get(collectionRef.select(...siblingFields)),\n ])\n if (!hostSnapshot.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (!source.exists || source.get('deletedAt') != null) {\n return { ok: false, status: 404, error: `Unknown ${noun}` }\n }\n const data = asRecord(source.data())\n\n // The two screen kinds share a collection and are told apart by `kind`:\n // an email design copied as a page would land on the Screens list, and\n // the other way round would put a page on the Emails page.\n //\n // Of the screens that are not pages, only a collection's entry template\n // may be copied as a screen (AGL-3102), and the copy stays a template:\n // `kind` rides across with the recipe's fields, so it composes nothing\n // until a collection points at it — which is how a second collection\n // starts from the first one's design. An error screen is refused:\n // `kind: 'error'` is the slot assignment's to stamp, because that write\n // is what bounds how many a site holds, and a copy would stamp one\n // outside it.\n const sourceKind = data['kind']\n const nonPageScreen =\n recipe.collection === 'screens' &&\n !screenClaimsToBeAPage({ kind: sourceKind as string })\n if (recipe.collection === 'screens') {\n if (kind === 'emailDesign' && sourceKind !== SCREEN_KIND_EMAIL) {\n return { ok: false, status: 400, error: 'That screen is not an email design' }\n }\n if (kind === 'screen' && nonPageScreen && sourceKind !== SCREEN_KIND_TEMPLATE) {\n return {\n ok: false,\n status: 400,\n error: 'Only a page or a collection entry template can be duplicated as a screen',\n }\n }\n }\n\n const siblings: SiblingRow[] = siblingsSnapshot.docs.map((row) => ({\n id: row.id,\n name: String(row.get(recipe.nameField) ?? ''),\n slug: String(row.get('slug') ?? ''),\n kind: row.get('kind'),\n deletedAt: row.get('deletedAt'),\n sourceType: row.get('source.type'),\n }))\n const live = siblings.filter((row) => row.deletedAt == null)\n\n // THE BAND, met inside the transaction (AGL-2231) with the create route's\n // own arithmetic: screens count what the routing map makes billable,\n // templates exclude the platform's starters, everything else counts its\n // live documents. A screen copy that is not a page — an email design or\n // an entry template — meets the flat platform ceiling on non-page screens\n // instead, the one an email design's create meets and a demoted template\n // fills, and never the page band: it spends none of it.\n const screenRows: BillableScreenSource[] = siblings.map((row) => ({\n id: row.id,\n kind: row.kind,\n deletedAt: row.deletedAt,\n }))\n const routingMap = hostSnapshot.get('screens') as never\n if (nonPageScreen) {\n if (nonPageScreenIds(screenRows, routingMap).size >= NON_PAGE_SCREEN_MAX_PER_HOST) {\n return {\n ok: false,\n status: 403,\n error:\n 'This site is at its limit of ' +\n `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template screens — ` +\n 'delete some to make room',\n }\n }\n } else if (recipe.quotaKey) {\n const used =\n kind === 'screen'\n ? billableScreenIds(screenRows, routingMap).size\n : kind === 'template'\n ? live.filter((row) => row.sourceType !== 'starter').length\n : live.length\n const quota = checkQuota(org as never, recipe.quotaKey as never, used)\n if (!quota.allowed) {\n return {\n ok: false,\n status: 403,\n error:\n `Your plan includes ${quota.limit} ${noun}s — ` +\n 'upgrade in Billing for more',\n }\n }\n }\n\n // THE NAME AND THE SLUG, unique among the live siblings. A deleted\n // sibling's name is free: a person who deleted \"Home\" and copies\n // \"About\" as \"Home\" is not confused by a tombstone.\n const sourceName = String(data[recipe.nameField] ?? '').trim()\n const requested = String(options.name ?? '').trim() || duplicateDisplayName(sourceName)\n const name = uniqueDuplicateName(\n requested,\n live.map((row) => row.name),\n )\n const slug = recipe.slug\n ? uniqueDuplicateSlug(\n data['slug'] as string | undefined,\n live.map((row) => row.slug),\n )\n : undefined\n\n // THE VERSION: the most recently saved one, else the one the document\n // points at. Read inside the transaction so the copy is of what was\n // saved when the count was taken.\n let versionData: Record<string, unknown> | null = null\n let sourceVersionNumber: number | null = null\n if (recipe.versionParentField) {\n const versionsRef = sourceRef.collection('versions')\n const [newest, all] = await Promise.all([\n tx.get(versionsRef.orderBy('updatedAt', 'desc').limit(1)),\n tx.get(versionsRef.select()),\n ])\n let found: FirebaseFirestore.DocumentSnapshot | null = newest.docs[0] ?? null\n const pointer = String(data['versionId'] ?? '')\n if (!found && pointer) {\n const pointed = await tx.get(versionsRef.doc(pointer))\n if (pointed.exists) found = pointed\n }\n if (found) {\n versionData = asRecord(found.data())\n sourceVersionNumber = all.size\n }\n }\n\n // THE DOCUMENT: the recipe's fields, the name, the slug, the stamps.\n const doc: Record<string, unknown> = {}\n for (const field of recipe.fields) {\n if (data[field] !== undefined) doc[field] = data[field]\n }\n doc[recipe.nameField] = name\n if (recipe.slug) {\n if (slug) doc['slug'] = slug\n else delete doc['slug']\n }\n /*\n * THE FORM'S BINDING, moved onto the copy (AGL-3024).\n *\n * A form's design names its own form in a prop, and that name is what\n * `/api/forms/submit` files a submission under. Copied verbatim it still\n * names the SOURCE, so the copy renders, collects, and files everything\n * it collects under the form it was copied from — while its own list\n * stays empty and nothing anywhere reports an error. `checkFormContract`\n * is what the console's form page banners it with; `formDesignReboundTo`\n * is the write side of the same fact.\n *\n * This is the one place a copied tree is not carried across untouched,\n * and the exception is narrow on purpose: no other kind here embeds its\n * own id in its design. `formDesignReboundTo` answers `null` when there\n * is nothing to move, so every other kind — and a form whose design is\n * already correct — pays nothing for this.\n */\n const rebound = (raw: unknown): unknown => {\n if (kind !== 'form') return raw\n return (\n formDesignReboundTo(decodeStoredNodes(raw), {\n formId: id,\n formName: name,\n }) ?? raw\n )\n }\n // The stored tree is msgpack at rest; `encodeStoredNodes` passes encoded\n // bytes through and packs a plain map, so a legacy plain source lands\n // compressed like every create does — and so does a rebound one, which\n // comes back decoded.\n const packed = encodeStoredNodes(rebound(doc['nodes']))\n if (packed) doc['nodes'] = Buffer.from(packed)\n tx.create(collectionRef.doc(id), {\n ...doc,\n ...(recipe.stamps ?? {}),\n // The keys the copy's list finds it by (AGL-3321), from the copy's own\n // name and stamps — see `artifactCreateListKeys`.\n ...artifactCreateListKeys(recipe.collection, { ...doc, ...(recipe.stamps ?? {}) }),\n // A copied form starts as a new one does, with its own search keys and\n // nothing counted (AGL-3330); only the routing it copied is kept.\n ...(kind === 'form'\n ? newFormListFields({\n id,\n displayName: name,\n slug: doc['slug'],\n routing: doc['routing'] as Parameters<typeof newFormListFields>[0]['routing'],\n })\n : {}),\n ...(versionData && versionId ? { versionId } : {}),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n if (versionData && versionId && recipe.versionParentField) {\n const versionNodes = encodeStoredNodes(rebound(versionData['nodes']))\n tx.create(collectionRef.doc(id).collection('versions').doc(versionId), {\n ...versionData,\n ...(versionNodes ? { nodes: Buffer.from(versionNodes) } : {}),\n [recipe.versionParentField]: id,\n hostId,\n displayName: duplicateVersionNote(sourceName, sourceVersionNumber),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n }\n return {\n ok: true,\n id,\n versionId: versionData && versionId ? versionId : null,\n name,\n sourceName,\n }\n })\n}\n"],"names":["artifactCreateListKeys","billableScreenIds","checkEntitlement","checkQuota","claimAttempt","createResourceUid","decodeStoredNodes","encodeStoredNodes","newFormListFields","NON_PAGE_SCREEN_MAX_PER_HOST","nonPageScreenIds","SCREEN_KIND_EMAIL","SCREEN_KIND_TEMPLATE","screenClaimsToBeAPage","DUPLICABLE_RESOURCE_NOUNS","DUPLICATE_BUSY_MESSAGE","duplicateDisplayName","duplicateVersionNote","uniqueDuplicateName","uniqueDuplicateSlug","formDesignReboundTo","Timestamp","logResourceDuplicated","firebaseAdmin","RECIPES","screen","collection","nameField","fields","versionParentField","quotaKey","slug","emailDesign","component","entitlement","layout","template","stamps","source","type","form","workflow","trigger","asRecord","value","Array","isArray","duplicateResource","kind","options","recipe","noun","sourceId","String","trim","slice","ok","status","error","firestore","app","attemptKey","claimed","scopeId","hostId","orgId","key","busyMessage","replay","body","claim","result","copy","uid","email","id","name","sourceName","target","versionId","answer","record","release","hostRef","doc","collectionRef","sourceRef","org","get","data","runTransaction","tx","siblingFields","hostSnapshot","siblingsSnapshot","Promise","all","select","exists","sourceKind","nonPageScreen","siblings","docs","map","row","deletedAt","sourceType","live","filter","screenRows","routingMap","size","used","length","quota","allowed","limit","requested","undefined","versionData","sourceVersionNumber","newest","versionsRef","orderBy","found","pointer","pointed","field","rebound","raw","formId","formName","packed","Buffer","from","create","displayName","routing","createdAt","now","updatedAt","createdBy","versionNodes","nodes"],"mappings":"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"}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type EmailDeliverabilityPreflightRequest, type EmailDeliverabilityPreflightVerdict, type EmailDeliverabilityVerdict, type EmailDeliveryEvent, type EmailSendPurpose, type MailDnsResolver, type MailDomainIntel, type MailGateway, type MailGatewayLedger, type MailGatewayOutcome, type MailGatewayStanding } from '@aglyn/shared-util-email';
|
|
18
|
+
import type { EmailDeliveryEventOutcome } from './email-delivery-log';
|
|
19
|
+
type Firestore = FirebaseFirestore.Firestore;
|
|
20
|
+
/** The platform MX cache, keyed by recipient domain. */
|
|
21
|
+
export declare const MAIL_DOMAINS_COLLECTION = "mailDomains";
|
|
22
|
+
/** The gateway ledger, at the top level and under an organization — see the module note. */
|
|
23
|
+
export declare const MAIL_GATEWAY_LEDGER_COLLECTION = "mailGatewayLedger";
|
|
24
|
+
/**
|
|
25
|
+
* Where a ledger lives: `null` for the top level (a Resend sending domain),
|
|
26
|
+
* or the organization a connected mailbox belongs to.
|
|
27
|
+
*/
|
|
28
|
+
export type MailGatewayLedgerScope = {
|
|
29
|
+
orgId: string;
|
|
30
|
+
} | null;
|
|
31
|
+
/** What the reads and writes here need beside their input; every field defaults to the platform's. */
|
|
32
|
+
export interface MailDeliverabilityDeps {
|
|
33
|
+
firestore?: Firestore;
|
|
34
|
+
resolver?: MailDnsResolver;
|
|
35
|
+
nowMs?: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The platform's resolver: the pinned public resolvers first, and — for the
|
|
39
|
+
* one answer that stops mail, "no MX" — the runtime's own as a second
|
|
40
|
+
* opinion that must agree. See the module note.
|
|
41
|
+
*/
|
|
42
|
+
export declare function platformMailDnsResolver(): MailDnsResolver;
|
|
43
|
+
/** Test seam: forget everything remembered in this process. */
|
|
44
|
+
export declare function resetMailDeliverabilityMemoryForTests(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Each domain's MX answer, keyed by domain: remembered, stored and fresh, or
|
|
47
|
+
* looked up and stored. `null` for a domain that could not be looked up and
|
|
48
|
+
* has no answer that may stand — see the module note.
|
|
49
|
+
*/
|
|
50
|
+
export declare function readMailDomains(domains: readonly string[], deps?: MailDeliverabilityDeps): Promise<Map<string, MailDomainIntel | null>>;
|
|
51
|
+
/**
|
|
52
|
+
* Whether a block on this sending domain hurts every tenant — Aglyn's own
|
|
53
|
+
* domain or a shared pool member — which the staff console surfaces first.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isSharedMailSendingDomain(domain: string | null | undefined): boolean;
|
|
56
|
+
/** The ledger of each gateway named for one sending domain, keyed by gateway; `null` where none exists yet. */
|
|
57
|
+
export declare function readMailGatewayLedgers(scope: MailGatewayLedgerScope, sendingDomain: string, gateways: readonly MailGateway[], deps?: MailDeliverabilityDeps): Promise<Map<MailGateway, MailGatewayLedger | null>>;
|
|
58
|
+
/**
|
|
59
|
+
* Counts one outcome on a sending domain's ledger with a gateway. Never
|
|
60
|
+
* throws: the send or the bounce it records has already happened, and a
|
|
61
|
+
* lost count understates a gateway while a thrown one loses the step that
|
|
62
|
+
* called. A delivery past the day's cap costs no write at all.
|
|
63
|
+
*/
|
|
64
|
+
export declare function recordMailGatewayOutcome(scope: MailGatewayLedgerScope, input: {
|
|
65
|
+
sendingDomain: string;
|
|
66
|
+
gateway: MailGateway;
|
|
67
|
+
outcome: MailGatewayOutcome;
|
|
68
|
+
count?: number;
|
|
69
|
+
atMs: number;
|
|
70
|
+
/** A refusal's diagnostic, scrubbed here of addresses before it is kept. */
|
|
71
|
+
detail?: string | null;
|
|
72
|
+
}, deps?: MailDeliverabilityDeps): Promise<void>;
|
|
73
|
+
/** What a batch read needs to know about the mail being sent. */
|
|
74
|
+
export interface MailDeliverabilityQuestion {
|
|
75
|
+
emails: readonly string[];
|
|
76
|
+
purpose: EmailSendPurpose;
|
|
77
|
+
/** The domain the mail leaves from; without one, no ledger is read and nothing is held. */
|
|
78
|
+
sendingDomain: string | null;
|
|
79
|
+
scope: MailGatewayLedgerScope;
|
|
80
|
+
/** Addresses a person already released the gateway hold for. */
|
|
81
|
+
holdReleased?: (email: string) => boolean;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The deliverability verdict for every address, keyed by the address as
|
|
85
|
+
* given: the MX answers read (and refreshed) above, joined to the sending
|
|
86
|
+
* domain's ledger with each gateway they name. A ledger that cannot be read
|
|
87
|
+
* reads as no history, which holds nothing.
|
|
88
|
+
*/
|
|
89
|
+
export declare function readMailDeliverability(question: MailDeliverabilityQuestion, deps?: MailDeliverabilityDeps): Promise<Map<string, EmailDeliverabilityVerdict>>;
|
|
90
|
+
/** Whether a verdict refuses because the domain takes no mail — the refusal that suppresses. */
|
|
91
|
+
export declare function isNoMailServerVerdict(verdict: EmailDeliverabilityVerdict | null | undefined): boolean;
|
|
92
|
+
/** Whether a verdict holds bulk mail for its gateway. */
|
|
93
|
+
export declare function isGatewayHeldVerdict(verdict: EmailDeliverabilityVerdict | null | undefined): boolean;
|
|
94
|
+
/** A campaign's audience after the checks, with who was excluded and why. */
|
|
95
|
+
export interface DeliverableRecipients {
|
|
96
|
+
deliverable: string[];
|
|
97
|
+
/** Addresses whose domain takes no mail. */
|
|
98
|
+
noMailServer: string[];
|
|
99
|
+
/** Addresses behind a gateway that refused this sending domain twice lately. */
|
|
100
|
+
gatewayHeld: string[];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A bulk audience with the addresses that would bounce, or that sit behind
|
|
104
|
+
* a gateway holding this sending domain, taken out — the count a campaign's
|
|
105
|
+
* review shows before Send, and the filter its batches run. Fails open: an
|
|
106
|
+
* error keeps everyone.
|
|
107
|
+
*/
|
|
108
|
+
export declare function filterDeliverableRecipients(input: {
|
|
109
|
+
emails: readonly string[];
|
|
110
|
+
sendingDomain: string | null;
|
|
111
|
+
purpose?: EmailSendPurpose;
|
|
112
|
+
}, deps?: MailDeliverabilityDeps): Promise<DeliverableRecipients>;
|
|
113
|
+
/**
|
|
114
|
+
* What `sendEmail` asks before a message leaves — see the seam in
|
|
115
|
+
* `@aglyn/shared-util-email`. Refuses every recipient whose domain takes no
|
|
116
|
+
* mail, and files the address on the platform suppression list with its own
|
|
117
|
+
* reason; holds bulk mail behind a gateway that refused this sending domain
|
|
118
|
+
* twice. The Resend path's ledger is keyed by the sending domain alone.
|
|
119
|
+
*/
|
|
120
|
+
export declare function runEmailDeliverabilityPreflight(request: EmailDeliverabilityPreflightRequest, deps?: MailDeliverabilityDeps): Promise<EmailDeliverabilityPreflightVerdict>;
|
|
121
|
+
/**
|
|
122
|
+
* Puts the preflight on `sendEmail`'s path. **Called at module load**, from
|
|
123
|
+
* the bottom of this file, for the reason `installEmailSendGovernor` is.
|
|
124
|
+
* Idempotent; the closure holds no state of its own.
|
|
125
|
+
*/
|
|
126
|
+
export declare function installEmailDeliverabilityPreflight(): void;
|
|
127
|
+
/**
|
|
128
|
+
* Counts what a Resend webhook reported on the sending domain's ledger: a
|
|
129
|
+
* delivery credits the gateway in front of the recipient, and a permanent
|
|
130
|
+
* bounce that reads as the gateway refusing the SENDER — not an unknown
|
|
131
|
+
* address — counts as a block, filed under the gateway the bounce names
|
|
132
|
+
* (its remote server, or its own words) or else the domain's MX. Only an
|
|
133
|
+
* event the delivery log saw for the first time counts, so a redelivered
|
|
134
|
+
* webhook counts nothing twice. Answers how many outcomes it recorded.
|
|
135
|
+
*/
|
|
136
|
+
export declare function recordDeliverabilityFromDeliveryEvents(events: readonly EmailDeliveryEvent[], outcomes: readonly EmailDeliveryEventOutcome[] | null, deps?: MailDeliverabilityDeps): Promise<number>;
|
|
137
|
+
/** One row of the platform ledger as the staff console shows it. */
|
|
138
|
+
export interface MailGatewayLedgerRow extends MailGatewayLedger {
|
|
139
|
+
id: string;
|
|
140
|
+
/** A pooled or platform sending domain: a block there hurts every tenant. */
|
|
141
|
+
shared: boolean;
|
|
142
|
+
standing: MailGatewayStanding;
|
|
143
|
+
/** Whether bulk mail from this domain into this gateway is held today. */
|
|
144
|
+
holds: boolean;
|
|
145
|
+
}
|
|
146
|
+
/** The platform ledger, most recently moved first. */
|
|
147
|
+
export declare function listMailGatewayLedgers(options?: {
|
|
148
|
+
limit?: number;
|
|
149
|
+
}, deps?: MailDeliverabilityDeps): Promise<MailGatewayLedgerRow[]>;
|
|
150
|
+
export {};
|