@aglyn/aglyn 1.0.0-beta.269 → 1.0.0-beta.271
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 +11 -11
- package/src/lib/app-utils/account-preferences.d.ts +248 -0
- package/src/lib/app-utils/account-preferences.js +434 -0
- package/src/lib/app-utils/account-preferences.js.map +1 -0
- package/src/lib/app-utils/activity-labels.js +2 -0
- package/src/lib/app-utils/activity-labels.js.map +1 -1
- package/src/lib/app-utils/analytics-events.d.ts +13 -0
- package/src/lib/app-utils/analytics-events.js +2 -0
- package/src/lib/app-utils/analytics-events.js.map +1 -1
- package/src/lib/app-utils/append-only-history.d.ts +94 -0
- package/src/lib/app-utils/append-only-history.js +73 -0
- package/src/lib/app-utils/append-only-history.js.map +1 -0
- package/src/lib/app-utils/bundle-intent.d.ts +186 -0
- package/src/lib/app-utils/bundle-intent.js +386 -0
- package/src/lib/app-utils/bundle-intent.js.map +1 -0
- package/src/lib/app-utils/client-build.d.ts +204 -0
- package/src/lib/app-utils/client-build.js +237 -0
- package/src/lib/app-utils/client-build.js.map +1 -0
- package/src/lib/app-utils/console-routes.d.ts +4 -0
- package/src/lib/app-utils/console-routes.js +2 -0
- package/src/lib/app-utils/console-routes.js.map +1 -1
- package/src/lib/app-utils/country-options.d.ts +27 -0
- package/src/lib/app-utils/country-options.js +96 -0
- package/src/lib/app-utils/country-options.js.map +1 -0
- package/src/lib/app-utils/docs-help-sections.generated.js +7 -5
- package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +11 -5
- package/src/lib/app-utils/docs-help.generated.js +22 -2
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +116 -39
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/margin-utilization.js +6 -4
- package/src/lib/app-utils/margin-utilization.js.map +1 -1
- package/src/lib/app-utils/media-filter.d.ts +26 -0
- package/src/lib/app-utils/media-filter.js +32 -0
- package/src/lib/app-utils/media-filter.js.map +1 -1
- package/src/lib/app-utils/mobile-push.d.ts +8 -0
- package/src/lib/app-utils/mobile-push.js +4 -1
- package/src/lib/app-utils/mobile-push.js.map +1 -1
- package/src/lib/app-utils/native-app-association.d.ts +3 -2
- package/src/lib/app-utils/native-app-association.js +4 -2
- package/src/lib/app-utils/native-app-association.js.map +1 -1
- package/src/lib/app-utils/native-nav.d.ts +265 -0
- package/src/lib/app-utils/native-nav.js +741 -0
- package/src/lib/app-utils/native-nav.js.map +1 -0
- package/src/lib/app-utils/native-themes.d.ts +249 -0
- package/src/lib/app-utils/native-themes.js +732 -0
- package/src/lib/app-utils/native-themes.js.map +1 -0
- package/src/lib/app-utils/notifications.d.ts +1 -1
- package/src/lib/app-utils/notifications.js +10 -0
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/onboarding-deep-link.d.ts +36 -2
- package/src/lib/app-utils/onboarding-deep-link.js +48 -5
- package/src/lib/app-utils/onboarding-deep-link.js.map +1 -1
- package/src/lib/app-utils/one-box-search.d.ts +108 -0
- package/src/lib/app-utils/one-box-search.js +167 -0
- package/src/lib/app-utils/one-box-search.js.map +1 -0
- package/src/lib/app-utils/operator-alerts.js +21 -0
- package/src/lib/app-utils/operator-alerts.js.map +1 -1
- package/src/lib/app-utils/org-permissions.js +10 -61
- package/src/lib/app-utils/org-permissions.js.map +1 -1
- package/src/lib/app-utils/org-role-grants.d.ts +95 -0
- package/src/lib/app-utils/org-role-grants.js +193 -0
- package/src/lib/app-utils/org-role-grants.js.map +1 -0
- package/src/lib/app-utils/owner-approval-record.d.ts +102 -0
- package/src/lib/app-utils/owner-approval-record.js +148 -0
- package/src/lib/app-utils/owner-approval-record.js.map +1 -0
- package/src/lib/app-utils/plan-entitlements.d.ts +224 -68
- package/src/lib/app-utils/plan-entitlements.js +278 -61
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/release-flags.d.ts +1 -1
- package/src/lib/app-utils/release-flags.js +10 -0
- package/src/lib/app-utils/release-flags.js.map +1 -1
- package/src/lib/app-utils/server.d.ts +4 -0
- package/src/lib/app-utils/server.js +7 -0
- package/src/lib/app-utils/server.js.map +1 -1
- package/src/lib/app-utils/site-care-project.d.ts +227 -0
- package/src/lib/app-utils/site-care-project.js +423 -0
- package/src/lib/app-utils/site-care-project.js.map +1 -0
- package/src/lib/app-utils/site-care.d.ts +268 -0
- package/src/lib/app-utils/site-care.js +283 -0
- package/src/lib/app-utils/site-care.js.map +1 -0
- package/src/lib/app-utils/tenant-dns-targets.d.ts +62 -0
- package/src/lib/app-utils/tenant-dns-targets.js +80 -0
- package/src/lib/app-utils/tenant-dns-targets.js.map +1 -0
- package/src/lib/app-utils/upgrade-proposal.d.ts +29 -4
- package/src/lib/app-utils/upgrade-proposal.js +60 -9
- package/src/lib/app-utils/upgrade-proposal.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +54 -10
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/foundation/definitions/organization.types.d.ts +3 -1
- package/src/lib/foundation/definitions/organization.types.js.map +1 -1
- package/src/lib/foundation/definitions/platform.types.d.ts +9 -0
- package/src/lib/foundation/definitions/platform.types.js.map +1 -1
- package/src/lib/foundation/index.d.ts +1 -1
- package/src/lib/foundation/index.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +86 -0
- package/src/lib/plugin-manager/feature-plugins.js +20 -0
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +22 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-ai-capabilities.d.ts +19 -0
- package/src/lib/plugin-manager/plugin-ai-capabilities.js.map +1 -1
- package/src/lib/plugin-manager/plugin-domain-events.d.ts +4 -3
- package/src/lib/plugin-manager/plugin-domain-events.js.map +1 -1
- package/src/lib/plugin-manager/plugin-domain-search.d.ts +81 -0
- package/src/lib/plugin-manager/plugin-domain-search.js +63 -0
- package/src/lib/plugin-manager/plugin-domain-search.js.map +1 -0
- package/src/lib/plugin-manager/plugin-entitlements.d.ts +23 -5
- package/src/lib/plugin-manager/plugin-entitlements.js +10 -0
- package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
- package/src/lib/plugin-manager/plugin-media-ingest.d.ts +25 -0
- package/src/lib/plugin-manager/plugin-media-ingest.js.map +1 -1
- package/src/lib/plugin-manager/plugin-search-sources.d.ts +85 -0
- package/src/lib/plugin-manager/plugin-search-sources.js +91 -0
- package/src/lib/plugin-manager/plugin-search-sources.js.map +1 -0
- package/src/lib/plugin-manager/plugin-services.js +6 -0
- package/src/lib/plugin-manager/plugin-services.js.map +1 -1
- package/src/lib/plugin-manager/purchase-gate-preclearance.d.ts +61 -0
- package/src/lib/plugin-manager/purchase-gate-preclearance.js +102 -0
- package/src/lib/plugin-manager/purchase-gate-preclearance.js.map +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/operator-alerts.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 { AglynNotificationType, NotificationLevel } from './notifications'\n\n/**\n * OPERATOR ALERTS (AGL-3377): the events whoever runs an install must hear\n * about, as one typed catalog.\n *\n * Every alert is an entry here (or a plugin's entry in the same shape,\n * registered through `@aglyn/aglyn/plugin-manager/operator-alerts`) and is\n * raised through ONE server entry point, `raiseOperatorAlert` in\n * `@aglyn/tenant-data-admin/server/operator-alerts`. That entry point writes\n * the staff console notification, dedupes a flapping condition, honours the\n * per-type switches staff set on Staff → Operator alerts, and delivers by\n * email (the `operator-alert` system email) and the optional out-of-band\n * webhook. No call site composes or sends its own mail.\n *\n * The catalog holds nothing install-specific: no address, no domain, no\n * host. Recipients come from `resolveStaffAlertRecipients()` and the webhook\n * from `OPERATOR_ALERT_WEBHOOK_URL`, so an entry reads the same on aglyn.com,\n * on a self-hosted install and in an OSS consumer's deployment.\n *\n * Copy is written against merge tokens (`{{orgId}}`), filled from the\n * `context` the call site passes. A token the call site did not supply is\n * blanked, so every sentence must still read when its optional tokens are\n * empty. Never put a data subject's address or other personal data into a\n * context: the alert travels to an inbox and a webhook, not only the console.\n */\n\n/**\n * How much an alert matters.\n *\n * - `must` — money moving with nobody looking, a legal duty unmet, data not\n * protected or not erased. On and immediate unless staff change it.\n * - `should` — a fault an operator wants to know about today.\n * - `low` — routine review work; on, and batched into the daily digest.\n */\nexport type OperatorAlertTier = 'must' | 'should' | 'low'\n\nexport type OperatorAlertCategory =\n | 'billing'\n | 'payments'\n | 'legal'\n | 'data'\n | 'ops'\n | 'deliverability'\n | 'security'\n | 'support'\n\n/** `digest` batches the alert into the once-a-day operator digest. */\nexport type OperatorAlertDelivery = 'immediate' | 'digest'\n\nexport interface OperatorAlertDefinition {\n /**\n * Stable id, also the key of its settings row. Never rename: staff\n * switches are stored under it. A plugin's types start with its plugin id\n * and a dot (`commerce.taxNotReversed`).\n */\n type: string\n /** The settings row's name. */\n label: string\n /** What it means and the first thing to do, for the settings page. */\n description: string\n tier: OperatorAlertTier\n category: OperatorAlertCategory\n /** The notification title and email subject, with `{{tokens}}`. */\n title: string\n /** What happened and what to do, with `{{tokens}}`. */\n body: string\n /** Console path the alert opens, with `{{tokens}}`; the call may override. */\n link?: string\n /** The coded default; staff may change it. */\n delivery: OperatorAlertDelivery\n /**\n * How long one `dedupeKey` stays quiet after it fired, in minutes. A\n * condition that flaps inside the window is told once. `0` never dedupes.\n * A call without a `dedupeKey` is never deduped.\n */\n dedupeWindowMinutes: number\n /**\n * Fire only once the same `dedupeKey` has been raised this many times\n * inside the window — for an endpoint any stranger can hit, where one\n * refused request is noise and several are a broken secret. Default 1.\n */\n minOccurrences?: number\n /** The coded default; staff may change it. */\n defaultEnabled: boolean\n /**\n * The console notification's type. Absent is the generic\n * `system.operatorAlert`; a type that already had its own notification\n * keeps it, so its icon, label and everyone's mutes are unchanged.\n */\n notificationType?: AglynNotificationType\n /**\n * How loud its console notification is drawn (AGL-3437). Absent, the\n * tier answers — see {@link operatorAlertLevel}. Set where the tone and\n * the importance part ways: a recovery is worth hearing about and is good\n * news, and a degraded check is a must-know that is not yet an outage.\n */\n level?: NotificationLevel\n /** The plugin that contributed it; absent for core's own. */\n pluginId?: string\n}\n\n/** Values a call site passes for an alert's `{{tokens}}`. */\nexport type OperatorAlertContext = Readonly<\n Record<string, string | number | boolean | null | undefined>\n>\n\n/** The notification type an alert without its own writes under. */\nexport const OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE: AglynNotificationType =\n 'system.operatorAlert'\n\nexport const OPERATOR_ALERT_TIER_LABELS: Record<OperatorAlertTier, string> = {\n must: 'Must know',\n should: 'Should know',\n low: 'Routine',\n}\n\n/**\n * The level an alert's notification is drawn at when its entry names none:\n * a must-know is red, a should-know amber, routine review work informational.\n */\nexport const OPERATOR_ALERT_TIER_LEVELS: Record<\n OperatorAlertTier,\n NotificationLevel\n> = {\n must: 'critical',\n should: 'warning',\n low: 'info',\n}\n\n/** The level an alert's console notification carries (AGL-3437). */\nexport function operatorAlertLevel(\n definition: Pick<OperatorAlertDefinition, 'tier' | 'level'>,\n): NotificationLevel {\n return definition.level ?? OPERATOR_ALERT_TIER_LEVELS[definition.tier]\n}\n\nexport const OPERATOR_ALERT_CATEGORY_LABELS: Record<\n OperatorAlertCategory,\n string\n> = {\n billing: 'Billing',\n payments: 'Payments & payouts',\n legal: 'Legal',\n data: 'Data protection',\n ops: 'Operations',\n deliverability: 'Email deliverability',\n security: 'Security',\n support: 'Support',\n}\n\nconst DAY = 24 * 60\n\n/**\n * Core's alerts. Plugins contribute theirs through the plugin-manager\n * registry; nothing plugin-specific belongs in this list.\n */\nexport const CORE_OPERATOR_ALERTS: readonly OperatorAlertDefinition[] = [\n // ── Fraud, risk and legal duties already alerting before AGL-3377 ──────\n {\n type: 'system.abuseReportUrgent',\n label: 'Urgent abuse, fraud or risk alert',\n description:\n 'Phishing, malware or CSAM reported on a site, card testing, a flagged or disputed payment, a held phishing email, a seller under review. Review it on Staff → Abuse reports.',\n tier: 'must',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n link: '/admin/abuse-reports',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.abuseReportUrgent',\n },\n {\n // The one lock no person placed (AGL-3450): staff confirm it before the\n // customer's appeal arrives, and re-place it as `abuse` or lift it.\n type: 'security.pageSecurityHold',\n label: 'Automatic security hold on a new workspace',\n description:\n 'The page screen held a phishing page from a workspace in its first two weeks and locked the workspace, the site and the publishing account as security. Confirm it on Staff → Abuse reports, then re-place it as abuse on Staff → Lockdown, or lift it there if it is a false positive.',\n tier: 'must',\n category: 'security',\n title: 'Security hold placed on {{orgName}}',\n body:\n '{{page}} on {{siteName}} ({{hostId}}) was held for review: {{signal}} Locked as security, pending your review: {{locks}}. The abuse row is {{reference}}. Lift the hold, or re-place it as abuse, on Staff → Lockdown (/admin/lockdown).',\n link: '/admin/abuse-reports?report={{reviewId}}',\n delivery: 'immediate',\n // One hold per workspace; a repeat inside the day is the same event.\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n notificationType: 'system.abuseReportUrgent',\n },\n {\n type: 'security.paymentFraudHold',\n label: 'Automatic security hold for payment fraud',\n description:\n 'Stripe flagged a workspace\\'s own subscription payment as fraud — a Radar block at highest risk, a Stripe fraud report, three or more cards failing within 30 minutes, an early fraud warning or a Radar review. A new workspace that never paid is locked as security (takedown) with its subscriptions canceled; an older or paying one is not locked and needs a decision. Confirm it on Staff → Abuse reports, then re-place it as abuse on Staff → Lockdown, or lift it there.',\n tier: 'must',\n category: 'security',\n title: '{{action}}: {{orgName}}',\n body:\n '{{trigger}} on {{orgName}} ({{orgId}}). Charges: {{charges}}; cards: {{cards}}, issued in {{cardCountries}}. Locked: {{locks}}. The abuse row is {{reference}}. Lift it, or re-place it as abuse, on Staff → Lockdown (/admin/lockdown).',\n link: '/admin/abuse-reports?report={{reviewId}}',\n delivery: 'immediate',\n // One per workspace per window: every further card in the burst is the\n // same event, and the abuse row carries all of them.\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n notificationType: 'system.abuseReportUrgent',\n },\n {\n type: 'system.disputeUnattributed',\n label: 'Card dispute with no owner',\n description:\n 'A chargeback arrived that no subscription, order or marketplace purchase claimed, so nothing is answering it. Find the charge in Stripe and respond before the evidence deadline.',\n tier: 'must',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.disputeUnattributed',\n },\n {\n type: 'system.dmcaCounterNotice',\n label: 'DMCA counter-notice',\n description:\n 'A customer contested a takedown. The statutory clock is already running; forward it to the complainant and restore on time unless they file suit.',\n tier: 'must',\n category: 'legal',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.dmcaCounterNotice',\n },\n {\n type: 'system.billingWebhookHalfApplied',\n label: 'Billing webhook half applied',\n description:\n 'A Stripe event threw after its handlers began, so its effects may be partly applied and Stripe will not retry it. Reconcile the event by hand.',\n tier: 'must',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.billingWebhookHalfApplied',\n },\n\n // ── MUST: billing ───────────────────────────────────────────────────────\n {\n type: 'billing.platformDisputeLost',\n label: 'Subscription dispute lost',\n description:\n 'A card dispute on a workspace subscription closed as lost: the money has been taken back. Decide whether the workspace keeps its plan.',\n tier: 'must',\n category: 'billing',\n title: 'Subscription dispute lost on workspace {{orgId}}',\n body:\n 'The card dispute {{disputeId}} on invoice {{invoiceId}} for workspace {{orgName}} ({{orgId}}) closed as lost ({{reason}}), and {{amount}} was taken back. The workspace keeps its plan until somebody decides otherwise.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.orphanedSubscription',\n label: 'Stripe is billing a workspace that does not exist',\n description:\n 'A subscription names a workspace that was deleted or erased, so the customer keeps being charged for nothing. Cancel or refund it in Stripe.',\n tier: 'must',\n category: 'billing',\n title: 'Subscription bills a missing workspace',\n body:\n 'Stripe subscription {{subscriptionId}} (customer {{customerId}}, plan {{plan}}) names workspace {{orgId}}, which {{reason}}. The {{eventType}} event changed nothing. Cancel or refund it in Stripe.',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.usageNotReported',\n label: 'Metered usage not reported to Stripe',\n description:\n 'A closed month measured billable usage and it reached no invoice. Report it to Stripe by hand or the revenue is lost.',\n tier: 'must',\n category: 'billing',\n title: 'Usage for {{month}} not billed on {{orgId}}',\n body:\n 'The closed month {{month}} measured {{amount}} of billable usage on workspace {{orgName}} ({{orgId}}), and it was not reported to Stripe ({{reason}}). Nothing will invoice it until somebody does.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 7 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.webhookSignatureRejected',\n label: 'Billing webhook failing its signature check',\n description:\n 'Repeated deliveries to the billing webhook were refused because their signature did not match. Usually a rolled signing secret: until it is fixed, Stripe cannot tell the install about money.',\n tier: 'must',\n category: 'billing',\n title: 'Billing webhook is refusing deliveries',\n body:\n 'The billing webhook refused several signed deliveries within six hours because their Stripe signature matched none of the configured secrets. If Stripe is the sender, its signing secret no longer matches this install’s, and no billing event is being applied.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n minOccurrences: 3,\n defaultEnabled: true,\n },\n {\n type: 'billing.webhookInert',\n label: 'Billing webhook delivery moved nothing',\n description:\n 'Stripe delivered an event this install subscribed to, and no handler did anything with it. Usually a handler that stopped being registered.',\n tier: 'must',\n category: 'billing',\n title: 'A {{eventType}} delivery changed nothing',\n body:\n 'Stripe event {{eventId}} ({{eventType}}) was accepted and no handler acted on it. Check which handler stopped being registered, then replay the event.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n\n // ── MUST: data protection and records ───────────────────────────────────\n {\n type: 'data.personErasureFailed',\n label: 'Personal data erasure failed',\n description:\n 'A data subject’s erasure request failed. It will be retried, but the legal deadline keeps running: read the error and fix the cause.',\n tier: 'must',\n category: 'data',\n title: 'Erasure request {{requestId}} failed',\n body:\n 'The erasure request {{requestId}} on workspace {{orgName}} ({{orgId}}) failed (attempt {{attempt}}): {{error}}. The statutory deadline keeps running while it retries.',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'data.orgErasureFailed',\n label: 'Workspace erasure failed',\n description:\n 'A workspace scheduled for erasure could not be erased. Its data is still held after the date it was promised gone.',\n tier: 'must',\n category: 'data',\n title: 'Workspace {{orgId}} could not be erased',\n body:\n 'The scheduled erasure of workspace {{orgName}} ({{orgId}}) did not complete: {{reason}}. It will be retried on the next run; its data is held until it succeeds.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'data.backupExportFailed',\n label: 'Database export failed',\n description:\n 'The scheduled database export did not start. A stale export means the worst day has nothing to restore from.',\n tier: 'must',\n category: 'data',\n title: 'Database export failed',\n body:\n 'The scheduled database export did not start ({{reason}}). The last good export is getting older; check the service account’s export permission and the bucket.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 12 * 60,\n defaultEnabled: true,\n },\n\n // ── MUST: legal ─────────────────────────────────────────────────────────\n {\n type: 'legal.abuseReportHigh',\n label: 'Takedown, impersonation or illegal-content report',\n description:\n 'A DMCA takedown, impersonation or illegal-content report. Each carries a response duty; review it on Staff → Abuse reports.',\n tier: 'must',\n category: 'legal',\n title: '{{category}} report on {{site}}',\n body:\n '{{site}} was reported for {{category}}. Reference {{reference}}. Review it and respond within the policy’s window.',\n link: '/admin/abuse-reports',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n },\n\n // ── MUST/SHOULD: health ─────────────────────────────────────────────────\n {\n type: 'system.healthDegraded',\n label: 'Health check degraded',\n description:\n 'A health check went from healthy to degraded: scheduled jobs silent, backups stale, billing deliveries failing, sign-ups refused, server errors, a site not rendering. Told once per incident.',\n tier: 'must',\n category: 'ops',\n title: '{{check}} is degraded',\n body: '{{check}} went from healthy to degraded. {{detail}}',\n link: '/admin/health',\n delivery: 'immediate',\n // Must-know, and still amber rather than red: degraded is not down.\n level: 'warning',\n // A check flapping across the line is told once an hour, not on\n // every swing.\n dedupeWindowMinutes: 60,\n defaultEnabled: true,\n },\n {\n // The reminder the edge alert never sends (AGL-3860): raised by the\n // operator alerts tick at 1 h and every 2 h after while a check stays\n // degraded. The tick raises it as Must know from 2 h, so its level\n // follows the tier it was raised at rather than one coded here.\n type: 'system.healthStillDegraded',\n label: 'Health check still degraded',\n description:\n 'A health check has stayed degraded for an hour or more. Told at 1 h and again every 2 h until it recovers; Should know for the first reminder, Must know from 2 h on. Open the failing endpoint, read the failing check’s code and fix the cause, or the check when the check is what is wrong.',\n tier: 'should',\n category: 'ops',\n title: '{{check}} is still degraded after {{duration}}',\n body: '{{check}} is still degraded after {{duration}}. {{detail}}',\n link: '/admin/health',\n delivery: 'immediate',\n // The tick dedupes each reminder per check per step on the check's own\n // state; the window only stops a retried tick from telling one twice.\n dedupeWindowMinutes: 60,\n defaultEnabled: true,\n },\n {\n // The post-deploy production canary (AGL-3567), raised from GitHub\n // Actions through `/api/admin/operator-alerts/canary`. Red because a\n // deploy that stops rendering pages is every published site down.\n type: 'ops.productionCanaryRed',\n label: 'Production canary failed after a deploy',\n description:\n 'Right after a tenant or console production deploy, uncached pages stopped rendering. The canary rolls the deploy back by itself when the failure holds across sites; after any rollback the next release must be promoted by hand.',\n tier: 'must',\n category: 'ops',\n title: '{{project}} production canary: {{outcome}}',\n body: '{{detail}}',\n link: '/admin/health',\n delivery: 'immediate',\n level: 'critical',\n // The caller dedupes per deployment and verdict; this only stops a\n // re-run of the same red run from telling it twice.\n dedupeWindowMinutes: 30,\n defaultEnabled: true,\n },\n {\n type: 'system.healthRecovered',\n label: 'Health check recovered',\n description: 'A degraded health check is healthy again.',\n tier: 'should',\n category: 'ops',\n title: '{{check}} recovered',\n body: '{{check}} is healthy again after {{duration}}.',\n link: '/admin/health',\n delivery: 'immediate',\n level: 'success',\n // A check flapping across the line is told once an hour, not on\n // every swing.\n dedupeWindowMinutes: 60,\n defaultEnabled: true,\n },\n {\n // The render monitor (AGL-3568): a published site fetched over HTTP, on\n // pages no cache can answer, failed several runs in a row. Red, not the\n // amber of a degraded check: every visitor without a cached copy is\n // getting an error page or a timeout right now.\n type: 'system.siteRenderFailing',\n label: 'Published site not rendering pages',\n description:\n 'The render monitor asked a published site for pages no cache can answer, several runs in a row, and got no complete page back. Visitors to cached pages may still see them, so the site can look up while every other page fails. Check the latest site runtime deployment first and roll it back if it just shipped.',\n tier: 'must',\n category: 'ops',\n title: 'Pages are not rendering on {{site}}',\n body:\n '{{site}} failed {{failures}} render checks in a row, over {{since}}. {{detail}} Cached pages may still load, so the site can look up while new renders fail.',\n link: '/admin/health',\n delivery: 'immediate',\n // Told once per outage: the monitor raises it on the edge, and the\n // window only covers a site flapping across the line.\n dedupeWindowMinutes: 30,\n defaultEnabled: true,\n },\n {\n type: 'system.siteRenderRecovered',\n label: 'Published site rendering again',\n description:\n 'A published site the render monitor had reported as not rendering serves fresh pages again.',\n tier: 'should',\n category: 'ops',\n title: '{{site}} is rendering pages again',\n body: 'Fresh page renders on {{site}} pass again after {{duration}}.',\n link: '/admin/health',\n delivery: 'immediate',\n level: 'success',\n dedupeWindowMinutes: 30,\n defaultEnabled: true,\n },\n {\n // The render monitor could not see the site (AGL-3580): bot protection\n // answered every probe it made, so it knows nothing about whether pages\n // render. Not an outage, and never worded as one — on 2026-10-05 a\n // missing probe token read as \"pages are not rendering\" for three hours\n // while the site served pages. Amber: detection is off until it is fixed.\n type: 'system.renderMonitorBlind',\n label: 'Render monitor cannot see a site',\n description:\n 'Bot protection answered the render monitor instead of a published site, several runs in a row, so nothing is checking whether that site renders pages. The site itself may be fine. Check AGLYN_PROBE_TOKEN on the console and the \"CI and uptime probe bypass\" firewall rule on the site runtime.',\n tier: 'must',\n category: 'ops',\n title: 'The render monitor cannot see {{site}}',\n body:\n 'Bot protection answered instead of {{site}} for {{runs}} runs in a row, so nothing is checking its pages. {{detail}} This is not a verdict on the site.',\n link: '/admin/health',\n delivery: 'immediate',\n level: 'warning',\n dedupeWindowMinutes: 360,\n defaultEnabled: true,\n },\n\n // ── SHOULD: billing ─────────────────────────────────────────────────────\n {\n type: 'billing.autoLocked',\n label: 'Workspace billing locked automatically',\n description:\n 'A workspace crossed its spend guard and the platform locked its billable activity. Check it is not a false positive before the customer notices.',\n tier: 'should',\n category: 'billing',\n title: 'Billing locked on {{orgName}}',\n body:\n 'Workspace {{orgName}} ({{orgId}}) was locked automatically: {{reason}}.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.platformInvoiceUncollectible',\n label: 'Subscription invoice voided or uncollectible',\n description:\n 'A workspace invoice was voided or marked uncollectible: revenue the books expected is not coming.',\n tier: 'should',\n category: 'billing',\n title: 'Invoice {{status}} on {{orgId}}',\n body:\n 'Invoice {{invoiceId}} for {{amount}} on workspace {{orgName}} ({{orgId}}) was marked {{status}}, so Stripe has stopped collecting it.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.connectPayoutFailed',\n label: 'Connected account payout or transfer failed',\n description:\n 'Money due to a merchant or seller did not arrive. Stripe retries on its own schedule; a human decides what happens next.',\n tier: 'should',\n category: 'billing',\n title: 'A {{kind}} to {{account}} failed',\n body:\n 'A {{amount}} Stripe {{kind}} ({{stripeId}}) to connected account {{account}} ({{merchant}}) failed: {{reason}}. The merchant’s funds are not where the ledger says.',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'staff.paymentFailed',\n label: 'Workspace payment failed',\n description:\n 'A paying workspace’s renewal failed. Stripe is retrying; the customer has been told.',\n tier: 'should',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'staff.paymentFailed',\n },\n\n // ── SHOULD: security ────────────────────────────────────────────────────\n {\n type: 'security.staffGranted',\n label: 'Staff access granted or raised',\n description:\n 'Somebody was made staff, or a staff role was raised. If nobody on the team did it, revoke it and rotate credentials.',\n tier: 'should',\n category: 'security',\n title: '{{target}} is now {{role}} staff',\n body: '{{actor}} granted {{target}} the {{role}} staff role (was {{previous}}).',\n link: '/admin/users',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n },\n\n // ── SHOULD: support ─────────────────────────────────────────────────────\n {\n type: 'support.slaBreached',\n label: 'Support ticket past its response time',\n description:\n 'A support ticket passed its promised response time without a staff reply.',\n tier: 'should',\n category: 'support',\n title: 'Ticket past its response time: {{subject}}',\n body:\n 'The {{tier}} ticket “{{subject}}” from workspace {{orgName}} ({{orgId}}) was due a first response {{overdue}} ago and has none.',\n // The ticket itself: the support page opens `?ticketId=` on load.\n link: '/admin/support?ticketId={{ticketId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'support.ticketOpened',\n label: 'New support ticket',\n description: 'A subscriber opened a support ticket.',\n tier: 'should',\n category: 'support',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'support.ticketOpened',\n // A customer asking for help, not a fault.\n level: 'info',\n },\n {\n type: 'support.ticketReply',\n label: 'Support ticket reply',\n description: 'A subscriber replied on a support ticket.',\n tier: 'should',\n category: 'support',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'support.ticketReply',\n level: 'info',\n },\n\n // ── SHOULD: deliverability ──────────────────────────────────────────────\n {\n type: 'deliverability.sendRateSaturated',\n label: 'Email send-rate governor saturated',\n description:\n 'The platform’s email send-rate budget is exhausted, so sends are being deferred. Raise the budget or find the sender filling it.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email sending is being throttled',\n body:\n 'The {{scope}} send-rate budget is full ({{detail}}), so sends are being deferred.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.sendRateUnavailable',\n label: 'Email send-rate governor unavailable',\n description:\n 'The send-rate governor could not read its counters, so mail is going out unmetered: nothing is holding the shared domains under their hourly ceiling.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email send-rate governor unavailable',\n body:\n 'The send-rate governor could not read its counters ({{error}}), so sends are being allowed unmetered until it can.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.gatewayBlocking',\n label: 'A recipient gateway is blocking the shared domain',\n description:\n 'A mail gateway is refusing mail from the shared sending domain, so everyone behind that gateway stops receiving it.',\n tier: 'should',\n category: 'deliverability',\n title: '{{gateway}} is blocking {{domain}}',\n body:\n 'Mail from {{domain}} is being refused by {{gateway}} ({{detail}}). Recipients behind that gateway are not receiving it.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.providerCredentialsRejected',\n label: 'Email provider rejected the credentials',\n description:\n 'The email provider refused the API key, or a sending pool is unhealthy: invites, password resets and receipts are not leaving. The webhook channel is the one that still reaches you.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email delivery is failing: {{problem}}',\n body: '{{detail}}',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n // A purchasing ceiling, not a job failure: the provisioning sweep ran\n // and every site keeps sending, so the copy must not read as an outage.\n type: 'deliverability.sendingDomainCapacityFull',\n label: 'Sending-domain allowance full',\n description:\n 'Every dedicated sending domain the provider allowance covers is in use, so new sites that ask for one stay on the shared pool. No mail is lost; raise the allowance or move merchants onto domains they own.',\n tier: 'should',\n category: 'deliverability',\n title: 'Sending-domain allowance full ({{held}}/{{capacity}})',\n body:\n 'The sending-domain allowance is full ({{held}} of {{capacity}} in use), so sites that asked for a dedicated domain keep sending on the shared pool. No mail is lost: what they go without is the isolation a domain of their own buys, and their campaigns are held to the pool’s stricter reputation limits. Raise AGLYN_SENDING_DOMAIN_CAPACITY once the provider allowance covers it, or move merchants onto domains they own.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n\n // ── SHOULD: operations ──────────────────────────────────────────────────\n {\n type: 'ops.reaperStuck',\n label: 'A reaper is stuck or failing',\n description:\n 'A cleanup job (sending domains, unverified workspaces) could not finish, so what it removes keeps accumulating.',\n tier: 'should',\n category: 'ops',\n title: '{{job}} is stuck',\n body: '{{job}} could not finish: {{detail}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'ops.pluginJobFailed',\n label: 'A plugin job failed',\n description:\n 'A plugin’s scheduled job threw: consent-group changes, the publish outbox, sending-domain provisioning, a plugin cron.',\n tier: 'should',\n category: 'ops',\n // `job` is a noun phrase that reads as a subject on its own (\"The\n // publish outbox drain\", \"The scheduled job acme-mail-send\"), so the\n // title and the body open with the same words.\n title: '{{job}} failed',\n body: '{{job}} failed: {{error}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n // Nightly, so once a day per provider is every run it could report.\n type: 'ops.adAudienceSyncFailed',\n label: 'Ad audience sync failed',\n description:\n 'The nightly account-holder audience sync (AGL-3780) could not update a Meta, Google or LinkedIn audience, or held a run whose audience would have shrunk by more than half. The audience keeps what it last had; check the provider credentials, or run the sync with force once the drop is confirmed.',\n tier: 'should',\n category: 'ops',\n title: 'Ad audience sync: {{provider}} not updated',\n body: 'The {{provider}} account-holder audience was not updated: {{detail}}. It keeps what it was last sent, and the next nightly run tries again.',\n link: '/admin/flags',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'system.pluginVerifierRegression',\n label: 'Plugin verifier regression',\n description:\n 'A live plugin version stopped passing the static verifier.',\n tier: 'should',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.pluginVerifierRegression',\n },\n {\n type: 'system.bandwidthCeilingTripped',\n label: 'Bandwidth ceiling reached',\n description:\n 'A site crossed the per-month bandwidth abuse ceiling.',\n tier: 'should',\n category: 'ops',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.bandwidthCeilingTripped',\n },\n\n // ── LOW ─────────────────────────────────────────────────────────────────\n {\n type: 'marketplace.review',\n label: 'Plugin listing waiting for review',\n description: 'A plugin listing or version was submitted for review.',\n tier: 'low',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'marketplace.review',\n },\n {\n type: 'system.scopeDrift',\n label: 'Resources missing a sharing scope',\n description:\n 'The weekly sweep found documents invisible to every site-scoped read: a creation path forgot the field.',\n tier: 'low',\n category: 'data',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.scopeDrift',\n },\n]\n\n/** An amount in minor units, as an alert prints it: `$12.40`. */\nexport function formatOperatorAlertAmount(\n cents: number,\n currency = 'usd',\n): string {\n const value = Number(cents) / 100\n if (!Number.isFinite(value)) return ''\n try {\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: String(currency || 'usd').toUpperCase(),\n }).format(value)\n } catch {\n return `${value.toFixed(2)} ${String(currency).toUpperCase()}`\n }\n}\n\n/** Replaces each `{{token}}` with its context value, or nothing. */\nexport function renderOperatorAlertTemplate(\n template: string,\n context: OperatorAlertContext | undefined,\n): string {\n return String(template ?? '')\n .replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_match, name: string) => {\n const value = context?.[name]\n return value === null || value === undefined ? '' : String(value)\n })\n .replace(/\\(\\s*\\)/g, '')\n .replace(/[ \\t]{2,}/g, ' ')\n .replace(/ ([.,;:])/g, '$1')\n .trim()\n}\n\n/** A staff member's answer for one type; an absent key keeps the default. */\nexport interface OperatorAlertTypeSetting {\n enabled?: boolean\n delivery?: OperatorAlertDelivery\n}\n\n/** `platformSettings/operatorAlerts`, keyed by alert type. */\nexport interface OperatorAlertSettings {\n types?: Record<string, OperatorAlertTypeSetting>\n}\n\n/** Whether a type is on, and how it is delivered, after staff's answers. */\nexport function effectiveOperatorAlertSetting(\n definition: Pick<OperatorAlertDefinition, 'type' | 'delivery' | 'defaultEnabled'>,\n settings: OperatorAlertSettings | null | undefined,\n): { enabled: boolean; delivery: OperatorAlertDelivery } {\n const answer = settings?.types?.[definition.type]\n return {\n enabled:\n typeof answer?.enabled === 'boolean'\n ? answer.enabled\n : definition.defaultEnabled,\n delivery:\n answer?.delivery === 'immediate' || answer?.delivery === 'digest'\n ? answer.delivery\n : definition.delivery,\n }\n}\n\n/**\n * Staff notification types whose alert emails the operator on a coded\n * default of immediate (AGL-3375), derived from the catalog so there is one\n * list.\n */\nexport const OPERATOR_ALERT_NOTIFICATION_TYPES: ReadonlySet<string> = new Set(\n CORE_OPERATOR_ALERTS.flatMap((definition) =>\n definition.notificationType ? [definition.notificationType] : [],\n ),\n)\n"],"names":["OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE","OPERATOR_ALERT_TIER_LABELS","must","should","low","OPERATOR_ALERT_TIER_LEVELS","operatorAlertLevel","definition","level","tier","OPERATOR_ALERT_CATEGORY_LABELS","billing","payments","legal","data","ops","deliverability","security","support","DAY","CORE_OPERATOR_ALERTS","type","label","description","category","title","body","link","delivery","dedupeWindowMinutes","defaultEnabled","notificationType","minOccurrences","formatOperatorAlertAmount","cents","currency","value","Number","isFinite","Intl","NumberFormat","style","String","toUpperCase","format","toFixed","renderOperatorAlertTemplate","template","context","replace","_match","name","undefined","trim","effectiveOperatorAlertSetting","settings","answer","types","enabled","OPERATOR_ALERT_NOTIFICATION_TYPES","Set","flatMap"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA6GD,iEAAiE,GACjE,OAAO,MAAMA,2CACX,uBAAsB;AAExB,OAAO,MAAMC,6BAAgE;IAC3EC,MAAM;IACNC,QAAQ;IACRC,KAAK;AACP,EAAC;AAED;;;CAGC,GACD,OAAO,MAAMC,6BAGT;IACFH,MAAM;IACNC,QAAQ;IACRC,KAAK;AACP,EAAC;AAED,kEAAkE,GAClE,OAAO,SAASE,mBACdC,UAA2D;QAEpDA;IAAP,QAAOA,oBAAAA,WAAWC,KAAK,YAAhBD,oBAAoBF,0BAA0B,CAACE,WAAWE,IAAI,CAAC;AACxE;AAEA,OAAO,MAAMC,iCAGT;IACFC,SAAS;IACTC,UAAU;IACVC,OAAO;IACPC,MAAM;IACNC,KAAK;IACLC,gBAAgB;IAChBC,UAAU;IACVC,SAAS;AACX,EAAC;AAED,MAAMC,MAAM,KAAK;AAEjB;;;CAGC,GACD,OAAO,MAAMC,uBAA2D;IACtE,0EAA0E;IAC1E;QACEC,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACE,wEAAwE;QACxE,oEAAoE;QACpEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACV,qEAAqE;QACrEC,qBAAqBV;QACrBW,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACV,uEAAuE;QACvE,qDAAqD;QACrDC,qBAAqBV;QACrBW,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IAEA,2EAA2E;IAC3E;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAIV;QACzBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBG,gBAAgB;QAChBF,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAK;QAC1BC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACV,oEAAoE;QACpEpB,OAAO;QACP,gEAAgE;QAChE,eAAe;QACfqB,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACE,oEAAoE;QACpE,sEAAsE;QACtE,mEAAmE;QACnE,gEAAgE;QAChET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACV,uEAAuE;QACvE,sEAAsE;QACtEC,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACE,mEAAmE;QACnE,qEAAqE;QACrE,kEAAkE;QAClET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVpB,OAAO;QACP,mEAAmE;QACnE,oDAAoD;QACpDqB,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVpB,OAAO;QACP,gEAAgE;QAChE,eAAe;QACfqB,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACE,wEAAwE;QACxE,wEAAwE;QACxE,oEAAoE;QACpE,gDAAgD;QAChDT,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACV,mEAAmE;QACnE,sDAAsD;QACtDC,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVpB,OAAO;QACPqB,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACE,uEAAuE;QACvE,wEAAwE;QACxE,mEAAmE;QACnE,wEAAwE;QACxE,0EAA0E;QAC1ET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVpB,OAAO;QACPqB,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IAEA,2EAA2E;IAC3E;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACF,kEAAkE;QAClEC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;QAClB,2CAA2C;QAC3CvB,OAAO;IACT;IACA;QACEa,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;QAClBvB,OAAO;IACT;IAEA,2EAA2E;IAC3E;QACEa,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACE,sEAAsE;QACtE,wEAAwE;QACxET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACV,kEAAkE;QAClE,qEAAqE;QACrE,+CAA+C;QAC/CC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACE,oEAAoE;QACpET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IAEA,2EAA2E;IAC3E;QACEV,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;CACD,CAAA;AAED,+DAA+D,GAC/D,OAAO,SAASE,0BACdC,KAAa,EACbC,WAAW,KAAK;IAEhB,MAAMC,QAAQC,OAAOH,SAAS;IAC9B,IAAI,CAACG,OAAOC,QAAQ,CAACF,QAAQ,OAAO;IACpC,IAAI;QACF,OAAO,IAAIG,KAAKC,YAAY,CAAC,SAAS;YACpCC,OAAO;YACPN,UAAUO,OAAOP,YAAY,OAAOQ,WAAW;QACjD,GAAGC,MAAM,CAACR;IACZ,EAAE,eAAM;QACN,OAAO,GAAGA,MAAMS,OAAO,CAAC,GAAG,CAAC,EAAEH,OAAOP,UAAUQ,WAAW,IAAI;IAChE;AACF;AAEA,kEAAkE,GAClE,OAAO,SAASG,4BACdC,QAAgB,EAChBC,OAAyC;IAEzC,OAAON,OAAOK,mBAAAA,WAAY,IACvBE,OAAO,CAAC,2BAA2B,CAACC,QAAQC;QAC3C,MAAMf,QAAQY,2BAAAA,OAAS,CAACG,KAAK;QAC7B,OAAOf,UAAU,QAAQA,UAAUgB,YAAY,KAAKV,OAAON;IAC7D,GACCa,OAAO,CAAC,YAAY,IACpBA,OAAO,CAAC,cAAc,KACtBA,OAAO,CAAC,cAAc,MACtBI,IAAI;AACT;AAaA,0EAA0E,GAC1E,OAAO,SAASC,8BACd/C,UAAiF,EACjFgD,QAAkD;QAEnCA;IAAf,MAAMC,SAASD,6BAAAA,kBAAAA,SAAUE,KAAK,qBAAfF,eAAiB,CAAChD,WAAWc,IAAI,CAAC;IACjD,OAAO;QACLqC,SACE,QAAOF,0BAAAA,OAAQE,OAAO,MAAK,YACvBF,OAAOE,OAAO,GACdnD,WAAWuB,cAAc;QAC/BF,UACE4B,CAAAA,0BAAAA,OAAQ5B,QAAQ,MAAK,eAAe4B,CAAAA,0BAAAA,OAAQ5B,QAAQ,MAAK,WACrD4B,OAAO5B,QAAQ,GACfrB,WAAWqB,QAAQ;IAC3B;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAM+B,oCAAyD,IAAIC,IACxExC,qBAAqByC,OAAO,CAAC,CAACtD,aAC5BA,WAAWwB,gBAAgB,GAAG;QAACxB,WAAWwB,gBAAgB;KAAC,GAAG,EAAE,GAEnE"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/operator-alerts.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 { AglynNotificationType, NotificationLevel } from './notifications'\n\n/**\n * OPERATOR ALERTS (AGL-3377): the events whoever runs an install must hear\n * about, as one typed catalog.\n *\n * Every alert is an entry here (or a plugin's entry in the same shape,\n * registered through `@aglyn/aglyn/plugin-manager/operator-alerts`) and is\n * raised through ONE server entry point, `raiseOperatorAlert` in\n * `@aglyn/tenant-data-admin/server/operator-alerts`. That entry point writes\n * the staff console notification, dedupes a flapping condition, honours the\n * per-type switches staff set on Staff → Operator alerts, and delivers by\n * email (the `operator-alert` system email) and the optional out-of-band\n * webhook. No call site composes or sends its own mail.\n *\n * The catalog holds nothing install-specific: no address, no domain, no\n * host. Recipients come from `resolveStaffAlertRecipients()` and the webhook\n * from `OPERATOR_ALERT_WEBHOOK_URL`, so an entry reads the same on aglyn.com,\n * on a self-hosted install and in an OSS consumer's deployment.\n *\n * Copy is written against merge tokens (`{{orgId}}`), filled from the\n * `context` the call site passes. A token the call site did not supply is\n * blanked, so every sentence must still read when its optional tokens are\n * empty. Never put a data subject's address or other personal data into a\n * context: the alert travels to an inbox and a webhook, not only the console.\n */\n\n/**\n * How much an alert matters.\n *\n * - `must` — money moving with nobody looking, a legal duty unmet, data not\n * protected or not erased. On and immediate unless staff change it.\n * - `should` — a fault an operator wants to know about today.\n * - `low` — routine review work; on, and batched into the daily digest.\n */\nexport type OperatorAlertTier = 'must' | 'should' | 'low'\n\nexport type OperatorAlertCategory =\n | 'billing'\n | 'payments'\n | 'legal'\n | 'data'\n | 'ops'\n | 'deliverability'\n | 'security'\n | 'support'\n\n/** `digest` batches the alert into the once-a-day operator digest. */\nexport type OperatorAlertDelivery = 'immediate' | 'digest'\n\nexport interface OperatorAlertDefinition {\n /**\n * Stable id, also the key of its settings row. Never rename: staff\n * switches are stored under it. A plugin's types start with its plugin id\n * and a dot (`commerce.taxNotReversed`).\n */\n type: string\n /** The settings row's name. */\n label: string\n /** What it means and the first thing to do, for the settings page. */\n description: string\n tier: OperatorAlertTier\n category: OperatorAlertCategory\n /** The notification title and email subject, with `{{tokens}}`. */\n title: string\n /** What happened and what to do, with `{{tokens}}`. */\n body: string\n /** Console path the alert opens, with `{{tokens}}`; the call may override. */\n link?: string\n /** The coded default; staff may change it. */\n delivery: OperatorAlertDelivery\n /**\n * How long one `dedupeKey` stays quiet after it fired, in minutes. A\n * condition that flaps inside the window is told once. `0` never dedupes.\n * A call without a `dedupeKey` is never deduped.\n */\n dedupeWindowMinutes: number\n /**\n * Fire only once the same `dedupeKey` has been raised this many times\n * inside the window — for an endpoint any stranger can hit, where one\n * refused request is noise and several are a broken secret. Default 1.\n */\n minOccurrences?: number\n /** The coded default; staff may change it. */\n defaultEnabled: boolean\n /**\n * The console notification's type. Absent is the generic\n * `system.operatorAlert`; a type that already had its own notification\n * keeps it, so its icon, label and everyone's mutes are unchanged.\n */\n notificationType?: AglynNotificationType\n /**\n * How loud its console notification is drawn (AGL-3437). Absent, the\n * tier answers — see {@link operatorAlertLevel}. Set where the tone and\n * the importance part ways: a recovery is worth hearing about and is good\n * news, and a degraded check is a must-know that is not yet an outage.\n */\n level?: NotificationLevel\n /** The plugin that contributed it; absent for core's own. */\n pluginId?: string\n}\n\n/** Values a call site passes for an alert's `{{tokens}}`. */\nexport type OperatorAlertContext = Readonly<\n Record<string, string | number | boolean | null | undefined>\n>\n\n/** The notification type an alert without its own writes under. */\nexport const OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE: AglynNotificationType =\n 'system.operatorAlert'\n\nexport const OPERATOR_ALERT_TIER_LABELS: Record<OperatorAlertTier, string> = {\n must: 'Must know',\n should: 'Should know',\n low: 'Routine',\n}\n\n/**\n * The level an alert's notification is drawn at when its entry names none:\n * a must-know is red, a should-know amber, routine review work informational.\n */\nexport const OPERATOR_ALERT_TIER_LEVELS: Record<\n OperatorAlertTier,\n NotificationLevel\n> = {\n must: 'critical',\n should: 'warning',\n low: 'info',\n}\n\n/** The level an alert's console notification carries (AGL-3437). */\nexport function operatorAlertLevel(\n definition: Pick<OperatorAlertDefinition, 'tier' | 'level'>,\n): NotificationLevel {\n return definition.level ?? OPERATOR_ALERT_TIER_LEVELS[definition.tier]\n}\n\nexport const OPERATOR_ALERT_CATEGORY_LABELS: Record<\n OperatorAlertCategory,\n string\n> = {\n billing: 'Billing',\n payments: 'Payments & payouts',\n legal: 'Legal',\n data: 'Data protection',\n ops: 'Operations',\n deliverability: 'Email deliverability',\n security: 'Security',\n support: 'Support',\n}\n\nconst DAY = 24 * 60\n\n/**\n * Core's alerts. Plugins contribute theirs through the plugin-manager\n * registry; nothing plugin-specific belongs in this list.\n */\nexport const CORE_OPERATOR_ALERTS: readonly OperatorAlertDefinition[] = [\n // ── Fraud, risk and legal duties already alerting before AGL-3377 ──────\n {\n type: 'system.abuseReportUrgent',\n label: 'Urgent abuse, fraud or risk alert',\n description:\n 'Phishing, malware or CSAM reported on a site, card testing, a flagged or disputed payment, a held phishing email, a seller under review. Review it on Staff → Abuse reports.',\n tier: 'must',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n link: '/admin/abuse-reports',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.abuseReportUrgent',\n },\n {\n // The one lock no person placed (AGL-3450): staff confirm it before the\n // customer's appeal arrives, and re-place it as `abuse` or lift it.\n type: 'security.pageSecurityHold',\n label: 'Automatic security hold on a new workspace',\n description:\n 'The page screen held a phishing page from a workspace in its first two weeks and locked the workspace, the site and the publishing account as security. Confirm it on Staff → Abuse reports, then re-place it as abuse on Staff → Lockdown, or lift it there if it is a false positive.',\n tier: 'must',\n category: 'security',\n title: 'Security hold placed on {{orgName}}',\n body:\n '{{page}} on {{siteName}} ({{hostId}}) was held for review: {{signal}} Locked as security, pending your review: {{locks}}. The abuse row is {{reference}}. Lift the hold, or re-place it as abuse, on Staff → Lockdown (/admin/lockdown).',\n link: '/admin/abuse-reports?report={{reviewId}}',\n delivery: 'immediate',\n // One hold per workspace; a repeat inside the day is the same event.\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n notificationType: 'system.abuseReportUrgent',\n },\n {\n type: 'security.paymentFraudHold',\n label: 'Automatic security hold for payment fraud',\n description:\n 'Stripe flagged a workspace\\'s own subscription payment as fraud — a Radar block at highest risk, a Stripe fraud report, three or more cards failing within 30 minutes, an early fraud warning or a Radar review. A new workspace that never paid is locked as security (takedown) with its subscriptions canceled; an older or paying one is not locked and needs a decision. Confirm it on Staff → Abuse reports, then re-place it as abuse on Staff → Lockdown, or lift it there.',\n tier: 'must',\n category: 'security',\n title: '{{action}}: {{orgName}}',\n body:\n '{{trigger}} on {{orgName}} ({{orgId}}). Charges: {{charges}}; cards: {{cards}}, issued in {{cardCountries}}. Locked: {{locks}}. The abuse row is {{reference}}. Lift it, or re-place it as abuse, on Staff → Lockdown (/admin/lockdown).',\n link: '/admin/abuse-reports?report={{reviewId}}',\n delivery: 'immediate',\n // One per workspace per window: every further card in the burst is the\n // same event, and the abuse row carries all of them.\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n notificationType: 'system.abuseReportUrgent',\n },\n {\n type: 'system.disputeUnattributed',\n label: 'Card dispute with no owner',\n description:\n 'A chargeback arrived that no subscription, order or marketplace purchase claimed, so nothing is answering it. Find the charge in Stripe and respond before the evidence deadline.',\n tier: 'must',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.disputeUnattributed',\n },\n {\n type: 'system.dmcaCounterNotice',\n label: 'DMCA counter-notice',\n description:\n 'A customer contested a takedown. The statutory clock is already running; forward it to the complainant and restore on time unless they file suit.',\n tier: 'must',\n category: 'legal',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.dmcaCounterNotice',\n },\n {\n type: 'system.billingWebhookHalfApplied',\n label: 'Billing webhook half applied',\n description:\n 'A Stripe event threw after its handlers began, so its effects may be partly applied and Stripe will not retry it. Reconcile the event by hand.',\n tier: 'must',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.billingWebhookHalfApplied',\n },\n\n // ── MUST: billing ───────────────────────────────────────────────────────\n {\n type: 'billing.platformDisputeLost',\n label: 'Subscription dispute lost',\n description:\n 'A card dispute on a workspace subscription closed as lost: the money has been taken back. Decide whether the workspace keeps its plan.',\n tier: 'must',\n category: 'billing',\n title: 'Subscription dispute lost on workspace {{orgId}}',\n body:\n 'The card dispute {{disputeId}} on invoice {{invoiceId}} for workspace {{orgName}} ({{orgId}}) closed as lost ({{reason}}), and {{amount}} was taken back. The workspace keeps its plan until somebody decides otherwise.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.orphanedSubscription',\n label: 'Stripe is billing a workspace that does not exist',\n description:\n 'A subscription names a workspace that was deleted or erased, so the customer keeps being charged for nothing. Cancel or refund it in Stripe.',\n tier: 'must',\n category: 'billing',\n title: 'Subscription bills a missing workspace',\n body:\n 'Stripe subscription {{subscriptionId}} (customer {{customerId}}, plan {{plan}}) names workspace {{orgId}}, which {{reason}}. The {{eventType}} event changed nothing. Cancel or refund it in Stripe.',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.usageNotReported',\n label: 'Metered usage not reported to Stripe',\n description:\n 'A closed month measured billable usage and it reached no invoice. Report it to Stripe by hand or the revenue is lost.',\n tier: 'must',\n category: 'billing',\n title: 'Usage for {{month}} not billed on {{orgId}}',\n body:\n 'The closed month {{month}} measured {{amount}} of billable usage on workspace {{orgName}} ({{orgId}}), and it was not reported to Stripe ({{reason}}). Nothing will invoice it until somebody does.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 7 * DAY,\n defaultEnabled: true,\n },\n {\n // The scheduled reconciliation of Site Care extra time (AGL-3959): a\n // request's billed hours and the Stripe invoice item that carries them\n // disagree, or one exists without the other. Should know, not must:\n // nothing is lost yet, the next invoice has not gone, and the fix is on\n // the request. The same check compares a flat-quoted project's\n // invoices (AGL-3970), whose findings arrive with `month` \"projects\".\n type: 'billing.siteCareReconciliation',\n label: 'Site Care billing does not match Stripe',\n description:\n 'A Site Care request’s billed hours, amount or line description disagree with its Stripe invoice item, a Stripe item has no request row behind it, a month’s total disagrees with its statement, or a project invoice’s status or amount disagrees with Stripe (or exists in Stripe with no project row). Open the workspace’s staff page and correct it from the request or the project — never by hand in Stripe.',\n tier: 'should',\n category: 'billing',\n title: 'Site Care billing mismatch on {{orgName}} ({{month}})',\n body:\n '{{count}} Site Care finding(s) on workspace {{orgName}} ({{orgId}}) for {{month}}: {{findings}} Fix it from the request on the staff organization page.',\n link: '/admin/orgs/{{orgId}}#site-care',\n delivery: 'immediate',\n // One alert a day per workspace and month: the same finding re-checked\n // tomorrow is the same news.\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.webhookSignatureRejected',\n label: 'Billing webhook failing its signature check',\n description:\n 'Repeated deliveries to the billing webhook were refused because their signature did not match. Usually a rolled signing secret: until it is fixed, Stripe cannot tell the install about money.',\n tier: 'must',\n category: 'billing',\n title: 'Billing webhook is refusing deliveries',\n body:\n 'The billing webhook refused several signed deliveries within six hours because their Stripe signature matched none of the configured secrets. If Stripe is the sender, its signing secret no longer matches this install’s, and no billing event is being applied.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n minOccurrences: 3,\n defaultEnabled: true,\n },\n {\n type: 'billing.webhookInert',\n label: 'Billing webhook delivery moved nothing',\n description:\n 'Stripe delivered an event this install subscribed to, and no handler did anything with it. Usually a handler that stopped being registered.',\n tier: 'must',\n category: 'billing',\n title: 'A {{eventType}} delivery changed nothing',\n body:\n 'Stripe event {{eventId}} ({{eventType}}) was accepted and no handler acted on it. Check which handler stopped being registered, then replay the event.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n\n // ── MUST: data protection and records ───────────────────────────────────\n {\n type: 'data.personErasureFailed',\n label: 'Personal data erasure failed',\n description:\n 'A data subject’s erasure request failed. It will be retried, but the legal deadline keeps running: read the error and fix the cause.',\n tier: 'must',\n category: 'data',\n title: 'Erasure request {{requestId}} failed',\n body:\n 'The erasure request {{requestId}} on workspace {{orgName}} ({{orgId}}) failed (attempt {{attempt}}): {{error}}. The statutory deadline keeps running while it retries.',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'data.orgErasureFailed',\n label: 'Workspace erasure failed',\n description:\n 'A workspace scheduled for erasure could not be erased. Its data is still held after the date it was promised gone.',\n tier: 'must',\n category: 'data',\n title: 'Workspace {{orgId}} could not be erased',\n body:\n 'The scheduled erasure of workspace {{orgName}} ({{orgId}}) did not complete: {{reason}}. It will be retried on the next run; its data is held until it succeeds.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'data.backupExportFailed',\n label: 'Database export failed',\n description:\n 'The scheduled database export did not start. A stale export means the worst day has nothing to restore from.',\n tier: 'must',\n category: 'data',\n title: 'Database export failed',\n body:\n 'The scheduled database export did not start ({{reason}}). The last good export is getting older; check the service account’s export permission and the bucket.',\n link: '/admin/health',\n delivery: 'immediate',\n dedupeWindowMinutes: 12 * 60,\n defaultEnabled: true,\n },\n\n // ── MUST: legal ─────────────────────────────────────────────────────────\n {\n type: 'legal.abuseReportHigh',\n label: 'Takedown, impersonation or illegal-content report',\n description:\n 'A DMCA takedown, impersonation or illegal-content report. Each carries a response duty; review it on Staff → Abuse reports.',\n tier: 'must',\n category: 'legal',\n title: '{{category}} report on {{site}}',\n body:\n '{{site}} was reported for {{category}}. Reference {{reference}}. Review it and respond within the policy’s window.',\n link: '/admin/abuse-reports',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n },\n\n // ── MUST/SHOULD: health ─────────────────────────────────────────────────\n {\n type: 'system.healthDegraded',\n label: 'Health check degraded',\n description:\n 'A health check went from healthy to degraded: scheduled jobs silent, backups stale, billing deliveries failing, sign-ups refused, server errors, a site not rendering. Told once per incident.',\n tier: 'must',\n category: 'ops',\n title: '{{check}} is degraded',\n body: '{{check}} went from healthy to degraded. {{detail}}',\n link: '/admin/health',\n delivery: 'immediate',\n // Must-know, and still amber rather than red: degraded is not down.\n level: 'warning',\n // A check flapping across the line is told once an hour, not on\n // every swing.\n dedupeWindowMinutes: 60,\n defaultEnabled: true,\n },\n {\n // The reminder the edge alert never sends (AGL-3860): raised by the\n // operator alerts tick at 1 h and every 2 h after while a check stays\n // degraded. The tick raises it as Must know from 2 h, so its level\n // follows the tier it was raised at rather than one coded here.\n type: 'system.healthStillDegraded',\n label: 'Health check still degraded',\n description:\n 'A health check has stayed degraded for an hour or more. Told at 1 h and again every 2 h until it recovers; Should know for the first reminder, Must know from 2 h on. Open the failing endpoint, read the failing check’s code and fix the cause, or the check when the check is what is wrong.',\n tier: 'should',\n category: 'ops',\n title: '{{check}} is still degraded after {{duration}}',\n body: '{{check}} is still degraded after {{duration}}. {{detail}}',\n link: '/admin/health',\n delivery: 'immediate',\n // The tick dedupes each reminder per check per step on the check's own\n // state; the window only stops a retried tick from telling one twice.\n dedupeWindowMinutes: 60,\n defaultEnabled: true,\n },\n {\n // The post-deploy production canary (AGL-3567), raised from GitHub\n // Actions through `/api/admin/operator-alerts/canary`. Red because a\n // deploy that stops rendering pages is every published site down.\n type: 'ops.productionCanaryRed',\n label: 'Production canary failed after a deploy',\n description:\n 'Right after a tenant or console production deploy, uncached pages stopped rendering. The canary rolls the deploy back by itself when the failure holds across sites; after any rollback the next release must be promoted by hand.',\n tier: 'must',\n category: 'ops',\n title: '{{project}} production canary: {{outcome}}',\n body: '{{detail}}',\n link: '/admin/health',\n delivery: 'immediate',\n level: 'critical',\n // The caller dedupes per deployment and verdict; this only stops a\n // re-run of the same red run from telling it twice.\n dedupeWindowMinutes: 30,\n defaultEnabled: true,\n },\n {\n type: 'system.healthRecovered',\n label: 'Health check recovered',\n description: 'A degraded health check is healthy again.',\n tier: 'should',\n category: 'ops',\n title: '{{check}} recovered',\n body: '{{check}} is healthy again after {{duration}}.',\n link: '/admin/health',\n delivery: 'immediate',\n level: 'success',\n // A check flapping across the line is told once an hour, not on\n // every swing.\n dedupeWindowMinutes: 60,\n defaultEnabled: true,\n },\n {\n // The render monitor (AGL-3568): a published site fetched over HTTP, on\n // pages no cache can answer, failed several runs in a row. Red, not the\n // amber of a degraded check: every visitor without a cached copy is\n // getting an error page or a timeout right now.\n type: 'system.siteRenderFailing',\n label: 'Published site not rendering pages',\n description:\n 'The render monitor asked a published site for pages no cache can answer, several runs in a row, and got no complete page back. Visitors to cached pages may still see them, so the site can look up while every other page fails. Check the latest site runtime deployment first and roll it back if it just shipped.',\n tier: 'must',\n category: 'ops',\n title: 'Pages are not rendering on {{site}}',\n body:\n '{{site}} failed {{failures}} render checks in a row, over {{since}}. {{detail}} Cached pages may still load, so the site can look up while new renders fail.',\n link: '/admin/health',\n delivery: 'immediate',\n // Told once per outage: the monitor raises it on the edge, and the\n // window only covers a site flapping across the line.\n dedupeWindowMinutes: 30,\n defaultEnabled: true,\n },\n {\n type: 'system.siteRenderRecovered',\n label: 'Published site rendering again',\n description:\n 'A published site the render monitor had reported as not rendering serves fresh pages again.',\n tier: 'should',\n category: 'ops',\n title: '{{site}} is rendering pages again',\n body: 'Fresh page renders on {{site}} pass again after {{duration}}.',\n link: '/admin/health',\n delivery: 'immediate',\n level: 'success',\n dedupeWindowMinutes: 30,\n defaultEnabled: true,\n },\n {\n // The render monitor could not see the site (AGL-3580): bot protection\n // answered every probe it made, so it knows nothing about whether pages\n // render. Not an outage, and never worded as one — on 2026-10-05 a\n // missing probe token read as \"pages are not rendering\" for three hours\n // while the site served pages. Amber: detection is off until it is fixed.\n type: 'system.renderMonitorBlind',\n label: 'Render monitor cannot see a site',\n description:\n 'Bot protection answered the render monitor instead of a published site, several runs in a row, so nothing is checking whether that site renders pages. The site itself may be fine. Check AGLYN_PROBE_TOKEN on the console and the \"CI and uptime probe bypass\" firewall rule on the site runtime.',\n tier: 'must',\n category: 'ops',\n title: 'The render monitor cannot see {{site}}',\n body:\n 'Bot protection answered instead of {{site}} for {{runs}} runs in a row, so nothing is checking its pages. {{detail}} This is not a verdict on the site.',\n link: '/admin/health',\n delivery: 'immediate',\n level: 'warning',\n dedupeWindowMinutes: 360,\n defaultEnabled: true,\n },\n\n // ── SHOULD: billing ─────────────────────────────────────────────────────\n {\n type: 'billing.autoLocked',\n label: 'Workspace billing locked automatically',\n description:\n 'A workspace crossed its spend guard and the platform locked its billable activity. Check it is not a false positive before the customer notices.',\n tier: 'should',\n category: 'billing',\n title: 'Billing locked on {{orgName}}',\n body:\n 'Workspace {{orgName}} ({{orgId}}) was locked automatically: {{reason}}.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.platformInvoiceUncollectible',\n label: 'Subscription invoice voided or uncollectible',\n description:\n 'A workspace invoice was voided or marked uncollectible: revenue the books expected is not coming.',\n tier: 'should',\n category: 'billing',\n title: 'Invoice {{status}} on {{orgId}}',\n body:\n 'Invoice {{invoiceId}} for {{amount}} on workspace {{orgName}} ({{orgId}}) was marked {{status}}, so Stripe has stopped collecting it.',\n link: '/admin/orgs/{{orgId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'billing.connectPayoutFailed',\n label: 'Connected account payout or transfer failed',\n description:\n 'Money due to a merchant or seller did not arrive. Stripe retries on its own schedule; a human decides what happens next.',\n tier: 'should',\n category: 'billing',\n title: 'A {{kind}} to {{account}} failed',\n body:\n 'A {{amount}} Stripe {{kind}} ({{stripeId}}) to connected account {{account}} ({{merchant}}) failed: {{reason}}. The merchant’s funds are not where the ledger says.',\n delivery: 'immediate',\n dedupeWindowMinutes: 30 * DAY,\n defaultEnabled: true,\n },\n {\n type: 'staff.paymentFailed',\n label: 'Workspace payment failed',\n description:\n 'A paying workspace’s renewal failed. Stripe is retrying; the customer has been told.',\n tier: 'should',\n category: 'billing',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'staff.paymentFailed',\n },\n\n // ── SHOULD: security ────────────────────────────────────────────────────\n {\n type: 'security.staffGranted',\n label: 'Staff access granted or raised',\n description:\n 'Somebody was made staff, or a staff role was raised. If nobody on the team did it, revoke it and rotate credentials.',\n tier: 'should',\n category: 'security',\n title: '{{target}} is now {{role}} staff',\n body: '{{actor}} granted {{target}} the {{role}} staff role (was {{previous}}).',\n link: '/admin/users',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n },\n\n // ── SHOULD: support ─────────────────────────────────────────────────────\n {\n type: 'support.slaBreached',\n label: 'Support ticket past its response time',\n description:\n 'A support ticket passed its promised response time without a staff reply.',\n tier: 'should',\n category: 'support',\n title: 'Ticket past its response time: {{subject}}',\n body:\n 'The {{tier}} ticket “{{subject}}” from workspace {{orgName}} ({{orgId}}) was due a first response {{overdue}} ago and has none.',\n // The ticket itself: the support page opens `?ticketId=` on load.\n link: '/admin/support?ticketId={{ticketId}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'support.ticketOpened',\n label: 'New support ticket',\n description: 'A subscriber opened a support ticket.',\n tier: 'should',\n category: 'support',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'support.ticketOpened',\n // A customer asking for help, not a fault.\n level: 'info',\n },\n {\n type: 'support.ticketReply',\n label: 'Support ticket reply',\n description: 'A subscriber replied on a support ticket.',\n tier: 'should',\n category: 'support',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'support.ticketReply',\n level: 'info',\n },\n\n // ── SHOULD: deliverability ──────────────────────────────────────────────\n {\n type: 'deliverability.sendRateSaturated',\n label: 'Email send-rate governor saturated',\n description:\n 'The platform’s email send-rate budget is exhausted, so sends are being deferred. Raise the budget or find the sender filling it.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email sending is being throttled',\n body:\n 'The {{scope}} send-rate budget is full ({{detail}}), so sends are being deferred.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.sendRateUnavailable',\n label: 'Email send-rate governor unavailable',\n description:\n 'The send-rate governor could not read its counters, so mail is going out unmetered: nothing is holding the shared domains under their hourly ceiling.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email send-rate governor unavailable',\n body:\n 'The send-rate governor could not read its counters ({{error}}), so sends are being allowed unmetered until it can.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.gatewayBlocking',\n label: 'A recipient gateway is blocking the shared domain',\n description:\n 'A mail gateway is refusing mail from the shared sending domain, so everyone behind that gateway stops receiving it.',\n tier: 'should',\n category: 'deliverability',\n title: '{{gateway}} is blocking {{domain}}',\n body:\n 'Mail from {{domain}} is being refused by {{gateway}} ({{detail}}). Recipients behind that gateway are not receiving it.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'deliverability.providerCredentialsRejected',\n label: 'Email provider rejected the credentials',\n description:\n 'The email provider refused the API key, or a sending pool is unhealthy: invites, password resets and receipts are not leaving. The webhook channel is the one that still reaches you.',\n tier: 'should',\n category: 'deliverability',\n title: 'Email delivery is failing: {{problem}}',\n body: '{{detail}}',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n // A purchasing ceiling, not a job failure: the provisioning sweep ran\n // and every site keeps sending, so the copy must not read as an outage.\n type: 'deliverability.sendingDomainCapacityFull',\n label: 'Sending-domain allowance full',\n description:\n 'Every dedicated sending domain the provider allowance covers is in use, so new sites that ask for one stay on the shared pool. No mail is lost; raise the allowance or move merchants onto domains they own.',\n tier: 'should',\n category: 'deliverability',\n title: 'Sending-domain allowance full ({{held}}/{{capacity}})',\n body:\n 'The sending-domain allowance is full ({{held}} of {{capacity}} in use), so sites that asked for a dedicated domain keep sending on the shared pool. No mail is lost: what they go without is the isolation a domain of their own buys, and their campaigns are held to the pool’s stricter reputation limits. Raise AGLYN_SENDING_DOMAIN_CAPACITY once the provider allowance covers it, or move merchants onto domains they own.',\n link: '/admin/emails',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n\n // ── SHOULD: operations ──────────────────────────────────────────────────\n {\n type: 'ops.reaperStuck',\n label: 'A reaper is stuck or failing',\n description:\n 'A cleanup job (sending domains, unverified workspaces) could not finish, so what it removes keeps accumulating.',\n tier: 'should',\n category: 'ops',\n title: '{{job}} is stuck',\n body: '{{job}} could not finish: {{detail}}',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'ops.pluginJobFailed',\n label: 'A plugin job failed',\n description:\n 'A plugin’s scheduled job threw: consent-group changes, the publish outbox, sending-domain provisioning, a plugin cron.',\n tier: 'should',\n category: 'ops',\n // `job` is a noun phrase that reads as a subject on its own (\"The\n // publish outbox drain\", \"The scheduled job acme-mail-send\"), so the\n // title and the body open with the same words.\n title: '{{job}} failed',\n body: '{{job}} failed: {{error}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 6 * 60,\n defaultEnabled: true,\n },\n {\n // Nightly, so once a day per provider is every run it could report.\n type: 'ops.adAudienceSyncFailed',\n label: 'Ad audience sync failed',\n description:\n 'The nightly account-holder audience sync (AGL-3780) could not update a Meta, Google or LinkedIn audience, or held a run whose audience would have shrunk by more than half. The audience keeps what it last had; check the provider credentials, or run the sync with force once the drop is confirmed.',\n tier: 'should',\n category: 'ops',\n title: 'Ad audience sync: {{provider}} not updated',\n body: 'The {{provider}} account-holder audience was not updated: {{detail}}. It keeps what it was last sent, and the next nightly run tries again.',\n link: '/admin/flags',\n delivery: 'immediate',\n dedupeWindowMinutes: DAY,\n defaultEnabled: true,\n },\n {\n type: 'system.pluginVerifierRegression',\n label: 'Plugin verifier regression',\n description:\n 'A live plugin version stopped passing the static verifier.',\n tier: 'should',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.pluginVerifierRegression',\n },\n {\n type: 'system.bandwidthCeilingTripped',\n label: 'Bandwidth ceiling reached',\n description:\n 'A site crossed the per-month bandwidth abuse ceiling.',\n tier: 'should',\n category: 'ops',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.bandwidthCeilingTripped',\n },\n\n // ── LOW ─────────────────────────────────────────────────────────────────\n {\n type: 'marketplace.review',\n label: 'Plugin listing waiting for review',\n description: 'A plugin listing or version was submitted for review.',\n tier: 'low',\n category: 'security',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'marketplace.review',\n },\n {\n type: 'system.scopeDrift',\n label: 'Resources missing a sharing scope',\n description:\n 'The weekly sweep found documents invisible to every site-scoped read: a creation path forgot the field.',\n tier: 'low',\n category: 'data',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'digest',\n dedupeWindowMinutes: 0,\n defaultEnabled: true,\n notificationType: 'system.scopeDrift',\n },\n]\n\n/** An amount in minor units, as an alert prints it: `$12.40`. */\nexport function formatOperatorAlertAmount(\n cents: number,\n currency = 'usd',\n): string {\n const value = Number(cents) / 100\n if (!Number.isFinite(value)) return ''\n try {\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: String(currency || 'usd').toUpperCase(),\n }).format(value)\n } catch {\n return `${value.toFixed(2)} ${String(currency).toUpperCase()}`\n }\n}\n\n/** Replaces each `{{token}}` with its context value, or nothing. */\nexport function renderOperatorAlertTemplate(\n template: string,\n context: OperatorAlertContext | undefined,\n): string {\n return String(template ?? '')\n .replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_match, name: string) => {\n const value = context?.[name]\n return value === null || value === undefined ? '' : String(value)\n })\n .replace(/\\(\\s*\\)/g, '')\n .replace(/[ \\t]{2,}/g, ' ')\n .replace(/ ([.,;:])/g, '$1')\n .trim()\n}\n\n/** A staff member's answer for one type; an absent key keeps the default. */\nexport interface OperatorAlertTypeSetting {\n enabled?: boolean\n delivery?: OperatorAlertDelivery\n}\n\n/** `platformSettings/operatorAlerts`, keyed by alert type. */\nexport interface OperatorAlertSettings {\n types?: Record<string, OperatorAlertTypeSetting>\n}\n\n/** Whether a type is on, and how it is delivered, after staff's answers. */\nexport function effectiveOperatorAlertSetting(\n definition: Pick<OperatorAlertDefinition, 'type' | 'delivery' | 'defaultEnabled'>,\n settings: OperatorAlertSettings | null | undefined,\n): { enabled: boolean; delivery: OperatorAlertDelivery } {\n const answer = settings?.types?.[definition.type]\n return {\n enabled:\n typeof answer?.enabled === 'boolean'\n ? answer.enabled\n : definition.defaultEnabled,\n delivery:\n answer?.delivery === 'immediate' || answer?.delivery === 'digest'\n ? answer.delivery\n : definition.delivery,\n }\n}\n\n/**\n * Staff notification types whose alert emails the operator on a coded\n * default of immediate (AGL-3375), derived from the catalog so there is one\n * list.\n */\nexport const OPERATOR_ALERT_NOTIFICATION_TYPES: ReadonlySet<string> = new Set(\n CORE_OPERATOR_ALERTS.flatMap((definition) =>\n definition.notificationType ? [definition.notificationType] : [],\n ),\n)\n"],"names":["OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE","OPERATOR_ALERT_TIER_LABELS","must","should","low","OPERATOR_ALERT_TIER_LEVELS","operatorAlertLevel","definition","level","tier","OPERATOR_ALERT_CATEGORY_LABELS","billing","payments","legal","data","ops","deliverability","security","support","DAY","CORE_OPERATOR_ALERTS","type","label","description","category","title","body","link","delivery","dedupeWindowMinutes","defaultEnabled","notificationType","minOccurrences","formatOperatorAlertAmount","cents","currency","value","Number","isFinite","Intl","NumberFormat","style","String","toUpperCase","format","toFixed","renderOperatorAlertTemplate","template","context","replace","_match","name","undefined","trim","effectiveOperatorAlertSetting","settings","answer","types","enabled","OPERATOR_ALERT_NOTIFICATION_TYPES","Set","flatMap"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA6GD,iEAAiE,GACjE,OAAO,MAAMA,2CACX,uBAAsB;AAExB,OAAO,MAAMC,6BAAgE;IAC3EC,MAAM;IACNC,QAAQ;IACRC,KAAK;AACP,EAAC;AAED;;;CAGC,GACD,OAAO,MAAMC,6BAGT;IACFH,MAAM;IACNC,QAAQ;IACRC,KAAK;AACP,EAAC;AAED,kEAAkE,GAClE,OAAO,SAASE,mBACdC,UAA2D;QAEpDA;IAAP,QAAOA,oBAAAA,WAAWC,KAAK,YAAhBD,oBAAoBF,0BAA0B,CAACE,WAAWE,IAAI,CAAC;AACxE;AAEA,OAAO,MAAMC,iCAGT;IACFC,SAAS;IACTC,UAAU;IACVC,OAAO;IACPC,MAAM;IACNC,KAAK;IACLC,gBAAgB;IAChBC,UAAU;IACVC,SAAS;AACX,EAAC;AAED,MAAMC,MAAM,KAAK;AAEjB;;;CAGC,GACD,OAAO,MAAMC,uBAA2D;IACtE,0EAA0E;IAC1E;QACEC,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACE,wEAAwE;QACxE,oEAAoE;QACpEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACV,qEAAqE;QACrEC,qBAAqBV;QACrBW,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACV,uEAAuE;QACvE,qDAAqD;QACrDC,qBAAqBV;QACrBW,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IAEA,2EAA2E;IAC3E;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAIV;QACzBW,gBAAgB;IAClB;IACA;QACE,qEAAqE;QACrE,uEAAuE;QACvE,oEAAoE;QACpE,wEAAwE;QACxE,+DAA+D;QAC/D,sEAAsE;QACtET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACV,uEAAuE;QACvE,6BAA6B;QAC7BC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBG,gBAAgB;QAChBF,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAK;QAC1BC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACV,oEAAoE;QACpEpB,OAAO;QACP,gEAAgE;QAChE,eAAe;QACfqB,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACE,oEAAoE;QACpE,sEAAsE;QACtE,mEAAmE;QACnE,gEAAgE;QAChET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACV,uEAAuE;QACvE,sEAAsE;QACtEC,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACE,mEAAmE;QACnE,qEAAqE;QACrE,kEAAkE;QAClET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVpB,OAAO;QACP,mEAAmE;QACnE,oDAAoD;QACpDqB,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVpB,OAAO;QACP,gEAAgE;QAChE,eAAe;QACfqB,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACE,wEAAwE;QACxE,wEAAwE;QACxE,oEAAoE;QACpE,gDAAgD;QAChDT,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACV,mEAAmE;QACnE,sDAAsD;QACtDC,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVpB,OAAO;QACPqB,qBAAqB;QACrBC,gBAAgB;IAClB;IACA;QACE,uEAAuE;QACvE,wEAAwE;QACxE,mEAAmE;QACnE,wEAAwE;QACxE,0EAA0E;QAC1ET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVpB,OAAO;QACPqB,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFE,UAAU;QACVC,qBAAqB,KAAKV;QAC1BW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IAEA,2EAA2E;IAC3E;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACF,kEAAkE;QAClEC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;QAClB,2CAA2C;QAC3CvB,OAAO;IACT;IACA;QACEa,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;QAClBvB,OAAO;IACT;IAEA,2EAA2E;IAC3E;QACEa,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACE,sEAAsE;QACtE,wEAAwE;QACxET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MACE;QACFC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IAEA,2EAA2E;IAC3E;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACV,kEAAkE;QAClE,qEAAqE;QACrE,+CAA+C;QAC/CC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB,IAAI;QACzBC,gBAAgB;IAClB;IACA;QACE,oEAAoE;QACpET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNC,MAAM;QACNC,UAAU;QACVC,qBAAqBV;QACrBW,gBAAgB;IAClB;IACA;QACET,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IAEA,2EAA2E;IAC3E;QACEV,MAAM;QACNC,OAAO;QACPC,aAAa;QACbd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;IACA;QACEV,MAAM;QACNC,OAAO;QACPC,aACE;QACFd,MAAM;QACNe,UAAU;QACVC,OAAO;QACPC,MAAM;QACNE,UAAU;QACVC,qBAAqB;QACrBC,gBAAgB;QAChBC,kBAAkB;IACpB;CACD,CAAA;AAED,+DAA+D,GAC/D,OAAO,SAASE,0BACdC,KAAa,EACbC,WAAW,KAAK;IAEhB,MAAMC,QAAQC,OAAOH,SAAS;IAC9B,IAAI,CAACG,OAAOC,QAAQ,CAACF,QAAQ,OAAO;IACpC,IAAI;QACF,OAAO,IAAIG,KAAKC,YAAY,CAAC,SAAS;YACpCC,OAAO;YACPN,UAAUO,OAAOP,YAAY,OAAOQ,WAAW;QACjD,GAAGC,MAAM,CAACR;IACZ,EAAE,eAAM;QACN,OAAO,GAAGA,MAAMS,OAAO,CAAC,GAAG,CAAC,EAAEH,OAAOP,UAAUQ,WAAW,IAAI;IAChE;AACF;AAEA,kEAAkE,GAClE,OAAO,SAASG,4BACdC,QAAgB,EAChBC,OAAyC;IAEzC,OAAON,OAAOK,mBAAAA,WAAY,IACvBE,OAAO,CAAC,2BAA2B,CAACC,QAAQC;QAC3C,MAAMf,QAAQY,2BAAAA,OAAS,CAACG,KAAK;QAC7B,OAAOf,UAAU,QAAQA,UAAUgB,YAAY,KAAKV,OAAON;IAC7D,GACCa,OAAO,CAAC,YAAY,IACpBA,OAAO,CAAC,cAAc,KACtBA,OAAO,CAAC,cAAc,MACtBI,IAAI;AACT;AAaA,0EAA0E,GAC1E,OAAO,SAASC,8BACd/C,UAAiF,EACjFgD,QAAkD;QAEnCA;IAAf,MAAMC,SAASD,6BAAAA,kBAAAA,SAAUE,KAAK,qBAAfF,eAAiB,CAAChD,WAAWc,IAAI,CAAC;IACjD,OAAO;QACLqC,SACE,QAAOF,0BAAAA,OAAQE,OAAO,MAAK,YACvBF,OAAOE,OAAO,GACdnD,WAAWuB,cAAc;QAC/BF,UACE4B,CAAAA,0BAAAA,OAAQ5B,QAAQ,MAAK,eAAe4B,CAAAA,0BAAAA,OAAQ5B,QAAQ,MAAK,WACrD4B,OAAO5B,QAAQ,GACfrB,WAAWqB,QAAQ;IAC3B;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAM+B,oCAAyD,IAAIC,IACxExC,qBAAqByC,OAAO,CAAC,CAACtD,aAC5BA,WAAWwB,gBAAgB,GAAG;QAACxB,WAAWwB,gBAAgB;KAAC,GAAG,EAAE,GAEnE"}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
17
|
import { listPluginOrgPermissions, subscribePluginEntitlements } from "../plugin-manager/plugin-entitlements.js";
|
|
18
18
|
import { ORG_ROLE_PERMISSION_KEYS } from "./org-roles.js";
|
|
19
|
+
import { CORE_ORG_PERMISSION_ENTRIES, CORE_ORG_ROLE_GRANTS } from "./org-role-grants.js";
|
|
19
20
|
/**
|
|
20
21
|
* The core keys, in display order for role editors.
|
|
21
22
|
*
|
|
@@ -37,69 +38,17 @@ import { ORG_ROLE_PERMISSION_KEYS } from "./org-roles.js";
|
|
|
37
38
|
*
|
|
38
39
|
* `apps/console/specs/org-permissions-are-enforced.spec.ts` fails the build if
|
|
39
40
|
* a key here, or a key a plugin declares, gains no server-side consumer.
|
|
40
|
-
*/ const CORE_ORG_PERMISSIONS =
|
|
41
|
-
|
|
42
|
-
key:
|
|
43
|
-
|
|
44
|
-
description: 'Rename the organization, change its slug, transfer it.'
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
key: 'org.auditLog',
|
|
48
|
-
label: 'Activity & audit log',
|
|
49
|
-
description: 'See the organization activity feed.'
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
key: 'billing.view',
|
|
53
|
-
label: 'View billing',
|
|
54
|
-
description: 'See the plan, usage meters, and invoices.'
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
key: 'billing.manage',
|
|
58
|
-
label: 'Manage billing',
|
|
59
|
-
description: 'Change plans, buy add-ons, update payment details.'
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
key: 'members.manage',
|
|
63
|
-
label: 'Manage members',
|
|
64
|
-
description: 'Invite, remove, and re-role organization members.'
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
key: 'hosts.create',
|
|
68
|
-
label: 'Create sites',
|
|
69
|
-
description: 'Create new sites in the organization.'
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
key: 'hosts.delete',
|
|
73
|
-
label: 'Delete sites',
|
|
74
|
-
description: 'Delete sites the member can access.'
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
key: 'data.manage',
|
|
78
|
-
label: 'Manage data',
|
|
79
|
-
description: 'Create, edit, and delete organization datasets.'
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
key: 'marketplace.publish',
|
|
83
|
-
label: 'Publish to marketplace',
|
|
84
|
-
description: 'Publish listings under the organization profile.'
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
key: 'plugins.install',
|
|
88
|
-
label: 'Install plugins',
|
|
89
|
-
description: 'Install or remove marketplace plugins.'
|
|
90
|
-
}
|
|
91
|
-
];
|
|
41
|
+
*/ const CORE_ORG_PERMISSIONS = // One table, shared with the native apps through the contracts (AGL-3950).
|
|
42
|
+
CORE_ORG_PERMISSION_ENTRIES.map((entry)=>_extends({}, entry, {
|
|
43
|
+
key: entry.key
|
|
44
|
+
}));
|
|
92
45
|
/** The core keys: a plugin may add keys beside them and never redefine one. */ const CORE_ORG_PERMISSION_KEYS = new Set(CORE_ORG_PERMISSIONS.map((definition)=>definition.key));
|
|
93
46
|
/** The legacy camelCase projection's keys, which no declaration may take either. */ const LEGACY_PERMISSION_KEYS = new Set(ORG_ROLE_PERMISSION_KEYS);
|
|
94
|
-
/** The core keys each org role holds when nothing refines it. */ const CORE_ROLE_GRANTS = {
|
|
95
|
-
owner:
|
|
96
|
-
admin:
|
|
97
|
-
editor: new Set(
|
|
98
|
-
|
|
99
|
-
'marketplace.publish',
|
|
100
|
-
'plugins.install'
|
|
101
|
-
]),
|
|
102
|
-
viewer: new Set()
|
|
47
|
+
/** The core keys each org role holds when nothing refines it (`org-role-grants.ts`). */ const CORE_ROLE_GRANTS = {
|
|
48
|
+
owner: new Set(CORE_ORG_ROLE_GRANTS.owner),
|
|
49
|
+
admin: new Set(CORE_ORG_ROLE_GRANTS.admin),
|
|
50
|
+
editor: new Set(CORE_ORG_ROLE_GRANTS.editor),
|
|
51
|
+
viewer: new Set(CORE_ORG_ROLE_GRANTS.viewer)
|
|
103
52
|
};
|
|
104
53
|
const ORG_ROLES = [
|
|
105
54
|
'owner',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/org-permissions.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 { AglynOrgMember, OrgRole } from '../foundation'\nimport {\n listPluginOrgPermissions,\n subscribePluginEntitlements,\n} from '../plugin-manager/plugin-entitlements'\nimport { ORG_ROLE_PERMISSION_KEYS } from './org-roles'\n\n/**\n * Granular org permissions (AGL-243) layered on the 4 org roles: each\n * role maps to a default permission set; custom roles\n * (`orgs/{orgId}/roles/{roleId}`) and per-member overrides refine it, so\n * e.g. an editor restricted to one host can also be granted billing\n * visibility — or an admin stripped of it — without inventing new roles.\n * The console hides unpermitted surfaces; the org API routes are the\n * enforcement point.\n */\nexport type CoreOrgPermission =\n | 'org.settings'\n | 'org.auditLog'\n | 'billing.view'\n | 'billing.manage'\n | 'members.manage'\n | 'hosts.create'\n | 'hosts.delete'\n | 'data.manage'\n | 'marketplace.publish'\n | 'plugins.install'\n\n/**\n * A key of the catalog: a core key above, or a key a plugin declares into\n * the catalog through `registerPluginEntitlements` (AGL-2984). A declared key\n * is stored on custom roles and member overrides, layered and resolved\n * exactly like a core key.\n */\nexport type OrgPermission = CoreOrgPermission | (string & {})\n\n/**\n * Legacy boolean permission map derived from the granular `OrgPermission`\n * set (AGL-243). Kept for the console surfaces that predate the granular\n * model; lives here so relocated feature plugins can accept it as a prop\n * (AGL-395).\n */\nexport interface OrgPermissions {\n createHosts: boolean\n editHosts: boolean\n editBilling: boolean\n publishToMarketplace: boolean\n installPlugins: boolean\n manageMembers: boolean\n}\n\nexport interface OrgPermissionDefinition {\n key: OrgPermission\n label: string\n description: string\n /** The plugin that declared the key; absent for a core key. */\n pluginId?: string\n}\n\n/**\n * The core keys, in display order for role editors.\n *\n * ⚠️ **EVERY KEY HERE MUST BE ENFORCED SERVER-SIDE.** A permission a customer\n * can tick that changes nothing is worse than its absence, because it implies\n * a control that does not exist — an owner unticks \"delete sites\", hands the\n * role out, and the member deletes sites while the console does not even dim\n * the button. Three of the eleven were in exactly that state (AGL-2444).\n *\n * `marketing.manage` was removed rather than wired, and the reason is worth\n * keeping: announcement bars, popups and campaigns live at\n * `hosts/{hostId}/overlays|campaigns|experiments` and are written\n * client-direct against the security rules, which gate on the HOST role.\n * There is no org-level boundary for it to sit on, and the action it named is\n * not org-scoped at all. Wiring it would have meant inventing one and\n * producing a second permission that looks enforced and is not. If a granular\n * marketing permission is wanted it belongs in the plugin-declared mechanism\n * (AGL-435), scoped to a host — a product decision, not this repair.\n *\n * `apps/console/specs/org-permissions-are-enforced.spec.ts` fails the build if\n * a key here, or a key a plugin declares, gains no server-side consumer.\n */\nconst CORE_ORG_PERMISSIONS: readonly OrgPermissionDefinition[] = [\n {\n key: 'org.settings',\n label: 'Organization settings',\n description: 'Rename the organization, change its slug, transfer it.',\n },\n {\n key: 'org.auditLog',\n label: 'Activity & audit log',\n description: 'See the organization activity feed.',\n },\n {\n key: 'billing.view',\n label: 'View billing',\n description: 'See the plan, usage meters, and invoices.',\n },\n {\n key: 'billing.manage',\n label: 'Manage billing',\n description: 'Change plans, buy add-ons, update payment details.',\n },\n {\n key: 'members.manage',\n label: 'Manage members',\n description: 'Invite, remove, and re-role organization members.',\n },\n {\n key: 'hosts.create',\n label: 'Create sites',\n description: 'Create new sites in the organization.',\n },\n {\n key: 'hosts.delete',\n label: 'Delete sites',\n description: 'Delete sites the member can access.',\n },\n {\n key: 'data.manage',\n label: 'Manage data',\n description: 'Create, edit, and delete organization datasets.',\n },\n {\n key: 'marketplace.publish',\n label: 'Publish to marketplace',\n description: 'Publish listings under the organization profile.',\n },\n {\n key: 'plugins.install',\n label: 'Install plugins',\n description: 'Install or remove marketplace plugins.',\n },\n]\n\n/** The core keys: a plugin may add keys beside them and never redefine one. */\nconst CORE_ORG_PERMISSION_KEYS: ReadonlySet<string> = new Set(\n CORE_ORG_PERMISSIONS.map((definition) => definition.key),\n)\n\n/** The legacy camelCase projection's keys, which no declaration may take either. */\nconst LEGACY_PERMISSION_KEYS: ReadonlySet<string> = new Set<string>(\n ORG_ROLE_PERMISSION_KEYS,\n)\n\n/** The core keys each org role holds when nothing refines it. */\nconst CORE_ROLE_GRANTS: Record<OrgRole, ReadonlySet<string>> = {\n owner: CORE_ORG_PERMISSION_KEYS,\n admin: CORE_ORG_PERMISSION_KEYS,\n editor: new Set<CoreOrgPermission>([\n 'data.manage',\n 'marketplace.publish',\n 'plugins.install',\n ]),\n viewer: new Set<string>(),\n}\n\nconst ORG_ROLES: readonly OrgRole[] = ['owner', 'admin', 'editor', 'viewer']\n\n/**\n * Every permission, in display order for role editors: the core keys, then\n * the keys plugins declare through `registerPluginEntitlements`, in the\n * plugins' catalog order (AGL-2984).\n *\n * LIVE, and kept in step in place. A plugin's declarations register at\n * module scope — at boot on the server, before the first paint in the\n * console — which can be after a module holding this binding was evaluated.\n * So this array, the key list, the role defaults and the empty map below are\n * rewritten in place on every registration instead of being computed once\n * when this module loads, and whoever holds the binding reads the declared\n * keys with the core ones.\n */\nexport const ORG_PERMISSIONS: readonly OrgPermissionDefinition[] = []\n\nexport const ORG_PERMISSION_KEYS: readonly OrgPermission[] = []\n\nconst NO_PERMISSIONS = {} as Record<OrgPermission, boolean>\n\n/**\n * Role → default permission set. Owner/admin hold everything (owner-only\n * actions like org deletion stay role-checked, not permission-checked);\n * editors work on content but see no money or roster controls; viewers read\n * only. A declared key's default for each role is the one its declaration\n * names.\n */\nexport const DEFAULT_ROLE_PERMISSIONS: Record<\n OrgRole,\n Record<OrgPermission, boolean>\n> = {\n owner: {} as Record<OrgPermission, boolean>,\n admin: {} as Record<OrgPermission, boolean>,\n editor: {} as Record<OrgPermission, boolean>,\n viewer: {} as Record<OrgPermission, boolean>,\n}\n\n/** Refused declarations already logged, so a refusal is reported once. */\nconst reportedRefusals = new Set<string>()\n\nfunction replaceItems<T>(target: readonly T[], items: readonly T[]): void {\n const list = target as T[]\n list.length = 0\n list.push(...items)\n}\n\nfunction replaceEntries(\n target: Record<string, boolean>,\n entries: Record<string, boolean>,\n): void {\n for (const key of Object.keys(target)) delete target[key]\n Object.assign(target, entries)\n}\n\n/**\n * Rebuilds the live catalog: the core keys, then every key a plugin declared.\n * A declaration naming a core or legacy key is refused — that key keeps its\n * own meaning and defaults — and logged once.\n */\nfunction syncCatalog(): void {\n const definitions: OrgPermissionDefinition[] = [...CORE_ORG_PERMISSIONS]\n const declaredDefaults = new Map<string, Readonly<Record<OrgRole, boolean>>>()\n for (const declared of listPluginOrgPermissions()) {\n if (\n CORE_ORG_PERMISSION_KEYS.has(declared.key) ||\n LEGACY_PERMISSION_KEYS.has(declared.key)\n ) {\n const refusal = `${declared.pluginId}\\x00${declared.key}`\n if (!reportedRefusals.has(refusal)) {\n reportedRefusals.add(refusal)\n console.error(\n `[plugins] refused catalog permission \"${declared.key}\" to ` +\n `\"${declared.pluginId}\": the key belongs to the core catalog`,\n )\n }\n continue\n }\n definitions.push({\n key: declared.key,\n label: declared.label,\n description: declared.description,\n pluginId: declared.pluginId,\n })\n declaredDefaults.set(declared.key, declared.roleDefaults)\n }\n const keys = definitions.map((definition) => definition.key)\n replaceItems(ORG_PERMISSIONS, definitions)\n replaceItems(ORG_PERMISSION_KEYS, keys)\n replaceEntries(NO_PERMISSIONS, Object.fromEntries(keys.map((key) => [key, false])))\n for (const role of ORG_ROLES) {\n replaceEntries(\n DEFAULT_ROLE_PERMISSIONS[role],\n Object.fromEntries(\n keys.map((key) => [\n key,\n declaredDefaults.has(key)\n ? declaredDefaults.get(key)?.[role] === true\n : CORE_ROLE_GRANTS[role].has(key),\n ]),\n ),\n )\n }\n CATALOG_AND_LEGACY_KEYS.clear()\n for (const key of [...keys, ...LEGACY_PERMISSION_KEYS]) {\n CATALOG_AND_LEGACY_KEYS.add(key)\n }\n}\n\n/** `orgs/{orgId}/roles/{roleId}` — a named custom permission set. */\nexport interface AglynOrgCustomRole {\n $id?: string\n name?: string\n description?: string\n /** Full permission map; missing keys read as false. */\n permissions?: Partial<Record<OrgPermission, boolean>>\n}\n\n/**\n * Effective permission map for a member: the org role's defaults, then\n * the custom role's map (when the member has `roleId` and the role doc\n * is supplied), then per-member overrides — later layers win key-by-key.\n */\nexport function resolveOrgPermissions(\n member:\n | (Partial<AglynOrgMember> & {\n roleId?: string\n permissions?: Partial<Record<OrgPermission, boolean>>\n })\n | null\n | undefined,\n customRole?: AglynOrgCustomRole | null,\n): Record<OrgPermission, boolean> {\n if (!member) return NO_PERMISSIONS\n const role = (member.role ?? 'viewer') as OrgRole\n const base = DEFAULT_ROLE_PERMISSIONS[role] ?? NO_PERMISSIONS\n const merged: Record<OrgPermission, boolean> = { ...base }\n if (member.roleId && customRole?.permissions) {\n for (const key of ORG_PERMISSION_KEYS) {\n const value = customRole.permissions[key]\n if (typeof value === 'boolean') merged[key] = value\n }\n }\n if (member.permissions) {\n for (const key of ORG_PERMISSION_KEYS) {\n const value = member.permissions[key]\n if (typeof value === 'boolean') merged[key] = value\n }\n }\n return merged\n}\n\n/**\n * The values a custom role and a per-member override set EXPLICITLY for\n * permission keys outside this catalog — the keys plugins declare through\n * `registerPluginPermissions` (AGL-2974).\n *\n * `resolveOrgPermissions` walks the catalog alone, so a plugin key stored on\n * a custom role or a member document never reached the `resolvedPermissions`\n * stamp the Firestore rules read. A rule gating on one could then honor\n * neither a grant to an editor nor a revocation from an admin. This carries\n * exactly those stored decisions, custom role first and the per-member\n * override over it, which is the precedence the resolver applies.\n *\n * Tier DEFAULTS are deliberately absent. They live in the plugin registry,\n * which is populated per process by whichever plugin bundles that process\n * loaded, and a stamp computed from it would differ by which route happened\n * to write it. A rule supplies the default itself from the member's role,\n * the way `memberResolves` is always conjoined with a role gate.\n *\n * The legacy six camelCase keys are excluded as well: they are the catalog's\n * older projection, not plugin keys, and no rule reads them.\n */\nexport function explicitPluginPermissionValues(\n member:\n | {\n roleId?: string\n permissions?: Partial<Record<string, unknown>> | null\n }\n | null\n | undefined,\n customRole?: { permissions?: Partial<Record<string, unknown>> | null } | null,\n): Record<string, boolean> {\n const values: Record<string, boolean> = {}\n if (!member) return values\n const layers = [\n member.roleId ? customRole?.permissions : undefined,\n member.permissions,\n ]\n for (const layer of layers) {\n for (const [key, value] of Object.entries(layer ?? {})) {\n if (typeof value !== 'boolean') continue\n if (CATALOG_AND_LEGACY_KEYS.has(key)) continue\n values[key] = value\n }\n }\n return values\n}\n\n/**\n * Keys {@link explicitPluginPermissionValues} leaves to the catalog: every\n * catalog key, declared ones included, and the legacy six. Kept in step by\n * `syncCatalog`.\n */\nconst CATALOG_AND_LEGACY_KEYS = new Set<string>()\n\n/**\n * The `resolvedPermissions` map stamped on a member document for the\n * Firestore rules: the catalog verdict with every layer applied, and beside\n * it the plugin keys a custom role or an override set explicitly.\n *\n * One function for every writer of the stamp, so the org-create transaction\n * and `syncOrgAuthProjections` cannot project different maps. The catalog\n * spreads last; the two key spaces do not overlap, and the order keeps it\n * true that nothing but the resolver decides a catalog key.\n */\nexport function projectMemberResolvedPermissions(\n member: Parameters<typeof resolveOrgPermissions>[0],\n customRole?: AglynOrgCustomRole | null,\n): Record<string, boolean> {\n return {\n ...explicitPluginPermissionValues(member, customRole),\n ...resolveOrgPermissions(member, customRole),\n }\n}\n\n/** Single-permission convenience over `resolveOrgPermissions`. */\nexport function hasOrgPermission(\n member: Parameters<typeof resolveOrgPermissions>[0],\n permission: OrgPermission,\n customRole?: AglynOrgCustomRole | null,\n): boolean {\n return resolveOrgPermissions(member, customRole)[permission]\n}\n\n/** The catalog label for a key — the words a refusal names — or the key itself. */\nexport function orgPermissionLabel(permission: OrgPermission): string {\n return (\n ORG_PERMISSIONS.find((definition) => definition.key === permission)?.label ??\n permission\n )\n}\n\n/** The keys plugins declared into the catalog, in catalog order. */\nexport function pluginOrgPermissionKeys(): OrgPermission[] {\n return ORG_PERMISSIONS.filter((definition) => definition.pluginId).map(\n (definition) => definition.key,\n )\n}\n\n/** One declared catalog key that moved, and the direction it moved in. */\nexport interface PluginPermissionChange {\n permission: string\n granted: boolean\n}\n\n/**\n * The keys PLUGINS declared into the catalog whose value moved between two\n * maps, in the new map's order — what a member, role or collaborator write\n * raises `org.permissions.changed` for, once per key, so the plugin that owns\n * a key can record who moved it.\n *\n * A key the new map leaves unset is not a change: on a role it defers to the\n * base role, on a site toggle to the host role, and neither is a grant or a\n * revocation of its own. A key set for the first time is a change, because\n * the verdict it produces is no longer the default's. The core keys and the\n * legacy six raise nothing; their writes are recorded by the routes' own\n * activity sentences.\n */\nexport function pluginPermissionChanges(\n before: Partial<Record<string, unknown>> | null | undefined,\n after: Partial<Record<string, unknown>> | null | undefined,\n): PluginPermissionChange[] {\n const changes: PluginPermissionChange[] = []\n for (const [key, next] of Object.entries(after ?? {})) {\n if (typeof next !== 'boolean') continue\n if (CORE_ORG_PERMISSION_KEYS.has(key) || LEGACY_PERMISSION_KEYS.has(key)) continue\n if (before?.[key] === next) continue\n changes.push({ permission: key, granted: next })\n }\n return changes\n}\n\n/**\n * The granular (dotted) set projected onto the legacy boolean map\n * (AGL-2350).\n *\n * ## Why this lives here rather than in the console hook\n *\n * It was a private function in `apps/console/hooks/use-org-permissions.ts`,\n * which meant the CLIENT translated the stored permission model into the\n * legacy flags and the SERVER did not translate at all — it derived the\n * legacy flags straight from the built-in role tier and dropped custom roles\n * and per-member overrides on the floor. The two disagreed exactly on the\n * feature `custom-roles.md` sells, in both directions: a permission granted\n * by a custom role showed in the UI and 403'd on POST, and one revoked by an\n * override was hidden in the UI and still succeeded on POST.\n *\n * One exported copy is what stops them drifting again.\n *\n * ## The key spaces are NOT interchangeable, and that is the trap\n *\n * Two permission vocabularies exist. The stored one is DOTTED\n * (`plugins.install`) — `apps/console/app/api/orgs/roles/route.ts` sanitizes\n * against `ORG_PERMISSION_KEYS` before writing, and `AglynOrgMember`\n * `permissions` is typed to it. The legacy one is camelCase\n * (`installPlugins`).\n *\n * `resolveRolePermissions` in `org-roles.ts` accepts `overrides` and\n * `customRoles` arguments keyed by the CAMELCASE space. Feeding it the real\n * stored documents therefore matches no key and changes nothing — it looks\n * like wiring the feature up while doing precisely nothing. Those two\n * parameters are used by nothing but that module's own spec. Translate\n * through here instead.\n *\n * `editHosts` is derived from the ROLE, not from a dotted key: it has no\n * counterpart in the granular catalog and never had one.\n */\nexport function toLegacyPermissions(\n granted: Record<OrgPermission, boolean>,\n role: OrgRole | null | undefined,\n): OrgPermissions {\n return {\n createHosts: granted['hosts.create'],\n editHosts: role !== 'viewer',\n editBilling: granted['billing.manage'],\n publishToMarketplace: granted['marketplace.publish'],\n installPlugins: granted['plugins.install'],\n manageMembers: granted['members.manage'],\n }\n}\n\nsyncCatalog()\nsubscribePluginEntitlements(syncCatalog)\n"],"names":["listPluginOrgPermissions","subscribePluginEntitlements","ORG_ROLE_PERMISSION_KEYS","CORE_ORG_PERMISSIONS","key","label","description","CORE_ORG_PERMISSION_KEYS","Set","map","definition","LEGACY_PERMISSION_KEYS","CORE_ROLE_GRANTS","owner","admin","editor","viewer","ORG_ROLES","ORG_PERMISSIONS","ORG_PERMISSION_KEYS","NO_PERMISSIONS","DEFAULT_ROLE_PERMISSIONS","reportedRefusals","replaceItems","target","items","list","length","push","replaceEntries","entries","Object","keys","assign","syncCatalog","definitions","declaredDefaults","Map","declared","has","refusal","pluginId","add","console","error","set","roleDefaults","fromEntries","role","get","CATALOG_AND_LEGACY_KEYS","clear","resolveOrgPermissions","member","customRole","base","merged","roleId","permissions","value","explicitPluginPermissionValues","values","layers","undefined","layer","projectMemberResolvedPermissions","hasOrgPermission","permission","orgPermissionLabel","find","pluginOrgPermissionKeys","filter","pluginPermissionChanges","before","after","changes","next","granted","toLegacyPermissions","createHosts","editHosts","editBilling","publishToMarketplace","installPlugins","manageMembers"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/org-permissions.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 { AglynOrgMember, OrgRole } from '../foundation'\nimport {\n listPluginOrgPermissions,\n subscribePluginEntitlements,\n} from '../plugin-manager/plugin-entitlements'\nimport { ORG_ROLE_PERMISSION_KEYS } from './org-roles'\nimport { CORE_ORG_PERMISSION_ENTRIES, CORE_ORG_ROLE_GRANTS } from './org-role-grants'\n\n/**\n * Granular org permissions (AGL-243) layered on the 4 org roles: each\n * role maps to a default permission set; custom roles\n * (`orgs/{orgId}/roles/{roleId}`) and per-member overrides refine it, so\n * e.g. an editor restricted to one host can also be granted billing\n * visibility — or an admin stripped of it — without inventing new roles.\n * The console hides unpermitted surfaces; the org API routes are the\n * enforcement point.\n */\nexport type CoreOrgPermission =\n | 'org.settings'\n | 'org.auditLog'\n | 'billing.view'\n | 'billing.manage'\n | 'members.manage'\n | 'hosts.create'\n | 'hosts.delete'\n | 'data.manage'\n | 'marketplace.publish'\n | 'plugins.install'\n\n/**\n * A key of the catalog: a core key above, or a key a plugin declares into\n * the catalog through `registerPluginEntitlements` (AGL-2984). A declared key\n * is stored on custom roles and member overrides, layered and resolved\n * exactly like a core key.\n */\nexport type OrgPermission = CoreOrgPermission | (string & {})\n\n/**\n * Legacy boolean permission map derived from the granular `OrgPermission`\n * set (AGL-243). Kept for the console surfaces that predate the granular\n * model; lives here so relocated feature plugins can accept it as a prop\n * (AGL-395).\n */\nexport interface OrgPermissions {\n createHosts: boolean\n editHosts: boolean\n editBilling: boolean\n publishToMarketplace: boolean\n installPlugins: boolean\n manageMembers: boolean\n}\n\nexport interface OrgPermissionDefinition {\n key: OrgPermission\n label: string\n description: string\n /** The plugin that declared the key; absent for a core key. */\n pluginId?: string\n}\n\n/**\n * The core keys, in display order for role editors.\n *\n * ⚠️ **EVERY KEY HERE MUST BE ENFORCED SERVER-SIDE.** A permission a customer\n * can tick that changes nothing is worse than its absence, because it implies\n * a control that does not exist — an owner unticks \"delete sites\", hands the\n * role out, and the member deletes sites while the console does not even dim\n * the button. Three of the eleven were in exactly that state (AGL-2444).\n *\n * `marketing.manage` was removed rather than wired, and the reason is worth\n * keeping: announcement bars, popups and campaigns live at\n * `hosts/{hostId}/overlays|campaigns|experiments` and are written\n * client-direct against the security rules, which gate on the HOST role.\n * There is no org-level boundary for it to sit on, and the action it named is\n * not org-scoped at all. Wiring it would have meant inventing one and\n * producing a second permission that looks enforced and is not. If a granular\n * marketing permission is wanted it belongs in the plugin-declared mechanism\n * (AGL-435), scoped to a host — a product decision, not this repair.\n *\n * `apps/console/specs/org-permissions-are-enforced.spec.ts` fails the build if\n * a key here, or a key a plugin declares, gains no server-side consumer.\n */\nconst CORE_ORG_PERMISSIONS: readonly OrgPermissionDefinition[] =\n // One table, shared with the native apps through the contracts (AGL-3950).\n CORE_ORG_PERMISSION_ENTRIES.map((entry) => ({ ...entry, key: entry.key as CoreOrgPermission }))\n\n/** The core keys: a plugin may add keys beside them and never redefine one. */\nconst CORE_ORG_PERMISSION_KEYS: ReadonlySet<string> = new Set(\n CORE_ORG_PERMISSIONS.map((definition) => definition.key),\n)\n\n/** The legacy camelCase projection's keys, which no declaration may take either. */\nconst LEGACY_PERMISSION_KEYS: ReadonlySet<string> = new Set<string>(\n ORG_ROLE_PERMISSION_KEYS,\n)\n\n/** The core keys each org role holds when nothing refines it (`org-role-grants.ts`). */\nconst CORE_ROLE_GRANTS: Record<OrgRole, ReadonlySet<string>> = {\n owner: new Set(CORE_ORG_ROLE_GRANTS.owner),\n admin: new Set(CORE_ORG_ROLE_GRANTS.admin),\n editor: new Set(CORE_ORG_ROLE_GRANTS.editor),\n viewer: new Set(CORE_ORG_ROLE_GRANTS.viewer),\n}\n\nconst ORG_ROLES: readonly OrgRole[] = ['owner', 'admin', 'editor', 'viewer']\n\n/**\n * Every permission, in display order for role editors: the core keys, then\n * the keys plugins declare through `registerPluginEntitlements`, in the\n * plugins' catalog order (AGL-2984).\n *\n * LIVE, and kept in step in place. A plugin's declarations register at\n * module scope — at boot on the server, before the first paint in the\n * console — which can be after a module holding this binding was evaluated.\n * So this array, the key list, the role defaults and the empty map below are\n * rewritten in place on every registration instead of being computed once\n * when this module loads, and whoever holds the binding reads the declared\n * keys with the core ones.\n */\nexport const ORG_PERMISSIONS: readonly OrgPermissionDefinition[] = []\n\nexport const ORG_PERMISSION_KEYS: readonly OrgPermission[] = []\n\nconst NO_PERMISSIONS = {} as Record<OrgPermission, boolean>\n\n/**\n * Role → default permission set. Owner/admin hold everything (owner-only\n * actions like org deletion stay role-checked, not permission-checked);\n * editors work on content but see no money or roster controls; viewers read\n * only. A declared key's default for each role is the one its declaration\n * names.\n */\nexport const DEFAULT_ROLE_PERMISSIONS: Record<\n OrgRole,\n Record<OrgPermission, boolean>\n> = {\n owner: {} as Record<OrgPermission, boolean>,\n admin: {} as Record<OrgPermission, boolean>,\n editor: {} as Record<OrgPermission, boolean>,\n viewer: {} as Record<OrgPermission, boolean>,\n}\n\n/** Refused declarations already logged, so a refusal is reported once. */\nconst reportedRefusals = new Set<string>()\n\nfunction replaceItems<T>(target: readonly T[], items: readonly T[]): void {\n const list = target as T[]\n list.length = 0\n list.push(...items)\n}\n\nfunction replaceEntries(\n target: Record<string, boolean>,\n entries: Record<string, boolean>,\n): void {\n for (const key of Object.keys(target)) delete target[key]\n Object.assign(target, entries)\n}\n\n/**\n * Rebuilds the live catalog: the core keys, then every key a plugin declared.\n * A declaration naming a core or legacy key is refused — that key keeps its\n * own meaning and defaults — and logged once.\n */\nfunction syncCatalog(): void {\n const definitions: OrgPermissionDefinition[] = [...CORE_ORG_PERMISSIONS]\n const declaredDefaults = new Map<string, Readonly<Record<OrgRole, boolean>>>()\n for (const declared of listPluginOrgPermissions()) {\n if (\n CORE_ORG_PERMISSION_KEYS.has(declared.key) ||\n LEGACY_PERMISSION_KEYS.has(declared.key)\n ) {\n const refusal = `${declared.pluginId}\\x00${declared.key}`\n if (!reportedRefusals.has(refusal)) {\n reportedRefusals.add(refusal)\n console.error(\n `[plugins] refused catalog permission \"${declared.key}\" to ` +\n `\"${declared.pluginId}\": the key belongs to the core catalog`,\n )\n }\n continue\n }\n definitions.push({\n key: declared.key,\n label: declared.label,\n description: declared.description,\n pluginId: declared.pluginId,\n })\n declaredDefaults.set(declared.key, declared.roleDefaults)\n }\n const keys = definitions.map((definition) => definition.key)\n replaceItems(ORG_PERMISSIONS, definitions)\n replaceItems(ORG_PERMISSION_KEYS, keys)\n replaceEntries(NO_PERMISSIONS, Object.fromEntries(keys.map((key) => [key, false])))\n for (const role of ORG_ROLES) {\n replaceEntries(\n DEFAULT_ROLE_PERMISSIONS[role],\n Object.fromEntries(\n keys.map((key) => [\n key,\n declaredDefaults.has(key)\n ? declaredDefaults.get(key)?.[role] === true\n : CORE_ROLE_GRANTS[role].has(key),\n ]),\n ),\n )\n }\n CATALOG_AND_LEGACY_KEYS.clear()\n for (const key of [...keys, ...LEGACY_PERMISSION_KEYS]) {\n CATALOG_AND_LEGACY_KEYS.add(key)\n }\n}\n\n/** `orgs/{orgId}/roles/{roleId}` — a named custom permission set. */\nexport interface AglynOrgCustomRole {\n $id?: string\n name?: string\n description?: string\n /** Full permission map; missing keys read as false. */\n permissions?: Partial<Record<OrgPermission, boolean>>\n}\n\n/**\n * Effective permission map for a member: the org role's defaults, then\n * the custom role's map (when the member has `roleId` and the role doc\n * is supplied), then per-member overrides — later layers win key-by-key.\n */\nexport function resolveOrgPermissions(\n member:\n | (Partial<AglynOrgMember> & {\n roleId?: string\n permissions?: Partial<Record<OrgPermission, boolean>>\n })\n | null\n | undefined,\n customRole?: AglynOrgCustomRole | null,\n): Record<OrgPermission, boolean> {\n if (!member) return NO_PERMISSIONS\n const role = (member.role ?? 'viewer') as OrgRole\n const base = DEFAULT_ROLE_PERMISSIONS[role] ?? NO_PERMISSIONS\n const merged: Record<OrgPermission, boolean> = { ...base }\n if (member.roleId && customRole?.permissions) {\n for (const key of ORG_PERMISSION_KEYS) {\n const value = customRole.permissions[key]\n if (typeof value === 'boolean') merged[key] = value\n }\n }\n if (member.permissions) {\n for (const key of ORG_PERMISSION_KEYS) {\n const value = member.permissions[key]\n if (typeof value === 'boolean') merged[key] = value\n }\n }\n return merged\n}\n\n/**\n * The values a custom role and a per-member override set EXPLICITLY for\n * permission keys outside this catalog — the keys plugins declare through\n * `registerPluginPermissions` (AGL-2974).\n *\n * `resolveOrgPermissions` walks the catalog alone, so a plugin key stored on\n * a custom role or a member document never reached the `resolvedPermissions`\n * stamp the Firestore rules read. A rule gating on one could then honor\n * neither a grant to an editor nor a revocation from an admin. This carries\n * exactly those stored decisions, custom role first and the per-member\n * override over it, which is the precedence the resolver applies.\n *\n * Tier DEFAULTS are deliberately absent. They live in the plugin registry,\n * which is populated per process by whichever plugin bundles that process\n * loaded, and a stamp computed from it would differ by which route happened\n * to write it. A rule supplies the default itself from the member's role,\n * the way `memberResolves` is always conjoined with a role gate.\n *\n * The legacy six camelCase keys are excluded as well: they are the catalog's\n * older projection, not plugin keys, and no rule reads them.\n */\nexport function explicitPluginPermissionValues(\n member:\n | {\n roleId?: string\n permissions?: Partial<Record<string, unknown>> | null\n }\n | null\n | undefined,\n customRole?: { permissions?: Partial<Record<string, unknown>> | null } | null,\n): Record<string, boolean> {\n const values: Record<string, boolean> = {}\n if (!member) return values\n const layers = [\n member.roleId ? customRole?.permissions : undefined,\n member.permissions,\n ]\n for (const layer of layers) {\n for (const [key, value] of Object.entries(layer ?? {})) {\n if (typeof value !== 'boolean') continue\n if (CATALOG_AND_LEGACY_KEYS.has(key)) continue\n values[key] = value\n }\n }\n return values\n}\n\n/**\n * Keys {@link explicitPluginPermissionValues} leaves to the catalog: every\n * catalog key, declared ones included, and the legacy six. Kept in step by\n * `syncCatalog`.\n */\nconst CATALOG_AND_LEGACY_KEYS = new Set<string>()\n\n/**\n * The `resolvedPermissions` map stamped on a member document for the\n * Firestore rules: the catalog verdict with every layer applied, and beside\n * it the plugin keys a custom role or an override set explicitly.\n *\n * One function for every writer of the stamp, so the org-create transaction\n * and `syncOrgAuthProjections` cannot project different maps. The catalog\n * spreads last; the two key spaces do not overlap, and the order keeps it\n * true that nothing but the resolver decides a catalog key.\n */\nexport function projectMemberResolvedPermissions(\n member: Parameters<typeof resolveOrgPermissions>[0],\n customRole?: AglynOrgCustomRole | null,\n): Record<string, boolean> {\n return {\n ...explicitPluginPermissionValues(member, customRole),\n ...resolveOrgPermissions(member, customRole),\n }\n}\n\n/** Single-permission convenience over `resolveOrgPermissions`. */\nexport function hasOrgPermission(\n member: Parameters<typeof resolveOrgPermissions>[0],\n permission: OrgPermission,\n customRole?: AglynOrgCustomRole | null,\n): boolean {\n return resolveOrgPermissions(member, customRole)[permission]\n}\n\n/** The catalog label for a key — the words a refusal names — or the key itself. */\nexport function orgPermissionLabel(permission: OrgPermission): string {\n return (\n ORG_PERMISSIONS.find((definition) => definition.key === permission)?.label ??\n permission\n )\n}\n\n/** The keys plugins declared into the catalog, in catalog order. */\nexport function pluginOrgPermissionKeys(): OrgPermission[] {\n return ORG_PERMISSIONS.filter((definition) => definition.pluginId).map(\n (definition) => definition.key,\n )\n}\n\n/** One declared catalog key that moved, and the direction it moved in. */\nexport interface PluginPermissionChange {\n permission: string\n granted: boolean\n}\n\n/**\n * The keys PLUGINS declared into the catalog whose value moved between two\n * maps, in the new map's order — what a member, role or collaborator write\n * raises `org.permissions.changed` for, once per key, so the plugin that owns\n * a key can record who moved it.\n *\n * A key the new map leaves unset is not a change: on a role it defers to the\n * base role, on a site toggle to the host role, and neither is a grant or a\n * revocation of its own. A key set for the first time is a change, because\n * the verdict it produces is no longer the default's. The core keys and the\n * legacy six raise nothing; their writes are recorded by the routes' own\n * activity sentences.\n */\nexport function pluginPermissionChanges(\n before: Partial<Record<string, unknown>> | null | undefined,\n after: Partial<Record<string, unknown>> | null | undefined,\n): PluginPermissionChange[] {\n const changes: PluginPermissionChange[] = []\n for (const [key, next] of Object.entries(after ?? {})) {\n if (typeof next !== 'boolean') continue\n if (CORE_ORG_PERMISSION_KEYS.has(key) || LEGACY_PERMISSION_KEYS.has(key)) continue\n if (before?.[key] === next) continue\n changes.push({ permission: key, granted: next })\n }\n return changes\n}\n\n/**\n * The granular (dotted) set projected onto the legacy boolean map\n * (AGL-2350).\n *\n * ## Why this lives here rather than in the console hook\n *\n * It was a private function in `apps/console/hooks/use-org-permissions.ts`,\n * which meant the CLIENT translated the stored permission model into the\n * legacy flags and the SERVER did not translate at all — it derived the\n * legacy flags straight from the built-in role tier and dropped custom roles\n * and per-member overrides on the floor. The two disagreed exactly on the\n * feature `custom-roles.md` sells, in both directions: a permission granted\n * by a custom role showed in the UI and 403'd on POST, and one revoked by an\n * override was hidden in the UI and still succeeded on POST.\n *\n * One exported copy is what stops them drifting again.\n *\n * ## The key spaces are NOT interchangeable, and that is the trap\n *\n * Two permission vocabularies exist. The stored one is DOTTED\n * (`plugins.install`) — `apps/console/app/api/orgs/roles/route.ts` sanitizes\n * against `ORG_PERMISSION_KEYS` before writing, and `AglynOrgMember`\n * `permissions` is typed to it. The legacy one is camelCase\n * (`installPlugins`).\n *\n * `resolveRolePermissions` in `org-roles.ts` accepts `overrides` and\n * `customRoles` arguments keyed by the CAMELCASE space. Feeding it the real\n * stored documents therefore matches no key and changes nothing — it looks\n * like wiring the feature up while doing precisely nothing. Those two\n * parameters are used by nothing but that module's own spec. Translate\n * through here instead.\n *\n * `editHosts` is derived from the ROLE, not from a dotted key: it has no\n * counterpart in the granular catalog and never had one.\n */\nexport function toLegacyPermissions(\n granted: Record<OrgPermission, boolean>,\n role: OrgRole | null | undefined,\n): OrgPermissions {\n return {\n createHosts: granted['hosts.create'],\n editHosts: role !== 'viewer',\n editBilling: granted['billing.manage'],\n publishToMarketplace: granted['marketplace.publish'],\n installPlugins: granted['plugins.install'],\n manageMembers: granted['members.manage'],\n }\n}\n\nsyncCatalog()\nsubscribePluginEntitlements(syncCatalog)\n"],"names":["listPluginOrgPermissions","subscribePluginEntitlements","ORG_ROLE_PERMISSION_KEYS","CORE_ORG_PERMISSION_ENTRIES","CORE_ORG_ROLE_GRANTS","CORE_ORG_PERMISSIONS","map","entry","key","CORE_ORG_PERMISSION_KEYS","Set","definition","LEGACY_PERMISSION_KEYS","CORE_ROLE_GRANTS","owner","admin","editor","viewer","ORG_ROLES","ORG_PERMISSIONS","ORG_PERMISSION_KEYS","NO_PERMISSIONS","DEFAULT_ROLE_PERMISSIONS","reportedRefusals","replaceItems","target","items","list","length","push","replaceEntries","entries","Object","keys","assign","syncCatalog","definitions","declaredDefaults","Map","declared","has","refusal","pluginId","add","console","error","label","description","set","roleDefaults","fromEntries","role","get","CATALOG_AND_LEGACY_KEYS","clear","resolveOrgPermissions","member","customRole","base","merged","roleId","permissions","value","explicitPluginPermissionValues","values","layers","undefined","layer","projectMemberResolvedPermissions","hasOrgPermission","permission","orgPermissionLabel","find","pluginOrgPermissionKeys","filter","pluginPermissionChanges","before","after","changes","next","granted","toLegacyPermissions","createHosts","editHosts","editBilling","publishToMarketplace","installPlugins","manageMembers"],"mappings":"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"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* WHAT EACH ORG ROLE MAY DO, AS DATA (AGL-243, AGL-3950).
|
|
19
|
+
*
|
|
20
|
+
* The core permission catalog and the core keys each org role holds when
|
|
21
|
+
* nothing refines it. `org-permissions.ts` builds the console's live
|
|
22
|
+
* catalog from these (adding the keys plugins declare, with their own role
|
|
23
|
+
* defaults), and the native apps read the same grants through the generated
|
|
24
|
+
* contracts, so a native gate (`hosts.create`, `data.manage`) answers as the
|
|
25
|
+
* console does for a member row that carries no `resolvedPermissions` stamp.
|
|
26
|
+
*
|
|
27
|
+
* `ORG_ROLE_GRANTS` in the contracts is this table with every plugin's
|
|
28
|
+
* declared catalog keys added (`assembleOrgRoleGrants`, run by
|
|
29
|
+
* `generate:native-contracts` over each plugin's declarations), which is
|
|
30
|
+
* exactly the set the console's `DEFAULT_ROLE_PERMISSIONS` holds once the
|
|
31
|
+
* plugins have registered.
|
|
32
|
+
*
|
|
33
|
+
* NO IMPORTS, on purpose: the native apps read it through the generated
|
|
34
|
+
* contracts.
|
|
35
|
+
*/
|
|
36
|
+
/** The four org roles. */
|
|
37
|
+
export type OrgRoleName = 'owner' | 'admin' | 'editor' | 'viewer';
|
|
38
|
+
/** One key of the org permission catalog. */
|
|
39
|
+
export interface OrgPermissionEntry {
|
|
40
|
+
key: string;
|
|
41
|
+
label: string;
|
|
42
|
+
description: string;
|
|
43
|
+
/** The plugin that declared it; absent for a core key. */
|
|
44
|
+
pluginId?: string;
|
|
45
|
+
}
|
|
46
|
+
/** The catalog and each role's default grants. */
|
|
47
|
+
export interface OrgRoleGrants {
|
|
48
|
+
/** Every key, in the order the role editor lists them. */
|
|
49
|
+
permissions: OrgPermissionEntry[];
|
|
50
|
+
/** The keys each role holds when no custom role or per-member override sets them. */
|
|
51
|
+
grants: Record<OrgRoleName, string[]>;
|
|
52
|
+
}
|
|
53
|
+
/** A key a plugin declares into the catalog, as `PluginOrgPermissionDeclaration` carries it. */
|
|
54
|
+
export interface OrgPermissionDeclaration {
|
|
55
|
+
key: string;
|
|
56
|
+
label: string;
|
|
57
|
+
description: string;
|
|
58
|
+
roleDefaults: Record<OrgRoleName, boolean>;
|
|
59
|
+
}
|
|
60
|
+
/** A custom role (`orgs/{orgId}/roles/{roleId}`) or a member's overrides: explicit verdicts per key. */
|
|
61
|
+
export interface OrgPermissionLayer {
|
|
62
|
+
permissions?: Record<string, boolean>;
|
|
63
|
+
}
|
|
64
|
+
export declare const ORG_ROLE_NAMES: readonly OrgRoleName[];
|
|
65
|
+
/**
|
|
66
|
+
* The core keys, in display order for role editors. Every key here is
|
|
67
|
+
* enforced server-side (`apps/console/specs/org-permissions-are-enforced.spec.ts`).
|
|
68
|
+
*/
|
|
69
|
+
export declare const CORE_ORG_PERMISSION_ENTRIES: readonly OrgPermissionEntry[];
|
|
70
|
+
/**
|
|
71
|
+
* The core keys each org role holds when nothing refines it: owners and
|
|
72
|
+
* admins hold every core key (owner-only acts like deleting the
|
|
73
|
+
* organization stay role-checked, not permission-checked); editors work on
|
|
74
|
+
* content and see no money or roster controls; viewers read only.
|
|
75
|
+
*/
|
|
76
|
+
export declare const CORE_ORG_ROLE_GRANTS: Readonly<Record<OrgRoleName, readonly string[]>>;
|
|
77
|
+
/** The core's catalog and grants; the contracts' value adds the plugins' keys. */
|
|
78
|
+
export declare const ORG_ROLE_GRANTS: OrgRoleGrants;
|
|
79
|
+
/**
|
|
80
|
+
* The catalog with plugins' declared keys after the core ones, each role
|
|
81
|
+
* holding a declared key when its declaration's `roleDefaults` says so. A
|
|
82
|
+
* declaration naming a core key is refused, as the console's catalog refuses
|
|
83
|
+
* it: the core key keeps its meaning and defaults.
|
|
84
|
+
*/
|
|
85
|
+
export declare function assembleOrgRoleGrants(core: OrgRoleGrants, declarations: readonly (OrgPermissionDeclaration & {
|
|
86
|
+
pluginId?: string;
|
|
87
|
+
})[]): OrgRoleGrants;
|
|
88
|
+
/**
|
|
89
|
+
* A member's effective permissions, as the console's `resolveOrgPermissions`
|
|
90
|
+
* answers them: the role's defaults, then the custom role's explicit
|
|
91
|
+
* verdicts (when the member has one), then the member's own overrides,
|
|
92
|
+
* later layers winning key by key, over the catalog's keys only. A missing
|
|
93
|
+
* role reads as viewer; a role the catalog does not know holds nothing.
|
|
94
|
+
*/
|
|
95
|
+
export declare function rolePermissions(catalog: OrgRoleGrants, role: string | null | undefined, customRole?: OrgPermissionLayer | null, overrides?: OrgPermissionLayer | null): Record<string, boolean>;
|