@aglyn/aglyn 1.0.0-beta.220 → 1.0.0-beta.221
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -11
- package/src/lib/app-utils/docs-help.generated.d.ts +2 -2
- package/src/lib/app-utils/docs-help.generated.js +3 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +9 -2
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/health-report.js +22 -0
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/leaving-notice.d.ts +96 -0
- package/src/lib/app-utils/leaving-notice.js +157 -0
- package/src/lib/app-utils/leaving-notice.js.map +1 -0
- package/src/lib/app-utils/operator-alerts.js +17 -0
- package/src/lib/app-utils/operator-alerts.js.map +1 -1
- package/src/lib/app-utils/screen-link-context-value.d.ts +7 -0
- package/src/lib/app-utils/screen-link-context-value.js.map +1 -1
- package/src/lib/app-utils/screen-link-context.js +14 -2
- package/src/lib/app-utils/screen-link-context.js.map +1 -1
- package/src/lib/app-utils/screen-route.d.ts +3 -0
- package/src/lib/app-utils/screen-route.js +6 -1
- package/src/lib/app-utils/screen-route.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +6 -0
- package/src/lib/plugin-manager/first-party-plugins.generated.js +4 -0
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-server-steps.d.ts +170 -0
- package/src/lib/plugin-manager/plugin-server-steps.js +116 -0
- package/src/lib/plugin-manager/plugin-server-steps.js.map +1 -0
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
/** The first path segment the notice owns on every site (a reserved slug). */
|
|
18
|
+
export declare const LEAVING_NOTICE_ROUTE_SEGMENT = "_aglyn";
|
|
19
|
+
/** Where the notice is served, on the site's own host. */
|
|
20
|
+
export declare const LEAVING_NOTICE_PATH = "/_aglyn/leaving";
|
|
21
|
+
/** The query parameter carrying the destination. */
|
|
22
|
+
export declare const LEAVING_NOTICE_DESTINATION_PARAM = "to";
|
|
23
|
+
/** The query parameter carrying the destination's signature. */
|
|
24
|
+
export declare const LEAVING_NOTICE_SIGNATURE_PARAM = "sig";
|
|
25
|
+
/** What a page in its notice window carries, from the server. */
|
|
26
|
+
export interface LeavingNoticeConfig {
|
|
27
|
+
/**
|
|
28
|
+
* Epoch ms the window closes. For people and specs reading the payload:
|
|
29
|
+
* nothing here compares it to a clock, because the page that carries this
|
|
30
|
+
* config was rendered inside the window and its href has to match the
|
|
31
|
+
* server's byte for byte.
|
|
32
|
+
*/
|
|
33
|
+
until: number;
|
|
34
|
+
/**
|
|
35
|
+
* The hosts a link may go to without the notice: the site's own subdomain
|
|
36
|
+
* and custom domain, and the platform's own. An entry starting with `.` is
|
|
37
|
+
* that domain AND every name under it.
|
|
38
|
+
*/
|
|
39
|
+
hosts: readonly string[];
|
|
40
|
+
/**
|
|
41
|
+
* Destination (as {@link normalizeLeavingDestination} writes it) → its
|
|
42
|
+
* signature, for every outside address the server found on this page. A
|
|
43
|
+
* destination with none still goes through the notice, which refuses it.
|
|
44
|
+
*/
|
|
45
|
+
sigs: Readonly<Record<string, string>>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The absolute http(s) address an href navigates to when it names another
|
|
49
|
+
* host, or `null` for anything that stays on the page's own origin or is not
|
|
50
|
+
* a navigation at all.
|
|
51
|
+
*
|
|
52
|
+
* Resolved the way a browser resolves it rather than pattern-matched, because
|
|
53
|
+
* a pattern is exactly what a disguised link is built to get past: `//evil`
|
|
54
|
+
* and `/\evil` are both protocol-relative to a browser while reading as
|
|
55
|
+
* site paths to a `^/` test. `mailto:`, `tel:` and `sms:` hand off to another
|
|
56
|
+
* app and are not navigations, so they answer `null` with every other scheme.
|
|
57
|
+
*
|
|
58
|
+
* The answer is the URL's `href` — lowercased host, default port dropped,
|
|
59
|
+
* root path filled in — so the server that signs a destination and the
|
|
60
|
+
* client that looks the signature up agree on one spelling.
|
|
61
|
+
*/
|
|
62
|
+
export declare function normalizeLeavingDestination(raw: unknown): string | null;
|
|
63
|
+
/**
|
|
64
|
+
* Whether a hostname is one a link may reach without the notice: listed in
|
|
65
|
+
* `hosts` (exactly, or under a `.domain` entry), or a development machine's
|
|
66
|
+
* own `localhost`, which sends a visitor nowhere.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isLeavingNoticeExempt(hostname: string, hosts: readonly string[]): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The normalized destination of an href that leaves for a host outside
|
|
71
|
+
* `hosts`, or `null` when the href stays, is not a navigation, or is exempt.
|
|
72
|
+
*/
|
|
73
|
+
export declare function leavingDestination(raw: unknown, hosts: readonly string[]): string | null;
|
|
74
|
+
/** The notice's own address for a destination, signed when a signature is known. */
|
|
75
|
+
export declare function leavingNoticeHref(destination: string, signature?: string | null): string;
|
|
76
|
+
/**
|
|
77
|
+
* The href a link should carry instead of `raw` — the notice for its
|
|
78
|
+
* destination — or `undefined` to leave it as it is: no config (the site is
|
|
79
|
+
* not in its window), a destination on an exempt host, or not a navigation.
|
|
80
|
+
*/
|
|
81
|
+
export declare function routeThroughLeavingNotice(raw: unknown, config: LeavingNoticeConfig | null | undefined): string | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Points the anchor an event landed in at the notice, when it leaves.
|
|
84
|
+
*
|
|
85
|
+
* The catch-all for every link `useLinkTarget` never saw — Markdown and rich
|
|
86
|
+
* text bodies, collection blocks, a plugin's own anchors. The href is
|
|
87
|
+
* rewritten IN PLACE, before the browser acts on the event, so the visitor's
|
|
88
|
+
* own gesture still decides how it opens: a modified click, a middle click
|
|
89
|
+
* and "Open in new tab" all take the notice with them. Next's `Link` leaves
|
|
90
|
+
* a non-local href to the browser, so the browser is what reads the change.
|
|
91
|
+
*
|
|
92
|
+
* Returns whether it rewrote anything.
|
|
93
|
+
*/
|
|
94
|
+
export declare function rerouteLeavingAnchor(target: EventTarget | null, config: LeavingNoticeConfig | null | undefined): boolean;
|
|
95
|
+
/** Installs {@link rerouteLeavingAnchor} on a document; returns the uninstall. */
|
|
96
|
+
export declare function installLeavingNoticeInterceptor(doc: Document, config: LeavingNoticeConfig): () => void;
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
* THE "YOU'RE LEAVING THIS SITE" NOTICE, CLIENT HALF (AGL-3452).
|
|
18
|
+
*
|
|
19
|
+
* Both phishing pages found on free sites on 2026-10-01 had one button, and it
|
|
20
|
+
* went straight to an outside credential harvester: the site's address was
|
|
21
|
+
* only the trusted-looking hop. So for a FREE workspace in its first 14 days,
|
|
22
|
+
* a link to another domain goes through a notice on the site's own host
|
|
23
|
+
* first, which names the destination in plain text and says the platform
|
|
24
|
+
* does not operate it.
|
|
25
|
+
*
|
|
26
|
+
* The server decides whether a page is in that window and hands the page a
|
|
27
|
+
* {@link LeavingNoticeConfig}; this module only applies it. Nothing here
|
|
28
|
+
* reads the clock, so the server render and the hydrating client always
|
|
29
|
+
* produce the same href.
|
|
30
|
+
*
|
|
31
|
+
* A leaf with no imports: it is on every published page through
|
|
32
|
+
* `useLinkTarget`, and the hosts and signatures it needs arrive as data.
|
|
33
|
+
*=========================================*/ /** The first path segment the notice owns on every site (a reserved slug). */ export const LEAVING_NOTICE_ROUTE_SEGMENT = '_aglyn';
|
|
34
|
+
/** Where the notice is served, on the site's own host. */ export const LEAVING_NOTICE_PATH = `/${LEAVING_NOTICE_ROUTE_SEGMENT}/leaving`;
|
|
35
|
+
/** The query parameter carrying the destination. */ export const LEAVING_NOTICE_DESTINATION_PARAM = 'to';
|
|
36
|
+
/** The query parameter carrying the destination's signature. */ export const LEAVING_NOTICE_SIGNATURE_PARAM = 'sig';
|
|
37
|
+
/** The origin a site-relative href is resolved against to tell it apart. */ const SITE_RELATIVE_BASE = 'https://site-relative.invalid';
|
|
38
|
+
const SITE_RELATIVE_HOSTNAME = 'site-relative.invalid';
|
|
39
|
+
/**
|
|
40
|
+
* The absolute http(s) address an href navigates to when it names another
|
|
41
|
+
* host, or `null` for anything that stays on the page's own origin or is not
|
|
42
|
+
* a navigation at all.
|
|
43
|
+
*
|
|
44
|
+
* Resolved the way a browser resolves it rather than pattern-matched, because
|
|
45
|
+
* a pattern is exactly what a disguised link is built to get past: `//evil`
|
|
46
|
+
* and `/\evil` are both protocol-relative to a browser while reading as
|
|
47
|
+
* site paths to a `^/` test. `mailto:`, `tel:` and `sms:` hand off to another
|
|
48
|
+
* app and are not navigations, so they answer `null` with every other scheme.
|
|
49
|
+
*
|
|
50
|
+
* The answer is the URL's `href` — lowercased host, default port dropped,
|
|
51
|
+
* root path filled in — so the server that signs a destination and the
|
|
52
|
+
* client that looks the signature up agree on one spelling.
|
|
53
|
+
*/ export function normalizeLeavingDestination(raw) {
|
|
54
|
+
if (typeof raw !== 'string') return null;
|
|
55
|
+
const trimmed = raw.trim();
|
|
56
|
+
if (!trimmed) return null;
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(trimmed, SITE_RELATIVE_BASE);
|
|
60
|
+
} catch (unused) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
64
|
+
if (!url.hostname || url.hostname === SITE_RELATIVE_HOSTNAME) return null;
|
|
65
|
+
return url.href;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Whether a hostname is one a link may reach without the notice: listed in
|
|
69
|
+
* `hosts` (exactly, or under a `.domain` entry), or a development machine's
|
|
70
|
+
* own `localhost`, which sends a visitor nowhere.
|
|
71
|
+
*/ export function isLeavingNoticeExempt(hostname, hosts) {
|
|
72
|
+
const name = String(hostname != null ? hostname : '').toLowerCase().replace(/\.+$/, '');
|
|
73
|
+
if (!name) return true;
|
|
74
|
+
if (name === 'localhost' || name.endsWith('.localhost')) return true;
|
|
75
|
+
for (const entry of hosts){
|
|
76
|
+
if (entry.startsWith('.')) {
|
|
77
|
+
if (name === entry.slice(1) || name.endsWith(entry)) return true;
|
|
78
|
+
} else if (name === entry) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The normalized destination of an href that leaves for a host outside
|
|
86
|
+
* `hosts`, or `null` when the href stays, is not a navigation, or is exempt.
|
|
87
|
+
*/ export function leavingDestination(raw, hosts) {
|
|
88
|
+
const destination = normalizeLeavingDestination(raw);
|
|
89
|
+
if (!destination) return null;
|
|
90
|
+
return isLeavingNoticeExempt(new URL(destination).hostname, hosts) ? null : destination;
|
|
91
|
+
}
|
|
92
|
+
/** The notice's own address for a destination, signed when a signature is known. */ export function leavingNoticeHref(destination, signature) {
|
|
93
|
+
const params = new URLSearchParams();
|
|
94
|
+
params.set(LEAVING_NOTICE_DESTINATION_PARAM, destination);
|
|
95
|
+
if (signature) params.set(LEAVING_NOTICE_SIGNATURE_PARAM, signature);
|
|
96
|
+
return `${LEAVING_NOTICE_PATH}?${params.toString()}`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The href a link should carry instead of `raw` — the notice for its
|
|
100
|
+
* destination — or `undefined` to leave it as it is: no config (the site is
|
|
101
|
+
* not in its window), a destination on an exempt host, or not a navigation.
|
|
102
|
+
*/ export function routeThroughLeavingNotice(raw, config) {
|
|
103
|
+
var _config_sigs;
|
|
104
|
+
if (!config) return undefined;
|
|
105
|
+
const destination = leavingDestination(raw, config.hosts);
|
|
106
|
+
if (!destination) return undefined;
|
|
107
|
+
return leavingNoticeHref(destination, (_config_sigs = config.sigs) == null ? void 0 : _config_sigs[destination]);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Points the anchor an event landed in at the notice, when it leaves.
|
|
111
|
+
*
|
|
112
|
+
* The catch-all for every link `useLinkTarget` never saw — Markdown and rich
|
|
113
|
+
* text bodies, collection blocks, a plugin's own anchors. The href is
|
|
114
|
+
* rewritten IN PLACE, before the browser acts on the event, so the visitor's
|
|
115
|
+
* own gesture still decides how it opens: a modified click, a middle click
|
|
116
|
+
* and "Open in new tab" all take the notice with them. Next's `Link` leaves
|
|
117
|
+
* a non-local href to the browser, so the browser is what reads the change.
|
|
118
|
+
*
|
|
119
|
+
* Returns whether it rewrote anything.
|
|
120
|
+
*/ export function rerouteLeavingAnchor(target, config) {
|
|
121
|
+
var _node_parentElement;
|
|
122
|
+
var _element_closest;
|
|
123
|
+
if (!config || !target) return false;
|
|
124
|
+
const node = target;
|
|
125
|
+
const element = node.nodeType === 1 ? node : (_node_parentElement = node.parentElement) != null ? _node_parentElement : null;
|
|
126
|
+
const anchor = element == null ? void 0 : (_element_closest = element.closest) == null ? void 0 : _element_closest.call(element, 'a[href], area[href]');
|
|
127
|
+
if (!anchor) return false;
|
|
128
|
+
const rerouted = routeThroughLeavingNotice(anchor.getAttribute('href'), config);
|
|
129
|
+
if (!rerouted) return false;
|
|
130
|
+
anchor.setAttribute('href', rerouted);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Every gesture that can follow, open or copy a link, caught on the way
|
|
135
|
+
* down so the href is the notice's before anything reads it.
|
|
136
|
+
*/ const LEAVING_NOTICE_EVENTS = [
|
|
137
|
+
'pointerdown',
|
|
138
|
+
'click',
|
|
139
|
+
'auxclick',
|
|
140
|
+
'contextmenu',
|
|
141
|
+
'focusin'
|
|
142
|
+
];
|
|
143
|
+
/** Installs {@link rerouteLeavingAnchor} on a document; returns the uninstall. */ export function installLeavingNoticeInterceptor(doc, config) {
|
|
144
|
+
const handler = (event)=>{
|
|
145
|
+
rerouteLeavingAnchor(event.target, config);
|
|
146
|
+
};
|
|
147
|
+
for (const type of LEAVING_NOTICE_EVENTS){
|
|
148
|
+
doc.addEventListener(type, handler, true);
|
|
149
|
+
}
|
|
150
|
+
return ()=>{
|
|
151
|
+
for (const type of LEAVING_NOTICE_EVENTS){
|
|
152
|
+
doc.removeEventListener(type, handler, true);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
//# sourceMappingURL=leaving-notice.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/leaving-notice.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE \"YOU'RE LEAVING THIS SITE\" NOTICE, CLIENT HALF (AGL-3452).\n *\n * Both phishing pages found on free sites on 2026-10-01 had one button, and it\n * went straight to an outside credential harvester: the site's address was\n * only the trusted-looking hop. So for a FREE workspace in its first 14 days,\n * a link to another domain goes through a notice on the site's own host\n * first, which names the destination in plain text and says the platform\n * does not operate it.\n *\n * The server decides whether a page is in that window and hands the page a\n * {@link LeavingNoticeConfig}; this module only applies it. Nothing here\n * reads the clock, so the server render and the hydrating client always\n * produce the same href.\n *\n * A leaf with no imports: it is on every published page through\n * `useLinkTarget`, and the hosts and signatures it needs arrive as data.\n *=========================================*/\n\n/** The first path segment the notice owns on every site (a reserved slug). */\nexport const LEAVING_NOTICE_ROUTE_SEGMENT = '_aglyn'\n\n/** Where the notice is served, on the site's own host. */\nexport const LEAVING_NOTICE_PATH = `/${LEAVING_NOTICE_ROUTE_SEGMENT}/leaving`\n\n/** The query parameter carrying the destination. */\nexport const LEAVING_NOTICE_DESTINATION_PARAM = 'to'\n\n/** The query parameter carrying the destination's signature. */\nexport const LEAVING_NOTICE_SIGNATURE_PARAM = 'sig'\n\n/** What a page in its notice window carries, from the server. */\nexport interface LeavingNoticeConfig {\n /**\n * Epoch ms the window closes. For people and specs reading the payload:\n * nothing here compares it to a clock, because the page that carries this\n * config was rendered inside the window and its href has to match the\n * server's byte for byte.\n */\n until: number\n /**\n * The hosts a link may go to without the notice: the site's own subdomain\n * and custom domain, and the platform's own. An entry starting with `.` is\n * that domain AND every name under it.\n */\n hosts: readonly string[]\n /**\n * Destination (as {@link normalizeLeavingDestination} writes it) → its\n * signature, for every outside address the server found on this page. A\n * destination with none still goes through the notice, which refuses it.\n */\n sigs: Readonly<Record<string, string>>\n}\n\n/** The origin a site-relative href is resolved against to tell it apart. */\nconst SITE_RELATIVE_BASE = 'https://site-relative.invalid'\nconst SITE_RELATIVE_HOSTNAME = 'site-relative.invalid'\n\n/**\n * The absolute http(s) address an href navigates to when it names another\n * host, or `null` for anything that stays on the page's own origin or is not\n * a navigation at all.\n *\n * Resolved the way a browser resolves it rather than pattern-matched, because\n * a pattern is exactly what a disguised link is built to get past: `//evil`\n * and `/\\evil` are both protocol-relative to a browser while reading as\n * site paths to a `^/` test. `mailto:`, `tel:` and `sms:` hand off to another\n * app and are not navigations, so they answer `null` with every other scheme.\n *\n * The answer is the URL's `href` — lowercased host, default port dropped,\n * root path filled in — so the server that signs a destination and the\n * client that looks the signature up agree on one spelling.\n */\nexport function normalizeLeavingDestination(raw: unknown): string | null {\n if (typeof raw !== 'string') return null\n const trimmed = raw.trim()\n if (!trimmed) return null\n let url: URL\n try {\n url = new URL(trimmed, SITE_RELATIVE_BASE)\n } catch {\n return null\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n if (!url.hostname || url.hostname === SITE_RELATIVE_HOSTNAME) return null\n return url.href\n}\n\n/**\n * Whether a hostname is one a link may reach without the notice: listed in\n * `hosts` (exactly, or under a `.domain` entry), or a development machine's\n * own `localhost`, which sends a visitor nowhere.\n */\nexport function isLeavingNoticeExempt(\n hostname: string,\n hosts: readonly string[],\n): boolean {\n const name = String(hostname ?? '')\n .toLowerCase()\n .replace(/\\.+$/, '')\n if (!name) return true\n if (name === 'localhost' || name.endsWith('.localhost')) return true\n for (const entry of hosts) {\n if (entry.startsWith('.')) {\n if (name === entry.slice(1) || name.endsWith(entry)) return true\n } else if (name === entry) {\n return true\n }\n }\n return false\n}\n\n/**\n * The normalized destination of an href that leaves for a host outside\n * `hosts`, or `null` when the href stays, is not a navigation, or is exempt.\n */\nexport function leavingDestination(\n raw: unknown,\n hosts: readonly string[],\n): string | null {\n const destination = normalizeLeavingDestination(raw)\n if (!destination) return null\n return isLeavingNoticeExempt(new URL(destination).hostname, hosts)\n ? null\n : destination\n}\n\n/** The notice's own address for a destination, signed when a signature is known. */\nexport function leavingNoticeHref(\n destination: string,\n signature?: string | null,\n): string {\n const params = new URLSearchParams()\n params.set(LEAVING_NOTICE_DESTINATION_PARAM, destination)\n if (signature) params.set(LEAVING_NOTICE_SIGNATURE_PARAM, signature)\n return `${LEAVING_NOTICE_PATH}?${params.toString()}`\n}\n\n/**\n * The href a link should carry instead of `raw` — the notice for its\n * destination — or `undefined` to leave it as it is: no config (the site is\n * not in its window), a destination on an exempt host, or not a navigation.\n */\nexport function routeThroughLeavingNotice(\n raw: unknown,\n config: LeavingNoticeConfig | null | undefined,\n): string | undefined {\n if (!config) return undefined\n const destination = leavingDestination(raw, config.hosts)\n if (!destination) return undefined\n return leavingNoticeHref(destination, config.sigs?.[destination])\n}\n\n/**\n * Points the anchor an event landed in at the notice, when it leaves.\n *\n * The catch-all for every link `useLinkTarget` never saw — Markdown and rich\n * text bodies, collection blocks, a plugin's own anchors. The href is\n * rewritten IN PLACE, before the browser acts on the event, so the visitor's\n * own gesture still decides how it opens: a modified click, a middle click\n * and \"Open in new tab\" all take the notice with them. Next's `Link` leaves\n * a non-local href to the browser, so the browser is what reads the change.\n *\n * Returns whether it rewrote anything.\n */\nexport function rerouteLeavingAnchor(\n target: EventTarget | null,\n config: LeavingNoticeConfig | null | undefined,\n): boolean {\n if (!config || !target) return false\n const node = target as Node\n const element =\n node.nodeType === 1 ? (node as Element) : (node.parentElement ?? null)\n const anchor = element?.closest?.('a[href], area[href]')\n if (!anchor) return false\n const rerouted = routeThroughLeavingNotice(anchor.getAttribute('href'), config)\n if (!rerouted) return false\n anchor.setAttribute('href', rerouted)\n return true\n}\n\n/**\n * Every gesture that can follow, open or copy a link, caught on the way\n * down so the href is the notice's before anything reads it.\n */\nconst LEAVING_NOTICE_EVENTS = [\n 'pointerdown',\n 'click',\n 'auxclick',\n 'contextmenu',\n 'focusin',\n] as const\n\n/** Installs {@link rerouteLeavingAnchor} on a document; returns the uninstall. */\nexport function installLeavingNoticeInterceptor(\n doc: Document,\n config: LeavingNoticeConfig,\n): () => void {\n const handler = (event: Event) => {\n rerouteLeavingAnchor(event.target, config)\n }\n for (const type of LEAVING_NOTICE_EVENTS) {\n doc.addEventListener(type, handler, true)\n }\n return () => {\n for (const type of LEAVING_NOTICE_EVENTS) {\n doc.removeEventListener(type, handler, true)\n }\n }\n}\n"],"names":["LEAVING_NOTICE_ROUTE_SEGMENT","LEAVING_NOTICE_PATH","LEAVING_NOTICE_DESTINATION_PARAM","LEAVING_NOTICE_SIGNATURE_PARAM","SITE_RELATIVE_BASE","SITE_RELATIVE_HOSTNAME","normalizeLeavingDestination","raw","trimmed","trim","url","URL","protocol","hostname","href","isLeavingNoticeExempt","hosts","name","String","toLowerCase","replace","endsWith","entry","startsWith","slice","leavingDestination","destination","leavingNoticeHref","signature","params","URLSearchParams","set","toString","routeThroughLeavingNotice","config","undefined","sigs","rerouteLeavingAnchor","target","node","element","nodeType","parentElement","anchor","closest","rerouted","getAttribute","setAttribute","LEAVING_NOTICE_EVENTS","installLeavingNoticeInterceptor","doc","handler","event","type","addEventListener","removeEventListener"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;2CAiB2C,GAE3C,4EAA4E,GAC5E,OAAO,MAAMA,+BAA+B,SAAQ;AAEpD,wDAAwD,GACxD,OAAO,MAAMC,sBAAsB,CAAC,CAAC,EAAED,6BAA6B,QAAQ,CAAC,CAAA;AAE7E,kDAAkD,GAClD,OAAO,MAAME,mCAAmC,KAAI;AAEpD,8DAA8D,GAC9D,OAAO,MAAMC,iCAAiC,MAAK;AAyBnD,0EAA0E,GAC1E,MAAMC,qBAAqB;AAC3B,MAAMC,yBAAyB;AAE/B;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,4BAA4BC,GAAY;IACtD,IAAI,OAAOA,QAAQ,UAAU,OAAO;IACpC,MAAMC,UAAUD,IAAIE,IAAI;IACxB,IAAI,CAACD,SAAS,OAAO;IACrB,IAAIE;IACJ,IAAI;QACFA,MAAM,IAAIC,IAAIH,SAASJ;IACzB,EAAE,eAAM;QACN,OAAO;IACT;IACA,IAAIM,IAAIE,QAAQ,KAAK,WAAWF,IAAIE,QAAQ,KAAK,UAAU,OAAO;IAClE,IAAI,CAACF,IAAIG,QAAQ,IAAIH,IAAIG,QAAQ,KAAKR,wBAAwB,OAAO;IACrE,OAAOK,IAAII,IAAI;AACjB;AAEA;;;;CAIC,GACD,OAAO,SAASC,sBACdF,QAAgB,EAChBG,KAAwB;IAExB,MAAMC,OAAOC,OAAOL,mBAAAA,WAAY,IAC7BM,WAAW,GACXC,OAAO,CAAC,QAAQ;IACnB,IAAI,CAACH,MAAM,OAAO;IAClB,IAAIA,SAAS,eAAeA,KAAKI,QAAQ,CAAC,eAAe,OAAO;IAChE,KAAK,MAAMC,SAASN,MAAO;QACzB,IAAIM,MAAMC,UAAU,CAAC,MAAM;YACzB,IAAIN,SAASK,MAAME,KAAK,CAAC,MAAMP,KAAKI,QAAQ,CAACC,QAAQ,OAAO;QAC9D,OAAO,IAAIL,SAASK,OAAO;YACzB,OAAO;QACT;IACF;IACA,OAAO;AACT;AAEA;;;CAGC,GACD,OAAO,SAASG,mBACdlB,GAAY,EACZS,KAAwB;IAExB,MAAMU,cAAcpB,4BAA4BC;IAChD,IAAI,CAACmB,aAAa,OAAO;IACzB,OAAOX,sBAAsB,IAAIJ,IAAIe,aAAab,QAAQ,EAAEG,SACxD,OACAU;AACN;AAEA,kFAAkF,GAClF,OAAO,SAASC,kBACdD,WAAmB,EACnBE,SAAyB;IAEzB,MAAMC,SAAS,IAAIC;IACnBD,OAAOE,GAAG,CAAC7B,kCAAkCwB;IAC7C,IAAIE,WAAWC,OAAOE,GAAG,CAAC5B,gCAAgCyB;IAC1D,OAAO,GAAG3B,oBAAoB,CAAC,EAAE4B,OAAOG,QAAQ,IAAI;AACtD;AAEA;;;;CAIC,GACD,OAAO,SAASC,0BACd1B,GAAY,EACZ2B,MAA8C;QAKRA;IAHtC,IAAI,CAACA,QAAQ,OAAOC;IACpB,MAAMT,cAAcD,mBAAmBlB,KAAK2B,OAAOlB,KAAK;IACxD,IAAI,CAACU,aAAa,OAAOS;IACzB,OAAOR,kBAAkBD,cAAaQ,eAAAA,OAAOE,IAAI,qBAAXF,YAAa,CAACR,YAAY;AAClE;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASW,qBACdC,MAA0B,EAC1BJ,MAA8C;QAKDK;QAC9BC;IAJf,IAAI,CAACN,UAAU,CAACI,QAAQ,OAAO;IAC/B,MAAMC,OAAOD;IACb,MAAME,UACJD,KAAKE,QAAQ,KAAK,IAAKF,QAAoBA,sBAAAA,KAAKG,aAAa,YAAlBH,sBAAsB;IACnE,MAAMI,SAASH,4BAAAA,mBAAAA,QAASI,OAAO,qBAAhBJ,sBAAAA,SAAmB;IAClC,IAAI,CAACG,QAAQ,OAAO;IACpB,MAAME,WAAWZ,0BAA0BU,OAAOG,YAAY,CAAC,SAASZ;IACxE,IAAI,CAACW,UAAU,OAAO;IACtBF,OAAOI,YAAY,CAAC,QAAQF;IAC5B,OAAO;AACT;AAEA;;;CAGC,GACD,MAAMG,wBAAwB;IAC5B;IACA;IACA;IACA;IACA;CACD;AAED,gFAAgF,GAChF,OAAO,SAASC,gCACdC,GAAa,EACbhB,MAA2B;IAE3B,MAAMiB,UAAU,CAACC;QACff,qBAAqBe,MAAMd,MAAM,EAAEJ;IACrC;IACA,KAAK,MAAMmB,QAAQL,sBAAuB;QACxCE,IAAII,gBAAgB,CAACD,MAAMF,SAAS;IACtC;IACA,OAAO;QACL,KAAK,MAAME,QAAQL,sBAAuB;YACxCE,IAAIK,mBAAmB,CAACF,MAAMF,SAAS;QACzC;IACF;AACF"}
|
|
@@ -61,6 +61,23 @@ const DAY = 24 * 60;
|
|
|
61
61
|
defaultEnabled: true,
|
|
62
62
|
notificationType: 'system.abuseReportUrgent'
|
|
63
63
|
},
|
|
64
|
+
{
|
|
65
|
+
// The one lock no person placed (AGL-3450): staff confirm it before the
|
|
66
|
+
// customer's appeal arrives, and re-place it as `abuse` or lift it.
|
|
67
|
+
type: 'security.pageSecurityHold',
|
|
68
|
+
label: 'Automatic security hold on a new workspace',
|
|
69
|
+
description: '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.',
|
|
70
|
+
tier: 'must',
|
|
71
|
+
category: 'security',
|
|
72
|
+
title: 'Security hold placed on {{orgName}}',
|
|
73
|
+
body: '{{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).',
|
|
74
|
+
link: '/admin/abuse-reports?report={{reviewId}}',
|
|
75
|
+
delivery: 'immediate',
|
|
76
|
+
// One hold per workspace; a repeat inside the day is the same event.
|
|
77
|
+
dedupeWindowMinutes: DAY,
|
|
78
|
+
defaultEnabled: true,
|
|
79
|
+
notificationType: 'system.abuseReportUrgent'
|
|
80
|
+
},
|
|
64
81
|
{
|
|
65
82
|
type: 'system.disputeUnattributed',
|
|
66
83
|
label: 'Card dispute with no owner',
|
|
@@ -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 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 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 // ── 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 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;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;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;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;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: '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 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 // ── 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 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,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;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;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;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"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { LeavingNoticeConfig } from './leaving-notice';
|
|
17
18
|
/**
|
|
18
19
|
* Host routing map: screen id → routed path in the tenant matcher format
|
|
19
20
|
* (root is `'/'`, nested paths are slash-joined segments WITHOUT a leading
|
|
@@ -45,6 +46,12 @@ export interface ScreenLinkContextValue {
|
|
|
45
46
|
localeVariants?: Record<string, string>;
|
|
46
47
|
/** Locale of the screen being rendered (AGL-164). */
|
|
47
48
|
currentLocale?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Present only on a published page of a site in its leaving-notice window
|
|
51
|
+
* (AGL-3452): links to other domains resolve to the notice on the site's
|
|
52
|
+
* own host instead. See `leaving-notice.ts`.
|
|
53
|
+
*/
|
|
54
|
+
leavingNotice?: LeavingNoticeConfig;
|
|
48
55
|
}
|
|
49
56
|
/**
|
|
50
57
|
* Render-time resolution context for id-based screen links: canvas nodes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/screen-link-context-value.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The render-time screen-link context, and the shape it carries.\n *\n * Its own module because a published page provides this context and reads\n * nothing else here: the map goes in at the top of the tree and the canvas\n * nodes below resolve their hrefs from it. `screen-link-context.ts` around it\n * is the resolution machinery every editing surface needs — the broken-link\n * verdict and its styling, the unavailable-screen labels, the href resolver,\n * the link-value splitter and the two hooks — and through\n * `screen-link-value.ts` the persisted link grammar as well. A bundler cannot\n * drop that around a single named import.\n *\n * No `'use client'` banner, for the reason `screen-link-context.ts` records:\n * inside this package the directive forks the module graph, and a second\n * copy of the context is a second context — providers on one, consumers on\n * the other, and every link resolving to nothing.\n */\n\nimport { createContext } from 'react'\n\n/**\n * Host routing map: screen id → routed path in the tenant matcher format\n * (root is `'/'`, nested paths are slash-joined segments WITHOUT a leading\n * slash, e.g. `company/about`). This is the `screens` field of the host\n * document — the single source of truth kept current by the publish and\n * hierarchy flows.\n */\nexport type ScreenRouteMap = Record<string, string>\n\nexport interface ScreenLinkContextValue {\n /** Routing map hrefs are resolved against. Absent → nothing resolves. */\n screens?: ScreenRouteMap\n /** Optional display names by screen id, for editor-facing pickers. */\n labels?: Record<string, string>\n /**\n * True inside editing surfaces (besigner canvas, preview): screen links\n * render their content but must not navigate.\n */\n suppressNavigation?: boolean\n /**\n * True ONLY on the static besigner canvas (AGL-830): interactions are\n * inert and command-bus-driven elements (nav menus, drawers) render their\n * editor affordance instead of the live popup. The Preview surface leaves\n * this falsy — it suppresses navigation but runs interactions for real, so\n * a hover-to-open mega menu behaves exactly like the live site. Split out\n * of {@link suppressNavigation}, which now means only \"links don't navigate\".\n */\n editorInert?: boolean\n /** Current screen's translations: locale → screen id (AGL-164). */\n localeVariants?: Record<string, string>\n /** Locale of the screen being rendered (AGL-164). */\n currentLocale?: string\n}\n\n/**\n * Render-time resolution context for id-based screen links: canvas nodes\n * persist a screen id, never a path, so slug renames and re-parenting can't\n * break links. Provided by the tenant page (map from static props, refreshed\n * by ISR) and by the console's besigner/preview surfaces (map from the live\n * host doc subscription, navigation suppressed). Context crosses the canvas\n * shadow DOM because the shadow root renders through a React portal.\n */\nexport const ScreenLinkContext = createContext<ScreenLinkContextValue>({})\nScreenLinkContext.displayName = 'ScreenLinkContext'\n"],"names":["createContext","ScreenLinkContext","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GAED,SAASA,aAAa,QAAQ,QAAO;
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/screen-link-context-value.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The render-time screen-link context, and the shape it carries.\n *\n * Its own module because a published page provides this context and reads\n * nothing else here: the map goes in at the top of the tree and the canvas\n * nodes below resolve their hrefs from it. `screen-link-context.ts` around it\n * is the resolution machinery every editing surface needs — the broken-link\n * verdict and its styling, the unavailable-screen labels, the href resolver,\n * the link-value splitter and the two hooks — and through\n * `screen-link-value.ts` the persisted link grammar as well. A bundler cannot\n * drop that around a single named import.\n *\n * No `'use client'` banner, for the reason `screen-link-context.ts` records:\n * inside this package the directive forks the module graph, and a second\n * copy of the context is a second context — providers on one, consumers on\n * the other, and every link resolving to nothing.\n */\n\nimport { createContext } from 'react'\nimport type { LeavingNoticeConfig } from './leaving-notice'\n\n/**\n * Host routing map: screen id → routed path in the tenant matcher format\n * (root is `'/'`, nested paths are slash-joined segments WITHOUT a leading\n * slash, e.g. `company/about`). This is the `screens` field of the host\n * document — the single source of truth kept current by the publish and\n * hierarchy flows.\n */\nexport type ScreenRouteMap = Record<string, string>\n\nexport interface ScreenLinkContextValue {\n /** Routing map hrefs are resolved against. Absent → nothing resolves. */\n screens?: ScreenRouteMap\n /** Optional display names by screen id, for editor-facing pickers. */\n labels?: Record<string, string>\n /**\n * True inside editing surfaces (besigner canvas, preview): screen links\n * render their content but must not navigate.\n */\n suppressNavigation?: boolean\n /**\n * True ONLY on the static besigner canvas (AGL-830): interactions are\n * inert and command-bus-driven elements (nav menus, drawers) render their\n * editor affordance instead of the live popup. The Preview surface leaves\n * this falsy — it suppresses navigation but runs interactions for real, so\n * a hover-to-open mega menu behaves exactly like the live site. Split out\n * of {@link suppressNavigation}, which now means only \"links don't navigate\".\n */\n editorInert?: boolean\n /** Current screen's translations: locale → screen id (AGL-164). */\n localeVariants?: Record<string, string>\n /** Locale of the screen being rendered (AGL-164). */\n currentLocale?: string\n /**\n * Present only on a published page of a site in its leaving-notice window\n * (AGL-3452): links to other domains resolve to the notice on the site's\n * own host instead. See `leaving-notice.ts`.\n */\n leavingNotice?: LeavingNoticeConfig\n}\n\n/**\n * Render-time resolution context for id-based screen links: canvas nodes\n * persist a screen id, never a path, so slug renames and re-parenting can't\n * break links. Provided by the tenant page (map from static props, refreshed\n * by ISR) and by the console's besigner/preview surfaces (map from the live\n * host doc subscription, navigation suppressed). Context crosses the canvas\n * shadow DOM because the shadow root renders through a React portal.\n */\nexport const ScreenLinkContext = createContext<ScreenLinkContextValue>({})\nScreenLinkContext.displayName = 'ScreenLinkContext'\n"],"names":["createContext","ScreenLinkContext","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GAED,SAASA,aAAa,QAAQ,QAAO;AA2CrC;;;;;;;CAOC,GACD,OAAO,MAAMC,oBAAoBD,cAAsC,CAAC,GAAE;AAC1EC,kBAAkBC,WAAW,GAAG"}
|
|
@@ -20,6 +20,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
20
20
|
// barrel from the tenant page) makes the bundler duplicate parts of the
|
|
21
21
|
// module graph — a second canvas/emitter instance renders the site blank.
|
|
22
22
|
import { useContext, useMemo } from "react";
|
|
23
|
+
import { routeThroughLeavingNotice } from "./leaving-notice.js";
|
|
23
24
|
import { ScreenLinkContext } from "./screen-link-context-value.js";
|
|
24
25
|
import { EXTERNAL_HREF_PATTERN, SAFE_HREF_PATTERN, formatCollectionLinkValue, linkTargetKind, parseCollectionLinkValue, parseEntryLinkValue, parseFeedLinkValue, resolveScreenHref, screenRoutesAnswerFor, splitLinkValue } from "./screen-link-value.js";
|
|
25
26
|
export * from "./screen-link-context-value.js";
|
|
@@ -260,12 +261,23 @@ export function unresolvedScreenOption(value, screens) {
|
|
|
260
261
|
*/ export function useLinkTarget(screenId, href) {
|
|
261
262
|
const target = splitLinkValue(screenId, href);
|
|
262
263
|
const resolved = useScreenLink(target.screenId);
|
|
264
|
+
const { leavingNotice } = useContext(ScreenLinkContext);
|
|
263
265
|
const externalHref = target.href && SAFE_HREF_PATTERN.test(target.href) ? target.href : undefined;
|
|
264
266
|
const finalHref = target.screenId ? resolved.href : externalHref;
|
|
267
|
+
/*
|
|
268
|
+
* A new free site's links to other domains go through the leaving notice on
|
|
269
|
+
* its own host (AGL-3452). Here rather than in each element so the served
|
|
270
|
+
* HTML already carries the notice's address: it holds without JavaScript,
|
|
271
|
+
* and it is what a hover, a copied link and "Open in new tab" all see.
|
|
272
|
+
*
|
|
273
|
+
* The notice is on the site, so the rewritten link no longer leaves it —
|
|
274
|
+
* and keeping the referrer is what lets the notice's Go back return to the
|
|
275
|
+
* page the visitor came from.
|
|
276
|
+
*/ const noticeHref = !target.screenId && !resolved.suppressNavigation ? routeThroughLeavingNotice(externalHref, leavingNotice) : undefined;
|
|
265
277
|
return _extends({}, resolved, {
|
|
266
|
-
href: finalHref,
|
|
278
|
+
href: noticeHref != null ? noticeHref : finalHref,
|
|
267
279
|
externalHref,
|
|
268
|
-
leavesSite: Boolean(!target.screenId && externalHref && EXTERNAL_HREF_PATTERN.test(externalHref))
|
|
280
|
+
leavesSite: Boolean(!noticeHref && !target.screenId && externalHref && EXTERNAL_HREF_PATTERN.test(externalHref))
|
|
269
281
|
});
|
|
270
282
|
}
|
|
271
283
|
/**
|