@aglyn/aglyn 1.0.0-beta.151 → 1.0.0-beta.153
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 +10 -10
- package/src/lib/app-utils/crm-csv.d.ts +10 -3
- package/src/lib/app-utils/crm-csv.js +37 -2
- package/src/lib/app-utils/crm-csv.js.map +1 -1
- package/src/lib/app-utils/crm-email-templates.js +15 -0
- package/src/lib/app-utils/crm-email-templates.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +69 -2
- package/src/lib/app-utils/crm.js +126 -1
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +1 -1
- package/src/lib/app-utils/docs-help.generated.js +2 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +30 -16
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/email-starter-policy.js +5 -3
- package/src/lib/app-utils/email-starter-policy.js.map +1 -1
- package/src/lib/app-utils/notifications.d.ts +11 -0
- package/src/lib/app-utils/notifications.js +11 -1
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/plan-entitlements.d.ts +34 -0
- package/src/lib/app-utils/plan-entitlements.js +40 -0
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/server.d.ts +0 -1
- package/src/lib/app-utils/server.js +0 -1
- package/src/lib/app-utils/server.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +1 -0
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-artifact-inventory.d.ts +145 -0
- package/src/lib/plugin-manager/plugin-artifact-inventory.js +126 -0
- package/src/lib/plugin-manager/plugin-artifact-inventory.js.map +1 -0
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +32 -1
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
- package/src/lib/plugin-manager/plugin-lead-conversion.d.ts +67 -0
- package/src/lib/plugin-manager/plugin-lead-conversion.js +93 -0
- package/src/lib/plugin-manager/plugin-lead-conversion.js.map +1 -0
- package/src/lib/plugin-manager/plugin-org-capacity.d.ts +12 -0
- package/src/lib/plugin-manager/plugin-org-capacity.js.map +1 -1
- package/src/lib/plugin-manager/plugin-permissions.d.ts +7 -0
- package/src/lib/plugin-manager/plugin-permissions.js +30 -0
- package/src/lib/plugin-manager/plugin-permissions.js.map +1 -1
- package/src/lib/plugin-manager/plugin-record-timeline.d.ts +8 -1
- package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
- package/src/lib/plugin-manager/plugin-trust-signing.d.ts +51 -0
- package/src/lib/plugin-manager/plugin-trust-signing.js +119 -0
- package/src/lib/plugin-manager/plugin-trust-signing.js.map +1 -0
- package/src/lib/app-utils/marketplace-verification.d.ts +0 -95
- package/src/lib/app-utils/marketplace-verification.js +0 -81
- package/src/lib/app-utils/marketplace-verification.js.map +0 -1
- package/src/lib/app-utils/publisher-attestation.d.ts +0 -99
- package/src/lib/app-utils/publisher-attestation.js +0 -158
- package/src/lib/app-utils/publisher-attestation.js.map +0 -1
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
* mailed.
|
|
19
19
|
*
|
|
20
20
|
* In core rather than beside the rest of the marketplace model for the reason
|
|
21
|
-
* `marketplace-provenance`
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* `marketplace-provenance` gives: three different projects have to ask these
|
|
22
|
+
* questions and a `scope:app` project may not depend on an `aglyn:addons` lib.
|
|
23
|
+
* `marketplace-verification` used to be cited here too and no longer can be —
|
|
24
|
+
* its console reader became the plugin's own surface, so the policy went with
|
|
25
|
+
* it (AGL-3080). A shared reason is only shared while both halves still hold. The marketplace plugin enforces the
|
|
24
26
|
* policy when content is published and installed; the marketing plugin asks
|
|
25
27
|
* the kill question on the send path; the console renders the assurance.
|
|
26
28
|
* Three readings of "is this template safe to mail" is the failure mode this
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/email-starter-policy.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 * What a marketplace email starter may contain, and whether one may still be\n * mailed.\n *\n * In core rather than beside the rest of the marketplace model for the reason\n * `marketplace-provenance` and `marketplace-verification` give: three\n * different projects have to ask these questions and a `scope:app` project may\n * not depend on an `aglyn:addons` lib. The marketplace plugin enforces the\n * policy when content is published and installed; the marketing plugin asks\n * the kill question on the send path; the console renders the assurance.\n * Three readings of \"is this template safe to mail\" is the failure mode this\n * module exists to prevent.\n *\n * ## Why an email design needs a policy the page allowlist does not supply\n *\n * A published page component is rendered by us, inside a document we serve,\n * behind our CSP. A published EMAIL design is rendered by the recipient's mail\n * client, out of our reach entirely, and it leaves on a sending domain whose\n * reputation every tenant shares. The two facts that follow are the whole\n * reason this file is stricter than `sanitizeMarketplaceDefinition`:\n *\n * - **A remote asset in an email is a read receipt.** The recipient's client\n * fetches every `src` on open, so whatever host the string names learns that\n * this person opened this message, at this time, from this IP. Naming a\n * host the PUBLISHER controls hands a third party a recipient-level\n * engagement feed on the tenant's audience — processing with no recorded\n * basis and no `assertedBy` provenance, which is exactly what the consent\n * architecture exists to make impossible. A 1×1 pixel is the deliberate form;\n * an ordinary hero image is the same disclosure with a picture attached.\n * - **A link is sent under the tenant's name.** A recipient reads the From\n * header, not the publisher's listing page.\n *\n * So `src` is held to first-party or inline, and `href` to a transport a\n * recipient's client will not silently downgrade. Violations are REFUSED\n * rather than stripped: `sanitizePublishedNodeProps` drops an unsafe URL and\n * publishes the rest, which is right for a page — a missing image is a\n * cosmetic defect — and wrong here, because the publisher whose tracker was\n * quietly removed learns nothing and tries again.\n */\n\nimport { isMediaCdnPath, isMediaRef, parseMediaRef } from './media-ref'\nimport { isListingWideRevocation, isPluginRevoked } from './plugin-manifest'\nimport type { PluginRevocation } from './plugin-manifest'\nimport { decodeStoredNodes } from './stored-nodes'\n\n/** Props that carry a URL into a recipient's mail client. */\nconst URL_PROPS = ['src', 'href'] as const\n\n/**\n * Props whose value is markup rather than text.\n *\n * `emailHtml` is already off `MARKETPLACE_EMAIL_COMPONENT_ID_ALLOWLIST`, so a\n * publish cannot mint one. This catches the same content arriving as a PROP on\n * a block that is on the list — a hand-written document, or a block that grows\n * an `html` prop later — which the component-id allowlist alone would pass.\n */\nconst MARKUP_PROPS = ['html', 'dangerouslySetInnerHTML'] as const\n\n/** Merge-tag opener. Recipient data must never reach a URL (see below). */\nconst MERGE_TAG_OPEN = '{{'\n\n/** Inline image data, which is inert and discloses nothing on open. */\nconst INLINE_IMAGE = /^data:image\\//i\n\n/** Link transports a recipient's client will not silently downgrade. */\nconst MAIL_SAFE_HREF = /^(https:\\/\\/|mailto:|\\/|#)/i\n\nexport type EmailStarterViolationCode =\n /** A `src` naming a host that is not ours — the read-receipt vector. */\n | 'remote-asset'\n /** An `href` on a transport we will not send under a tenant's name. */\n | 'unsafe-link'\n /** Recipient data heading for a URL. */\n | 'merge-tag-in-url'\n /** Markup we would have to trust rather than render. */\n | 'raw-markup'\n /** The node map could not be read, so nothing about it is known. */\n | 'unreadable'\n\nexport interface EmailStarterViolation {\n code: EmailStarterViolationCode\n /** The offending node's id, or `'nodes'` when the map itself is the fault. */\n nodeId: string\n componentId: string\n /** The prop that carried it, for a message that can be acted on. */\n prop: string\n /** Truncated: a violation is reported to a publisher, and often logged. */\n value: string\n}\n\nexport interface EmailStarterInspection {\n ok: boolean\n violations: EmailStarterViolation[]\n /**\n * Every host this design would send a recipient to, sorted and deduplicated.\n *\n * The inspectable half of the link policy. A tenant about to mail their\n * customers under their own From header is entitled to see the list of\n * domains a stranger's template points them at, and no allowlist can decide\n * that for them — a link to a legitimate retailer and a link to the\n * publisher's affiliate funnel are the same shape.\n */\n linkHosts: string[]\n /**\n * Media scopes belonging to somebody other than the installing site.\n *\n * Not a violation: the asset is served by our own CDN, so the publisher\n * learns nothing about the recipient, and refusing would make almost every\n * real template uninstallable. It is REPORTED because the reference stays\n * live after a copy-on-install — the publisher can still replace the bytes\n * behind it — which is the one way an installed design can change without\n * an update being taken.\n */\n foreignMediaScopes: string[]\n /** The decoded map, so a caller need not decode twice. Null when unreadable. */\n nodes: Record<string, unknown> | null\n}\n\nfunction truncate(value: unknown): string {\n const text = typeof value === 'string' ? value : String(value ?? '')\n return text.length > 120 ? `${text.slice(0, 117)}...` : text\n}\n\n/**\n * The host a link points at, or null for a same-origin or non-URL target.\n *\n * `URL` rather than a regex because the question is \"which host will the mail\n * client resolve this to\", and that is the parser's answer, not ours. A value\n * that will not parse is not reported as a host — it is already refused by the\n * transport check, and inventing a host for it would put a fiction on a screen\n * a tenant is using to make a decision.\n */\nfunction linkHost(value: string): string | null {\n if (!/^https?:\\/\\//i.test(value)) return null\n try {\n return new URL(value).host.toLowerCase() || null\n } catch {\n return null\n }\n}\n\n/**\n * Whether a decoded value is a node MAP rather than something that merely\n * survived decoding.\n *\n * The case this exists for is the AGL-1394 shape: `nodes` read raw off a\n * screen version is a msgpack `Buffer`, and `Object.entries` of a Buffer\n * yields byte INDICES paired with NUMBERS. Every check below asks a node for a\n * `props` object, finds none, and the walk completes reporting a clean design.\n * A scanner that cannot read its input must say so, not pass it.\n *\n * `decodeStoredNodes` handles the three storage forms, so anything still\n * failing here was never a node map to begin with.\n */\nfunction isNodeMap(value: unknown): value is Record<string, unknown> {\n if (!value || typeof value !== 'object' || Array.isArray(value)) return false\n const entries = Object.values(value as Record<string, unknown>)\n if (!entries.length) return false\n return entries.every(\n (entry) => Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry),\n )\n}\n\n/**\n * Reads a marketplace email design against the mail-client policy above.\n *\n * Takes `nodes` in whatever form the document stored it — a plain Firestore\n * map, compressed msgpack bytes, or the `{type:'Buffer'}` envelope an export\n * bundle carries — and decodes before walking, because a naive walk over the\n * compressed form finds nothing and reports the design clean.\n *\n * @param raw - the version document's `nodes` field, undecoded.\n * @param options - carries `hostId`, the site the design is being installed\n * onto, used only to classify media scopes as local or foreign. Omitted at\n * publish time, where there is no installing site yet.\n */\nexport function inspectEmailStarter(\n raw: unknown,\n options?: { hostId?: string | null },\n): EmailStarterInspection {\n const decoded = decodeStoredNodes<Record<string, unknown>>(raw)\n if (!isNodeMap(decoded)) {\n return {\n ok: false,\n violations: [\n {\n code: 'unreadable',\n nodeId: 'nodes',\n componentId: '',\n prop: 'nodes',\n value: decoded === null ? 'undecodable' : 'not a node map',\n },\n ],\n linkHosts: [],\n foreignMediaScopes: [],\n nodes: null,\n }\n }\n\n const violations: EmailStarterViolation[] = []\n const hosts = new Set<string>()\n const foreignScopes = new Set<string>()\n\n for (const [nodeId, value] of Object.entries(decoded)) {\n const node = value as { componentId?: unknown; props?: unknown }\n const componentId = String(node.componentId ?? '')\n const props =\n node.props && typeof node.props === 'object' && !Array.isArray(node.props)\n ? (node.props as Record<string, unknown>)\n : {}\n\n for (const prop of MARKUP_PROPS) {\n if (props[prop] === undefined) continue\n violations.push({\n code: 'raw-markup',\n nodeId,\n componentId,\n prop,\n value: truncate(props[prop]),\n })\n }\n\n for (const prop of URL_PROPS) {\n const value = props[prop]\n if (typeof value !== 'string' || !value) continue\n\n // Checked before the transport rules, and reported as its own code,\n // because the two failures need different sentences: an `http:` link is\n // a mistake to correct, and a merge tag in a URL is recipient data on\n // its way into a query string — a standing prohibition, and the reason\n // it is prohibited is that a URL is logged by every hop that serves it.\n if (value.includes(MERGE_TAG_OPEN)) {\n violations.push({\n code: 'merge-tag-in-url',\n nodeId,\n componentId,\n prop,\n value: truncate(value),\n })\n continue\n }\n\n if (prop === 'src') {\n if (isMediaRef(value)) {\n const ref = parseMediaRef(value)\n if (!ref) {\n violations.push({\n code: 'remote-asset',\n nodeId,\n componentId,\n prop,\n value: truncate(value),\n })\n continue\n }\n if (options?.hostId && !ref.scope.split(':').includes(options.hostId)) {\n foreignScopes.add(ref.scope)\n }\n continue\n }\n if (isMediaCdnPath(value) || INLINE_IMAGE.test(value)) continue\n violations.push({\n code: 'remote-asset',\n nodeId,\n componentId,\n prop,\n value: truncate(value),\n })\n continue\n }\n\n const host = linkHost(value)\n if (host) hosts.add(host)\n if (!MAIL_SAFE_HREF.test(value)) {\n violations.push({\n code: 'unsafe-link',\n nodeId,\n componentId,\n prop,\n value: truncate(value),\n })\n }\n }\n }\n\n return {\n ok: violations.length === 0,\n violations,\n linkHosts: [...hosts].sort(),\n foreignMediaScopes: [...foreignScopes].sort(),\n nodes: decoded,\n }\n}\n\n/** One sentence per violation code, in the second person. */\nconst VIOLATION_COPY: Record<EmailStarterViolationCode, string> = {\n 'remote-asset':\n 'images must come from your own media library or be inline — an image ' +\n 'loaded from another site tells that site who opened the email',\n 'unsafe-link':\n 'links must be https, mailto, or a path on the sending site',\n 'merge-tag-in-url':\n 'a merge tag may not appear in a link or image address — that puts ' +\n 'recipient data into a URL',\n 'raw-markup':\n 'raw HTML cannot be published as an email design; build it from email ' +\n 'blocks',\n unreadable: 'the design could not be read',\n}\n\n/**\n * A refusal message for a failed inspection, or `undefined` when it passed.\n *\n * One message naming ONE violation and its count, rather than a list: the\n * caller is an API route whose body is rendered in a dialog, and a publisher\n * fixing eleven images fixes them the same way they fix the first.\n */\nexport function emailStarterRefusal(\n inspection: EmailStarterInspection,\n): string | undefined {\n const [first] = inspection.violations\n if (!first) return undefined\n const more = inspection.violations.length - 1\n const at = first.componentId ? ` (on a ${first.componentId} block)` : ''\n return (\n `This email design cannot be published or installed: ` +\n `${VIOLATION_COPY[first.code]}${at}. Found \"${first.value}\"` +\n (more > 0 ? ` and ${more} more like it.` : '.')\n )\n}\n\n/**\n * How much is actually known about the version of an email starter that was\n * installed.\n *\n * `unreviewed` is a real state, not a missing one, and it is the DEFAULT.\n */\nexport type EmailStarterAssurance = 'approved' | 'unreviewed' | 'rejected'\n\n/**\n * The assurance of ONE version, read from that version's own document.\n *\n * Deliberately takes the version and nothing else. A listing carries\n * `reviewStatus: 'verified'` and a `latestVersionReviewState` mirror, and both\n * describe the listing or its newest build — neither is a statement about the\n * version somebody is installing right now. Passing the listing in here would\n * let version 7 inherit version 3's approval, which is the precise failure the\n * per-version model exists to prevent, and it would do it silently.\n *\n * An absent `reviewState` is `unreviewed`, never `approved`: an email starter\n * is auto-listed like every other copied artifact, so most versions have no\n * verdict at all and the honest answer is that nobody has looked.\n */\nexport function resolveEmailStarterAssurance(\n version: { reviewState?: unknown } | null | undefined,\n): EmailStarterAssurance {\n const state = String(version?.reviewState ?? '')\n if (state === 'approved') return 'approved'\n if (state === 'rejected') return 'rejected'\n return 'unreviewed'\n}\n\n/** What the console says about an assurance, so no surface invents its own. */\nexport function emailStarterAssuranceLabel(\n assurance: EmailStarterAssurance,\n): string {\n switch (assurance) {\n case 'approved':\n return 'Reviewed by Aglyn'\n case 'rejected':\n return 'Turned down in review'\n default:\n return 'Not reviewed'\n }\n}\n\n/** Why an installed email design may not be mailed. */\nexport interface EmailStarterSendBlock {\n code: 'killed'\n /** Rendered to the tenant; staff's reason when they recorded one. */\n reason: string\n}\n\n/**\n * Whether the kill switch stops this installed design from being SENT.\n *\n * ## What a kill has to reach, and why the other levers do not reach it\n *\n * An email starter is copied on install: the tree becomes a screen document in\n * the tenant's own site and nothing reads the listing again. That is the right\n * versioning model — a publisher's push must not rewrite an email a tenant is\n * mailing from — but it means every lever the marketplace already had stops at\n * the storefront. `deletedAt` unpublishes, `hiddenAt` blocks browse and blocks\n * the next install, and a rejected version was never installable. None of them\n * is felt by a tenant who installed last week and is sending today.\n *\n * So the kill switch is the one that has to reach through, and it is checked\n * HERE — at the send — rather than by deleting or rewriting the tenant's\n * document. Refusing the send and leaving the content alone is the same rule\n * capacity enforcement follows: a control that reaches a customer's own data\n * to enforce a decision about somebody else's artifact takes the wrong thing\n * away. The tenant keeps the design, keeps editing it, keeps previewing it;\n * what they cannot do is put it on the shared sending domain.\n *\n * Rejection is deliberately NOT a kill and must not become one here: a version\n * turned down in review was never installable, so nothing is running it, and\n * conflating the two would let a review verdict silently stop live mail.\n *\n * A listing-wide kill catches an install whose provenance never recorded a\n * version (everything installed before the provenance stamp). A per-version\n * kill cannot, and does not pretend to — `isPluginRevoked` answers false for\n * an empty version against a version list, which is the honest reading.\n */\nexport function emailStarterSendBlock(input: {\n /** The install stamp on the document being sent. */\n installedFrom: { listingId?: string | null; version?: string | null } | null | undefined\n /** `revocations/{listingId}`, or null when there is none. */\n revocation: PluginRevocation | null | undefined\n}): EmailStarterSendBlock | null {\n const listingId = input.installedFrom?.listingId\n if (!listingId || !input.revocation) return null\n const version = String(input.installedFrom?.version ?? '')\n const killed = version\n ? isPluginRevoked(input.revocation, version)\n : isListingWideRevocation(input.revocation)\n if (!killed) return null\n const reason = String(input.revocation.reason ?? '').trim()\n return {\n code: 'killed',\n reason:\n 'This email was installed from a marketplace template that Aglyn has ' +\n 'since stopped' +\n (reason ? `: ${reason}. ` : '. ') +\n 'Sending from it is blocked. The design is still here and still ' +\n 'editable, and a campaign built from your own design sends normally.',\n }\n}\n"],"names":["isMediaCdnPath","isMediaRef","parseMediaRef","isListingWideRevocation","isPluginRevoked","decodeStoredNodes","URL_PROPS","MARKUP_PROPS","MERGE_TAG_OPEN","INLINE_IMAGE","MAIL_SAFE_HREF","truncate","value","text","String","length","slice","linkHost","test","URL","host","toLowerCase","isNodeMap","Array","isArray","entries","Object","values","every","entry","Boolean","inspectEmailStarter","raw","options","decoded","ok","violations","code","nodeId","componentId","prop","linkHosts","foreignMediaScopes","nodes","hosts","Set","foreignScopes","node","props","undefined","push","includes","ref","hostId","scope","split","add","sort","VIOLATION_COPY","unreadable","emailStarterRefusal","inspection","first","more","at","resolveEmailStarterAssurance","version","state","reviewState","emailStarterAssuranceLabel","assurance","emailStarterSendBlock","input","listingId","installedFrom","revocation","killed","reason","trim"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/email-starter-policy.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 * What a marketplace email starter may contain, and whether one may still be\n * mailed.\n *\n * In core rather than beside the rest of the marketplace model for the reason\n * `marketplace-provenance` gives: three different projects have to ask these\n * questions and a `scope:app` project may not depend on an `aglyn:addons` lib.\n * `marketplace-verification` used to be cited here too and no longer can be —\n * its console reader became the plugin's own surface, so the policy went with\n * it (AGL-3080). A shared reason is only shared while both halves still hold. The marketplace plugin enforces the\n * policy when content is published and installed; the marketing plugin asks\n * the kill question on the send path; the console renders the assurance.\n * Three readings of \"is this template safe to mail\" is the failure mode this\n * module exists to prevent.\n *\n * ## Why an email design needs a policy the page allowlist does not supply\n *\n * A published page component is rendered by us, inside a document we serve,\n * behind our CSP. A published EMAIL design is rendered by the recipient's mail\n * client, out of our reach entirely, and it leaves on a sending domain whose\n * reputation every tenant shares. The two facts that follow are the whole\n * reason this file is stricter than `sanitizeMarketplaceDefinition`:\n *\n * - **A remote asset in an email is a read receipt.** The recipient's client\n * fetches every `src` on open, so whatever host the string names learns that\n * this person opened this message, at this time, from this IP. Naming a\n * host the PUBLISHER controls hands a third party a recipient-level\n * engagement feed on the tenant's audience — processing with no recorded\n * basis and no `assertedBy` provenance, which is exactly what the consent\n * architecture exists to make impossible. A 1×1 pixel is the deliberate form;\n * an ordinary hero image is the same disclosure with a picture attached.\n * - **A link is sent under the tenant's name.** A recipient reads the From\n * header, not the publisher's listing page.\n *\n * So `src` is held to first-party or inline, and `href` to a transport a\n * recipient's client will not silently downgrade. Violations are REFUSED\n * rather than stripped: `sanitizePublishedNodeProps` drops an unsafe URL and\n * publishes the rest, which is right for a page — a missing image is a\n * cosmetic defect — and wrong here, because the publisher whose tracker was\n * quietly removed learns nothing and tries again.\n */\n\nimport { isMediaCdnPath, isMediaRef, parseMediaRef } from './media-ref'\nimport { isListingWideRevocation, isPluginRevoked } from './plugin-manifest'\nimport type { PluginRevocation } from './plugin-manifest'\nimport { decodeStoredNodes } from './stored-nodes'\n\n/** Props that carry a URL into a recipient's mail client. */\nconst URL_PROPS = ['src', 'href'] as const\n\n/**\n * Props whose value is markup rather than text.\n *\n * `emailHtml` is already off `MARKETPLACE_EMAIL_COMPONENT_ID_ALLOWLIST`, so a\n * publish cannot mint one. This catches the same content arriving as a PROP on\n * a block that is on the list — a hand-written document, or a block that grows\n * an `html` prop later — which the component-id allowlist alone would pass.\n */\nconst MARKUP_PROPS = ['html', 'dangerouslySetInnerHTML'] as const\n\n/** Merge-tag opener. Recipient data must never reach a URL (see below). */\nconst MERGE_TAG_OPEN = '{{'\n\n/** Inline image data, which is inert and discloses nothing on open. */\nconst INLINE_IMAGE = /^data:image\\//i\n\n/** Link transports a recipient's client will not silently downgrade. */\nconst MAIL_SAFE_HREF = /^(https:\\/\\/|mailto:|\\/|#)/i\n\nexport type EmailStarterViolationCode =\n /** A `src` naming a host that is not ours — the read-receipt vector. */\n | 'remote-asset'\n /** An `href` on a transport we will not send under a tenant's name. */\n | 'unsafe-link'\n /** Recipient data heading for a URL. */\n | 'merge-tag-in-url'\n /** Markup we would have to trust rather than render. */\n | 'raw-markup'\n /** The node map could not be read, so nothing about it is known. */\n | 'unreadable'\n\nexport interface EmailStarterViolation {\n code: EmailStarterViolationCode\n /** The offending node's id, or `'nodes'` when the map itself is the fault. */\n nodeId: string\n componentId: string\n /** The prop that carried it, for a message that can be acted on. */\n prop: string\n /** Truncated: a violation is reported to a publisher, and often logged. */\n value: string\n}\n\nexport interface EmailStarterInspection {\n ok: boolean\n violations: EmailStarterViolation[]\n /**\n * Every host this design would send a recipient to, sorted and deduplicated.\n *\n * The inspectable half of the link policy. A tenant about to mail their\n * customers under their own From header is entitled to see the list of\n * domains a stranger's template points them at, and no allowlist can decide\n * that for them — a link to a legitimate retailer and a link to the\n * publisher's affiliate funnel are the same shape.\n */\n linkHosts: string[]\n /**\n * Media scopes belonging to somebody other than the installing site.\n *\n * Not a violation: the asset is served by our own CDN, so the publisher\n * learns nothing about the recipient, and refusing would make almost every\n * real template uninstallable. It is REPORTED because the reference stays\n * live after a copy-on-install — the publisher can still replace the bytes\n * behind it — which is the one way an installed design can change without\n * an update being taken.\n */\n foreignMediaScopes: string[]\n /** The decoded map, so a caller need not decode twice. Null when unreadable. */\n nodes: Record<string, unknown> | null\n}\n\nfunction truncate(value: unknown): string {\n const text = typeof value === 'string' ? value : String(value ?? '')\n return text.length > 120 ? `${text.slice(0, 117)}...` : text\n}\n\n/**\n * The host a link points at, or null for a same-origin or non-URL target.\n *\n * `URL` rather than a regex because the question is \"which host will the mail\n * client resolve this to\", and that is the parser's answer, not ours. A value\n * that will not parse is not reported as a host — it is already refused by the\n * transport check, and inventing a host for it would put a fiction on a screen\n * a tenant is using to make a decision.\n */\nfunction linkHost(value: string): string | null {\n if (!/^https?:\\/\\//i.test(value)) return null\n try {\n return new URL(value).host.toLowerCase() || null\n } catch {\n return null\n }\n}\n\n/**\n * Whether a decoded value is a node MAP rather than something that merely\n * survived decoding.\n *\n * The case this exists for is the AGL-1394 shape: `nodes` read raw off a\n * screen version is a msgpack `Buffer`, and `Object.entries` of a Buffer\n * yields byte INDICES paired with NUMBERS. Every check below asks a node for a\n * `props` object, finds none, and the walk completes reporting a clean design.\n * A scanner that cannot read its input must say so, not pass it.\n *\n * `decodeStoredNodes` handles the three storage forms, so anything still\n * failing here was never a node map to begin with.\n */\nfunction isNodeMap(value: unknown): value is Record<string, unknown> {\n if (!value || typeof value !== 'object' || Array.isArray(value)) return false\n const entries = Object.values(value as Record<string, unknown>)\n if (!entries.length) return false\n return entries.every(\n (entry) => Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry),\n )\n}\n\n/**\n * Reads a marketplace email design against the mail-client policy above.\n *\n * Takes `nodes` in whatever form the document stored it — a plain Firestore\n * map, compressed msgpack bytes, or the `{type:'Buffer'}` envelope an export\n * bundle carries — and decodes before walking, because a naive walk over the\n * compressed form finds nothing and reports the design clean.\n *\n * @param raw - the version document's `nodes` field, undecoded.\n * @param options - carries `hostId`, the site the design is being installed\n * onto, used only to classify media scopes as local or foreign. Omitted at\n * publish time, where there is no installing site yet.\n */\nexport function inspectEmailStarter(\n raw: unknown,\n options?: { hostId?: string | null },\n): EmailStarterInspection {\n const decoded = decodeStoredNodes<Record<string, unknown>>(raw)\n if (!isNodeMap(decoded)) {\n return {\n ok: false,\n violations: [\n {\n code: 'unreadable',\n nodeId: 'nodes',\n componentId: '',\n prop: 'nodes',\n value: decoded === null ? 'undecodable' : 'not a node map',\n },\n ],\n linkHosts: [],\n foreignMediaScopes: [],\n nodes: null,\n }\n }\n\n const violations: EmailStarterViolation[] = []\n const hosts = new Set<string>()\n const foreignScopes = new Set<string>()\n\n for (const [nodeId, value] of Object.entries(decoded)) {\n const node = value as { componentId?: unknown; props?: unknown }\n const componentId = String(node.componentId ?? '')\n const props =\n node.props && typeof node.props === 'object' && !Array.isArray(node.props)\n ? (node.props as Record<string, unknown>)\n : {}\n\n for (const prop of MARKUP_PROPS) {\n if (props[prop] === undefined) continue\n violations.push({\n code: 'raw-markup',\n nodeId,\n componentId,\n prop,\n value: truncate(props[prop]),\n })\n }\n\n for (const prop of URL_PROPS) {\n const value = props[prop]\n if (typeof value !== 'string' || !value) continue\n\n // Checked before the transport rules, and reported as its own code,\n // because the two failures need different sentences: an `http:` link is\n // a mistake to correct, and a merge tag in a URL is recipient data on\n // its way into a query string — a standing prohibition, and the reason\n // it is prohibited is that a URL is logged by every hop that serves it.\n if (value.includes(MERGE_TAG_OPEN)) {\n violations.push({\n code: 'merge-tag-in-url',\n nodeId,\n componentId,\n prop,\n value: truncate(value),\n })\n continue\n }\n\n if (prop === 'src') {\n if (isMediaRef(value)) {\n const ref = parseMediaRef(value)\n if (!ref) {\n violations.push({\n code: 'remote-asset',\n nodeId,\n componentId,\n prop,\n value: truncate(value),\n })\n continue\n }\n if (options?.hostId && !ref.scope.split(':').includes(options.hostId)) {\n foreignScopes.add(ref.scope)\n }\n continue\n }\n if (isMediaCdnPath(value) || INLINE_IMAGE.test(value)) continue\n violations.push({\n code: 'remote-asset',\n nodeId,\n componentId,\n prop,\n value: truncate(value),\n })\n continue\n }\n\n const host = linkHost(value)\n if (host) hosts.add(host)\n if (!MAIL_SAFE_HREF.test(value)) {\n violations.push({\n code: 'unsafe-link',\n nodeId,\n componentId,\n prop,\n value: truncate(value),\n })\n }\n }\n }\n\n return {\n ok: violations.length === 0,\n violations,\n linkHosts: [...hosts].sort(),\n foreignMediaScopes: [...foreignScopes].sort(),\n nodes: decoded,\n }\n}\n\n/** One sentence per violation code, in the second person. */\nconst VIOLATION_COPY: Record<EmailStarterViolationCode, string> = {\n 'remote-asset':\n 'images must come from your own media library or be inline — an image ' +\n 'loaded from another site tells that site who opened the email',\n 'unsafe-link':\n 'links must be https, mailto, or a path on the sending site',\n 'merge-tag-in-url':\n 'a merge tag may not appear in a link or image address — that puts ' +\n 'recipient data into a URL',\n 'raw-markup':\n 'raw HTML cannot be published as an email design; build it from email ' +\n 'blocks',\n unreadable: 'the design could not be read',\n}\n\n/**\n * A refusal message for a failed inspection, or `undefined` when it passed.\n *\n * One message naming ONE violation and its count, rather than a list: the\n * caller is an API route whose body is rendered in a dialog, and a publisher\n * fixing eleven images fixes them the same way they fix the first.\n */\nexport function emailStarterRefusal(\n inspection: EmailStarterInspection,\n): string | undefined {\n const [first] = inspection.violations\n if (!first) return undefined\n const more = inspection.violations.length - 1\n const at = first.componentId ? ` (on a ${first.componentId} block)` : ''\n return (\n `This email design cannot be published or installed: ` +\n `${VIOLATION_COPY[first.code]}${at}. Found \"${first.value}\"` +\n (more > 0 ? ` and ${more} more like it.` : '.')\n )\n}\n\n/**\n * How much is actually known about the version of an email starter that was\n * installed.\n *\n * `unreviewed` is a real state, not a missing one, and it is the DEFAULT.\n */\nexport type EmailStarterAssurance = 'approved' | 'unreviewed' | 'rejected'\n\n/**\n * The assurance of ONE version, read from that version's own document.\n *\n * Deliberately takes the version and nothing else. A listing carries\n * `reviewStatus: 'verified'` and a `latestVersionReviewState` mirror, and both\n * describe the listing or its newest build — neither is a statement about the\n * version somebody is installing right now. Passing the listing in here would\n * let version 7 inherit version 3's approval, which is the precise failure the\n * per-version model exists to prevent, and it would do it silently.\n *\n * An absent `reviewState` is `unreviewed`, never `approved`: an email starter\n * is auto-listed like every other copied artifact, so most versions have no\n * verdict at all and the honest answer is that nobody has looked.\n */\nexport function resolveEmailStarterAssurance(\n version: { reviewState?: unknown } | null | undefined,\n): EmailStarterAssurance {\n const state = String(version?.reviewState ?? '')\n if (state === 'approved') return 'approved'\n if (state === 'rejected') return 'rejected'\n return 'unreviewed'\n}\n\n/** What the console says about an assurance, so no surface invents its own. */\nexport function emailStarterAssuranceLabel(\n assurance: EmailStarterAssurance,\n): string {\n switch (assurance) {\n case 'approved':\n return 'Reviewed by Aglyn'\n case 'rejected':\n return 'Turned down in review'\n default:\n return 'Not reviewed'\n }\n}\n\n/** Why an installed email design may not be mailed. */\nexport interface EmailStarterSendBlock {\n code: 'killed'\n /** Rendered to the tenant; staff's reason when they recorded one. */\n reason: string\n}\n\n/**\n * Whether the kill switch stops this installed design from being SENT.\n *\n * ## What a kill has to reach, and why the other levers do not reach it\n *\n * An email starter is copied on install: the tree becomes a screen document in\n * the tenant's own site and nothing reads the listing again. That is the right\n * versioning model — a publisher's push must not rewrite an email a tenant is\n * mailing from — but it means every lever the marketplace already had stops at\n * the storefront. `deletedAt` unpublishes, `hiddenAt` blocks browse and blocks\n * the next install, and a rejected version was never installable. None of them\n * is felt by a tenant who installed last week and is sending today.\n *\n * So the kill switch is the one that has to reach through, and it is checked\n * HERE — at the send — rather than by deleting or rewriting the tenant's\n * document. Refusing the send and leaving the content alone is the same rule\n * capacity enforcement follows: a control that reaches a customer's own data\n * to enforce a decision about somebody else's artifact takes the wrong thing\n * away. The tenant keeps the design, keeps editing it, keeps previewing it;\n * what they cannot do is put it on the shared sending domain.\n *\n * Rejection is deliberately NOT a kill and must not become one here: a version\n * turned down in review was never installable, so nothing is running it, and\n * conflating the two would let a review verdict silently stop live mail.\n *\n * A listing-wide kill catches an install whose provenance never recorded a\n * version (everything installed before the provenance stamp). A per-version\n * kill cannot, and does not pretend to — `isPluginRevoked` answers false for\n * an empty version against a version list, which is the honest reading.\n */\nexport function emailStarterSendBlock(input: {\n /** The install stamp on the document being sent. */\n installedFrom: { listingId?: string | null; version?: string | null } | null | undefined\n /** `revocations/{listingId}`, or null when there is none. */\n revocation: PluginRevocation | null | undefined\n}): EmailStarterSendBlock | null {\n const listingId = input.installedFrom?.listingId\n if (!listingId || !input.revocation) return null\n const version = String(input.installedFrom?.version ?? '')\n const killed = version\n ? isPluginRevoked(input.revocation, version)\n : isListingWideRevocation(input.revocation)\n if (!killed) return null\n const reason = String(input.revocation.reason ?? '').trim()\n return {\n code: 'killed',\n reason:\n 'This email was installed from a marketplace template that Aglyn has ' +\n 'since stopped' +\n (reason ? `: ${reason}. ` : '. ') +\n 'Sending from it is blocked. The design is still here and still ' +\n 'editable, and a campaign built from your own design sends normally.',\n }\n}\n"],"names":["isMediaCdnPath","isMediaRef","parseMediaRef","isListingWideRevocation","isPluginRevoked","decodeStoredNodes","URL_PROPS","MARKUP_PROPS","MERGE_TAG_OPEN","INLINE_IMAGE","MAIL_SAFE_HREF","truncate","value","text","String","length","slice","linkHost","test","URL","host","toLowerCase","isNodeMap","Array","isArray","entries","Object","values","every","entry","Boolean","inspectEmailStarter","raw","options","decoded","ok","violations","code","nodeId","componentId","prop","linkHosts","foreignMediaScopes","nodes","hosts","Set","foreignScopes","node","props","undefined","push","includes","ref","hostId","scope","split","add","sort","VIOLATION_COPY","unreadable","emailStarterRefusal","inspection","first","more","at","resolveEmailStarterAssurance","version","state","reviewState","emailStarterAssuranceLabel","assurance","emailStarterSendBlock","input","listingId","installedFrom","revocation","killed","reason","trim"],"mappings":"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"}
|
|
@@ -65,6 +65,17 @@ export declare function notificationMuted(prefs: Record<string, boolean> | null
|
|
|
65
65
|
* notification and the email it travels with.
|
|
66
66
|
*/
|
|
67
67
|
export declare const DIGEST_PREFS_FIELD = "digestPrefs";
|
|
68
|
+
/**
|
|
69
|
+
* The key the daily CRM digest keeps inside {@link DIGEST_PREFS_FIELD}.
|
|
70
|
+
*
|
|
71
|
+
* Exported so the console writes the switch through a name rather than a
|
|
72
|
+
* second copy of the string (AGL-3230). The reader below and the page that
|
|
73
|
+
* flips it were spelling the same literal in two files, which is how a
|
|
74
|
+
* preference comes to be written under one key and read under another — and
|
|
75
|
+
* it kept the plugin's vocabulary in a console page, where
|
|
76
|
+
* `check:plugin-domain-in-core` rightly refuses it.
|
|
77
|
+
*/
|
|
78
|
+
export declare const CRM_DAILY_DIGEST_KEY = "crmDaily";
|
|
68
79
|
export declare function crmDailyDigestEnabled(prefs: Record<string, boolean> | null | undefined): boolean;
|
|
69
80
|
/**
|
|
70
81
|
* The field on `users/{uid}` naming the workspaces whose weekly insights a
|
|
@@ -96,8 +96,18 @@ export function notificationCategory(type) {
|
|
|
96
96
|
* drops, not a bucket of types, and one switch governs both the console
|
|
97
97
|
* notification and the email it travels with.
|
|
98
98
|
*/ export const DIGEST_PREFS_FIELD = 'digestPrefs';
|
|
99
|
+
/**
|
|
100
|
+
* The key the daily CRM digest keeps inside {@link DIGEST_PREFS_FIELD}.
|
|
101
|
+
*
|
|
102
|
+
* Exported so the console writes the switch through a name rather than a
|
|
103
|
+
* second copy of the string (AGL-3230). The reader below and the page that
|
|
104
|
+
* flips it were spelling the same literal in two files, which is how a
|
|
105
|
+
* preference comes to be written under one key and read under another — and
|
|
106
|
+
* it kept the plugin's vocabulary in a console page, where
|
|
107
|
+
* `check:plugin-domain-in-core` rightly refuses it.
|
|
108
|
+
*/ export const CRM_DAILY_DIGEST_KEY = 'crmDaily';
|
|
99
109
|
export function crmDailyDigestEnabled(prefs) {
|
|
100
|
-
return (prefs == null ? void 0 : prefs[
|
|
110
|
+
return (prefs == null ? void 0 : prefs[CRM_DAILY_DIGEST_KEY]) !== false;
|
|
101
111
|
}
|
|
102
112
|
/**
|
|
103
113
|
* The field on `users/{uid}` naming the workspaces whose weekly insights a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\n\n/**\n * In-app notifications (AGL-259): per-user docs at\n * `users/{uid}/notifications/{id}`, written by the Admin SDK (emitters in\n * the API routes + the `notifyAdmins` automation step) and read/marked by\n * their owner. Types follow the common SaaS taxonomy so the console can\n * icon/group them without a registry.\n */\nexport type AglynNotificationType =\n | 'billing.invoice'\n | 'billing.paymentFailed'\n // Stripe gave up on a failed renewal and CANCELLED the subscription\n // (AGL-1877). Distinct from `paymentFailed`, which announces one failed\n // attempt inside a retry window the customer can still recover from; this\n // one is the window having closed. Measured on the test account with a\n // test clock: five attempts over 21.08 days, then\n // `cancellation_details.reason: 'payment_failed'` — and until this existed\n // the org went silently to Free at that moment, with the `past_due` banner\n // disappearing at exactly the instant the consequence arrived.\n | 'billing.subscriptionCanceled'\n | 'billing.usage'\n | 'team.invite'\n | 'team.roleChanged'\n | 'team.hostAccessGranted'\n | 'content.formSubmission'\n | 'content.booking'\n | 'content.order'\n | 'content.lowStock'\n // A CRM task was assigned to the recipient by somebody else (AGL-2599).\n // `content.` because it is the same kind of message as a form submission\n // or a booking: a thing on the site that now needs this person's hands,\n // not a change to their standing (`team.`) and not a platform notice\n // (`system.`). Someone who has muted the operational stream has said they\n // do not want to be told about work as it arrives, and a task is work.\n | 'content.taskAssigned'\n // A CRM task's reminder came due (AGL-2659): the hourly runner telling\n // the assignee, at the task's own time, that the task is due. `content.`\n // beside `taskAssigned` for the same reason it gives — work on the site,\n // not standing — and so the one mute that stops \"work arriving\" stops\n // \"work falling due\" with it. The mute governs the whole reminder, mail\n // included, unlike the digest's: a reminder is one message about one\n // task, and there is no schedule to keep separately from it.\n | 'content.taskReminder'\n // A contact, or a lead, became the recipient's to work (AGL-2618): a\n // capture the assignment rules or the site's default owner routed to\n // them, a lead somebody converted and handed to them, or an automation's\n // \"assign an owner\" step — every server path that writes `ownerUid`\n // except the recipient assigning themselves. Two types rather than one\n // because the record the person opens differs: a lead is the site's own\n // working record and a contact is the org's, and the link goes to the\n // one they will actually work. `content.`, beside `taskAssigned`, for the\n // same reason: work arriving, not standing changing.\n | 'content.contactAssigned'\n | 'content.leadAssigned'\n // The morning's CRM digest (AGL-2619): what the recipient owes today —\n // overdue and due-today tasks, and the leads nobody has worked. `content.`\n // for the reason `taskAssigned` is: it is about work on the site, and the\n // person who muted the operational stream has asked not to be told about\n // work. The digest EMAIL is governed separately, by\n // `crmDailyDigestEnabled`: the mute is a fact about the console feed and\n // the digest switch is a fact about the digest.\n | 'content.crmDailyDigest'\n // The weekly insights (AGL-2915): what a site's figures showed that week,\n // for a person who asked for them. `content.` beside the CRM digest, for the\n // reason that one gives: it is about the site's work, and the operational\n // mute governs the console notification while the person's own switch for\n // the digest governs the digest.\n | 'content.insightsDigest'\n // Marketplace review verdicts (AGL-432/653).\n | 'marketplace.review'\n // Support desk, staff audience (AGL-850): a subscriber opened or replied to\n // a ticket. Fanned out to staff-claim holders, not org members.\n | 'support.ticketOpened'\n | 'support.ticketReply'\n | 'system.announcement'\n // A live plugin version stopped passing the static verifier (AGL-1086).\n // Staff audience: bytes we told workspaces were checked now fail checks\n // that did not exist when they were approved.\n //\n // Deliberately NOT under `marketplace.` (AGL-1088). Category is the prefix,\n // categories are mutable per user, and Marketplace is the category a staff\n // member mutes to stop routine listing-review chatter — which would drop\n // this alert as collateral. `system` is the bucket nobody mutes to reduce\n // noise. The adminAudit record survives a mute either way; the timeliness\n // does not, and timeliness is the whole point of the alert.\n | 'system.pluginVerifierRegression'\n // A sign-in method was removed from the user's own account because their\n // organization turned on SSO enforcement (AGL-1129). `system.`, not\n // `team.`, for the AGL-1088 reason above: `team` is the category someone\n // mutes to stop routine roster chatter, and \"the way you sign in just\n // changed\" is not chatter — the next sign-in fails without it.\n | 'system.signInMethodRemoved'\n // Documents in a scoped collection carrying no `visibleTo` (AGL-1478).\n // Staff audience: the weekly dry run found resources that are invisible\n // to every site-scoped read, which always means a creation path forgot\n // the field. `system.` for the AGL-1088 reason above, and because the\n // collection it names may be `marketplace`-adjacent or not.\n | 'system.scopeDrift'\n // An SSO domain that WAS DNS-proven has stopped answering with our\n // challenge record, for three consecutive weekly sweeps (AGL-1210).\n // Nothing has been turned off — sign-in for that domain still routes\n // exactly as it did — and that is precisely why this has to be said out\n // loud rather than left in a log.\n //\n // `system.`, not `team.`, for the AGL-1088 reason above and one of its\n // own: the audience is the org's own admins, the subject is whether their\n // people can still sign in, and it must reach them BEFORE anybody decides\n // to revoke the routing. A category somebody mutes to quieten roster\n // chatter is the wrong place for the one warning that precedes a lockout.\n | 'system.ssoDomainUnverified'\n // A site hit the per-month form-submission abuse ceiling and further\n // submissions are being refused (AGL-1655). `system.`, not `content.`, for\n // the AGL-1088 reason above and more sharply than any of them: `content` is\n // literally the category a site owner mutes to stop routine form-submission\n // chatter, and this is the one form notification that says the form has\n // STOPPED accepting. Filing it under the muted bucket would guarantee it\n // reaches nobody on exactly the sites busy enough to trip it.\n | 'system.formSubmissionsPaused'\n // A site reached the flat platform ceiling on member accounts or on lead\n // records, so further ones are being refused (AGL-1529). `system.` for the\n // same reason as `formSubmissionsPaused` directly above, and with the same\n // sharpness: `content.` is the bucket an owner mutes to stop routine\n // sign-up and lead chatter, and this is the one notification saying the\n // sign-ups have STOPPED — muted exactly on the sites busy enough to trip it.\n | 'system.visitorRecordsPaused'\n // An outsider reported one of our sites for phishing, malware or CSAM\n // (AGL-1964). `system` for the AGL-1088 reason and, again, more sharply\n // than most: this notification goes to STAFF, not to a customer, and its\n // subject is somebody else's site. There is no bucket a recipient could\n // mute it into that would be honest — nobody has opted into being told a\n // stranger is being phished through our platform, and nobody should be able\n // to opt out of it either.\n //\n // Only the urgent categories raise one, and only on a first report. See the\n // fan-out in apps/tenant/app/api/report-abuse/route.ts for why: a flood of\n // alerts IS the flood, and the alert it would cost us is the phishing one.\n | 'system.abuseReportUrgent'\n // The §512(g) counter-notice (AGL-1983), and the one place the \"only urgent\n // categories raise a notification\" restraint above is deliberately not\n // applied. Every counter-notice carries a statutory deadline that is\n // ALREADY RUNNING when it arrives — the clock counts from the subscriber's\n // submission, not from our attention — so there is no low-value tail of\n // these to drown out the important ones, and the one that goes unread is a\n // customer locked out of their own work plus an unmet obligation under\n // §512(g)(2)(A). Raised on first submission only, like the one above, so a\n // resubmitting customer cannot re-alert.\n | 'system.dmcaCounterNotice'\n // A site crossed the per-month bandwidth abuse ceiling (AGL-2155). Two\n // audiences share one type: staff, because an uncompensated free site\n // serving six figures of page views is an incident; and the site's own\n // managers, because on free the site is now serving a capped notice and\n // nobody should learn that from a visitor.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this meter: on the free plan there is no bill, so `billing` would be the\n // one category where the message is literally never about money — and it is\n // exactly the free-tier trip that changes what visitors see.\n | 'system.bandwidthCeilingTripped'\n // A free site crossed its PLAN's included bandwidth and is now serving the\n // capped notice (AGL-2413). Distinct from the ceiling above on purpose: that\n // one is an incident at 10x the band and goes to staff first; this one is an\n // ordinary quota at 1x, concerns only the site's own managers, and its\n // remedy is an upgrade rather than an investigation. Sending both under one\n // type would tell an owner their traffic was being treated as suspected\n // abuse when it is simply a successful free site.\n | 'system.bandwidthCapEngaged'\n // A Stripe billing webhook threw AFTER its handlers had begun (AGL-2157),\n // so its side effects may be half applied and its idempotency claim is\n // being HELD — Stripe will not retry it. Staff audience: this is the one\n // failure on that route no automatic retry can make safe, because the\n // handlers behind it are not all idempotent, and a human has to reconcile.\n | 'system.billingWebhookHalfApplied'\n // A card dispute arrived that NOTHING in the platform claimed (AGL-2429):\n // no `platformRevenue` row, no storefront order, no marketplace purchase.\n // Staff audience, and the reason it needs a type of its own is that its\n // absence was the bug — the routine case (a storefront or marketplace\n // chargeback, which the plugins own) and the fault case were the same\n // silence from the route's side, so a dispute nobody handled looked\n // exactly like one somebody did.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this alert: `billing` is the category a recipient is most likely to have\n // muted as routine invoice traffic, and this is the one message on that\n // route where muting it means money moves with nobody looking.\n | 'system.disputeUnattributed'\n // Somebody created an account, and somebody created a workspace\n // (AGL-3225). Staff audience, and the only two types in the taxonomy whose\n // subject is the platform's own growth rather than anybody's work.\n //\n // `staff.`, and NOT `system.`, and the AGL-1088 note above is the reason\n // rather than an exception to it: `system` is the bucket nobody mutes to\n // reduce noise, which is exactly what makes it the wrong home for the two\n // routine, high-volume events in the product. A staff member must be able\n // to stop hearing about every sign-up without also dropping the verifier\n // regression and the unattributed dispute that share that bucket — and\n // before this category existed, there was nowhere to put them where that\n // was true.\n //\n // The category also tells the settings page who a row is for: `staff` is\n // rendered only to claim holders, so no customer is shown a switch for\n // notifications they could never receive.\n | 'staff.userSignedUp'\n | 'staff.orgCreated'\n\nexport interface AglynNotification {\n $id?: string\n type: AglynNotificationType\n title: string\n body?: string\n /** Console path the notification opens (e.g. a host inbox). */\n link?: string\n orgId?: string\n hostId?: string\n createdAt?: ITimestamp\n /** Set by the owner when read; unread while absent. */\n readAt?: ITimestamp | null\n}\n\nexport const NOTIFICATION_TYPE_LABELS: Record<AglynNotificationType, string> =\n {\n 'billing.invoice': 'Invoice available',\n 'billing.paymentFailed': 'Payment failed',\n 'billing.subscriptionCanceled': 'Subscription canceled',\n 'billing.usage': 'Usage threshold',\n 'team.invite': 'Team invite',\n 'team.roleChanged': 'Role changed',\n 'team.hostAccessGranted': 'Site access granted',\n 'content.formSubmission': 'Form submission',\n 'content.booking': 'New booking',\n 'content.order': 'New order',\n 'content.lowStock': 'Low stock',\n 'content.taskAssigned': 'Task assigned to you',\n 'content.taskReminder': 'Task reminder',\n 'content.contactAssigned': 'Contact assigned to you',\n 'content.leadAssigned': 'Lead assigned to you',\n 'content.crmDailyDigest': 'Daily CRM digest',\n 'content.insightsDigest': 'Weekly insights',\n 'marketplace.review': 'Listing review',\n\n 'support.ticketOpened': 'New support ticket',\n 'support.ticketReply': 'Support ticket reply',\n 'system.announcement': 'Announcement',\n 'system.pluginVerifierRegression': 'Plugin verifier regression',\n 'system.signInMethodRemoved': 'Sign-in method removed',\n 'system.scopeDrift': 'Resources missing a sharing scope',\n 'system.ssoDomainUnverified': 'SSO domain no longer proves ownership',\n 'system.formSubmissionsPaused': 'Form submissions paused',\n 'system.visitorRecordsPaused': 'Sign-ups or leads paused',\n 'system.abuseReportUrgent': 'Urgent abuse report',\n 'system.dmcaCounterNotice': 'DMCA counter-notice',\n 'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',\n 'system.bandwidthCapEngaged': 'Monthly traffic limit reached',\n 'system.billingWebhookHalfApplied': 'Billing webhook half applied',\n 'system.disputeUnattributed': 'Card dispute with no owner',\n 'staff.userSignedUp': 'New account',\n 'staff.orgCreated': 'New workspace',\n }\n\n/** Preference buckets (AGL-267): the prefix before the dot. */\nexport type NotificationCategory =\n | 'billing'\n | 'team'\n | 'content'\n | 'marketplace'\n | 'support'\n | 'system'\n // Staff-only, and shown only to staff (AGL-3225) — see\n // {@link STAFF_NOTIFICATION_CATEGORIES}.\n | 'staff'\n\nexport const NOTIFICATION_CATEGORY_LABELS: Record<\n NotificationCategory,\n string\n> = {\n billing: 'Billing',\n team: 'Team & access',\n content: 'Forms & bookings',\n marketplace: 'Marketplace',\n support: 'Support',\n system: 'Product & system',\n staff: 'Platform growth',\n}\n\n/**\n * The categories only staff can receive (AGL-3225).\n *\n * The settings page hides these rows from everybody else, because a switch\n * for mail that can never arrive is a promise the product does not keep. It\n * is presentation only: `notifyStaff` is what decides the audience, and it\n * enumerates the `staff` claim rather than reading this.\n */\nexport const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory> =\n new Set<NotificationCategory>(['staff'])\n\nexport function notificationCategory(\n type: AglynNotificationType | string,\n): NotificationCategory {\n const prefix = String(type).split('.')[0]\n return (\n [\n 'billing',\n 'team',\n 'content',\n 'marketplace',\n 'support',\n 'system',\n 'staff',\n ].includes(prefix)\n ? prefix\n : 'system'\n ) as NotificationCategory\n}\n\n/**\n * Per-user mute map stored at `users/{uid}.notificationPrefs`\n * (`{ [category]: false }` mutes); absent categories stay on.\n */\nexport function notificationMuted(\n prefs: Record<string, boolean> | null | undefined,\n type: AglynNotificationType | string,\n): boolean {\n return prefs?.[notificationCategory(type)] === false\n}\n\n/**\n * The field on `users/{uid}` that holds a person's digest switches\n * (AGL-2619): `{ crmDaily: false }` turns the daily CRM digest off, and an\n * absent key leaves it on. Its own map rather than a key in\n * `notificationPrefs`, because that map is keyed by CATEGORY and read by\n * {@link notificationMuted} — a digest is a schedule a person keeps or\n * drops, not a bucket of types, and one switch governs both the console\n * notification and the email it travels with.\n */\nexport const DIGEST_PREFS_FIELD = 'digestPrefs'\n\nexport function crmDailyDigestEnabled(\n prefs: Record<string, boolean> | null | undefined,\n): boolean {\n return prefs?.['crmDaily'] !== false\n}\n\n/**\n * The field on `users/{uid}` naming the workspaces whose weekly insights a\n * person asked for (AGL-2915): `{ [orgId]: true }`. OPT-IN, unlike the CRM\n * digest — an absent key is off — because a weekly insight is generated, and a\n * generation spends the workspace's credits. The person turns it on beside the\n * answers themselves, where the plan, the release and their permission have\n * already been checked, and off again there or in Notifications; the weekly\n * sweep checks all three again before it spends anything.\n */\nexport const INSIGHT_DIGESTS_FIELD = 'insightDigests'\n\n/** Whether a person asked for a workspace's weekly insights. */\nexport function insightDigestSubscribed(\n value: Record<string, boolean> | null | undefined,\n orgId: string,\n): boolean {\n return Boolean(orgId) && value?.[orgId] === true\n}\n\n/**\n * The channels a notification can travel on (AGL-3223).\n *\n * `console` is the feed at `/manage/notifications` and the app-bar dropdown\n * that reads it — the only channel that existed. `email` is the message the\n * fan-out sends beside that doc when the recipient asked for one.\n */\nexport type NotificationChannel = 'console' | 'email'\n\n/**\n * One scope's answer for one category. **Tri-state on purpose**: `true` and\n * `false` decide, and an ABSENT key inherits from the scope above.\n *\n * Inheritance is the whole reason this is a partial rather than a pair of\n * booleans. A person who wants form submissions from one busy site and not\n * from the other five has to be able to say that about the one site without\n * restating their answer for every other category at every other scope — and\n * a two-valued leaf cannot express \"I have not said\", so every override would\n * have to be written out in full and would then stop tracking the account\n * default it was never meant to detach from.\n */\nexport interface NotificationChannelPrefs {\n console?: boolean\n email?: boolean\n}\n\nexport type NotificationCategoryPrefs = Partial<\n Record<NotificationCategory, NotificationChannelPrefs>\n>\n\n/**\n * Per-scope, per-channel notification preferences (AGL-3223), stored at\n * `users/{uid}.notificationSettings`.\n *\n * Three layers, narrowest first when resolving: the SITE a notification\n * concerns, then the WORKSPACE, then the account. Anything none of them\n * answers falls to {@link NOTIFICATION_CHANNEL_DEFAULTS}.\n *\n * ON THE USER DOCUMENT, not on `orgs/{orgId}/members/{uid}`, and that is a\n * cost decision rather than a modelling preference. `notifyUsers` already\n * does exactly one `getAll` over the recipients' user docs to read their\n * category mutes, so every layer living here means per-site preferences are\n * read for free on the fan-out's hot path. The member row would add a read\n * per recipient per notification to answer a question the document already in\n * hand could have answered.\n */\nexport interface NotificationSettings {\n account?: NotificationCategoryPrefs\n /** Keyed by org id. */\n orgs?: Record<string, NotificationCategoryPrefs>\n /** Keyed by host id — a site's own answer, narrower than its workspace's. */\n hosts?: Record<string, NotificationCategoryPrefs>\n}\n\nexport const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings'\n\n/**\n * What a category does when nobody has said otherwise.\n *\n * Console on, email OFF, everywhere. Email defaults off because the inbox is\n * not ours to fill: the product already sends transactional mail nobody opted\n * into — welcome, verification, invites, dunning, usage alerts — and every one\n * of those leaves on the same domain a customer's password reset depends on.\n * Turning a busy site's form submissions into mail by default would put that\n * domain's reputation behind traffic the recipient never asked for.\n *\n * Exhaustive `Record` deliberately: a new {@link NotificationCategory} is a\n * compile error here until somebody decides what it does by default, which is\n * the one question a new category must not be able to ship without answering.\n */\nexport const NOTIFICATION_CHANNEL_DEFAULTS: Record<\n NotificationCategory,\n Record<NotificationChannel, boolean>\n> = {\n billing: { console: true, email: false },\n team: { console: true, email: false },\n content: { console: true, email: false },\n marketplace: { console: true, email: false },\n support: { console: true, email: false },\n system: { console: true, email: false },\n // Console ON, because the complaint this answers is that staff never heard\n // about a sign-up at all; email off, like everything else, because that is\n // what the channel defaults to and a sign-up is not urgent enough to be the\n // exception that starts filling inboxes by default.\n staff: { console: true, email: false },\n}\n\n/**\n * The types that send their OWN email and must never be mailed again by the\n * generic channel (AGL-3224).\n *\n * Both digests compose a message the fan-out could not reproduce — a day's\n * owed tasks, a week's figures — and send it from their own route under their\n * own switch (`digestPrefs.crmDaily`, `insightDigests.{orgId}`). A recipient\n * who switches the `content` email channel on would otherwise receive the\n * digest twice: once as the digest, once as a one-line \"Daily CRM digest\"\n * notification saying that the digest happened.\n */\nexport const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string> = new Set<\n AglynNotificationType\n>(['content.crmDailyDigest', 'content.insightsDigest'])\n\n/**\n * Whether a channel is on for one notification, at its own scope.\n *\n * Resolution order, first answer wins: the notification's SITE, its\n * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope\n * that holds no entry for the category, or an entry with the channel key\n * absent, does not answer — see {@link NotificationChannelPrefs}.\n *\n * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces\n * (AGL-267), and it sits BELOW the account layer rather than beside it.\n * Nothing migrates it: a person who never opens the new settings page keeps\n * being governed by the mutes they set years ago, and the first thing they do\n * set on the account layer overrides the old map for that category without\n * disturbing the rest of it. It answers for `console` only, because the map\n * predates there being a second channel and reading a console mute as an\n * email preference would be inventing an answer its author never gave.\n */\nexport function notificationChannelEnabled(\n settings: NotificationSettings | null | undefined,\n channel: NotificationChannel,\n type: AglynNotificationType | string,\n scope?: { orgId?: string | null; hostId?: string | null },\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n const category = notificationCategory(type)\n const layers: Array<NotificationCategoryPrefs | undefined> = [\n scope?.hostId ? settings?.hosts?.[scope.hostId] : undefined,\n scope?.orgId ? settings?.orgs?.[scope.orgId] : undefined,\n settings?.account,\n ]\n for (const layer of layers) {\n const answer = layer?.[category]?.[channel]\n if (typeof answer === 'boolean') return answer\n }\n if (channel === 'console' && notificationMuted(legacyPrefs, type)) return false\n return NOTIFICATION_CHANNEL_DEFAULTS[category][channel]\n}\n\n/**\n * What one scope says, with no inheritance applied — what the settings page\n * renders as Inherit / On / Off, and `undefined` is Inherit.\n *\n * `scope` names the layer directly rather than being derived from a\n * notification, because the page edits a layer whether or not anything has\n * ever arrived from it.\n */\nexport function notificationScopePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n category: NotificationCategory,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.account\n : scope.kind === 'org'\n ? settings?.orgs?.[scope.id]\n : settings?.hosts?.[scope.id]\n return layer?.[category]?.[channel]\n}\n\n/**\n * The scopes this person has said something different about — what the\n * settings page lists so an override is never somewhere you have to go\n * looking for.\n *\n * Returns ids, not names: the page holds the org and host rosters and this\n * module holds no lookups. An id with no name is still worth listing, because\n * a preference about a workspace somebody left is exactly the kind of\n * leftover that is invisible until it is listed.\n */\nexport function notificationOverriddenScopes(\n settings: NotificationSettings | null | undefined,\n): { orgIds: string[]; hostIds: string[] } {\n const named = (layers: Record<string, NotificationCategoryPrefs> | undefined) =>\n Object.entries(layers ?? {})\n .filter(([, prefs]) =>\n Object.values(prefs ?? {}).some((channels) =>\n Object.values(channels ?? {}).some(\n (value) => typeof value === 'boolean',\n ),\n ),\n )\n .map(([id]) => id)\n .sort()\n return { orgIds: named(settings?.orgs), hostIds: named(settings?.hosts) }\n}\n"],"names":["NOTIFICATION_TYPE_LABELS","NOTIFICATION_CATEGORY_LABELS","billing","team","content","marketplace","support","system","staff","STAFF_NOTIFICATION_CATEGORIES","Set","notificationCategory","type","prefix","String","split","includes","notificationMuted","prefs","DIGEST_PREFS_FIELD","crmDailyDigestEnabled","INSIGHT_DIGESTS_FIELD","insightDigestSubscribed","value","orgId","Boolean","NOTIFICATION_SETTINGS_FIELD","NOTIFICATION_CHANNEL_DEFAULTS","console","email","NOTIFICATION_SELF_SENT_EMAIL_TYPES","notificationChannelEnabled","settings","channel","scope","legacyPrefs","category","layers","hostId","hosts","undefined","orgs","account","layer","answer","notificationScopePref","kind","id","notificationOverriddenScopes","named","Object","entries","filter","values","some","channels","map","sort","orgIds","hostIds"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\n\n/**\n * In-app notifications (AGL-259): per-user docs at\n * `users/{uid}/notifications/{id}`, written by the Admin SDK (emitters in\n * the API routes + the `notifyAdmins` automation step) and read/marked by\n * their owner. Types follow the common SaaS taxonomy so the console can\n * icon/group them without a registry.\n */\nexport type AglynNotificationType =\n | 'billing.invoice'\n | 'billing.paymentFailed'\n // Stripe gave up on a failed renewal and CANCELLED the subscription\n // (AGL-1877). Distinct from `paymentFailed`, which announces one failed\n // attempt inside a retry window the customer can still recover from; this\n // one is the window having closed. Measured on the test account with a\n // test clock: five attempts over 21.08 days, then\n // `cancellation_details.reason: 'payment_failed'` — and until this existed\n // the org went silently to Free at that moment, with the `past_due` banner\n // disappearing at exactly the instant the consequence arrived.\n | 'billing.subscriptionCanceled'\n | 'billing.usage'\n | 'team.invite'\n | 'team.roleChanged'\n | 'team.hostAccessGranted'\n | 'content.formSubmission'\n | 'content.booking'\n | 'content.order'\n | 'content.lowStock'\n // A CRM task was assigned to the recipient by somebody else (AGL-2599).\n // `content.` because it is the same kind of message as a form submission\n // or a booking: a thing on the site that now needs this person's hands,\n // not a change to their standing (`team.`) and not a platform notice\n // (`system.`). Someone who has muted the operational stream has said they\n // do not want to be told about work as it arrives, and a task is work.\n | 'content.taskAssigned'\n // A CRM task's reminder came due (AGL-2659): the hourly runner telling\n // the assignee, at the task's own time, that the task is due. `content.`\n // beside `taskAssigned` for the same reason it gives — work on the site,\n // not standing — and so the one mute that stops \"work arriving\" stops\n // \"work falling due\" with it. The mute governs the whole reminder, mail\n // included, unlike the digest's: a reminder is one message about one\n // task, and there is no schedule to keep separately from it.\n | 'content.taskReminder'\n // A contact, or a lead, became the recipient's to work (AGL-2618): a\n // capture the assignment rules or the site's default owner routed to\n // them, a lead somebody converted and handed to them, or an automation's\n // \"assign an owner\" step — every server path that writes `ownerUid`\n // except the recipient assigning themselves. Two types rather than one\n // because the record the person opens differs: a lead is the site's own\n // working record and a contact is the org's, and the link goes to the\n // one they will actually work. `content.`, beside `taskAssigned`, for the\n // same reason: work arriving, not standing changing.\n | 'content.contactAssigned'\n | 'content.leadAssigned'\n // The morning's CRM digest (AGL-2619): what the recipient owes today —\n // overdue and due-today tasks, and the leads nobody has worked. `content.`\n // for the reason `taskAssigned` is: it is about work on the site, and the\n // person who muted the operational stream has asked not to be told about\n // work. The digest EMAIL is governed separately, by\n // `crmDailyDigestEnabled`: the mute is a fact about the console feed and\n // the digest switch is a fact about the digest.\n | 'content.crmDailyDigest'\n // The weekly insights (AGL-2915): what a site's figures showed that week,\n // for a person who asked for them. `content.` beside the CRM digest, for the\n // reason that one gives: it is about the site's work, and the operational\n // mute governs the console notification while the person's own switch for\n // the digest governs the digest.\n | 'content.insightsDigest'\n // Marketplace review verdicts (AGL-432/653).\n | 'marketplace.review'\n // Support desk, staff audience (AGL-850): a subscriber opened or replied to\n // a ticket. Fanned out to staff-claim holders, not org members.\n | 'support.ticketOpened'\n | 'support.ticketReply'\n | 'system.announcement'\n // A live plugin version stopped passing the static verifier (AGL-1086).\n // Staff audience: bytes we told workspaces were checked now fail checks\n // that did not exist when they were approved.\n //\n // Deliberately NOT under `marketplace.` (AGL-1088). Category is the prefix,\n // categories are mutable per user, and Marketplace is the category a staff\n // member mutes to stop routine listing-review chatter — which would drop\n // this alert as collateral. `system` is the bucket nobody mutes to reduce\n // noise. The adminAudit record survives a mute either way; the timeliness\n // does not, and timeliness is the whole point of the alert.\n | 'system.pluginVerifierRegression'\n // A sign-in method was removed from the user's own account because their\n // organization turned on SSO enforcement (AGL-1129). `system.`, not\n // `team.`, for the AGL-1088 reason above: `team` is the category someone\n // mutes to stop routine roster chatter, and \"the way you sign in just\n // changed\" is not chatter — the next sign-in fails without it.\n | 'system.signInMethodRemoved'\n // Documents in a scoped collection carrying no `visibleTo` (AGL-1478).\n // Staff audience: the weekly dry run found resources that are invisible\n // to every site-scoped read, which always means a creation path forgot\n // the field. `system.` for the AGL-1088 reason above, and because the\n // collection it names may be `marketplace`-adjacent or not.\n | 'system.scopeDrift'\n // An SSO domain that WAS DNS-proven has stopped answering with our\n // challenge record, for three consecutive weekly sweeps (AGL-1210).\n // Nothing has been turned off — sign-in for that domain still routes\n // exactly as it did — and that is precisely why this has to be said out\n // loud rather than left in a log.\n //\n // `system.`, not `team.`, for the AGL-1088 reason above and one of its\n // own: the audience is the org's own admins, the subject is whether their\n // people can still sign in, and it must reach them BEFORE anybody decides\n // to revoke the routing. A category somebody mutes to quieten roster\n // chatter is the wrong place for the one warning that precedes a lockout.\n | 'system.ssoDomainUnverified'\n // A site hit the per-month form-submission abuse ceiling and further\n // submissions are being refused (AGL-1655). `system.`, not `content.`, for\n // the AGL-1088 reason above and more sharply than any of them: `content` is\n // literally the category a site owner mutes to stop routine form-submission\n // chatter, and this is the one form notification that says the form has\n // STOPPED accepting. Filing it under the muted bucket would guarantee it\n // reaches nobody on exactly the sites busy enough to trip it.\n | 'system.formSubmissionsPaused'\n // A site reached the flat platform ceiling on member accounts or on lead\n // records, so further ones are being refused (AGL-1529). `system.` for the\n // same reason as `formSubmissionsPaused` directly above, and with the same\n // sharpness: `content.` is the bucket an owner mutes to stop routine\n // sign-up and lead chatter, and this is the one notification saying the\n // sign-ups have STOPPED — muted exactly on the sites busy enough to trip it.\n | 'system.visitorRecordsPaused'\n // An outsider reported one of our sites for phishing, malware or CSAM\n // (AGL-1964). `system` for the AGL-1088 reason and, again, more sharply\n // than most: this notification goes to STAFF, not to a customer, and its\n // subject is somebody else's site. There is no bucket a recipient could\n // mute it into that would be honest — nobody has opted into being told a\n // stranger is being phished through our platform, and nobody should be able\n // to opt out of it either.\n //\n // Only the urgent categories raise one, and only on a first report. See the\n // fan-out in apps/tenant/app/api/report-abuse/route.ts for why: a flood of\n // alerts IS the flood, and the alert it would cost us is the phishing one.\n | 'system.abuseReportUrgent'\n // The §512(g) counter-notice (AGL-1983), and the one place the \"only urgent\n // categories raise a notification\" restraint above is deliberately not\n // applied. Every counter-notice carries a statutory deadline that is\n // ALREADY RUNNING when it arrives — the clock counts from the subscriber's\n // submission, not from our attention — so there is no low-value tail of\n // these to drown out the important ones, and the one that goes unread is a\n // customer locked out of their own work plus an unmet obligation under\n // §512(g)(2)(A). Raised on first submission only, like the one above, so a\n // resubmitting customer cannot re-alert.\n | 'system.dmcaCounterNotice'\n // A site crossed the per-month bandwidth abuse ceiling (AGL-2155). Two\n // audiences share one type: staff, because an uncompensated free site\n // serving six figures of page views is an incident; and the site's own\n // managers, because on free the site is now serving a capped notice and\n // nobody should learn that from a visitor.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this meter: on the free plan there is no bill, so `billing` would be the\n // one category where the message is literally never about money — and it is\n // exactly the free-tier trip that changes what visitors see.\n | 'system.bandwidthCeilingTripped'\n // A free site crossed its PLAN's included bandwidth and is now serving the\n // capped notice (AGL-2413). Distinct from the ceiling above on purpose: that\n // one is an incident at 10x the band and goes to staff first; this one is an\n // ordinary quota at 1x, concerns only the site's own managers, and its\n // remedy is an upgrade rather than an investigation. Sending both under one\n // type would tell an owner their traffic was being treated as suspected\n // abuse when it is simply a successful free site.\n | 'system.bandwidthCapEngaged'\n // A Stripe billing webhook threw AFTER its handlers had begun (AGL-2157),\n // so its side effects may be half applied and its idempotency claim is\n // being HELD — Stripe will not retry it. Staff audience: this is the one\n // failure on that route no automatic retry can make safe, because the\n // handlers behind it are not all idempotent, and a human has to reconcile.\n | 'system.billingWebhookHalfApplied'\n // A card dispute arrived that NOTHING in the platform claimed (AGL-2429):\n // no `platformRevenue` row, no storefront order, no marketplace purchase.\n // Staff audience, and the reason it needs a type of its own is that its\n // absence was the bug — the routine case (a storefront or marketplace\n // chargeback, which the plugins own) and the fault case were the same\n // silence from the route's side, so a dispute nobody handled looked\n // exactly like one somebody did.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this alert: `billing` is the category a recipient is most likely to have\n // muted as routine invoice traffic, and this is the one message on that\n // route where muting it means money moves with nobody looking.\n | 'system.disputeUnattributed'\n // Somebody created an account, and somebody created a workspace\n // (AGL-3225). Staff audience, and the only two types in the taxonomy whose\n // subject is the platform's own growth rather than anybody's work.\n //\n // `staff.`, and NOT `system.`, and the AGL-1088 note above is the reason\n // rather than an exception to it: `system` is the bucket nobody mutes to\n // reduce noise, which is exactly what makes it the wrong home for the two\n // routine, high-volume events in the product. A staff member must be able\n // to stop hearing about every sign-up without also dropping the verifier\n // regression and the unattributed dispute that share that bucket — and\n // before this category existed, there was nowhere to put them where that\n // was true.\n //\n // The category also tells the settings page who a row is for: `staff` is\n // rendered only to claim holders, so no customer is shown a switch for\n // notifications they could never receive.\n | 'staff.userSignedUp'\n | 'staff.orgCreated'\n\nexport interface AglynNotification {\n $id?: string\n type: AglynNotificationType\n title: string\n body?: string\n /** Console path the notification opens (e.g. a host inbox). */\n link?: string\n orgId?: string\n hostId?: string\n createdAt?: ITimestamp\n /** Set by the owner when read; unread while absent. */\n readAt?: ITimestamp | null\n}\n\nexport const NOTIFICATION_TYPE_LABELS: Record<AglynNotificationType, string> = {\n 'billing.invoice': 'Invoice available',\n 'billing.paymentFailed': 'Payment failed',\n 'billing.subscriptionCanceled': 'Subscription canceled',\n 'billing.usage': 'Usage threshold',\n 'team.invite': 'Team invite',\n 'team.roleChanged': 'Role changed',\n 'team.hostAccessGranted': 'Site access granted',\n 'content.formSubmission': 'Form submission',\n 'content.booking': 'New booking',\n 'content.order': 'New order',\n 'content.lowStock': 'Low stock',\n 'content.taskAssigned': 'Task assigned to you',\n 'content.taskReminder': 'Task reminder',\n 'content.contactAssigned': 'Contact assigned to you',\n 'content.leadAssigned': 'Lead assigned to you',\n 'content.crmDailyDigest': 'Daily CRM digest',\n 'content.insightsDigest': 'Weekly insights',\n 'marketplace.review': 'Listing review',\n\n 'support.ticketOpened': 'New support ticket',\n 'support.ticketReply': 'Support ticket reply',\n 'system.announcement': 'Announcement',\n 'system.pluginVerifierRegression': 'Plugin verifier regression',\n 'system.signInMethodRemoved': 'Sign-in method removed',\n 'system.scopeDrift': 'Resources missing a sharing scope',\n 'system.ssoDomainUnverified': 'SSO domain no longer proves ownership',\n 'system.formSubmissionsPaused': 'Form submissions paused',\n 'system.visitorRecordsPaused': 'Sign-ups or leads paused',\n 'system.abuseReportUrgent': 'Urgent abuse report',\n 'system.dmcaCounterNotice': 'DMCA counter-notice',\n 'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',\n 'system.bandwidthCapEngaged': 'Monthly traffic limit reached',\n 'system.billingWebhookHalfApplied': 'Billing webhook half applied',\n 'system.disputeUnattributed': 'Card dispute with no owner',\n 'staff.userSignedUp': 'New account',\n 'staff.orgCreated': 'New workspace',\n}\n\n/** Preference buckets (AGL-267): the prefix before the dot. */\nexport type NotificationCategory =\n | 'billing'\n | 'team'\n | 'content'\n | 'marketplace'\n | 'support'\n | 'system'\n // Staff-only, and shown only to staff (AGL-3225) — see\n // {@link STAFF_NOTIFICATION_CATEGORIES}.\n | 'staff'\n\nexport const NOTIFICATION_CATEGORY_LABELS: Record<\n NotificationCategory,\n string\n> = {\n billing: 'Billing',\n team: 'Team & access',\n content: 'Forms & bookings',\n marketplace: 'Marketplace',\n support: 'Support',\n system: 'Product & system',\n staff: 'Platform growth',\n}\n\n/**\n * The categories only staff can receive (AGL-3225).\n *\n * The settings page hides these rows from everybody else, because a switch\n * for mail that can never arrive is a promise the product does not keep. It\n * is presentation only: `notifyStaff` is what decides the audience, and it\n * enumerates the `staff` claim rather than reading this.\n */\nexport const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory> =\n new Set<NotificationCategory>(['staff'])\n\nexport function notificationCategory(\n type: AglynNotificationType | string,\n): NotificationCategory {\n const prefix = String(type).split('.')[0]\n return (\n [\n 'billing',\n 'team',\n 'content',\n 'marketplace',\n 'support',\n 'system',\n 'staff',\n ].includes(prefix)\n ? prefix\n : 'system'\n ) as NotificationCategory\n}\n\n/**\n * Per-user mute map stored at `users/{uid}.notificationPrefs`\n * (`{ [category]: false }` mutes); absent categories stay on.\n */\nexport function notificationMuted(\n prefs: Record<string, boolean> | null | undefined,\n type: AglynNotificationType | string,\n): boolean {\n return prefs?.[notificationCategory(type)] === false\n}\n\n/**\n * The field on `users/{uid}` that holds a person's digest switches\n * (AGL-2619): `{ crmDaily: false }` turns the daily CRM digest off, and an\n * absent key leaves it on. Its own map rather than a key in\n * `notificationPrefs`, because that map is keyed by CATEGORY and read by\n * {@link notificationMuted} — a digest is a schedule a person keeps or\n * drops, not a bucket of types, and one switch governs both the console\n * notification and the email it travels with.\n */\nexport const DIGEST_PREFS_FIELD = 'digestPrefs'\n\n/**\n * The key the daily CRM digest keeps inside {@link DIGEST_PREFS_FIELD}.\n *\n * Exported so the console writes the switch through a name rather than a\n * second copy of the string (AGL-3230). The reader below and the page that\n * flips it were spelling the same literal in two files, which is how a\n * preference comes to be written under one key and read under another — and\n * it kept the plugin's vocabulary in a console page, where\n * `check:plugin-domain-in-core` rightly refuses it.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\nexport function crmDailyDigestEnabled(\n prefs: Record<string, boolean> | null | undefined,\n): boolean {\n return prefs?.[CRM_DAILY_DIGEST_KEY] !== false\n}\n\n/**\n * The field on `users/{uid}` naming the workspaces whose weekly insights a\n * person asked for (AGL-2915): `{ [orgId]: true }`. OPT-IN, unlike the CRM\n * digest — an absent key is off — because a weekly insight is generated, and a\n * generation spends the workspace's credits. The person turns it on beside the\n * answers themselves, where the plan, the release and their permission have\n * already been checked, and off again there or in Notifications; the weekly\n * sweep checks all three again before it spends anything.\n */\nexport const INSIGHT_DIGESTS_FIELD = 'insightDigests'\n\n/** Whether a person asked for a workspace's weekly insights. */\nexport function insightDigestSubscribed(\n value: Record<string, boolean> | null | undefined,\n orgId: string,\n): boolean {\n return Boolean(orgId) && value?.[orgId] === true\n}\n\n/**\n * The channels a notification can travel on (AGL-3223).\n *\n * `console` is the feed at `/manage/notifications` and the app-bar dropdown\n * that reads it — the only channel that existed. `email` is the message the\n * fan-out sends beside that doc when the recipient asked for one.\n */\nexport type NotificationChannel = 'console' | 'email'\n\n/**\n * One scope's answer for one category. **Tri-state on purpose**: `true` and\n * `false` decide, and an ABSENT key inherits from the scope above.\n *\n * Inheritance is the whole reason this is a partial rather than a pair of\n * booleans. A person who wants form submissions from one busy site and not\n * from the other five has to be able to say that about the one site without\n * restating their answer for every other category at every other scope — and\n * a two-valued leaf cannot express \"I have not said\", so every override would\n * have to be written out in full and would then stop tracking the account\n * default it was never meant to detach from.\n */\nexport interface NotificationChannelPrefs {\n console?: boolean\n email?: boolean\n}\n\nexport type NotificationCategoryPrefs = Partial<\n Record<NotificationCategory, NotificationChannelPrefs>\n>\n\n/**\n * Per-scope, per-channel notification preferences (AGL-3223), stored at\n * `users/{uid}.notificationSettings`.\n *\n * Three layers, narrowest first when resolving: the SITE a notification\n * concerns, then the WORKSPACE, then the account. Anything none of them\n * answers falls to {@link NOTIFICATION_CHANNEL_DEFAULTS}.\n *\n * ON THE USER DOCUMENT, not on `orgs/{orgId}/members/{uid}`, and that is a\n * cost decision rather than a modelling preference. `notifyUsers` already\n * does exactly one `getAll` over the recipients' user docs to read their\n * category mutes, so every layer living here means per-site preferences are\n * read for free on the fan-out's hot path. The member row would add a read\n * per recipient per notification to answer a question the document already in\n * hand could have answered.\n */\nexport interface NotificationSettings {\n account?: NotificationCategoryPrefs\n /** Keyed by org id. */\n orgs?: Record<string, NotificationCategoryPrefs>\n /** Keyed by host id — a site's own answer, narrower than its workspace's. */\n hosts?: Record<string, NotificationCategoryPrefs>\n}\n\nexport const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings'\n\n/**\n * What a category does when nobody has said otherwise.\n *\n * Console on, email OFF, everywhere. Email defaults off because the inbox is\n * not ours to fill: the product already sends transactional mail nobody opted\n * into — welcome, verification, invites, dunning, usage alerts — and every one\n * of those leaves on the same domain a customer's password reset depends on.\n * Turning a busy site's form submissions into mail by default would put that\n * domain's reputation behind traffic the recipient never asked for.\n *\n * Exhaustive `Record` deliberately: a new {@link NotificationCategory} is a\n * compile error here until somebody decides what it does by default, which is\n * the one question a new category must not be able to ship without answering.\n */\nexport const NOTIFICATION_CHANNEL_DEFAULTS: Record<\n NotificationCategory,\n Record<NotificationChannel, boolean>\n> = {\n billing: { console: true, email: false },\n team: { console: true, email: false },\n content: { console: true, email: false },\n marketplace: { console: true, email: false },\n support: { console: true, email: false },\n system: { console: true, email: false },\n // Console ON, because the complaint this answers is that staff never heard\n // about a sign-up at all; email off, like everything else, because that is\n // what the channel defaults to and a sign-up is not urgent enough to be the\n // exception that starts filling inboxes by default.\n staff: { console: true, email: false },\n}\n\n/**\n * The types that send their OWN email and must never be mailed again by the\n * generic channel (AGL-3224).\n *\n * Both digests compose a message the fan-out could not reproduce — a day's\n * owed tasks, a week's figures — and send it from their own route under their\n * own switch (`digestPrefs.crmDaily`, `insightDigests.{orgId}`). A recipient\n * who switches the `content` email channel on would otherwise receive the\n * digest twice: once as the digest, once as a one-line \"Daily CRM digest\"\n * notification saying that the digest happened.\n */\nexport const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string> =\n new Set<AglynNotificationType>([\n 'content.crmDailyDigest',\n 'content.insightsDigest',\n ])\n\n/**\n * Whether a channel is on for one notification, at its own scope.\n *\n * Resolution order, first answer wins: the notification's SITE, its\n * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope\n * that holds no entry for the category, or an entry with the channel key\n * absent, does not answer — see {@link NotificationChannelPrefs}.\n *\n * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces\n * (AGL-267), and it sits BELOW the account layer rather than beside it.\n * Nothing migrates it: a person who never opens the new settings page keeps\n * being governed by the mutes they set years ago, and the first thing they do\n * set on the account layer overrides the old map for that category without\n * disturbing the rest of it. It answers for `console` only, because the map\n * predates there being a second channel and reading a console mute as an\n * email preference would be inventing an answer its author never gave.\n */\nexport function notificationChannelEnabled(\n settings: NotificationSettings | null | undefined,\n channel: NotificationChannel,\n type: AglynNotificationType | string,\n scope?: { orgId?: string | null; hostId?: string | null },\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n const category = notificationCategory(type)\n const layers: Array<NotificationCategoryPrefs | undefined> = [\n scope?.hostId ? settings?.hosts?.[scope.hostId] : undefined,\n scope?.orgId ? settings?.orgs?.[scope.orgId] : undefined,\n settings?.account,\n ]\n for (const layer of layers) {\n const answer = layer?.[category]?.[channel]\n if (typeof answer === 'boolean') return answer\n }\n if (channel === 'console' && notificationMuted(legacyPrefs, type))\n return false\n return NOTIFICATION_CHANNEL_DEFAULTS[category][channel]\n}\n\n/**\n * What one scope says, with no inheritance applied — what the settings page\n * renders as Inherit / On / Off, and `undefined` is Inherit.\n *\n * `scope` names the layer directly rather than being derived from a\n * notification, because the page edits a layer whether or not anything has\n * ever arrived from it.\n */\nexport function notificationScopePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n category: NotificationCategory,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.account\n : scope.kind === 'org'\n ? settings?.orgs?.[scope.id]\n : settings?.hosts?.[scope.id]\n return layer?.[category]?.[channel]\n}\n\n/**\n * The scopes this person has said something different about — what the\n * settings page lists so an override is never somewhere you have to go\n * looking for.\n *\n * Returns ids, not names: the page holds the org and host rosters and this\n * module holds no lookups. An id with no name is still worth listing, because\n * a preference about a workspace somebody left is exactly the kind of\n * leftover that is invisible until it is listed.\n */\nexport function notificationOverriddenScopes(\n settings: NotificationSettings | null | undefined,\n): { orgIds: string[]; hostIds: string[] } {\n const named = (\n layers: Record<string, NotificationCategoryPrefs> | undefined,\n ) =>\n Object.entries(layers ?? {})\n .filter(([, prefs]) =>\n Object.values(prefs ?? {}).some((channels) =>\n Object.values(channels ?? {}).some(\n (value) => typeof value === 'boolean',\n ),\n ),\n )\n .map(([id]) => id)\n .sort()\n return { orgIds: named(settings?.orgs), hostIds: named(settings?.hosts) }\n}\n"],"names":["NOTIFICATION_TYPE_LABELS","NOTIFICATION_CATEGORY_LABELS","billing","team","content","marketplace","support","system","staff","STAFF_NOTIFICATION_CATEGORIES","Set","notificationCategory","type","prefix","String","split","includes","notificationMuted","prefs","DIGEST_PREFS_FIELD","CRM_DAILY_DIGEST_KEY","crmDailyDigestEnabled","INSIGHT_DIGESTS_FIELD","insightDigestSubscribed","value","orgId","Boolean","NOTIFICATION_SETTINGS_FIELD","NOTIFICATION_CHANNEL_DEFAULTS","console","email","NOTIFICATION_SELF_SENT_EMAIL_TYPES","notificationChannelEnabled","settings","channel","scope","legacyPrefs","category","layers","hostId","hosts","undefined","orgs","account","layer","answer","notificationScopePref","kind","id","notificationOverriddenScopes","named","Object","entries","filter","values","some","channels","map","sort","orgIds","hostIds"],"mappings":"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"}
|
|
@@ -2187,6 +2187,40 @@ export interface DatasetQuotaResult {
|
|
|
2187
2187
|
* plan's hard max; beyond the max the only path is upgrading.
|
|
2188
2188
|
*/
|
|
2189
2189
|
export declare function checkDatasetQuota(org: Partial<AglynOrgBilling> | null | undefined, currentUsage: number): DatasetQuotaResult;
|
|
2190
|
+
export interface PluginOrgCapacityQuotaResult {
|
|
2191
|
+
/** False once usage meets the effective limit. */
|
|
2192
|
+
allowed: boolean;
|
|
2193
|
+
/** Included + purchased, clamped to the plan's ceiling for this capacity. */
|
|
2194
|
+
limit: number;
|
|
2195
|
+
remaining: number;
|
|
2196
|
+
/** What the plan includes — the figure a downgrade warning quotes. */
|
|
2197
|
+
included: number;
|
|
2198
|
+
/** What the org bought on top, 0 on a dead subscription. */
|
|
2199
|
+
purchased: number;
|
|
2200
|
+
/** How far buying can raise it on this plan. */
|
|
2201
|
+
ceiling: number;
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* The quota for a capacity a PLUGIN backs (AGL-3080), from its declaration.
|
|
2205
|
+
*
|
|
2206
|
+
* `checkDatasetQuota` is this arithmetic written out for the one capacity
|
|
2207
|
+
* core happened to know the name of. It has a dozen callers on money paths
|
|
2208
|
+
* and keeps its own shape; what moves here is the ability to ask the same
|
|
2209
|
+
* question about a capacity core does not name, which is what a console meter
|
|
2210
|
+
* needs — it draws a row per declared capacity and cannot switch on `datasets`.
|
|
2211
|
+
*
|
|
2212
|
+
* The two must agree, and `plan-entitlements-plugin-capacity.spec.ts` is what
|
|
2213
|
+
* holds that: for every plan, this answers exactly what `checkDatasetQuota`
|
|
2214
|
+
* answers for the declared dataset capacity. A second implementation of a
|
|
2215
|
+
* limit is worth having only while something refuses to let the two drift.
|
|
2216
|
+
*
|
|
2217
|
+
* ⚑ An undeclared kind, or a declaration naming a field no plan carries, is
|
|
2218
|
+
* ZERO — included, ceiling and limit all — which reads as "you are over" and
|
|
2219
|
+
* shows a meter full. That is the same direction `overLimitRows` and
|
|
2220
|
+
* `includedCapacity` already fail in, and for the same reason: the reassuring
|
|
2221
|
+
* answer here would be a meter promising room the create is refused at.
|
|
2222
|
+
*/
|
|
2223
|
+
export declare function checkPluginOrgCapacityQuota(org: Partial<AglynOrgBilling> | null | undefined, kind: string, currentUsage: number): PluginOrgCapacityQuotaResult;
|
|
2190
2224
|
export interface DataStorageQuotaResult {
|
|
2191
2225
|
/**
|
|
2192
2226
|
* False only when the plan hard-blocks (no overage pricing) and usage
|
|
@@ -18,6 +18,7 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
|
|
|
18
18
|
import { isOrgOverrideReasonCode } from "./org-override-reason.js";
|
|
19
19
|
import { PLATFORM_BRAND_NAME, PLATFORM_BRANDING_PROFILE } from "./platform-brand.js";
|
|
20
20
|
import { listPluginFeatures, listPluginSeatAddons, pluginSeatAddon } from "../plugin-manager/plugin-entitlements.js";
|
|
21
|
+
import { pluginOrgCapacity } from "../plugin-manager/plugin-org-capacity.js";
|
|
21
22
|
/** Sentinel for quotas a plan does not cap; `checkQuota` always allows. */ export const UNLIMITED = Number.POSITIVE_INFINITY;
|
|
22
23
|
/**
|
|
23
24
|
* How many saved form definitions one site may hold, on every plan that can
|
|
@@ -3570,6 +3571,45 @@ export function checkQuota(org, quota, currentUsage) {
|
|
|
3570
3571
|
addonPriceUsd
|
|
3571
3572
|
};
|
|
3572
3573
|
}
|
|
3574
|
+
/**
|
|
3575
|
+
* The quota for a capacity a PLUGIN backs (AGL-3080), from its declaration.
|
|
3576
|
+
*
|
|
3577
|
+
* `checkDatasetQuota` is this arithmetic written out for the one capacity
|
|
3578
|
+
* core happened to know the name of. It has a dozen callers on money paths
|
|
3579
|
+
* and keeps its own shape; what moves here is the ability to ask the same
|
|
3580
|
+
* question about a capacity core does not name, which is what a console meter
|
|
3581
|
+
* needs — it draws a row per declared capacity and cannot switch on `datasets`.
|
|
3582
|
+
*
|
|
3583
|
+
* The two must agree, and `plan-entitlements-plugin-capacity.spec.ts` is what
|
|
3584
|
+
* holds that: for every plan, this answers exactly what `checkDatasetQuota`
|
|
3585
|
+
* answers for the declared dataset capacity. A second implementation of a
|
|
3586
|
+
* limit is worth having only while something refuses to let the two drift.
|
|
3587
|
+
*
|
|
3588
|
+
* ⚑ An undeclared kind, or a declaration naming a field no plan carries, is
|
|
3589
|
+
* ZERO — included, ceiling and limit all — which reads as "you are over" and
|
|
3590
|
+
* shows a meter full. That is the same direction `overLimitRows` and
|
|
3591
|
+
* `includedCapacity` already fail in, and for the same reason: the reassuring
|
|
3592
|
+
* answer here would be a meter promising room the create is refused at.
|
|
3593
|
+
*/ export function checkPluginOrgCapacityQuota(org, kind, currentUsage) {
|
|
3594
|
+
var _entitlements_capacity_includedEntitlement, _entitlements_capacity_purchaseCeilingEntitlement, _resolvePurchasedAddons_capacity_addonKind;
|
|
3595
|
+
const capacity = pluginOrgCapacity(kind);
|
|
3596
|
+
const entitlements = resolveOrgEntitlements(org);
|
|
3597
|
+
const included = capacity ? Number((_entitlements_capacity_includedEntitlement = entitlements[capacity.includedEntitlement]) != null ? _entitlements_capacity_includedEntitlement : 0) : 0;
|
|
3598
|
+
const ceiling = capacity ? Number((_entitlements_capacity_purchaseCeilingEntitlement = entitlements[capacity.purchaseCeilingEntitlement]) != null ? _entitlements_capacity_purchaseCeilingEntitlement : 0) : 0;
|
|
3599
|
+
// The add-on kind IS the `seatAddons` key it is stored under — the
|
|
3600
|
+
// generator refuses two capacities on one add-on kind, so one purchased
|
|
3601
|
+
// quantity can only ever raise the capacity that sells it.
|
|
3602
|
+
const purchased = capacity ? Math.max(0, Number((_resolvePurchasedAddons_capacity_addonKind = resolvePurchasedAddons(org)[capacity.addonKind]) != null ? _resolvePurchasedAddons_capacity_addonKind : 0)) : 0;
|
|
3603
|
+
const limit = Math.min(included + purchased, ceiling);
|
|
3604
|
+
return {
|
|
3605
|
+
allowed: currentUsage < limit,
|
|
3606
|
+
limit,
|
|
3607
|
+
remaining: Math.max(0, limit - currentUsage),
|
|
3608
|
+
included,
|
|
3609
|
+
purchased,
|
|
3610
|
+
ceiling
|
|
3611
|
+
};
|
|
3612
|
+
}
|
|
3573
3613
|
/**
|
|
3574
3614
|
* Org dataset-storage meter (AGL-240): aggregate stored document bytes
|
|
3575
3615
|
* across `orgs/{orgId}/datasets`. Plans with an `extraDataGbMonthlyUsd`
|