@aglyn/aglyn 1.0.0-beta.195 → 1.0.0-beta.196

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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * The header and footer a system email is drawn inside, for the brand it is
19
+ * sent under (AGL-3322).
20
+ *
21
+ * A system email's built-in copy is a few lines of text, and only a staff
22
+ * design can place the platform marketing site's email blocks. Without this,
23
+ * the mail the platform sends most — every password reset, invite and alert
24
+ * nobody has designed — would name its sender nowhere but the From line, and
25
+ * tell the reader neither why it came nor where to get help. The renderer
26
+ * draws whatever `EmailChrome` it is handed; this module decides what that
27
+ * is, which comes down to one question: whose mail is this?
28
+ *
29
+ * ## The platform's own
30
+ *
31
+ * Its logo linked to its home page, and a footer of the email's reason, the
32
+ * support link and a `© {year} {legal name} · {postal address}` line. Every
33
+ * value is read from `platform-brand.ts`, so a renamed or self-hosted
34
+ * deployment prints its own configuration: its logo or its name in bold, its
35
+ * home page or no link, its address or none — and never ours. The Aglyn
36
+ * defaults there apply only while the deployment is still called Aglyn.
37
+ *
38
+ * ## A white-label organization's
39
+ *
40
+ * Its own email logo, or its product name in bold; the reason, whose brand
41
+ * tokens are the agency's; and a support link only when the agency set one.
42
+ * No legal line: the only legal name and address this module knows are the
43
+ * platform's, and the recipient is the agency's customer, who must not learn
44
+ * the platform exists from the foot of an invite.
45
+ *
46
+ * Pure: no reads, no clock of its own that a test cannot replace.
47
+ */
48
+ import type { EmailChrome } from '@aglyn/shared-util-email/email-render';
49
+ import type { SystemEmailTemplateDefinition } from '@aglyn/shared-util-email/system-email-catalog';
50
+ /**
51
+ * `brand.*` tokens every system email resolves, UNDER whatever the caller
52
+ * supplies (AGL-2139).
53
+ *
54
+ * The order is the whole safety property. A system email blanks any token
55
+ * the caller did not provide, so a designed template written against
56
+ * `{{brand.productName}}` would ship with a hole in the sentence at any send
57
+ * site that forgot to pass one. Defaulting to the platform profile means the
58
+ * worst case is the platform's own copy rather than a gap — and an
59
+ * org-context sender that spreads `brandMergeTokens(branding)` into its merge
60
+ * map wins, because a later key overwrites an earlier one.
61
+ *
62
+ * The platform-scoped senders — password reset, email verification, the
63
+ * security alerts, the staff erasure-hold alert — genuinely have no org (each
64
+ * says so at its own call site, and password reset deliberately refuses to
65
+ * look one up because that would be an enumeration oracle). The platform IS
66
+ * their brand, so these are the right answer for them.
67
+ */
68
+ export declare const PLATFORM_BRAND_MERGE_TOKENS: Readonly<Record<string, string>>;
69
+ /**
70
+ * Brand inputs that are NOT merge tokens (AGL-2139).
71
+ *
72
+ * `brand.productName` and friends ride the merge map because a designer types
73
+ * them into copy. The logo and the home page are structural — the header the
74
+ * renderer draws, or does not — so they are options rather than tokens a
75
+ * template could forget to place.
76
+ */
77
+ export interface SystemEmailBrandOptions {
78
+ /** The org's resolved `emailLogoUrl`; absent or blank is no org logo. */
79
+ brandLogoUrl?: string | null;
80
+ /**
81
+ * The org's resolved `homeUrl`: where a white-label header links. Only an
82
+ * `http(s)` URL is linked; a `mailto:` home is no destination for a logo.
83
+ */
84
+ brandHomeUrl?: string | null;
85
+ }
86
+ /**
87
+ * Whether a send goes out under the PLATFORM's brand, which is the only kind
88
+ * that may carry the platform's header and footer — the marketing site's
89
+ * email blocks (AGL-3318), or the chrome {@link buildSystemEmailChrome} draws
90
+ * in their place (AGL-3322).
91
+ *
92
+ * Those are the platform's logo, its address, its line telling the reader why
93
+ * they get the mail. An agency on white-label sells its clients a product
94
+ * with no vendor in it, and an invite or a usage summary sent to one of their
95
+ * people reads as the agency's own mail. One platform footer in it names the
96
+ * vendor the whole tier is bought to hide.
97
+ *
98
+ * Read off what the send is actually branded as, rather than asked of the
99
+ * caller, so no sender can forget to say. An org-context sender spreads
100
+ * `brandMergeTokens(resolveBrandingProfile(org))` over the defaults and passes
101
+ * the profile's email logo. A white-label profile never inherits the
102
+ * platform's support URL (AGL-2428), so its tokens differ from
103
+ * {@link PLATFORM_BRAND_MERGE_TOKENS} on `brand.supportUrl` at least, and the
104
+ * platform profile has no email logo of its own, so any logo is an org's. The
105
+ * org-less senders, and every org without white-label, which resolves to the
106
+ * platform profile itself, match the defaults exactly: those sends are the
107
+ * platform's.
108
+ */
109
+ export declare function isPlatformBrandedSend(merged: Readonly<Record<string, string>>, options?: SystemEmailBrandOptions): boolean;
110
+ export interface SystemEmailChromeInput extends SystemEmailBrandOptions {
111
+ /** The email being sent; its `footerReason` is the footer's first line. */
112
+ definition: Pick<SystemEmailTemplateDefinition, 'footerReason'>;
113
+ /**
114
+ * The send's merge map, platform brand tokens under the caller's — the map
115
+ * the body renders with, which is what makes the reason's tokens resolve to
116
+ * the same names the body uses.
117
+ */
118
+ merged: Readonly<Record<string, string>>;
119
+ /** The clock the copyright year is read from. */
120
+ now?: () => Date;
121
+ }
122
+ /**
123
+ * The chrome one system email is drawn inside, for the brand it is sent
124
+ * under. See the module comment for the two shapes and why they differ.
125
+ *
126
+ * The reason and the other lines go out as templates: the renderer runs them
127
+ * through the same merge substitution as the body, so `{{org.name}}` and
128
+ * `{{brand.productName}}` read exactly as they do in the copy above them.
129
+ */
130
+ export declare function buildSystemEmailChrome(input: SystemEmailChromeInput): EmailChrome;
@@ -0,0 +1,171 @@
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 header and footer a system email is drawn inside, for the brand it is
18
+ * sent under (AGL-3322).
19
+ *
20
+ * A system email's built-in copy is a few lines of text, and only a staff
21
+ * design can place the platform marketing site's email blocks. Without this,
22
+ * the mail the platform sends most — every password reset, invite and alert
23
+ * nobody has designed — would name its sender nowhere but the From line, and
24
+ * tell the reader neither why it came nor where to get help. The renderer
25
+ * draws whatever `EmailChrome` it is handed; this module decides what that
26
+ * is, which comes down to one question: whose mail is this?
27
+ *
28
+ * ## The platform's own
29
+ *
30
+ * Its logo linked to its home page, and a footer of the email's reason, the
31
+ * support link and a `© {year} {legal name} · {postal address}` line. Every
32
+ * value is read from `platform-brand.ts`, so a renamed or self-hosted
33
+ * deployment prints its own configuration: its logo or its name in bold, its
34
+ * home page or no link, its address or none — and never ours. The Aglyn
35
+ * defaults there apply only while the deployment is still called Aglyn.
36
+ *
37
+ * ## A white-label organization's
38
+ *
39
+ * Its own email logo, or its product name in bold; the reason, whose brand
40
+ * tokens are the agency's; and a support link only when the agency set one.
41
+ * No legal line: the only legal name and address this module knows are the
42
+ * platform's, and the recipient is the agency's customer, who must not learn
43
+ * the platform exists from the foot of an invite.
44
+ *
45
+ * Pure: no reads, no clock of its own that a test cannot replace.
46
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
47
+ import { brandMergeTokens } from "./plan-entitlements.js";
48
+ import { PLATFORM_BRANDING_PROFILE, PLATFORM_BRAND_LEGAL_NAME, PLATFORM_BRAND_NAME, PLATFORM_EMAIL_LOGO_URL, PLATFORM_HOME_URL, PLATFORM_POSTAL_ADDRESS } from "./platform-brand.js";
49
+ /**
50
+ * `brand.*` tokens every system email resolves, UNDER whatever the caller
51
+ * supplies (AGL-2139).
52
+ *
53
+ * The order is the whole safety property. A system email blanks any token
54
+ * the caller did not provide, so a designed template written against
55
+ * `{{brand.productName}}` would ship with a hole in the sentence at any send
56
+ * site that forgot to pass one. Defaulting to the platform profile means the
57
+ * worst case is the platform's own copy rather than a gap — and an
58
+ * org-context sender that spreads `brandMergeTokens(branding)` into its merge
59
+ * map wins, because a later key overwrites an earlier one.
60
+ *
61
+ * The platform-scoped senders — password reset, email verification, the
62
+ * security alerts, the staff erasure-hold alert — genuinely have no org (each
63
+ * says so at its own call site, and password reset deliberately refuses to
64
+ * look one up because that would be an enumeration oracle). The platform IS
65
+ * their brand, so these are the right answer for them.
66
+ */ export const PLATFORM_BRAND_MERGE_TOKENS = brandMergeTokens(PLATFORM_BRANDING_PROFILE);
67
+ /**
68
+ * Whether a send goes out under the PLATFORM's brand, which is the only kind
69
+ * that may carry the platform's header and footer — the marketing site's
70
+ * email blocks (AGL-3318), or the chrome {@link buildSystemEmailChrome} draws
71
+ * in their place (AGL-3322).
72
+ *
73
+ * Those are the platform's logo, its address, its line telling the reader why
74
+ * they get the mail. An agency on white-label sells its clients a product
75
+ * with no vendor in it, and an invite or a usage summary sent to one of their
76
+ * people reads as the agency's own mail. One platform footer in it names the
77
+ * vendor the whole tier is bought to hide.
78
+ *
79
+ * Read off what the send is actually branded as, rather than asked of the
80
+ * caller, so no sender can forget to say. An org-context sender spreads
81
+ * `brandMergeTokens(resolveBrandingProfile(org))` over the defaults and passes
82
+ * the profile's email logo. A white-label profile never inherits the
83
+ * platform's support URL (AGL-2428), so its tokens differ from
84
+ * {@link PLATFORM_BRAND_MERGE_TOKENS} on `brand.supportUrl` at least, and the
85
+ * platform profile has no email logo of its own, so any logo is an org's. The
86
+ * org-less senders, and every org without white-label, which resolves to the
87
+ * platform profile itself, match the defaults exactly: those sends are the
88
+ * platform's.
89
+ */ export function isPlatformBrandedSend(merged, options = {}) {
90
+ var _options_brandLogoUrl;
91
+ if (String((_options_brandLogoUrl = options.brandLogoUrl) != null ? _options_brandLogoUrl : '').trim()) return false;
92
+ return Object.entries(PLATFORM_BRAND_MERGE_TOKENS).every(([token, value])=>merged[token] === value);
93
+ }
94
+ /** A non-empty trimmed string, else undefined. */ const clean = (value)=>{
95
+ const text = typeof value === 'string' ? value.trim() : '';
96
+ return text.length ? text : undefined;
97
+ };
98
+ /**
99
+ * The footer's support link, from the send's `brand.supportUrl`, or
100
+ * undefined when it has none.
101
+ *
102
+ * Empty is a decision, not a gap: a white-label org that set no support URL
103
+ * gets NO line rather than the platform's desk (AGL-2428), which cannot help
104
+ * its customers and would name the vendor to them. A `mailto:` reads as the
105
+ * mailbox, which is what the reader has to type into a client that does not
106
+ * follow the link; a web page reads as an instruction.
107
+ */ function supportLink(url) {
108
+ const href = clean(url);
109
+ if (!href) return undefined;
110
+ if (/^mailto:/i.test(href)) {
111
+ const address = clean(href.slice('mailto:'.length).split('?')[0]);
112
+ return address ? {
113
+ label: address,
114
+ href
115
+ } : undefined;
116
+ }
117
+ return {
118
+ label: 'Get help',
119
+ href
120
+ };
121
+ }
122
+ /**
123
+ * The chrome one system email is drawn inside, for the brand it is sent
124
+ * under. See the module comment for the two shapes and why they differ.
125
+ *
126
+ * The reason and the other lines go out as templates: the renderer runs them
127
+ * through the same merge substitution as the body, so `{{org.name}}` and
128
+ * `{{brand.productName}}` read exactly as they do in the copy above them.
129
+ */ export function buildSystemEmailChrome(input) {
130
+ var _input_now, _merged_brandproductName;
131
+ const { definition, merged, brandLogoUrl, brandHomeUrl } = input;
132
+ const now = (_input_now = input.now) != null ? _input_now : ()=>new Date();
133
+ const reason = clean(definition.footerReason);
134
+ const support = supportLink(merged['brand.supportUrl']);
135
+ const footer = _extends({}, reason ? {
136
+ reason
137
+ } : {}, support ? {
138
+ support
139
+ } : {});
140
+ if (isPlatformBrandedSend(merged, {
141
+ brandLogoUrl
142
+ })) {
143
+ const legal = `© ${now().getUTCFullYear()} ${PLATFORM_BRAND_LEGAL_NAME}` + (PLATFORM_POSTAL_ADDRESS ? ` · ${PLATFORM_POSTAL_ADDRESS}` : '');
144
+ return {
145
+ header: _extends({}, PLATFORM_EMAIL_LOGO_URL ? {
146
+ logoUrl: PLATFORM_EMAIL_LOGO_URL
147
+ } : {}, {
148
+ logoAlt: PLATFORM_BRAND_NAME
149
+ }, PLATFORM_HOME_URL ? {
150
+ href: PLATFORM_HOME_URL
151
+ } : {}),
152
+ footer: _extends({}, footer, {
153
+ legal
154
+ })
155
+ };
156
+ }
157
+ const logoUrl = clean(brandLogoUrl);
158
+ const home = clean(brandHomeUrl);
159
+ return {
160
+ header: _extends({}, logoUrl ? {
161
+ logoUrl
162
+ } : {}, {
163
+ logoAlt: (_merged_brandproductName = merged['brand.productName']) != null ? _merged_brandproductName : ''
164
+ }, home && /^https?:\/\//i.test(home) ? {
165
+ href: home
166
+ } : {}),
167
+ footer
168
+ };
169
+ }
170
+
171
+ //# sourceMappingURL=system-email-chrome.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/system-email-chrome.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 header and footer a system email is drawn inside, for the brand it is\n * sent under (AGL-3322).\n *\n * A system email's built-in copy is a few lines of text, and only a staff\n * design can place the platform marketing site's email blocks. Without this,\n * the mail the platform sends most — every password reset, invite and alert\n * nobody has designed — would name its sender nowhere but the From line, and\n * tell the reader neither why it came nor where to get help. The renderer\n * draws whatever `EmailChrome` it is handed; this module decides what that\n * is, which comes down to one question: whose mail is this?\n *\n * ## The platform's own\n *\n * Its logo linked to its home page, and a footer of the email's reason, the\n * support link and a `© {year} {legal name} · {postal address}` line. Every\n * value is read from `platform-brand.ts`, so a renamed or self-hosted\n * deployment prints its own configuration: its logo or its name in bold, its\n * home page or no link, its address or none — and never ours. The Aglyn\n * defaults there apply only while the deployment is still called Aglyn.\n *\n * ## A white-label organization's\n *\n * Its own email logo, or its product name in bold; the reason, whose brand\n * tokens are the agency's; and a support link only when the agency set one.\n * No legal line: the only legal name and address this module knows are the\n * platform's, and the recipient is the agency's customer, who must not learn\n * the platform exists from the foot of an invite.\n *\n * Pure: no reads, no clock of its own that a test cannot replace.\n */\n\nimport type {\n EmailChrome,\n EmailChromeFooter,\n} from '@aglyn/shared-util-email/email-render'\nimport type { SystemEmailTemplateDefinition } from '@aglyn/shared-util-email/system-email-catalog'\n\nimport { brandMergeTokens } from './plan-entitlements'\nimport {\n PLATFORM_BRANDING_PROFILE,\n PLATFORM_BRAND_LEGAL_NAME,\n PLATFORM_BRAND_NAME,\n PLATFORM_EMAIL_LOGO_URL,\n PLATFORM_HOME_URL,\n PLATFORM_POSTAL_ADDRESS,\n} from './platform-brand'\n\n/**\n * `brand.*` tokens every system email resolves, UNDER whatever the caller\n * supplies (AGL-2139).\n *\n * The order is the whole safety property. A system email blanks any token\n * the caller did not provide, so a designed template written against\n * `{{brand.productName}}` would ship with a hole in the sentence at any send\n * site that forgot to pass one. Defaulting to the platform profile means the\n * worst case is the platform's own copy rather than a gap — and an\n * org-context sender that spreads `brandMergeTokens(branding)` into its merge\n * map wins, because a later key overwrites an earlier one.\n *\n * The platform-scoped senders — password reset, email verification, the\n * security alerts, the staff erasure-hold alert — genuinely have no org (each\n * says so at its own call site, and password reset deliberately refuses to\n * look one up because that would be an enumeration oracle). The platform IS\n * their brand, so these are the right answer for them.\n */\nexport const PLATFORM_BRAND_MERGE_TOKENS: Readonly<Record<string, string>> =\n brandMergeTokens(PLATFORM_BRANDING_PROFILE)\n\n/**\n * Brand inputs that are NOT merge tokens (AGL-2139).\n *\n * `brand.productName` and friends ride the merge map because a designer types\n * them into copy. The logo and the home page are structural — the header the\n * renderer draws, or does not — so they are options rather than tokens a\n * template could forget to place.\n */\nexport interface SystemEmailBrandOptions {\n /** The org's resolved `emailLogoUrl`; absent or blank is no org logo. */\n brandLogoUrl?: string | null\n /**\n * The org's resolved `homeUrl`: where a white-label header links. Only an\n * `http(s)` URL is linked; a `mailto:` home is no destination for a logo.\n */\n brandHomeUrl?: string | null\n}\n\n/**\n * Whether a send goes out under the PLATFORM's brand, which is the only kind\n * that may carry the platform's header and footer — the marketing site's\n * email blocks (AGL-3318), or the chrome {@link buildSystemEmailChrome} draws\n * in their place (AGL-3322).\n *\n * Those are the platform's logo, its address, its line telling the reader why\n * they get the mail. An agency on white-label sells its clients a product\n * with no vendor in it, and an invite or a usage summary sent to one of their\n * people reads as the agency's own mail. One platform footer in it names the\n * vendor the whole tier is bought to hide.\n *\n * Read off what the send is actually branded as, rather than asked of the\n * caller, so no sender can forget to say. An org-context sender spreads\n * `brandMergeTokens(resolveBrandingProfile(org))` over the defaults and passes\n * the profile's email logo. A white-label profile never inherits the\n * platform's support URL (AGL-2428), so its tokens differ from\n * {@link PLATFORM_BRAND_MERGE_TOKENS} on `brand.supportUrl` at least, and the\n * platform profile has no email logo of its own, so any logo is an org's. The\n * org-less senders, and every org without white-label, which resolves to the\n * platform profile itself, match the defaults exactly: those sends are the\n * platform's.\n */\nexport function isPlatformBrandedSend(\n merged: Readonly<Record<string, string>>,\n options: SystemEmailBrandOptions = {},\n): boolean {\n if (String(options.brandLogoUrl ?? '').trim()) return false\n return Object.entries(PLATFORM_BRAND_MERGE_TOKENS).every(\n ([token, value]) => merged[token] === value,\n )\n}\n\nexport interface SystemEmailChromeInput extends SystemEmailBrandOptions {\n /** The email being sent; its `footerReason` is the footer's first line. */\n definition: Pick<SystemEmailTemplateDefinition, 'footerReason'>\n /**\n * The send's merge map, platform brand tokens under the caller's — the map\n * the body renders with, which is what makes the reason's tokens resolve to\n * the same names the body uses.\n */\n merged: Readonly<Record<string, string>>\n /** The clock the copyright year is read from. */\n now?: () => Date\n}\n\n/** A non-empty trimmed string, else undefined. */\nconst clean = (value: string | null | undefined): string | undefined => {\n const text = typeof value === 'string' ? value.trim() : ''\n return text.length ? text : undefined\n}\n\n/**\n * The footer's support link, from the send's `brand.supportUrl`, or\n * undefined when it has none.\n *\n * Empty is a decision, not a gap: a white-label org that set no support URL\n * gets NO line rather than the platform's desk (AGL-2428), which cannot help\n * its customers and would name the vendor to them. A `mailto:` reads as the\n * mailbox, which is what the reader has to type into a client that does not\n * follow the link; a web page reads as an instruction.\n */\nfunction supportLink(\n url: string | undefined,\n): EmailChromeFooter['support'] | undefined {\n const href = clean(url)\n if (!href) return undefined\n if (/^mailto:/i.test(href)) {\n const address = clean(href.slice('mailto:'.length).split('?')[0])\n return address ? { label: address, href } : undefined\n }\n return { label: 'Get help', href }\n}\n\n/**\n * The chrome one system email is drawn inside, for the brand it is sent\n * under. See the module comment for the two shapes and why they differ.\n *\n * The reason and the other lines go out as templates: the renderer runs them\n * through the same merge substitution as the body, so `{{org.name}}` and\n * `{{brand.productName}}` read exactly as they do in the copy above them.\n */\nexport function buildSystemEmailChrome(\n input: SystemEmailChromeInput,\n): EmailChrome {\n const { definition, merged, brandLogoUrl, brandHomeUrl } = input\n const now = input.now ?? (() => new Date())\n const reason = clean(definition.footerReason)\n const support = supportLink(merged['brand.supportUrl'])\n const footer: EmailChromeFooter = {\n ...(reason ? { reason } : {}),\n ...(support ? { support } : {}),\n }\n\n if (isPlatformBrandedSend(merged, { brandLogoUrl })) {\n const legal =\n `© ${now().getUTCFullYear()} ${PLATFORM_BRAND_LEGAL_NAME}` +\n (PLATFORM_POSTAL_ADDRESS ? ` · ${PLATFORM_POSTAL_ADDRESS}` : '')\n return {\n header: {\n ...(PLATFORM_EMAIL_LOGO_URL ? { logoUrl: PLATFORM_EMAIL_LOGO_URL } : {}),\n logoAlt: PLATFORM_BRAND_NAME,\n ...(PLATFORM_HOME_URL ? { href: PLATFORM_HOME_URL } : {}),\n },\n footer: { ...footer, legal },\n }\n }\n\n const logoUrl = clean(brandLogoUrl)\n const home = clean(brandHomeUrl)\n return {\n header: {\n ...(logoUrl ? { logoUrl } : {}),\n logoAlt: merged['brand.productName'] ?? '',\n ...(home && /^https?:\\/\\//i.test(home) ? { href: home } : {}),\n },\n footer,\n }\n}\n"],"names":["brandMergeTokens","PLATFORM_BRANDING_PROFILE","PLATFORM_BRAND_LEGAL_NAME","PLATFORM_BRAND_NAME","PLATFORM_EMAIL_LOGO_URL","PLATFORM_HOME_URL","PLATFORM_POSTAL_ADDRESS","PLATFORM_BRAND_MERGE_TOKENS","isPlatformBrandedSend","merged","options","String","brandLogoUrl","trim","Object","entries","every","token","value","clean","text","length","undefined","supportLink","url","href","test","address","slice","split","label","buildSystemEmailChrome","input","definition","brandHomeUrl","now","Date","reason","footerReason","support","footer","legal","getUTCFullYear","header","logoUrl","logoAlt","home"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC;AAQD,SAASA,gBAAgB,QAAQ,yBAAqB;AACtD,SACEC,yBAAyB,EACzBC,yBAAyB,EACzBC,mBAAmB,EACnBC,uBAAuB,EACvBC,iBAAiB,EACjBC,uBAAuB,QAClB,sBAAkB;AAEzB;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,8BACXP,iBAAiBC,2BAA0B;AAoB7C;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASO,sBACdC,MAAwC,EACxCC,UAAmC,CAAC,CAAC;QAE1BA;IAAX,IAAIC,QAAOD,wBAAAA,QAAQE,YAAY,YAApBF,wBAAwB,IAAIG,IAAI,IAAI,OAAO;IACtD,OAAOC,OAAOC,OAAO,CAACR,6BAA6BS,KAAK,CACtD,CAAC,CAACC,OAAOC,MAAM,GAAKT,MAAM,CAACQ,MAAM,KAAKC;AAE1C;AAeA,gDAAgD,GAChD,MAAMC,QAAQ,CAACD;IACb,MAAME,OAAO,OAAOF,UAAU,WAAWA,MAAML,IAAI,KAAK;IACxD,OAAOO,KAAKC,MAAM,GAAGD,OAAOE;AAC9B;AAEA;;;;;;;;;CASC,GACD,SAASC,YACPC,GAAuB;IAEvB,MAAMC,OAAON,MAAMK;IACnB,IAAI,CAACC,MAAM,OAAOH;IAClB,IAAI,YAAYI,IAAI,CAACD,OAAO;QAC1B,MAAME,UAAUR,MAAMM,KAAKG,KAAK,CAAC,UAAUP,MAAM,EAAEQ,KAAK,CAAC,IAAI,CAAC,EAAE;QAChE,OAAOF,UAAU;YAAEG,OAAOH;YAASF;QAAK,IAAIH;IAC9C;IACA,OAAO;QAAEQ,OAAO;QAAYL;IAAK;AACnC;AAEA;;;;;;;CAOC,GACD,OAAO,SAASM,uBACdC,KAA6B;QAGjBA,YA2BCvB;IA5Bb,MAAM,EAAEwB,UAAU,EAAExB,MAAM,EAAEG,YAAY,EAAEsB,YAAY,EAAE,GAAGF;IAC3D,MAAMG,OAAMH,aAAAA,MAAMG,GAAG,YAATH,aAAc,IAAM,IAAII;IACpC,MAAMC,SAASlB,MAAMc,WAAWK,YAAY;IAC5C,MAAMC,UAAUhB,YAAYd,MAAM,CAAC,mBAAmB;IACtD,MAAM+B,SAA4B,aAC5BH,SAAS;QAAEA;IAAO,IAAI,CAAC,GACvBE,UAAU;QAAEA;IAAQ,IAAI,CAAC;IAG/B,IAAI/B,sBAAsBC,QAAQ;QAAEG;IAAa,IAAI;QACnD,MAAM6B,QACJ,CAAC,EAAE,EAAEN,MAAMO,cAAc,GAAG,CAAC,EAAExC,2BAA2B,GACzDI,CAAAA,0BAA0B,CAAC,GAAG,EAAEA,yBAAyB,GAAG,EAAC;QAChE,OAAO;YACLqC,QAAQ,aACFvC,0BAA0B;gBAAEwC,SAASxC;YAAwB,IAAI,CAAC;gBACtEyC,SAAS1C;eACLE,oBAAoB;gBAAEoB,MAAMpB;YAAkB,IAAI,CAAC;YAEzDmC,QAAQ,aAAKA;gBAAQC;;QACvB;IACF;IAEA,MAAMG,UAAUzB,MAAMP;IACtB,MAAMkC,OAAO3B,MAAMe;IACnB,OAAO;QACLS,QAAQ,aACFC,UAAU;YAAEA;QAAQ,IAAI,CAAC;YAC7BC,OAAO,GAAEpC,2BAAAA,MAAM,CAAC,oBAAoB,YAA3BA,2BAA+B;WACpCqC,QAAQ,gBAAgBpB,IAAI,CAACoB,QAAQ;YAAErB,MAAMqB;QAAK,IAAI,CAAC;QAE7DN;IACF;AACF"}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * Text one plugin has written for it by the workspace's generation service
19
+ * (AGL-3324).
20
+ *
21
+ * A plugin that wants a paragraph drafted — a sequence's email for one
22
+ * person, a summary, a subject line — must not reach a model itself: the
23
+ * provider, the model choice, the credits, the allotments and the usage
24
+ * ledger all belong to whichever plugin sells generation to the workspace,
25
+ * and the package map forbids importing it. So that plugin registers ONE
26
+ * generator here (a slot), and a caller asks for it by nothing more than
27
+ * "the generator": the request names the member the text is for and what
28
+ * the text is for, and the generator applies every rule it applies at its
29
+ * own doors — the permission, the entitlement, the switches, the meter —
30
+ * and answers the text, or why not, and never throws for a refusal.
31
+ *
32
+ * ## The registry carries no authorization of the CALLER
33
+ *
34
+ * A generator applies its rules to the member the request names, and knows
35
+ * nothing about whether the caller may act for that member. A caller
36
+ * establishes, in its own terms and before it asks: who is asking, and that
37
+ * the org is the one they are a member of.
38
+ *
39
+ * ## What a request may carry
40
+ *
41
+ * `system` is the caller's standing instructions, byte-identical from one
42
+ * call to the next so a provider that caches a prefix can; everything about
43
+ * the particular person or record rides in `prompt`. Nothing here is
44
+ * remembered by the generator beyond its usage ledger, which keeps the
45
+ * purpose and the tokens and never the words.
46
+ */
47
+ export interface PluginTextGenerationRequest {
48
+ orgId: string;
49
+ /**
50
+ * The site the text is for, when it is for one: a collaborator's
51
+ * permission and a site's allotment are decided on it. `null` on an
52
+ * org-level surface.
53
+ */
54
+ hostId: string | null;
55
+ /** The member the text is for: their permission, their allotment, their usage. */
56
+ uid: string;
57
+ /** Whether the member's session carries a verified staff claim. */
58
+ staff: boolean;
59
+ /**
60
+ * The org document the caller already read, for the generator's
61
+ * entitlement and meter reads; `null` and the generator reads it.
62
+ */
63
+ org: Readonly<Record<string, unknown>> | null;
64
+ /**
65
+ * What the text is for, as the usage ledger buckets it: the plugin's id
66
+ * and a word, joined by a hyphen (`outreach-curate`). Letters, digits and
67
+ * hyphens only; a generator normalizes anything else.
68
+ */
69
+ purpose: string;
70
+ /** The caller's standing instructions — see the module note. */
71
+ system: string;
72
+ /** The one turn: the brief, the record's facts, the skeleton to rewrite. */
73
+ prompt: string;
74
+ /** The longest answer wanted, in tokens; the generator caps it at its own ceiling. */
75
+ maxTokens?: number;
76
+ now?: Date;
77
+ signal?: AbortSignal;
78
+ }
79
+ /** Why a generator did not answer with text. */
80
+ export type PluginTextGenerationRefusalReason =
81
+ /** No plugin generates text for this workspace, or the door is not released to it. */
82
+ 'unavailable'
83
+ /** The member's role lacks the generation permission. */
84
+ | 'permission'
85
+ /** The workspace's plan does not include generation. */
86
+ | 'entitlement'
87
+ /** Generation is switched off — for the platform, the workspace or the site. */
88
+ | 'off'
89
+ /** A limit refused the request: credits, a budget, an allotment. */
90
+ | 'quota'
91
+ /** The model declined to write it. */
92
+ | 'refused'
93
+ /** The provider could not be reached or failed; nothing was spent. */
94
+ | 'failed';
95
+ export interface PluginTextGenerationRefusal {
96
+ ok: false;
97
+ /** The HTTP status a door would answer with. */
98
+ status: number;
99
+ reason: PluginTextGenerationRefusalReason;
100
+ /** Customer-safe: a caller shows it as it stands. */
101
+ error: string;
102
+ }
103
+ export interface PluginTextGenerationAnswer {
104
+ ok: true;
105
+ text: string;
106
+ /** The model that wrote it, for the caller's own audit record. */
107
+ model: string;
108
+ usage: {
109
+ inputTokens: number;
110
+ outputTokens: number;
111
+ };
112
+ }
113
+ export type PluginTextGenerationResult = PluginTextGenerationAnswer | PluginTextGenerationRefusal;
114
+ export interface PluginTextGenerator {
115
+ /** The text, or why not. A refusal is returned, never thrown. */
116
+ generate(request: PluginTextGenerationRequest): Promise<PluginTextGenerationResult>;
117
+ }
118
+ export declare const PLUGIN_TEXT_GENERATION: import("./plugin-services").PluginServiceContract<PluginTextGenerator>;
119
+ /**
120
+ * Registers the workspace's text generator. The owner is the loader's marker
121
+ * when a register fn is running, else `options.pluginId`; with neither the
122
+ * registration throws. A second plugin's generator throws naming both, and
123
+ * the incumbent keeps serving; the same plugin registering again replaces
124
+ * its own.
125
+ */
126
+ export declare function registerPluginTextGenerator(generator: PluginTextGenerator, options?: {
127
+ pluginId?: string;
128
+ }): void;
129
+ export interface ResolvedPluginTextGenerator {
130
+ /** The plugin that generates. */
131
+ pluginId: string;
132
+ generator: PluginTextGenerator;
133
+ }
134
+ /** The generator with its owner, or `null` when no plugin generates text. */
135
+ export declare function pluginTextGenerator(): ResolvedPluginTextGenerator | null;
136
+ /** A purpose as the ledger keys it: lower-case letters, digits and hyphens. */
137
+ export declare function normalizePluginTextGenerationPurpose(purpose: string): string;
@@ -0,0 +1,43 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { definePluginServiceContract, registerPluginService, resolvePluginServices } from "./plugin-services.js";
18
+ export const PLUGIN_TEXT_GENERATION = definePluginServiceContract('core.text-generation', {
19
+ multiple: false
20
+ });
21
+ /**
22
+ * Registers the workspace's text generator. The owner is the loader's marker
23
+ * when a register fn is running, else `options.pluginId`; with neither the
24
+ * registration throws. A second plugin's generator throws naming both, and
25
+ * the incumbent keeps serving; the same plugin registering again replaces
26
+ * its own.
27
+ */ export function registerPluginTextGenerator(generator, options) {
28
+ registerPluginService(PLUGIN_TEXT_GENERATION, generator, _extends({}, (options == null ? void 0 : options.pluginId) ? {
29
+ pluginId: options.pluginId
30
+ } : {}));
31
+ }
32
+ /** The generator with its owner, or `null` when no plugin generates text. */ export function pluginTextGenerator() {
33
+ const entry = resolvePluginServices(PLUGIN_TEXT_GENERATION)[0];
34
+ return entry ? {
35
+ pluginId: entry.pluginId,
36
+ generator: entry.impl
37
+ } : null;
38
+ }
39
+ /** A purpose as the ledger keys it: lower-case letters, digits and hyphens. */ export function normalizePluginTextGenerationPurpose(purpose) {
40
+ return String(purpose != null ? purpose : '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'plugin';
41
+ }
42
+
43
+ //# sourceMappingURL=plugin-text-generation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-text-generation.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from './plugin-services'\n\n/**\n * Text one plugin has written for it by the workspace's generation service\n * (AGL-3324).\n *\n * A plugin that wants a paragraph drafted — a sequence's email for one\n * person, a summary, a subject line — must not reach a model itself: the\n * provider, the model choice, the credits, the allotments and the usage\n * ledger all belong to whichever plugin sells generation to the workspace,\n * and the package map forbids importing it. So that plugin registers ONE\n * generator here (a slot), and a caller asks for it by nothing more than\n * \"the generator\": the request names the member the text is for and what\n * the text is for, and the generator applies every rule it applies at its\n * own doors — the permission, the entitlement, the switches, the meter —\n * and answers the text, or why not, and never throws for a refusal.\n *\n * ## The registry carries no authorization of the CALLER\n *\n * A generator applies its rules to the member the request names, and knows\n * nothing about whether the caller may act for that member. A caller\n * establishes, in its own terms and before it asks: who is asking, and that\n * the org is the one they are a member of.\n *\n * ## What a request may carry\n *\n * `system` is the caller's standing instructions, byte-identical from one\n * call to the next so a provider that caches a prefix can; everything about\n * the particular person or record rides in `prompt`. Nothing here is\n * remembered by the generator beyond its usage ledger, which keeps the\n * purpose and the tokens and never the words.\n */\n\nexport interface PluginTextGenerationRequest {\n orgId: string\n /**\n * The site the text is for, when it is for one: a collaborator's\n * permission and a site's allotment are decided on it. `null` on an\n * org-level surface.\n */\n hostId: string | null\n /** The member the text is for: their permission, their allotment, their usage. */\n uid: string\n /** Whether the member's session carries a verified staff claim. */\n staff: boolean\n /**\n * The org document the caller already read, for the generator's\n * entitlement and meter reads; `null` and the generator reads it.\n */\n org: Readonly<Record<string, unknown>> | null\n /**\n * What the text is for, as the usage ledger buckets it: the plugin's id\n * and a word, joined by a hyphen (`outreach-curate`). Letters, digits and\n * hyphens only; a generator normalizes anything else.\n */\n purpose: string\n /** The caller's standing instructions — see the module note. */\n system: string\n /** The one turn: the brief, the record's facts, the skeleton to rewrite. */\n prompt: string\n /** The longest answer wanted, in tokens; the generator caps it at its own ceiling. */\n maxTokens?: number\n now?: Date\n signal?: AbortSignal\n}\n\n/** Why a generator did not answer with text. */\nexport type PluginTextGenerationRefusalReason =\n /** No plugin generates text for this workspace, or the door is not released to it. */\n | 'unavailable'\n /** The member's role lacks the generation permission. */\n | 'permission'\n /** The workspace's plan does not include generation. */\n | 'entitlement'\n /** Generation is switched off — for the platform, the workspace or the site. */\n | 'off'\n /** A limit refused the request: credits, a budget, an allotment. */\n | 'quota'\n /** The model declined to write it. */\n | 'refused'\n /** The provider could not be reached or failed; nothing was spent. */\n | 'failed'\n\nexport interface PluginTextGenerationRefusal {\n ok: false\n /** The HTTP status a door would answer with. */\n status: number\n reason: PluginTextGenerationRefusalReason\n /** Customer-safe: a caller shows it as it stands. */\n error: string\n}\n\nexport interface PluginTextGenerationAnswer {\n ok: true\n text: string\n /** The model that wrote it, for the caller's own audit record. */\n model: string\n usage: { inputTokens: number; outputTokens: number }\n}\n\nexport type PluginTextGenerationResult = PluginTextGenerationAnswer | PluginTextGenerationRefusal\n\nexport interface PluginTextGenerator {\n /** The text, or why not. A refusal is returned, never thrown. */\n generate(request: PluginTextGenerationRequest): Promise<PluginTextGenerationResult>\n}\n\nexport const PLUGIN_TEXT_GENERATION = definePluginServiceContract<PluginTextGenerator>(\n 'core.text-generation',\n { multiple: false },\n)\n\n/**\n * Registers the workspace's text generator. The owner is the loader's marker\n * when a register fn is running, else `options.pluginId`; with neither the\n * registration throws. A second plugin's generator throws naming both, and\n * the incumbent keeps serving; the same plugin registering again replaces\n * its own.\n */\nexport function registerPluginTextGenerator(\n generator: PluginTextGenerator,\n options?: { pluginId?: string },\n): void {\n registerPluginService(PLUGIN_TEXT_GENERATION, generator, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n })\n}\n\nexport interface ResolvedPluginTextGenerator {\n /** The plugin that generates. */\n pluginId: string\n generator: PluginTextGenerator\n}\n\n/** The generator with its owner, or `null` when no plugin generates text. */\nexport function pluginTextGenerator(): ResolvedPluginTextGenerator | null {\n const entry = resolvePluginServices(PLUGIN_TEXT_GENERATION)[0]\n return entry ? { pluginId: entry.pluginId, generator: entry.impl } : null\n}\n\n/** A purpose as the ledger keys it: lower-case letters, digits and hyphens. */\nexport function normalizePluginTextGenerationPurpose(purpose: string): string {\n return (\n String(purpose ?? '')\n .toLowerCase()\n .replace(/[^a-z0-9-]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'plugin'\n )\n}\n"],"names":["definePluginServiceContract","registerPluginService","resolvePluginServices","PLUGIN_TEXT_GENERATION","multiple","registerPluginTextGenerator","generator","options","pluginId","pluginTextGenerator","entry","impl","normalizePluginTextGenerationPurpose","purpose","String","toLowerCase","replace"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,2BAA2B,EAC3BC,qBAAqB,EACrBC,qBAAqB,QAChB,uBAAmB;AA2G1B,OAAO,MAAMC,yBAAyBH,4BACpC,wBACA;IAAEI,UAAU;AAAM,GACnB;AAED;;;;;;CAMC,GACD,OAAO,SAASC,4BACdC,SAA8B,EAC9BC,OAA+B;IAE/BN,sBAAsBE,wBAAwBG,WAAW,aACnDC,CAAAA,2BAAAA,QAASC,QAAQ,IAAG;QAAEA,UAAUD,QAAQC,QAAQ;IAAC,IAAI,CAAC;AAE9D;AAQA,2EAA2E,GAC3E,OAAO,SAASC;IACd,MAAMC,QAAQR,sBAAsBC,uBAAuB,CAAC,EAAE;IAC9D,OAAOO,QAAQ;QAAEF,UAAUE,MAAMF,QAAQ;QAAEF,WAAWI,MAAMC,IAAI;IAAC,IAAI;AACvE;AAEA,6EAA6E,GAC7E,OAAO,SAASC,qCAAqCC,OAAe;IAClE,OACEC,OAAOD,kBAAAA,UAAW,IACfE,WAAW,GACXC,OAAO,CAAC,gBAAgB,KACxBA,OAAO,CAAC,YAAY,OAAO;AAElC"}