@aglyn/aglyn 1.0.0-beta.190 → 1.0.0-beta.191
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/consent-groups.d.ts +57 -2
- package/src/lib/app-utils/consent-groups.js +57 -4
- package/src/lib/app-utils/consent-groups.js.map +1 -1
- package/src/lib/app-utils/contact-holder.js +5 -3
- package/src/lib/app-utils/contact-holder.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +1 -1
- package/src/lib/app-utils/docs-help.generated.js +1 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +24 -0
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/aglyn",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.191",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -33,16 +33,16 @@
|
|
|
33
33
|
"./package.json": "./package.json"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
38
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
39
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
40
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
41
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
42
|
-
"@aglyn/shared-util-logger": "1.0.0-beta.
|
|
43
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
44
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
45
|
-
"@aglyn/shared-util-vendor": "1.0.0-beta.
|
|
36
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.191",
|
|
37
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.191",
|
|
38
|
+
"@aglyn/shared-data-types": "1.0.0-beta.191",
|
|
39
|
+
"@aglyn/shared-util-email": "1.0.0-beta.191",
|
|
40
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.191",
|
|
41
|
+
"@aglyn/shared-util-http": "1.0.0-beta.191",
|
|
42
|
+
"@aglyn/shared-util-logger": "1.0.0-beta.191",
|
|
43
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.191",
|
|
44
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.191",
|
|
45
|
+
"@aglyn/shared-util-vendor": "1.0.0-beta.191",
|
|
46
46
|
"@data-driven-forms/react-form-renderer": "^4.2.0",
|
|
47
47
|
"@msgpack/msgpack": "^3.1.3",
|
|
48
48
|
"@types/unist": "^3.0.3",
|
|
@@ -70,6 +70,22 @@
|
|
|
70
70
|
* (`marketing-consent.ts`) — and the preference pages lift one across the
|
|
71
71
|
* group, since a person rejoining the sender has rejoined all of it.
|
|
72
72
|
*
|
|
73
|
+
* ## A pending CONFIRMATION is the asking site's, unless the org says the group waits
|
|
74
|
+
*
|
|
75
|
+
* A site that asks for a confirmation click holds its own mail on that stream
|
|
76
|
+
* until the click comes. A pending question is not a refusal, so by default
|
|
77
|
+
* the rule above does not carry it: the sibling sites go on mailing. An org
|
|
78
|
+
* may turn {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on, and then every
|
|
79
|
+
* site of a declared group waits for the click the way the asking site does —
|
|
80
|
+
* a pending entry on any of them holds the stream from all of them, read
|
|
81
|
+
* across the CURRENT group exactly as a refusal is.
|
|
82
|
+
*
|
|
83
|
+
* The switch is resolved INTO the group ({@link ConsentGroup.awaitsConfirmation}),
|
|
84
|
+
* so a send path already holding the group holds the answer and reads nothing
|
|
85
|
+
* more for it. A group of one never waits on anybody: there is nobody else.
|
|
86
|
+
* Off is the default and the absence, and an org that never set it reads
|
|
87
|
+
* exactly the documents it read before the field existed.
|
|
88
|
+
*
|
|
73
89
|
* ⚠️ The one change that runs AGAINST reading is a site LEAVING a group. A
|
|
74
90
|
* refusal is stored on the site the person acted on, so once that site is no
|
|
75
91
|
* longer named, its former siblings stop seeing a refusal that was given to
|
|
@@ -85,6 +101,7 @@
|
|
|
85
101
|
* are resolved by different functions on purpose, and neither reads the
|
|
86
102
|
* other.
|
|
87
103
|
*/
|
|
104
|
+
import type { TopicSubscriptionState } from './email-topics';
|
|
88
105
|
import { type ScopeToken } from './scope-tokens';
|
|
89
106
|
/**
|
|
90
107
|
* The org field holding the declarations:
|
|
@@ -96,6 +113,17 @@ import { type ScopeToken } from './scope-tokens';
|
|
|
96
113
|
* and there is no reading of that which is safe to pool on.
|
|
97
114
|
*/
|
|
98
115
|
export declare const CONSENT_GROUPS_FIELD = "consentGroups";
|
|
116
|
+
/**
|
|
117
|
+
* The org field that makes a declared group's sites wait for each other's
|
|
118
|
+
* confirmation click (AGL-3316): `true` is on; absent is off.
|
|
119
|
+
*
|
|
120
|
+
* One switch for the org beside {@link CONSENT_GROUPS_FIELD} rather than a
|
|
121
|
+
* flag on each declaration, because a declaration decides WHICH sites are one
|
|
122
|
+
* sender and this decides how the org treats a question one of them has put.
|
|
123
|
+
* Written only by `/api/orgs/settings`; the rules deny both fields to every
|
|
124
|
+
* client.
|
|
125
|
+
*/
|
|
126
|
+
export declare const CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD = "consentGroupsAwaitConfirmation";
|
|
99
127
|
/**
|
|
100
128
|
* The most sites one group may name.
|
|
101
129
|
*
|
|
@@ -141,6 +169,13 @@ export interface ConsentGroup {
|
|
|
141
169
|
hostIds: string[];
|
|
142
170
|
/** False for the implicit group of one. */
|
|
143
171
|
declared: boolean;
|
|
172
|
+
/**
|
|
173
|
+
* Whether a confirmation one site of the group is waiting on holds the
|
|
174
|
+
* other sites' mail too — the org's
|
|
175
|
+
* {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD}, for a declared group.
|
|
176
|
+
* Always `false` for a group of one, which has no other site to hold.
|
|
177
|
+
*/
|
|
178
|
+
awaitsConfirmation: boolean;
|
|
144
179
|
}
|
|
145
180
|
/**
|
|
146
181
|
* The group of one — a site that has declared no pooling.
|
|
@@ -150,6 +185,13 @@ export interface ConsentGroup {
|
|
|
150
185
|
* a helper decide what it covers.
|
|
151
186
|
*/
|
|
152
187
|
export declare function soloConsentGroup(hostId: string): ConsentGroup;
|
|
188
|
+
/**
|
|
189
|
+
* Whether the org turned {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on.
|
|
190
|
+
*
|
|
191
|
+
* Only a stored `true` is on. Absent, `false`, or anything a stray write left
|
|
192
|
+
* is off — the state every org was in before the field existed.
|
|
193
|
+
*/
|
|
194
|
+
export declare function consentGroupsAwaitConfirmation(org: Record<string, unknown> | null | undefined): boolean;
|
|
153
195
|
/**
|
|
154
196
|
* Reads the declared groups off an org document, dropping every entry that
|
|
155
197
|
* cannot be honored.
|
|
@@ -185,10 +227,23 @@ export declare function consentGroupForHost(org: Record<string, unknown> | null
|
|
|
185
227
|
* Every site the group names, because a refusal filed against any of them is
|
|
186
228
|
* a refusal of the sender — see "OPT-OUT runs the other way" above. The
|
|
187
229
|
* sending site leads so a reader that treats its own record differently from
|
|
188
|
-
* a sibling's (a pending confirmation
|
|
189
|
-
*
|
|
230
|
+
* a sibling's (a sibling's pending confirmation holds the send only when the
|
|
231
|
+
* group {@link ConsentGroup.awaitsConfirmation}) can tell them apart by
|
|
232
|
+
* position. A group of one is the site alone.
|
|
190
233
|
*/
|
|
191
234
|
export declare function consentGroupOptOutHosts(group: Pick<ConsentGroup, 'hostId' | 'hostIds'>): string[];
|
|
235
|
+
/**
|
|
236
|
+
* The sending site's standing on one stream, read across its group.
|
|
237
|
+
*
|
|
238
|
+
* `states` are the stream's states on {@link consentGroupOptOutHosts}' sites,
|
|
239
|
+
* in that order, so the sending site's comes first. A refusal on any of them
|
|
240
|
+
* is a refusal of the sender. The sending site's own pending confirmation
|
|
241
|
+
* holds its mail; a sibling's holds it only when the group
|
|
242
|
+
* {@link ConsentGroup.awaitsConfirmation}. The precedence is the one
|
|
243
|
+
* `readTopicSubscriptionState` keeps within one entry: a refusal outranks a
|
|
244
|
+
* pending question, and an expired question is still pending.
|
|
245
|
+
*/
|
|
246
|
+
export declare function consentGroupTopicState(group: Pick<ConsentGroup, 'awaitsConfirmation'>, states: readonly TopicSubscriptionState[]): TopicSubscriptionState;
|
|
192
247
|
/**
|
|
193
248
|
* The `visibleTo` a resource captured by this group starts with.
|
|
194
249
|
*
|
|
@@ -69,6 +69,22 @@
|
|
|
69
69
|
* (`marketing-consent.ts`) — and the preference pages lift one across the
|
|
70
70
|
* group, since a person rejoining the sender has rejoined all of it.
|
|
71
71
|
*
|
|
72
|
+
* ## A pending CONFIRMATION is the asking site's, unless the org says the group waits
|
|
73
|
+
*
|
|
74
|
+
* A site that asks for a confirmation click holds its own mail on that stream
|
|
75
|
+
* until the click comes. A pending question is not a refusal, so by default
|
|
76
|
+
* the rule above does not carry it: the sibling sites go on mailing. An org
|
|
77
|
+
* may turn {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on, and then every
|
|
78
|
+
* site of a declared group waits for the click the way the asking site does —
|
|
79
|
+
* a pending entry on any of them holds the stream from all of them, read
|
|
80
|
+
* across the CURRENT group exactly as a refusal is.
|
|
81
|
+
*
|
|
82
|
+
* The switch is resolved INTO the group ({@link ConsentGroup.awaitsConfirmation}),
|
|
83
|
+
* so a send path already holding the group holds the answer and reads nothing
|
|
84
|
+
* more for it. A group of one never waits on anybody: there is nobody else.
|
|
85
|
+
* Off is the default and the absence, and an org that never set it reads
|
|
86
|
+
* exactly the documents it read before the field existed.
|
|
87
|
+
*
|
|
72
88
|
* ⚠️ The one change that runs AGAINST reading is a site LEAVING a group. A
|
|
73
89
|
* refusal is stored on the site the person acted on, so once that site is no
|
|
74
90
|
* longer named, its former siblings stop seeing a refusal that was given to
|
|
@@ -93,6 +109,16 @@
|
|
|
93
109
|
* site A naming a group that site B has left is a half-declared controller,
|
|
94
110
|
* and there is no reading of that which is safe to pool on.
|
|
95
111
|
*/ export const CONSENT_GROUPS_FIELD = 'consentGroups';
|
|
112
|
+
/**
|
|
113
|
+
* The org field that makes a declared group's sites wait for each other's
|
|
114
|
+
* confirmation click (AGL-3316): `true` is on; absent is off.
|
|
115
|
+
*
|
|
116
|
+
* One switch for the org beside {@link CONSENT_GROUPS_FIELD} rather than a
|
|
117
|
+
* flag on each declaration, because a declaration decides WHICH sites are one
|
|
118
|
+
* sender and this decides how the org treats a question one of them has put.
|
|
119
|
+
* Written only by `/api/orgs/settings`; the rules deny both fields to every
|
|
120
|
+
* client.
|
|
121
|
+
*/ export const CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD = 'consentGroupsAwaitConfirmation';
|
|
96
122
|
/**
|
|
97
123
|
* The most sites one group may name.
|
|
98
124
|
*
|
|
@@ -118,9 +144,18 @@
|
|
|
118
144
|
hostIds: [
|
|
119
145
|
hostId
|
|
120
146
|
],
|
|
121
|
-
declared: false
|
|
147
|
+
declared: false,
|
|
148
|
+
awaitsConfirmation: false
|
|
122
149
|
};
|
|
123
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether the org turned {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on.
|
|
153
|
+
*
|
|
154
|
+
* Only a stored `true` is on. Absent, `false`, or anything a stray write left
|
|
155
|
+
* is off — the state every org was in before the field existed.
|
|
156
|
+
*/ export function consentGroupsAwaitConfirmation(org) {
|
|
157
|
+
return (org != null ? org : {})[CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD] === true;
|
|
158
|
+
}
|
|
124
159
|
/**
|
|
125
160
|
* Reads the declared groups off an org document, dropping every entry that
|
|
126
161
|
* cannot be honored.
|
|
@@ -198,7 +233,8 @@
|
|
|
198
233
|
hostIds: [
|
|
199
234
|
...group.hostIds
|
|
200
235
|
],
|
|
201
|
-
declared: true
|
|
236
|
+
declared: true,
|
|
237
|
+
awaitsConfirmation: consentGroupsAwaitConfirmation(org)
|
|
202
238
|
};
|
|
203
239
|
}
|
|
204
240
|
return soloConsentGroup(hostId);
|
|
@@ -210,8 +246,9 @@
|
|
|
210
246
|
* Every site the group names, because a refusal filed against any of them is
|
|
211
247
|
* a refusal of the sender — see "OPT-OUT runs the other way" above. The
|
|
212
248
|
* sending site leads so a reader that treats its own record differently from
|
|
213
|
-
* a sibling's (a pending confirmation
|
|
214
|
-
*
|
|
249
|
+
* a sibling's (a sibling's pending confirmation holds the send only when the
|
|
250
|
+
* group {@link ConsentGroup.awaitsConfirmation}) can tell them apart by
|
|
251
|
+
* position. A group of one is the site alone.
|
|
215
252
|
*/ export function consentGroupOptOutHosts(group) {
|
|
216
253
|
var _group_hostIds;
|
|
217
254
|
const siblings = ((_group_hostIds = group.hostIds) != null ? _group_hostIds : []).filter((id)=>typeof id === 'string' && id && id !== group.hostId);
|
|
@@ -220,6 +257,22 @@
|
|
|
220
257
|
...new Set(siblings)
|
|
221
258
|
];
|
|
222
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* The sending site's standing on one stream, read across its group.
|
|
262
|
+
*
|
|
263
|
+
* `states` are the stream's states on {@link consentGroupOptOutHosts}' sites,
|
|
264
|
+
* in that order, so the sending site's comes first. A refusal on any of them
|
|
265
|
+
* is a refusal of the sender. The sending site's own pending confirmation
|
|
266
|
+
* holds its mail; a sibling's holds it only when the group
|
|
267
|
+
* {@link ConsentGroup.awaitsConfirmation}. The precedence is the one
|
|
268
|
+
* `readTopicSubscriptionState` keeps within one entry: a refusal outranks a
|
|
269
|
+
* pending question, and an expired question is still pending.
|
|
270
|
+
*/ export function consentGroupTopicState(group, states) {
|
|
271
|
+
const [own = 'subscribed', ...siblings] = states;
|
|
272
|
+
if (own === 'opted-out' || siblings.includes('opted-out')) return 'opted-out';
|
|
273
|
+
if (own === 'pending') return 'pending';
|
|
274
|
+
return group.awaitsConfirmation === true && siblings.includes('pending') ? 'pending' : 'subscribed';
|
|
275
|
+
}
|
|
223
276
|
/**
|
|
224
277
|
* The `visibleTo` a resource captured by this group starts with.
|
|
225
278
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/consent-groups.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 * CONSENT GROUPS — the declared unit a marketing basis is given to.\n *\n * ## The two organizations this exists to serve at once\n *\n * An AGENCY runs twelve unrelated client brands out of one account. A person\n * who ticked a box on one client's form has agreed to hear from that client\n * and from nobody else, and mailing them on behalf of the other eleven is a\n * consent violation whatever the account structure says.\n *\n * A SINGLE BUSINESS runs three sites — a shop, a booking page, a blog — under\n * one name. It collects addresses on all three and mails them from any of\n * them, because there is one controller and one brand and the visitor knows\n * it. Refusing that is not caution, it is a product that cannot serve a\n * perfectly ordinary customer.\n *\n * The org boundary cannot tell them apart, and neither can anything else the\n * database already holds. So the difference is DECLARED: a group names the\n * sites that are one sender. Undeclared, every site is its own group of one,\n * which is the agency's answer and costs the agency nothing to get right.\n *\n * ## ⛔ NOTHING HERE IS INFERRED\n *\n * Not from shared ownership, not from a shared org, not from a shared sending\n * domain, not from a shared theme. Sharing an account is a billing fact.\n * There is exactly one input — {@link CONSENT_GROUPS_FIELD} on the org — and\n * a site that is not named in it is alone.\n *\n * ## A group must be DISCLOSABLE, so it must have a NAME\n *\n * Pooling is only legitimate if the person was told about it: the checkbox\n * has to say who they will hear from. A group with no display name cannot be\n * rendered on a capture surface, so it cannot be disclosed, so it does not\n * pool — {@link consentGroupForHost} returns the group of one for every site\n * in a nameless group rather than pooling silently. That refusal is the whole\n * mechanism by which \"declared\" is stronger than \"configured\".\n *\n * ## Pooling applies FORWARD only\n *\n * A grant records the group AS DISCLOSED at the moment it was given, in the\n * entry itself, and every covered site gets its own entry. Adding a site to a\n * group therefore reaches captures made after the change and none made\n * before. An org-level switch that widened existing grants would be the leak\n * wearing a different hat: the people already on the list were told a\n * different thing.\n *\n * OPT-OUT runs the other way — read against the CURRENT group, so a site\n * joining a group inherits every refusal already standing against it. The\n * asymmetry is the same one the rest of this area keeps: a permissive fact is\n * written narrowly and read exactly, a restrictive fact is read as broadly as\n * it could possibly apply. Every opt-out the send paths consult is read this\n * way — the site suppression lists, the topic opt-outs and the recipient's\n * cadence (`email-suppression.ts`), and the consent refusal\n * (`marketing-consent.ts`) — and the preference pages lift one across the\n * group, since a person rejoining the sender has rejoined all of it.\n *\n * ⚠️ The one change that runs AGAINST reading is a site LEAVING a group. A\n * refusal is stored on the site the person acted on, so once that site is no\n * longer named, its former siblings stop seeing a refusal that was given to\n * them too. Whatever edits this declaration must carry the leaving site's\n * refusals onto the sites that stay — there is no console editor for it yet,\n * and that is the rule the first one has to keep.\n *\n * ## VISIBILITY IS A SEPARATE AXIS\n *\n * Seeing a contact is not permission to mail them. A group decides consent\n * and opt-out; `visibleTo` decides who can read the row, and an org may\n * widen that for reporting while consent stays where it was given. The two\n * are resolved by different functions on purpose, and neither reads the\n * other.\n */\n\nimport { hostScopeToken, MAX_SCOPE_HOSTS, type ScopeToken } from './scope-tokens'\n\n/**\n * The org field holding the declarations:\n * `{ [groupId]: { name, hostIds } }`.\n *\n * On the org rather than on each host, because a group is a statement ABOUT\n * a set and a set stored as N per-host pointers can disagree with itself —\n * site A naming a group that site B has left is a half-declared controller,\n * and there is no reading of that which is safe to pool on.\n */\nexport const CONSENT_GROUPS_FIELD = 'consentGroups'\n\n/**\n * The most sites one group may name.\n *\n * The same ceiling `visibleTo` carries, and for a related reason: the send\n * path reads a group's opt-out lists in one `getAll`, so the group multiplies\n * that round trip's size. It is also a sanity bound on a disclosure — a\n * checkbox that has to name forty brands has not disclosed anything.\n */\nexport const MAX_CONSENT_GROUP_HOSTS = MAX_SCOPE_HOSTS\n\n/** One declared group, as stored. */\nexport interface StoredConsentGroup {\n /** Shown on the capture surface. Without it the group cannot pool. */\n name: string\n /** The sites that are one sender. */\n hostIds: string[]\n}\n\n/**\n * The group a host belongs to, resolved FOR that host.\n *\n * It carries the asking site as well as the set, which is what lets one value\n * be passed where both are needed. Every consent read needs both — the grant\n * is looked up under the asking site, while a refusal is honored across the\n * whole group — and two arguments that must agree is two arguments that can\n * disagree.\n */\nexport interface ConsentGroup {\n /** The site this was resolved for. Always a member of {@link hostIds}. */\n hostId: string\n /**\n * The group's id, or the host's own id for an undeclared site.\n *\n * Recorded on every grant so a stored basis says which controller it was\n * given to even after the declaration changes.\n */\n groupId: string\n /**\n * What the capture surface must display. `null` for a group of one, where\n * the site's own name is what a form already shows and inventing a second\n * one would put a name in front of a person that nothing else uses.\n */\n name: string | null\n /** Every site covered, sorted, always including the host asked about. */\n hostIds: string[]\n /** False for the implicit group of one. */\n declared: boolean\n}\n\n/**\n * The group of one — a site that has declared no pooling.\n *\n * Exported because it is a value callers state deliberately: a writer that\n * means \"this site only\" says so, rather than passing a host id and letting\n * a helper decide what it covers.\n */\nexport function soloConsentGroup(hostId: string): ConsentGroup {\n if (!hostId) {\n throw new Error('[consent-groups] a consent group must name a site')\n }\n return {\n hostId,\n groupId: hostId,\n name: null,\n hostIds: [hostId],\n declared: false,\n }\n}\n\n/**\n * Reads the declared groups off an org document, dropping every entry that\n * cannot be honored.\n *\n * A malformed declaration reads as ABSENT, never as a wider group: the whole\n * value of this field is that pooling is deliberate, and a corrupt value must\n * not be a way to reach an audience nobody declared.\n *\n * The four refusals, each of which would otherwise pool without a disclosure:\n *\n * - **no usable name** — cannot be shown on a form, so cannot be disclosed;\n * - **fewer than two sites** — not a pooling declaration, and a group of one\n * is what an undeclared site already gets;\n * - **over {@link MAX_CONSENT_GROUP_HOSTS}** — see that constant;\n * - **a site claimed by two groups** — two controllers claiming one site is\n * a contradiction, not a wider group, so BOTH claims are dropped and the\n * site falls back to being alone.\n */\nexport function readConsentGroups(\n org: Record<string, unknown> | null | undefined,\n): Record<string, StoredConsentGroup> {\n const raw = (org ?? {})[CONSENT_GROUPS_FIELD]\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}\n const usable: Record<string, StoredConsentGroup> = {}\n for (const [groupId, value] of Object.entries(raw as Record<string, unknown>)) {\n if (!groupId || !value || typeof value !== 'object') continue\n const group = value as Record<string, unknown>\n const name = typeof group['name'] === 'string' ? group['name'].trim() : ''\n if (!name) continue\n const hostIds = Array.isArray(group['hostIds'])\n ? [\n ...new Set(\n (group['hostIds'] as unknown[])\n .map((id) => String(id ?? '').trim())\n .filter(Boolean),\n ),\n ].sort()\n : []\n if (hostIds.length < 2 || hostIds.length > MAX_CONSENT_GROUP_HOSTS) continue\n usable[groupId] = { name, hostIds }\n }\n /*\n * The overlap pass, second because it needs every survivor of the first.\n * A site named by two groups is dropped from BOTH — the alternative is\n * picking one, and a coin flip is not a declaration.\n */\n const claims = new Map<string, number>()\n for (const group of Object.values(usable)) {\n for (const hostId of group.hostIds) {\n claims.set(hostId, (claims.get(hostId) ?? 0) + 1)\n }\n }\n const contested = new Set(\n [...claims.entries()].filter(([, count]) => count > 1).map(([id]) => id),\n )\n if (!contested.size) return usable\n const settled: Record<string, StoredConsentGroup> = {}\n for (const [groupId, group] of Object.entries(usable)) {\n if (group.hostIds.some((hostId) => contested.has(hostId))) continue\n settled[groupId] = group\n }\n return settled\n}\n\n/**\n * The group `hostId` belongs to — the declared one, or the group of one.\n *\n * The ONLY way any caller learns what a site's consent covers. A caller that\n * built its own set from `org.hosts`, or from a sending domain, or from a\n * theme, would be inferring a controller, which is the thing this module\n * exists to make impossible.\n */\nexport function consentGroupForHost(\n org: Record<string, unknown> | null | undefined,\n hostId: string,\n): ConsentGroup {\n if (!hostId) {\n throw new Error('[consent-groups] a consent group must name a site')\n }\n for (const [groupId, group] of Object.entries(readConsentGroups(org))) {\n if (!group.hostIds.includes(hostId)) continue\n return {\n hostId,\n groupId,\n name: group.name,\n hostIds: [...group.hostIds],\n declared: true,\n }\n }\n return soloConsentGroup(hostId)\n}\n\n/**\n * The sites whose OPT-OUTS answer for mail sent by `group.hostId`, the\n * sending site first.\n *\n * Every site the group names, because a refusal filed against any of them is\n * a refusal of the sender — see \"OPT-OUT runs the other way\" above. The\n * sending site leads so a reader that treats its own record differently from\n * a sibling's (a pending confirmation is the sending site's own concern) can\n * tell them apart by position. A group of one is the site alone.\n */\nexport function consentGroupOptOutHosts(\n group: Pick<ConsentGroup, 'hostId' | 'hostIds'>,\n): string[] {\n const siblings = (group.hostIds ?? []).filter(\n (id) => typeof id === 'string' && id && id !== group.hostId,\n )\n return [group.hostId, ...new Set(siblings)]\n}\n\n/**\n * The `visibleTo` a resource captured by this group starts with.\n *\n * A separate function from anything above, called by the capture doors and\n * by nothing that decides mailability, because visibility and consent are\n * different questions with different answers. A group of one produces\n * `['host:{id}']` — the isolation an agency needs, arrived at without the\n * agency configuring anything.\n */\nexport function consentGroupScope(group: ConsentGroup): ScopeToken[] {\n return group.hostIds.map(hostScopeToken)\n}\n\n/**\n * The sentence a capture surface must show beside the checkbox, or `null`\n * when the site's own name already says it.\n *\n * Returned as text rather than a boolean so that the disclosure and the\n * grant come from ONE resolved group: a form that rendered its own wording\n * from a separate lookup could show one set of brands and record another,\n * and the recorded set is the one that decides who mails this person.\n */\nexport function consentGroupDisclosure(group: ConsentGroup): string | null {\n if (!group.declared || !group.name) return null\n return `You'll receive marketing email from ${group.name}, which covers ${group.hostIds.length} sites.`\n}\n"],"names":["hostScopeToken","MAX_SCOPE_HOSTS","CONSENT_GROUPS_FIELD","MAX_CONSENT_GROUP_HOSTS","soloConsentGroup","hostId","Error","groupId","name","hostIds","declared","readConsentGroups","org","raw","Array","isArray","usable","value","Object","entries","group","trim","Set","map","id","String","filter","Boolean","sort","length","claims","Map","values","set","get","contested","count","size","settled","some","has","consentGroupForHost","includes","consentGroupOptOutHosts","siblings","consentGroupScope","consentGroupDisclosure"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/consent-groups.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 * CONSENT GROUPS — the declared unit a marketing basis is given to.\n *\n * ## The two organizations this exists to serve at once\n *\n * An AGENCY runs twelve unrelated client brands out of one account. A person\n * who ticked a box on one client's form has agreed to hear from that client\n * and from nobody else, and mailing them on behalf of the other eleven is a\n * consent violation whatever the account structure says.\n *\n * A SINGLE BUSINESS runs three sites — a shop, a booking page, a blog — under\n * one name. It collects addresses on all three and mails them from any of\n * them, because there is one controller and one brand and the visitor knows\n * it. Refusing that is not caution, it is a product that cannot serve a\n * perfectly ordinary customer.\n *\n * The org boundary cannot tell them apart, and neither can anything else the\n * database already holds. So the difference is DECLARED: a group names the\n * sites that are one sender. Undeclared, every site is its own group of one,\n * which is the agency's answer and costs the agency nothing to get right.\n *\n * ## ⛔ NOTHING HERE IS INFERRED\n *\n * Not from shared ownership, not from a shared org, not from a shared sending\n * domain, not from a shared theme. Sharing an account is a billing fact.\n * There is exactly one input — {@link CONSENT_GROUPS_FIELD} on the org — and\n * a site that is not named in it is alone.\n *\n * ## A group must be DISCLOSABLE, so it must have a NAME\n *\n * Pooling is only legitimate if the person was told about it: the checkbox\n * has to say who they will hear from. A group with no display name cannot be\n * rendered on a capture surface, so it cannot be disclosed, so it does not\n * pool — {@link consentGroupForHost} returns the group of one for every site\n * in a nameless group rather than pooling silently. That refusal is the whole\n * mechanism by which \"declared\" is stronger than \"configured\".\n *\n * ## Pooling applies FORWARD only\n *\n * A grant records the group AS DISCLOSED at the moment it was given, in the\n * entry itself, and every covered site gets its own entry. Adding a site to a\n * group therefore reaches captures made after the change and none made\n * before. An org-level switch that widened existing grants would be the leak\n * wearing a different hat: the people already on the list were told a\n * different thing.\n *\n * OPT-OUT runs the other way — read against the CURRENT group, so a site\n * joining a group inherits every refusal already standing against it. The\n * asymmetry is the same one the rest of this area keeps: a permissive fact is\n * written narrowly and read exactly, a restrictive fact is read as broadly as\n * it could possibly apply. Every opt-out the send paths consult is read this\n * way — the site suppression lists, the topic opt-outs and the recipient's\n * cadence (`email-suppression.ts`), and the consent refusal\n * (`marketing-consent.ts`) — and the preference pages lift one across the\n * group, since a person rejoining the sender has rejoined all of it.\n *\n * ## A pending CONFIRMATION is the asking site's, unless the org says the group waits\n *\n * A site that asks for a confirmation click holds its own mail on that stream\n * until the click comes. A pending question is not a refusal, so by default\n * the rule above does not carry it: the sibling sites go on mailing. An org\n * may turn {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on, and then every\n * site of a declared group waits for the click the way the asking site does —\n * a pending entry on any of them holds the stream from all of them, read\n * across the CURRENT group exactly as a refusal is.\n *\n * The switch is resolved INTO the group ({@link ConsentGroup.awaitsConfirmation}),\n * so a send path already holding the group holds the answer and reads nothing\n * more for it. A group of one never waits on anybody: there is nobody else.\n * Off is the default and the absence, and an org that never set it reads\n * exactly the documents it read before the field existed.\n *\n * ⚠️ The one change that runs AGAINST reading is a site LEAVING a group. A\n * refusal is stored on the site the person acted on, so once that site is no\n * longer named, its former siblings stop seeing a refusal that was given to\n * them too. Whatever edits this declaration must carry the leaving site's\n * refusals onto the sites that stay — there is no console editor for it yet,\n * and that is the rule the first one has to keep.\n *\n * ## VISIBILITY IS A SEPARATE AXIS\n *\n * Seeing a contact is not permission to mail them. A group decides consent\n * and opt-out; `visibleTo` decides who can read the row, and an org may\n * widen that for reporting while consent stays where it was given. The two\n * are resolved by different functions on purpose, and neither reads the\n * other.\n */\n\nimport type { TopicSubscriptionState } from './email-topics'\nimport { hostScopeToken, MAX_SCOPE_HOSTS, type ScopeToken } from './scope-tokens'\n\n/**\n * The org field holding the declarations:\n * `{ [groupId]: { name, hostIds } }`.\n *\n * On the org rather than on each host, because a group is a statement ABOUT\n * a set and a set stored as N per-host pointers can disagree with itself —\n * site A naming a group that site B has left is a half-declared controller,\n * and there is no reading of that which is safe to pool on.\n */\nexport const CONSENT_GROUPS_FIELD = 'consentGroups'\n\n/**\n * The org field that makes a declared group's sites wait for each other's\n * confirmation click (AGL-3316): `true` is on; absent is off.\n *\n * One switch for the org beside {@link CONSENT_GROUPS_FIELD} rather than a\n * flag on each declaration, because a declaration decides WHICH sites are one\n * sender and this decides how the org treats a question one of them has put.\n * Written only by `/api/orgs/settings`; the rules deny both fields to every\n * client.\n */\nexport const CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD =\n 'consentGroupsAwaitConfirmation'\n\n/**\n * The most sites one group may name.\n *\n * The same ceiling `visibleTo` carries, and for a related reason: the send\n * path reads a group's opt-out lists in one `getAll`, so the group multiplies\n * that round trip's size. It is also a sanity bound on a disclosure — a\n * checkbox that has to name forty brands has not disclosed anything.\n */\nexport const MAX_CONSENT_GROUP_HOSTS = MAX_SCOPE_HOSTS\n\n/** One declared group, as stored. */\nexport interface StoredConsentGroup {\n /** Shown on the capture surface. Without it the group cannot pool. */\n name: string\n /** The sites that are one sender. */\n hostIds: string[]\n}\n\n/**\n * The group a host belongs to, resolved FOR that host.\n *\n * It carries the asking site as well as the set, which is what lets one value\n * be passed where both are needed. Every consent read needs both — the grant\n * is looked up under the asking site, while a refusal is honored across the\n * whole group — and two arguments that must agree is two arguments that can\n * disagree.\n */\nexport interface ConsentGroup {\n /** The site this was resolved for. Always a member of {@link hostIds}. */\n hostId: string\n /**\n * The group's id, or the host's own id for an undeclared site.\n *\n * Recorded on every grant so a stored basis says which controller it was\n * given to even after the declaration changes.\n */\n groupId: string\n /**\n * What the capture surface must display. `null` for a group of one, where\n * the site's own name is what a form already shows and inventing a second\n * one would put a name in front of a person that nothing else uses.\n */\n name: string | null\n /** Every site covered, sorted, always including the host asked about. */\n hostIds: string[]\n /** False for the implicit group of one. */\n declared: boolean\n /**\n * Whether a confirmation one site of the group is waiting on holds the\n * other sites' mail too — the org's\n * {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD}, for a declared group.\n * Always `false` for a group of one, which has no other site to hold.\n */\n awaitsConfirmation: boolean\n}\n\n/**\n * The group of one — a site that has declared no pooling.\n *\n * Exported because it is a value callers state deliberately: a writer that\n * means \"this site only\" says so, rather than passing a host id and letting\n * a helper decide what it covers.\n */\nexport function soloConsentGroup(hostId: string): ConsentGroup {\n if (!hostId) {\n throw new Error('[consent-groups] a consent group must name a site')\n }\n return {\n hostId,\n groupId: hostId,\n name: null,\n hostIds: [hostId],\n declared: false,\n awaitsConfirmation: false,\n }\n}\n\n/**\n * Whether the org turned {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on.\n *\n * Only a stored `true` is on. Absent, `false`, or anything a stray write left\n * is off — the state every org was in before the field existed.\n */\nexport function consentGroupsAwaitConfirmation(\n org: Record<string, unknown> | null | undefined,\n): boolean {\n return (org ?? {})[CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD] === true\n}\n\n/**\n * Reads the declared groups off an org document, dropping every entry that\n * cannot be honored.\n *\n * A malformed declaration reads as ABSENT, never as a wider group: the whole\n * value of this field is that pooling is deliberate, and a corrupt value must\n * not be a way to reach an audience nobody declared.\n *\n * The four refusals, each of which would otherwise pool without a disclosure:\n *\n * - **no usable name** — cannot be shown on a form, so cannot be disclosed;\n * - **fewer than two sites** — not a pooling declaration, and a group of one\n * is what an undeclared site already gets;\n * - **over {@link MAX_CONSENT_GROUP_HOSTS}** — see that constant;\n * - **a site claimed by two groups** — two controllers claiming one site is\n * a contradiction, not a wider group, so BOTH claims are dropped and the\n * site falls back to being alone.\n */\nexport function readConsentGroups(\n org: Record<string, unknown> | null | undefined,\n): Record<string, StoredConsentGroup> {\n const raw = (org ?? {})[CONSENT_GROUPS_FIELD]\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}\n const usable: Record<string, StoredConsentGroup> = {}\n for (const [groupId, value] of Object.entries(raw as Record<string, unknown>)) {\n if (!groupId || !value || typeof value !== 'object') continue\n const group = value as Record<string, unknown>\n const name = typeof group['name'] === 'string' ? group['name'].trim() : ''\n if (!name) continue\n const hostIds = Array.isArray(group['hostIds'])\n ? [\n ...new Set(\n (group['hostIds'] as unknown[])\n .map((id) => String(id ?? '').trim())\n .filter(Boolean),\n ),\n ].sort()\n : []\n if (hostIds.length < 2 || hostIds.length > MAX_CONSENT_GROUP_HOSTS) continue\n usable[groupId] = { name, hostIds }\n }\n /*\n * The overlap pass, second because it needs every survivor of the first.\n * A site named by two groups is dropped from BOTH — the alternative is\n * picking one, and a coin flip is not a declaration.\n */\n const claims = new Map<string, number>()\n for (const group of Object.values(usable)) {\n for (const hostId of group.hostIds) {\n claims.set(hostId, (claims.get(hostId) ?? 0) + 1)\n }\n }\n const contested = new Set(\n [...claims.entries()].filter(([, count]) => count > 1).map(([id]) => id),\n )\n if (!contested.size) return usable\n const settled: Record<string, StoredConsentGroup> = {}\n for (const [groupId, group] of Object.entries(usable)) {\n if (group.hostIds.some((hostId) => contested.has(hostId))) continue\n settled[groupId] = group\n }\n return settled\n}\n\n/**\n * The group `hostId` belongs to — the declared one, or the group of one.\n *\n * The ONLY way any caller learns what a site's consent covers. A caller that\n * built its own set from `org.hosts`, or from a sending domain, or from a\n * theme, would be inferring a controller, which is the thing this module\n * exists to make impossible.\n */\nexport function consentGroupForHost(\n org: Record<string, unknown> | null | undefined,\n hostId: string,\n): ConsentGroup {\n if (!hostId) {\n throw new Error('[consent-groups] a consent group must name a site')\n }\n for (const [groupId, group] of Object.entries(readConsentGroups(org))) {\n if (!group.hostIds.includes(hostId)) continue\n return {\n hostId,\n groupId,\n name: group.name,\n hostIds: [...group.hostIds],\n declared: true,\n awaitsConfirmation: consentGroupsAwaitConfirmation(org),\n }\n }\n return soloConsentGroup(hostId)\n}\n\n/**\n * The sites whose OPT-OUTS answer for mail sent by `group.hostId`, the\n * sending site first.\n *\n * Every site the group names, because a refusal filed against any of them is\n * a refusal of the sender — see \"OPT-OUT runs the other way\" above. The\n * sending site leads so a reader that treats its own record differently from\n * a sibling's (a sibling's pending confirmation holds the send only when the\n * group {@link ConsentGroup.awaitsConfirmation}) can tell them apart by\n * position. A group of one is the site alone.\n */\nexport function consentGroupOptOutHosts(\n group: Pick<ConsentGroup, 'hostId' | 'hostIds'>,\n): string[] {\n const siblings = (group.hostIds ?? []).filter(\n (id) => typeof id === 'string' && id && id !== group.hostId,\n )\n return [group.hostId, ...new Set(siblings)]\n}\n\n/**\n * The sending site's standing on one stream, read across its group.\n *\n * `states` are the stream's states on {@link consentGroupOptOutHosts}' sites,\n * in that order, so the sending site's comes first. A refusal on any of them\n * is a refusal of the sender. The sending site's own pending confirmation\n * holds its mail; a sibling's holds it only when the group\n * {@link ConsentGroup.awaitsConfirmation}. The precedence is the one\n * `readTopicSubscriptionState` keeps within one entry: a refusal outranks a\n * pending question, and an expired question is still pending.\n */\nexport function consentGroupTopicState(\n group: Pick<ConsentGroup, 'awaitsConfirmation'>,\n states: readonly TopicSubscriptionState[],\n): TopicSubscriptionState {\n const [own = 'subscribed', ...siblings] = states\n if (own === 'opted-out' || siblings.includes('opted-out')) return 'opted-out'\n if (own === 'pending') return 'pending'\n return group.awaitsConfirmation === true && siblings.includes('pending')\n ? 'pending'\n : 'subscribed'\n}\n\n/**\n * The `visibleTo` a resource captured by this group starts with.\n *\n * A separate function from anything above, called by the capture doors and\n * by nothing that decides mailability, because visibility and consent are\n * different questions with different answers. A group of one produces\n * `['host:{id}']` — the isolation an agency needs, arrived at without the\n * agency configuring anything.\n */\nexport function consentGroupScope(group: ConsentGroup): ScopeToken[] {\n return group.hostIds.map(hostScopeToken)\n}\n\n/**\n * The sentence a capture surface must show beside the checkbox, or `null`\n * when the site's own name already says it.\n *\n * Returned as text rather than a boolean so that the disclosure and the\n * grant come from ONE resolved group: a form that rendered its own wording\n * from a separate lookup could show one set of brands and record another,\n * and the recorded set is the one that decides who mails this person.\n */\nexport function consentGroupDisclosure(group: ConsentGroup): string | null {\n if (!group.declared || !group.name) return null\n return `You'll receive marketing email from ${group.name}, which covers ${group.hostIds.length} sites.`\n}\n"],"names":["hostScopeToken","MAX_SCOPE_HOSTS","CONSENT_GROUPS_FIELD","CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD","MAX_CONSENT_GROUP_HOSTS","soloConsentGroup","hostId","Error","groupId","name","hostIds","declared","awaitsConfirmation","consentGroupsAwaitConfirmation","org","readConsentGroups","raw","Array","isArray","usable","value","Object","entries","group","trim","Set","map","id","String","filter","Boolean","sort","length","claims","Map","values","set","get","contested","count","size","settled","some","has","consentGroupForHost","includes","consentGroupOptOutHosts","siblings","consentGroupTopicState","states","own","consentGroupScope","consentGroupDisclosure"],"mappings":"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"}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* feature plugin (AGL-2662).
|
|
28
28
|
*/ import { CONTACT_FACETS_FIELD } from "./contacts.js";
|
|
29
29
|
import { CAPTURED_BY_HOST_FIELD } from "./marketing-consent.js";
|
|
30
|
-
import { consentGroupForHost, readConsentGroups, soloConsentGroup } from "./consent-groups.js";
|
|
30
|
+
import { consentGroupForHost, consentGroupsAwaitConfirmation, readConsentGroups, soloConsentGroup } from "./consent-groups.js";
|
|
31
31
|
/**
|
|
32
32
|
* The holder of nothing: the group a contact is flattened through when no
|
|
33
33
|
* site has captured it and no facet exists — a row written by a path that
|
|
@@ -44,7 +44,8 @@ import { consentGroupForHost, readConsentGroups, soloConsentGroup } from "./cons
|
|
|
44
44
|
groupId: '',
|
|
45
45
|
name: null,
|
|
46
46
|
hostIds: [],
|
|
47
|
-
declared: false
|
|
47
|
+
declared: false,
|
|
48
|
+
awaitsConfirmation: false
|
|
48
49
|
});
|
|
49
50
|
/**
|
|
50
51
|
* The sites that captured this person, in CAPTURE order — the order
|
|
@@ -115,7 +116,8 @@ import { consentGroupForHost, readConsentGroups, soloConsentGroup } from "./cons
|
|
|
115
116
|
hostIds: [
|
|
116
117
|
...declared.hostIds
|
|
117
118
|
],
|
|
118
|
-
declared: true
|
|
119
|
+
declared: true,
|
|
120
|
+
awaitsConfirmation: consentGroupsAwaitConfirmation(org)
|
|
119
121
|
};
|
|
120
122
|
}
|
|
121
123
|
return soloConsentGroup(key);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/contact-holder.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 * WHICH HOLDER A CROSS-SITE READER SEES A CONTACT THROUGH (AGL-2630).\n *\n * A contact document carries one facet per holder, and every field but the\n * shared identity lives in one of them. A surface standing on a site reads\n * that site's group; a surface standing at the organization — the org-level\n * Contacts list, and the whole-collection export that writes the same file\n * — has no site to read as and needs one answer per row.\n *\n * In the library rather than in the CRM plugin because both of those\n * readers need it and one of them is an app: an app may not import a\n * feature plugin (AGL-2662).\n */\n\nimport { CONTACT_FACETS_FIELD } from './contacts'\nimport { CAPTURED_BY_HOST_FIELD } from './marketing-consent'\nimport {\n type ConsentGroup,\n consentGroupForHost,\n readConsentGroups,\n soloConsentGroup,\n} from './consent-groups'\n\n/**\n * The holder of nothing: the group a contact is flattened through when no\n * site has captured it and no facet exists — a row written by a path that\n * predates attribution, read at the org level (AGL-2630).\n *\n * An empty `groupId` reads an empty facet (`readContactFacet` answers the\n * empty facet for a key the map lacks) and REFUSES a write\n * (`contactFacetPath` throws on an empty holder), which is the right pair:\n * the row still shows its shared identity, and nothing edits a facet\n * nobody holds. Exported so a surface can test for it rather than for an\n * empty string.\n */\nexport const NO_HOLDER_GROUP: ConsentGroup = Object.freeze({\n hostId: '',\n groupId: '',\n name: null,\n hostIds: [],\n declared: false,\n}) as ConsentGroup\n\n/**\n * The sites that captured this person, in CAPTURE order — the order\n * `arrayUnion` left, so the first entry is the site that met them first.\n *\n * `contactCaptureHostIds` sorts the same array for RENDERING, where a\n * stable order matters more than a first. Both readers below want the\n * first, so neither may use the sorted one.\n */\nfunction captureOrder(\n record: Record<string, unknown> | null | undefined,\n): string[] {\n const raw = (record ?? {})[CAPTURED_BY_HOST_FIELD]\n if (!Array.isArray(raw)) return []\n return raw.map((id) => String(id ?? '').trim()).filter(Boolean)\n}\n\n/**\n * The group a LEAD is read and acted on through (AGL-3278).\n *\n * The same question {@link contactPrimaryGroup} answers, with a shorter\n * answer: a lead is one flat document — no facets, one profile, one\n * status — so there is nothing to choose between and the holder is simply\n * the site that captured the person first. What it is FOR is the same: at\n * the organization level the record page has no viewing site, and it needs\n * one to read the consent basis against, to file an activity under, to\n * offer a booking on and to convert as.\n *\n * A lead no site has captured — a row imported before attribution — reads\n * through {@link NO_HOLDER_GROUP}, whose empty `hostId` is how a surface\n * asks \"is there a site here at all\" without testing for an empty string.\n */\nexport function leadPrimaryGroup(\n lead: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const captured = captureOrder(lead)\n return captured.length ? consentGroupForHost(org, captured[0]) : NO_HOLDER_GROUP\n}\n\n/**\n * The group a CROSS-HOLDER reader flattens one contact through (AGL-2630).\n *\n * Under a site there is one answer for every row — the site's own group,\n * `consentGroupForHost` — and the reader never asks this. At the\n * organization level there is no viewing site, and a contact captured by\n * three sites has up to three facets; the org-wide member reading them all\n * needs ONE to show the row's profile, owner and stage under, and to write\n * an edit back to. This picks the person's PRIMARY holder: the first\n * capturing site, in capture order, whose group actually holds a facet;\n * then any facet at all, resolved back to its declared group or to the\n * site whose id it is keyed by; then the first capturing site even with no\n * facet yet (the facet is written on the first edit). A row nobody captured\n * and nobody wrote a facet for reads through {@link NO_HOLDER_GROUP}.\n *\n * Capture order rather than sorted order, deliberately: `capturedByHostIds`\n * is maintained by `arrayUnion`, so its first entry is the site that met\n * the person first, and that site's profile is the one a person reading\n * across brands would expect to see. `contactCaptureHostIds` sorts the same\n * array for RENDERING, where a stable order matters more than a first.\n */\nexport function contactPrimaryGroup(\n row: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const record = row ?? {}\n const facets = record[CONTACT_FACETS_FIELD]\n const facetKeys =\n facets && typeof facets === 'object' && !Array.isArray(facets)\n ? Object.keys(facets as Record<string, unknown>)\n : []\n const captured = captureOrder(record)\n for (const hostId of captured) {\n const group = consentGroupForHost(org, hostId)\n if (facetKeys.includes(group.groupId)) return group\n }\n for (const key of facetKeys) {\n const declared = readConsentGroups(org)[key]\n if (declared) {\n return {\n hostId: declared.hostIds[0],\n groupId: key,\n name: declared.name,\n hostIds: [...declared.hostIds],\n declared: true,\n }\n }\n return soloConsentGroup(key)\n }\n if (captured.length) return consentGroupForHost(org, captured[0])\n return NO_HOLDER_GROUP\n}"],"names":["CONTACT_FACETS_FIELD","CAPTURED_BY_HOST_FIELD","consentGroupForHost","readConsentGroups","soloConsentGroup","NO_HOLDER_GROUP","Object","freeze","hostId","groupId","name","hostIds","declared","captureOrder","record","raw","Array","isArray","map","id","String","trim","filter","Boolean","leadPrimaryGroup","lead","org","captured","length","contactPrimaryGroup","row","facets","facetKeys","keys","group","includes","key"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED,SAASA,oBAAoB,QAAQ,gBAAY;AACjD,SAASC,sBAAsB,QAAQ,yBAAqB;AAC5D,SAEEC,mBAAmB,EACnBC,iBAAiB,EACjBC,gBAAgB,QACX,sBAAkB;AAEzB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,kBAAgCC,OAAOC,MAAM,CAAC;IACzDC,QAAQ;IACRC,SAAS;IACTC,MAAM;IACNC,SAAS,EAAE;IACXC,UAAU;
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/contact-holder.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 * WHICH HOLDER A CROSS-SITE READER SEES A CONTACT THROUGH (AGL-2630).\n *\n * A contact document carries one facet per holder, and every field but the\n * shared identity lives in one of them. A surface standing on a site reads\n * that site's group; a surface standing at the organization — the org-level\n * Contacts list, and the whole-collection export that writes the same file\n * — has no site to read as and needs one answer per row.\n *\n * In the library rather than in the CRM plugin because both of those\n * readers need it and one of them is an app: an app may not import a\n * feature plugin (AGL-2662).\n */\n\nimport { CONTACT_FACETS_FIELD } from './contacts'\nimport { CAPTURED_BY_HOST_FIELD } from './marketing-consent'\nimport {\n type ConsentGroup,\n consentGroupForHost,\n consentGroupsAwaitConfirmation,\n readConsentGroups,\n soloConsentGroup,\n} from './consent-groups'\n\n/**\n * The holder of nothing: the group a contact is flattened through when no\n * site has captured it and no facet exists — a row written by a path that\n * predates attribution, read at the org level (AGL-2630).\n *\n * An empty `groupId` reads an empty facet (`readContactFacet` answers the\n * empty facet for a key the map lacks) and REFUSES a write\n * (`contactFacetPath` throws on an empty holder), which is the right pair:\n * the row still shows its shared identity, and nothing edits a facet\n * nobody holds. Exported so a surface can test for it rather than for an\n * empty string.\n */\nexport const NO_HOLDER_GROUP: ConsentGroup = Object.freeze({\n hostId: '',\n groupId: '',\n name: null,\n hostIds: [],\n declared: false,\n awaitsConfirmation: false,\n}) as ConsentGroup\n\n/**\n * The sites that captured this person, in CAPTURE order — the order\n * `arrayUnion` left, so the first entry is the site that met them first.\n *\n * `contactCaptureHostIds` sorts the same array for RENDERING, where a\n * stable order matters more than a first. Both readers below want the\n * first, so neither may use the sorted one.\n */\nfunction captureOrder(\n record: Record<string, unknown> | null | undefined,\n): string[] {\n const raw = (record ?? {})[CAPTURED_BY_HOST_FIELD]\n if (!Array.isArray(raw)) return []\n return raw.map((id) => String(id ?? '').trim()).filter(Boolean)\n}\n\n/**\n * The group a LEAD is read and acted on through (AGL-3278).\n *\n * The same question {@link contactPrimaryGroup} answers, with a shorter\n * answer: a lead is one flat document — no facets, one profile, one\n * status — so there is nothing to choose between and the holder is simply\n * the site that captured the person first. What it is FOR is the same: at\n * the organization level the record page has no viewing site, and it needs\n * one to read the consent basis against, to file an activity under, to\n * offer a booking on and to convert as.\n *\n * A lead no site has captured — a row imported before attribution — reads\n * through {@link NO_HOLDER_GROUP}, whose empty `hostId` is how a surface\n * asks \"is there a site here at all\" without testing for an empty string.\n */\nexport function leadPrimaryGroup(\n lead: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const captured = captureOrder(lead)\n return captured.length ? consentGroupForHost(org, captured[0]) : NO_HOLDER_GROUP\n}\n\n/**\n * The group a CROSS-HOLDER reader flattens one contact through (AGL-2630).\n *\n * Under a site there is one answer for every row — the site's own group,\n * `consentGroupForHost` — and the reader never asks this. At the\n * organization level there is no viewing site, and a contact captured by\n * three sites has up to three facets; the org-wide member reading them all\n * needs ONE to show the row's profile, owner and stage under, and to write\n * an edit back to. This picks the person's PRIMARY holder: the first\n * capturing site, in capture order, whose group actually holds a facet;\n * then any facet at all, resolved back to its declared group or to the\n * site whose id it is keyed by; then the first capturing site even with no\n * facet yet (the facet is written on the first edit). A row nobody captured\n * and nobody wrote a facet for reads through {@link NO_HOLDER_GROUP}.\n *\n * Capture order rather than sorted order, deliberately: `capturedByHostIds`\n * is maintained by `arrayUnion`, so its first entry is the site that met\n * the person first, and that site's profile is the one a person reading\n * across brands would expect to see. `contactCaptureHostIds` sorts the same\n * array for RENDERING, where a stable order matters more than a first.\n */\nexport function contactPrimaryGroup(\n row: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const record = row ?? {}\n const facets = record[CONTACT_FACETS_FIELD]\n const facetKeys =\n facets && typeof facets === 'object' && !Array.isArray(facets)\n ? Object.keys(facets as Record<string, unknown>)\n : []\n const captured = captureOrder(record)\n for (const hostId of captured) {\n const group = consentGroupForHost(org, hostId)\n if (facetKeys.includes(group.groupId)) return group\n }\n for (const key of facetKeys) {\n const declared = readConsentGroups(org)[key]\n if (declared) {\n return {\n hostId: declared.hostIds[0],\n groupId: key,\n name: declared.name,\n hostIds: [...declared.hostIds],\n declared: true,\n awaitsConfirmation: consentGroupsAwaitConfirmation(org),\n }\n }\n return soloConsentGroup(key)\n }\n if (captured.length) return consentGroupForHost(org, captured[0])\n return NO_HOLDER_GROUP\n}"],"names":["CONTACT_FACETS_FIELD","CAPTURED_BY_HOST_FIELD","consentGroupForHost","consentGroupsAwaitConfirmation","readConsentGroups","soloConsentGroup","NO_HOLDER_GROUP","Object","freeze","hostId","groupId","name","hostIds","declared","awaitsConfirmation","captureOrder","record","raw","Array","isArray","map","id","String","trim","filter","Boolean","leadPrimaryGroup","lead","org","captured","length","contactPrimaryGroup","row","facets","facetKeys","keys","group","includes","key"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED,SAASA,oBAAoB,QAAQ,gBAAY;AACjD,SAASC,sBAAsB,QAAQ,yBAAqB;AAC5D,SAEEC,mBAAmB,EACnBC,8BAA8B,EAC9BC,iBAAiB,EACjBC,gBAAgB,QACX,sBAAkB;AAEzB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,kBAAgCC,OAAOC,MAAM,CAAC;IACzDC,QAAQ;IACRC,SAAS;IACTC,MAAM;IACNC,SAAS,EAAE;IACXC,UAAU;IACVC,oBAAoB;AACtB,GAAkB;AAElB;;;;;;;CAOC,GACD,SAASC,aACPC,MAAkD;IAElD,MAAMC,MAAM,CAACD,iBAAAA,SAAU,CAAC,EAAE,CAACf,uBAAuB;IAClD,IAAI,CAACiB,MAAMC,OAAO,CAACF,MAAM,OAAO,EAAE;IAClC,OAAOA,IAAIG,GAAG,CAAC,CAACC,KAAOC,OAAOD,aAAAA,KAAM,IAAIE,IAAI,IAAIC,MAAM,CAACC;AACzD;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,iBACdC,IAAgD,EAChDC,GAA+C;IAE/C,MAAMC,WAAWd,aAAaY;IAC9B,OAAOE,SAASC,MAAM,GAAG5B,oBAAoB0B,KAAKC,QAAQ,CAAC,EAAE,IAAIvB;AACnE;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASyB,oBACdC,GAA+C,EAC/CJ,GAA+C;IAE/C,MAAMZ,SAASgB,cAAAA,MAAO,CAAC;IACvB,MAAMC,SAASjB,MAAM,CAAChB,qBAAqB;IAC3C,MAAMkC,YACJD,UAAU,OAAOA,WAAW,YAAY,CAACf,MAAMC,OAAO,CAACc,UACnD1B,OAAO4B,IAAI,CAACF,UACZ,EAAE;IACR,MAAMJ,WAAWd,aAAaC;IAC9B,KAAK,MAAMP,UAAUoB,SAAU;QAC7B,MAAMO,QAAQlC,oBAAoB0B,KAAKnB;QACvC,IAAIyB,UAAUG,QAAQ,CAACD,MAAM1B,OAAO,GAAG,OAAO0B;IAChD;IACA,KAAK,MAAME,OAAOJ,UAAW;QAC3B,MAAMrB,WAAWT,kBAAkBwB,IAAI,CAACU,IAAI;QAC5C,IAAIzB,UAAU;YACZ,OAAO;gBACLJ,QAAQI,SAASD,OAAO,CAAC,EAAE;gBAC3BF,SAAS4B;gBACT3B,MAAME,SAASF,IAAI;gBACnBC,SAAS;uBAAIC,SAASD,OAAO;iBAAC;gBAC9BC,UAAU;gBACVC,oBAAoBX,+BAA+ByB;YACrD;QACF;QACA,OAAOvB,iBAAiBiC;IAC1B;IACA,IAAIT,SAASC,MAAM,EAAE,OAAO5B,oBAAoB0B,KAAKC,QAAQ,CAAC,EAAE;IAChE,OAAOvB;AACT"}
|
|
@@ -309,7 +309,7 @@ export declare const PLUGIN_DOCS_ANCHORS: {
|
|
|
309
309
|
readonly datasets: readonly ["#model-builder", "#typed-documents", "#relations", "#query-layer", "#repeatable-components", "#who-a-dataset-is-shared-with", "#import--export", "#related"];
|
|
310
310
|
readonly deals: readonly ["#pipelines", "#stages", "#the-board-and-the-table", "#import-from-csv", "#creating-a-deal", "#line-items", "#moving-winning-and-losing", "#a-won-deal-makes-its-contact-a-customer", "#a-deals-page", "#files", "#related"];
|
|
311
311
|
readonly designedEmails: readonly ["#create-a-template", "#find-a-template", "#duplicate-a-template", "#styling-email-blocks", "#merge-tokens", "#send-it", "#the-plain-text-version", "#start-from-a-brief-instead"];
|
|
312
|
-
readonly emailCampaigns: readonly ["#send-a-campaign", "#campaigns-belong-to-the-organization", "#organization-emails-page", "#campaigns-group-emails", "#filter-the-lists", "#what-belongs-to-a-campaign", "#who-the-email-comes-from", "#sending-domains", "#account-email-always-sends", "#marketing-needs-a-domain", "#two-ways-to-get-a-domain", "#a-domain-we-set-up-is-a-request", "#domain-states", "#senders", "#send-a-test", "#preview-the-email", "#monthly-send-cap", "#personalize-with-merge-tags", "#recipient-count", "#who-a-campaign-is-allowed-to-reach", "#schedule-a-send", "#duplicate-an-email", "#email-lists", "#manual-lists", "#list-members", "#add-to-a-list", "#import-a-list", "#remove-from-a-list", "#lists-built-from-a-rule", "#experiments", "#experiments-across-sites", "#opens--clicks", "#the-campaign-report", "#per-contact-engagement", "#which-links-were-clicked", "#revenue-from-a-campaign", "#conversions", "#compliance", "#list-unsubscribe", "#topics", "#preference-page", "#frequency-opt-down", "#double-opt-in", "#marketing-mail", "#frequency-cap", "#suppressions", "#add-a-suppression", "#platform-suppressions", "#related"];
|
|
312
|
+
readonly emailCampaigns: readonly ["#send-a-campaign", "#campaigns-belong-to-the-organization", "#organization-emails-page", "#campaigns-group-emails", "#filter-the-lists", "#what-belongs-to-a-campaign", "#who-the-email-comes-from", "#sending-domains", "#account-email-always-sends", "#marketing-needs-a-domain", "#two-ways-to-get-a-domain", "#a-domain-we-set-up-is-a-request", "#domain-states", "#senders", "#send-a-test", "#preview-the-email", "#monthly-send-cap", "#personalize-with-merge-tags", "#recipient-count", "#who-a-campaign-is-allowed-to-reach", "#schedule-a-send", "#duplicate-an-email", "#email-lists", "#manual-lists", "#list-members", "#add-to-a-list", "#import-a-list", "#remove-from-a-list", "#lists-built-from-a-rule", "#experiments", "#experiments-across-sites", "#opens--clicks", "#the-campaign-report", "#per-contact-engagement", "#which-links-were-clicked", "#revenue-from-a-campaign", "#conversions", "#compliance", "#list-unsubscribe", "#topics", "#preference-page", "#frequency-opt-down", "#double-opt-in", "#consent-group-confirmation", "#marketing-mail", "#frequency-cap", "#suppressions", "#add-a-suppression", "#platform-suppressions", "#related"];
|
|
313
313
|
readonly events: readonly ["#manage-events", "#show-events-on-a-screen", "#search-engines", "#related"];
|
|
314
314
|
readonly forms: readonly ["#reading-submissions-from-code", "#build-a-form", "#monthly-allowance-per-plan", "#spam-and-abuse-protection", "#the-per-site-monthly-ceiling", "#field-types", "#labels-and-placeholders", "#example-a-quick-survey", "#after-submit", "#example-grow-an-email-list-from-a-signup-form", "#where-submissions-go", "#the-inbox", "#who-a-submission-is-from", "#where-this-one-went", "#replying-to-a-submission", "#every-sites-inbox-at-once", "#one-forms-own-page", "#find-a-form", "#duplicate-a-form", "#switch-forms-off-for-one-site", "#related"];
|
|
315
315
|
readonly installYourFirstPlugin: readonly ["#before-you-start", "#step-1-open", "#step-2-browse", "#step-3-reviews", "#step-4-targeting", "#step-5-install", "#step-6-use", "#step-7-off", "#what-to-do-next", "#related"];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/docs-help.generated.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// GENERATED FILE — do not edit. Regenerate with:\n// node tools/scripts/generate-docs-help.mjs\n// Source of truth: apps/docs/docs frontmatter + headings (AGL-602).\n\n// The first-party plugin consoles live in `libs/plugins/*` and cannot import\n// the console's constants, so they carry their own generated subset of the\n// docs help registry — the topics their cards actually link to, and no others.\n\nexport interface PluginDocsTopic {\n /** Docs-site path, e.g. `/commerce-and-bookings/commerce/overview`. */\n path: string\n /** Docs page title. */\n title: string\n /** Verbatim docs frontmatter description — the tooltip excerpt. */\n excerpt: string\n}\n\nexport const PLUGIN_DOCS = {\n abuseReports: {\n path: '/staff-console/abuse-reports',\n title: 'Abuse reports',\n excerpt: 'The public abuse-report queue — where outside reports land, how to triage by severity, which lever answers which report, and the CSAM and DMCA paths that are not takedown buttons.',\n },\n actionsBuilder: {\n path: '/marketing-and-automation/workflows-and-actions/actions-builder',\n title: 'Actions builder',\n excerpt: 'Map a single event to a single action without building a full workflow.',\n },\n aglynAssist: {\n path: '/getting-started/aglyn-assist',\n title: 'Aglyn Assist',\n excerpt: 'Ask the built-in AI helper how to do anything in Aglyn — it answers from these docs and links you straight to the right page.',\n },\n aiAutomations: {\n path: '/ai/automations-with-ai',\n title: 'Explain automations with AI',\n excerpt: 'Aglyn AI can explain an automation you already have and tell you why one of its runs failed. Drafting an automation from a description is not available yet.',\n },\n aiCrm: {\n path: '/ai/crm-by-ai',\n title: 'The AI CRM built into Aglyn',\n excerpt: 'An AI CRM for small business: a short summary and a suggested next step on a contact, company, deal or lead, a one-to-one email drafted into the composer, and an import\\'s columns matched to fields.',\n },\n aiExperiments: {\n path: '/ai/ab-tests-with-ai',\n title: 'A/B tests by AI: write variants, read the result',\n excerpt: 'Have Aglyn AI write two to four variants for a screen, section or email experiment, and put a finished test into plain language — with the verdict decided from the counts before the model is asked anything.',\n },\n aiMonitoring: {\n path: '/staff-console/ai-monitoring',\n title: 'AI monitoring',\n excerpt: 'How staff watch one organization\\'s AI usage — the add-on, credit pool, overage, refusals, jobs, tokens and cache hit rate, top spenders and margin — and where those figures appear across the staff console.',\n },\n aiProducts: {\n path: '/ai/products-with-ai',\n title: 'Product copy and catalogs with AI',\n excerpt: 'An AI product description generator for your catalog: write copy for one product or a whole import, propose a first catalog, and propose categories and discounts — all as proposals you review.',\n },\n aiSeo: {\n path: '/building-sites/seo/seo-by-ai',\n title: 'AI SEO for your website',\n excerpt: 'AI SEO for websites, on the pages you already have: have AI write a page or product\\'s search listing, audit every published page with a proposed fix for each finding, and draft your structured data and /llms.txt.',\n },\n aiThemes: {\n path: '/ai/theme-assist',\n title: 'Change your site\\'s theme with AI',\n excerpt: 'Describe a change to your site\\'s theme and get a proposal for the theme editor\\'s own controls, previewed before and after, that you save in the editor. Rolling out.',\n },\n assistSignals: {\n path: '/staff-console/assist-signals',\n title: 'Assist Signal',\n excerpt: 'The docs-gap and cost board behind Assist — how the ranking is ordered, why ungrounded questions are counted separately, and what the cache-read rate says about margin.',\n },\n billing: {\n path: '/workspace-and-billing/billing-and-plans/overview',\n title: 'Billing & Plans',\n excerpt: 'How Aglyn\\'s tiers, entitlements, quotas, usage meters, and seat add-ons work.',\n },\n bindings: {\n path: '/building-sites/bindings/overview',\n title: 'Bindings, Variables & Functions',\n excerpt: 'Live values in your content — typed variables, no-code functions, and rename-safe id tokens.',\n },\n bookings: {\n path: '/commerce-and-bookings/bookings/overview',\n title: 'Bookings & Scheduling',\n excerpt: 'Offer services with availability, let visitors book, take payment, and send reminders.',\n },\n buildAWorkflow: {\n path: '/marketing-and-automation/workflows-and-actions/build-a-workflow',\n title: 'Build a workflow',\n excerpt: 'Create a multi-step workflow that runs when a site event fires.',\n },\n catalog: {\n path: '/commerce-and-bookings/commerce/catalog',\n title: 'Product catalog',\n excerpt: 'Products with options and variants, categories, tags, and manual or smart collections.',\n },\n commerce: {\n path: '/commerce-and-bookings/commerce/overview',\n title: 'Commerce',\n excerpt: 'Sell physical, digital, and service products with a full catalog, orders pipeline, shipping, taxes, and your own Stripe account.',\n },\n commerceEndToEnd: {\n path: '/guides/commerce-end-to-end',\n title: 'Commerce end to end',\n excerpt: 'Create products with billing modes, design the storefront with Commerce blocks, take Stripe checkout for one-time and subscription sales, and run orders from the console.',\n },\n companies: {\n path: '/content-and-data/crm/companies',\n title: 'Companies',\n excerpt: 'Group your contacts under the businesses they belong to — one record per company, with its domain, owner, address and the people who work there.',\n },\n consoleTour: {\n path: '/getting-started/console-tour',\n title: 'The console tour',\n excerpt: 'Where things live in the Aglyn console app bar and navigation.',\n },\n contactActivities: {\n path: '/content-and-data/crm/activities',\n title: 'Activities & the timeline',\n excerpt: 'Log calls, emails, meetings and notes against a contact, a company, a deal or a lead, email one person from their record, and read it all in one timeline beside everything captured and every campaign sent.',\n },\n contactFields: {\n path: '/content-and-data/crm/custom-fields',\n title: 'Custom fields',\n excerpt: 'Define your own properties on contacts, companies, deals and leads — text, number, date, choice, checkbox or link — show them on every record and its list, and save form answers straight into a contact\\'s.',\n },\n contactRecord: {\n path: '/content-and-data/crm/contact-record',\n title: 'The contact record',\n excerpt: 'Add a contact by hand, keep a profile on them — phone, company, title, address, owner and lifecycle stage — open their own page in the CRM, and merge two records that turn out to be one person.',\n },\n contacts: {\n path: '/content-and-data/crm/overview',\n title: 'CRM',\n excerpt: 'One place for the people who interact with your sites — contacts captured from forms, members, orders and bookings, with leads, companies, a deals pipeline, tasks, a timeline, reports and custom fields.',\n },\n crmEmailTemplates: {\n path: '/content-and-data/crm/email-templates',\n title: 'Email templates',\n excerpt: 'Keep the letters your team sends from a record — templates that fill in the subject and the message, snippets that drop in a paragraph, and merge fields that fill in the person\\'s name, their company, the deal and you.',\n },\n crmLeads: {\n path: '/content-and-data/crm/leads',\n title: 'Leads',\n excerpt: 'Work the people your site has captured — a status, an owner and notes on every lead — and convert one into a contact, a company and a deal.',\n },\n crmReports: {\n path: '/content-and-data/crm/reports',\n title: 'Reports',\n excerpt: 'New contacts, where they came from and which sources convert, the lead funnel, the open pipeline and its forecast, won and lost, who logged what, and the task load — counted on the server, every table exportable as CSV.',\n },\n crmSettings: {\n path: '/content-and-data/crm/settings',\n title: 'CRM settings',\n excerpt: 'What the CRM does on its own for every site — whether a company is created from a contact\\'s work email domain, who a new contact is assigned to, the address that files replies on a record, and each site\\'s recipes.',\n },\n crmTasks: {\n path: '/content-and-data/crm/tasks',\n title: 'Tasks & follow-ups',\n excerpt: 'Calls, emails, meetings and to-dos with a due date, an assignee and a link to the record they are for — overdue and today read off the clock, a snooze, a reminder at its own time, and a morning digest.',\n },\n crmViews: {\n path: '/content-and-data/crm/views',\n title: 'Saved views',\n excerpt: 'Keep a CRM list\\'s filters, columns and sort under a name, open it from the views menu or a link, share it with the team, and use a contacts view as an email audience.',\n },\n datasets: {\n path: '/content-and-data/datasets/overview',\n title: 'Datasets & Dynamic Content',\n excerpt: 'Model structured content with typed fields and relations, then bind it into repeatable components.',\n },\n deals: {\n path: '/content-and-data/crm/deals',\n title: 'Deals pipeline',\n excerpt: 'Every open deal by stage — with an amount, an owner and an expected close — as a board you drag across or a table you page through, and the won and lost history behind it.',\n },\n designedEmails: {\n path: '/marketing-and-automation/email-campaigns/designed-emails',\n title: 'Designed emails',\n excerpt: 'Build campaign emails in the besigner with email-safe blocks and merge tokens — no separate editor.',\n },\n emailCampaigns: {\n path: '/marketing-and-automation/email-campaigns/overview',\n title: 'Email Campaigns',\n excerpt: 'Campaigns to audiences built from your contacts. Campaign email starts at Pro and needs a sending domain of the site\\'s own; receipts and account email send on every plan.',\n },\n events: {\n path: '/content-and-data/events/overview',\n title: 'Events Calendar',\n excerpt: 'Keep a schedule of events in the console and publish the ones you choose to any screen, with search-engine event markup.',\n },\n forms: {\n path: '/content-and-data/forms/overview',\n title: 'Forms & Lead Capture',\n excerpt: 'Add forms to your site, collect submissions in an inbox, and write them into datasets.',\n },\n installYourFirstPlugin: {\n path: '/guides/install-your-first-plugin',\n title: 'Install your first marketplace item',\n excerpt: 'A click-by-click walkthrough of the Marketplace — find something, choose which sites get it, install it, and turn it off again.',\n },\n inviteTeammates: {\n path: '/workspace-and-billing/teams-and-roles/invite-teammates',\n title: 'Invite teammates',\n excerpt: 'Add people to your site and understand how team members act within your organization.',\n },\n manifestAndEnvs: {\n path: '/developers/plugins/reference/manifest-and-envs',\n title: 'Manifests, trust lifecycle & environment',\n excerpt: 'The plugin manifest schema, the marketplace listing/version documents, the trust state machine, and every PLUGIN_* environment variable.',\n },\n marketingOverlays: {\n path: '/marketing-and-automation/marketing-overlays/overview',\n title: 'Marketing Overlays',\n excerpt: 'Site-wide announcement bars and promotional popups with triggers, scheduling, and email capture.',\n },\n membersOnly: {\n path: '/workspace-and-billing/teams-and-roles/members-only',\n title: 'Members-only areas',\n excerpt: 'Let visitors sign up as members and gate screens so only members can view them.',\n },\n orgAutomations: {\n path: '/marketing-and-automation/workflows-and-actions/org-automations',\n title: 'Org automations',\n excerpt: 'Write an automation once for the whole organization and run it on the sites you choose, with a pause on each site.',\n },\n plugins: {\n path: '/developers/plugins/overview',\n title: 'Plugins & Marketplace',\n excerpt: 'Extend Aglyn with sandboxed plugins — install from the marketplace, configure them, and publish your own.',\n },\n pos: {\n path: '/commerce-and-bookings/commerce/pos-and-reservations',\n title: 'POS & reservations',\n excerpt: 'Sell in person from the console register and take date-range reservations with deposits.',\n },\n publishAPlugin: {\n path: '/developers/plugins/publish-a-plugin',\n title: 'Publish a plugin',\n excerpt: 'Ship your own plugin to the marketplace with version pinning.',\n },\n publisherHandbook: {\n path: '/developers/plugins/publishing/publisher-handbook',\n title: 'Publisher handbook',\n excerpt: 'Publishing to the Aglyn marketplace — from profile setup through listing authoring, review, updates, and getting paid.',\n },\n redirects: {\n path: '/building-sites/redirects/overview',\n title: 'Redirects',\n excerpt: 'Manage URL redirects with validation, loop detection, and hit metrics.',\n },\n sandboxSecurity: {\n path: '/developers/plugins/reference/sandbox-security',\n title: 'Sandbox security model',\n excerpt: 'How sandboxed marketplace plugins are isolated — separate origin, per-manifest CSP, pinned artifacts — and what that means when you write one.',\n },\n sequences: {\n path: '/content-and-data/crm/sequences',\n title: 'Sequences',\n excerpt: 'One-to-one email sequences a rep sends to a person from their own connected mailbox, kept with the CRM. Rolling out.',\n },\n webhooks: {\n path: '/marketing-and-automation/workflows-and-actions/webhooks',\n title: 'Webhooks',\n excerpt: 'Connect Aglyn to other systems with outbound and inbound webhooks.',\n },\n} as const satisfies Record<string, PluginDocsTopic>\n\nexport type PluginDocsKey = keyof typeof PLUGIN_DOCS\n\nexport const PLUGIN_DOCS_ANCHORS = {\n abuseReports: ['#where-reports-come-from', '#triage-by-severity', '#csam', '#which-lever', '#statuses', '#disclosure', '#dmca', '#counter-notices', '#counter-notice-clock', '#counter-notice-steps', '#repeat-infringers', '#repeat-infringer-threshold', '#known-gaps', '#related'],\n actionsBuilder: ['#create-an-action', '#recipes', '#describe-it', '#triggers', '#crm-events', '#only-run-when-a-field-matches', '#chain-multiple-conditions-andor', '#steps', '#crm-steps', '#step-conditions', '#sequences', '#run-history', '#what-is-and-isnt-recorded', '#interactions-from-the-besigner', '#when-to-use-which', '#related'],\n aglynAssist: ['#what-it-can-do', '#aglyn-ai', '#answers-for-beginners-and-developers', '#offers-to-open-a-page', '#edits-in-the-besigner', '#where-an-answer-came-from', '#answers-straight-from-the-documentation', '#message-limits', '#feedback', '#privacy'],\n aiAutomations: ['#draft', '#explain', '#why-a-run-failed', '#what-is-sent', '#who-can-use-it', '#related'],\n aiCrm: ['#summarize-a-record', '#summaries-are-reused-until-the-record-changes', '#draft-an-email', '#match-columns', '#what-is-sent', '#who-can-use-it', '#related'],\n aiExperiments: ['#it-proposes-you-write', '#write-variants', '#putting-them-in', '#what-it-will-not-write', '#read-a-result', '#the-verdict', '#the-words', '#undecided', '#what-is-sent', '#who-can-use-it', '#related'],\n aiMonitoring: ['#the-ai-card', '#where-else', '#one-account', '#the-spend-leaderboard', '#alerts', '#related'],\n aiProducts: ['#write-a-products-copy', '#write-copy-for-many-products', '#when-you-import-products', '#propose-a-first-catalog', '#propose-categories-and-discounts', '#what-the-copy-never-says', '#what-is-sent-to-the-ai-provider', '#who-can-use-it', '#related'],\n aiSeo: ['#write-a-pages-listing', '#write-a-products-listing', '#audit-the-whole-site', '#target-keywords', '#apply-all-as-drafts', '#structured-data-and-llmstxt', '#related'],\n aiThemes: ['#change-what-you-describe-or-design-a-new-theme', '#match-your-brand', '#review-the-proposal', '#save-it-or-dont', '#who-can-use-it', '#related'],\n assistSignals: ['#the-workflow-this-board-exists-for', '#fleet', '#the-cache-read-rate-and-what-a-bad-number-looks-like', '#where-the-money-goes', '#tokens-by-kind', '#docs-gaps', '#questions-the-docs-could-not-answer', '#what-people-actually-asked', '#what-assist-costs-by-workspace', '#reading-the-sample-honestly', '#related'],\n billing: ['#tiers--entitlements', '#enterprise', '#single-sign-on-and-enforcement', '#usage-meters', '#who-is-generating-what', '#ai-allotments', '#storage-overage', '#if-you-would-rather-uploads-stopped', '#assist-overage', '#stop-ai-assist-at-the-included-band', '#ai-overage-ceiling', '#free-ai-credits', '#ai-credit-alerts', '#usage-budget', '#seats', '#crm-records', '#the-crm-suite', '#one-to-one-email', '#organization-data', '#api-access', '#payments', '#outstanding', '#plan-total', '#billing-email', '#payment-methods', '#billing-address', '#tax-ids', '#sales-tax', '#platform-fees', '#related'],\n bindings: ['#binding-tokens', '#rename-safe-id-tokens', '#insert-a-variable', '#token-pills', '#in-the-canvas-text-editor', '#site-details', '#typed-variables', '#no-code-functions', '#parameters-a-visitor-can-answer', '#a-calculator-you-lay-out', '#where-used--safety', '#workflows', '#related'],\n bookings: ['#set-up-bookings', '#taking-bookings', '#reminders', '#payments-and-fees', '#service-tax', '#manage', '#booking-from-the-crm', '#canceling-and-refunding', '#related'],\n buildAWorkflow: ['#1-open-the-workflows-page', '#2-choose-a-trigger', '#3-add-steps', '#waiting', '#4-save-and-test', '#duplicate-a-workflow', '#tips', '#related'],\n catalog: ['#products-options-and-variants', '#billing-modes-and-subscriptions', '#ai', '#categories-and-tags', '#collections', '#slugs', '#merchant-center-feed', '#related'],\n commerce: ['#products-hub', '#inventory', '#reserved-stock', '#stock-movements', '#gift-cards', '#recovery-and-alerts', '#orders', '#orders-screen', '#order-statuses', '#order-money-tiles', '#a-lost-dispute', '#shipping--taxes', '#lodging-tax-on-reservations', '#storefront-sales-tax', '#destination-coverage', '#dropshipping', '#related'],\n commerceEndToEnd: ['#1-connect-payments', '#2-create-products', '#3-design-the-storefront', '#catalog-search-filters-and-sort', '#category-pages', '#the-product-page-template', '#4-what-checkout-does', '#paying-without-leaving-your-site', '#5-run-orders-from-the-console', '#6-subscriptions--the-stripe-portal', '#related'],\n companies: ['#the-companies-list', '#create-a-company', '#a-companys-page', '#contacts-at-a-company', '#linked-on-capture', '#import-from-csv', '#export-csv', '#deleting-a-company', '#who-can-see-a-company', '#files', '#related'],\n consoleTour: ['#the-app-bar', '#in-context-help', '#primary-navigation', '#editing-vs-managing', '#the-sites-list', '#the-status-pill', '#how-the-pill-is-decided', '#your-site-allowance', '#a-sites-dashboard', '#next', '#workspace-settings--notifications', '#the-notifications-feed', '#notification-settings', '#one-kind-at-a-time', '#workspace-and-site-overrides', '#daily-digests', '#alerts-on-this-device'],\n contactActivities: ['#four-kinds-of-history', '#campaign-email', '#logging-an-activity', '#meeting-from-a-booking', '#click-to-call', '#sending-an-email', '#delivery-states', '#captured-email', '#where-an-activity-is-visible', '#the-recent-activity-feed', '#related'],\n contactFields: ['#define-a-field', '#fields-per-record', '#where-values-show', '#save-a-form-field', '#lead-source-values', '#over-the-api', '#retire-restore-delete', '#recompute-next-activity', '#related'],\n contactRecord: ['#adding-a-contact-by-hand', '#the-record-page', '#deleting-and-erasing', '#what-each-site-keeps-to-itself', '#merging-two-records', '#likely-duplicates', '#owner', '#last-engaged', '#lifecycle-stages', '#where-the-persons-lead-is', '#finding-a-contact', '#files', '#related'],\n contacts: ['#whats-in-the-crm-area', '#unified-ingestion', '#what-each-plan-includes', '#the-contacts-page', '#import-from-csv', '#segments', '#everywhere-the-crm-shows-up', '#capture-replies', '#at-the-organization-level', '#who-can-open-the-crm', '#related'],\n crmEmailTemplates: ['#templates-and-snippets', '#merge-fields', '#saving', '#managing-templates', '#duplicate-a-template', '#shared-or-personal', '#over-the-rest-api', '#related'],\n crmLeads: ['#what-makes-a-lead', '#what-a-lead-holds', '#adding-a-lead-by-hand', '#the-leads-list', '#working-a-lead-from-the-row', '#several-leads-at-once', '#export-csv', '#import-from-csv', '#who-owns-a-lead', '#a-leads-page', '#email-state', '#converting-a-lead', '#unqualifying-a-lead', '#erasing-the-person', '#who-can-do-this', '#related'],\n crmReports: ['#choosing-a-period', '#contacts', '#sources-and-lifecycle', '#conversion-by-source', '#lead-funnel', '#pipeline', '#forecast-by-close-month', '#won-and-lost', '#won-and-lost-by-owner', '#activity-by-teammate', '#tasks', '#exporting-a-table', '#crm-at-a-glance', '#how-the-numbers-are-counted', '#related'],\n crmSettings: ['#companies', '#create-companies-from-work-email-domains', '#default-owner', '#assignment-rules', '#round-robin', '#email-templates', '#email-capture', '#your-sending-addresses', '#recipes', '#related'],\n crmTasks: ['#the-tasks-page', '#the-calendar-view', '#snoozing-a-task', '#selecting-exporting-and-acting-on-many', '#import-from-csv', '#creating-a-task', '#assigning-a-task-to-someone-else', '#completing-and-reopening', '#organization-tasks', '#tasks-on-a-contact-company-or-deal', '#reminders', '#turning-reminders-off', '#next-activity', '#the-daily-digest', '#turning-it-off', '#the-dashboard-card', '#who-can-do-what', '#related'],\n crmViews: ['#the-views-control', '#a-view-is-a-link', '#filters', '#filters-on-the-contacts-list', '#filters-on-the-other-lists', '#columns-and-sort', '#segments-and-views', '#who-sees-what', '#related'],\n datasets: ['#model-builder', '#typed-documents', '#relations', '#query-layer', '#repeatable-components', '#who-a-dataset-is-shared-with', '#import--export', '#related'],\n deals: ['#pipelines', '#stages', '#the-board-and-the-table', '#import-from-csv', '#creating-a-deal', '#line-items', '#moving-winning-and-losing', '#a-won-deal-makes-its-contact-a-customer', '#a-deals-page', '#files', '#related'],\n designedEmails: ['#create-a-template', '#find-a-template', '#duplicate-a-template', '#styling-email-blocks', '#merge-tokens', '#send-it', '#the-plain-text-version', '#start-from-a-brief-instead'],\n emailCampaigns: ['#send-a-campaign', '#campaigns-belong-to-the-organization', '#organization-emails-page', '#campaigns-group-emails', '#filter-the-lists', '#what-belongs-to-a-campaign', '#who-the-email-comes-from', '#sending-domains', '#account-email-always-sends', '#marketing-needs-a-domain', '#two-ways-to-get-a-domain', '#a-domain-we-set-up-is-a-request', '#domain-states', '#senders', '#send-a-test', '#preview-the-email', '#monthly-send-cap', '#personalize-with-merge-tags', '#recipient-count', '#who-a-campaign-is-allowed-to-reach', '#schedule-a-send', '#duplicate-an-email', '#email-lists', '#manual-lists', '#list-members', '#add-to-a-list', '#import-a-list', '#remove-from-a-list', '#lists-built-from-a-rule', '#experiments', '#experiments-across-sites', '#opens--clicks', '#the-campaign-report', '#per-contact-engagement', '#which-links-were-clicked', '#revenue-from-a-campaign', '#conversions', '#compliance', '#list-unsubscribe', '#topics', '#preference-page', '#frequency-opt-down', '#double-opt-in', '#marketing-mail', '#frequency-cap', '#suppressions', '#add-a-suppression', '#platform-suppressions', '#related'],\n events: ['#manage-events', '#show-events-on-a-screen', '#search-engines', '#related'],\n forms: ['#reading-submissions-from-code', '#build-a-form', '#monthly-allowance-per-plan', '#spam-and-abuse-protection', '#the-per-site-monthly-ceiling', '#field-types', '#labels-and-placeholders', '#example-a-quick-survey', '#after-submit', '#example-grow-an-email-list-from-a-signup-form', '#where-submissions-go', '#the-inbox', '#who-a-submission-is-from', '#where-this-one-went', '#replying-to-a-submission', '#every-sites-inbox-at-once', '#one-forms-own-page', '#find-a-form', '#duplicate-a-form', '#switch-forms-off-for-one-site', '#related'],\n installYourFirstPlugin: ['#before-you-start', '#step-1-open', '#step-2-browse', '#step-3-reviews', '#step-4-targeting', '#step-5-install', '#step-6-use', '#step-7-off', '#what-to-do-next', '#related'],\n inviteTeammates: ['#invite-someone', '#pending-invites', '#who-gets-told', '#accepting-an-invite', '#an-invitation-never-changes-who-owns-the-workspace', '#how-team-members-act', '#you-are-a-site-collaborators-support-channel', '#help-a-teammate-who-is-locked-out', '#why-you-cant-always-set-a-password', '#activity-log', '#ai-actions', '#ai-usage', '#ai-allotment', '#tips', '#related'],\n manifestAndEnvs: ['#plugin-manifest-published-with-every-version', '#contributes--where-the-plugin-loads', '#config--settings-without-writing-a-settings-screen', '#listing--version-documents', '#review--trust-lifecycle', '#environment-variables', '#pluginsconfigjson-first-party-contributors'],\n marketingOverlays: ['#announcement-bar', '#promotional-popups', '#frequency', '#popup-v2', '#multiple-overlays-scheduling--page-targeting', '#variables-in-copy', '#engagement-stats', '#across-your-sites', '#related'],\n membersOnly: ['#let-visitors-sign-up', '#sign-in-sign-up-and-recovery-pages', '#forgotten-passwords', '#gate-a-screen', '#manage-your-members', '#suspend-or-reactivate-a-member', '#tips', '#related'],\n orgAutomations: ['#what-an-org-automation-is', '#create-one', '#triggers', '#steps', '#pause-it-on-one-site', '#waiting-switching-off-and-deleting', '#every-sites-own-automations', '#related'],\n plugins: ['#install--upgrade', '#browse-card', '#whats-included', '#what-the-badges-on-a-listing-mean', '#how-plugins-run', '#when-one-plugin-depends-on-another', '#a-dependency-that-is-off-for-one-site', '#configure', '#configure-site', '#publish-your-own', '#related'],\n pos: ['#registers', '#the-register', '#platform-fees-at-the-register', '#selling-past-the-count', '#when-something-disconnects', '#reservations', '#related'],\n publishAPlugin: ['#the-publish-pipeline', '#private-plugins', '#paid-listings', '#your-publisher-profile', '#tips', '#related'],\n publisherHandbook: ['#before-your-first-publish', '#the-publisher-agreement', '#where-to-publish-from', '#what-installing-each-type-does', '#rules-an-email-starter-has-to-meet', '#publishing-a-version', '#before-you-publish', '#review-what-happens-after-you-publish', '#the-two-badges-and-what-each-one-promises', '#asking-to-be-verified', '#testing-a-version-before-it-is-approved', '#watching-your-own-submission', '#disabled-versions', '#private-plugins', '#authoring-your-listing', '#what-your-listing-can-say-about-aglyn', '#versioning--updates', '#shipping-a-new-version', '#how-installs-work-the-buyer-side', '#getting-paid', '#low-prices-and-processing'],\n redirects: ['#manage-redirects', '#sending-visitors-to-another-site', '#metrics', '#match-modes-v2', '#related'],\n sandboxSecurity: ['#a-separate-origin', '#per-manifest-network-policy', '#when-you-cant-declare-the-origin', '#pinned-immutable-artifacts', '#what-this-means-when-you-build', '#related'],\n sequences: ['#what-it-is-for', '#sent-from-your-own-mailbox', '#where-it-lives', '#connect-a-mailbox', '#send-as', '#daily-cap', '#mailbox-status', '#auto-pause', '#mailbox-actions', '#link-domains', '#compliance-settings', '#allowed-countries', '#do-not-contact-domains', '#sequences', '#build-a-sequence', '#sequence-status', '#enroll', '#cold-contacts', '#enrollments', '#sending', '#what-stops-a-sequence', '#unsubscribe', '#related'],\n webhooks: ['#outbound-webhooks', '#inbound-webhooks', '#tips', '#related'],\n} as const satisfies Partial<Record<PluginDocsKey, readonly `#${string}`[]>>\n\ntype PluginAnchorMap = typeof PLUGIN_DOCS_ANCHORS\n\n/** Valid heading anchors for a plugin docs page (`never` when none). */\nexport type PluginDocsAnchor<K extends PluginDocsKey> =\n K extends keyof PluginAnchorMap ? PluginAnchorMap[K][number] : never\n"],"names":["PLUGIN_DOCS","abuseReports","path","title","excerpt","actionsBuilder","aglynAssist","aiAutomations","aiCrm","aiExperiments","aiMonitoring","aiProducts","aiSeo","aiThemes","assistSignals","billing","bindings","bookings","buildAWorkflow","catalog","commerce","commerceEndToEnd","companies","consoleTour","contactActivities","contactFields","contactRecord","contacts","crmEmailTemplates","crmLeads","crmReports","crmSettings","crmTasks","crmViews","datasets","deals","designedEmails","emailCampaigns","events","forms","installYourFirstPlugin","inviteTeammates","manifestAndEnvs","marketingOverlays","membersOnly","orgAutomations","plugins","pos","publishAPlugin","publisherHandbook","redirects","sandboxSecurity","sequences","webhooks","PLUGIN_DOCS_ANCHORS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD,iDAAiD;AACjD,8CAA8C;AAC9C,oEAAoE;AAEpE,6EAA6E;AAC7E,2EAA2E;AAC3E,+EAA+E;AAW/E,OAAO,MAAMA,cAAc;IACzBC,cAAc;QACZC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAC,gBAAgB;QACdH,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAE,aAAa;QACXJ,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAG,eAAe;QACbL,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAI,OAAO;QACLN,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAK,eAAe;QACbP,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAM,cAAc;QACZR,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAO,YAAY;QACVT,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAQ,OAAO;QACLV,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAS,UAAU;QACRX,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAU,eAAe;QACbZ,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAW,SAAS;QACPb,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAY,UAAU;QACRd,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAa,UAAU;QACRf,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAc,gBAAgB;QACdhB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAe,SAAS;QACPjB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAgB,UAAU;QACRlB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAiB,kBAAkB;QAChBnB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAkB,WAAW;QACTpB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAmB,aAAa;QACXrB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAoB,mBAAmB;QACjBtB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAqB,eAAe;QACbvB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAsB,eAAe;QACbxB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAuB,UAAU;QACRzB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAwB,mBAAmB;QACjB1B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAyB,UAAU;QACR3B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA0B,YAAY;QACV5B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA2B,aAAa;QACX7B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA4B,UAAU;QACR9B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA6B,UAAU;QACR/B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA8B,UAAU;QACRhC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA+B,OAAO;QACLjC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAgC,gBAAgB;QACdlC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAiC,gBAAgB;QACdnC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAkC,QAAQ;QACNpC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAmC,OAAO;QACLrC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAoC,wBAAwB;QACtBtC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAqC,iBAAiB;QACfvC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAsC,iBAAiB;QACfxC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAuC,mBAAmB;QACjBzC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAwC,aAAa;QACX1C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAyC,gBAAgB;QACd3C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA0C,SAAS;QACP5C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA2C,KAAK;QACH7C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA4C,gBAAgB;QACd9C,MAAM;QACNC,O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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/docs-help.generated.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// GENERATED FILE — do not edit. Regenerate with:\n// node tools/scripts/generate-docs-help.mjs\n// Source of truth: apps/docs/docs frontmatter + headings (AGL-602).\n\n// The first-party plugin consoles live in `libs/plugins/*` and cannot import\n// the console's constants, so they carry their own generated subset of the\n// docs help registry — the topics their cards actually link to, and no others.\n\nexport interface PluginDocsTopic {\n /** Docs-site path, e.g. `/commerce-and-bookings/commerce/overview`. */\n path: string\n /** Docs page title. */\n title: string\n /** Verbatim docs frontmatter description — the tooltip excerpt. */\n excerpt: string\n}\n\nexport const PLUGIN_DOCS = {\n abuseReports: {\n path: '/staff-console/abuse-reports',\n title: 'Abuse reports',\n excerpt: 'The public abuse-report queue — where outside reports land, how to triage by severity, which lever answers which report, and the CSAM and DMCA paths that are not takedown buttons.',\n },\n actionsBuilder: {\n path: '/marketing-and-automation/workflows-and-actions/actions-builder',\n title: 'Actions builder',\n excerpt: 'Map a single event to a single action without building a full workflow.',\n },\n aglynAssist: {\n path: '/getting-started/aglyn-assist',\n title: 'Aglyn Assist',\n excerpt: 'Ask the built-in AI helper how to do anything in Aglyn — it answers from these docs and links you straight to the right page.',\n },\n aiAutomations: {\n path: '/ai/automations-with-ai',\n title: 'Explain automations with AI',\n excerpt: 'Aglyn AI can explain an automation you already have and tell you why one of its runs failed. Drafting an automation from a description is not available yet.',\n },\n aiCrm: {\n path: '/ai/crm-by-ai',\n title: 'The AI CRM built into Aglyn',\n excerpt: 'An AI CRM for small business: a short summary and a suggested next step on a contact, company, deal or lead, a one-to-one email drafted into the composer, and an import\\'s columns matched to fields.',\n },\n aiExperiments: {\n path: '/ai/ab-tests-with-ai',\n title: 'A/B tests by AI: write variants, read the result',\n excerpt: 'Have Aglyn AI write two to four variants for a screen, section or email experiment, and put a finished test into plain language — with the verdict decided from the counts before the model is asked anything.',\n },\n aiMonitoring: {\n path: '/staff-console/ai-monitoring',\n title: 'AI monitoring',\n excerpt: 'How staff watch one organization\\'s AI usage — the add-on, credit pool, overage, refusals, jobs, tokens and cache hit rate, top spenders and margin — and where those figures appear across the staff console.',\n },\n aiProducts: {\n path: '/ai/products-with-ai',\n title: 'Product copy and catalogs with AI',\n excerpt: 'An AI product description generator for your catalog: write copy for one product or a whole import, propose a first catalog, and propose categories and discounts — all as proposals you review.',\n },\n aiSeo: {\n path: '/building-sites/seo/seo-by-ai',\n title: 'AI SEO for your website',\n excerpt: 'AI SEO for websites, on the pages you already have: have AI write a page or product\\'s search listing, audit every published page with a proposed fix for each finding, and draft your structured data and /llms.txt.',\n },\n aiThemes: {\n path: '/ai/theme-assist',\n title: 'Change your site\\'s theme with AI',\n excerpt: 'Describe a change to your site\\'s theme and get a proposal for the theme editor\\'s own controls, previewed before and after, that you save in the editor. Rolling out.',\n },\n assistSignals: {\n path: '/staff-console/assist-signals',\n title: 'Assist Signal',\n excerpt: 'The docs-gap and cost board behind Assist — how the ranking is ordered, why ungrounded questions are counted separately, and what the cache-read rate says about margin.',\n },\n billing: {\n path: '/workspace-and-billing/billing-and-plans/overview',\n title: 'Billing & Plans',\n excerpt: 'How Aglyn\\'s tiers, entitlements, quotas, usage meters, and seat add-ons work.',\n },\n bindings: {\n path: '/building-sites/bindings/overview',\n title: 'Bindings, Variables & Functions',\n excerpt: 'Live values in your content — typed variables, no-code functions, and rename-safe id tokens.',\n },\n bookings: {\n path: '/commerce-and-bookings/bookings/overview',\n title: 'Bookings & Scheduling',\n excerpt: 'Offer services with availability, let visitors book, take payment, and send reminders.',\n },\n buildAWorkflow: {\n path: '/marketing-and-automation/workflows-and-actions/build-a-workflow',\n title: 'Build a workflow',\n excerpt: 'Create a multi-step workflow that runs when a site event fires.',\n },\n catalog: {\n path: '/commerce-and-bookings/commerce/catalog',\n title: 'Product catalog',\n excerpt: 'Products with options and variants, categories, tags, and manual or smart collections.',\n },\n commerce: {\n path: '/commerce-and-bookings/commerce/overview',\n title: 'Commerce',\n excerpt: 'Sell physical, digital, and service products with a full catalog, orders pipeline, shipping, taxes, and your own Stripe account.',\n },\n commerceEndToEnd: {\n path: '/guides/commerce-end-to-end',\n title: 'Commerce end to end',\n excerpt: 'Create products with billing modes, design the storefront with Commerce blocks, take Stripe checkout for one-time and subscription sales, and run orders from the console.',\n },\n companies: {\n path: '/content-and-data/crm/companies',\n title: 'Companies',\n excerpt: 'Group your contacts under the businesses they belong to — one record per company, with its domain, owner, address and the people who work there.',\n },\n consoleTour: {\n path: '/getting-started/console-tour',\n title: 'The console tour',\n excerpt: 'Where things live in the Aglyn console app bar and navigation.',\n },\n contactActivities: {\n path: '/content-and-data/crm/activities',\n title: 'Activities & the timeline',\n excerpt: 'Log calls, emails, meetings and notes against a contact, a company, a deal or a lead, email one person from their record, and read it all in one timeline beside everything captured and every campaign sent.',\n },\n contactFields: {\n path: '/content-and-data/crm/custom-fields',\n title: 'Custom fields',\n excerpt: 'Define your own properties on contacts, companies, deals and leads — text, number, date, choice, checkbox or link — show them on every record and its list, and save form answers straight into a contact\\'s.',\n },\n contactRecord: {\n path: '/content-and-data/crm/contact-record',\n title: 'The contact record',\n excerpt: 'Add a contact by hand, keep a profile on them — phone, company, title, address, owner and lifecycle stage — open their own page in the CRM, and merge two records that turn out to be one person.',\n },\n contacts: {\n path: '/content-and-data/crm/overview',\n title: 'CRM',\n excerpt: 'One place for the people who interact with your sites — contacts captured from forms, members, orders and bookings, with leads, companies, a deals pipeline, tasks, a timeline, reports and custom fields.',\n },\n crmEmailTemplates: {\n path: '/content-and-data/crm/email-templates',\n title: 'Email templates',\n excerpt: 'Keep the letters your team sends from a record — templates that fill in the subject and the message, snippets that drop in a paragraph, and merge fields that fill in the person\\'s name, their company, the deal and you.',\n },\n crmLeads: {\n path: '/content-and-data/crm/leads',\n title: 'Leads',\n excerpt: 'Work the people your site has captured — a status, an owner and notes on every lead — and convert one into a contact, a company and a deal.',\n },\n crmReports: {\n path: '/content-and-data/crm/reports',\n title: 'Reports',\n excerpt: 'New contacts, where they came from and which sources convert, the lead funnel, the open pipeline and its forecast, won and lost, who logged what, and the task load — counted on the server, every table exportable as CSV.',\n },\n crmSettings: {\n path: '/content-and-data/crm/settings',\n title: 'CRM settings',\n excerpt: 'What the CRM does on its own for every site — whether a company is created from a contact\\'s work email domain, who a new contact is assigned to, the address that files replies on a record, and each site\\'s recipes.',\n },\n crmTasks: {\n path: '/content-and-data/crm/tasks',\n title: 'Tasks & follow-ups',\n excerpt: 'Calls, emails, meetings and to-dos with a due date, an assignee and a link to the record they are for — overdue and today read off the clock, a snooze, a reminder at its own time, and a morning digest.',\n },\n crmViews: {\n path: '/content-and-data/crm/views',\n title: 'Saved views',\n excerpt: 'Keep a CRM list\\'s filters, columns and sort under a name, open it from the views menu or a link, share it with the team, and use a contacts view as an email audience.',\n },\n datasets: {\n path: '/content-and-data/datasets/overview',\n title: 'Datasets & Dynamic Content',\n excerpt: 'Model structured content with typed fields and relations, then bind it into repeatable components.',\n },\n deals: {\n path: '/content-and-data/crm/deals',\n title: 'Deals pipeline',\n excerpt: 'Every open deal by stage — with an amount, an owner and an expected close — as a board you drag across or a table you page through, and the won and lost history behind it.',\n },\n designedEmails: {\n path: '/marketing-and-automation/email-campaigns/designed-emails',\n title: 'Designed emails',\n excerpt: 'Build campaign emails in the besigner with email-safe blocks and merge tokens — no separate editor.',\n },\n emailCampaigns: {\n path: '/marketing-and-automation/email-campaigns/overview',\n title: 'Email Campaigns',\n excerpt: 'Campaigns to audiences built from your contacts. Campaign email starts at Pro and needs a sending domain of the site\\'s own; receipts and account email send on every plan.',\n },\n events: {\n path: '/content-and-data/events/overview',\n title: 'Events Calendar',\n excerpt: 'Keep a schedule of events in the console and publish the ones you choose to any screen, with search-engine event markup.',\n },\n forms: {\n path: '/content-and-data/forms/overview',\n title: 'Forms & Lead Capture',\n excerpt: 'Add forms to your site, collect submissions in an inbox, and write them into datasets.',\n },\n installYourFirstPlugin: {\n path: '/guides/install-your-first-plugin',\n title: 'Install your first marketplace item',\n excerpt: 'A click-by-click walkthrough of the Marketplace — find something, choose which sites get it, install it, and turn it off again.',\n },\n inviteTeammates: {\n path: '/workspace-and-billing/teams-and-roles/invite-teammates',\n title: 'Invite teammates',\n excerpt: 'Add people to your site and understand how team members act within your organization.',\n },\n manifestAndEnvs: {\n path: '/developers/plugins/reference/manifest-and-envs',\n title: 'Manifests, trust lifecycle & environment',\n excerpt: 'The plugin manifest schema, the marketplace listing/version documents, the trust state machine, and every PLUGIN_* environment variable.',\n },\n marketingOverlays: {\n path: '/marketing-and-automation/marketing-overlays/overview',\n title: 'Marketing Overlays',\n excerpt: 'Site-wide announcement bars and promotional popups with triggers, scheduling, and email capture.',\n },\n membersOnly: {\n path: '/workspace-and-billing/teams-and-roles/members-only',\n title: 'Members-only areas',\n excerpt: 'Let visitors sign up as members and gate screens so only members can view them.',\n },\n orgAutomations: {\n path: '/marketing-and-automation/workflows-and-actions/org-automations',\n title: 'Org automations',\n excerpt: 'Write an automation once for the whole organization and run it on the sites you choose, with a pause on each site.',\n },\n plugins: {\n path: '/developers/plugins/overview',\n title: 'Plugins & Marketplace',\n excerpt: 'Extend Aglyn with sandboxed plugins — install from the marketplace, configure them, and publish your own.',\n },\n pos: {\n path: '/commerce-and-bookings/commerce/pos-and-reservations',\n title: 'POS & reservations',\n excerpt: 'Sell in person from the console register and take date-range reservations with deposits.',\n },\n publishAPlugin: {\n path: '/developers/plugins/publish-a-plugin',\n title: 'Publish a plugin',\n excerpt: 'Ship your own plugin to the marketplace with version pinning.',\n },\n publisherHandbook: {\n path: '/developers/plugins/publishing/publisher-handbook',\n title: 'Publisher handbook',\n excerpt: 'Publishing to the Aglyn marketplace — from profile setup through listing authoring, review, updates, and getting paid.',\n },\n redirects: {\n path: '/building-sites/redirects/overview',\n title: 'Redirects',\n excerpt: 'Manage URL redirects with validation, loop detection, and hit metrics.',\n },\n sandboxSecurity: {\n path: '/developers/plugins/reference/sandbox-security',\n title: 'Sandbox security model',\n excerpt: 'How sandboxed marketplace plugins are isolated — separate origin, per-manifest CSP, pinned artifacts — and what that means when you write one.',\n },\n sequences: {\n path: '/content-and-data/crm/sequences',\n title: 'Sequences',\n excerpt: 'One-to-one email sequences a rep sends to a person from their own connected mailbox, kept with the CRM. Rolling out.',\n },\n webhooks: {\n path: '/marketing-and-automation/workflows-and-actions/webhooks',\n title: 'Webhooks',\n excerpt: 'Connect Aglyn to other systems with outbound and inbound webhooks.',\n },\n} as const satisfies Record<string, PluginDocsTopic>\n\nexport type PluginDocsKey = keyof typeof PLUGIN_DOCS\n\nexport const PLUGIN_DOCS_ANCHORS = {\n abuseReports: ['#where-reports-come-from', '#triage-by-severity', '#csam', '#which-lever', '#statuses', '#disclosure', '#dmca', '#counter-notices', '#counter-notice-clock', '#counter-notice-steps', '#repeat-infringers', '#repeat-infringer-threshold', '#known-gaps', '#related'],\n actionsBuilder: ['#create-an-action', '#recipes', '#describe-it', '#triggers', '#crm-events', '#only-run-when-a-field-matches', '#chain-multiple-conditions-andor', '#steps', '#crm-steps', '#step-conditions', '#sequences', '#run-history', '#what-is-and-isnt-recorded', '#interactions-from-the-besigner', '#when-to-use-which', '#related'],\n aglynAssist: ['#what-it-can-do', '#aglyn-ai', '#answers-for-beginners-and-developers', '#offers-to-open-a-page', '#edits-in-the-besigner', '#where-an-answer-came-from', '#answers-straight-from-the-documentation', '#message-limits', '#feedback', '#privacy'],\n aiAutomations: ['#draft', '#explain', '#why-a-run-failed', '#what-is-sent', '#who-can-use-it', '#related'],\n aiCrm: ['#summarize-a-record', '#summaries-are-reused-until-the-record-changes', '#draft-an-email', '#match-columns', '#what-is-sent', '#who-can-use-it', '#related'],\n aiExperiments: ['#it-proposes-you-write', '#write-variants', '#putting-them-in', '#what-it-will-not-write', '#read-a-result', '#the-verdict', '#the-words', '#undecided', '#what-is-sent', '#who-can-use-it', '#related'],\n aiMonitoring: ['#the-ai-card', '#where-else', '#one-account', '#the-spend-leaderboard', '#alerts', '#related'],\n aiProducts: ['#write-a-products-copy', '#write-copy-for-many-products', '#when-you-import-products', '#propose-a-first-catalog', '#propose-categories-and-discounts', '#what-the-copy-never-says', '#what-is-sent-to-the-ai-provider', '#who-can-use-it', '#related'],\n aiSeo: ['#write-a-pages-listing', '#write-a-products-listing', '#audit-the-whole-site', '#target-keywords', '#apply-all-as-drafts', '#structured-data-and-llmstxt', '#related'],\n aiThemes: ['#change-what-you-describe-or-design-a-new-theme', '#match-your-brand', '#review-the-proposal', '#save-it-or-dont', '#who-can-use-it', '#related'],\n assistSignals: ['#the-workflow-this-board-exists-for', '#fleet', '#the-cache-read-rate-and-what-a-bad-number-looks-like', '#where-the-money-goes', '#tokens-by-kind', '#docs-gaps', '#questions-the-docs-could-not-answer', '#what-people-actually-asked', '#what-assist-costs-by-workspace', '#reading-the-sample-honestly', '#related'],\n billing: ['#tiers--entitlements', '#enterprise', '#single-sign-on-and-enforcement', '#usage-meters', '#who-is-generating-what', '#ai-allotments', '#storage-overage', '#if-you-would-rather-uploads-stopped', '#assist-overage', '#stop-ai-assist-at-the-included-band', '#ai-overage-ceiling', '#free-ai-credits', '#ai-credit-alerts', '#usage-budget', '#seats', '#crm-records', '#the-crm-suite', '#one-to-one-email', '#organization-data', '#api-access', '#payments', '#outstanding', '#plan-total', '#billing-email', '#payment-methods', '#billing-address', '#tax-ids', '#sales-tax', '#platform-fees', '#related'],\n bindings: ['#binding-tokens', '#rename-safe-id-tokens', '#insert-a-variable', '#token-pills', '#in-the-canvas-text-editor', '#site-details', '#typed-variables', '#no-code-functions', '#parameters-a-visitor-can-answer', '#a-calculator-you-lay-out', '#where-used--safety', '#workflows', '#related'],\n bookings: ['#set-up-bookings', '#taking-bookings', '#reminders', '#payments-and-fees', '#service-tax', '#manage', '#booking-from-the-crm', '#canceling-and-refunding', '#related'],\n buildAWorkflow: ['#1-open-the-workflows-page', '#2-choose-a-trigger', '#3-add-steps', '#waiting', '#4-save-and-test', '#duplicate-a-workflow', '#tips', '#related'],\n catalog: ['#products-options-and-variants', '#billing-modes-and-subscriptions', '#ai', '#categories-and-tags', '#collections', '#slugs', '#merchant-center-feed', '#related'],\n commerce: ['#products-hub', '#inventory', '#reserved-stock', '#stock-movements', '#gift-cards', '#recovery-and-alerts', '#orders', '#orders-screen', '#order-statuses', '#order-money-tiles', '#a-lost-dispute', '#shipping--taxes', '#lodging-tax-on-reservations', '#storefront-sales-tax', '#destination-coverage', '#dropshipping', '#related'],\n commerceEndToEnd: ['#1-connect-payments', '#2-create-products', '#3-design-the-storefront', '#catalog-search-filters-and-sort', '#category-pages', '#the-product-page-template', '#4-what-checkout-does', '#paying-without-leaving-your-site', '#5-run-orders-from-the-console', '#6-subscriptions--the-stripe-portal', '#related'],\n companies: ['#the-companies-list', '#create-a-company', '#a-companys-page', '#contacts-at-a-company', '#linked-on-capture', '#import-from-csv', '#export-csv', '#deleting-a-company', '#who-can-see-a-company', '#files', '#related'],\n consoleTour: ['#the-app-bar', '#in-context-help', '#primary-navigation', '#editing-vs-managing', '#the-sites-list', '#the-status-pill', '#how-the-pill-is-decided', '#your-site-allowance', '#a-sites-dashboard', '#next', '#workspace-settings--notifications', '#the-notifications-feed', '#notification-settings', '#one-kind-at-a-time', '#workspace-and-site-overrides', '#daily-digests', '#alerts-on-this-device'],\n contactActivities: ['#four-kinds-of-history', '#campaign-email', '#logging-an-activity', '#meeting-from-a-booking', '#click-to-call', '#sending-an-email', '#delivery-states', '#captured-email', '#where-an-activity-is-visible', '#the-recent-activity-feed', '#related'],\n contactFields: ['#define-a-field', '#fields-per-record', '#where-values-show', '#save-a-form-field', '#lead-source-values', '#over-the-api', '#retire-restore-delete', '#recompute-next-activity', '#related'],\n contactRecord: ['#adding-a-contact-by-hand', '#the-record-page', '#deleting-and-erasing', '#what-each-site-keeps-to-itself', '#merging-two-records', '#likely-duplicates', '#owner', '#last-engaged', '#lifecycle-stages', '#where-the-persons-lead-is', '#finding-a-contact', '#files', '#related'],\n contacts: ['#whats-in-the-crm-area', '#unified-ingestion', '#what-each-plan-includes', '#the-contacts-page', '#import-from-csv', '#segments', '#everywhere-the-crm-shows-up', '#capture-replies', '#at-the-organization-level', '#who-can-open-the-crm', '#related'],\n crmEmailTemplates: ['#templates-and-snippets', '#merge-fields', '#saving', '#managing-templates', '#duplicate-a-template', '#shared-or-personal', '#over-the-rest-api', '#related'],\n crmLeads: ['#what-makes-a-lead', '#what-a-lead-holds', '#adding-a-lead-by-hand', '#the-leads-list', '#working-a-lead-from-the-row', '#several-leads-at-once', '#export-csv', '#import-from-csv', '#who-owns-a-lead', '#a-leads-page', '#email-state', '#converting-a-lead', '#unqualifying-a-lead', '#erasing-the-person', '#who-can-do-this', '#related'],\n crmReports: ['#choosing-a-period', '#contacts', '#sources-and-lifecycle', '#conversion-by-source', '#lead-funnel', '#pipeline', '#forecast-by-close-month', '#won-and-lost', '#won-and-lost-by-owner', '#activity-by-teammate', '#tasks', '#exporting-a-table', '#crm-at-a-glance', '#how-the-numbers-are-counted', '#related'],\n crmSettings: ['#companies', '#create-companies-from-work-email-domains', '#default-owner', '#assignment-rules', '#round-robin', '#email-templates', '#email-capture', '#your-sending-addresses', '#recipes', '#related'],\n crmTasks: ['#the-tasks-page', '#the-calendar-view', '#snoozing-a-task', '#selecting-exporting-and-acting-on-many', '#import-from-csv', '#creating-a-task', '#assigning-a-task-to-someone-else', '#completing-and-reopening', '#organization-tasks', '#tasks-on-a-contact-company-or-deal', '#reminders', '#turning-reminders-off', '#next-activity', '#the-daily-digest', '#turning-it-off', '#the-dashboard-card', '#who-can-do-what', '#related'],\n crmViews: ['#the-views-control', '#a-view-is-a-link', '#filters', '#filters-on-the-contacts-list', '#filters-on-the-other-lists', '#columns-and-sort', '#segments-and-views', '#who-sees-what', '#related'],\n datasets: ['#model-builder', '#typed-documents', '#relations', '#query-layer', '#repeatable-components', '#who-a-dataset-is-shared-with', '#import--export', '#related'],\n deals: ['#pipelines', '#stages', '#the-board-and-the-table', '#import-from-csv', '#creating-a-deal', '#line-items', '#moving-winning-and-losing', '#a-won-deal-makes-its-contact-a-customer', '#a-deals-page', '#files', '#related'],\n designedEmails: ['#create-a-template', '#find-a-template', '#duplicate-a-template', '#styling-email-blocks', '#merge-tokens', '#send-it', '#the-plain-text-version', '#start-from-a-brief-instead'],\n emailCampaigns: ['#send-a-campaign', '#campaigns-belong-to-the-organization', '#organization-emails-page', '#campaigns-group-emails', '#filter-the-lists', '#what-belongs-to-a-campaign', '#who-the-email-comes-from', '#sending-domains', '#account-email-always-sends', '#marketing-needs-a-domain', '#two-ways-to-get-a-domain', '#a-domain-we-set-up-is-a-request', '#domain-states', '#senders', '#send-a-test', '#preview-the-email', '#monthly-send-cap', '#personalize-with-merge-tags', '#recipient-count', '#who-a-campaign-is-allowed-to-reach', '#schedule-a-send', '#duplicate-an-email', '#email-lists', '#manual-lists', '#list-members', '#add-to-a-list', '#import-a-list', '#remove-from-a-list', '#lists-built-from-a-rule', '#experiments', '#experiments-across-sites', '#opens--clicks', '#the-campaign-report', '#per-contact-engagement', '#which-links-were-clicked', '#revenue-from-a-campaign', '#conversions', '#compliance', '#list-unsubscribe', '#topics', '#preference-page', '#frequency-opt-down', '#double-opt-in', '#consent-group-confirmation', '#marketing-mail', '#frequency-cap', '#suppressions', '#add-a-suppression', '#platform-suppressions', '#related'],\n events: ['#manage-events', '#show-events-on-a-screen', '#search-engines', '#related'],\n forms: ['#reading-submissions-from-code', '#build-a-form', '#monthly-allowance-per-plan', '#spam-and-abuse-protection', '#the-per-site-monthly-ceiling', '#field-types', '#labels-and-placeholders', '#example-a-quick-survey', '#after-submit', '#example-grow-an-email-list-from-a-signup-form', '#where-submissions-go', '#the-inbox', '#who-a-submission-is-from', '#where-this-one-went', '#replying-to-a-submission', '#every-sites-inbox-at-once', '#one-forms-own-page', '#find-a-form', '#duplicate-a-form', '#switch-forms-off-for-one-site', '#related'],\n installYourFirstPlugin: ['#before-you-start', '#step-1-open', '#step-2-browse', '#step-3-reviews', '#step-4-targeting', '#step-5-install', '#step-6-use', '#step-7-off', '#what-to-do-next', '#related'],\n inviteTeammates: ['#invite-someone', '#pending-invites', '#who-gets-told', '#accepting-an-invite', '#an-invitation-never-changes-who-owns-the-workspace', '#how-team-members-act', '#you-are-a-site-collaborators-support-channel', '#help-a-teammate-who-is-locked-out', '#why-you-cant-always-set-a-password', '#activity-log', '#ai-actions', '#ai-usage', '#ai-allotment', '#tips', '#related'],\n manifestAndEnvs: ['#plugin-manifest-published-with-every-version', '#contributes--where-the-plugin-loads', '#config--settings-without-writing-a-settings-screen', '#listing--version-documents', '#review--trust-lifecycle', '#environment-variables', '#pluginsconfigjson-first-party-contributors'],\n marketingOverlays: ['#announcement-bar', '#promotional-popups', '#frequency', '#popup-v2', '#multiple-overlays-scheduling--page-targeting', '#variables-in-copy', '#engagement-stats', '#across-your-sites', '#related'],\n membersOnly: ['#let-visitors-sign-up', '#sign-in-sign-up-and-recovery-pages', '#forgotten-passwords', '#gate-a-screen', '#manage-your-members', '#suspend-or-reactivate-a-member', '#tips', '#related'],\n orgAutomations: ['#what-an-org-automation-is', '#create-one', '#triggers', '#steps', '#pause-it-on-one-site', '#waiting-switching-off-and-deleting', '#every-sites-own-automations', '#related'],\n plugins: ['#install--upgrade', '#browse-card', '#whats-included', '#what-the-badges-on-a-listing-mean', '#how-plugins-run', '#when-one-plugin-depends-on-another', '#a-dependency-that-is-off-for-one-site', '#configure', '#configure-site', '#publish-your-own', '#related'],\n pos: ['#registers', '#the-register', '#platform-fees-at-the-register', '#selling-past-the-count', '#when-something-disconnects', '#reservations', '#related'],\n publishAPlugin: ['#the-publish-pipeline', '#private-plugins', '#paid-listings', '#your-publisher-profile', '#tips', '#related'],\n publisherHandbook: ['#before-your-first-publish', '#the-publisher-agreement', '#where-to-publish-from', '#what-installing-each-type-does', '#rules-an-email-starter-has-to-meet', '#publishing-a-version', '#before-you-publish', '#review-what-happens-after-you-publish', '#the-two-badges-and-what-each-one-promises', '#asking-to-be-verified', '#testing-a-version-before-it-is-approved', '#watching-your-own-submission', '#disabled-versions', '#private-plugins', '#authoring-your-listing', '#what-your-listing-can-say-about-aglyn', '#versioning--updates', '#shipping-a-new-version', '#how-installs-work-the-buyer-side', '#getting-paid', '#low-prices-and-processing'],\n redirects: ['#manage-redirects', '#sending-visitors-to-another-site', '#metrics', '#match-modes-v2', '#related'],\n sandboxSecurity: ['#a-separate-origin', '#per-manifest-network-policy', '#when-you-cant-declare-the-origin', '#pinned-immutable-artifacts', '#what-this-means-when-you-build', '#related'],\n sequences: ['#what-it-is-for', '#sent-from-your-own-mailbox', '#where-it-lives', '#connect-a-mailbox', '#send-as', '#daily-cap', '#mailbox-status', '#auto-pause', '#mailbox-actions', '#link-domains', '#compliance-settings', '#allowed-countries', '#do-not-contact-domains', '#sequences', '#build-a-sequence', '#sequence-status', '#enroll', '#cold-contacts', '#enrollments', '#sending', '#what-stops-a-sequence', '#unsubscribe', '#related'],\n webhooks: ['#outbound-webhooks', '#inbound-webhooks', '#tips', '#related'],\n} as const satisfies Partial<Record<PluginDocsKey, readonly `#${string}`[]>>\n\ntype PluginAnchorMap = typeof PLUGIN_DOCS_ANCHORS\n\n/** Valid heading anchors for a plugin docs page (`never` when none). */\nexport type PluginDocsAnchor<K extends PluginDocsKey> =\n K extends keyof PluginAnchorMap ? PluginAnchorMap[K][number] : never\n"],"names":["PLUGIN_DOCS","abuseReports","path","title","excerpt","actionsBuilder","aglynAssist","aiAutomations","aiCrm","aiExperiments","aiMonitoring","aiProducts","aiSeo","aiThemes","assistSignals","billing","bindings","bookings","buildAWorkflow","catalog","commerce","commerceEndToEnd","companies","consoleTour","contactActivities","contactFields","contactRecord","contacts","crmEmailTemplates","crmLeads","crmReports","crmSettings","crmTasks","crmViews","datasets","deals","designedEmails","emailCampaigns","events","forms","installYourFirstPlugin","inviteTeammates","manifestAndEnvs","marketingOverlays","membersOnly","orgAutomations","plugins","pos","publishAPlugin","publisherHandbook","redirects","sandboxSecurity","sequences","webhooks","PLUGIN_DOCS_ANCHORS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD,iDAAiD;AACjD,8CAA8C;AAC9C,oEAAoE;AAEpE,6EAA6E;AAC7E,2EAA2E;AAC3E,+EAA+E;AAW/E,OAAO,MAAMA,cAAc;IACzBC,cAAc;QACZC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAC,gBAAgB;QACdH,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAE,aAAa;QACXJ,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAG,eAAe;QACbL,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAI,OAAO;QACLN,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAK,eAAe;QACbP,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAM,cAAc;QACZR,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAO,YAAY;QACVT,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAQ,OAAO;QACLV,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAS,UAAU;QACRX,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAU,eAAe;QACbZ,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAW,SAAS;QACPb,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAY,UAAU;QACRd,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAa,UAAU;QACRf,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAc,gBAAgB;QACdhB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAe,SAAS;QACPjB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAgB,UAAU;QACRlB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAiB,kBAAkB;QAChBnB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAkB,WAAW;QACTpB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAmB,aAAa;QACXrB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAoB,mBAAmB;QACjBtB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAqB,eAAe;QACbvB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAsB,eAAe;QACbxB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAuB,UAAU;QACRzB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAwB,mBAAmB;QACjB1B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAyB,UAAU;QACR3B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA0B,YAAY;QACV5B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA2B,aAAa;QACX7B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA4B,UAAU;QACR9B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA6B,UAAU;QACR/B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA8B,UAAU;QACRhC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA+B,OAAO;QACLjC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAgC,gBAAgB;QACdlC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAiC,gBAAgB;QACdnC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAkC,QAAQ;QACNpC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAmC,OAAO;QACLrC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAoC,wBAAwB;QACtBtC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAqC,iBAAiB;QACfvC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAsC,iBAAiB;QACfxC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAuC,mBAAmB;QACjBzC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAwC,aAAa;QACX1C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAyC,gBAAgB;QACd3C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA0C,SAAS;QACP5C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA2C,KAAK;QACH7C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA4C,gBAAgB;QACd9C,MAAM;QACNC,O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"}
|
|
@@ -1109,6 +1109,30 @@ export interface AglynOrgBilling extends AglynDocument {
|
|
|
1109
1109
|
* than throwing inside a page render.
|
|
1110
1110
|
*/
|
|
1111
1111
|
timeZone?: string;
|
|
1112
|
+
/**
|
|
1113
|
+
* The sites this organization declared ONE SENDER, `{ [groupId]: { name,
|
|
1114
|
+
* hostIds } }` (`consent-groups.ts`). Absent, every site is alone.
|
|
1115
|
+
*
|
|
1116
|
+
* SERVER-OWNED: denied to the client SDK, because `consentGroupForHost`
|
|
1117
|
+
* reads it in `app-utils` and decides who a marketing basis covers and whose
|
|
1118
|
+
* opt-outs hold a send. No console screen writes it; a declaration is made
|
|
1119
|
+
* by an Admin-SDK writer that must carry a leaving site's refusals onto the
|
|
1120
|
+
* sites that stay (see that module).
|
|
1121
|
+
*/
|
|
1122
|
+
consentGroups?: Record<string, {
|
|
1123
|
+
name: string;
|
|
1124
|
+
hostIds: string[];
|
|
1125
|
+
}>;
|
|
1126
|
+
/**
|
|
1127
|
+
* Whether a declared group's sites wait for each other's confirmation click
|
|
1128
|
+
* (AGL-3316). Absent or `false` is off: a pending confirmation holds only
|
|
1129
|
+
* the asking site's mail.
|
|
1130
|
+
*
|
|
1131
|
+
* SERVER-OWNED: written through /api/orgs/settings and denied to the client
|
|
1132
|
+
* SDK, for the reason `consentGroups` is — `consentGroupForHost` resolves it
|
|
1133
|
+
* into every group a send path reads.
|
|
1134
|
+
*/
|
|
1135
|
+
consentGroupsAwaitConfirmation?: boolean;
|
|
1112
1136
|
/** The workspace URL segment, reserved through `orgSlugs/{slug}`. */
|
|
1113
1137
|
slug?: string;
|
|
1114
1138
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/org-billing.types.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 org's billing/entitlement vocabulary (AGL-443 naming cleanup).\n *\n * HISTORY: these types were spelled `Tenant*` until AGL-444 — they\n * predate the organizations migration (AGL-232..238); the retired\n * `tenants/{uid}` collection's billing shape was mirrored ONTO the org\n * doc and the names came along. The alias is gone: everything here is\n * `Org*` and describes fields of `orgs/{orgId}`. The last persisted\n * tenant spellings (the `users.{uid}.tenants` map, Stripe\n * `metadata[tenantId]`, `host.tenantId`) were retired pre-launch in\n * AGL-445 — billing keys off `metadata[orgId]` now.\n *\n * Convention (see the docs-site glossary): \"organization/org\" is the\n * entity; \"workspace\" is the user-facing word for it; \"tenant\" is\n * reserved for the published-site runtime (`apps/tenant`,\n * `@aglyn/tenant-*` libs).\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n UserUid,\n} from './platform.types'\n/*\n * The plugin half of the two composed shapes below (AGL-3124).\n *\n * Each is an EMPTY interface a plugin augments with `declare module`, so the\n * keys a plugin owns are part of `OrgEntitlements` and `OrgFeatureFlags`\n * without the core listing them. Nothing composes today — the interfaces are\n * empty until a plugin declares into them — so this changes no shape at this\n * sha; what it changes is where the NEXT key is declared.\n *\n * `import type` and nothing else: TypeScript erases it, so the plugin-manager\n * module is not on this module's runtime graph and a published page that\n * reaches the billing types (through the foundation barrel it must not reach\n * anyway) carries nothing new.\n */\nimport type {\n PluginEntitlementFeatures,\n PluginEntitlementQuotas,\n} from '../../plugin-manager/plugin-entitlement-keys'\n\nexport type { OrgUid } from './platform.types'\n\n\n/** Hosted in master catalog */\n/**\n * SaaS subscription tiers (Tenant Billing & SaaS Plans, AGL-38..41).\n * Pricing v3 (2026-07) inserted `scale` between business and advanced to\n * fill the $139→$399 gap, and added `agency` above advanced for\n * high-volume multi-site orgs — see the Pricing Decision Log.\n *\n * `enterprise` (AGL-1118) is a REAL plan, not a display label: it tops the\n * ladder with unlimited capacity plus white-label and SSO, and it is the ONE\n * tier with no list price — it is staff-provisioned per deal (AGL-1110), never\n * self-serve. Surfaces that offer plans for sale iterate `SELF_SERVE_PLANS`,\n * which excludes it; surfaces that merely NAME the org's plan read\n * `PLAN_LABELS` and get \"Enterprise\" for free.\n */\nexport type OrgPlan =\n | 'free'\n | 'starter'\n | 'pro'\n | 'business'\n | 'scale'\n | 'advanced'\n | 'agency'\n | 'enterprise'\n\n/**\n * The CORE boolean feature gates per plan; quotas live beside them as\n * numbers. Read {@link OrgFeatureFlags} instead — this half exists so the\n * plan tables can stay exhaustive over the platform's own gates while a\n * plugin declares its own (AGL-3124).\n */\nexport interface CoreOrgFeatureFlags {\n /** A/B experiments (AGL-252); Business tier. */\n abTesting?: boolean\n versioning?: boolean\n reusableComponents?: boolean\n customDomain?: boolean\n /**\n * Send mail as a domain the CUSTOMER owns — `hello@acme.com` — by publishing\n * our SPF, DKIM and return-path records in their own zone.\n *\n * Distinct from `customDomain`, which is the site's public web address and\n * authorizes nothing about mail; and distinct from `whiteLabel`, which\n * replaces the Aglyn brand — product name, logo, colors, support URL,\n * console chrome, favicon — across every branded surface. Sending as your\n * own name is one narrow consequence of white-labeling rather than the\n * whole of it, so the two cannot share a flag without the wider capability\n * following the narrower one wherever it goes.\n *\n * It is also the CHEAP half of the sending model, which is why it sits low\n * on the ladder. A customer-owned domain costs the platform one provider\n * domain object and NOTHING in our own DNS zone, because the customer\n * publishes the records; the platform subdomain a site is issued costs a\n * provider slot, three records in our zone and a permanent place in the\n * re-verification sweep. Holding the free-to-us option behind the highest\n * paywall makes the expensive one the default at every tier that can send.\n *\n * Read through `checkEntitlement` at the two routes that write a sending\n * identity: the org's domain list, and the per-site selection.\n */\n customSendingDomain?: boolean\n /**\n * Hold a sending subdomain of the PLATFORM's own —\n * `hello@{label}.mail.aglyn.app` — provisioned for one site inside Aglyn's\n * mail apex.\n *\n * The EXPENSIVE half of the sending model, and the reason it is a separate\n * flag from `customSendingDomain`. A domain the customer owns costs one\n * provider domain object and nothing in our zone, because they publish the\n * records. One of these costs a provider slot, THREE records in our own\n * zone, and a permanent place in the re-verification sweep — per site,\n * forever. The two capabilities are decided against different resources, so\n * a shared flag would move a bounded one whenever the unbounded one is\n * re-cut.\n *\n * The flag is the SECOND of two conditions and never the only one. Nothing\n * provisions on entitlement alone: a merchant asks from the sending-identity\n * route, this says whether the org carries what they asked for, and the\n * provider ceiling still refuses beyond the account's allowance. A site\n * refused at any of the three sends on the shared pool rather than not at\n * all.\n *\n * Read through `checkEntitlement` at\n * `libs/aglyn/src/lib/app-utils/dedicated-sending-domain.ts`, which is the\n * only reader — the claim path in `host-sending-domain.ts` goes through it.\n */\n dedicatedSendingDomain?: boolean\n removeBranding?: boolean\n /** Schedule a version to publish at a date/time (tier above versioning). */\n scheduledPublishing?: boolean\n /** Sell listings on the marketplace marketplace (AGL-46). */\n marketplaceSelling?: boolean\n /** AI copy assist in the besigner (AGL-89). */\n aiAssist?: boolean\n /**\n * Generative building and automation (AGL-2896): pages, components,\n * layouts, templates, SEO, email, campaigns, A/B variants, analytics\n * insights, products, CRM records, onboarding and workflows produced by\n * the assistant rather than assembled by hand.\n *\n * Distinct from `aiAssist`, which is the chat guide, copy assist and\n * generate-section rung every tier from Pro up carries. This is the\n * expensive rung: a generated surface carries the node tree, the catalog\n * and the theme in and iterates, so it draws credits by the hundreds where\n * a question draws tens. No self-serve tier includes it; the Aglyn AI\n * add-on (`OrgSeatAddons.aiAddon`) switches it on and adds the credit band\n * that funds it, and `resolveOrgEntitlements` flips this flag together\n * with `aiAssist` when the add-on is present. Enterprise carries it in the\n * agreement.\n */\n aiGenerative?: boolean\n /** No-code workflow builder (AGL-101). */\n workflows?: boolean\n /** Datasets + repeatable components (AGL-102/103). */\n dataStore?: boolean\n /** Video/file uploads in the media manager (AGL-162). */\n videoMedia?: boolean\n /** Appointment bookings (AGL-159). */\n bookings?: boolean\n /**\n * THE CRM (AGL-2611): contacts, leads, companies, the deals pipeline,\n * tasks, reports and custom fields, the CRM automation steps, and the\n * `crm:*` REST resources.\n *\n * The Contacts section included (AGL-2851). Capture still writes a site's\n * audience into the contacts collection on every plan including Free,\n * banded by `contactsPerHost`, and exporting or erasing those people stays\n * on every plan in Settings → Privacy. What this flag gates is the CRM a\n * team works them in, and it is the upgrade motive from Free to the first\n * paid tier rather than a line on top of one: for the small-business buyer\n * the CRM is the reason to pick a platform over a page builder.\n *\n * Read by the console shell through the CRM extension's and its widgets'\n * `featureFlag` (so the shell refuses every CRM section and page before\n * one mounts), by the `crm/*` routes, by the automation executor before a\n * CRM step runs, by the REST dispatcher in front of the CRM resources,\n * contacts included, and, restated, by the Firestore rules on a client's\n * CRM writes. A per-org override on `entitlements.features.crm` works the\n * way every other flag's does.\n */\n crm?: boolean\n /**\n * SEQUENCES (AGL-2974): one-to-one, multi-step email sequences a rep sends\n * from their own connected mailbox, logged on the CRM's records. The key\n * below keeps the name the feature shipped under, because it is stored on\n * the org (AGL-3199).\n *\n * False on EVERY plan, Enterprise included. Which tiers carry sequences\n * and connected mailboxes, and at what caps, is a packaging decision that\n * has not been made, so no plan may claim it. An organization reaches it\n * only through the per-org override on `entitlements.features.outreach`.\n *\n * Read by the console shell through the Sequences extension's\n * `featureFlag`, by the plugin's routes, and, restated, by the Firestore\n * rules in front of the `outreach*` collections. The rules carry no plan\n * list for it because no plan grants it; when packaging lands, the plan\n * table and those rules change together.\n */\n outreach?: boolean\n /**\n * Basic presentational interactions (AGL-577): menu/drawer open-close,\n * element show/hide, class toggles, sticky nav, navigation, site\n * alerts. Included on ALL plans — pure client-side DOM with no server\n * cost. The `actions` flag below gates the powerful automation steps\n * (server dispatch, runJs, analytics, overlays, raw HTML).\n *\n * `true` on all eight tiers, and NO code gates on it — which makes it look\n * dead, and it has now been filed as dead once (AGL-2082). It is not.\n * `tools/marketing/build-pricing-tables.mts` reads its value to emit the\n * \"Interactions\" row on the public /pricing compare table, ticked on every\n * plan, and the Free plan card bullets it. That row is the claim that a\n * hover-to-open menu is not a paid feature — a real competitive statement,\n * and the reason `true` everywhere is a DECISION rather than a default.\n *\n * So: do not delete it as dead weight. Deleting it silently removes a\n * public pricing row while the Figma frames still carry it in all four\n * responsive variants, which is a pricing call and not a cleanup.\n */\n interactions?: boolean\n /** Event → action automation builder (AGL-148). */\n actions?: boolean\n /** Outbound/inbound webhooks (AGL-149). */\n webhooks?: boolean\n /** Customer REST API v1 + API keys (AGL-615); Business tier. */\n apiAccess?: boolean\n /** Whole-site export/backup + restore (AGL-163). */\n siteExport?: boolean\n /** Multilingual sites (AGL-164): locale variants + switcher. */\n multilingual?: boolean\n /** Event Calendar add-on (AGL-145); paid, not part of any base tier. */\n eventCalendar?: boolean\n /** URL redirects manager (AGL-154). */\n redirects?: boolean\n /** Per-screen traffic analytics (AGL-150). */\n screenAnalytics?: boolean\n /** CDN delivery + responsive image variants for media (AGL-175). */\n mediaCdn?: boolean\n /** Announcement bar + promotional popups (AGL-195/196). */\n marketingOverlays?: boolean\n /** Full storefront commerce: catalog, cart, checkout (AGL-278). */\n commerce?: boolean\n /** Console point-of-sale mode (AGL-312). */\n pos?: boolean\n /** Recurring storefront subscription products (AGL-303). */\n storefrontSubscriptions?: boolean\n /** Entitlement-gated screens/sections/video paywalls (AGL-309). */\n contentGating?: boolean\n /** Gift cards & store credit (AGL-322). */\n giftCards?: boolean\n /** Verified-buyer product reviews (AGL-324). */\n productReviews?: boolean\n /** Abandoned checkout recovery emails (AGL-323). */\n abandonedCart?: boolean\n /** Dropship supplier routing on paid orders (AGL-289). */\n dropshipRouting?: boolean\n /** Commerce analytics dashboard (AGL-327). */\n commerceAnalytics?: boolean\n /**\n * White-label the platform (White-Label Phase 1): replace the Aglyn brand\n * — product name, logo, colors, support URL, transactional email from-name\n * — with the org's own `brandingProfile` across every branded surface.\n * Agency ($799) and Enterprise carry it on the plan (AGL-1118); any other\n * tier needs a per-org `entitlements` override. Strictly broader than\n * `removeBranding`, which only drops the\n * \"Made with Aglyn\" badge on published sites; white-label REPLACES the\n * brand rather than merely hiding it. Every branded surface resolves the\n * effective brand through `resolveBrandingProfile` so it can never drift.\n */\n whiteLabel?: boolean\n /**\n * Enterprise SSO (AGL-1101): the org's console users sign in through the\n * org's own SAML/OIDC IdP, wired as a per-org GCIP tenant (`org.sso`).\n * Distinct from `whiteLabel` — an Agency org can have one without the other.\n * Carried by the `enterprise` plan (AGL-1118) and false on every other base\n * plan; a lower tier needs a per-org `entitlements` override, which is how\n * enterprise orgs provisioned before that plan existed still get it. Gates\n * the staff SSO-config card and the SSO sign-in path; a non-entitled org can\n * neither configure nor use SSO.\n */\n ssoEnabled?: boolean\n}\n\n/**\n * Boolean feature gates per plan, the platform's and its plugins' together.\n *\n * {@link CoreOrgFeatureFlags} is the platform's own set — what\n * `PLAN_ENTITLEMENTS` declares for every plan, and what\n * `Required<CoreOrgFeatureFlags>` keeps exhaustive.\n * {@link PluginEntitlementFeatures} is the plugin half (AGL-3124): empty\n * until a plugin declares a gate of its own into it with `declare module`,\n * and resolved from that plugin's `registerPluginEntitlements` defaults\n * rather than from a row in a core plan table — which is why a plugin's gate\n * joins HERE and not above. Every reader keeps reading `OrgFeatureFlags`, and\n * `keyof OrgFeatureFlags` admits a plugin's key the moment it is declared.\n */\nexport interface OrgFeatureFlags\n extends CoreOrgFeatureFlags,\n PluginEntitlementFeatures {}\n\n/**\n * An org's white-label brand identity (White-Label Phase 1). Populated on\n * the org doc (`orgs/{orgId}.brandingProfile`) and applied ONLY when the org\n * carries the `whiteLabel` entitlement (Agency or Enterprise plan, or a\n * per-org override);\n * otherwise every surface falls back to the Aglyn defaults baked into\n * `resolveBrandingProfile`. Every field is optional — a partial profile\n * still resolves, with the Aglyn default filling each gap — so an agency can\n * set just a product name and from-name without supplying logos.\n */\nexport interface OrgBrandingProfile {\n /** Brand name shown in place of \"Aglyn\" (console chrome, emails, badges). */\n productName?: string\n /** Primary/full-color logo URL (light backgrounds, console chrome). */\n logoUrl?: string\n /** Favicon URL for branded surfaces. */\n faviconUrl?: string\n /** Brand primary color as a CSS color (hex), e.g. `#1a73e8`. */\n primaryColor?: string\n /** Support/help destination linked from branded surfaces and emails. */\n supportUrl?: string\n /** Transactional email from-name (the display name before the address). */\n fromName?: string\n /** Logo URL specifically for the email header (often a hosted PNG). */\n emailLogoUrl?: string\n /** Custom console domain the agency serves the app on (Phase 4 wiring). */\n customConsoleDomain?: string\n}\n\n/**\n * An org's enterprise SSO configuration (AGL-1101, Phase 1). Applied ONLY when\n * the org carries the `ssoEnabled` entitlement. The actual IdP lives in a\n * per-org **GCIP tenant** (`tenantId`) with a SAML/OIDC **provider**\n * (`providerId`); this block is the org-doc mirror the console reads to route\n * sign-in and the staff card edits. A public `ssoDomains/{domain}` doc maps a\n * verified email domain → `{ orgId, tenantId, providerId }` so the\n * pre-auth sign-in page can resolve an SSO org without reading the org doc.\n *\n * Security: a domain reaches `domains[]` ONLY by passing DNS TXT verification\n * (AGL-1210). This is the account-takeover guard, and it is the whole reason\n * self-serve is safe: without it an org could claim a domain it does not own,\n * `ssoDomains/{domain}` would route that domain's sign-ins to its IdP, and it\n * would intercept another company's logins. Claims in flight live in\n * `orgs/{orgId}/ssoDomains/{domain}` (see `OrgSsoDomainClaim`) and are NOT\n * governed until verified.\n *\n * `enforced` blocks password/social login for the governed domains, so it is a\n * lockout risk and is rehearsed (`previewSsoEnforcement`) before it is applied.\n */\nexport interface OrgSsoConfig {\n /** GCIP tenant id that carries this org's IdP provider. */\n tenantId: string\n /** GCIP provider id, e.g. `saml.aglyn-workspace` or `oidc.acme`. */\n providerId: string\n /** IdP protocol. Phase 1 ships SAML; OIDC is Phase 2. */\n protocol: 'saml' | 'oidc'\n /** Human label for the IdP (shown on the SSO button + staff card). */\n displayName?: string\n /**\n * Email domains routed to this IdP (lowercased, no `@`). A domain is added\n * here only after its claim passes DNS TXT verification, and removed the\n * moment re-verification fails — so membership of this array IS the\n * \"ownership proven\" statement. Never write to it from anywhere but the\n * verification path.\n */\n domains: string[]\n /**\n * Retained for `sso-jit`, which gates on it. Kept in lockstep with\n * `domains.length > 0` by the verification path; it was a staff-attested\n * boolean before AGL-1210 and is now derived, never asserted by a human.\n */\n domainVerified: boolean\n /**\n * SAML metadata the CUSTOMER supplies about their IdP. Stored so the pool's\n * provider config can be rebuilt or re-applied without asking again. The\n * X.509 certificate is a public signing certificate, not a secret.\n */\n idp?: {\n entityId: string\n ssoUrl: string\n certificates: string[]\n }\n /**\n * Require SSO for the governed domains — disables password/social login for\n * them (Phase 2). Phase 1 keeps this false so users keep a fallback.\n */\n enforced: boolean\n /** Lifecycle: `configuring` (not live), `active`, or `disabled`. */\n status: 'configuring' | 'active' | 'disabled'\n /**\n * Uid + time of the last config change. Since AGL-1210 this is normally an\n * ORG ADMIN, not staff — the flow is self-serve end to end.\n */\n configuredBy?: string\n configuredAt?: ITimestamp\n}\n\n/**\n * A pending or proven claim on one email domain (AGL-1210), stored at\n * `orgs/{orgId}/ssoDomains/{domain}`.\n *\n * A subcollection rather than a map on the org doc because domains contain\n * dots, and a dotted key in a Firestore map is read as a nested field path by\n * every update helper — writing `sso.domainClaims[\"acme.com\"]` would silently\n * create `{acme: {com: …}}`. A document id has no such ambiguity.\n *\n * The claim is deliberately worthless on its own: holding one grants nothing.\n * Only `verified` moving to true adds the domain to `sso.domains`, and only\n * that array is consulted at sign-in.\n */\nexport interface OrgSsoDomainClaim {\n /** The domain being claimed (lowercased, no `@`); mirrors the document id. */\n domain: string\n /**\n * Random value the org must publish as a DNS TXT record at\n * `_aglyn-challenge.<domain>`. Per org+domain, so two orgs claiming the same\n * domain get different tokens and neither can pass on the other's record.\n */\n token: string\n /** True once a DNS lookup has actually seen `token` at the challenge host. */\n verified: boolean\n createdAt: ITimestamp\n verifiedAt?: ITimestamp\n /** Last lookup attempt, successful or not — drives re-verification. */\n lastCheckedAt?: ITimestamp\n /**\n * TXT records seen on the last FAILED lookup. Shown back to the customer,\n * because \"no record found\" and \"found the wrong value\" are different\n * mistakes and the fix differs.\n */\n lastRecords?: string[]\n}\n\n/**\n * Effective limits/gates for a tenant. Plan defaults come from\n * `PLAN_ENTITLEMENTS` (versioned with the app); per-tenant overrides can be\n * stored on the tenant doc and win over the plan defaults.\n *\n * The CORE half. Read {@link OrgEntitlements} instead: it is this plus\n * whatever the plugins declare. The keys spelled out below predate the seam\n * and move to their plugins with the rest of AGL-3080; every NEW key is\n * declared by whoever owns it.\n */\nexport interface CoreOrgEntitlements {\n hostLimit?: number\n screensPerHost?: number\n sharedLayoutsPerHost?: number\n /** Saved templates per host (AGL-666) — includes marketplace downloads. */\n templatesPerHost?: number\n // NOTE (AGL-658): \"add-on\" means two unrelated things in this codebase.\n // `seatAddons` below are BILLING capacity — extra managers, hosts, seats —\n // surfaced in the UI as \"plan add-ons\". The marketplace sense (installed\n // plugins, the `orgAddons` slot) is a different concept entirely. The\n // marketplace owns the bare word; billing copy always qualifies it.\n // Firestore and Stripe lookup keys stay as they are — they are persisted.\n storagePerHostMb?: number\n /**\n * RETIRED (AGL-2133). Enforced by nothing and unreachable by measurement —\n * see `RETIRED_ENTITLEMENT_KEYS`. It stays on the TYPE because live org\n * documents still carry staff overrides of it and `OrgEntitlements` is the\n * shape those documents are read through; `resolveOrgEntitlements` drops\n * it, and no plan declares it any more, so nothing can resolve a value.\n * @deprecated\n */\n totalSiteSizeMb?: number\n /**\n * Included per-site COLLABORATOR seats (`hosts/{id}/members`,\n * viewer/editor/admin) — console teammates scoped to one site, not\n * end-user member accounts (`siteMembers`), which are unlimited on\n * every plan (AGL-888/889). Legacy key name; persisted, do not rename.\n */\n membersPerHost?: number\n /** Seat model (AGL-112): included tenant-manager seats. */\n managersPerOrg?: number\n /** Hard seat caps incl. purchased addons; beyond these, upgrade the plan. */\n maxManagersPerOrg?: number\n /** Hard per-site collaborator cap incl. addons (see `membersPerHost`). */\n maxMembersPerHost?: number\n bandwidthGb?: number\n /** Form submissions accepted per calendar month (Forms & Lead Capture). */\n formSubmissionsPerMonth?: number\n /**\n * Saved form DEFINITIONS per host — documents under\n * `hosts/{hostId}/forms`, the entity a submission's `formId` points at.\n *\n * Not the same dimension as `formSubmissionsPerMonth`, and the two are\n * routinely confused: this is how many distinct intake forms an author may\n * build, that is how many replies the site may accept. Only the second is\n * tiered. This one resolves to the same `FORMS_PER_HOST_CEILING` on every\n * plan that has the form entity at all — it bounds a collection rather than\n * selling a tier — and exists as an entitlement so that `checkQuota` can\n * refuse a create, and so a contract can override one org's number.\n *\n * Counts the CATALOG, never the traffic. A `Form` node drawn on a page and\n * left unbound submits without a definition and spends nothing here, which\n * is why a plan resolving to 0 still accepts submissions.\n */\n formsPerHost?: number\n /** Component-builder caps (AGL-99): host variables. */\n variablesPerHost?: number\n /** Component-builder caps (AGL-99): host functions. */\n functionsPerHost?: number\n /** Workflow builder cap (AGL-99/101). */\n workflowsPerHost?: number\n /** Event-triggered workflow runs per calendar month (AGL-165). */\n workflowRunsPerMonth?: number\n /** Bookable services per host (AGL-159). */\n servicesPerHost?: number\n /** Redirect rules per host (AGL-154). */\n redirectsPerHost?: number\n /**\n * The CRM RECORDS band (AGL-197, widened in AGL-2611): contacts, companies\n * and deals, counted together across the org. The persisted key still says\n * \"contacts\" because it is written on live org documents as a staff\n * override and read back through this type; every customer surface says\n * \"CRM records\". A tier with an `extraContactsUsdPer1k` rate meters past\n * it; a tier with none refuses the next record. Free has no CRM\n * (AGL-2851), so on Free the record its band refuses is a capture. Tasks\n * and activities are not counted — see `CRM_ACTIVITIES_PER_RECORD_CEILING`.\n */\n contactsPerHost?: number\n /**\n * One-to-one emails a workspace may send from CRM records per UTC day —\n * the message a rep writes to one person from their contact page, as\n * against a campaign (`emailSendsPerMonth`) or transactional mail.\n *\n * A HARD cap on every tier and a daily one, because it is the only send\n * class a person can produce by hand at volume: a campaign is one act over\n * a metered audience, and a receipt follows an order somebody paid for,\n * but a rep with a template and a list can put a thousand messages onto\n * the platform's sending reputation in an afternoon. The day boundary is\n * what makes the cap a pace rather than a wall — tomorrow the count is\n * zero again — and the number is sized so that every tier holds its\n * margin with the whole day spent, which is the arithmetic the Drive\n * pricing decision of 2026-09-05 records. `checkCrmEmailQuota` reads it;\n * the counter it is enforced against is `orgs/{orgId}/crmEmailUsage/{day}`.\n *\n * Every send still counts on the org's `emailSends` cost meter, like any\n * other message the provider charged for. 0 on Free, which has no CRM,\n * and finite on every paid tier, Enterprise's 2,000 included.\n *\n * ## This ladder and `emailSendsPerMonth` are NOT the same ladder\n *\n * They answer to different principles, deliberately, and a tier's two\n * numbers are not expected to look like each other. `emailSendsPerMonth`\n * is sized against SHARED-DOMAIN REPUTATION: campaign mail is bulk on a\n * pool every tier without its own verified sending domain rides under\n * `p=reject`, so what one workspace sends is a cost every other workspace\n * pays. This one is sized against COGS SHARE: one-to-one mail is a per-tier\n * margin question, which is why it is a daily pace rather than a monthly\n * band.\n *\n * The two are far apart at the entry tier — a band of 0 beside a real daily\n * allowance — and that reads as an inconsistency until the principles are\n * named. It is not one. One-to-one mail is addressed to a single person,\n * usually inside a relationship the recipient started, and it reaches the\n * provider through a per-user pace limit, both suppression lists, a\n * `declined` consent basis as a hard stop, and an atomic daily reservation.\n * Complaint rates on it are structurally unlike bulk. Reconciling the two\n * ladders would price a reply to one customer as though it carried a\n * campaign's reputation risk.\n */\n crmEmailsPerDay?: number\n /**\n * CAMPAIGN emails sendable per calendar month (AGL-161), and campaign\n * emails only (AGL-1438).\n *\n * Transactional mail — password resets, invites, order confirmations,\n * booking reminders, workflow notifications — is never refused by this cap\n * at any tier. It still COUNTS toward the org's email cost meter; it simply\n * cannot be blocked, because a quota that can drop a password reset locks\n * somebody out of their own account and a dropped order confirmation reads\n * to the buyer as a failed order.\n *\n * The name is the narrow thing on purpose. A cap that means something\n * narrower than it says is how AGL-1438 came to exist, so every surface\n * that shows this number says \"campaign\".\n *\n * Sized against shared-domain reputation, which is a different principle\n * from the one behind `crmEmailsPerDay` — see there for why the two ladders\n * are not meant to track each other.\n */\n emailSendsPerMonth?: number\n /**\n * Aglyn Assist credits per calendar month — the band assist spends against.\n *\n * A CREDIT IS A UNIT OF MODEL COST, not a message. One assist action can\n * cost two orders of magnitude more than another: a question is a few\n * thousand tokens, while generating a screen carries the node tree, the\n * component catalog and the theme tokens in, structured markup out, and\n * iterates. A message allowance would price those the same, so one\n * workspace's ten screen builds would outspend another's thousand questions\n * and both would read as \"within allowance\".\n *\n * The unit is defined by `ASSIST_CREDIT_COST_USD` and the conversion lives\n * in `assist-credits.ts`. Only that module turns credits into dollars;\n * everything a customer sees counts credits, because the dollar figure\n * behind them is our provider bill and not a price.\n *\n * ## HOW THE BAND IS SIZED, and why it is not a share of the price\n *\n * A credit is a dollar of provider spend at `ASSIST_CREDIT_COST_USD`, so\n * the band IS a liability figure: the plan's whole assist give is\n * `assistCreditsPerMonth / 1000` dollars. Credits past it are not a give —\n * since AGL-2653 they are SOLD at `extraAssistCreditsUsdPer1k`, unless the\n * org's `assistOverage.hardCap` asks `reserveAssistMessage` to refuse at\n * the band instead. Sizing it as a share of the subscription price says\n * nothing about whether the tier can afford it, because the price is also\n * carrying storage, bandwidth, form submissions, dataset storage, API\n * requests, contacts and email.\n *\n * So each band is sized against what those SEVEN other terms leave. Every\n * paid band takes between a quarter and a third of that remainder, which\n * keeps the tier's worst case positive and keeps the ladder's shape: the\n * margin each tier holds at 100% of every band stays in proportion to what\n * it held before assist was sold at all. `tier-margin-floor.spec.ts` is the\n * model, and it pins both the rule and the resulting figures.\n *\n * 0 on Free and Starter, which carry no `aiAssist` and no band. The Aglyn\n * AI add-on (AGL-2896) adds `AI_ADDON_CREDITS_PER_MONTH[plan]` to this\n * band in `resolveOrgEntitlements` — one pool, widened, rather than a\n * second meter — so Starter with the add-on carries a band after all.\n */\n assistCreditsPerMonth?: number\n /** Action runs per calendar month (AGL-148). */\n actionRunsPerMonth?: number\n /** Included customer REST API requests per calendar month (AGL-634);\n * Beyond it, metered overage per 1,000 where the plan prices it. Only\n * Business/Advanced carry `apiAccess`, so lower tiers are 0. */\n apiRequestsPerMonth?: number\n /** Dynamic data caps — org-scoped (AGL-239/240): datasets are shared\n * by every host in the org, so counts and size meter per org. */\n datasetsPerOrg?: number\n /** Hard dataset cap incl. addons (AGL-132/240); beyond it, upgrade. */\n maxDatasetsPerOrg?: number\n recordsPerDataset?: number\n /** Included aggregate dataset storage (MB) across the org (AGL-240);\n * Beyond it, metered overage per GB where the plan prices it. */\n dataStorageMbPerOrg?: number\n /** @deprecated Legacy host-keyed override (pre-AGL-240); resolved into\n * `datasetsPerOrg` by `resolveOrgEntitlements`. */\n datasetsPerHost?: number\n /** @deprecated Legacy host-keyed override (pre-AGL-240); resolved into\n * `maxDatasetsPerOrg` by `resolveOrgEntitlements`. */\n maxDatasetsPerHost?: number\n /** Catalog products per host (AGL-278). */\n productsPerHost?: number\n /** Inventory locations per host (AGL-286). */\n inventoryLocations?: number\n /** Concurrent POS registers (AGL-312); add-ons raise it (AGL-329). */\n posRegisters?: number\n /** Platform fee % on physical storefront sales (Connect app fee). */\n transactionFeePhysicalPct?: number\n /** Platform fee % on digital storefront sales (Connect app fee). */\n transactionFeeDigitalPct?: number\n /**\n * Platform take rate % on the org's MARKETPLACE listing sales (AGL-46,\n * resolved from entitlements per AGL-1543): 20 on paid plans, 30 on\n * free. Distinct from the storefront `transactionFee*Pct` above — this\n * is Aglyn's cut of a marketplace sale, priced off the SELLER org.\n */\n marketplaceFeePct?: number\n features?: OrgFeatureFlags\n /**\n * A staff PLAN COMP (AGL-3034) — see `OrgPlanComp`. Not a quota: the\n * resolver's numeric loop skips it, and `ResolvedOrgEntitlements` omits\n * it. Read through `readOrgPlanComp`, never directly.\n */\n planComp?: OrgPlanComp\n}\n\n/**\n * Effective limits and gates for a tenant, the platform's and its plugins'\n * together (AGL-3124).\n *\n * {@link CoreOrgEntitlements} is what `PLAN_ENTITLEMENTS` declares per plan\n * and what `ResolvedOrgEntitlements` keeps exhaustive.\n * {@link PluginEntitlementQuotas} is the plugin half: empty until a plugin\n * declares a key of its own into it with `declare module` and registers it\n * with `registerPluginEntitlementKeys`, and resolved from that plugin's seat\n * add-on declaration rather than from a row in a core plan table. So\n * `resolveOrgEntitlements` carries a plugin's band, and `keyof\n * OrgEntitlements` admits its key, without this file naming the plugin's\n * domain.\n */\nexport interface OrgEntitlements\n extends CoreOrgEntitlements,\n PluginEntitlementQuotas {}\n\n/**\n * A plan staff GRANTED a workspace that no live subscription pays for\n * (AGL-3034), stored at `orgs/{orgId}.entitlements.planComp`.\n *\n * ## Why a marker of its own, and not the stored `plan`\n *\n * `plan: 'pro'` beside `billingStatus: 'canceled'` is what EVERY customer who\n * canceled can look like: Stripe's mirror is not always walked back to\n * `free`, and a staff override used to write `plan` directly. So a stored\n * paid plan must never outrank a dead subscription — `resolveEffectivePlan`\n * reads it as Free, which is AGL-247's rule and stays it. A comp is a\n * different fact with a different writer: only `/api/admin/org-override`\n * writes this, audited, with a reason. Nothing reads a comp out of `plan`.\n *\n * ## When it applies\n *\n * Only while the subscription is dead or absent — `resolvePlanComp`. A live\n * subscription (and any status the resolver does not read as dead) governs\n * the plan, and the comp waits, dormant, until staff remove it or the\n * subscription ends.\n *\n * ## Why under `entitlements`\n *\n * It sits beside the other staff overrides, on the one map the override\n * route already owns and the rules already deny to every client, staff\n * included (AGL-1795). A top-level key would have needed a new entry in all\n * three of the org rule's deny-lists, in a rules file at its size limit.\n *\n * ## What it is NOT\n *\n * Not a sale. A comp bills nothing, so every metered band on it is a wall —\n * `resolvePlanPricing` — and it is never a card on file or a paying\n * subscription: `isBillingSubscription` stays false, and the AI overage\n * charge path has no rate to bill it at.\n *\n * ## Capped or uncapped (AGL-3049)\n *\n * A comp is CAPPED unless staff lift it: every band is its plan's figure, a\n * hard limit, and a per-org quota override beside the comp raises one band.\n * An UNCAPPED comp (`uncapped: true`) reads every band and quota as\n * unlimited while it is in force — the shape an internal workspace needs —\n * and still bills nothing. The flag is part of the grant: the same route\n * writes it with its own reason and audit row, removing the comp removes it,\n * and a live subscription ignores it along with the rest of the comp.\n *\n * The route states it on every grant, `false` included. The org write is a\n * merge, which writes a nested map key by key, so a capped grant that left\n * the key out would keep the `true` of the uncapped comp it replaced.\n *\n * Typed loosely where foundation cannot import the vocabulary: `reason` is\n * an `OrgOverrideReasonCode` (`app-utils/org-override-reason`), narrowed by\n * `readOrgPlanComp`.\n */\nexport interface OrgPlanComp {\n /** The plan granted. A paid plan; a comp of `free` is not a comp. */\n plan: OrgPlan\n /**\n * Every band and quota reads as unlimited while the comp is in force\n * (AGL-3049). Only a literal `true` lifts them; absent means capped.\n */\n uncapped?: boolean\n /** Why, from the override's fixed reason set (AGL-1652). */\n reason: string\n /** Staff-only rationale; explicit `null` when none was given. */\n note: string | null\n /** The staff uid that granted it. */\n grantedBy: string\n /** When it was granted — the server's clock. */\n grantedAt: ITimestamp | null\n}\n\n/**\n * Paid addon quantities (AGL-112/524) purchased on top of the plan's\n * included allowances, billed as items on the org's Stripe subscription.\n * Seat/dataset kinds resolve as `included + purchased` clamped to the\n * plan's hard max — beyond the max the org must upgrade. Purchases only\n * count while the subscription is alive (they bill on it); staff grants\n * live on `entitlements` overrides instead, so the two never collide.\n */\nexport interface OrgSeatAddons {\n /** Extra tenant-manager seats. */\n managers?: number\n /**\n * Extra per-site collaborator seats. Legacy key name (AGL-888): the Stripe\n * price env suffix is still `EXTRA_MEMBER` and existing org docs carry this\n * key — persisted, do not rename.\n *\n * A POOL, not a raise (AGL-2439) — the same correction `posRegisters`\n * received in AGL-1775, applied to the key that never got it.\n * `membersPerHost` is enforced PER SITE, so folding this org-wide quantity\n * into the org-level entitlement handed every site the whole purchase: one\n * extra collaborator seat bought 20 collaborators on a 20-site org. Since\n * AGL-2439 the quantity here is the size of an org-level pool and\n * `collaboratorAllocations` says which site each purchased seat is assigned\n * to. Nothing reads this as a per-site number; use\n * `checkHostCollaboratorQuota` / `resolveHostCollaboratorCap`.\n */\n members?: number\n /** Extra org datasets (AGL-132/240); billed monthly per dataset. */\n datasets?: number\n /** Extra sites beyond the plan's `hostLimit` (AGL-68/524). */\n hosts?: number\n /**\n * Extra POS registers beyond the plan's `posRegisters` (AGL-329/524).\n *\n * A POOL, not a raise (AGL-1775). `posRegisters` is enforced PER SITE, so\n * folding this quantity into the org-level entitlement handed every site\n * the whole purchase — one $89/mo register bought 20 registers on a\n * 20-site org. Since AGL-1775 the quantity here is the size of an org-level\n * pool and `registerAllocations` says which site each purchased seat is\n * assigned to. Nothing reads this as a per-site number; use\n * `resolveHostRegisterCap`.\n */\n posRegisters?: number\n /** Event Calendar org-wide toggle, 0/1 (AGL-145/524). */\n eventCalendar?: number\n /**\n * Aglyn AI org-wide toggle, 0/1 (AGL-2896). One purchase covers every\n * host in the org, the way Event Calendar does: `resolveOrgEntitlements`\n * reads any quantity >= 1 as one, adds the plan's\n * `AI_ADDON_CREDITS_PER_MONTH` band to `assistCreditsPerMonth`, and\n * switches `features.aiGenerative` and `features.aiAssist` on. Priced per\n * plan at `PlanPricing.aiAddonMonthlyUsd`; `null` there means the plan\n * does not sell it.\n */\n aiAddon?: number\n}\n\n/**\n * Which SITE each purchased POS register seat is assigned to (AGL-1775):\n * `{ [hostId]: seats }`, drawn from the org-level pool\n * `seatAddons.posRegisters`.\n *\n * WHY IT LIVES ON THE ORG DOC. The pool is org-level and the entitlement it\n * modifies is resolved from this same document, so a caller that can resolve\n * entitlements at all already holds the allocation — there is no second read\n * to fail and no separate loading state in which a host could resolve to\n * something other than its plan cap. That property is the point: the\n * `checkQuota(undefined)` = Free-tier lesson inverted, where an absent\n * allocation must mean the PLAN's cap and never the pooled total.\n *\n * AN ENTITLEMENT INPUT, and therefore Admin-SDK-only — it is denied to every\n * client in `cloud/firebase-firestore.rules` alongside `seatAddons` and\n * `entitlements`. A client that could write this could assign itself the\n * whole pool on every site, which is the defect AGL-1775 exists to close.\n *\n * A host id absent from the map holds ZERO purchased seats and resolves to\n * the plan cap alone. Deleting a site deletes its key, which returns its\n * seats to the pool — the pool is `seatAddons.posRegisters` minus the sum of\n * this map, so a released key is available capacity by arithmetic rather\n * than by a separate counter that could drift.\n */\nexport type OrgRegisterAllocations = Record<string, number>\n\n/**\n * Which SITE each purchased COLLABORATOR seat is assigned to (AGL-2439):\n * `{ [hostId]: seats }`, drawn from the org-level pool `seatAddons.members`.\n *\n * The same object as `OrgRegisterAllocations` and for the same reasons —\n * see that type's block, all of which applies verbatim. It is a distinct type\n * because it is a distinct field on the document and the two pools are bought\n * separately; nothing may read one as the other.\n *\n * AN ENTITLEMENT INPUT, and therefore Admin-SDK-only — denied to every client\n * in `cloud/firebase-firestore.rules` alongside `seatAddons`,\n * `registerAllocations` and `entitlements`. A client that could write this\n * could assign itself the whole pool on every site, which is the defect the\n * pool exists to close.\n *\n * A host id absent from the map holds ZERO purchased seats and resolves to\n * the plan cap alone. Deleting a site deletes its key, returning its seats to\n * the pool by arithmetic rather than by a counter that could drift.\n *\n * ONE THING DIFFERS FROM REGISTERS and it is deliberate: collaborator seats\n * clamp to the plan's `maxMembersPerHost` band, because that is how they are\n * sold. Assigning ten pool seats to a Starter site cannot lift it past ten —\n * `resolveHostCollaboratorCap` applies the clamp, so the console must not\n * present an assignment past the band as capacity.\n */\nexport type OrgCollaboratorAllocations = Record<string, number>\n\n/**\n * The monthly storage-overage spend cap an org CHOSE for itself (AGL-1886,\n * corrected 2026-08-18).\n *\n * The ceiling on metered spend is the END USER's control: it exists to prevent\n * a surprise, not to gate the product on a customer's acknowledgement.\n *\n * So this document is **absent by default and absent is the normal state**.\n * Storage past a metered plan's included band bills without it; the customer\n * is warned by `usage-alerts` on approach and at the band. Writing a cap here\n * is a customer opting IN to being stopped, and it is the only thing that can\n * refuse an upload on a plan that meters.\n *\n * NOT a consent record. As an acknowledgement that must exist before a paying\n * org may store a byte past its band, it costs revenue and blocks customers\n * without preventing any bill — `report-usage` bills stored bytes and never\n * reads this document.\n *\n * Still an ENTITLEMENT INPUT in the security sense, in the other direction: a\n * member who could raise their own `capUsd` could raise their own spend\n * ceiling, so the rules deny it to every client and only\n * `/api/billing/storage-overage` (Admin SDK, `billing.manage`) writes it.\n *\n * @see apps/console/utils/storage-overage.ts for the model and what still\n * hard-bands.\n */\nexport interface OrgStorageOverage {\n /**\n * The monthly storage-overage spend the org will not go past. Present means\n * capped; absent means uncapped, which is the default. A present but\n * malformed value resolves to `STORAGE_CAP_FALLBACK_USD` rather than to \"no\n * cap\" — a customer who asked for a ceiling must not be billed past one\n * because the stored number was corrupt.\n */\n capUsd?: number\n /** When the cap was set, for the audit trail. */\n capSetAt?: ITimestamp | null\n /** The uid that set it. */\n capSetBy?: string | null\n /**\n * LEGACY (pre-2026-08-18): the acknowledged-consent pair. Read only, and\n * honoured as a cap of `monthlyCeilingUsd` so a ceiling somebody typed is\n * not silently raised. Never written again.\n */\n acknowledgedAt?: ITimestamp | null\n /** @deprecated legacy consent field; see `acknowledgedAt`. */\n acknowledgedBy?: string | null\n /** @deprecated legacy bound; read as a cap. See `capUsd`. */\n monthlyCeilingUsd?: number\n}\n\n/**\n * The org's own answer to what happens when Aglyn Assist reaches its included\n * band (AGL-2653).\n *\n * Absent by default, and absent is the normal state: assist past the band is\n * SOLD — `reserveAssistMessage` keeps reserving and `report-usage` bills the\n * credits past the band at `PLAN_PRICING.extraAssistCreditsUsdPer1k`. Writing\n * `hardCap: true` is a customer opting IN to being stopped at the band\n * instead, which is what the gate did for every org before the overage was\n * sold. The shape mirrors `OrgStorageOverage`, the same control on storage,\n * for the same reason: a ceiling on metered spend is the END USER's control,\n * offered rather than imposed.\n *\n * An ENTITLEMENT INPUT in the security sense, in both directions at once. A\n * client that could clear it would lift a ceiling the org chose; one that\n * could set it on another org would switch that org's assistant off at the\n * band. So the rules deny it to every client and only\n * `/api/billing/assist-overage` (Admin SDK, `billing.manage`) writes it. Read\n * through `resolveAssistHardCap` in `app-utils/assist-credits`, never\n * directly.\n *\n * `report-usage` never reads this map — it bills whatever landed past the\n * band, the way storage bills stored bytes. With the cap on that is at most\n * the one exchange that crossed the line, because an exchange's cost is known\n * only after it is answered; with it off, it is the overage the org chose to\n * buy. Reading the switch at sweep time instead would let a flip on the 1st\n * erase a month's overage, the AGL-2399 shape on a different meter.\n *\n * On a plan whose `extraAssistCreditsUsdPer1k` is `null` the switch changes\n * nothing: there is no rate to sell past the band at, so the band stays the\n * wall it always was, on or off.\n *\n * ## The dollar ceiling beside the switch (AGL-2898)\n *\n * `capUsd` is the second control, and it answers a different question. The\n * switch asks \"sell past the band at all?\"; the ceiling asks \"and if so, how\n * much?\" — a monthly figure in USD of OVERAGE, priced at the plan's rate, past\n * which the assistant refuses for the rest of the month. It is the\n * `storageOverage.capUsd` of this meter: optional, absent by default, and the\n * customer's own number rather than a band the plan sold them. Read through\n * `resolveAssistOverageCapUsd`, and enforced by `assistOverageCapReached`\n * inside the same reservation transaction as the band.\n *\n * The two are independent. With the switch on the ceiling is never reached,\n * because nothing past the band is ever sold; with the switch off and no\n * ceiling the overage is open-ended, bounded only by the plan's message cap.\n * On a plan with no rate the ceiling, like the switch, changes nothing:\n * overage on such a plan prices to zero and zero never reaches a ceiling.\n */\nexport interface OrgAssistOverage {\n /** True stops assist at the included band; absent or false sells past it. */\n hardCap?: boolean\n /** When the switch was last written, for the audit trail. */\n hardCapSetAt?: ITimestamp | null\n /** The uid that wrote it. */\n hardCapSetBy?: string | null\n /**\n * The month's ceiling on AI overage, in USD at the plan's rate. `null` or\n * absent is no ceiling; anything that is not a finite positive number reads\n * as none too, so a corrupt value cannot become a wall of `NaN`.\n */\n capUsd?: number | null\n /** When the ceiling was last written or cleared, for the audit trail. */\n capSetAt?: ITimestamp | null\n /** The uid that wrote it. */\n capSetBy?: string | null\n}\n\n/**\n * A discount applied to the org's OWN Aglyn subscription (AGL-1105) — a\n * comped enterprise deal or a redeemed coupon, mirrored from a Stripe coupon\n * so `orgMonthlyRevenueUsd` can report net-of-discount MRR without a Stripe\n * round-trip. Exactly one of `percentOff` / `amountOffUsd` is set, matching\n * the Stripe coupon it points at (Stripe coupons are one or the other).\n * DISTINCT from a storefront/customer discount — this is Aglyn's own bill.\n */\nexport interface OrgDiscount {\n /** The Stripe coupon id (`co_…`) applied to the subscription. */\n couponId: string\n /** The Stripe promotion code id (`promo_…`), when the coupon has a code. */\n promotionCodeId?: string\n /** The human redemption code (e.g. `LAUNCH25`), when one exists. */\n code?: string\n /** Percentage off, 0–100 (mutually exclusive with `amountOffUsd`). */\n percentOff?: number\n /** Fixed USD off per invoice (mutually exclusive with `percentOff`). */\n amountOffUsd?: number\n /** The staff uid that applied it (audit trail; self-serve reads `system`). */\n appliedBy: UserUid\n /** Free-text why (the enterprise deal, the promotion) — staff-only. */\n reason?: string\n appliedAt: ITimestamp\n}\n\nexport interface OrgSubscription {\n status?:\n | 'active'\n | 'trialing'\n | 'past_due'\n | 'canceled'\n | 'incomplete'\n | 'unpaid'\n /**\n * WHY the subscription ended, mirrored from Stripe's\n * `cancellation_details.reason` on `customer.subscription.deleted`\n * (AGL-1877). `'payment_failed'` is Stripe having exhausted the dunning\n * retries; `'cancellation_requested'` is the customer leaving on purpose.\n *\n * The distinction is not cosmetic — it is the only thing that tells a\n * DELINQUENT workspace apart from one that simply left, and both arrive as\n * the same event with the same `status: 'canceled'` and the same\n * `plan: 'free'` mirror. Without it `shouldAutoLockOrgForBilling` had no\n * reachable delinquent state at all (see `billing-auto-lock.ts`), the\n * churn funnel counted a dunning failure as voluntary churn, and nobody\n * could be told which had happened.\n *\n * Absent on every org whose subscription ended before this shipped, and\n * every consumer must fail CLOSED on that — an unknown reason is not a\n * proven payment failure.\n */\n canceledReason?: string | null\n priceId?: string\n /**\n * Billing interval of the plan item (AGL-532), webhook-mirrored: the\n * Billing page initializes its monthly/annual toggle from it and plan\n * switches keep it unless the toggle says otherwise.\n */\n interval?: 'month' | 'year'\n currentPeriodEnd?: ITimestamp\n /**\n * A downgrade scheduled for the current period end (AGL-1862). Set by\n * `/api/billing/subscription` when a switch walks DOWN the self-serve\n * ladder — the Stripe subscription schedule owns the transition, this is\n * the manager-gated mirror the billing page renders. `null` (not absent)\n * once released, so a merge clears it. Rides inside `subscription` because\n * `pickOrgBillingFields` drops any other top-level key.\n */\n pendingDowngrade?: {\n plan: string\n interval: 'month' | 'year'\n /** ISO timestamp of the period end the schedule flips at. */\n effectiveAt: string | null\n scheduleId: string\n } | null\n /**\n * Negotiated custom price as a **monthly-normalized** USD figure (AGL-1110),\n * set when an enterprise org bills at an ad-hoc amount rather than a plan's\n * list price — e.g. `agency` capability at $2,730/mo. An annual custom deal\n * is stored ÷12 here (the yearly total lives on the Stripe price), so every\n * reader treats it as monthly and never re-divides. Webhook-mirrored from the\n * Stripe subscription's recurring price. When present it is the truth for\n * revenue — `orgListPriceMonthlyUsd`/`orgMonthlyRevenueUsd` use it instead of\n * the plan default (fixes the custom-price MRR under-report, AGL-1110).\n */\n customMonthlyUsd?: number\n}\n\n/**\n * The org's billing/entitlement doc shape — the view of `orgs/{orgId}`\n * that `useCurrentOrg()`, the plugin-page `org` prop, and the\n * entitlement resolvers carry. (Formerly `AglynTenant`; the alias was\n * removed in AGL-444.)\n */\n/**\n * `orgs/{orgId}.bandwidthCap` — the engaged free-plan bandwidth cap\n * (AGL-1967/2070/2155).\n *\n * STAMPED WITH THE MONTH IT IS FOR, and never cleared. A new month simply\n * stops matching, so the cap lifts on its own with no write: a marker whose\n * removal depends on a cron running is a marker that stays engaged when the\n * cron fails, which would take a customer's site down over an infrastructure\n * problem rather than over their traffic.\n *\n * Plain numbers rather than `ITimestamp`, matching `suspendedUntilMs`, so the\n * value survives the tenant's cache serialization unchanged.\n */\nexport interface OrgBandwidthCap {\n /** UTC `YYYY-MM` this cap was engaged for. Only the current month refuses. */\n month: string\n /** When the sweep engaged it. Diagnostic only; nothing gates on it. */\n engagedAt?: number\n /** The org-wide page views measured at engage time. Diagnostic. */\n pageViews?: number\n /** The band those page views were measured against. Diagnostic. */\n includedPageViews?: number\n}\n\n/**\n * The organization document.\n *\n * NOT composed from a plugin-augmented interface, deliberately (AGL-3124).\n * `org-write-deny-coverage.spec.ts` enumerates this interface's fields by\n * reading THIS FILE'S SOURCE, and checks every one of them against the\n * Firestore write-deny rules; a field a plugin declared from its own file\n * would be invisible to that sweep, so composing here would open a rules\n * coverage hole to save a plugin an already-solved problem. A plugin's own\n * settings block goes through `registerPluginConfigSchema` into\n * `pluginSettings/{pluginId}`, which is its own document under its own rule.\n */\nexport interface AglynOrgBilling extends AglynDocument {\n /** The document id, injected by the reader — never a stored field. */\n $id: OrgUid\n /**\n * The org's display name, written by `createOrganization` and renamed\n * through /api/orgs/settings (the name is denormalized onto every\n * membership row, so the rename fans out server-side). Client-writable by\n * an org admin on purpose — see `ORG_CLIENT_WRITABLE_FIELDS`.\n *\n * Spelled `displayName` here until AGL-1355; nothing ever read that, and\n * the document has always carried `name`. The coverage guard derives the\n * org's field set from this interface, so a field that does not exist\n * bought nothing but a blind spot.\n */\n name?: string\n /** Free-text workspace description; nothing gates on it. */\n description?: string\n /**\n * The IANA zone this workspace's published dates read in (AGL-3237) —\n * `America/Chicago`, `Europe/Berlin`. Absent is UTC.\n *\n * What it decides is which CALENDAR DAY an instant is attributed to on a\n * published site, which is an editorial fact about the publisher: a post\n * that went out at 19:30 in Chicago is dated the 21st here and was dated\n * the 22nd while UTC decided it. Sites inherit it; see\n * `resolveSiteTimeZone`.\n *\n * SERVER-OWNED: written through /api/orgs/settings and denied to the\n * client SDK, because `resolveSiteTimeZone` reads it in `app-utils` and\n * the write-deny guard treats a resolver input as server-owned.\n * Nothing bills, gates or routes on it, and it is validated at every read\n * through `isSupportedTimeZone`, so an unusable value renders as UTC rather\n * than throwing inside a page render.\n */\n timeZone?: string\n /** The workspace URL segment, reserved through `orgSlugs/{slug}`. */\n slug?: string\n /**\n * The owner's uid. Spelled `ownerId` here until AGL-1355 — every reader\n * has always used `ownerUid` (the key `createOrganization` writes and the\n * rules deny), so the declared name was dead.\n */\n ownerUid?: UserUid\n hosts?: Record<HostUid, true>\n /** Subscription tier; missing/unknown plans resolve as `free`. */\n plan?: OrgPlan\n /** Per-org entitlement overrides (admin console); win over plan defaults. */\n entitlements?: OrgEntitlements\n /**\n * Per-org RELEASE-flag overrides (AGL-1635) — a different axis from\n * `entitlements`, which asks what the org's plan includes. These ask\n * whether an unreleased feature is switched on for this one customer, and\n * win over both the Remote Config value and the rollout bucket.\n *\n * Staff-only, and deliberately narrower than `entitlements`: super staff\n * alone may write it, matching the platform-wide flag editor\n * (`/api/admin/flags` is super-only), because forcing a flag on for an org\n * is the same class of act as flipping it for everyone — just scoped.\n * Read through `parseOrgReleaseFlagOverrides`, never directly.\n *\n * Typed loosely on purpose. The keys ARE `ReleaseFlagKey`, but that union\n * lives in `app-utils/release-flags` and foundation cannot import\n * app-utils (see `platform.types.ts`). Declaring the narrow type here\n * would also overstate what is on disk: this map outlives registry\n * renames, so a retired key is a thing that genuinely exists in Firestore.\n * `parseOrgReleaseFlagOverrides` is what narrows it, dropping unknown keys\n * and non-booleans.\n */\n releaseFlags?: Record<string, boolean>\n /**\n * White-label brand identity (White-Label Phase 1). Applied only when the\n * org carries the `whiteLabel` entitlement; read exclusively through\n * `resolveBrandingProfile`, never directly, so no surface diverges.\n */\n brandingProfile?: OrgBrandingProfile\n /**\n * Enterprise SSO config (AGL-1101). Applied only when the org carries the\n * `ssoEnabled` entitlement; the console routes sign-in through the org's\n * GCIP tenant/provider named here. See `OrgSsoConfig`.\n */\n sso?: OrgSsoConfig\n /** Per-org plugin switchboard (AGL-416); see plugin-manager/enabled-plugins. */\n enabledPlugins?: string[]\n /** Purchased addon seats (AGL-112); billed monthly per seat. */\n seatAddons?: OrgSeatAddons\n /**\n * POS register seats assigned out of the org pool (AGL-1775). An\n * ENTITLEMENT INPUT: it raises a per-site cap, so it is Admin-SDK-only.\n * @see OrgRegisterAllocations\n */\n registerAllocations?: OrgRegisterAllocations\n /**\n * Per-site collaborator seats assigned out of the org pool (AGL-2439). An\n * ENTITLEMENT INPUT: it raises a per-site cap, so it is Admin-SDK-only.\n * @see OrgCollaboratorAllocations\n */\n collaboratorAllocations?: OrgCollaboratorAllocations\n /**\n * The free plan's engaged BANDWIDTH CAP (AGL-1967/2070/2155), denormalized\n * onto this doc by the `usage-alerts` cron so the serving path can refuse a\n * capped site without a read of its own.\n *\n * An ENTITLEMENT INPUT in the strongest sense: it is the only thing standing\n * between a free site that has blown its band and unmetered egress, so a\n * client-writable value would let an org admin lift their own cap by\n * deleting one field. Admin-SDK only; denied in the rules.\n *\n * Read exclusively through `bandwidthCapEngaged` (`app-utils/bandwidth-cap`),\n * never directly — the marker on its own is not the answer. The resolver\n * re-derives the plan on every read, which is what lets an org that upgrades\n * mid-month start serving again without waiting for a cron to clear it.\n */\n bandwidthCap?: OrgBandwidthCap\n /**\n * The org's assist hard-cap switch (AGL-2653) — see `OrgAssistOverage`.\n * Admin-SDK-only, denied in the rules beside `storageOverage`; read\n * through `resolveAssistHardCap`.\n */\n assistOverage?: OrgAssistOverage\n stripeCustomerId?: string\n subscription?: OrgSubscription\n /**\n * Discount on the org's own Aglyn subscription (AGL-1105) — staff-applied\n * enterprise deal or a redeemed coupon. `orgMonthlyRevenueUsd` subtracts it\n * for net-of-discount MRR; `orgListPriceMonthlyUsd` ignores it (list price).\n */\n discount?: OrgDiscount\n /**\n * Explicit **comped enterprise** marker (AGL-1110). Makes the org read as\n * \"Enterprise\" (via `isEnterpriseOrg`) without a negotiated custom price —\n * for internal/dogfood accounts (e.g. Aglyn's own org) that carry full\n * Enterprise capability + SSO but are 100%-discounted, so they collect $0\n * while infra cost is still metered. Staff-set; distinct from a paying\n * custom-priced enterprise, which qualifies via `subscription.customMonthlyUsd`.\n */\n enterprise?: boolean\n /**\n * The bare `subscription.status` word the Stripe webhook mirrors back onto\n * this doc for the AGL-275 dunning banner and `resolveEffectivePlan`\n * (AGL-1028). An ENTITLEMENT INPUT: a dead subscription downgrades a paid\n * plan to free, so a client-writable value would restore the plan.\n */\n billingStatus?: string\n /** Staff suspension (AGL-202): set = all the org's sites serve 503. */\n suspendedAt?: ITimestamp | null\n suspendedReason?: string\n /**\n * GDPR erasure request (AGL-206): hard deletion happens ONLY via `eraseOrg`\n * after a 7-day hold from this stamp — reached by the\n * `/api/admin/run-erasures` cron, or by hand with\n * tools/scripts/erase-tenant.mjs, which calls the same function (AGL-1481).\n */\n erasureRequestedAt?: ITimestamp | null\n /**\n * The CRM's organization-wide settings (AGL-2613), written from\n * CRM → Settings by an owner or admin with the client SDK — see\n * `ORG_CLIENT_WRITABLE_FIELDS`. One map rather than a key per setting, so\n * the section can grow without a rules change each time.\n */\n crm?: OrgCrmSettings\n createdAt?: ITimestamp\n updatedAt?: ITimestamp\n /**\n * The workspace's email capture address (AGL-2657) — see `OrgCrmInbound`.\n * Server-owned and member-readable: the token IS the address, so a client\n * that could write it could point another workspace's mail here.\n */\n crmInbound?: OrgCrmInbound\n}\n\n/**\n * What the CRM lets an organization decide for every site at once\n * (AGL-2613). Every key is optional and off when absent: a setting that\n * has never been touched behaves as the product did before it existed.\n */\nexport interface OrgCrmSettings {\n /**\n * Create a company from a captured contact's work email domain when no\n * company the capturing site can see carries that domain. Off by default:\n * a company minted from every domain that ever submitted a form is a list\n * nobody asked for. Public mailbox domains never qualify either way.\n */\n autoCreateCompanies?: boolean\n /**\n * Per-site settings, keyed by host id (AGL-2618). On the ORG document\n * rather than on each host document because the reader is the org-level\n * assignment pass — one read of one document answers every site's default\n * — and because the writer is the same owner-or-admin the rest of this\n * map admits, through the same client branch. A host document would need\n * its own rules clause for a key only the CRM reads.\n */\n hosts?: Record<string, OrgCrmHostSettings>\n /**\n * The assignment rules, in the order they are tried (AGL-2618). The first\n * rule whose every condition holds names the owner; the site's default\n * owner is the fallback when none does. Bounded by\n * `CRM_ASSIGNMENT_RULES_MAX` in the section that writes it.\n */\n assignmentRules?: OrgCrmAssignmentRule[]\n /** The round-robin pool and its pointer — see `OrgCrmRoundRobin`. */\n roundRobin?: OrgCrmRoundRobin\n}\n\n/** What one site decides for records captured on it (AGL-2618). */\nexport interface OrgCrmHostSettings {\n /**\n * The member every record captured on this site is handed to when no\n * assignment rule claimed it. Absent: the record stays unassigned, which\n * is what the product did before the setting existed.\n */\n defaultOwnerUid?: string\n}\n\n/**\n * The workspace's email capture address, as the org document carries it\n * (AGL-2657): `crm+<token>@<capture domain>`.\n *\n * One token per organization, minted lazily by `crm/inbound-address` the\n * first time a member asks for the address, and replaced by the same route\n * on a rotation. Every site of the organization shares it: a captured\n * message is filed on the record that matches its correspondent, and the\n * record says which site it belongs to, so the address need not.\n */\nexport interface OrgCrmInbound {\n /** The url-safe secret in the local part; `crm+<token>@…`. */\n token: string\n /** When the first token was minted, epoch ms. */\n createdAtMs: number\n /** When the token was last replaced, epoch ms; absent until it has been. */\n rotatedAtMs?: number\n}\n\n/**\n * One assignment rule (AGL-2618): `when` every named condition holds for a\n * capture, `assign` the record this way. A condition left out is not a\n * condition — a rule naming only a source matches every capture from that\n * source — and a rule naming nothing matches every capture, which is how a\n * catch-all is written. `source` is a `ContactSource`, typed as a string\n * here because the foundation cannot import the capture vocabulary; the CRM\n * module narrows it.\n */\nexport interface OrgCrmAssignmentRule {\n id: string\n when: {\n source?: string\n formId?: string\n emailDomain?: string\n tag?: string\n }\n assign: { memberUid: string } | { roundRobin: true }\n}\n\n/**\n * The round-robin pool (AGL-2618): the members handed records in turn, and\n * WHO GOT THE LAST ONE. The pointer is a uid rather than an index so that\n * editing the pool — a member added, removed or moved — never skips or\n * repeats anybody: the next record goes to whoever follows the last\n * recipient in the pool as it stands, and to the first member when the last\n * recipient is no longer in it. Advanced only by the server, inside the\n * transaction that writes the owner, so two captures landing together take\n * two different members.\n */\nexport interface OrgCrmRoundRobin {\n memberUids?: string[]\n lastAssignedUid?: string\n}\n\n/**\n * WHO OWNS EACH FIELD OF `orgs/{orgId}` (AGL-1355).\n *\n * AGL-1354 found four server-owned keys — `brandingProfile`, `sso`,\n * `discount`, `enterprise` — that the Firestore rules' org-update key diff\n * had drifted past, so an org admin holding nothing but the Firebase client\n * SDK could write them on any plan. The keys are closed. The MECHANISM that\n * opened them was a hand-maintained deny-list with nothing checking it, and\n * that is what these two maps close.\n *\n * The rule is DEFAULT-DENY, and it is enforced from the interface above:\n * `org-write-deny-coverage.spec.ts` reads every field declared on\n * `AglynOrgBilling`, and one that appears in neither the rules' deny-list nor\n * one of these maps FAILS THE BUILD, naming the field. Adding a field to the\n * interface therefore forces the ownership decision here, at the declaration,\n * on the same commit — instead of shipping client-writable and staying that\n * way until the next audit.\n *\n * So: add a field above, and either add it to the `hasAny([...])` list in\n * `cloud/firebase-firestore.rules` under `match /orgs/{orgId}` (server-owned:\n * anything an entitlement, a price, a routing decision or a staff judgement\n * reads) or add it below WITH A REASON. When in doubt, deny it — a field the\n * server writes through an Admin-SDK route loses nothing by being denied to\n * the client, and that is true of every key AGL-1354 closed.\n *\n * The entries are the fields an org admin may set from the client SDK. The\n * reason is mandatory and is the whole value of the map: it records that\n * someone decided, rather than that someone forgot.\n */\nexport const ORG_CLIENT_WRITABLE_FIELDS: Readonly<Record<string, string>> = {\n name:\n 'The workspace name. Deliberately writable by an org admin — the rules ' +\n 'admit the rename branch and `firestore-rules.test.mjs` asserts it still ' +\n 'succeeds. Cosmetic: no entitlement, price or routing decision reads it. ' +\n '(The console renames through /api/orgs/settings anyway, because the name ' +\n 'is denormalized onto every membership row and the fan-out is a server ' +\n 'job — but the client write is allowed and must stay allowed.)',\n description:\n 'Free-text workspace blurb. Nothing resolves, gates or bills on it, and ' +\n 'no server route owns it.',\n createdAt:\n 'Creation stamp seeded by `createOrganization`. Nothing gates on it; the ' +\n 'staff org list only displays it. Denying it would buy nothing and would ' +\n 'break the merge-writes below, which stamp the pair together.',\n updatedAt:\n 'Last-write stamp. Every client write that IS allowed sets it in the same ' +\n '`setDoc(..., { merge: true })` — the staff suspension, erasure-request ' +\n 'and plan-override cards all do — so denying it would deny those writes.',\n crm:\n 'The CRM settings map (AGL-2613): `autoCreateCompanies`, the per-site ' +\n 'default owner, the assignment rules and the round-robin pool ' +\n '(AGL-2618), and whatever the CRM → Settings section adds beside them. ' +\n 'Deliberately writable by an org owner or admin — the section writes it ' +\n 'client-direct by dotted path. No entitlement, price, routing decision ' +\n 'or staff judgement reads it; its readers are the contact capture door ' +\n 'deciding whether to mint a company record from an email domain and ' +\n 'whom to hand a new record to, which are choices about the org\\'s own ' +\n \"data that the org's managers are the right people to make. The pool's \" +\n 'pointer (`crm.roundRobin.lastAssignedUid`) is advanced by the server ' +\n 'inside the assigning transaction; a client that moved it would only ' +\n 'change who is next, never what anybody may see.',\n}\n\n/**\n * Fields declared on `AglynOrgBilling` that are NOT stored on the document,\n * and so cannot be written by anyone. Separate from the map above because\n * \"the client may set this\" and \"this is not a field\" are different\n * statements, and collapsing them would let a genuinely client-writable field\n * hide behind a synthetic one.\n */\nexport const ORG_UNPERSISTED_FIELDS: Readonly<Record<string, string>> = {\n $id: 'The document id, injected by the reader. Never written as a field.',\n}\n\n"],"names":["ORG_CLIENT_WRITABLE_FIELDS","name","description","createdAt","updatedAt","crm","ORG_UNPERSISTED_FIELDS","$id"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GAg2CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMA,6BAA+D;IAC1EC,MACE,2EACA,6EACA,6EACA,8EACA,2EACA;IACFC,aACE,4EACA;IACFC,WACE,6EACA,6EACA;IACFC,WACE,8EACA,4EACA;IACFC,KACE,0EACA,kEACA,2EACA,4EACA,2EACA,2EACA,wEACA,0EACA,2EACA,0EACA,yEACA;AACJ,EAAC;AAED;;;;;;CAMC,GACD,OAAO,MAAMC,yBAA2D;IACtEC,KAAK;AACP,EAAC"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/org-billing.types.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 org's billing/entitlement vocabulary (AGL-443 naming cleanup).\n *\n * HISTORY: these types were spelled `Tenant*` until AGL-444 — they\n * predate the organizations migration (AGL-232..238); the retired\n * `tenants/{uid}` collection's billing shape was mirrored ONTO the org\n * doc and the names came along. The alias is gone: everything here is\n * `Org*` and describes fields of `orgs/{orgId}`. The last persisted\n * tenant spellings (the `users.{uid}.tenants` map, Stripe\n * `metadata[tenantId]`, `host.tenantId`) were retired pre-launch in\n * AGL-445 — billing keys off `metadata[orgId]` now.\n *\n * Convention (see the docs-site glossary): \"organization/org\" is the\n * entity; \"workspace\" is the user-facing word for it; \"tenant\" is\n * reserved for the published-site runtime (`apps/tenant`,\n * `@aglyn/tenant-*` libs).\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n UserUid,\n} from './platform.types'\n/*\n * The plugin half of the two composed shapes below (AGL-3124).\n *\n * Each is an EMPTY interface a plugin augments with `declare module`, so the\n * keys a plugin owns are part of `OrgEntitlements` and `OrgFeatureFlags`\n * without the core listing them. Nothing composes today — the interfaces are\n * empty until a plugin declares into them — so this changes no shape at this\n * sha; what it changes is where the NEXT key is declared.\n *\n * `import type` and nothing else: TypeScript erases it, so the plugin-manager\n * module is not on this module's runtime graph and a published page that\n * reaches the billing types (through the foundation barrel it must not reach\n * anyway) carries nothing new.\n */\nimport type {\n PluginEntitlementFeatures,\n PluginEntitlementQuotas,\n} from '../../plugin-manager/plugin-entitlement-keys'\n\nexport type { OrgUid } from './platform.types'\n\n\n/** Hosted in master catalog */\n/**\n * SaaS subscription tiers (Tenant Billing & SaaS Plans, AGL-38..41).\n * Pricing v3 (2026-07) inserted `scale` between business and advanced to\n * fill the $139→$399 gap, and added `agency` above advanced for\n * high-volume multi-site orgs — see the Pricing Decision Log.\n *\n * `enterprise` (AGL-1118) is a REAL plan, not a display label: it tops the\n * ladder with unlimited capacity plus white-label and SSO, and it is the ONE\n * tier with no list price — it is staff-provisioned per deal (AGL-1110), never\n * self-serve. Surfaces that offer plans for sale iterate `SELF_SERVE_PLANS`,\n * which excludes it; surfaces that merely NAME the org's plan read\n * `PLAN_LABELS` and get \"Enterprise\" for free.\n */\nexport type OrgPlan =\n | 'free'\n | 'starter'\n | 'pro'\n | 'business'\n | 'scale'\n | 'advanced'\n | 'agency'\n | 'enterprise'\n\n/**\n * The CORE boolean feature gates per plan; quotas live beside them as\n * numbers. Read {@link OrgFeatureFlags} instead — this half exists so the\n * plan tables can stay exhaustive over the platform's own gates while a\n * plugin declares its own (AGL-3124).\n */\nexport interface CoreOrgFeatureFlags {\n /** A/B experiments (AGL-252); Business tier. */\n abTesting?: boolean\n versioning?: boolean\n reusableComponents?: boolean\n customDomain?: boolean\n /**\n * Send mail as a domain the CUSTOMER owns — `hello@acme.com` — by publishing\n * our SPF, DKIM and return-path records in their own zone.\n *\n * Distinct from `customDomain`, which is the site's public web address and\n * authorizes nothing about mail; and distinct from `whiteLabel`, which\n * replaces the Aglyn brand — product name, logo, colors, support URL,\n * console chrome, favicon — across every branded surface. Sending as your\n * own name is one narrow consequence of white-labeling rather than the\n * whole of it, so the two cannot share a flag without the wider capability\n * following the narrower one wherever it goes.\n *\n * It is also the CHEAP half of the sending model, which is why it sits low\n * on the ladder. A customer-owned domain costs the platform one provider\n * domain object and NOTHING in our own DNS zone, because the customer\n * publishes the records; the platform subdomain a site is issued costs a\n * provider slot, three records in our zone and a permanent place in the\n * re-verification sweep. Holding the free-to-us option behind the highest\n * paywall makes the expensive one the default at every tier that can send.\n *\n * Read through `checkEntitlement` at the two routes that write a sending\n * identity: the org's domain list, and the per-site selection.\n */\n customSendingDomain?: boolean\n /**\n * Hold a sending subdomain of the PLATFORM's own —\n * `hello@{label}.mail.aglyn.app` — provisioned for one site inside Aglyn's\n * mail apex.\n *\n * The EXPENSIVE half of the sending model, and the reason it is a separate\n * flag from `customSendingDomain`. A domain the customer owns costs one\n * provider domain object and nothing in our zone, because they publish the\n * records. One of these costs a provider slot, THREE records in our own\n * zone, and a permanent place in the re-verification sweep — per site,\n * forever. The two capabilities are decided against different resources, so\n * a shared flag would move a bounded one whenever the unbounded one is\n * re-cut.\n *\n * The flag is the SECOND of two conditions and never the only one. Nothing\n * provisions on entitlement alone: a merchant asks from the sending-identity\n * route, this says whether the org carries what they asked for, and the\n * provider ceiling still refuses beyond the account's allowance. A site\n * refused at any of the three sends on the shared pool rather than not at\n * all.\n *\n * Read through `checkEntitlement` at\n * `libs/aglyn/src/lib/app-utils/dedicated-sending-domain.ts`, which is the\n * only reader — the claim path in `host-sending-domain.ts` goes through it.\n */\n dedicatedSendingDomain?: boolean\n removeBranding?: boolean\n /** Schedule a version to publish at a date/time (tier above versioning). */\n scheduledPublishing?: boolean\n /** Sell listings on the marketplace marketplace (AGL-46). */\n marketplaceSelling?: boolean\n /** AI copy assist in the besigner (AGL-89). */\n aiAssist?: boolean\n /**\n * Generative building and automation (AGL-2896): pages, components,\n * layouts, templates, SEO, email, campaigns, A/B variants, analytics\n * insights, products, CRM records, onboarding and workflows produced by\n * the assistant rather than assembled by hand.\n *\n * Distinct from `aiAssist`, which is the chat guide, copy assist and\n * generate-section rung every tier from Pro up carries. This is the\n * expensive rung: a generated surface carries the node tree, the catalog\n * and the theme in and iterates, so it draws credits by the hundreds where\n * a question draws tens. No self-serve tier includes it; the Aglyn AI\n * add-on (`OrgSeatAddons.aiAddon`) switches it on and adds the credit band\n * that funds it, and `resolveOrgEntitlements` flips this flag together\n * with `aiAssist` when the add-on is present. Enterprise carries it in the\n * agreement.\n */\n aiGenerative?: boolean\n /** No-code workflow builder (AGL-101). */\n workflows?: boolean\n /** Datasets + repeatable components (AGL-102/103). */\n dataStore?: boolean\n /** Video/file uploads in the media manager (AGL-162). */\n videoMedia?: boolean\n /** Appointment bookings (AGL-159). */\n bookings?: boolean\n /**\n * THE CRM (AGL-2611): contacts, leads, companies, the deals pipeline,\n * tasks, reports and custom fields, the CRM automation steps, and the\n * `crm:*` REST resources.\n *\n * The Contacts section included (AGL-2851). Capture still writes a site's\n * audience into the contacts collection on every plan including Free,\n * banded by `contactsPerHost`, and exporting or erasing those people stays\n * on every plan in Settings → Privacy. What this flag gates is the CRM a\n * team works them in, and it is the upgrade motive from Free to the first\n * paid tier rather than a line on top of one: for the small-business buyer\n * the CRM is the reason to pick a platform over a page builder.\n *\n * Read by the console shell through the CRM extension's and its widgets'\n * `featureFlag` (so the shell refuses every CRM section and page before\n * one mounts), by the `crm/*` routes, by the automation executor before a\n * CRM step runs, by the REST dispatcher in front of the CRM resources,\n * contacts included, and, restated, by the Firestore rules on a client's\n * CRM writes. A per-org override on `entitlements.features.crm` works the\n * way every other flag's does.\n */\n crm?: boolean\n /**\n * SEQUENCES (AGL-2974): one-to-one, multi-step email sequences a rep sends\n * from their own connected mailbox, logged on the CRM's records. The key\n * below keeps the name the feature shipped under, because it is stored on\n * the org (AGL-3199).\n *\n * False on EVERY plan, Enterprise included. Which tiers carry sequences\n * and connected mailboxes, and at what caps, is a packaging decision that\n * has not been made, so no plan may claim it. An organization reaches it\n * only through the per-org override on `entitlements.features.outreach`.\n *\n * Read by the console shell through the Sequences extension's\n * `featureFlag`, by the plugin's routes, and, restated, by the Firestore\n * rules in front of the `outreach*` collections. The rules carry no plan\n * list for it because no plan grants it; when packaging lands, the plan\n * table and those rules change together.\n */\n outreach?: boolean\n /**\n * Basic presentational interactions (AGL-577): menu/drawer open-close,\n * element show/hide, class toggles, sticky nav, navigation, site\n * alerts. Included on ALL plans — pure client-side DOM with no server\n * cost. The `actions` flag below gates the powerful automation steps\n * (server dispatch, runJs, analytics, overlays, raw HTML).\n *\n * `true` on all eight tiers, and NO code gates on it — which makes it look\n * dead, and it has now been filed as dead once (AGL-2082). It is not.\n * `tools/marketing/build-pricing-tables.mts` reads its value to emit the\n * \"Interactions\" row on the public /pricing compare table, ticked on every\n * plan, and the Free plan card bullets it. That row is the claim that a\n * hover-to-open menu is not a paid feature — a real competitive statement,\n * and the reason `true` everywhere is a DECISION rather than a default.\n *\n * So: do not delete it as dead weight. Deleting it silently removes a\n * public pricing row while the Figma frames still carry it in all four\n * responsive variants, which is a pricing call and not a cleanup.\n */\n interactions?: boolean\n /** Event → action automation builder (AGL-148). */\n actions?: boolean\n /** Outbound/inbound webhooks (AGL-149). */\n webhooks?: boolean\n /** Customer REST API v1 + API keys (AGL-615); Business tier. */\n apiAccess?: boolean\n /** Whole-site export/backup + restore (AGL-163). */\n siteExport?: boolean\n /** Multilingual sites (AGL-164): locale variants + switcher. */\n multilingual?: boolean\n /** Event Calendar add-on (AGL-145); paid, not part of any base tier. */\n eventCalendar?: boolean\n /** URL redirects manager (AGL-154). */\n redirects?: boolean\n /** Per-screen traffic analytics (AGL-150). */\n screenAnalytics?: boolean\n /** CDN delivery + responsive image variants for media (AGL-175). */\n mediaCdn?: boolean\n /** Announcement bar + promotional popups (AGL-195/196). */\n marketingOverlays?: boolean\n /** Full storefront commerce: catalog, cart, checkout (AGL-278). */\n commerce?: boolean\n /** Console point-of-sale mode (AGL-312). */\n pos?: boolean\n /** Recurring storefront subscription products (AGL-303). */\n storefrontSubscriptions?: boolean\n /** Entitlement-gated screens/sections/video paywalls (AGL-309). */\n contentGating?: boolean\n /** Gift cards & store credit (AGL-322). */\n giftCards?: boolean\n /** Verified-buyer product reviews (AGL-324). */\n productReviews?: boolean\n /** Abandoned checkout recovery emails (AGL-323). */\n abandonedCart?: boolean\n /** Dropship supplier routing on paid orders (AGL-289). */\n dropshipRouting?: boolean\n /** Commerce analytics dashboard (AGL-327). */\n commerceAnalytics?: boolean\n /**\n * White-label the platform (White-Label Phase 1): replace the Aglyn brand\n * — product name, logo, colors, support URL, transactional email from-name\n * — with the org's own `brandingProfile` across every branded surface.\n * Agency ($799) and Enterprise carry it on the plan (AGL-1118); any other\n * tier needs a per-org `entitlements` override. Strictly broader than\n * `removeBranding`, which only drops the\n * \"Made with Aglyn\" badge on published sites; white-label REPLACES the\n * brand rather than merely hiding it. Every branded surface resolves the\n * effective brand through `resolveBrandingProfile` so it can never drift.\n */\n whiteLabel?: boolean\n /**\n * Enterprise SSO (AGL-1101): the org's console users sign in through the\n * org's own SAML/OIDC IdP, wired as a per-org GCIP tenant (`org.sso`).\n * Distinct from `whiteLabel` — an Agency org can have one without the other.\n * Carried by the `enterprise` plan (AGL-1118) and false on every other base\n * plan; a lower tier needs a per-org `entitlements` override, which is how\n * enterprise orgs provisioned before that plan existed still get it. Gates\n * the staff SSO-config card and the SSO sign-in path; a non-entitled org can\n * neither configure nor use SSO.\n */\n ssoEnabled?: boolean\n}\n\n/**\n * Boolean feature gates per plan, the platform's and its plugins' together.\n *\n * {@link CoreOrgFeatureFlags} is the platform's own set — what\n * `PLAN_ENTITLEMENTS` declares for every plan, and what\n * `Required<CoreOrgFeatureFlags>` keeps exhaustive.\n * {@link PluginEntitlementFeatures} is the plugin half (AGL-3124): empty\n * until a plugin declares a gate of its own into it with `declare module`,\n * and resolved from that plugin's `registerPluginEntitlements` defaults\n * rather than from a row in a core plan table — which is why a plugin's gate\n * joins HERE and not above. Every reader keeps reading `OrgFeatureFlags`, and\n * `keyof OrgFeatureFlags` admits a plugin's key the moment it is declared.\n */\nexport interface OrgFeatureFlags\n extends CoreOrgFeatureFlags,\n PluginEntitlementFeatures {}\n\n/**\n * An org's white-label brand identity (White-Label Phase 1). Populated on\n * the org doc (`orgs/{orgId}.brandingProfile`) and applied ONLY when the org\n * carries the `whiteLabel` entitlement (Agency or Enterprise plan, or a\n * per-org override);\n * otherwise every surface falls back to the Aglyn defaults baked into\n * `resolveBrandingProfile`. Every field is optional — a partial profile\n * still resolves, with the Aglyn default filling each gap — so an agency can\n * set just a product name and from-name without supplying logos.\n */\nexport interface OrgBrandingProfile {\n /** Brand name shown in place of \"Aglyn\" (console chrome, emails, badges). */\n productName?: string\n /** Primary/full-color logo URL (light backgrounds, console chrome). */\n logoUrl?: string\n /** Favicon URL for branded surfaces. */\n faviconUrl?: string\n /** Brand primary color as a CSS color (hex), e.g. `#1a73e8`. */\n primaryColor?: string\n /** Support/help destination linked from branded surfaces and emails. */\n supportUrl?: string\n /** Transactional email from-name (the display name before the address). */\n fromName?: string\n /** Logo URL specifically for the email header (often a hosted PNG). */\n emailLogoUrl?: string\n /** Custom console domain the agency serves the app on (Phase 4 wiring). */\n customConsoleDomain?: string\n}\n\n/**\n * An org's enterprise SSO configuration (AGL-1101, Phase 1). Applied ONLY when\n * the org carries the `ssoEnabled` entitlement. The actual IdP lives in a\n * per-org **GCIP tenant** (`tenantId`) with a SAML/OIDC **provider**\n * (`providerId`); this block is the org-doc mirror the console reads to route\n * sign-in and the staff card edits. A public `ssoDomains/{domain}` doc maps a\n * verified email domain → `{ orgId, tenantId, providerId }` so the\n * pre-auth sign-in page can resolve an SSO org without reading the org doc.\n *\n * Security: a domain reaches `domains[]` ONLY by passing DNS TXT verification\n * (AGL-1210). This is the account-takeover guard, and it is the whole reason\n * self-serve is safe: without it an org could claim a domain it does not own,\n * `ssoDomains/{domain}` would route that domain's sign-ins to its IdP, and it\n * would intercept another company's logins. Claims in flight live in\n * `orgs/{orgId}/ssoDomains/{domain}` (see `OrgSsoDomainClaim`) and are NOT\n * governed until verified.\n *\n * `enforced` blocks password/social login for the governed domains, so it is a\n * lockout risk and is rehearsed (`previewSsoEnforcement`) before it is applied.\n */\nexport interface OrgSsoConfig {\n /** GCIP tenant id that carries this org's IdP provider. */\n tenantId: string\n /** GCIP provider id, e.g. `saml.aglyn-workspace` or `oidc.acme`. */\n providerId: string\n /** IdP protocol. Phase 1 ships SAML; OIDC is Phase 2. */\n protocol: 'saml' | 'oidc'\n /** Human label for the IdP (shown on the SSO button + staff card). */\n displayName?: string\n /**\n * Email domains routed to this IdP (lowercased, no `@`). A domain is added\n * here only after its claim passes DNS TXT verification, and removed the\n * moment re-verification fails — so membership of this array IS the\n * \"ownership proven\" statement. Never write to it from anywhere but the\n * verification path.\n */\n domains: string[]\n /**\n * Retained for `sso-jit`, which gates on it. Kept in lockstep with\n * `domains.length > 0` by the verification path; it was a staff-attested\n * boolean before AGL-1210 and is now derived, never asserted by a human.\n */\n domainVerified: boolean\n /**\n * SAML metadata the CUSTOMER supplies about their IdP. Stored so the pool's\n * provider config can be rebuilt or re-applied without asking again. The\n * X.509 certificate is a public signing certificate, not a secret.\n */\n idp?: {\n entityId: string\n ssoUrl: string\n certificates: string[]\n }\n /**\n * Require SSO for the governed domains — disables password/social login for\n * them (Phase 2). Phase 1 keeps this false so users keep a fallback.\n */\n enforced: boolean\n /** Lifecycle: `configuring` (not live), `active`, or `disabled`. */\n status: 'configuring' | 'active' | 'disabled'\n /**\n * Uid + time of the last config change. Since AGL-1210 this is normally an\n * ORG ADMIN, not staff — the flow is self-serve end to end.\n */\n configuredBy?: string\n configuredAt?: ITimestamp\n}\n\n/**\n * A pending or proven claim on one email domain (AGL-1210), stored at\n * `orgs/{orgId}/ssoDomains/{domain}`.\n *\n * A subcollection rather than a map on the org doc because domains contain\n * dots, and a dotted key in a Firestore map is read as a nested field path by\n * every update helper — writing `sso.domainClaims[\"acme.com\"]` would silently\n * create `{acme: {com: …}}`. A document id has no such ambiguity.\n *\n * The claim is deliberately worthless on its own: holding one grants nothing.\n * Only `verified` moving to true adds the domain to `sso.domains`, and only\n * that array is consulted at sign-in.\n */\nexport interface OrgSsoDomainClaim {\n /** The domain being claimed (lowercased, no `@`); mirrors the document id. */\n domain: string\n /**\n * Random value the org must publish as a DNS TXT record at\n * `_aglyn-challenge.<domain>`. Per org+domain, so two orgs claiming the same\n * domain get different tokens and neither can pass on the other's record.\n */\n token: string\n /** True once a DNS lookup has actually seen `token` at the challenge host. */\n verified: boolean\n createdAt: ITimestamp\n verifiedAt?: ITimestamp\n /** Last lookup attempt, successful or not — drives re-verification. */\n lastCheckedAt?: ITimestamp\n /**\n * TXT records seen on the last FAILED lookup. Shown back to the customer,\n * because \"no record found\" and \"found the wrong value\" are different\n * mistakes and the fix differs.\n */\n lastRecords?: string[]\n}\n\n/**\n * Effective limits/gates for a tenant. Plan defaults come from\n * `PLAN_ENTITLEMENTS` (versioned with the app); per-tenant overrides can be\n * stored on the tenant doc and win over the plan defaults.\n *\n * The CORE half. Read {@link OrgEntitlements} instead: it is this plus\n * whatever the plugins declare. The keys spelled out below predate the seam\n * and move to their plugins with the rest of AGL-3080; every NEW key is\n * declared by whoever owns it.\n */\nexport interface CoreOrgEntitlements {\n hostLimit?: number\n screensPerHost?: number\n sharedLayoutsPerHost?: number\n /** Saved templates per host (AGL-666) — includes marketplace downloads. */\n templatesPerHost?: number\n // NOTE (AGL-658): \"add-on\" means two unrelated things in this codebase.\n // `seatAddons` below are BILLING capacity — extra managers, hosts, seats —\n // surfaced in the UI as \"plan add-ons\". The marketplace sense (installed\n // plugins, the `orgAddons` slot) is a different concept entirely. The\n // marketplace owns the bare word; billing copy always qualifies it.\n // Firestore and Stripe lookup keys stay as they are — they are persisted.\n storagePerHostMb?: number\n /**\n * RETIRED (AGL-2133). Enforced by nothing and unreachable by measurement —\n * see `RETIRED_ENTITLEMENT_KEYS`. It stays on the TYPE because live org\n * documents still carry staff overrides of it and `OrgEntitlements` is the\n * shape those documents are read through; `resolveOrgEntitlements` drops\n * it, and no plan declares it any more, so nothing can resolve a value.\n * @deprecated\n */\n totalSiteSizeMb?: number\n /**\n * Included per-site COLLABORATOR seats (`hosts/{id}/members`,\n * viewer/editor/admin) — console teammates scoped to one site, not\n * end-user member accounts (`siteMembers`), which are unlimited on\n * every plan (AGL-888/889). Legacy key name; persisted, do not rename.\n */\n membersPerHost?: number\n /** Seat model (AGL-112): included tenant-manager seats. */\n managersPerOrg?: number\n /** Hard seat caps incl. purchased addons; beyond these, upgrade the plan. */\n maxManagersPerOrg?: number\n /** Hard per-site collaborator cap incl. addons (see `membersPerHost`). */\n maxMembersPerHost?: number\n bandwidthGb?: number\n /** Form submissions accepted per calendar month (Forms & Lead Capture). */\n formSubmissionsPerMonth?: number\n /**\n * Saved form DEFINITIONS per host — documents under\n * `hosts/{hostId}/forms`, the entity a submission's `formId` points at.\n *\n * Not the same dimension as `formSubmissionsPerMonth`, and the two are\n * routinely confused: this is how many distinct intake forms an author may\n * build, that is how many replies the site may accept. Only the second is\n * tiered. This one resolves to the same `FORMS_PER_HOST_CEILING` on every\n * plan that has the form entity at all — it bounds a collection rather than\n * selling a tier — and exists as an entitlement so that `checkQuota` can\n * refuse a create, and so a contract can override one org's number.\n *\n * Counts the CATALOG, never the traffic. A `Form` node drawn on a page and\n * left unbound submits without a definition and spends nothing here, which\n * is why a plan resolving to 0 still accepts submissions.\n */\n formsPerHost?: number\n /** Component-builder caps (AGL-99): host variables. */\n variablesPerHost?: number\n /** Component-builder caps (AGL-99): host functions. */\n functionsPerHost?: number\n /** Workflow builder cap (AGL-99/101). */\n workflowsPerHost?: number\n /** Event-triggered workflow runs per calendar month (AGL-165). */\n workflowRunsPerMonth?: number\n /** Bookable services per host (AGL-159). */\n servicesPerHost?: number\n /** Redirect rules per host (AGL-154). */\n redirectsPerHost?: number\n /**\n * The CRM RECORDS band (AGL-197, widened in AGL-2611): contacts, companies\n * and deals, counted together across the org. The persisted key still says\n * \"contacts\" because it is written on live org documents as a staff\n * override and read back through this type; every customer surface says\n * \"CRM records\". A tier with an `extraContactsUsdPer1k` rate meters past\n * it; a tier with none refuses the next record. Free has no CRM\n * (AGL-2851), so on Free the record its band refuses is a capture. Tasks\n * and activities are not counted — see `CRM_ACTIVITIES_PER_RECORD_CEILING`.\n */\n contactsPerHost?: number\n /**\n * One-to-one emails a workspace may send from CRM records per UTC day —\n * the message a rep writes to one person from their contact page, as\n * against a campaign (`emailSendsPerMonth`) or transactional mail.\n *\n * A HARD cap on every tier and a daily one, because it is the only send\n * class a person can produce by hand at volume: a campaign is one act over\n * a metered audience, and a receipt follows an order somebody paid for,\n * but a rep with a template and a list can put a thousand messages onto\n * the platform's sending reputation in an afternoon. The day boundary is\n * what makes the cap a pace rather than a wall — tomorrow the count is\n * zero again — and the number is sized so that every tier holds its\n * margin with the whole day spent, which is the arithmetic the Drive\n * pricing decision of 2026-09-05 records. `checkCrmEmailQuota` reads it;\n * the counter it is enforced against is `orgs/{orgId}/crmEmailUsage/{day}`.\n *\n * Every send still counts on the org's `emailSends` cost meter, like any\n * other message the provider charged for. 0 on Free, which has no CRM,\n * and finite on every paid tier, Enterprise's 2,000 included.\n *\n * ## This ladder and `emailSendsPerMonth` are NOT the same ladder\n *\n * They answer to different principles, deliberately, and a tier's two\n * numbers are not expected to look like each other. `emailSendsPerMonth`\n * is sized against SHARED-DOMAIN REPUTATION: campaign mail is bulk on a\n * pool every tier without its own verified sending domain rides under\n * `p=reject`, so what one workspace sends is a cost every other workspace\n * pays. This one is sized against COGS SHARE: one-to-one mail is a per-tier\n * margin question, which is why it is a daily pace rather than a monthly\n * band.\n *\n * The two are far apart at the entry tier — a band of 0 beside a real daily\n * allowance — and that reads as an inconsistency until the principles are\n * named. It is not one. One-to-one mail is addressed to a single person,\n * usually inside a relationship the recipient started, and it reaches the\n * provider through a per-user pace limit, both suppression lists, a\n * `declined` consent basis as a hard stop, and an atomic daily reservation.\n * Complaint rates on it are structurally unlike bulk. Reconciling the two\n * ladders would price a reply to one customer as though it carried a\n * campaign's reputation risk.\n */\n crmEmailsPerDay?: number\n /**\n * CAMPAIGN emails sendable per calendar month (AGL-161), and campaign\n * emails only (AGL-1438).\n *\n * Transactional mail — password resets, invites, order confirmations,\n * booking reminders, workflow notifications — is never refused by this cap\n * at any tier. It still COUNTS toward the org's email cost meter; it simply\n * cannot be blocked, because a quota that can drop a password reset locks\n * somebody out of their own account and a dropped order confirmation reads\n * to the buyer as a failed order.\n *\n * The name is the narrow thing on purpose. A cap that means something\n * narrower than it says is how AGL-1438 came to exist, so every surface\n * that shows this number says \"campaign\".\n *\n * Sized against shared-domain reputation, which is a different principle\n * from the one behind `crmEmailsPerDay` — see there for why the two ladders\n * are not meant to track each other.\n */\n emailSendsPerMonth?: number\n /**\n * Aglyn Assist credits per calendar month — the band assist spends against.\n *\n * A CREDIT IS A UNIT OF MODEL COST, not a message. One assist action can\n * cost two orders of magnitude more than another: a question is a few\n * thousand tokens, while generating a screen carries the node tree, the\n * component catalog and the theme tokens in, structured markup out, and\n * iterates. A message allowance would price those the same, so one\n * workspace's ten screen builds would outspend another's thousand questions\n * and both would read as \"within allowance\".\n *\n * The unit is defined by `ASSIST_CREDIT_COST_USD` and the conversion lives\n * in `assist-credits.ts`. Only that module turns credits into dollars;\n * everything a customer sees counts credits, because the dollar figure\n * behind them is our provider bill and not a price.\n *\n * ## HOW THE BAND IS SIZED, and why it is not a share of the price\n *\n * A credit is a dollar of provider spend at `ASSIST_CREDIT_COST_USD`, so\n * the band IS a liability figure: the plan's whole assist give is\n * `assistCreditsPerMonth / 1000` dollars. Credits past it are not a give —\n * since AGL-2653 they are SOLD at `extraAssistCreditsUsdPer1k`, unless the\n * org's `assistOverage.hardCap` asks `reserveAssistMessage` to refuse at\n * the band instead. Sizing it as a share of the subscription price says\n * nothing about whether the tier can afford it, because the price is also\n * carrying storage, bandwidth, form submissions, dataset storage, API\n * requests, contacts and email.\n *\n * So each band is sized against what those SEVEN other terms leave. Every\n * paid band takes between a quarter and a third of that remainder, which\n * keeps the tier's worst case positive and keeps the ladder's shape: the\n * margin each tier holds at 100% of every band stays in proportion to what\n * it held before assist was sold at all. `tier-margin-floor.spec.ts` is the\n * model, and it pins both the rule and the resulting figures.\n *\n * 0 on Free and Starter, which carry no `aiAssist` and no band. The Aglyn\n * AI add-on (AGL-2896) adds `AI_ADDON_CREDITS_PER_MONTH[plan]` to this\n * band in `resolveOrgEntitlements` — one pool, widened, rather than a\n * second meter — so Starter with the add-on carries a band after all.\n */\n assistCreditsPerMonth?: number\n /** Action runs per calendar month (AGL-148). */\n actionRunsPerMonth?: number\n /** Included customer REST API requests per calendar month (AGL-634);\n * Beyond it, metered overage per 1,000 where the plan prices it. Only\n * Business/Advanced carry `apiAccess`, so lower tiers are 0. */\n apiRequestsPerMonth?: number\n /** Dynamic data caps — org-scoped (AGL-239/240): datasets are shared\n * by every host in the org, so counts and size meter per org. */\n datasetsPerOrg?: number\n /** Hard dataset cap incl. addons (AGL-132/240); beyond it, upgrade. */\n maxDatasetsPerOrg?: number\n recordsPerDataset?: number\n /** Included aggregate dataset storage (MB) across the org (AGL-240);\n * Beyond it, metered overage per GB where the plan prices it. */\n dataStorageMbPerOrg?: number\n /** @deprecated Legacy host-keyed override (pre-AGL-240); resolved into\n * `datasetsPerOrg` by `resolveOrgEntitlements`. */\n datasetsPerHost?: number\n /** @deprecated Legacy host-keyed override (pre-AGL-240); resolved into\n * `maxDatasetsPerOrg` by `resolveOrgEntitlements`. */\n maxDatasetsPerHost?: number\n /** Catalog products per host (AGL-278). */\n productsPerHost?: number\n /** Inventory locations per host (AGL-286). */\n inventoryLocations?: number\n /** Concurrent POS registers (AGL-312); add-ons raise it (AGL-329). */\n posRegisters?: number\n /** Platform fee % on physical storefront sales (Connect app fee). */\n transactionFeePhysicalPct?: number\n /** Platform fee % on digital storefront sales (Connect app fee). */\n transactionFeeDigitalPct?: number\n /**\n * Platform take rate % on the org's MARKETPLACE listing sales (AGL-46,\n * resolved from entitlements per AGL-1543): 20 on paid plans, 30 on\n * free. Distinct from the storefront `transactionFee*Pct` above — this\n * is Aglyn's cut of a marketplace sale, priced off the SELLER org.\n */\n marketplaceFeePct?: number\n features?: OrgFeatureFlags\n /**\n * A staff PLAN COMP (AGL-3034) — see `OrgPlanComp`. Not a quota: the\n * resolver's numeric loop skips it, and `ResolvedOrgEntitlements` omits\n * it. Read through `readOrgPlanComp`, never directly.\n */\n planComp?: OrgPlanComp\n}\n\n/**\n * Effective limits and gates for a tenant, the platform's and its plugins'\n * together (AGL-3124).\n *\n * {@link CoreOrgEntitlements} is what `PLAN_ENTITLEMENTS` declares per plan\n * and what `ResolvedOrgEntitlements` keeps exhaustive.\n * {@link PluginEntitlementQuotas} is the plugin half: empty until a plugin\n * declares a key of its own into it with `declare module` and registers it\n * with `registerPluginEntitlementKeys`, and resolved from that plugin's seat\n * add-on declaration rather than from a row in a core plan table. So\n * `resolveOrgEntitlements` carries a plugin's band, and `keyof\n * OrgEntitlements` admits its key, without this file naming the plugin's\n * domain.\n */\nexport interface OrgEntitlements\n extends CoreOrgEntitlements,\n PluginEntitlementQuotas {}\n\n/**\n * A plan staff GRANTED a workspace that no live subscription pays for\n * (AGL-3034), stored at `orgs/{orgId}.entitlements.planComp`.\n *\n * ## Why a marker of its own, and not the stored `plan`\n *\n * `plan: 'pro'` beside `billingStatus: 'canceled'` is what EVERY customer who\n * canceled can look like: Stripe's mirror is not always walked back to\n * `free`, and a staff override used to write `plan` directly. So a stored\n * paid plan must never outrank a dead subscription — `resolveEffectivePlan`\n * reads it as Free, which is AGL-247's rule and stays it. A comp is a\n * different fact with a different writer: only `/api/admin/org-override`\n * writes this, audited, with a reason. Nothing reads a comp out of `plan`.\n *\n * ## When it applies\n *\n * Only while the subscription is dead or absent — `resolvePlanComp`. A live\n * subscription (and any status the resolver does not read as dead) governs\n * the plan, and the comp waits, dormant, until staff remove it or the\n * subscription ends.\n *\n * ## Why under `entitlements`\n *\n * It sits beside the other staff overrides, on the one map the override\n * route already owns and the rules already deny to every client, staff\n * included (AGL-1795). A top-level key would have needed a new entry in all\n * three of the org rule's deny-lists, in a rules file at its size limit.\n *\n * ## What it is NOT\n *\n * Not a sale. A comp bills nothing, so every metered band on it is a wall —\n * `resolvePlanPricing` — and it is never a card on file or a paying\n * subscription: `isBillingSubscription` stays false, and the AI overage\n * charge path has no rate to bill it at.\n *\n * ## Capped or uncapped (AGL-3049)\n *\n * A comp is CAPPED unless staff lift it: every band is its plan's figure, a\n * hard limit, and a per-org quota override beside the comp raises one band.\n * An UNCAPPED comp (`uncapped: true`) reads every band and quota as\n * unlimited while it is in force — the shape an internal workspace needs —\n * and still bills nothing. The flag is part of the grant: the same route\n * writes it with its own reason and audit row, removing the comp removes it,\n * and a live subscription ignores it along with the rest of the comp.\n *\n * The route states it on every grant, `false` included. The org write is a\n * merge, which writes a nested map key by key, so a capped grant that left\n * the key out would keep the `true` of the uncapped comp it replaced.\n *\n * Typed loosely where foundation cannot import the vocabulary: `reason` is\n * an `OrgOverrideReasonCode` (`app-utils/org-override-reason`), narrowed by\n * `readOrgPlanComp`.\n */\nexport interface OrgPlanComp {\n /** The plan granted. A paid plan; a comp of `free` is not a comp. */\n plan: OrgPlan\n /**\n * Every band and quota reads as unlimited while the comp is in force\n * (AGL-3049). Only a literal `true` lifts them; absent means capped.\n */\n uncapped?: boolean\n /** Why, from the override's fixed reason set (AGL-1652). */\n reason: string\n /** Staff-only rationale; explicit `null` when none was given. */\n note: string | null\n /** The staff uid that granted it. */\n grantedBy: string\n /** When it was granted — the server's clock. */\n grantedAt: ITimestamp | null\n}\n\n/**\n * Paid addon quantities (AGL-112/524) purchased on top of the plan's\n * included allowances, billed as items on the org's Stripe subscription.\n * Seat/dataset kinds resolve as `included + purchased` clamped to the\n * plan's hard max — beyond the max the org must upgrade. Purchases only\n * count while the subscription is alive (they bill on it); staff grants\n * live on `entitlements` overrides instead, so the two never collide.\n */\nexport interface OrgSeatAddons {\n /** Extra tenant-manager seats. */\n managers?: number\n /**\n * Extra per-site collaborator seats. Legacy key name (AGL-888): the Stripe\n * price env suffix is still `EXTRA_MEMBER` and existing org docs carry this\n * key — persisted, do not rename.\n *\n * A POOL, not a raise (AGL-2439) — the same correction `posRegisters`\n * received in AGL-1775, applied to the key that never got it.\n * `membersPerHost` is enforced PER SITE, so folding this org-wide quantity\n * into the org-level entitlement handed every site the whole purchase: one\n * extra collaborator seat bought 20 collaborators on a 20-site org. Since\n * AGL-2439 the quantity here is the size of an org-level pool and\n * `collaboratorAllocations` says which site each purchased seat is assigned\n * to. Nothing reads this as a per-site number; use\n * `checkHostCollaboratorQuota` / `resolveHostCollaboratorCap`.\n */\n members?: number\n /** Extra org datasets (AGL-132/240); billed monthly per dataset. */\n datasets?: number\n /** Extra sites beyond the plan's `hostLimit` (AGL-68/524). */\n hosts?: number\n /**\n * Extra POS registers beyond the plan's `posRegisters` (AGL-329/524).\n *\n * A POOL, not a raise (AGL-1775). `posRegisters` is enforced PER SITE, so\n * folding this quantity into the org-level entitlement handed every site\n * the whole purchase — one $89/mo register bought 20 registers on a\n * 20-site org. Since AGL-1775 the quantity here is the size of an org-level\n * pool and `registerAllocations` says which site each purchased seat is\n * assigned to. Nothing reads this as a per-site number; use\n * `resolveHostRegisterCap`.\n */\n posRegisters?: number\n /** Event Calendar org-wide toggle, 0/1 (AGL-145/524). */\n eventCalendar?: number\n /**\n * Aglyn AI org-wide toggle, 0/1 (AGL-2896). One purchase covers every\n * host in the org, the way Event Calendar does: `resolveOrgEntitlements`\n * reads any quantity >= 1 as one, adds the plan's\n * `AI_ADDON_CREDITS_PER_MONTH` band to `assistCreditsPerMonth`, and\n * switches `features.aiGenerative` and `features.aiAssist` on. Priced per\n * plan at `PlanPricing.aiAddonMonthlyUsd`; `null` there means the plan\n * does not sell it.\n */\n aiAddon?: number\n}\n\n/**\n * Which SITE each purchased POS register seat is assigned to (AGL-1775):\n * `{ [hostId]: seats }`, drawn from the org-level pool\n * `seatAddons.posRegisters`.\n *\n * WHY IT LIVES ON THE ORG DOC. The pool is org-level and the entitlement it\n * modifies is resolved from this same document, so a caller that can resolve\n * entitlements at all already holds the allocation — there is no second read\n * to fail and no separate loading state in which a host could resolve to\n * something other than its plan cap. That property is the point: the\n * `checkQuota(undefined)` = Free-tier lesson inverted, where an absent\n * allocation must mean the PLAN's cap and never the pooled total.\n *\n * AN ENTITLEMENT INPUT, and therefore Admin-SDK-only — it is denied to every\n * client in `cloud/firebase-firestore.rules` alongside `seatAddons` and\n * `entitlements`. A client that could write this could assign itself the\n * whole pool on every site, which is the defect AGL-1775 exists to close.\n *\n * A host id absent from the map holds ZERO purchased seats and resolves to\n * the plan cap alone. Deleting a site deletes its key, which returns its\n * seats to the pool — the pool is `seatAddons.posRegisters` minus the sum of\n * this map, so a released key is available capacity by arithmetic rather\n * than by a separate counter that could drift.\n */\nexport type OrgRegisterAllocations = Record<string, number>\n\n/**\n * Which SITE each purchased COLLABORATOR seat is assigned to (AGL-2439):\n * `{ [hostId]: seats }`, drawn from the org-level pool `seatAddons.members`.\n *\n * The same object as `OrgRegisterAllocations` and for the same reasons —\n * see that type's block, all of which applies verbatim. It is a distinct type\n * because it is a distinct field on the document and the two pools are bought\n * separately; nothing may read one as the other.\n *\n * AN ENTITLEMENT INPUT, and therefore Admin-SDK-only — denied to every client\n * in `cloud/firebase-firestore.rules` alongside `seatAddons`,\n * `registerAllocations` and `entitlements`. A client that could write this\n * could assign itself the whole pool on every site, which is the defect the\n * pool exists to close.\n *\n * A host id absent from the map holds ZERO purchased seats and resolves to\n * the plan cap alone. Deleting a site deletes its key, returning its seats to\n * the pool by arithmetic rather than by a counter that could drift.\n *\n * ONE THING DIFFERS FROM REGISTERS and it is deliberate: collaborator seats\n * clamp to the plan's `maxMembersPerHost` band, because that is how they are\n * sold. Assigning ten pool seats to a Starter site cannot lift it past ten —\n * `resolveHostCollaboratorCap` applies the clamp, so the console must not\n * present an assignment past the band as capacity.\n */\nexport type OrgCollaboratorAllocations = Record<string, number>\n\n/**\n * The monthly storage-overage spend cap an org CHOSE for itself (AGL-1886,\n * corrected 2026-08-18).\n *\n * The ceiling on metered spend is the END USER's control: it exists to prevent\n * a surprise, not to gate the product on a customer's acknowledgement.\n *\n * So this document is **absent by default and absent is the normal state**.\n * Storage past a metered plan's included band bills without it; the customer\n * is warned by `usage-alerts` on approach and at the band. Writing a cap here\n * is a customer opting IN to being stopped, and it is the only thing that can\n * refuse an upload on a plan that meters.\n *\n * NOT a consent record. As an acknowledgement that must exist before a paying\n * org may store a byte past its band, it costs revenue and blocks customers\n * without preventing any bill — `report-usage` bills stored bytes and never\n * reads this document.\n *\n * Still an ENTITLEMENT INPUT in the security sense, in the other direction: a\n * member who could raise their own `capUsd` could raise their own spend\n * ceiling, so the rules deny it to every client and only\n * `/api/billing/storage-overage` (Admin SDK, `billing.manage`) writes it.\n *\n * @see apps/console/utils/storage-overage.ts for the model and what still\n * hard-bands.\n */\nexport interface OrgStorageOverage {\n /**\n * The monthly storage-overage spend the org will not go past. Present means\n * capped; absent means uncapped, which is the default. A present but\n * malformed value resolves to `STORAGE_CAP_FALLBACK_USD` rather than to \"no\n * cap\" — a customer who asked for a ceiling must not be billed past one\n * because the stored number was corrupt.\n */\n capUsd?: number\n /** When the cap was set, for the audit trail. */\n capSetAt?: ITimestamp | null\n /** The uid that set it. */\n capSetBy?: string | null\n /**\n * LEGACY (pre-2026-08-18): the acknowledged-consent pair. Read only, and\n * honoured as a cap of `monthlyCeilingUsd` so a ceiling somebody typed is\n * not silently raised. Never written again.\n */\n acknowledgedAt?: ITimestamp | null\n /** @deprecated legacy consent field; see `acknowledgedAt`. */\n acknowledgedBy?: string | null\n /** @deprecated legacy bound; read as a cap. See `capUsd`. */\n monthlyCeilingUsd?: number\n}\n\n/**\n * The org's own answer to what happens when Aglyn Assist reaches its included\n * band (AGL-2653).\n *\n * Absent by default, and absent is the normal state: assist past the band is\n * SOLD — `reserveAssistMessage` keeps reserving and `report-usage` bills the\n * credits past the band at `PLAN_PRICING.extraAssistCreditsUsdPer1k`. Writing\n * `hardCap: true` is a customer opting IN to being stopped at the band\n * instead, which is what the gate did for every org before the overage was\n * sold. The shape mirrors `OrgStorageOverage`, the same control on storage,\n * for the same reason: a ceiling on metered spend is the END USER's control,\n * offered rather than imposed.\n *\n * An ENTITLEMENT INPUT in the security sense, in both directions at once. A\n * client that could clear it would lift a ceiling the org chose; one that\n * could set it on another org would switch that org's assistant off at the\n * band. So the rules deny it to every client and only\n * `/api/billing/assist-overage` (Admin SDK, `billing.manage`) writes it. Read\n * through `resolveAssistHardCap` in `app-utils/assist-credits`, never\n * directly.\n *\n * `report-usage` never reads this map — it bills whatever landed past the\n * band, the way storage bills stored bytes. With the cap on that is at most\n * the one exchange that crossed the line, because an exchange's cost is known\n * only after it is answered; with it off, it is the overage the org chose to\n * buy. Reading the switch at sweep time instead would let a flip on the 1st\n * erase a month's overage, the AGL-2399 shape on a different meter.\n *\n * On a plan whose `extraAssistCreditsUsdPer1k` is `null` the switch changes\n * nothing: there is no rate to sell past the band at, so the band stays the\n * wall it always was, on or off.\n *\n * ## The dollar ceiling beside the switch (AGL-2898)\n *\n * `capUsd` is the second control, and it answers a different question. The\n * switch asks \"sell past the band at all?\"; the ceiling asks \"and if so, how\n * much?\" — a monthly figure in USD of OVERAGE, priced at the plan's rate, past\n * which the assistant refuses for the rest of the month. It is the\n * `storageOverage.capUsd` of this meter: optional, absent by default, and the\n * customer's own number rather than a band the plan sold them. Read through\n * `resolveAssistOverageCapUsd`, and enforced by `assistOverageCapReached`\n * inside the same reservation transaction as the band.\n *\n * The two are independent. With the switch on the ceiling is never reached,\n * because nothing past the band is ever sold; with the switch off and no\n * ceiling the overage is open-ended, bounded only by the plan's message cap.\n * On a plan with no rate the ceiling, like the switch, changes nothing:\n * overage on such a plan prices to zero and zero never reaches a ceiling.\n */\nexport interface OrgAssistOverage {\n /** True stops assist at the included band; absent or false sells past it. */\n hardCap?: boolean\n /** When the switch was last written, for the audit trail. */\n hardCapSetAt?: ITimestamp | null\n /** The uid that wrote it. */\n hardCapSetBy?: string | null\n /**\n * The month's ceiling on AI overage, in USD at the plan's rate. `null` or\n * absent is no ceiling; anything that is not a finite positive number reads\n * as none too, so a corrupt value cannot become a wall of `NaN`.\n */\n capUsd?: number | null\n /** When the ceiling was last written or cleared, for the audit trail. */\n capSetAt?: ITimestamp | null\n /** The uid that wrote it. */\n capSetBy?: string | null\n}\n\n/**\n * A discount applied to the org's OWN Aglyn subscription (AGL-1105) — a\n * comped enterprise deal or a redeemed coupon, mirrored from a Stripe coupon\n * so `orgMonthlyRevenueUsd` can report net-of-discount MRR without a Stripe\n * round-trip. Exactly one of `percentOff` / `amountOffUsd` is set, matching\n * the Stripe coupon it points at (Stripe coupons are one or the other).\n * DISTINCT from a storefront/customer discount — this is Aglyn's own bill.\n */\nexport interface OrgDiscount {\n /** The Stripe coupon id (`co_…`) applied to the subscription. */\n couponId: string\n /** The Stripe promotion code id (`promo_…`), when the coupon has a code. */\n promotionCodeId?: string\n /** The human redemption code (e.g. `LAUNCH25`), when one exists. */\n code?: string\n /** Percentage off, 0–100 (mutually exclusive with `amountOffUsd`). */\n percentOff?: number\n /** Fixed USD off per invoice (mutually exclusive with `percentOff`). */\n amountOffUsd?: number\n /** The staff uid that applied it (audit trail; self-serve reads `system`). */\n appliedBy: UserUid\n /** Free-text why (the enterprise deal, the promotion) — staff-only. */\n reason?: string\n appliedAt: ITimestamp\n}\n\nexport interface OrgSubscription {\n status?:\n | 'active'\n | 'trialing'\n | 'past_due'\n | 'canceled'\n | 'incomplete'\n | 'unpaid'\n /**\n * WHY the subscription ended, mirrored from Stripe's\n * `cancellation_details.reason` on `customer.subscription.deleted`\n * (AGL-1877). `'payment_failed'` is Stripe having exhausted the dunning\n * retries; `'cancellation_requested'` is the customer leaving on purpose.\n *\n * The distinction is not cosmetic — it is the only thing that tells a\n * DELINQUENT workspace apart from one that simply left, and both arrive as\n * the same event with the same `status: 'canceled'` and the same\n * `plan: 'free'` mirror. Without it `shouldAutoLockOrgForBilling` had no\n * reachable delinquent state at all (see `billing-auto-lock.ts`), the\n * churn funnel counted a dunning failure as voluntary churn, and nobody\n * could be told which had happened.\n *\n * Absent on every org whose subscription ended before this shipped, and\n * every consumer must fail CLOSED on that — an unknown reason is not a\n * proven payment failure.\n */\n canceledReason?: string | null\n priceId?: string\n /**\n * Billing interval of the plan item (AGL-532), webhook-mirrored: the\n * Billing page initializes its monthly/annual toggle from it and plan\n * switches keep it unless the toggle says otherwise.\n */\n interval?: 'month' | 'year'\n currentPeriodEnd?: ITimestamp\n /**\n * A downgrade scheduled for the current period end (AGL-1862). Set by\n * `/api/billing/subscription` when a switch walks DOWN the self-serve\n * ladder — the Stripe subscription schedule owns the transition, this is\n * the manager-gated mirror the billing page renders. `null` (not absent)\n * once released, so a merge clears it. Rides inside `subscription` because\n * `pickOrgBillingFields` drops any other top-level key.\n */\n pendingDowngrade?: {\n plan: string\n interval: 'month' | 'year'\n /** ISO timestamp of the period end the schedule flips at. */\n effectiveAt: string | null\n scheduleId: string\n } | null\n /**\n * Negotiated custom price as a **monthly-normalized** USD figure (AGL-1110),\n * set when an enterprise org bills at an ad-hoc amount rather than a plan's\n * list price — e.g. `agency` capability at $2,730/mo. An annual custom deal\n * is stored ÷12 here (the yearly total lives on the Stripe price), so every\n * reader treats it as monthly and never re-divides. Webhook-mirrored from the\n * Stripe subscription's recurring price. When present it is the truth for\n * revenue — `orgListPriceMonthlyUsd`/`orgMonthlyRevenueUsd` use it instead of\n * the plan default (fixes the custom-price MRR under-report, AGL-1110).\n */\n customMonthlyUsd?: number\n}\n\n/**\n * The org's billing/entitlement doc shape — the view of `orgs/{orgId}`\n * that `useCurrentOrg()`, the plugin-page `org` prop, and the\n * entitlement resolvers carry. (Formerly `AglynTenant`; the alias was\n * removed in AGL-444.)\n */\n/**\n * `orgs/{orgId}.bandwidthCap` — the engaged free-plan bandwidth cap\n * (AGL-1967/2070/2155).\n *\n * STAMPED WITH THE MONTH IT IS FOR, and never cleared. A new month simply\n * stops matching, so the cap lifts on its own with no write: a marker whose\n * removal depends on a cron running is a marker that stays engaged when the\n * cron fails, which would take a customer's site down over an infrastructure\n * problem rather than over their traffic.\n *\n * Plain numbers rather than `ITimestamp`, matching `suspendedUntilMs`, so the\n * value survives the tenant's cache serialization unchanged.\n */\nexport interface OrgBandwidthCap {\n /** UTC `YYYY-MM` this cap was engaged for. Only the current month refuses. */\n month: string\n /** When the sweep engaged it. Diagnostic only; nothing gates on it. */\n engagedAt?: number\n /** The org-wide page views measured at engage time. Diagnostic. */\n pageViews?: number\n /** The band those page views were measured against. Diagnostic. */\n includedPageViews?: number\n}\n\n/**\n * The organization document.\n *\n * NOT composed from a plugin-augmented interface, deliberately (AGL-3124).\n * `org-write-deny-coverage.spec.ts` enumerates this interface's fields by\n * reading THIS FILE'S SOURCE, and checks every one of them against the\n * Firestore write-deny rules; a field a plugin declared from its own file\n * would be invisible to that sweep, so composing here would open a rules\n * coverage hole to save a plugin an already-solved problem. A plugin's own\n * settings block goes through `registerPluginConfigSchema` into\n * `pluginSettings/{pluginId}`, which is its own document under its own rule.\n */\nexport interface AglynOrgBilling extends AglynDocument {\n /** The document id, injected by the reader — never a stored field. */\n $id: OrgUid\n /**\n * The org's display name, written by `createOrganization` and renamed\n * through /api/orgs/settings (the name is denormalized onto every\n * membership row, so the rename fans out server-side). Client-writable by\n * an org admin on purpose — see `ORG_CLIENT_WRITABLE_FIELDS`.\n *\n * Spelled `displayName` here until AGL-1355; nothing ever read that, and\n * the document has always carried `name`. The coverage guard derives the\n * org's field set from this interface, so a field that does not exist\n * bought nothing but a blind spot.\n */\n name?: string\n /** Free-text workspace description; nothing gates on it. */\n description?: string\n /**\n * The IANA zone this workspace's published dates read in (AGL-3237) —\n * `America/Chicago`, `Europe/Berlin`. Absent is UTC.\n *\n * What it decides is which CALENDAR DAY an instant is attributed to on a\n * published site, which is an editorial fact about the publisher: a post\n * that went out at 19:30 in Chicago is dated the 21st here and was dated\n * the 22nd while UTC decided it. Sites inherit it; see\n * `resolveSiteTimeZone`.\n *\n * SERVER-OWNED: written through /api/orgs/settings and denied to the\n * client SDK, because `resolveSiteTimeZone` reads it in `app-utils` and\n * the write-deny guard treats a resolver input as server-owned.\n * Nothing bills, gates or routes on it, and it is validated at every read\n * through `isSupportedTimeZone`, so an unusable value renders as UTC rather\n * than throwing inside a page render.\n */\n timeZone?: string\n /**\n * The sites this organization declared ONE SENDER, `{ [groupId]: { name,\n * hostIds } }` (`consent-groups.ts`). Absent, every site is alone.\n *\n * SERVER-OWNED: denied to the client SDK, because `consentGroupForHost`\n * reads it in `app-utils` and decides who a marketing basis covers and whose\n * opt-outs hold a send. No console screen writes it; a declaration is made\n * by an Admin-SDK writer that must carry a leaving site's refusals onto the\n * sites that stay (see that module).\n */\n consentGroups?: Record<string, { name: string; hostIds: string[] }>\n /**\n * Whether a declared group's sites wait for each other's confirmation click\n * (AGL-3316). Absent or `false` is off: a pending confirmation holds only\n * the asking site's mail.\n *\n * SERVER-OWNED: written through /api/orgs/settings and denied to the client\n * SDK, for the reason `consentGroups` is — `consentGroupForHost` resolves it\n * into every group a send path reads.\n */\n consentGroupsAwaitConfirmation?: boolean\n /** The workspace URL segment, reserved through `orgSlugs/{slug}`. */\n slug?: string\n /**\n * The owner's uid. Spelled `ownerId` here until AGL-1355 — every reader\n * has always used `ownerUid` (the key `createOrganization` writes and the\n * rules deny), so the declared name was dead.\n */\n ownerUid?: UserUid\n hosts?: Record<HostUid, true>\n /** Subscription tier; missing/unknown plans resolve as `free`. */\n plan?: OrgPlan\n /** Per-org entitlement overrides (admin console); win over plan defaults. */\n entitlements?: OrgEntitlements\n /**\n * Per-org RELEASE-flag overrides (AGL-1635) — a different axis from\n * `entitlements`, which asks what the org's plan includes. These ask\n * whether an unreleased feature is switched on for this one customer, and\n * win over both the Remote Config value and the rollout bucket.\n *\n * Staff-only, and deliberately narrower than `entitlements`: super staff\n * alone may write it, matching the platform-wide flag editor\n * (`/api/admin/flags` is super-only), because forcing a flag on for an org\n * is the same class of act as flipping it for everyone — just scoped.\n * Read through `parseOrgReleaseFlagOverrides`, never directly.\n *\n * Typed loosely on purpose. The keys ARE `ReleaseFlagKey`, but that union\n * lives in `app-utils/release-flags` and foundation cannot import\n * app-utils (see `platform.types.ts`). Declaring the narrow type here\n * would also overstate what is on disk: this map outlives registry\n * renames, so a retired key is a thing that genuinely exists in Firestore.\n * `parseOrgReleaseFlagOverrides` is what narrows it, dropping unknown keys\n * and non-booleans.\n */\n releaseFlags?: Record<string, boolean>\n /**\n * White-label brand identity (White-Label Phase 1). Applied only when the\n * org carries the `whiteLabel` entitlement; read exclusively through\n * `resolveBrandingProfile`, never directly, so no surface diverges.\n */\n brandingProfile?: OrgBrandingProfile\n /**\n * Enterprise SSO config (AGL-1101). Applied only when the org carries the\n * `ssoEnabled` entitlement; the console routes sign-in through the org's\n * GCIP tenant/provider named here. See `OrgSsoConfig`.\n */\n sso?: OrgSsoConfig\n /** Per-org plugin switchboard (AGL-416); see plugin-manager/enabled-plugins. */\n enabledPlugins?: string[]\n /** Purchased addon seats (AGL-112); billed monthly per seat. */\n seatAddons?: OrgSeatAddons\n /**\n * POS register seats assigned out of the org pool (AGL-1775). An\n * ENTITLEMENT INPUT: it raises a per-site cap, so it is Admin-SDK-only.\n * @see OrgRegisterAllocations\n */\n registerAllocations?: OrgRegisterAllocations\n /**\n * Per-site collaborator seats assigned out of the org pool (AGL-2439). An\n * ENTITLEMENT INPUT: it raises a per-site cap, so it is Admin-SDK-only.\n * @see OrgCollaboratorAllocations\n */\n collaboratorAllocations?: OrgCollaboratorAllocations\n /**\n * The free plan's engaged BANDWIDTH CAP (AGL-1967/2070/2155), denormalized\n * onto this doc by the `usage-alerts` cron so the serving path can refuse a\n * capped site without a read of its own.\n *\n * An ENTITLEMENT INPUT in the strongest sense: it is the only thing standing\n * between a free site that has blown its band and unmetered egress, so a\n * client-writable value would let an org admin lift their own cap by\n * deleting one field. Admin-SDK only; denied in the rules.\n *\n * Read exclusively through `bandwidthCapEngaged` (`app-utils/bandwidth-cap`),\n * never directly — the marker on its own is not the answer. The resolver\n * re-derives the plan on every read, which is what lets an org that upgrades\n * mid-month start serving again without waiting for a cron to clear it.\n */\n bandwidthCap?: OrgBandwidthCap\n /**\n * The org's assist hard-cap switch (AGL-2653) — see `OrgAssistOverage`.\n * Admin-SDK-only, denied in the rules beside `storageOverage`; read\n * through `resolveAssistHardCap`.\n */\n assistOverage?: OrgAssistOverage\n stripeCustomerId?: string\n subscription?: OrgSubscription\n /**\n * Discount on the org's own Aglyn subscription (AGL-1105) — staff-applied\n * enterprise deal or a redeemed coupon. `orgMonthlyRevenueUsd` subtracts it\n * for net-of-discount MRR; `orgListPriceMonthlyUsd` ignores it (list price).\n */\n discount?: OrgDiscount\n /**\n * Explicit **comped enterprise** marker (AGL-1110). Makes the org read as\n * \"Enterprise\" (via `isEnterpriseOrg`) without a negotiated custom price —\n * for internal/dogfood accounts (e.g. Aglyn's own org) that carry full\n * Enterprise capability + SSO but are 100%-discounted, so they collect $0\n * while infra cost is still metered. Staff-set; distinct from a paying\n * custom-priced enterprise, which qualifies via `subscription.customMonthlyUsd`.\n */\n enterprise?: boolean\n /**\n * The bare `subscription.status` word the Stripe webhook mirrors back onto\n * this doc for the AGL-275 dunning banner and `resolveEffectivePlan`\n * (AGL-1028). An ENTITLEMENT INPUT: a dead subscription downgrades a paid\n * plan to free, so a client-writable value would restore the plan.\n */\n billingStatus?: string\n /** Staff suspension (AGL-202): set = all the org's sites serve 503. */\n suspendedAt?: ITimestamp | null\n suspendedReason?: string\n /**\n * GDPR erasure request (AGL-206): hard deletion happens ONLY via `eraseOrg`\n * after a 7-day hold from this stamp — reached by the\n * `/api/admin/run-erasures` cron, or by hand with\n * tools/scripts/erase-tenant.mjs, which calls the same function (AGL-1481).\n */\n erasureRequestedAt?: ITimestamp | null\n /**\n * The CRM's organization-wide settings (AGL-2613), written from\n * CRM → Settings by an owner or admin with the client SDK — see\n * `ORG_CLIENT_WRITABLE_FIELDS`. One map rather than a key per setting, so\n * the section can grow without a rules change each time.\n */\n crm?: OrgCrmSettings\n createdAt?: ITimestamp\n updatedAt?: ITimestamp\n /**\n * The workspace's email capture address (AGL-2657) — see `OrgCrmInbound`.\n * Server-owned and member-readable: the token IS the address, so a client\n * that could write it could point another workspace's mail here.\n */\n crmInbound?: OrgCrmInbound\n}\n\n/**\n * What the CRM lets an organization decide for every site at once\n * (AGL-2613). Every key is optional and off when absent: a setting that\n * has never been touched behaves as the product did before it existed.\n */\nexport interface OrgCrmSettings {\n /**\n * Create a company from a captured contact's work email domain when no\n * company the capturing site can see carries that domain. Off by default:\n * a company minted from every domain that ever submitted a form is a list\n * nobody asked for. Public mailbox domains never qualify either way.\n */\n autoCreateCompanies?: boolean\n /**\n * Per-site settings, keyed by host id (AGL-2618). On the ORG document\n * rather than on each host document because the reader is the org-level\n * assignment pass — one read of one document answers every site's default\n * — and because the writer is the same owner-or-admin the rest of this\n * map admits, through the same client branch. A host document would need\n * its own rules clause for a key only the CRM reads.\n */\n hosts?: Record<string, OrgCrmHostSettings>\n /**\n * The assignment rules, in the order they are tried (AGL-2618). The first\n * rule whose every condition holds names the owner; the site's default\n * owner is the fallback when none does. Bounded by\n * `CRM_ASSIGNMENT_RULES_MAX` in the section that writes it.\n */\n assignmentRules?: OrgCrmAssignmentRule[]\n /** The round-robin pool and its pointer — see `OrgCrmRoundRobin`. */\n roundRobin?: OrgCrmRoundRobin\n}\n\n/** What one site decides for records captured on it (AGL-2618). */\nexport interface OrgCrmHostSettings {\n /**\n * The member every record captured on this site is handed to when no\n * assignment rule claimed it. Absent: the record stays unassigned, which\n * is what the product did before the setting existed.\n */\n defaultOwnerUid?: string\n}\n\n/**\n * The workspace's email capture address, as the org document carries it\n * (AGL-2657): `crm+<token>@<capture domain>`.\n *\n * One token per organization, minted lazily by `crm/inbound-address` the\n * first time a member asks for the address, and replaced by the same route\n * on a rotation. Every site of the organization shares it: a captured\n * message is filed on the record that matches its correspondent, and the\n * record says which site it belongs to, so the address need not.\n */\nexport interface OrgCrmInbound {\n /** The url-safe secret in the local part; `crm+<token>@…`. */\n token: string\n /** When the first token was minted, epoch ms. */\n createdAtMs: number\n /** When the token was last replaced, epoch ms; absent until it has been. */\n rotatedAtMs?: number\n}\n\n/**\n * One assignment rule (AGL-2618): `when` every named condition holds for a\n * capture, `assign` the record this way. A condition left out is not a\n * condition — a rule naming only a source matches every capture from that\n * source — and a rule naming nothing matches every capture, which is how a\n * catch-all is written. `source` is a `ContactSource`, typed as a string\n * here because the foundation cannot import the capture vocabulary; the CRM\n * module narrows it.\n */\nexport interface OrgCrmAssignmentRule {\n id: string\n when: {\n source?: string\n formId?: string\n emailDomain?: string\n tag?: string\n }\n assign: { memberUid: string } | { roundRobin: true }\n}\n\n/**\n * The round-robin pool (AGL-2618): the members handed records in turn, and\n * WHO GOT THE LAST ONE. The pointer is a uid rather than an index so that\n * editing the pool — a member added, removed or moved — never skips or\n * repeats anybody: the next record goes to whoever follows the last\n * recipient in the pool as it stands, and to the first member when the last\n * recipient is no longer in it. Advanced only by the server, inside the\n * transaction that writes the owner, so two captures landing together take\n * two different members.\n */\nexport interface OrgCrmRoundRobin {\n memberUids?: string[]\n lastAssignedUid?: string\n}\n\n/**\n * WHO OWNS EACH FIELD OF `orgs/{orgId}` (AGL-1355).\n *\n * AGL-1354 found four server-owned keys — `brandingProfile`, `sso`,\n * `discount`, `enterprise` — that the Firestore rules' org-update key diff\n * had drifted past, so an org admin holding nothing but the Firebase client\n * SDK could write them on any plan. The keys are closed. The MECHANISM that\n * opened them was a hand-maintained deny-list with nothing checking it, and\n * that is what these two maps close.\n *\n * The rule is DEFAULT-DENY, and it is enforced from the interface above:\n * `org-write-deny-coverage.spec.ts` reads every field declared on\n * `AglynOrgBilling`, and one that appears in neither the rules' deny-list nor\n * one of these maps FAILS THE BUILD, naming the field. Adding a field to the\n * interface therefore forces the ownership decision here, at the declaration,\n * on the same commit — instead of shipping client-writable and staying that\n * way until the next audit.\n *\n * So: add a field above, and either add it to the `hasAny([...])` list in\n * `cloud/firebase-firestore.rules` under `match /orgs/{orgId}` (server-owned:\n * anything an entitlement, a price, a routing decision or a staff judgement\n * reads) or add it below WITH A REASON. When in doubt, deny it — a field the\n * server writes through an Admin-SDK route loses nothing by being denied to\n * the client, and that is true of every key AGL-1354 closed.\n *\n * The entries are the fields an org admin may set from the client SDK. The\n * reason is mandatory and is the whole value of the map: it records that\n * someone decided, rather than that someone forgot.\n */\nexport const ORG_CLIENT_WRITABLE_FIELDS: Readonly<Record<string, string>> = {\n name:\n 'The workspace name. Deliberately writable by an org admin — the rules ' +\n 'admit the rename branch and `firestore-rules.test.mjs` asserts it still ' +\n 'succeeds. Cosmetic: no entitlement, price or routing decision reads it. ' +\n '(The console renames through /api/orgs/settings anyway, because the name ' +\n 'is denormalized onto every membership row and the fan-out is a server ' +\n 'job — but the client write is allowed and must stay allowed.)',\n description:\n 'Free-text workspace blurb. Nothing resolves, gates or bills on it, and ' +\n 'no server route owns it.',\n createdAt:\n 'Creation stamp seeded by `createOrganization`. Nothing gates on it; the ' +\n 'staff org list only displays it. Denying it would buy nothing and would ' +\n 'break the merge-writes below, which stamp the pair together.',\n updatedAt:\n 'Last-write stamp. Every client write that IS allowed sets it in the same ' +\n '`setDoc(..., { merge: true })` — the staff suspension, erasure-request ' +\n 'and plan-override cards all do — so denying it would deny those writes.',\n crm:\n 'The CRM settings map (AGL-2613): `autoCreateCompanies`, the per-site ' +\n 'default owner, the assignment rules and the round-robin pool ' +\n '(AGL-2618), and whatever the CRM → Settings section adds beside them. ' +\n 'Deliberately writable by an org owner or admin — the section writes it ' +\n 'client-direct by dotted path. No entitlement, price, routing decision ' +\n 'or staff judgement reads it; its readers are the contact capture door ' +\n 'deciding whether to mint a company record from an email domain and ' +\n 'whom to hand a new record to, which are choices about the org\\'s own ' +\n \"data that the org's managers are the right people to make. The pool's \" +\n 'pointer (`crm.roundRobin.lastAssignedUid`) is advanced by the server ' +\n 'inside the assigning transaction; a client that moved it would only ' +\n 'change who is next, never what anybody may see.',\n}\n\n/**\n * Fields declared on `AglynOrgBilling` that are NOT stored on the document,\n * and so cannot be written by anyone. Separate from the map above because\n * \"the client may set this\" and \"this is not a field\" are different\n * statements, and collapsing them would let a genuinely client-writable field\n * hide behind a synthetic one.\n */\nexport const ORG_UNPERSISTED_FIELDS: Readonly<Record<string, string>> = {\n $id: 'The document id, injected by the reader. Never written as a field.',\n}\n\n"],"names":["ORG_CLIENT_WRITABLE_FIELDS","name","description","createdAt","updatedAt","crm","ORG_UNPERSISTED_FIELDS","$id"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GAq3CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMA,6BAA+D;IAC1EC,MACE,2EACA,6EACA,6EACA,8EACA,2EACA;IACFC,aACE,4EACA;IACFC,WACE,6EACA,6EACA;IACFC,WACE,8EACA,4EACA;IACFC,KACE,0EACA,kEACA,2EACA,4EACA,2EACA,2EACA,wEACA,0EACA,2EACA,0EACA,yEACA;AACJ,EAAC;AAED;;;;;;CAMC,GACD,OAAO,MAAMC,yBAA2D;IACtEC,KAAK;AACP,EAAC"}
|