@aglyn/aglyn 1.0.0-beta.160 → 1.0.0-beta.162

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.
Files changed (30) hide show
  1. package/package.json +10 -10
  2. package/src/lib/app-utils/contacts.d.ts +8 -0
  3. package/src/lib/app-utils/contacts.js.map +1 -1
  4. package/src/lib/app-utils/crm.d.ts +13 -0
  5. package/src/lib/app-utils/crm.js.map +1 -1
  6. package/src/lib/app-utils/docs-help.generated.d.ts +3 -3
  7. package/src/lib/app-utils/docs-help.generated.js +3 -0
  8. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  9. package/src/lib/app-utils/docs-index.generated.js +7 -7
  10. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  11. package/src/lib/app-utils/email-state.d.ts +57 -0
  12. package/src/lib/app-utils/email-state.js +136 -0
  13. package/src/lib/app-utils/email-state.js.map +1 -0
  14. package/src/lib/app-utils/health-report.d.ts +28 -0
  15. package/src/lib/app-utils/health-report.js +36 -0
  16. package/src/lib/app-utils/health-report.js.map +1 -1
  17. package/src/lib/app-utils/notifications.d.ts +58 -0
  18. package/src/lib/app-utils/notifications.js +62 -8
  19. package/src/lib/app-utils/notifications.js.map +1 -1
  20. package/src/lib/app-utils/search-indexing.d.ts +37 -2
  21. package/src/lib/app-utils/search-indexing.js +23 -1
  22. package/src/lib/app-utils/search-indexing.js.map +1 -1
  23. package/src/lib/app-utils/server.d.ts +1 -0
  24. package/src/lib/app-utils/server.js +3 -0
  25. package/src/lib/app-utils/server.js.map +1 -1
  26. package/src/lib/plugin-manager/plugin-record-email-state.d.ts +85 -0
  27. package/src/lib/plugin-manager/plugin-record-email-state.js +53 -0
  28. package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -0
  29. package/src/lib/plugin-manager/plugin-record-timeline.d.ts +22 -0
  30. package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
@@ -49,6 +49,21 @@ export declare const NOTIFICATION_CATEGORY_LABELS: Record<NotificationCategory,
49
49
  * enumerates the `staff` claim rather than reading this.
50
50
  */
51
51
  export declare const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory>;
52
+ /**
53
+ * What each category actually covers, in the reader's words (AGL-3251).
54
+ *
55
+ * The settings page listed seven bare labels and two switches, so deciding
56
+ * whether to silence `Product & system` meant guessing what was in it. The
57
+ * type labels beneath each row now name the contents exactly; this is the
58
+ * one-line answer for somebody who does not want to expand anything.
59
+ *
60
+ * Written as what ARRIVES rather than as what the bucket is called: "someone
61
+ * joins, a role changes" is checkable against your own feed in a way that
62
+ * "team and access events" is not. Exhaustive `Record`, like the channel
63
+ * defaults below — a new category cannot ship without somebody saying in
64
+ * plain words what lands in it.
65
+ */
66
+ export declare const NOTIFICATION_CATEGORY_DESCRIPTIONS: Record<NotificationCategory, string>;
52
67
  export declare function notificationCategory(type: AglynNotificationType | string): NotificationCategory;
53
68
  /**
54
69
  * Per-user mute map stored at `users/{uid}.notificationPrefs`
@@ -114,6 +129,17 @@ export interface NotificationChannelPrefs {
114
129
  email?: boolean;
115
130
  }
116
131
  export type NotificationCategoryPrefs = Partial<Record<NotificationCategory, NotificationChannelPrefs>>;
132
+ /**
133
+ * One scope's answer for a single notification TYPE (AGL-3251), tri-state on
134
+ * the same terms as {@link NotificationChannelPrefs}: an absent key falls
135
+ * through to the category.
136
+ *
137
+ * Its own map rather than extra keys in {@link NotificationCategoryPrefs},
138
+ * because that one is an exhaustive record keyed by category and a type key
139
+ * inside it would type-check only by widening the thing that makes a missing
140
+ * category a compile error.
141
+ */
142
+ export type NotificationTypePrefs = Partial<Record<AglynNotificationType, NotificationChannelPrefs>>;
117
143
  /**
118
144
  * Per-scope, per-channel notification preferences (AGL-3223), stored at
119
145
  * `users/{uid}.notificationSettings`.
@@ -132,6 +158,19 @@ export type NotificationCategoryPrefs = Partial<Record<NotificationCategory, Not
132
158
  */
133
159
  export interface NotificationSettings {
134
160
  account?: NotificationCategoryPrefs;
161
+ /**
162
+ * Per-TYPE answers at the account scope (AGL-3251), consulted before
163
+ * {@link NotificationSettings.account}'s category answer and never above a
164
+ * narrower scope — see {@link notificationChannelEnabled}.
165
+ *
166
+ * ACCOUNT ONLY, deliberately. The per-workspace and per-site card is
167
+ * already seven categories by three states by two channels, and a type row
168
+ * for each of the ~thirty types, per workspace AND per site, is a page
169
+ * nobody can read — which is the complaint this whole issue started as.
170
+ * The account scope is where "stop telling me about THIS" belongs anyway:
171
+ * it is a statement about the kind of thing, and kinds do not vary by site.
172
+ */
173
+ accountTypes?: NotificationTypePrefs;
135
174
  /** Keyed by org id. */
136
175
  orgs?: Record<string, NotificationCategoryPrefs>;
137
176
  /** Keyed by host id — a site's own answer, narrower than its workspace's. */
@@ -194,6 +233,25 @@ export declare function notificationChannelEnabled(settings: NotificationSetting
194
233
  * notification, because the page edits a layer whether or not anything has
195
234
  * ever arrived from it.
196
235
  */
236
+ /**
237
+ * What the account scope says about one TYPE, with no inheritance applied
238
+ * (AGL-3251) — `undefined` means the row follows its category.
239
+ *
240
+ * The page needs the unresolved answer to draw the difference between "on
241
+ * because Billing is on" and "on because you said so": only the second can be
242
+ * reset, and a switch that cannot show which one it is leaves a person unable
243
+ * to get back to following the category.
244
+ */
245
+ export declare function notificationAccountTypePref(settings: NotificationSettings | null | undefined, type: AglynNotificationType | string, channel: NotificationChannel): boolean | undefined;
246
+ /**
247
+ * Every type in a category, in the order the labels declare them
248
+ * (AGL-3251) — what the settings page expands a category row into.
249
+ *
250
+ * Derived from {@link NOTIFICATION_TYPE_LABELS} rather than held as a second
251
+ * map, so a type added there appears under its category without anybody
252
+ * remembering to list it twice.
253
+ */
254
+ export declare function notificationTypesInCategory(category: NotificationCategory): AglynNotificationType[];
197
255
  export declare function notificationScopePref(settings: NotificationSettings | null | undefined, scope: {
198
256
  kind: 'account';
199
257
  } | {
@@ -69,6 +69,28 @@ export const NOTIFICATION_CATEGORY_LABELS = {
69
69
  */ export const STAFF_NOTIFICATION_CATEGORIES = new Set([
70
70
  'staff'
71
71
  ]);
72
+ /**
73
+ * What each category actually covers, in the reader's words (AGL-3251).
74
+ *
75
+ * The settings page listed seven bare labels and two switches, so deciding
76
+ * whether to silence `Product & system` meant guessing what was in it. The
77
+ * type labels beneath each row now name the contents exactly; this is the
78
+ * one-line answer for somebody who does not want to expand anything.
79
+ *
80
+ * Written as what ARRIVES rather than as what the bucket is called: "someone
81
+ * joins, a role changes" is checkable against your own feed in a way that
82
+ * "team and access events" is not. Exhaustive `Record`, like the channel
83
+ * defaults below — a new category cannot ship without somebody saying in
84
+ * plain words what lands in it.
85
+ */ export const NOTIFICATION_CATEGORY_DESCRIPTIONS = {
86
+ billing: 'Invoices, failed payments, cancellations, and usage that crosses a plan limit.',
87
+ team: 'Somebody joins or leaves, a role changes, or a site is shared with you.',
88
+ content: 'Work arriving on your sites: form submissions, bookings, orders, low stock, and the tasks and leads assigned to you.',
89
+ marketplace: 'Decisions on plugin listings you submitted for review.',
90
+ support: 'New support tickets and replies on tickets you are following.',
91
+ system: 'Announcements, and the faults the platform finds in your account: sign-in methods removed, traffic limits reached, billing or sharing left in a broken state.',
92
+ staff: 'New accounts and new workspaces across the whole platform. Only staff receive these.'
93
+ };
72
94
  export function notificationCategory(type) {
73
95
  const prefix = String(type).split('.')[0];
74
96
  return [
@@ -200,16 +222,26 @@ export const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings';
200
222
  * predates there being a second channel and reading a console mute as an
201
223
  * email preference would be inventing an answer its author never gave.
202
224
  */ export function notificationChannelEnabled(settings, channel, type, scope, legacyPrefs) {
203
- var _settings_hosts, _settings_orgs;
225
+ var _settings_hosts_scope_hostId, _settings_hosts, _settings_orgs_scope_orgId, _settings_orgs, _settings_accountTypes, _settings_account;
204
226
  const category = notificationCategory(type);
205
- const layers = [
206
- (scope == null ? void 0 : scope.hostId) ? settings == null ? void 0 : (_settings_hosts = settings.hosts) == null ? void 0 : _settings_hosts[scope.hostId] : undefined,
207
- (scope == null ? void 0 : scope.orgId) ? settings == null ? void 0 : (_settings_orgs = settings.orgs) == null ? void 0 : _settings_orgs[scope.orgId] : undefined,
208
- settings == null ? void 0 : settings.account
227
+ /*
228
+ * The account layer answers TWICE, type before category (AGL-3251).
229
+ *
230
+ * The minimal extension of the rule above rather than a new one: the layers
231
+ * keep their order, narrowest scope first, and only what "the account says"
232
+ * gets finer. A person who switched `Payment failed` off has said something
233
+ * about that type specifically, so it must beat their own `Billing` answer
234
+ * — and it must NOT beat a narrower scope, because "quiet down this one
235
+ * noisy site" is the question the scope layers exist to answer and a
236
+ * type-level opinion about the kind of thing does not overrule it.
237
+ */ const layers = [
238
+ (scope == null ? void 0 : scope.hostId) ? settings == null ? void 0 : (_settings_hosts = settings.hosts) == null ? void 0 : (_settings_hosts_scope_hostId = _settings_hosts[scope.hostId]) == null ? void 0 : _settings_hosts_scope_hostId[category] : undefined,
239
+ (scope == null ? void 0 : scope.orgId) ? settings == null ? void 0 : (_settings_orgs = settings.orgs) == null ? void 0 : (_settings_orgs_scope_orgId = _settings_orgs[scope.orgId]) == null ? void 0 : _settings_orgs_scope_orgId[category] : undefined,
240
+ settings == null ? void 0 : (_settings_accountTypes = settings.accountTypes) == null ? void 0 : _settings_accountTypes[type],
241
+ settings == null ? void 0 : (_settings_account = settings.account) == null ? void 0 : _settings_account[category]
209
242
  ];
210
243
  for (const layer of layers){
211
- var _layer_category;
212
- const answer = layer == null ? void 0 : (_layer_category = layer[category]) == null ? void 0 : _layer_category[channel];
244
+ const answer = layer == null ? void 0 : layer[channel];
213
245
  if (typeof answer === 'boolean') return answer;
214
246
  }
215
247
  if (channel === 'console' && notificationMuted(legacyPrefs, type)) return false;
@@ -222,7 +254,29 @@ export const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings';
222
254
  * `scope` names the layer directly rather than being derived from a
223
255
  * notification, because the page edits a layer whether or not anything has
224
256
  * ever arrived from it.
225
- */ export function notificationScopePref(settings, scope, category, channel) {
257
+ */ /**
258
+ * What the account scope says about one TYPE, with no inheritance applied
259
+ * (AGL-3251) — `undefined` means the row follows its category.
260
+ *
261
+ * The page needs the unresolved answer to draw the difference between "on
262
+ * because Billing is on" and "on because you said so": only the second can be
263
+ * reset, and a switch that cannot show which one it is leaves a person unable
264
+ * to get back to following the category.
265
+ */ export function notificationAccountTypePref(settings, type, channel) {
266
+ var _settings_accountTypes_type, _settings_accountTypes;
267
+ return settings == null ? void 0 : (_settings_accountTypes = settings.accountTypes) == null ? void 0 : (_settings_accountTypes_type = _settings_accountTypes[type]) == null ? void 0 : _settings_accountTypes_type[channel];
268
+ }
269
+ /**
270
+ * Every type in a category, in the order the labels declare them
271
+ * (AGL-3251) — what the settings page expands a category row into.
272
+ *
273
+ * Derived from {@link NOTIFICATION_TYPE_LABELS} rather than held as a second
274
+ * map, so a type added there appears under its category without anybody
275
+ * remembering to list it twice.
276
+ */ export function notificationTypesInCategory(category) {
277
+ return Object.keys(NOTIFICATION_TYPE_LABELS).filter((type)=>notificationCategory(type) === category);
278
+ }
279
+ export function notificationScopePref(settings, scope, category, channel) {
226
280
  var _settings_orgs, _settings_hosts, _layer_category;
227
281
  const layer = scope.kind === 'account' ? settings == null ? void 0 : settings.account : scope.kind === 'org' ? settings == null ? void 0 : (_settings_orgs = settings.orgs) == null ? void 0 : _settings_orgs[scope.id] : settings == null ? void 0 : (_settings_hosts = settings.hosts) == null ? void 0 : _settings_hosts[scope.id];
228
282
  return layer == null ? void 0 : (_layer_category = layer[category]) == null ? void 0 : _layer_category[channel];
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\n\n/**\n * In-app notifications (AGL-259): per-user docs at\n * `users/{uid}/notifications/{id}`, written by the Admin SDK (emitters in\n * the API routes + the `notifyAdmins` automation step) and read/marked by\n * their owner. Types follow the common SaaS taxonomy so the console can\n * icon/group them without a registry.\n */\nexport type AglynNotificationType =\n | 'billing.invoice'\n | 'billing.paymentFailed'\n // Stripe gave up on a failed renewal and CANCELLED the subscription\n // (AGL-1877). Distinct from `paymentFailed`, which announces one failed\n // attempt inside a retry window the customer can still recover from; this\n // one is the window having closed. Measured on the test account with a\n // test clock: five attempts over 21.08 days, then\n // `cancellation_details.reason: 'payment_failed'` — and until this existed\n // the org went silently to Free at that moment, with the `past_due` banner\n // disappearing at exactly the instant the consequence arrived.\n | 'billing.subscriptionCanceled'\n | 'billing.usage'\n | 'team.invite'\n | 'team.roleChanged'\n | 'team.hostAccessGranted'\n | 'content.formSubmission'\n | 'content.booking'\n | 'content.order'\n | 'content.lowStock'\n // A CRM task was assigned to the recipient by somebody else (AGL-2599).\n // `content.` because it is the same kind of message as a form submission\n // or a booking: a thing on the site that now needs this person's hands,\n // not a change to their standing (`team.`) and not a platform notice\n // (`system.`). Someone who has muted the operational stream has said they\n // do not want to be told about work as it arrives, and a task is work.\n | 'content.taskAssigned'\n // A CRM task's reminder came due (AGL-2659): the hourly runner telling\n // the assignee, at the task's own time, that the task is due. `content.`\n // beside `taskAssigned` for the same reason it gives — work on the site,\n // not standing — and so the one mute that stops \"work arriving\" stops\n // \"work falling due\" with it. The mute governs the whole reminder, mail\n // included, unlike the digest's: a reminder is one message about one\n // task, and there is no schedule to keep separately from it.\n | 'content.taskReminder'\n // A contact, or a lead, became the recipient's to work (AGL-2618): a\n // capture the assignment rules or the site's default owner routed to\n // them, a lead somebody converted and handed to them, or an automation's\n // \"assign an owner\" step — every server path that writes `ownerUid`\n // except the recipient assigning themselves. Two types rather than one\n // because the record the person opens differs: a lead is the site's own\n // working record and a contact is the org's, and the link goes to the\n // one they will actually work. `content.`, beside `taskAssigned`, for the\n // same reason: work arriving, not standing changing.\n | 'content.contactAssigned'\n | 'content.leadAssigned'\n // The morning's CRM digest (AGL-2619): what the recipient owes today —\n // overdue and due-today tasks, and the leads nobody has worked. `content.`\n // for the reason `taskAssigned` is: it is about work on the site, and the\n // person who muted the operational stream has asked not to be told about\n // work. The digest EMAIL is governed separately, by\n // `crmDailyDigestEnabled`: the mute is a fact about the console feed and\n // the digest switch is a fact about the digest.\n | 'content.crmDailyDigest'\n // The weekly insights (AGL-2915): what a site's figures showed that week,\n // for a person who asked for them. `content.` beside the CRM digest, for the\n // reason that one gives: it is about the site's work, and the operational\n // mute governs the console notification while the person's own switch for\n // the digest governs the digest.\n | 'content.insightsDigest'\n // Marketplace review verdicts (AGL-432/653).\n | 'marketplace.review'\n // Support desk, staff audience (AGL-850): a subscriber opened or replied to\n // a ticket. Fanned out to staff-claim holders, not org members.\n | 'support.ticketOpened'\n | 'support.ticketReply'\n | 'system.announcement'\n // A live plugin version stopped passing the static verifier (AGL-1086).\n // Staff audience: bytes we told workspaces were checked now fail checks\n // that did not exist when they were approved.\n //\n // Deliberately NOT under `marketplace.` (AGL-1088). Category is the prefix,\n // categories are mutable per user, and Marketplace is the category a staff\n // member mutes to stop routine listing-review chatter — which would drop\n // this alert as collateral. `system` is the bucket nobody mutes to reduce\n // noise. The adminAudit record survives a mute either way; the timeliness\n // does not, and timeliness is the whole point of the alert.\n | 'system.pluginVerifierRegression'\n // A sign-in method was removed from the user's own account because their\n // organization turned on SSO enforcement (AGL-1129). `system.`, not\n // `team.`, for the AGL-1088 reason above: `team` is the category someone\n // mutes to stop routine roster chatter, and \"the way you sign in just\n // changed\" is not chatter — the next sign-in fails without it.\n | 'system.signInMethodRemoved'\n // Documents in a scoped collection carrying no `visibleTo` (AGL-1478).\n // Staff audience: the weekly dry run found resources that are invisible\n // to every site-scoped read, which always means a creation path forgot\n // the field. `system.` for the AGL-1088 reason above, and because the\n // collection it names may be `marketplace`-adjacent or not.\n | 'system.scopeDrift'\n // An SSO domain that WAS DNS-proven has stopped answering with our\n // challenge record, for three consecutive weekly sweeps (AGL-1210).\n // Nothing has been turned off — sign-in for that domain still routes\n // exactly as it did — and that is precisely why this has to be said out\n // loud rather than left in a log.\n //\n // `system.`, not `team.`, for the AGL-1088 reason above and one of its\n // own: the audience is the org's own admins, the subject is whether their\n // people can still sign in, and it must reach them BEFORE anybody decides\n // to revoke the routing. A category somebody mutes to quieten roster\n // chatter is the wrong place for the one warning that precedes a lockout.\n | 'system.ssoDomainUnverified'\n // A site hit the per-month form-submission abuse ceiling and further\n // submissions are being refused (AGL-1655). `system.`, not `content.`, for\n // the AGL-1088 reason above and more sharply than any of them: `content` is\n // literally the category a site owner mutes to stop routine form-submission\n // chatter, and this is the one form notification that says the form has\n // STOPPED accepting. Filing it under the muted bucket would guarantee it\n // reaches nobody on exactly the sites busy enough to trip it.\n | 'system.formSubmissionsPaused'\n // A site reached the flat platform ceiling on member accounts or on lead\n // records, so further ones are being refused (AGL-1529). `system.` for the\n // same reason as `formSubmissionsPaused` directly above, and with the same\n // sharpness: `content.` is the bucket an owner mutes to stop routine\n // sign-up and lead chatter, and this is the one notification saying the\n // sign-ups have STOPPED — muted exactly on the sites busy enough to trip it.\n | 'system.visitorRecordsPaused'\n // An outsider reported one of our sites for phishing, malware or CSAM\n // (AGL-1964). `system` for the AGL-1088 reason and, again, more sharply\n // than most: this notification goes to STAFF, not to a customer, and its\n // subject is somebody else's site. There is no bucket a recipient could\n // mute it into that would be honest — nobody has opted into being told a\n // stranger is being phished through our platform, and nobody should be able\n // to opt out of it either.\n //\n // Only the urgent categories raise one, and only on a first report. See the\n // fan-out in apps/tenant/app/api/report-abuse/route.ts for why: a flood of\n // alerts IS the flood, and the alert it would cost us is the phishing one.\n | 'system.abuseReportUrgent'\n // The §512(g) counter-notice (AGL-1983), and the one place the \"only urgent\n // categories raise a notification\" restraint above is deliberately not\n // applied. Every counter-notice carries a statutory deadline that is\n // ALREADY RUNNING when it arrives — the clock counts from the subscriber's\n // submission, not from our attention — so there is no low-value tail of\n // these to drown out the important ones, and the one that goes unread is a\n // customer locked out of their own work plus an unmet obligation under\n // §512(g)(2)(A). Raised on first submission only, like the one above, so a\n // resubmitting customer cannot re-alert.\n | 'system.dmcaCounterNotice'\n // A site crossed the per-month bandwidth abuse ceiling (AGL-2155). Two\n // audiences share one type: staff, because an uncompensated free site\n // serving six figures of page views is an incident; and the site's own\n // managers, because on free the site is now serving a capped notice and\n // nobody should learn that from a visitor.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this meter: on the free plan there is no bill, so `billing` would be the\n // one category where the message is literally never about money — and it is\n // exactly the free-tier trip that changes what visitors see.\n | 'system.bandwidthCeilingTripped'\n // A free site crossed its PLAN's included bandwidth and is now serving the\n // capped notice (AGL-2413). Distinct from the ceiling above on purpose: that\n // one is an incident at 10x the band and goes to staff first; this one is an\n // ordinary quota at 1x, concerns only the site's own managers, and its\n // remedy is an upgrade rather than an investigation. Sending both under one\n // type would tell an owner their traffic was being treated as suspected\n // abuse when it is simply a successful free site.\n | 'system.bandwidthCapEngaged'\n // A Stripe billing webhook threw AFTER its handlers had begun (AGL-2157),\n // so its side effects may be half applied and its idempotency claim is\n // being HELD — Stripe will not retry it. Staff audience: this is the one\n // failure on that route no automatic retry can make safe, because the\n // handlers behind it are not all idempotent, and a human has to reconcile.\n | 'system.billingWebhookHalfApplied'\n // A card dispute arrived that NOTHING in the platform claimed (AGL-2429):\n // no `platformRevenue` row, no storefront order, no marketplace purchase.\n // Staff audience, and the reason it needs a type of its own is that its\n // absence was the bug — the routine case (a storefront or marketplace\n // chargeback, which the plugins own) and the fault case were the same\n // silence from the route's side, so a dispute nobody handled looked\n // exactly like one somebody did.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this alert: `billing` is the category a recipient is most likely to have\n // muted as routine invoice traffic, and this is the one message on that\n // route where muting it means money moves with nobody looking.\n | 'system.disputeUnattributed'\n // Somebody created an account, and somebody created a workspace\n // (AGL-3225). Staff audience, and the only two types in the taxonomy whose\n // subject is the platform's own growth rather than anybody's work.\n //\n // `staff.`, and NOT `system.`, and the AGL-1088 note above is the reason\n // rather than an exception to it: `system` is the bucket nobody mutes to\n // reduce noise, which is exactly what makes it the wrong home for the two\n // routine, high-volume events in the product. A staff member must be able\n // to stop hearing about every sign-up without also dropping the verifier\n // regression and the unattributed dispute that share that bucket — and\n // before this category existed, there was nowhere to put them where that\n // was true.\n //\n // The category also tells the settings page who a row is for: `staff` is\n // rendered only to claim holders, so no customer is shown a switch for\n // notifications they could never receive.\n | 'staff.userSignedUp'\n | 'staff.orgCreated'\n\nexport interface AglynNotification {\n $id?: string\n type: AglynNotificationType\n title: string\n body?: string\n /** Console path the notification opens (e.g. a host inbox). */\n link?: string\n orgId?: string\n hostId?: string\n createdAt?: ITimestamp\n /** Set by the owner when read; unread while absent. */\n readAt?: ITimestamp | null\n}\n\nexport const NOTIFICATION_TYPE_LABELS: Record<AglynNotificationType, string> = {\n 'billing.invoice': 'Invoice available',\n 'billing.paymentFailed': 'Payment failed',\n 'billing.subscriptionCanceled': 'Subscription canceled',\n 'billing.usage': 'Usage threshold',\n 'team.invite': 'Team invite',\n 'team.roleChanged': 'Role changed',\n 'team.hostAccessGranted': 'Site access granted',\n 'content.formSubmission': 'Form submission',\n 'content.booking': 'New booking',\n 'content.order': 'New order',\n 'content.lowStock': 'Low stock',\n 'content.taskAssigned': 'Task assigned to you',\n 'content.taskReminder': 'Task reminder',\n 'content.contactAssigned': 'Contact assigned to you',\n 'content.leadAssigned': 'Lead assigned to you',\n 'content.crmDailyDigest': 'Daily CRM digest',\n 'content.insightsDigest': 'Weekly insights',\n 'marketplace.review': 'Listing review',\n\n 'support.ticketOpened': 'New support ticket',\n 'support.ticketReply': 'Support ticket reply',\n 'system.announcement': 'Announcement',\n 'system.pluginVerifierRegression': 'Plugin verifier regression',\n 'system.signInMethodRemoved': 'Sign-in method removed',\n 'system.scopeDrift': 'Resources missing a sharing scope',\n 'system.ssoDomainUnverified': 'SSO domain no longer proves ownership',\n 'system.formSubmissionsPaused': 'Form submissions paused',\n 'system.visitorRecordsPaused': 'Sign-ups or leads paused',\n 'system.abuseReportUrgent': 'Urgent abuse report',\n 'system.dmcaCounterNotice': 'DMCA counter-notice',\n 'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',\n 'system.bandwidthCapEngaged': 'Monthly traffic limit reached',\n 'system.billingWebhookHalfApplied': 'Billing webhook half applied',\n 'system.disputeUnattributed': 'Card dispute with no owner',\n 'staff.userSignedUp': 'New account',\n 'staff.orgCreated': 'New workspace',\n}\n\n/** Preference buckets (AGL-267): the prefix before the dot. */\nexport type NotificationCategory =\n | 'billing'\n | 'team'\n | 'content'\n | 'marketplace'\n | 'support'\n | 'system'\n // Staff-only, and shown only to staff (AGL-3225) — see\n // {@link STAFF_NOTIFICATION_CATEGORIES}.\n | 'staff'\n\nexport const NOTIFICATION_CATEGORY_LABELS: Record<\n NotificationCategory,\n string\n> = {\n billing: 'Billing',\n team: 'Team & access',\n content: 'Forms & bookings',\n marketplace: 'Marketplace',\n support: 'Support',\n system: 'Product & system',\n staff: 'Platform growth',\n}\n\n/**\n * The categories only staff can receive (AGL-3225).\n *\n * The settings page hides these rows from everybody else, because a switch\n * for mail that can never arrive is a promise the product does not keep. It\n * is presentation only: `notifyStaff` is what decides the audience, and it\n * enumerates the `staff` claim rather than reading this.\n */\nexport const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory> =\n new Set<NotificationCategory>(['staff'])\n\nexport function notificationCategory(\n type: AglynNotificationType | string,\n): NotificationCategory {\n const prefix = String(type).split('.')[0]\n return (\n [\n 'billing',\n 'team',\n 'content',\n 'marketplace',\n 'support',\n 'system',\n 'staff',\n ].includes(prefix)\n ? prefix\n : 'system'\n ) as NotificationCategory\n}\n\n/**\n * Per-user mute map stored at `users/{uid}.notificationPrefs`\n * (`{ [category]: false }` mutes); absent categories stay on.\n */\nexport function notificationMuted(\n prefs: Record<string, boolean> | null | undefined,\n type: AglynNotificationType | string,\n): boolean {\n return prefs?.[notificationCategory(type)] === false\n}\n\n/**\n * The field on `users/{uid}` that holds a person's digest switches\n * (AGL-2619): `{ crmDaily: false }` turns the daily CRM digest off, and an\n * absent key leaves it on. Its own map rather than a key in\n * `notificationPrefs`, because that map is keyed by CATEGORY and read by\n * {@link notificationMuted} — a digest is a schedule a person keeps or\n * drops, not a bucket of types, and one switch governs both the console\n * notification and the email it travels with.\n */\nexport const DIGEST_PREFS_FIELD = 'digestPrefs'\n\n/**\n * The key the daily CRM digest keeps inside {@link DIGEST_PREFS_FIELD}.\n *\n * Exported so the console writes the switch through a name rather than a\n * second copy of the string (AGL-3230). The reader below and the page that\n * flips it were spelling the same literal in two files, which is how a\n * preference comes to be written under one key and read under another — and\n * it kept the plugin's vocabulary in a console page, where\n * `check:plugin-domain-in-core` rightly refuses it.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\nexport function crmDailyDigestEnabled(\n prefs: Record<string, boolean> | null | undefined,\n): boolean {\n return prefs?.[CRM_DAILY_DIGEST_KEY] !== false\n}\n\n/**\n * The field on `users/{uid}` naming the workspaces whose weekly insights a\n * person asked for (AGL-2915): `{ [orgId]: true }`. OPT-IN, unlike the CRM\n * digest — an absent key is off — because a weekly insight is generated, and a\n * generation spends the workspace's credits. The person turns it on beside the\n * answers themselves, where the plan, the release and their permission have\n * already been checked, and off again there or in Notifications; the weekly\n * sweep checks all three again before it spends anything.\n */\nexport const INSIGHT_DIGESTS_FIELD = 'insightDigests'\n\n/** Whether a person asked for a workspace's weekly insights. */\nexport function insightDigestSubscribed(\n value: Record<string, boolean> | null | undefined,\n orgId: string,\n): boolean {\n return Boolean(orgId) && value?.[orgId] === true\n}\n\n/**\n * The channels a notification can travel on (AGL-3223).\n *\n * `console` is the feed at `/manage/notifications` and the app-bar dropdown\n * that reads it — the only channel that existed. `email` is the message the\n * fan-out sends beside that doc when the recipient asked for one.\n */\nexport type NotificationChannel = 'console' | 'email'\n\n/**\n * One scope's answer for one category. **Tri-state on purpose**: `true` and\n * `false` decide, and an ABSENT key inherits from the scope above.\n *\n * Inheritance is the whole reason this is a partial rather than a pair of\n * booleans. A person who wants form submissions from one busy site and not\n * from the other five has to be able to say that about the one site without\n * restating their answer for every other category at every other scope — and\n * a two-valued leaf cannot express \"I have not said\", so every override would\n * have to be written out in full and would then stop tracking the account\n * default it was never meant to detach from.\n */\nexport interface NotificationChannelPrefs {\n console?: boolean\n email?: boolean\n}\n\nexport type NotificationCategoryPrefs = Partial<\n Record<NotificationCategory, NotificationChannelPrefs>\n>\n\n/**\n * Per-scope, per-channel notification preferences (AGL-3223), stored at\n * `users/{uid}.notificationSettings`.\n *\n * Three layers, narrowest first when resolving: the SITE a notification\n * concerns, then the WORKSPACE, then the account. Anything none of them\n * answers falls to {@link NOTIFICATION_CHANNEL_DEFAULTS}.\n *\n * ON THE USER DOCUMENT, not on `orgs/{orgId}/members/{uid}`, and that is a\n * cost decision rather than a modelling preference. `notifyUsers` already\n * does exactly one `getAll` over the recipients' user docs to read their\n * category mutes, so every layer living here means per-site preferences are\n * read for free on the fan-out's hot path. The member row would add a read\n * per recipient per notification to answer a question the document already in\n * hand could have answered.\n */\nexport interface NotificationSettings {\n account?: NotificationCategoryPrefs\n /** Keyed by org id. */\n orgs?: Record<string, NotificationCategoryPrefs>\n /** Keyed by host id — a site's own answer, narrower than its workspace's. */\n hosts?: Record<string, NotificationCategoryPrefs>\n}\n\nexport const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings'\n\n/**\n * What a category does when nobody has said otherwise.\n *\n * Console on, email OFF, everywhere. Email defaults off because the inbox is\n * not ours to fill: the product already sends transactional mail nobody opted\n * into — welcome, verification, invites, dunning, usage alerts — and every one\n * of those leaves on the same domain a customer's password reset depends on.\n * Turning a busy site's form submissions into mail by default would put that\n * domain's reputation behind traffic the recipient never asked for.\n *\n * Exhaustive `Record` deliberately: a new {@link NotificationCategory} is a\n * compile error here until somebody decides what it does by default, which is\n * the one question a new category must not be able to ship without answering.\n */\nexport const NOTIFICATION_CHANNEL_DEFAULTS: Record<\n NotificationCategory,\n Record<NotificationChannel, boolean>\n> = {\n billing: { console: true, email: false },\n team: { console: true, email: false },\n content: { console: true, email: false },\n marketplace: { console: true, email: false },\n support: { console: true, email: false },\n system: { console: true, email: false },\n // Console ON, because the complaint this answers is that staff never heard\n // about a sign-up at all; email off, like everything else, because that is\n // what the channel defaults to and a sign-up is not urgent enough to be the\n // exception that starts filling inboxes by default.\n staff: { console: true, email: false },\n}\n\n/**\n * The types that send their OWN email and must never be mailed again by the\n * generic channel (AGL-3224).\n *\n * Both digests compose a message the fan-out could not reproduce — a day's\n * owed tasks, a week's figures — and send it from their own route under their\n * own switch (`digestPrefs.crmDaily`, `insightDigests.{orgId}`). A recipient\n * who switches the `content` email channel on would otherwise receive the\n * digest twice: once as the digest, once as a one-line \"Daily CRM digest\"\n * notification saying that the digest happened.\n */\nexport const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string> =\n new Set<AglynNotificationType>([\n 'content.crmDailyDigest',\n 'content.insightsDigest',\n ])\n\n/**\n * Whether a channel is on for one notification, at its own scope.\n *\n * Resolution order, first answer wins: the notification's SITE, its\n * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope\n * that holds no entry for the category, or an entry with the channel key\n * absent, does not answer — see {@link NotificationChannelPrefs}.\n *\n * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces\n * (AGL-267), and it sits BELOW the account layer rather than beside it.\n * Nothing migrates it: a person who never opens the new settings page keeps\n * being governed by the mutes they set years ago, and the first thing they do\n * set on the account layer overrides the old map for that category without\n * disturbing the rest of it. It answers for `console` only, because the map\n * predates there being a second channel and reading a console mute as an\n * email preference would be inventing an answer its author never gave.\n */\nexport function notificationChannelEnabled(\n settings: NotificationSettings | null | undefined,\n channel: NotificationChannel,\n type: AglynNotificationType | string,\n scope?: { orgId?: string | null; hostId?: string | null },\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n const category = notificationCategory(type)\n const layers: Array<NotificationCategoryPrefs | undefined> = [\n scope?.hostId ? settings?.hosts?.[scope.hostId] : undefined,\n scope?.orgId ? settings?.orgs?.[scope.orgId] : undefined,\n settings?.account,\n ]\n for (const layer of layers) {\n const answer = layer?.[category]?.[channel]\n if (typeof answer === 'boolean') return answer\n }\n if (channel === 'console' && notificationMuted(legacyPrefs, type))\n return false\n return NOTIFICATION_CHANNEL_DEFAULTS[category][channel]\n}\n\n/**\n * What one scope says, with no inheritance applied — what the settings page\n * renders as Inherit / On / Off, and `undefined` is Inherit.\n *\n * `scope` names the layer directly rather than being derived from a\n * notification, because the page edits a layer whether or not anything has\n * ever arrived from it.\n */\nexport function notificationScopePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n category: NotificationCategory,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.account\n : scope.kind === 'org'\n ? settings?.orgs?.[scope.id]\n : settings?.hosts?.[scope.id]\n return layer?.[category]?.[channel]\n}\n\n/**\n * The scopes this person has said something different about — what the\n * settings page lists so an override is never somewhere you have to go\n * looking for.\n *\n * Returns ids, not names: the page holds the org and host rosters and this\n * module holds no lookups. An id with no name is still worth listing, because\n * a preference about a workspace somebody left is exactly the kind of\n * leftover that is invisible until it is listed.\n */\nexport function notificationOverriddenScopes(\n settings: NotificationSettings | null | undefined,\n): { orgIds: string[]; hostIds: string[] } {\n const named = (\n layers: Record<string, NotificationCategoryPrefs> | undefined,\n ) =>\n Object.entries(layers ?? {})\n .filter(([, prefs]) =>\n Object.values(prefs ?? {}).some((channels) =>\n Object.values(channels ?? {}).some(\n (value) => typeof value === 'boolean',\n ),\n ),\n )\n .map(([id]) => id)\n .sort()\n return { orgIds: named(settings?.orgs), hostIds: named(settings?.hosts) }\n}\n"],"names":["NOTIFICATION_TYPE_LABELS","NOTIFICATION_CATEGORY_LABELS","billing","team","content","marketplace","support","system","staff","STAFF_NOTIFICATION_CATEGORIES","Set","notificationCategory","type","prefix","String","split","includes","notificationMuted","prefs","DIGEST_PREFS_FIELD","CRM_DAILY_DIGEST_KEY","crmDailyDigestEnabled","INSIGHT_DIGESTS_FIELD","insightDigestSubscribed","value","orgId","Boolean","NOTIFICATION_SETTINGS_FIELD","NOTIFICATION_CHANNEL_DEFAULTS","console","email","NOTIFICATION_SELF_SENT_EMAIL_TYPES","notificationChannelEnabled","settings","channel","scope","legacyPrefs","category","layers","hostId","hosts","undefined","orgs","account","layer","answer","notificationScopePref","kind","id","notificationOverriddenScopes","named","Object","entries","filter","values","some","channels","map","sort","orgIds","hostIds"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\n\n/**\n * In-app notifications (AGL-259): per-user docs at\n * `users/{uid}/notifications/{id}`, written by the Admin SDK (emitters in\n * the API routes + the `notifyAdmins` automation step) and read/marked by\n * their owner. Types follow the common SaaS taxonomy so the console can\n * icon/group them without a registry.\n */\nexport type AglynNotificationType =\n | 'billing.invoice'\n | 'billing.paymentFailed'\n // Stripe gave up on a failed renewal and CANCELLED the subscription\n // (AGL-1877). Distinct from `paymentFailed`, which announces one failed\n // attempt inside a retry window the customer can still recover from; this\n // one is the window having closed. Measured on the test account with a\n // test clock: five attempts over 21.08 days, then\n // `cancellation_details.reason: 'payment_failed'` — and until this existed\n // the org went silently to Free at that moment, with the `past_due` banner\n // disappearing at exactly the instant the consequence arrived.\n | 'billing.subscriptionCanceled'\n | 'billing.usage'\n | 'team.invite'\n | 'team.roleChanged'\n | 'team.hostAccessGranted'\n | 'content.formSubmission'\n | 'content.booking'\n | 'content.order'\n | 'content.lowStock'\n // A CRM task was assigned to the recipient by somebody else (AGL-2599).\n // `content.` because it is the same kind of message as a form submission\n // or a booking: a thing on the site that now needs this person's hands,\n // not a change to their standing (`team.`) and not a platform notice\n // (`system.`). Someone who has muted the operational stream has said they\n // do not want to be told about work as it arrives, and a task is work.\n | 'content.taskAssigned'\n // A CRM task's reminder came due (AGL-2659): the hourly runner telling\n // the assignee, at the task's own time, that the task is due. `content.`\n // beside `taskAssigned` for the same reason it gives — work on the site,\n // not standing — and so the one mute that stops \"work arriving\" stops\n // \"work falling due\" with it. The mute governs the whole reminder, mail\n // included, unlike the digest's: a reminder is one message about one\n // task, and there is no schedule to keep separately from it.\n | 'content.taskReminder'\n // A contact, or a lead, became the recipient's to work (AGL-2618): a\n // capture the assignment rules or the site's default owner routed to\n // them, a lead somebody converted and handed to them, or an automation's\n // \"assign an owner\" step — every server path that writes `ownerUid`\n // except the recipient assigning themselves. Two types rather than one\n // because the record the person opens differs: a lead is the site's own\n // working record and a contact is the org's, and the link goes to the\n // one they will actually work. `content.`, beside `taskAssigned`, for the\n // same reason: work arriving, not standing changing.\n | 'content.contactAssigned'\n | 'content.leadAssigned'\n // The morning's CRM digest (AGL-2619): what the recipient owes today —\n // overdue and due-today tasks, and the leads nobody has worked. `content.`\n // for the reason `taskAssigned` is: it is about work on the site, and the\n // person who muted the operational stream has asked not to be told about\n // work. The digest EMAIL is governed separately, by\n // `crmDailyDigestEnabled`: the mute is a fact about the console feed and\n // the digest switch is a fact about the digest.\n | 'content.crmDailyDigest'\n // The weekly insights (AGL-2915): what a site's figures showed that week,\n // for a person who asked for them. `content.` beside the CRM digest, for the\n // reason that one gives: it is about the site's work, and the operational\n // mute governs the console notification while the person's own switch for\n // the digest governs the digest.\n | 'content.insightsDigest'\n // Marketplace review verdicts (AGL-432/653).\n | 'marketplace.review'\n // Support desk, staff audience (AGL-850): a subscriber opened or replied to\n // a ticket. Fanned out to staff-claim holders, not org members.\n | 'support.ticketOpened'\n | 'support.ticketReply'\n | 'system.announcement'\n // A live plugin version stopped passing the static verifier (AGL-1086).\n // Staff audience: bytes we told workspaces were checked now fail checks\n // that did not exist when they were approved.\n //\n // Deliberately NOT under `marketplace.` (AGL-1088). Category is the prefix,\n // categories are mutable per user, and Marketplace is the category a staff\n // member mutes to stop routine listing-review chatter — which would drop\n // this alert as collateral. `system` is the bucket nobody mutes to reduce\n // noise. The adminAudit record survives a mute either way; the timeliness\n // does not, and timeliness is the whole point of the alert.\n | 'system.pluginVerifierRegression'\n // A sign-in method was removed from the user's own account because their\n // organization turned on SSO enforcement (AGL-1129). `system.`, not\n // `team.`, for the AGL-1088 reason above: `team` is the category someone\n // mutes to stop routine roster chatter, and \"the way you sign in just\n // changed\" is not chatter — the next sign-in fails without it.\n | 'system.signInMethodRemoved'\n // Documents in a scoped collection carrying no `visibleTo` (AGL-1478).\n // Staff audience: the weekly dry run found resources that are invisible\n // to every site-scoped read, which always means a creation path forgot\n // the field. `system.` for the AGL-1088 reason above, and because the\n // collection it names may be `marketplace`-adjacent or not.\n | 'system.scopeDrift'\n // An SSO domain that WAS DNS-proven has stopped answering with our\n // challenge record, for three consecutive weekly sweeps (AGL-1210).\n // Nothing has been turned off — sign-in for that domain still routes\n // exactly as it did — and that is precisely why this has to be said out\n // loud rather than left in a log.\n //\n // `system.`, not `team.`, for the AGL-1088 reason above and one of its\n // own: the audience is the org's own admins, the subject is whether their\n // people can still sign in, and it must reach them BEFORE anybody decides\n // to revoke the routing. A category somebody mutes to quieten roster\n // chatter is the wrong place for the one warning that precedes a lockout.\n | 'system.ssoDomainUnverified'\n // A site hit the per-month form-submission abuse ceiling and further\n // submissions are being refused (AGL-1655). `system.`, not `content.`, for\n // the AGL-1088 reason above and more sharply than any of them: `content` is\n // literally the category a site owner mutes to stop routine form-submission\n // chatter, and this is the one form notification that says the form has\n // STOPPED accepting. Filing it under the muted bucket would guarantee it\n // reaches nobody on exactly the sites busy enough to trip it.\n | 'system.formSubmissionsPaused'\n // A site reached the flat platform ceiling on member accounts or on lead\n // records, so further ones are being refused (AGL-1529). `system.` for the\n // same reason as `formSubmissionsPaused` directly above, and with the same\n // sharpness: `content.` is the bucket an owner mutes to stop routine\n // sign-up and lead chatter, and this is the one notification saying the\n // sign-ups have STOPPED — muted exactly on the sites busy enough to trip it.\n | 'system.visitorRecordsPaused'\n // An outsider reported one of our sites for phishing, malware or CSAM\n // (AGL-1964). `system` for the AGL-1088 reason and, again, more sharply\n // than most: this notification goes to STAFF, not to a customer, and its\n // subject is somebody else's site. There is no bucket a recipient could\n // mute it into that would be honest — nobody has opted into being told a\n // stranger is being phished through our platform, and nobody should be able\n // to opt out of it either.\n //\n // Only the urgent categories raise one, and only on a first report. See the\n // fan-out in apps/tenant/app/api/report-abuse/route.ts for why: a flood of\n // alerts IS the flood, and the alert it would cost us is the phishing one.\n | 'system.abuseReportUrgent'\n // The §512(g) counter-notice (AGL-1983), and the one place the \"only urgent\n // categories raise a notification\" restraint above is deliberately not\n // applied. Every counter-notice carries a statutory deadline that is\n // ALREADY RUNNING when it arrives — the clock counts from the subscriber's\n // submission, not from our attention — so there is no low-value tail of\n // these to drown out the important ones, and the one that goes unread is a\n // customer locked out of their own work plus an unmet obligation under\n // §512(g)(2)(A). Raised on first submission only, like the one above, so a\n // resubmitting customer cannot re-alert.\n | 'system.dmcaCounterNotice'\n // A site crossed the per-month bandwidth abuse ceiling (AGL-2155). Two\n // audiences share one type: staff, because an uncompensated free site\n // serving six figures of page views is an incident; and the site's own\n // managers, because on free the site is now serving a capped notice and\n // nobody should learn that from a visitor.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this meter: on the free plan there is no bill, so `billing` would be the\n // one category where the message is literally never about money — and it is\n // exactly the free-tier trip that changes what visitors see.\n | 'system.bandwidthCeilingTripped'\n // A free site crossed its PLAN's included bandwidth and is now serving the\n // capped notice (AGL-2413). Distinct from the ceiling above on purpose: that\n // one is an incident at 10x the band and goes to staff first; this one is an\n // ordinary quota at 1x, concerns only the site's own managers, and its\n // remedy is an upgrade rather than an investigation. Sending both under one\n // type would tell an owner their traffic was being treated as suspected\n // abuse when it is simply a successful free site.\n | 'system.bandwidthCapEngaged'\n // A Stripe billing webhook threw AFTER its handlers had begun (AGL-2157),\n // so its side effects may be half applied and its idempotency claim is\n // being HELD — Stripe will not retry it. Staff audience: this is the one\n // failure on that route no automatic retry can make safe, because the\n // handlers behind it are not all idempotent, and a human has to reconcile.\n | 'system.billingWebhookHalfApplied'\n // A card dispute arrived that NOTHING in the platform claimed (AGL-2429):\n // no `platformRevenue` row, no storefront order, no marketplace purchase.\n // Staff audience, and the reason it needs a type of its own is that its\n // absence was the bug — the routine case (a storefront or marketplace\n // chargeback, which the plugins own) and the fault case were the same\n // silence from the route's side, so a dispute nobody handled looked\n // exactly like one somebody did.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this alert: `billing` is the category a recipient is most likely to have\n // muted as routine invoice traffic, and this is the one message on that\n // route where muting it means money moves with nobody looking.\n | 'system.disputeUnattributed'\n // Somebody created an account, and somebody created a workspace\n // (AGL-3225). Staff audience, and the only two types in the taxonomy whose\n // subject is the platform's own growth rather than anybody's work.\n //\n // `staff.`, and NOT `system.`, and the AGL-1088 note above is the reason\n // rather than an exception to it: `system` is the bucket nobody mutes to\n // reduce noise, which is exactly what makes it the wrong home for the two\n // routine, high-volume events in the product. A staff member must be able\n // to stop hearing about every sign-up without also dropping the verifier\n // regression and the unattributed dispute that share that bucket — and\n // before this category existed, there was nowhere to put them where that\n // was true.\n //\n // The category also tells the settings page who a row is for: `staff` is\n // rendered only to claim holders, so no customer is shown a switch for\n // notifications they could never receive.\n | 'staff.userSignedUp'\n | 'staff.orgCreated'\n\nexport interface AglynNotification {\n $id?: string\n type: AglynNotificationType\n title: string\n body?: string\n /** Console path the notification opens (e.g. a host inbox). */\n link?: string\n orgId?: string\n hostId?: string\n createdAt?: ITimestamp\n /** Set by the owner when read; unread while absent. */\n readAt?: ITimestamp | null\n}\n\nexport const NOTIFICATION_TYPE_LABELS: Record<AglynNotificationType, string> = {\n 'billing.invoice': 'Invoice available',\n 'billing.paymentFailed': 'Payment failed',\n 'billing.subscriptionCanceled': 'Subscription canceled',\n 'billing.usage': 'Usage threshold',\n 'team.invite': 'Team invite',\n 'team.roleChanged': 'Role changed',\n 'team.hostAccessGranted': 'Site access granted',\n 'content.formSubmission': 'Form submission',\n 'content.booking': 'New booking',\n 'content.order': 'New order',\n 'content.lowStock': 'Low stock',\n 'content.taskAssigned': 'Task assigned to you',\n 'content.taskReminder': 'Task reminder',\n 'content.contactAssigned': 'Contact assigned to you',\n 'content.leadAssigned': 'Lead assigned to you',\n 'content.crmDailyDigest': 'Daily CRM digest',\n 'content.insightsDigest': 'Weekly insights',\n 'marketplace.review': 'Listing review',\n\n 'support.ticketOpened': 'New support ticket',\n 'support.ticketReply': 'Support ticket reply',\n 'system.announcement': 'Announcement',\n 'system.pluginVerifierRegression': 'Plugin verifier regression',\n 'system.signInMethodRemoved': 'Sign-in method removed',\n 'system.scopeDrift': 'Resources missing a sharing scope',\n 'system.ssoDomainUnverified': 'SSO domain no longer proves ownership',\n 'system.formSubmissionsPaused': 'Form submissions paused',\n 'system.visitorRecordsPaused': 'Sign-ups or leads paused',\n 'system.abuseReportUrgent': 'Urgent abuse report',\n 'system.dmcaCounterNotice': 'DMCA counter-notice',\n 'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',\n 'system.bandwidthCapEngaged': 'Monthly traffic limit reached',\n 'system.billingWebhookHalfApplied': 'Billing webhook half applied',\n 'system.disputeUnattributed': 'Card dispute with no owner',\n 'staff.userSignedUp': 'New account',\n 'staff.orgCreated': 'New workspace',\n}\n\n/** Preference buckets (AGL-267): the prefix before the dot. */\nexport type NotificationCategory =\n | 'billing'\n | 'team'\n | 'content'\n | 'marketplace'\n | 'support'\n | 'system'\n // Staff-only, and shown only to staff (AGL-3225) — see\n // {@link STAFF_NOTIFICATION_CATEGORIES}.\n | 'staff'\n\nexport const NOTIFICATION_CATEGORY_LABELS: Record<\n NotificationCategory,\n string\n> = {\n billing: 'Billing',\n team: 'Team & access',\n content: 'Forms & bookings',\n marketplace: 'Marketplace',\n support: 'Support',\n system: 'Product & system',\n staff: 'Platform growth',\n}\n\n/**\n * The categories only staff can receive (AGL-3225).\n *\n * The settings page hides these rows from everybody else, because a switch\n * for mail that can never arrive is a promise the product does not keep. It\n * is presentation only: `notifyStaff` is what decides the audience, and it\n * enumerates the `staff` claim rather than reading this.\n */\nexport const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory> =\n new Set<NotificationCategory>(['staff'])\n\n/**\n * What each category actually covers, in the reader's words (AGL-3251).\n *\n * The settings page listed seven bare labels and two switches, so deciding\n * whether to silence `Product & system` meant guessing what was in it. The\n * type labels beneath each row now name the contents exactly; this is the\n * one-line answer for somebody who does not want to expand anything.\n *\n * Written as what ARRIVES rather than as what the bucket is called: \"someone\n * joins, a role changes\" is checkable against your own feed in a way that\n * \"team and access events\" is not. Exhaustive `Record`, like the channel\n * defaults below — a new category cannot ship without somebody saying in\n * plain words what lands in it.\n */\nexport const NOTIFICATION_CATEGORY_DESCRIPTIONS: Record<\n NotificationCategory,\n string\n> = {\n billing:\n 'Invoices, failed payments, cancellations, and usage that crosses a plan limit.',\n team: 'Somebody joins or leaves, a role changes, or a site is shared with you.',\n content:\n 'Work arriving on your sites: form submissions, bookings, orders, low stock, and the tasks and leads assigned to you.',\n marketplace: 'Decisions on plugin listings you submitted for review.',\n support: 'New support tickets and replies on tickets you are following.',\n system:\n 'Announcements, and the faults the platform finds in your account: sign-in methods removed, traffic limits reached, billing or sharing left in a broken state.',\n staff:\n 'New accounts and new workspaces across the whole platform. Only staff receive these.',\n}\n\nexport function notificationCategory(\n type: AglynNotificationType | string,\n): NotificationCategory {\n const prefix = String(type).split('.')[0]\n return (\n [\n 'billing',\n 'team',\n 'content',\n 'marketplace',\n 'support',\n 'system',\n 'staff',\n ].includes(prefix)\n ? prefix\n : 'system'\n ) as NotificationCategory\n}\n\n/**\n * Per-user mute map stored at `users/{uid}.notificationPrefs`\n * (`{ [category]: false }` mutes); absent categories stay on.\n */\nexport function notificationMuted(\n prefs: Record<string, boolean> | null | undefined,\n type: AglynNotificationType | string,\n): boolean {\n return prefs?.[notificationCategory(type)] === false\n}\n\n/**\n * The field on `users/{uid}` that holds a person's digest switches\n * (AGL-2619): `{ crmDaily: false }` turns the daily CRM digest off, and an\n * absent key leaves it on. Its own map rather than a key in\n * `notificationPrefs`, because that map is keyed by CATEGORY and read by\n * {@link notificationMuted} — a digest is a schedule a person keeps or\n * drops, not a bucket of types, and one switch governs both the console\n * notification and the email it travels with.\n */\nexport const DIGEST_PREFS_FIELD = 'digestPrefs'\n\n/**\n * The key the daily CRM digest keeps inside {@link DIGEST_PREFS_FIELD}.\n *\n * Exported so the console writes the switch through a name rather than a\n * second copy of the string (AGL-3230). The reader below and the page that\n * flips it were spelling the same literal in two files, which is how a\n * preference comes to be written under one key and read under another — and\n * it kept the plugin's vocabulary in a console page, where\n * `check:plugin-domain-in-core` rightly refuses it.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\nexport function crmDailyDigestEnabled(\n prefs: Record<string, boolean> | null | undefined,\n): boolean {\n return prefs?.[CRM_DAILY_DIGEST_KEY] !== false\n}\n\n/**\n * The field on `users/{uid}` naming the workspaces whose weekly insights a\n * person asked for (AGL-2915): `{ [orgId]: true }`. OPT-IN, unlike the CRM\n * digest — an absent key is off — because a weekly insight is generated, and a\n * generation spends the workspace's credits. The person turns it on beside the\n * answers themselves, where the plan, the release and their permission have\n * already been checked, and off again there or in Notifications; the weekly\n * sweep checks all three again before it spends anything.\n */\nexport const INSIGHT_DIGESTS_FIELD = 'insightDigests'\n\n/** Whether a person asked for a workspace's weekly insights. */\nexport function insightDigestSubscribed(\n value: Record<string, boolean> | null | undefined,\n orgId: string,\n): boolean {\n return Boolean(orgId) && value?.[orgId] === true\n}\n\n/**\n * The channels a notification can travel on (AGL-3223).\n *\n * `console` is the feed at `/manage/notifications` and the app-bar dropdown\n * that reads it — the only channel that existed. `email` is the message the\n * fan-out sends beside that doc when the recipient asked for one.\n */\nexport type NotificationChannel = 'console' | 'email'\n\n/**\n * One scope's answer for one category. **Tri-state on purpose**: `true` and\n * `false` decide, and an ABSENT key inherits from the scope above.\n *\n * Inheritance is the whole reason this is a partial rather than a pair of\n * booleans. A person who wants form submissions from one busy site and not\n * from the other five has to be able to say that about the one site without\n * restating their answer for every other category at every other scope — and\n * a two-valued leaf cannot express \"I have not said\", so every override would\n * have to be written out in full and would then stop tracking the account\n * default it was never meant to detach from.\n */\nexport interface NotificationChannelPrefs {\n console?: boolean\n email?: boolean\n}\n\nexport type NotificationCategoryPrefs = Partial<\n Record<NotificationCategory, NotificationChannelPrefs>\n>\n\n/**\n * One scope's answer for a single notification TYPE (AGL-3251), tri-state on\n * the same terms as {@link NotificationChannelPrefs}: an absent key falls\n * through to the category.\n *\n * Its own map rather than extra keys in {@link NotificationCategoryPrefs},\n * because that one is an exhaustive record keyed by category and a type key\n * inside it would type-check only by widening the thing that makes a missing\n * category a compile error.\n */\nexport type NotificationTypePrefs = Partial<\n Record<AglynNotificationType, NotificationChannelPrefs>\n>\n\n/**\n * Per-scope, per-channel notification preferences (AGL-3223), stored at\n * `users/{uid}.notificationSettings`.\n *\n * Three layers, narrowest first when resolving: the SITE a notification\n * concerns, then the WORKSPACE, then the account. Anything none of them\n * answers falls to {@link NOTIFICATION_CHANNEL_DEFAULTS}.\n *\n * ON THE USER DOCUMENT, not on `orgs/{orgId}/members/{uid}`, and that is a\n * cost decision rather than a modelling preference. `notifyUsers` already\n * does exactly one `getAll` over the recipients' user docs to read their\n * category mutes, so every layer living here means per-site preferences are\n * read for free on the fan-out's hot path. The member row would add a read\n * per recipient per notification to answer a question the document already in\n * hand could have answered.\n */\nexport interface NotificationSettings {\n account?: NotificationCategoryPrefs\n /**\n * Per-TYPE answers at the account scope (AGL-3251), consulted before\n * {@link NotificationSettings.account}'s category answer and never above a\n * narrower scope — see {@link notificationChannelEnabled}.\n *\n * ACCOUNT ONLY, deliberately. The per-workspace and per-site card is\n * already seven categories by three states by two channels, and a type row\n * for each of the ~thirty types, per workspace AND per site, is a page\n * nobody can read — which is the complaint this whole issue started as.\n * The account scope is where \"stop telling me about THIS\" belongs anyway:\n * it is a statement about the kind of thing, and kinds do not vary by site.\n */\n accountTypes?: NotificationTypePrefs\n /** Keyed by org id. */\n orgs?: Record<string, NotificationCategoryPrefs>\n /** Keyed by host id — a site's own answer, narrower than its workspace's. */\n hosts?: Record<string, NotificationCategoryPrefs>\n}\n\nexport const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings'\n\n/**\n * What a category does when nobody has said otherwise.\n *\n * Console on, email OFF, everywhere. Email defaults off because the inbox is\n * not ours to fill: the product already sends transactional mail nobody opted\n * into — welcome, verification, invites, dunning, usage alerts — and every one\n * of those leaves on the same domain a customer's password reset depends on.\n * Turning a busy site's form submissions into mail by default would put that\n * domain's reputation behind traffic the recipient never asked for.\n *\n * Exhaustive `Record` deliberately: a new {@link NotificationCategory} is a\n * compile error here until somebody decides what it does by default, which is\n * the one question a new category must not be able to ship without answering.\n */\nexport const NOTIFICATION_CHANNEL_DEFAULTS: Record<\n NotificationCategory,\n Record<NotificationChannel, boolean>\n> = {\n billing: { console: true, email: false },\n team: { console: true, email: false },\n content: { console: true, email: false },\n marketplace: { console: true, email: false },\n support: { console: true, email: false },\n system: { console: true, email: false },\n // Console ON, because the complaint this answers is that staff never heard\n // about a sign-up at all; email off, like everything else, because that is\n // what the channel defaults to and a sign-up is not urgent enough to be the\n // exception that starts filling inboxes by default.\n staff: { console: true, email: false },\n}\n\n/**\n * The types that send their OWN email and must never be mailed again by the\n * generic channel (AGL-3224).\n *\n * Both digests compose a message the fan-out could not reproduce — a day's\n * owed tasks, a week's figures — and send it from their own route under their\n * own switch (`digestPrefs.crmDaily`, `insightDigests.{orgId}`). A recipient\n * who switches the `content` email channel on would otherwise receive the\n * digest twice: once as the digest, once as a one-line \"Daily CRM digest\"\n * notification saying that the digest happened.\n */\nexport const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string> =\n new Set<AglynNotificationType>([\n 'content.crmDailyDigest',\n 'content.insightsDigest',\n ])\n\n/**\n * Whether a channel is on for one notification, at its own scope.\n *\n * Resolution order, first answer wins: the notification's SITE, its\n * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope\n * that holds no entry for the category, or an entry with the channel key\n * absent, does not answer — see {@link NotificationChannelPrefs}.\n *\n * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces\n * (AGL-267), and it sits BELOW the account layer rather than beside it.\n * Nothing migrates it: a person who never opens the new settings page keeps\n * being governed by the mutes they set years ago, and the first thing they do\n * set on the account layer overrides the old map for that category without\n * disturbing the rest of it. It answers for `console` only, because the map\n * predates there being a second channel and reading a console mute as an\n * email preference would be inventing an answer its author never gave.\n */\nexport function notificationChannelEnabled(\n settings: NotificationSettings | null | undefined,\n channel: NotificationChannel,\n type: AglynNotificationType | string,\n scope?: { orgId?: string | null; hostId?: string | null },\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n const category = notificationCategory(type)\n /*\n * The account layer answers TWICE, type before category (AGL-3251).\n *\n * The minimal extension of the rule above rather than a new one: the layers\n * keep their order, narrowest scope first, and only what \"the account says\"\n * gets finer. A person who switched `Payment failed` off has said something\n * about that type specifically, so it must beat their own `Billing` answer\n * — and it must NOT beat a narrower scope, because \"quiet down this one\n * noisy site\" is the question the scope layers exist to answer and a\n * type-level opinion about the kind of thing does not overrule it.\n */\n const layers: Array<NotificationChannelPrefs | undefined> = [\n scope?.hostId ? settings?.hosts?.[scope.hostId]?.[category] : undefined,\n scope?.orgId ? settings?.orgs?.[scope.orgId]?.[category] : undefined,\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n for (const layer of layers) {\n const answer = layer?.[channel]\n if (typeof answer === 'boolean') return answer\n }\n if (channel === 'console' && notificationMuted(legacyPrefs, type))\n return false\n return NOTIFICATION_CHANNEL_DEFAULTS[category][channel]\n}\n\n/**\n * What one scope says, with no inheritance applied — what the settings page\n * renders as Inherit / On / Off, and `undefined` is Inherit.\n *\n * `scope` names the layer directly rather than being derived from a\n * notification, because the page edits a layer whether or not anything has\n * ever arrived from it.\n */\n/**\n * What the account scope says about one TYPE, with no inheritance applied\n * (AGL-3251) — `undefined` means the row follows its category.\n *\n * The page needs the unresolved answer to draw the difference between \"on\n * because Billing is on\" and \"on because you said so\": only the second can be\n * reset, and a switch that cannot show which one it is leaves a person unable\n * to get back to following the category.\n */\nexport function notificationAccountTypePref(\n settings: NotificationSettings | null | undefined,\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n return settings?.accountTypes?.[type as AglynNotificationType]?.[channel]\n}\n\n/**\n * Every type in a category, in the order the labels declare them\n * (AGL-3251) — what the settings page expands a category row into.\n *\n * Derived from {@link NOTIFICATION_TYPE_LABELS} rather than held as a second\n * map, so a type added there appears under its category without anybody\n * remembering to list it twice.\n */\nexport function notificationTypesInCategory(\n category: NotificationCategory,\n): AglynNotificationType[] {\n return (\n Object.keys(NOTIFICATION_TYPE_LABELS) as AglynNotificationType[]\n ).filter((type) => notificationCategory(type) === category)\n}\n\nexport function notificationScopePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n category: NotificationCategory,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.account\n : scope.kind === 'org'\n ? settings?.orgs?.[scope.id]\n : settings?.hosts?.[scope.id]\n return layer?.[category]?.[channel]\n}\n\n/**\n * The scopes this person has said something different about — what the\n * settings page lists so an override is never somewhere you have to go\n * looking for.\n *\n * Returns ids, not names: the page holds the org and host rosters and this\n * module holds no lookups. An id with no name is still worth listing, because\n * a preference about a workspace somebody left is exactly the kind of\n * leftover that is invisible until it is listed.\n */\nexport function notificationOverriddenScopes(\n settings: NotificationSettings | null | undefined,\n): { orgIds: string[]; hostIds: string[] } {\n const named = (\n layers: Record<string, NotificationCategoryPrefs> | undefined,\n ) =>\n Object.entries(layers ?? {})\n .filter(([, prefs]) =>\n Object.values(prefs ?? {}).some((channels) =>\n Object.values(channels ?? {}).some(\n (value) => typeof value === 'boolean',\n ),\n ),\n )\n .map(([id]) => id)\n .sort()\n return { orgIds: named(settings?.orgs), hostIds: named(settings?.hosts) }\n}\n"],"names":["NOTIFICATION_TYPE_LABELS","NOTIFICATION_CATEGORY_LABELS","billing","team","content","marketplace","support","system","staff","STAFF_NOTIFICATION_CATEGORIES","Set","NOTIFICATION_CATEGORY_DESCRIPTIONS","notificationCategory","type","prefix","String","split","includes","notificationMuted","prefs","DIGEST_PREFS_FIELD","CRM_DAILY_DIGEST_KEY","crmDailyDigestEnabled","INSIGHT_DIGESTS_FIELD","insightDigestSubscribed","value","orgId","Boolean","NOTIFICATION_SETTINGS_FIELD","NOTIFICATION_CHANNEL_DEFAULTS","console","email","NOTIFICATION_SELF_SENT_EMAIL_TYPES","notificationChannelEnabled","settings","channel","scope","legacyPrefs","category","layers","hostId","hosts","undefined","orgs","accountTypes","account","layer","answer","notificationAccountTypePref","notificationTypesInCategory","Object","keys","filter","notificationScopePref","kind","id","notificationOverriddenScopes","named","entries","values","some","channels","map","sort","orgIds","hostIds"],"mappings":"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"}
@@ -29,7 +29,7 @@ import { HostScreenVisibility } from '../foundation/definitions/platform.types';
29
29
  * submission, and submitting a page you have marked noindex is the shape that
30
30
  * gets a site flagged for conflicting directives.
31
31
  *
32
- * There are two controls and they compose:
32
+ * There are three controls and they compose:
33
33
  *
34
34
  * - **Site-level** `host.seo.discourageSearchEngines` — the staged-launch
35
35
  * switch. Everything about the site goes dark to crawlers at once.
@@ -38,6 +38,11 @@ import { HostScreenVisibility } from '../foundation/definitions/platform.types';
38
38
  * `UNLISTED` is literally `PUBLIC | (1 << 2)`: "public, plus the not-listed
39
39
  * bit". A second per-screen field would be a field that can disagree with
40
40
  * this one, and every render surface would then need a rule for which wins.
41
+ * - **Per-collection** `collection.excludeEntriesFromSearch` (AGL-3247) — a
42
+ * content collection keeps its listing and its category listings indexable
43
+ * and withholds its ENTRIES. That is a different axis from the two above,
44
+ * not a third copy of either: the subject is the collection, and what it
45
+ * answers for is every URL underneath it.
41
46
  */
42
47
  /** The host fields this module reads; keeps callers free of the full doc. */
43
48
  export interface SearchIndexingHost {
@@ -54,6 +59,15 @@ export interface SearchIndexingHost {
54
59
  export interface SearchIndexingScreen {
55
60
  visibility?: HostScreenVisibility | null;
56
61
  }
62
+ /** The content-collection fields this module reads. */
63
+ export interface SearchIndexingCollection {
64
+ /**
65
+ * "List the collection, not its entries" (AGL-3247). PERSISTED NAME — flat
66
+ * on the collection document, where `schemaType` and `categories` already
67
+ * live, rather than under a `seo` map the collection has never carried.
68
+ */
69
+ excludeEntriesFromSearch?: boolean | null;
70
+ }
57
71
  /**
58
72
  * Site-wide opt-out. Absent means "index me" — the default a site is created
59
73
  * with, and the only safe default: a missing field must never be read as
@@ -73,11 +87,32 @@ export declare function isSearchDiscouraged(host: SearchIndexingHost | null | un
73
87
  * one whose *name* is about search.
74
88
  */
75
89
  export declare function isScreenIndexable(screen: SearchIndexingScreen | null | undefined): boolean;
76
- /** Both controls together — what a render surface actually wants to know. */
90
+ /**
91
+ * The site and screen controls together — what a SCREEN render wants to know.
92
+ *
93
+ * Collection entries do not come through here; their URL belongs to the
94
+ * collection rather than to a screen, so they ask
95
+ * {@link areCollectionEntriesIndexable} alongside {@link isSearchDiscouraged}.
96
+ */
77
97
  export declare function isPageIndexable(options: {
78
98
  host?: SearchIndexingHost | null;
79
99
  screen?: SearchIndexingScreen | null;
80
100
  }): boolean;
101
+ /**
102
+ * Whether a content collection's ENTRIES may be indexed, ignoring the
103
+ * site-level switch (AGL-3247).
104
+ *
105
+ * Absent means "index them" — the default every collection was created with,
106
+ * and the only safe default, exactly as for {@link isSearchDiscouraged}: a
107
+ * schema slip must never be readable as "de-index this customer's blog".
108
+ *
109
+ * This says nothing about the LISTING at `/{collection}` or the category
110
+ * listings under it. Those stay indexable on purpose — they are the pages
111
+ * that make the withheld entries reachable at all, and a collection whose
112
+ * whole point is that it is too numerous to index is still a collection a
113
+ * reader should be able to find.
114
+ */
115
+ export declare function areCollectionEntriesIndexable(collection: SearchIndexingCollection | null | undefined): boolean;
81
116
  /**
82
117
  * The `robots.txt` body for a host.
83
118
  *
@@ -39,10 +39,32 @@ import { SITEMAP_INDEX_PATH } from "./sitemap.js";
39
39
  if (visibility == null) return true;
40
40
  return visibility === HostScreenVisibility.PUBLIC;
41
41
  }
42
- /** Both controls together — what a render surface actually wants to know. */ export function isPageIndexable(options) {
42
+ /**
43
+ * The site and screen controls together — what a SCREEN render wants to know.
44
+ *
45
+ * Collection entries do not come through here; their URL belongs to the
46
+ * collection rather than to a screen, so they ask
47
+ * {@link areCollectionEntriesIndexable} alongside {@link isSearchDiscouraged}.
48
+ */ export function isPageIndexable(options) {
43
49
  if (isSearchDiscouraged(options.host)) return false;
44
50
  return isScreenIndexable(options.screen);
45
51
  }
52
+ /**
53
+ * Whether a content collection's ENTRIES may be indexed, ignoring the
54
+ * site-level switch (AGL-3247).
55
+ *
56
+ * Absent means "index them" — the default every collection was created with,
57
+ * and the only safe default, exactly as for {@link isSearchDiscouraged}: a
58
+ * schema slip must never be readable as "de-index this customer's blog".
59
+ *
60
+ * This says nothing about the LISTING at `/{collection}` or the category
61
+ * listings under it. Those stay indexable on purpose — they are the pages
62
+ * that make the withheld entries reachable at all, and a collection whose
63
+ * whole point is that it is too numerous to index is still a collection a
64
+ * reader should be able to find.
65
+ */ export function areCollectionEntriesIndexable(collection) {
66
+ return (collection == null ? void 0 : collection.excludeEntriesFromSearch) !== true;
67
+ }
46
68
  /**
47
69
  * The `robots.txt` body for a host.
48
70
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/search-indexing.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { HostScreenVisibility } from '../foundation/definitions/platform.types'\nimport { SITEMAP_INDEX_PATH } from './sitemap'\n\n/**\n * Search-indexing policy (AGL-1263): the single answer to \"may a crawler\n * index this?\", shared by every surface that has to agree about it.\n *\n * Four surfaces answer that question and they must never disagree — the\n * server `generateMetadata`, its client `<Head>` twin, `robots.txt` and\n * `sitemap.xml`. Before this module three of them had their own copy of the\n * rule and the fourth had none: the sitemap listed whatever was in the host's\n * routing map, so an UNLISTED page was simultaneously told \"do not index me\"\n * in its own head and advertised as a canonical URL in the site's sitemap.\n * That is not a harmless duplication — a sitemap entry is an explicit\n * submission, and submitting a page you have marked noindex is the shape that\n * gets a site flagged for conflicting directives.\n *\n * There are two controls and they compose:\n *\n * - **Site-level** `host.seo.discourageSearchEngines` — the staged-launch\n * switch. Everything about the site goes dark to crawlers at once.\n * - **Per-screen** `screen.visibility` — the existing\n * {@link HostScreenVisibility} model, NOT a parallel `noindex` flag.\n * `UNLISTED` is literally `PUBLIC | (1 << 2)`: \"public, plus the not-listed\n * bit\". A second per-screen field would be a field that can disagree with\n * this one, and every render surface would then need a rule for which wins.\n */\n\n/** The host fields this module reads; keeps callers free of the full doc. */\nexport interface SearchIndexingHost {\n seo?: {\n /**\n * Site-wide \"discourage search engines\" (AGL-1263). PERSISTED NAME — the\n * wording matches the switch an author sees, so support conversations and\n * the document agree.\n */\n discourageSearchEngines?: boolean\n } | null\n}\n\n/** The screen fields this module reads. */\nexport interface SearchIndexingScreen {\n visibility?: HostScreenVisibility | null\n}\n\n/**\n * Site-wide opt-out. Absent means \"index me\" — the default a site is created\n * with, and the only safe default: a missing field must never be read as\n * \"hide this site\", or a schema slip would quietly de-index every customer.\n */\nexport function isSearchDiscouraged(\n host: SearchIndexingHost | null | undefined,\n): boolean {\n return host?.seo?.discourageSearchEngines === true\n}\n\n/**\n * Whether a screen may be indexed on its own merits, ignoring the site-level\n * switch.\n *\n * Only `PUBLIC` — and an absent value, which every screen predating the\n * visibility model carries — is indexable. Everything else is excluded, and\n * that is wider than the old `=== UNLISTED` test on purpose: a\n * password-protected, members-only or private screen has nothing a crawler\n * can reach, so listing it in a sitemap published a URL that answers with a\n * gate. `UNLISTED` was the only one anyone remembered because it is the only\n * one whose *name* is about search.\n */\nexport function isScreenIndexable(\n screen: SearchIndexingScreen | null | undefined,\n): boolean {\n const visibility = screen?.visibility\n if (visibility == null) return true\n return visibility === HostScreenVisibility.PUBLIC\n}\n\n/** Both controls together — what a render surface actually wants to know. */\nexport function isPageIndexable(options: {\n host?: SearchIndexingHost | null\n screen?: SearchIndexingScreen | null\n}): boolean {\n if (isSearchDiscouraged(options.host)) return false\n return isScreenIndexable(options.screen)\n}\n\n/**\n * The `robots.txt` body for a host.\n *\n * When search is discouraged the file disallows everything AND the pages\n * carry `noindex`, which reads like a contradiction and is not. `Disallow`\n * only asks a crawler not to FETCH; a URL linked from elsewhere can still be\n * indexed without ever being fetched, and a crawler that obeys the disallow\n * never sees the `noindex` that would have stopped it. Belt and braces is the\n * documented remedy, and it is what every other builder's equivalent switch\n * does.\n *\n * The `Sitemap:` line is dropped in that state rather than kept: handing a\n * crawler an index of the site you just asked it not to crawl is the\n * contradiction the rest of this module exists to remove.\n */\nexport function buildRobotsTxt(options: {\n host?: SearchIndexingHost | null\n /** Absolute origin (no trailing slash); omitted when unresolvable. */\n origin?: string\n}): string {\n if (isSearchDiscouraged(options.host)) {\n /*\n Every named agent group is dropped along with the wildcard. A group that\n named an agent and allowed it would be a LOUDER permission than the\n wildcard's refusal — `robots.txt` precedence gives the most specific\n matching group, so `User-agent: ClaudeBot` beats `User-agent: *` — and a\n site that asked to be left alone would be inviting exactly the readers it\n asked to leave.\n */\n return 'User-agent: *\\nDisallow: /\\n'\n }\n const sitemap = options.origin\n ? `Sitemap: ${options.origin}${SITEMAP_INDEX_PATH}\\n`\n : ''\n /*\n AI agents are named EXPLICITLY, and the group says exactly what the\n wildcard above already says.\n\n That looks redundant and is not. A wildcard `Allow: /` is the absence of a\n restriction; a named group is a STATEMENT, and several readers only look\n for the statement: an AI crawler checks for a group bearing its own token\n before it checks the wildcard, some readiness audits score a site on\n whether one exists, and an operator reading this file learns that admitting\n these agents is a decision somebody made rather than a default nobody\n revisited.\n\n It is also the file that will be edited when the decision changes. A site\n owner who wants to exclude one model's crawler edits its line here; before\n this list existed there was no line to edit, and the only lever was a\n wildcard `Disallow` that would have taken Google with it.\n\n ⚠️ `robots.txt` is a REQUEST, not a control. It stops a crawler that reads\n it and honors it, and nothing else — it is not an access control, and the\n WAF is where an agent is actually admitted or refused. The two have to\n agree: a site that names ClaudeBot here and challenges it at the edge has\n published an invitation it does not honor.\n */\n const agents = AI_AGENT_USER_AGENTS.map(\n (agent) => `User-agent: ${agent}\\nAllow: /\\n`,\n ).join('\\n')\n return `User-agent: *\\nAllow: /\\n\\n${agents}\\n${sitemap}`\n}\n\n/**\n * The AI agent user agents a site admits by name.\n *\n * Two kinds, kept in one list because `robots.txt` does not distinguish them\n * and a site owner reading the file should see everything that is admitted:\n *\n * - **Fetchers** — `ChatGPT-User`, `Claude-User`, `Perplexity-User`,\n * `Claude-SearchBot`, `ora-agent`. These act for a person who is asking\n * about the site RIGHT NOW. Refusing one does not protect anything; it\n * means the answer that person gets is written without the site's own\n * words in it.\n * - **Trainers and indexers** — `GPTBot`, `ClaudeBot`, `Google-Extended`,\n * `PerplexityBot`, `Applebot-Extended`, `CCBot`, `Bytespider`,\n * `Amazonbot`, `meta-externalagent`, `DeepSeekBot`, `cohere-ai`,\n * `Diffbot`, `Omgilibot`, `Timpibot`, `YouBot`. These read for a corpus.\n *\n * Admitting the second group is a judgment, and it is the platform DEFAULT\n * rather than a permanent answer: a tenant site is published to be read, and\n * a site that is absent from the corpora is absent from the answers. A site\n * owner who disagrees has `discourageSearchEngines`, which refuses everything\n * here in one switch.\n *\n * Spellings are the vendors' own tokens and are case-sensitive to some\n * readers. Do not \"tidy\" them.\n */\nexport const AI_AGENT_USER_AGENTS: readonly string[] = [\n 'GPTBot',\n 'ChatGPT-User',\n 'OAI-SearchBot',\n 'ClaudeBot',\n 'Claude-User',\n 'Claude-SearchBot',\n 'anthropic-ai',\n 'Google-Extended',\n 'PerplexityBot',\n 'Perplexity-User',\n 'Applebot-Extended',\n 'meta-externalagent',\n 'Amazonbot',\n 'Bytespider',\n 'CCBot',\n 'cohere-ai',\n 'DeepSeekBot',\n 'Diffbot',\n 'MistralAI-User',\n 'Omgilibot',\n 'Timpibot',\n 'YouBot',\n 'ora-agent',\n /*\n OUR OWN CLI (AGL-2717), and it belongs in this list rather than beside it.\n\n `@aglyn/cli` reads a site on a person's behalf — the same job\n `ChatGPT-User` and `Perplexity-User` do, and the same reason they are here.\n It names itself `aglyn-cli/<version> (+https://aglyn.com)` so a site\n operator has something to match on and somewhere to complain to.\n\n It is in THIS list, and not only in the WAF, because\n `check:agent-readiness` asserts the two are the same set: a client admitted\n at the edge but unnamed here is a client nobody reading `robots.txt` knows\n is admitted. The wildcard above already allows it, so the group changes\n nothing functionally — it is the statement that matters, exactly as for the\n twenty-three above.\n */\n 'aglyn-cli',\n]\n\n/**\n * Screen ids that are PUBLISHED but are HTTP STATUS pages, and must never be\n * handed to a crawler or an agent (AGL-2716).\n *\n * ⚠️ NOT `screen-route.ts`'s `nonPageScreenIds`, which is a BILLING predicate:\n * that one answers \"which live documents does the flat infrastructure cap\n * bound\" and counts email documents and entry templates. Two questions, two\n * answers, and merging them would make a pricing cap decide what a crawler may\n * read. The name here says `status` for that reason.\n *\n * Two kinds, and both were found the hard way:\n *\n * - **Bound error screens.** `aglyn.com` published `/401`, `/404` and `/503`\n * into its sitemap, which hands a new domain the worst possible first crawl:\n * the crawler fetches each one, gets the status code it exists to represent,\n * and logs crawl errors and soft-404s against a site it has never seen.\n * - **A bare status code as the whole path.** An error screen on this platform\n * is usually just a screen published at the status path with nothing binding\n * it, so filtering the bound ids alone changed nothing on the site that had\n * the problem. The pattern is anchored, so a real page at `/404-guide` or\n * `/products/503` keeps its place.\n *\n * Extracted from `/api/sitemap`, where it was written inline, so `/llms.txt`\n * and every later reader apply the same rule. The one thing this MUST NOT\n * become is a second copy of that filter: the two surfaces disagreeing is how\n * a site advertises to agents the URLs it withheld from crawlers.\n *\n * Template screens are NOT here. They are excluded too, but the source is a\n * Firestore read (`getTemplateScreenIds`) rather than the host document, so\n * they cannot be decided by a pure function.\n */\nexport function statusPageScreenIds(\n host:\n | {\n screens?: Record<string, string | undefined> | null\n errorScreens?: Record<string, string | undefined> | null\n notFoundScreenId?: string | null\n }\n | null\n | undefined,\n): Set<string> {\n const excluded = new Set<string>()\n for (const bound of [\n ...Object.values(host?.errorScreens ?? {}),\n host?.notFoundScreenId,\n ]) {\n if (typeof bound === 'string' && bound) excluded.add(bound)\n }\n for (const [screenId, path] of Object.entries(host?.screens ?? {})) {\n if (STATUS_CODE_PATH.test(String(path))) excluded.add(screenId)\n }\n return excluded\n}\n\n/** A bare HTTP status code as an entire route path — `/404`, `503`. */\nconst STATUS_CODE_PATH = /^\\/?[1-5][0-9][0-9]$/\n"],"names":["HostScreenVisibility","SITEMAP_INDEX_PATH","isSearchDiscouraged","host","seo","discourageSearchEngines","isScreenIndexable","screen","visibility","PUBLIC","isPageIndexable","options","buildRobotsTxt","sitemap","origin","agents","AI_AGENT_USER_AGENTS","map","agent","join","statusPageScreenIds","excluded","Set","bound","Object","values","errorScreens","notFoundScreenId","add","screenId","path","entries","screens","STATUS_CODE_PATH","test","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,8CAA0C;AAC/E,SAASC,kBAAkB,QAAQ,eAAW;AA4C9C;;;;CAIC,GACD,OAAO,SAASC,oBACdC,IAA2C;QAEpCA;IAAP,OAAOA,CAAAA,yBAAAA,YAAAA,KAAMC,GAAG,qBAATD,UAAWE,uBAAuB,MAAK;AAChD;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,kBACdC,MAA+C;IAE/C,MAAMC,aAAaD,0BAAAA,OAAQC,UAAU;IACrC,IAAIA,cAAc,MAAM,OAAO;IAC/B,OAAOA,eAAeR,qBAAqBS,MAAM;AACnD;AAEA,2EAA2E,GAC3E,OAAO,SAASC,gBAAgBC,OAG/B;IACC,IAAIT,oBAAoBS,QAAQR,IAAI,GAAG,OAAO;IAC9C,OAAOG,kBAAkBK,QAAQJ,MAAM;AACzC;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASK,eAAeD,OAI9B;IACC,IAAIT,oBAAoBS,QAAQR,IAAI,GAAG;QACrC;;;;;;;IAOA,GACA,OAAO;IACT;IACA,MAAMU,UAAUF,QAAQG,MAAM,GAC1B,CAAC,SAAS,EAAEH,QAAQG,MAAM,GAAGb,mBAAmB,EAAE,CAAC,GACnD;IACJ;;;;;;;;;;;;;;;;;;;;;;EAsBA,GACA,MAAMc,SAASC,qBAAqBC,GAAG,CACrC,CAACC,QAAU,CAAC,YAAY,EAAEA,MAAM,YAAY,CAAC,EAC7CC,IAAI,CAAC;IACP,OAAO,CAAC,2BAA2B,EAAEJ,OAAO,EAAE,EAAEF,SAAS;AAC3D;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,MAAMG,uBAA0C;IACrD;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;;;;;;;;;;;;;EAcA,GACA;CACD,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASI,oBACdjB,IAOa;;IAEb,MAAMkB,WAAW,IAAIC;IACrB,KAAK,MAAMC,SAAS;WACfC,OAAOC,MAAM,SAACtB,wBAAAA,KAAMuB,YAAY,mBAAI,CAAC;QACxCvB,wBAAAA,KAAMwB,gBAAgB;KACvB,CAAE;QACD,IAAI,OAAOJ,UAAU,YAAYA,OAAOF,SAASO,GAAG,CAACL;IACvD;IACA,KAAK,MAAM,CAACM,UAAUC,KAAK,IAAIN,OAAOO,OAAO,UAAC5B,wBAAAA,KAAM6B,OAAO,oBAAI,CAAC,GAAI;QAClE,IAAIC,iBAAiBC,IAAI,CAACC,OAAOL,QAAQT,SAASO,GAAG,CAACC;IACxD;IACA,OAAOR;AACT;AAEA,qEAAqE,GACrE,MAAMY,mBAAmB"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/search-indexing.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { HostScreenVisibility } from '../foundation/definitions/platform.types'\nimport { SITEMAP_INDEX_PATH } from './sitemap'\n\n/**\n * Search-indexing policy (AGL-1263): the single answer to \"may a crawler\n * index this?\", shared by every surface that has to agree about it.\n *\n * Four surfaces answer that question and they must never disagree — the\n * server `generateMetadata`, its client `<Head>` twin, `robots.txt` and\n * `sitemap.xml`. Before this module three of them had their own copy of the\n * rule and the fourth had none: the sitemap listed whatever was in the host's\n * routing map, so an UNLISTED page was simultaneously told \"do not index me\"\n * in its own head and advertised as a canonical URL in the site's sitemap.\n * That is not a harmless duplication — a sitemap entry is an explicit\n * submission, and submitting a page you have marked noindex is the shape that\n * gets a site flagged for conflicting directives.\n *\n * There are three controls and they compose:\n *\n * - **Site-level** `host.seo.discourageSearchEngines` — the staged-launch\n * switch. Everything about the site goes dark to crawlers at once.\n * - **Per-screen** `screen.visibility` — the existing\n * {@link HostScreenVisibility} model, NOT a parallel `noindex` flag.\n * `UNLISTED` is literally `PUBLIC | (1 << 2)`: \"public, plus the not-listed\n * bit\". A second per-screen field would be a field that can disagree with\n * this one, and every render surface would then need a rule for which wins.\n * - **Per-collection** `collection.excludeEntriesFromSearch` (AGL-3247) — a\n * content collection keeps its listing and its category listings indexable\n * and withholds its ENTRIES. That is a different axis from the two above,\n * not a third copy of either: the subject is the collection, and what it\n * answers for is every URL underneath it.\n */\n\n/** The host fields this module reads; keeps callers free of the full doc. */\nexport interface SearchIndexingHost {\n seo?: {\n /**\n * Site-wide \"discourage search engines\" (AGL-1263). PERSISTED NAME — the\n * wording matches the switch an author sees, so support conversations and\n * the document agree.\n */\n discourageSearchEngines?: boolean\n } | null\n}\n\n/** The screen fields this module reads. */\nexport interface SearchIndexingScreen {\n visibility?: HostScreenVisibility | null\n}\n\n/** The content-collection fields this module reads. */\nexport interface SearchIndexingCollection {\n /**\n * \"List the collection, not its entries\" (AGL-3247). PERSISTED NAME — flat\n * on the collection document, where `schemaType` and `categories` already\n * live, rather than under a `seo` map the collection has never carried.\n */\n excludeEntriesFromSearch?: boolean | null\n}\n\n/**\n * Site-wide opt-out. Absent means \"index me\" — the default a site is created\n * with, and the only safe default: a missing field must never be read as\n * \"hide this site\", or a schema slip would quietly de-index every customer.\n */\nexport function isSearchDiscouraged(\n host: SearchIndexingHost | null | undefined,\n): boolean {\n return host?.seo?.discourageSearchEngines === true\n}\n\n/**\n * Whether a screen may be indexed on its own merits, ignoring the site-level\n * switch.\n *\n * Only `PUBLIC` — and an absent value, which every screen predating the\n * visibility model carries — is indexable. Everything else is excluded, and\n * that is wider than the old `=== UNLISTED` test on purpose: a\n * password-protected, members-only or private screen has nothing a crawler\n * can reach, so listing it in a sitemap published a URL that answers with a\n * gate. `UNLISTED` was the only one anyone remembered because it is the only\n * one whose *name* is about search.\n */\nexport function isScreenIndexable(\n screen: SearchIndexingScreen | null | undefined,\n): boolean {\n const visibility = screen?.visibility\n if (visibility == null) return true\n return visibility === HostScreenVisibility.PUBLIC\n}\n\n/**\n * The site and screen controls together — what a SCREEN render wants to know.\n *\n * Collection entries do not come through here; their URL belongs to the\n * collection rather than to a screen, so they ask\n * {@link areCollectionEntriesIndexable} alongside {@link isSearchDiscouraged}.\n */\nexport function isPageIndexable(options: {\n host?: SearchIndexingHost | null\n screen?: SearchIndexingScreen | null\n}): boolean {\n if (isSearchDiscouraged(options.host)) return false\n return isScreenIndexable(options.screen)\n}\n\n/**\n * Whether a content collection's ENTRIES may be indexed, ignoring the\n * site-level switch (AGL-3247).\n *\n * Absent means \"index them\" — the default every collection was created with,\n * and the only safe default, exactly as for {@link isSearchDiscouraged}: a\n * schema slip must never be readable as \"de-index this customer's blog\".\n *\n * This says nothing about the LISTING at `/{collection}` or the category\n * listings under it. Those stay indexable on purpose — they are the pages\n * that make the withheld entries reachable at all, and a collection whose\n * whole point is that it is too numerous to index is still a collection a\n * reader should be able to find.\n */\nexport function areCollectionEntriesIndexable(\n collection: SearchIndexingCollection | null | undefined,\n): boolean {\n return collection?.excludeEntriesFromSearch !== true\n}\n\n/**\n * The `robots.txt` body for a host.\n *\n * When search is discouraged the file disallows everything AND the pages\n * carry `noindex`, which reads like a contradiction and is not. `Disallow`\n * only asks a crawler not to FETCH; a URL linked from elsewhere can still be\n * indexed without ever being fetched, and a crawler that obeys the disallow\n * never sees the `noindex` that would have stopped it. Belt and braces is the\n * documented remedy, and it is what every other builder's equivalent switch\n * does.\n *\n * The `Sitemap:` line is dropped in that state rather than kept: handing a\n * crawler an index of the site you just asked it not to crawl is the\n * contradiction the rest of this module exists to remove.\n */\nexport function buildRobotsTxt(options: {\n host?: SearchIndexingHost | null\n /** Absolute origin (no trailing slash); omitted when unresolvable. */\n origin?: string\n}): string {\n if (isSearchDiscouraged(options.host)) {\n /*\n Every named agent group is dropped along with the wildcard. A group that\n named an agent and allowed it would be a LOUDER permission than the\n wildcard's refusal — `robots.txt` precedence gives the most specific\n matching group, so `User-agent: ClaudeBot` beats `User-agent: *` — and a\n site that asked to be left alone would be inviting exactly the readers it\n asked to leave.\n */\n return 'User-agent: *\\nDisallow: /\\n'\n }\n const sitemap = options.origin\n ? `Sitemap: ${options.origin}${SITEMAP_INDEX_PATH}\\n`\n : ''\n /*\n AI agents are named EXPLICITLY, and the group says exactly what the\n wildcard above already says.\n\n That looks redundant and is not. A wildcard `Allow: /` is the absence of a\n restriction; a named group is a STATEMENT, and several readers only look\n for the statement: an AI crawler checks for a group bearing its own token\n before it checks the wildcard, some readiness audits score a site on\n whether one exists, and an operator reading this file learns that admitting\n these agents is a decision somebody made rather than a default nobody\n revisited.\n\n It is also the file that will be edited when the decision changes. A site\n owner who wants to exclude one model's crawler edits its line here; before\n this list existed there was no line to edit, and the only lever was a\n wildcard `Disallow` that would have taken Google with it.\n\n ⚠️ `robots.txt` is a REQUEST, not a control. It stops a crawler that reads\n it and honors it, and nothing else — it is not an access control, and the\n WAF is where an agent is actually admitted or refused. The two have to\n agree: a site that names ClaudeBot here and challenges it at the edge has\n published an invitation it does not honor.\n */\n const agents = AI_AGENT_USER_AGENTS.map(\n (agent) => `User-agent: ${agent}\\nAllow: /\\n`,\n ).join('\\n')\n return `User-agent: *\\nAllow: /\\n\\n${agents}\\n${sitemap}`\n}\n\n/**\n * The AI agent user agents a site admits by name.\n *\n * Two kinds, kept in one list because `robots.txt` does not distinguish them\n * and a site owner reading the file should see everything that is admitted:\n *\n * - **Fetchers** — `ChatGPT-User`, `Claude-User`, `Perplexity-User`,\n * `Claude-SearchBot`, `ora-agent`. These act for a person who is asking\n * about the site RIGHT NOW. Refusing one does not protect anything; it\n * means the answer that person gets is written without the site's own\n * words in it.\n * - **Trainers and indexers** — `GPTBot`, `ClaudeBot`, `Google-Extended`,\n * `PerplexityBot`, `Applebot-Extended`, `CCBot`, `Bytespider`,\n * `Amazonbot`, `meta-externalagent`, `DeepSeekBot`, `cohere-ai`,\n * `Diffbot`, `Omgilibot`, `Timpibot`, `YouBot`. These read for a corpus.\n *\n * Admitting the second group is a judgment, and it is the platform DEFAULT\n * rather than a permanent answer: a tenant site is published to be read, and\n * a site that is absent from the corpora is absent from the answers. A site\n * owner who disagrees has `discourageSearchEngines`, which refuses everything\n * here in one switch.\n *\n * Spellings are the vendors' own tokens and are case-sensitive to some\n * readers. Do not \"tidy\" them.\n */\nexport const AI_AGENT_USER_AGENTS: readonly string[] = [\n 'GPTBot',\n 'ChatGPT-User',\n 'OAI-SearchBot',\n 'ClaudeBot',\n 'Claude-User',\n 'Claude-SearchBot',\n 'anthropic-ai',\n 'Google-Extended',\n 'PerplexityBot',\n 'Perplexity-User',\n 'Applebot-Extended',\n 'meta-externalagent',\n 'Amazonbot',\n 'Bytespider',\n 'CCBot',\n 'cohere-ai',\n 'DeepSeekBot',\n 'Diffbot',\n 'MistralAI-User',\n 'Omgilibot',\n 'Timpibot',\n 'YouBot',\n 'ora-agent',\n /*\n OUR OWN CLI (AGL-2717), and it belongs in this list rather than beside it.\n\n `@aglyn/cli` reads a site on a person's behalf — the same job\n `ChatGPT-User` and `Perplexity-User` do, and the same reason they are here.\n It names itself `aglyn-cli/<version> (+https://aglyn.com)` so a site\n operator has something to match on and somewhere to complain to.\n\n It is in THIS list, and not only in the WAF, because\n `check:agent-readiness` asserts the two are the same set: a client admitted\n at the edge but unnamed here is a client nobody reading `robots.txt` knows\n is admitted. The wildcard above already allows it, so the group changes\n nothing functionally — it is the statement that matters, exactly as for the\n twenty-three above.\n */\n 'aglyn-cli',\n]\n\n/**\n * Screen ids that are PUBLISHED but are HTTP STATUS pages, and must never be\n * handed to a crawler or an agent (AGL-2716).\n *\n * ⚠️ NOT `screen-route.ts`'s `nonPageScreenIds`, which is a BILLING predicate:\n * that one answers \"which live documents does the flat infrastructure cap\n * bound\" and counts email documents and entry templates. Two questions, two\n * answers, and merging them would make a pricing cap decide what a crawler may\n * read. The name here says `status` for that reason.\n *\n * Two kinds, and both were found the hard way:\n *\n * - **Bound error screens.** `aglyn.com` published `/401`, `/404` and `/503`\n * into its sitemap, which hands a new domain the worst possible first crawl:\n * the crawler fetches each one, gets the status code it exists to represent,\n * and logs crawl errors and soft-404s against a site it has never seen.\n * - **A bare status code as the whole path.** An error screen on this platform\n * is usually just a screen published at the status path with nothing binding\n * it, so filtering the bound ids alone changed nothing on the site that had\n * the problem. The pattern is anchored, so a real page at `/404-guide` or\n * `/products/503` keeps its place.\n *\n * Extracted from `/api/sitemap`, where it was written inline, so `/llms.txt`\n * and every later reader apply the same rule. The one thing this MUST NOT\n * become is a second copy of that filter: the two surfaces disagreeing is how\n * a site advertises to agents the URLs it withheld from crawlers.\n *\n * Template screens are NOT here. They are excluded too, but the source is a\n * Firestore read (`getTemplateScreenIds`) rather than the host document, so\n * they cannot be decided by a pure function.\n */\nexport function statusPageScreenIds(\n host:\n | {\n screens?: Record<string, string | undefined> | null\n errorScreens?: Record<string, string | undefined> | null\n notFoundScreenId?: string | null\n }\n | null\n | undefined,\n): Set<string> {\n const excluded = new Set<string>()\n for (const bound of [\n ...Object.values(host?.errorScreens ?? {}),\n host?.notFoundScreenId,\n ]) {\n if (typeof bound === 'string' && bound) excluded.add(bound)\n }\n for (const [screenId, path] of Object.entries(host?.screens ?? {})) {\n if (STATUS_CODE_PATH.test(String(path))) excluded.add(screenId)\n }\n return excluded\n}\n\n/** A bare HTTP status code as an entire route path — `/404`, `503`. */\nconst STATUS_CODE_PATH = /^\\/?[1-5][0-9][0-9]$/\n"],"names":["HostScreenVisibility","SITEMAP_INDEX_PATH","isSearchDiscouraged","host","seo","discourageSearchEngines","isScreenIndexable","screen","visibility","PUBLIC","isPageIndexable","options","areCollectionEntriesIndexable","collection","excludeEntriesFromSearch","buildRobotsTxt","sitemap","origin","agents","AI_AGENT_USER_AGENTS","map","agent","join","statusPageScreenIds","excluded","Set","bound","Object","values","errorScreens","notFoundScreenId","add","screenId","path","entries","screens","STATUS_CODE_PATH","test","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,8CAA0C;AAC/E,SAASC,kBAAkB,QAAQ,eAAW;AA2D9C;;;;CAIC,GACD,OAAO,SAASC,oBACdC,IAA2C;QAEpCA;IAAP,OAAOA,CAAAA,yBAAAA,YAAAA,KAAMC,GAAG,qBAATD,UAAWE,uBAAuB,MAAK;AAChD;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,kBACdC,MAA+C;IAE/C,MAAMC,aAAaD,0BAAAA,OAAQC,UAAU;IACrC,IAAIA,cAAc,MAAM,OAAO;IAC/B,OAAOA,eAAeR,qBAAqBS,MAAM;AACnD;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,gBAAgBC,OAG/B;IACC,IAAIT,oBAAoBS,QAAQR,IAAI,GAAG,OAAO;IAC9C,OAAOG,kBAAkBK,QAAQJ,MAAM;AACzC;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASK,8BACdC,UAAuD;IAEvD,OAAOA,CAAAA,8BAAAA,WAAYC,wBAAwB,MAAK;AAClD;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,eAAeJ,OAI9B;IACC,IAAIT,oBAAoBS,QAAQR,IAAI,GAAG;QACrC;;;;;;;IAOA,GACA,OAAO;IACT;IACA,MAAMa,UAAUL,QAAQM,MAAM,GAC1B,CAAC,SAAS,EAAEN,QAAQM,MAAM,GAAGhB,mBAAmB,EAAE,CAAC,GACnD;IACJ;;;;;;;;;;;;;;;;;;;;;;EAsBA,GACA,MAAMiB,SAASC,qBAAqBC,GAAG,CACrC,CAACC,QAAU,CAAC,YAAY,EAAEA,MAAM,YAAY,CAAC,EAC7CC,IAAI,CAAC;IACP,OAAO,CAAC,2BAA2B,EAAEJ,OAAO,EAAE,EAAEF,SAAS;AAC3D;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,MAAMG,uBAA0C;IACrD;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;;;;;;;;;;;;;EAcA,GACA;CACD,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASI,oBACdpB,IAOa;;IAEb,MAAMqB,WAAW,IAAIC;IACrB,KAAK,MAAMC,SAAS;WACfC,OAAOC,MAAM,SAACzB,wBAAAA,KAAM0B,YAAY,mBAAI,CAAC;QACxC1B,wBAAAA,KAAM2B,gBAAgB;KACvB,CAAE;QACD,IAAI,OAAOJ,UAAU,YAAYA,OAAOF,SAASO,GAAG,CAACL;IACvD;IACA,KAAK,MAAM,CAACM,UAAUC,KAAK,IAAIN,OAAOO,OAAO,UAAC/B,wBAAAA,KAAMgC,OAAO,oBAAI,CAAC,GAAI;QAClE,IAAIC,iBAAiBC,IAAI,CAACC,OAAOL,QAAQT,SAASO,GAAG,CAACC;IACxD;IACA,OAAOR;AACT;AAEA,qEAAqE,GACrE,MAAMY,mBAAmB"}
@@ -44,6 +44,7 @@ export * from './screen-link-value';
44
44
  export * from './link-references';
45
45
  export * from './analytics-path-key';
46
46
  export * from './contacts';
47
+ export * from './email-state';
47
48
  export * from './crm';
48
49
  export * from './crm-email-templates';
49
50
  export * from './crm-booking';
@@ -56,6 +56,9 @@ export * from "./screen-link-value.js";
56
56
  export * from "./link-references.js";
57
57
  export * from "./analytics-path-key.js";
58
58
  export * from "./contacts.js";
59
+ // A lead's or a contact's email state (AGL-3245): the last verdict on its
60
+ // address, in words a person can act on, mirrored from the senders' lists.
61
+ export * from "./email-state.js";
59
62
  // The records that sit beside a contact — companies, pipelines, deals, tasks,
60
63
  // activities, field definitions (AGL-2595). Pure like `contacts`, and beside
61
64
  // it because `ContactFacet` names its lifecycle stage and custom-value types;