@aglyn/aglyn 1.0.0-beta.201 → 1.0.0-beta.203
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -11
- package/src/lib/app-utils/activity-presenter.d.ts +29 -3
- package/src/lib/app-utils/activity-presenter.js +32 -4
- package/src/lib/app-utils/activity-presenter.js.map +1 -1
- package/src/lib/app-utils/api-plugins.d.ts +20 -0
- package/src/lib/app-utils/api-plugins.js +9 -0
- package/src/lib/app-utils/api-plugins.js.map +1 -1
- package/src/lib/app-utils/card-payment-velocity.d.ts +57 -0
- package/src/lib/app-utils/card-payment-velocity.js +90 -0
- package/src/lib/app-utils/card-payment-velocity.js.map +1 -0
- package/src/lib/app-utils/crm-digest.d.ts +6 -0
- package/src/lib/app-utils/crm-digest.js +1 -0
- package/src/lib/app-utils/crm-digest.js.map +1 -1
- package/src/lib/app-utils/crm-task-reminders.d.ts +6 -0
- package/src/lib/app-utils/crm-task-reminders.js +1 -0
- package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +1 -1
- package/src/lib/app-utils/docs-help.generated.js +2 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +8 -1
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/marketplace-listing-visibility.d.ts +1 -0
- package/src/lib/app-utils/marketplace-listing-visibility.js +11 -5
- package/src/lib/app-utils/marketplace-listing-visibility.js.map +1 -1
- package/src/lib/app-utils/notifications.d.ts +35 -0
- package/src/lib/app-utils/notifications.js +57 -1
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/plan-entitlements.js +23 -0
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/platform-brand.d.ts +8 -2
- package/src/lib/app-utils/platform-brand.js +9 -3
- package/src/lib/app-utils/platform-brand.js.map +1 -1
- package/src/lib/app-utils/plugin-bundle-checks.d.ts +2 -1
- package/src/lib/app-utils/plugin-bundle-checks.js +36 -1
- package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
- package/src/lib/app-utils/site-return-url.d.ts +55 -0
- package/src/lib/app-utils/site-return-url.js +129 -0
- package/src/lib/app-utils/site-return-url.js.map +1 -0
- package/src/lib/plugin-manager/billing-webhook-hooks.d.ts +9 -0
- package/src/lib/plugin-manager/billing-webhook-hooks.js +5 -1
- package/src/lib/plugin-manager/billing-webhook-hooks.js.map +1 -1
- package/src/lib/plugin-manager/plugin-org-lockdown.d.ts +77 -0
- package/src/lib/plugin-manager/plugin-org-lockdown.js +88 -0
- package/src/lib/plugin-manager/plugin-org-lockdown.js.map +1 -0
- package/src/lib/plugin-manager/plugin-recurring-charges.d.ts +39 -0
- package/src/lib/plugin-manager/plugin-recurring-charges.js +49 -0
- package/src/lib/plugin-manager/plugin-recurring-charges.js.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/aglyn",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.203",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -33,16 +33,16 @@
|
|
|
33
33
|
"./package.json": "./package.json"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
38
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
39
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
40
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
41
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
42
|
-
"@aglyn/shared-util-logger": "1.0.0-beta.
|
|
43
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
44
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
45
|
-
"@aglyn/shared-util-vendor": "1.0.0-beta.
|
|
36
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.203",
|
|
37
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.203",
|
|
38
|
+
"@aglyn/shared-data-types": "1.0.0-beta.203",
|
|
39
|
+
"@aglyn/shared-util-email": "1.0.0-beta.203",
|
|
40
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.203",
|
|
41
|
+
"@aglyn/shared-util-http": "1.0.0-beta.203",
|
|
42
|
+
"@aglyn/shared-util-logger": "1.0.0-beta.203",
|
|
43
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.203",
|
|
44
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.203",
|
|
45
|
+
"@aglyn/shared-util-vendor": "1.0.0-beta.203",
|
|
46
46
|
"@data-driven-forms/react-form-renderer": "^4.2.0",
|
|
47
47
|
"@msgpack/msgpack": "^3.1.3",
|
|
48
48
|
"@types/unist": "^3.0.3",
|
|
@@ -28,8 +28,23 @@ export interface ActivityEntryLike {
|
|
|
28
28
|
target?: ActivityTargetLike | null;
|
|
29
29
|
type?: string;
|
|
30
30
|
targetId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The acting uid: a person's, the literal `'api'` for a key, a
|
|
33
|
+
* `system:` id for a platform process, or null when no person acted.
|
|
34
|
+
*/
|
|
35
|
+
actorId?: string | null;
|
|
31
36
|
/** The address the actor had when the entry was written; null for a key. */
|
|
32
37
|
actorEmail?: string | null;
|
|
38
|
+
/**
|
|
39
|
+
* The address `actorId` holds TODAY, looked up on read by a route with the
|
|
40
|
+
* Admin SDK, for an entry that recorded a uid and no address.
|
|
41
|
+
*/
|
|
42
|
+
actorEmailNow?: string | null;
|
|
43
|
+
/**
|
|
44
|
+
* For a STAFF reader only: the staff member behind an entry the workspace
|
|
45
|
+
* did not perform (a staff cancellation), resolved by the route.
|
|
46
|
+
*/
|
|
47
|
+
staffActorEmail?: string | null;
|
|
33
48
|
/** The name of the API key that wrote the entry, when a key did (AGL-2632). */
|
|
34
49
|
apiKeyName?: string | null;
|
|
35
50
|
}
|
|
@@ -94,9 +109,20 @@ export declare function activityPrimaryText(entry: ActivityEntryLike): string;
|
|
|
94
109
|
/** How an integration's key is named wherever an actor is: `API key Zapier`. */
|
|
95
110
|
export declare function apiKeyActorLabel(name: string): string;
|
|
96
111
|
/**
|
|
97
|
-
* The "who" line for an entry
|
|
98
|
-
*
|
|
99
|
-
*
|
|
112
|
+
* The "who" line for an entry — always someone a reader can narrow down.
|
|
113
|
+
*
|
|
114
|
+
* In order: the address recorded when the entry was written; the key that
|
|
115
|
+
* wrote it, by name; the address the recorded uid holds now, when the writer
|
|
116
|
+
* held a uid and no address (the billing webhook, before it carried the
|
|
117
|
+
* address stamped at the console act); else the uid itself, for an account
|
|
118
|
+
* that no longer resolves.
|
|
119
|
+
*
|
|
120
|
+
* An entry with no uid at all is not an anonymous one. Every writer that
|
|
121
|
+
* leaves `actorId` null is a platform process, so the feed names it: a
|
|
122
|
+
* workflow run by its workflow, anything else — the billing webhook,
|
|
123
|
+
* inbound CRM mail, a background AI job, a staff cancellation — as the
|
|
124
|
+
* platform; a staff reader is also told which staff member. Never "Someone", which read as a person nobody could identify
|
|
125
|
+
* (AGL-3369).
|
|
100
126
|
*/
|
|
101
127
|
export declare function activityActorLabel(entry: ActivityEntryLike): string;
|
|
102
128
|
/**
|
|
@@ -26,6 +26,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
26
26
|
// `@aglyn/aglyn/app-utils/activity-presenter`.
|
|
27
27
|
import { Route, buildRoute } from "./console-routes.js";
|
|
28
28
|
import { CRM_RECORD_SECTIONS, crmRecordHref, crmSectionHref } from "./console-record-links.js";
|
|
29
|
+
import { PLATFORM_BRAND_NAME } from "./platform-brand.js";
|
|
29
30
|
import { hostEventLabel } from "./workflows.js";
|
|
30
31
|
import { duplicateActivityActionLabel } from "./duplicate-resource.js";
|
|
31
32
|
import { pluginActivityActionLabel, pluginActivityGroupForAction, pluginActivityTargetNoun } from "../plugin-manager/plugin-activity-actions.js";
|
|
@@ -120,13 +121,40 @@ import { pluginActivityActionLabel, pluginActivityGroupForAction, pluginActivity
|
|
|
120
121
|
return `API key ${name}`;
|
|
121
122
|
}
|
|
122
123
|
/**
|
|
123
|
-
* The "who" line for an entry
|
|
124
|
-
*
|
|
125
|
-
*
|
|
124
|
+
* The "who" line for an entry — always someone a reader can narrow down.
|
|
125
|
+
*
|
|
126
|
+
* In order: the address recorded when the entry was written; the key that
|
|
127
|
+
* wrote it, by name; the address the recorded uid holds now, when the writer
|
|
128
|
+
* held a uid and no address (the billing webhook, before it carried the
|
|
129
|
+
* address stamped at the console act); else the uid itself, for an account
|
|
130
|
+
* that no longer resolves.
|
|
131
|
+
*
|
|
132
|
+
* An entry with no uid at all is not an anonymous one. Every writer that
|
|
133
|
+
* leaves `actorId` null is a platform process, so the feed names it: a
|
|
134
|
+
* workflow run by its workflow, anything else — the billing webhook,
|
|
135
|
+
* inbound CRM mail, a background AI job, a staff cancellation — as the
|
|
136
|
+
* platform; a staff reader is also told which staff member. Never "Someone", which read as a person nobody could identify
|
|
137
|
+
* (AGL-3369).
|
|
126
138
|
*/ export function activityActorLabel(entry) {
|
|
139
|
+
var _entry_target, _ref;
|
|
140
|
+
var _entry_actorId;
|
|
127
141
|
if (entry.actorEmail) return entry.actorEmail;
|
|
128
142
|
if (entry.apiKeyName) return apiKeyActorLabel(entry.apiKeyName);
|
|
129
|
-
|
|
143
|
+
const actorId = (_entry_actorId = entry.actorId) == null ? void 0 : _entry_actorId.trim();
|
|
144
|
+
if (actorId === 'api') return 'API key';
|
|
145
|
+
if (actorId && !actorId.startsWith('system:')) {
|
|
146
|
+
return entry.actorEmailNow || `Account ${actorId}`;
|
|
147
|
+
}
|
|
148
|
+
const target = (_entry_target = entry.target) != null ? _entry_target : undefined;
|
|
149
|
+
if (((_ref = target == null ? void 0 : target.type) != null ? _ref : entry.type) === 'workflow') {
|
|
150
|
+
var _target_name;
|
|
151
|
+
const name = target == null ? void 0 : (_target_name = target.name) == null ? void 0 : _target_name.trim();
|
|
152
|
+
return name ? `Workflow ${name}` : 'Workflow';
|
|
153
|
+
}
|
|
154
|
+
if (entry.staffActorEmail) {
|
|
155
|
+
return `${PLATFORM_BRAND_NAME} staff (${entry.staffActorEmail})`;
|
|
156
|
+
}
|
|
157
|
+
return PLATFORM_BRAND_NAME;
|
|
130
158
|
}
|
|
131
159
|
/**
|
|
132
160
|
* A deep link to the resource an entry points at, or `undefined` when it
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/activity-presenter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Activity-feed presenter (AGL-810). The `hosts/{id}/activity` and\n// `orgs/{id}/activity` logs store a free-text `action` plus a `{ type, id,\n// name }` target, written from ~40 scattered call sites. The three renderers\n// (host card, host table, org card) used to each format entries their own\n// way — one dropped the target entirely, one printed the raw Firestore doc\n// id when no name was recorded. This module is the single place that turns a\n// stored entry into something a human reads, and into a deep link to the\n// thing that changed. It is deliberately dependency-free (only the string\n// route table) so plugin libs and the console app can both import it via\n// `@aglyn/aglyn/app-utils/activity-presenter`.\n\nimport { Route, buildRoute } from './console-routes'\nimport {\n CRM_RECORD_SECTIONS,\n crmRecordHref,\n crmSectionHref,\n type CrmRecordKind,\n} from './console-record-links'\nimport { hostEventLabel } from './workflows'\nimport { duplicateActivityActionLabel } from './duplicate-resource'\nimport {\n pluginActivityActionLabel,\n pluginActivityGroupForAction,\n pluginActivityTargetNoun,\n} from '../plugin-manager/plugin-activity-actions'\n\n/** The stored `target` sub-object, read defensively (any field may be absent). */\nexport interface ActivityTargetLike {\n type?: string\n id?: string\n name?: string\n /** Present on screen saves so the deep link can hit the exact version. */\n versionId?: string\n}\n\n/** A stored activity document, read defensively. */\nexport interface ActivityEntryLike {\n action?: string\n target?: ActivityTargetLike | null\n // Some early org entries carried the type/target id at the top level rather\n // than nested under `target` — tolerate both so no entry renders blank.\n type?: string\n targetId?: string\n /** The address the actor had when the entry was written; null for a key. */\n actorEmail?: string | null\n /** The name of the API key that wrote the entry, when a key did (AGL-2632). */\n apiKeyName?: string | null\n}\n\n/**\n * Who a host activity entry is attributed to — the writer's half of the\n * contract whose reader is {@link activityActorLabel}.\n *\n * A person is their verified uid and the address they had at the time. An\n * integration is the literal uid `'api'`, no address, and the name the\n * organization gave its key — carried because a feed that says \"Someone\"\n * about every write a key made is an audit trail with a hole in it: three\n * Zapier zaps and a nightly import all read as the same nobody.\n */\nexport interface HostActivityActor {\n uid: string\n email?: string | null\n /** The API key's name, for an entry an integration wrote. */\n apiKeyName?: string | null\n}\n\n/**\n * What the presenter needs to build a link. `host` is the `[host]` URL\n * segment — the site SLUG the console routes by (AGL-622), NOT the host doc\n * id — so pass `useParams().host`, not the doc id. When it is present the\n * entry is treated as host-scoped, otherwise org-scoped — which\n * disambiguates `member`, the one type both logs use.\n */\nexport interface ActivityLinkContext {\n orgSlug?: string\n host?: string\n}\n\n/** Human, singular noun for a target type. Falls back to the raw type. */\nconst TYPE_LABELS: Record<string, string> = {\n host: 'Site',\n screen: 'Screen',\n layout: 'Layout',\n theme: 'Theme',\n media: 'Media',\n content: 'Content',\n variable: 'Variable',\n function: 'Function',\n workflow: 'Workflow',\n member: 'Member',\n component: 'Component',\n template: 'Template',\n org: 'Organization',\n invite: 'Invitation',\n subscription: 'Subscription',\n // A custom role's AI permission flipped (AGL-2929).\n role: 'Role',\n // A generation job (AGL-2904): the org feed names the job, and each of its\n // outputs names the resource it produced.\n aiJob: 'AI generation',\n // CRM records (AGL-2622): a person added, a company or a deal created or\n // deleted, a lead converted — logged into the feed of the site whose\n // console did it, and linked to the record the way a screen is.\n contact: 'Contact',\n lead: 'Lead',\n company: 'Company',\n deal: 'Deal',\n}\n\n/** The activity target types that are CRM records, for the link switch. */\nconst CRM_TARGET_KINDS = new Set<string>(Object.keys(CRM_RECORD_SECTIONS))\n\n/**\n * The org-level hub's section for each record kind an org feed entry may\n * name (AGL-2634): the four record kinds, and tasks, which have a section\n * and no page of their own.\n */\nconst ORG_CRM_SECTIONS: Record<string, string> = {\n ...CRM_RECORD_SECTIONS,\n task: 'tasks',\n}\n\n/**\n * Human noun for a target type, e.g. `'screen'` → `'Screen'`. A plugin's\n * own resource is stored as `pluginId:noun` (AGL-2978), and a reader sees\n * the noun: `acme-mail:mailbox` → `Mailbox`.\n */\nexport function activityTypeLabel(type: string | undefined): string {\n if (!type) return 'Item'\n const known = TYPE_LABELS[type]\n if (known) return known\n const noun = pluginActivityTargetNoun(type) ?? type\n return noun.charAt(0).toUpperCase() + noun.slice(1)\n}\n\n/**\n * A label for the target column/link. Prefers the recorded name; when none\n * was written (older entries, id-only saves) it degrades to the humanized\n * type — NEVER the raw doc id, which is meaningless to a reader.\n */\nexport function activityTargetLabel(\n target: ActivityTargetLike | null | undefined,\n): string {\n if (target?.name) return target.name\n return activityTypeLabel(target?.type)\n}\n\n/**\n * What the action column reads (AGL-2929). Most actions are the sentence\n * their writer stored; a plugin's rows store a code (`ai.job.output`) so\n * that three surfaces can recognize them without parsing prose, and the\n * code is translated here — once, from the plugin-declared catalog\n * (AGL-2940) — rather than shown to a reader as a dotted path.\n */\nexport function activityActionLabel(action: string | undefined): string {\n const stored = action?.trim() ?? ''\n return (\n pluginActivityActionLabel(stored) ??\n duplicateActivityActionLabel(stored) ??\n stored\n )\n}\n\n/**\n * The filter group an entry's code belongs to — the chip that keeps it —\n * or `undefined` for a prose action no plugin declared.\n */\nexport function activityEntryGroupId(entry: ActivityEntryLike): string | undefined {\n return pluginActivityGroupForAction(entry.action?.trim())?.id\n}\n\n/**\n * The primary line for a feed entry: the action, suffixed with the target\n * name when one is known, so \"Saved the screen\" becomes \"Saved the screen —\n * Home\". A missing action degrades to the target label rather than blank.\n */\nexport function activityPrimaryText(entry: ActivityEntryLike): string {\n const action = activityActionLabel(entry.action)\n const name = entry.target?.name?.trim()\n if (action && name) return `${action} — ${name}`\n if (action) return action\n return activityTargetLabel(entry.target)\n}\n\n/** How an integration's key is named wherever an actor is: `API key Zapier`. */\nexport function apiKeyActorLabel(name: string): string {\n return `API key ${name}`\n}\n\n/**\n * The \"who\" line for an entry: the actor's address, else the key that wrote\n * it by name, else `Someone` — for an entry that recorded neither, which\n * every key-written entry was before the name was carried.\n */\nexport function activityActorLabel(entry: ActivityEntryLike): string {\n if (entry.actorEmail) return entry.actorEmail\n if (entry.apiKeyName) return apiKeyActorLabel(entry.apiKeyName)\n return 'Someone'\n}\n\n/**\n * A deep link to the resource an entry points at, or `undefined` when it\n * cannot be built (missing context, or a type with nowhere sensible to go).\n * `buildRoute` emits `<orgSlug?>` placeholders for missing params, so every\n * branch guards its inputs and returns `undefined` rather than a dead link.\n */\nexport function activityHref(\n entry: ActivityEntryLike,\n ctx: ActivityLinkContext,\n): string | undefined {\n const { orgSlug, host } = ctx\n if (!orgSlug) return undefined\n const target = entry.target ?? undefined\n const type = target?.type ?? entry.type\n const id = target?.id ?? entry.targetId\n const versionId = target?.versionId\n\n // Host-scoped entries: every host route needs both orgSlug and host.\n if (host) {\n switch (type) {\n case 'host':\n return buildRoute(Route.HOST_DASHBOARD, { orgSlug, host })\n case 'screen':\n // The screen detail page is version-addressed; without a versionId\n // there is no \"current\" pointer to route through, so land on the list.\n return id && versionId\n ? buildRoute(Route.SCREEN_DETAILS, {\n orgSlug,\n host,\n screenId: id,\n versionId,\n })\n : buildRoute(Route.HOST_SCREENS, { orgSlug, host })\n case 'component':\n return id\n ? buildRoute(Route.COMPONENT_DETAILS, {\n orgSlug,\n host,\n componentId: id,\n })\n : buildRoute(Route.HOST_COMPONENTS, { orgSlug, host })\n case 'template':\n return id\n ? buildRoute(Route.TEMPLATE_DETAILS, {\n orgSlug,\n host,\n templateId: id,\n })\n : buildRoute(Route.HOST_TEMPLATES, { orgSlug, host })\n case 'layout':\n return id\n ? buildRoute(Route.LAYOUT_DETAILS, { orgSlug, host, layoutId: id })\n : buildRoute(Route.HOST_LAYOUTS, { orgSlug, host })\n case 'theme':\n return buildRoute(Route.HOST_SETUP_THEME, { orgSlug, host })\n case 'media':\n return buildRoute(Route.HOST_MEDIA, { orgSlug, host })\n case 'content':\n return buildRoute(Route.HOST_CONTENT, { orgSlug, host })\n case 'variable':\n return buildRoute(Route.HOST_DATA, { orgSlug, host })\n case 'function':\n return buildRoute(Route.HOST_LOGIC, { orgSlug, host })\n case 'workflow':\n return buildRoute(Route.HOST_AUTOMATION, { orgSlug, host })\n case 'member':\n // Site team is managed on the Setup page — there is no per-member\n // host route.\n return buildRoute(Route.HOST_SETUP, { orgSlug, host })\n default:\n // A CRM record's own page, or its list when the entry named no id —\n // a deleted record still has a list to land on. The CRM is a plugin\n // hub, so its addresses come from the shared builder rather than\n // the route table, the same door the console's search uses.\n if (type && CRM_TARGET_KINDS.has(type)) {\n const kind = type as CrmRecordKind\n return id\n ? crmRecordHref({ orgSlug, host }, kind, id)\n : crmSectionHref({ orgSlug, host }, CRM_RECORD_SECTIONS[kind])\n }\n return undefined\n }\n }\n\n // Org-scoped entries.\n switch (type) {\n case 'org':\n return buildRoute(Route.ORG_SETTINGS, { orgSlug })\n case 'member':\n return id\n ? buildRoute(Route.MANAGE_TEAM_MEMBER, { orgSlug, uid: id })\n : buildRoute(Route.MANAGE_TEAM, { orgSlug })\n case 'invite':\n return buildRoute(Route.MANAGE_TEAM, { orgSlug })\n default: {\n // A CRM record acted on at the ORGANIZATION level (AGL-2634) — a deal\n // moved from the org board, a merge, a bulk bar's action — opens in\n // the org-level hub. A lead's org address needs its site, which the\n // entry does not carry, and a bulk line names no record: both land\n // on the section.\n const section = type ? ORG_CRM_SECTIONS[type] : undefined\n if (!section) return undefined\n const hub = `${buildRoute(Route.ORG_CRM, { orgSlug })}/${section}`\n return id && type !== 'lead' && type !== 'task'\n ? `${hub}/${encodeURIComponent(id)}`\n : hub\n }\n }\n}\n\n/**\n * How an automation run ended (AGL-2171).\n *\n * `skipped` is a first-class outcome, not an absence. An unmet condition\n * used to be a bare `continue` that wrote nothing, so \"why didn't my\n * automation fire?\" — the most common support question about automations —\n * had no answer anywhere in the product, while `/product/workflows`\n * advertises an amber `Skipped` row that answers it.\n */\nexport type ActionRunResult = 'succeeded' | 'failed' | 'skipped'\n\n/** A stored run entry: an activity document with the run fields on it. */\nexport interface ActionRunEntryLike extends ActivityEntryLike {\n result?: string\n trigger?: string\n summary?: string\n}\n\n/**\n * `formSubmission` → `Form submitted`.\n *\n * The console rendered the raw camelCase identifier in the trigger select\n * and in every run row. The words are `HOST_EVENT_LABELS` in `workflows.ts`\n * — one map, so the picker an author chose from and the run row they read\n * afterwards cannot name the same event differently.\n */\nexport function actionTriggerLabel(event: string | undefined): string {\n return hostEventLabel(event)\n}\n\n/**\n * The run's outcome, tolerating rows written before it was recorded.\n *\n * A legacy entry carries only the prose `action` string, so it is read\n * back: `with errors:` means it failed and `Action ran on` means it\n * succeeded. Anything else is not a run at all — the activity collection\n * also holds publishes, media saves and member changes, and calling one of\n * those `succeeded` would put it in the run history under a verdict that\n * means nothing for it.\n */\nexport function actionRunResult(\n entry: ActionRunEntryLike,\n): ActionRunResult | undefined {\n const stored = String(entry.result ?? '').trim()\n if (stored === 'succeeded' || stored === 'failed' || stored === 'skipped') {\n return stored\n }\n const action = String(entry.action ?? '')\n if (!action.startsWith('Action ran on')) return undefined\n return action.includes('with errors:') ? 'failed' : 'succeeded'\n}\n\n/**\n * The `What happened` column: `Sent email · saved to Leads · webhook 200`.\n *\n * Falls back to the legacy prose, minus its `Action ran on <event>` prefix\n * — that fact is the Trigger column now, and repeating it in the summary\n * would make every historic row say the same thing twice.\n */\nexport function actionRunSummary(entry: ActionRunEntryLike): string {\n const summary = String(entry.summary ?? '').trim()\n if (summary) return summary\n const action = String(entry.action ?? '').trim()\n const errors = action.match(/with errors:\\s*(.+)$/)\n if (errors) return errors[1]\n // A pre-AGL-2171 success recorded nothing but the fact that it ran.\n if (action.startsWith('Action ran on')) return 'Ran'\n return action\n}\n"],"names":["Route","buildRoute","CRM_RECORD_SECTIONS","crmRecordHref","crmSectionHref","hostEventLabel","duplicateActivityActionLabel","pluginActivityActionLabel","pluginActivityGroupForAction","pluginActivityTargetNoun","TYPE_LABELS","host","screen","layout","theme","media","content","variable","function","workflow","member","component","template","org","invite","subscription","role","aiJob","contact","lead","company","deal","CRM_TARGET_KINDS","Set","Object","keys","ORG_CRM_SECTIONS","task","activityTypeLabel","type","known","noun","charAt","toUpperCase","slice","activityTargetLabel","target","name","activityActionLabel","action","stored","trim","activityEntryGroupId","entry","id","activityPrimaryText","apiKeyActorLabel","activityActorLabel","actorEmail","apiKeyName","activityHref","ctx","orgSlug","undefined","targetId","versionId","HOST_DASHBOARD","SCREEN_DETAILS","screenId","HOST_SCREENS","COMPONENT_DETAILS","componentId","HOST_COMPONENTS","TEMPLATE_DETAILS","templateId","HOST_TEMPLATES","LAYOUT_DETAILS","layoutId","HOST_LAYOUTS","HOST_SETUP_THEME","HOST_MEDIA","HOST_CONTENT","HOST_DATA","HOST_LOGIC","HOST_AUTOMATION","HOST_SETUP","has","kind","ORG_SETTINGS","MANAGE_TEAM_MEMBER","uid","MANAGE_TEAM","section","hub","ORG_CRM","encodeURIComponent","actionTriggerLabel","event","actionRunResult","String","result","startsWith","includes","actionRunSummary","summary","errors","match"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/activity-presenter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Activity-feed presenter (AGL-810). The `hosts/{id}/activity` and\n// `orgs/{id}/activity` logs store a free-text `action` plus a `{ type, id,\n// name }` target, written from ~40 scattered call sites. The three renderers\n// (host card, host table, org card) used to each format entries their own\n// way — one dropped the target entirely, one printed the raw Firestore doc\n// id when no name was recorded. This module is the single place that turns a\n// stored entry into something a human reads, and into a deep link to the\n// thing that changed. It is deliberately dependency-free (only the string\n// route table) so plugin libs and the console app can both import it via\n// `@aglyn/aglyn/app-utils/activity-presenter`.\n\nimport { Route, buildRoute } from './console-routes'\nimport {\n CRM_RECORD_SECTIONS,\n crmRecordHref,\n crmSectionHref,\n type CrmRecordKind,\n} from './console-record-links'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\nimport { hostEventLabel } from './workflows'\nimport { duplicateActivityActionLabel } from './duplicate-resource'\nimport {\n pluginActivityActionLabel,\n pluginActivityGroupForAction,\n pluginActivityTargetNoun,\n} from '../plugin-manager/plugin-activity-actions'\n\n/** The stored `target` sub-object, read defensively (any field may be absent). */\nexport interface ActivityTargetLike {\n type?: string\n id?: string\n name?: string\n /** Present on screen saves so the deep link can hit the exact version. */\n versionId?: string\n}\n\n/** A stored activity document, read defensively. */\nexport interface ActivityEntryLike {\n action?: string\n target?: ActivityTargetLike | null\n // Some early org entries carried the type/target id at the top level rather\n // than nested under `target` — tolerate both so no entry renders blank.\n type?: string\n targetId?: string\n /**\n * The acting uid: a person's, the literal `'api'` for a key, a\n * `system:` id for a platform process, or null when no person acted.\n */\n actorId?: string | null\n /** The address the actor had when the entry was written; null for a key. */\n actorEmail?: string | null\n /**\n * The address `actorId` holds TODAY, looked up on read by a route with the\n * Admin SDK, for an entry that recorded a uid and no address.\n */\n actorEmailNow?: string | null\n /**\n * For a STAFF reader only: the staff member behind an entry the workspace\n * did not perform (a staff cancellation), resolved by the route.\n */\n staffActorEmail?: string | null\n /** The name of the API key that wrote the entry, when a key did (AGL-2632). */\n apiKeyName?: string | null\n}\n\n/**\n * Who a host activity entry is attributed to — the writer's half of the\n * contract whose reader is {@link activityActorLabel}.\n *\n * A person is their verified uid and the address they had at the time. An\n * integration is the literal uid `'api'`, no address, and the name the\n * organization gave its key — carried because a feed that says \"Someone\"\n * about every write a key made is an audit trail with a hole in it: three\n * Zapier zaps and a nightly import all read as the same nobody.\n */\nexport interface HostActivityActor {\n uid: string\n email?: string | null\n /** The API key's name, for an entry an integration wrote. */\n apiKeyName?: string | null\n}\n\n/**\n * What the presenter needs to build a link. `host` is the `[host]` URL\n * segment — the site SLUG the console routes by (AGL-622), NOT the host doc\n * id — so pass `useParams().host`, not the doc id. When it is present the\n * entry is treated as host-scoped, otherwise org-scoped — which\n * disambiguates `member`, the one type both logs use.\n */\nexport interface ActivityLinkContext {\n orgSlug?: string\n host?: string\n}\n\n/** Human, singular noun for a target type. Falls back to the raw type. */\nconst TYPE_LABELS: Record<string, string> = {\n host: 'Site',\n screen: 'Screen',\n layout: 'Layout',\n theme: 'Theme',\n media: 'Media',\n content: 'Content',\n variable: 'Variable',\n function: 'Function',\n workflow: 'Workflow',\n member: 'Member',\n component: 'Component',\n template: 'Template',\n org: 'Organization',\n invite: 'Invitation',\n subscription: 'Subscription',\n // A custom role's AI permission flipped (AGL-2929).\n role: 'Role',\n // A generation job (AGL-2904): the org feed names the job, and each of its\n // outputs names the resource it produced.\n aiJob: 'AI generation',\n // CRM records (AGL-2622): a person added, a company or a deal created or\n // deleted, a lead converted — logged into the feed of the site whose\n // console did it, and linked to the record the way a screen is.\n contact: 'Contact',\n lead: 'Lead',\n company: 'Company',\n deal: 'Deal',\n}\n\n/** The activity target types that are CRM records, for the link switch. */\nconst CRM_TARGET_KINDS = new Set<string>(Object.keys(CRM_RECORD_SECTIONS))\n\n/**\n * The org-level hub's section for each record kind an org feed entry may\n * name (AGL-2634): the four record kinds, and tasks, which have a section\n * and no page of their own.\n */\nconst ORG_CRM_SECTIONS: Record<string, string> = {\n ...CRM_RECORD_SECTIONS,\n task: 'tasks',\n}\n\n/**\n * Human noun for a target type, e.g. `'screen'` → `'Screen'`. A plugin's\n * own resource is stored as `pluginId:noun` (AGL-2978), and a reader sees\n * the noun: `acme-mail:mailbox` → `Mailbox`.\n */\nexport function activityTypeLabel(type: string | undefined): string {\n if (!type) return 'Item'\n const known = TYPE_LABELS[type]\n if (known) return known\n const noun = pluginActivityTargetNoun(type) ?? type\n return noun.charAt(0).toUpperCase() + noun.slice(1)\n}\n\n/**\n * A label for the target column/link. Prefers the recorded name; when none\n * was written (older entries, id-only saves) it degrades to the humanized\n * type — NEVER the raw doc id, which is meaningless to a reader.\n */\nexport function activityTargetLabel(\n target: ActivityTargetLike | null | undefined,\n): string {\n if (target?.name) return target.name\n return activityTypeLabel(target?.type)\n}\n\n/**\n * What the action column reads (AGL-2929). Most actions are the sentence\n * their writer stored; a plugin's rows store a code (`ai.job.output`) so\n * that three surfaces can recognize them without parsing prose, and the\n * code is translated here — once, from the plugin-declared catalog\n * (AGL-2940) — rather than shown to a reader as a dotted path.\n */\nexport function activityActionLabel(action: string | undefined): string {\n const stored = action?.trim() ?? ''\n return (\n pluginActivityActionLabel(stored) ??\n duplicateActivityActionLabel(stored) ??\n stored\n )\n}\n\n/**\n * The filter group an entry's code belongs to — the chip that keeps it —\n * or `undefined` for a prose action no plugin declared.\n */\nexport function activityEntryGroupId(entry: ActivityEntryLike): string | undefined {\n return pluginActivityGroupForAction(entry.action?.trim())?.id\n}\n\n/**\n * The primary line for a feed entry: the action, suffixed with the target\n * name when one is known, so \"Saved the screen\" becomes \"Saved the screen —\n * Home\". A missing action degrades to the target label rather than blank.\n */\nexport function activityPrimaryText(entry: ActivityEntryLike): string {\n const action = activityActionLabel(entry.action)\n const name = entry.target?.name?.trim()\n if (action && name) return `${action} — ${name}`\n if (action) return action\n return activityTargetLabel(entry.target)\n}\n\n/** How an integration's key is named wherever an actor is: `API key Zapier`. */\nexport function apiKeyActorLabel(name: string): string {\n return `API key ${name}`\n}\n\n/**\n * The \"who\" line for an entry — always someone a reader can narrow down.\n *\n * In order: the address recorded when the entry was written; the key that\n * wrote it, by name; the address the recorded uid holds now, when the writer\n * held a uid and no address (the billing webhook, before it carried the\n * address stamped at the console act); else the uid itself, for an account\n * that no longer resolves.\n *\n * An entry with no uid at all is not an anonymous one. Every writer that\n * leaves `actorId` null is a platform process, so the feed names it: a\n * workflow run by its workflow, anything else — the billing webhook,\n * inbound CRM mail, a background AI job, a staff cancellation — as the\n * platform; a staff reader is also told which staff member. Never \"Someone\", which read as a person nobody could identify\n * (AGL-3369).\n */\nexport function activityActorLabel(entry: ActivityEntryLike): string {\n if (entry.actorEmail) return entry.actorEmail\n if (entry.apiKeyName) return apiKeyActorLabel(entry.apiKeyName)\n const actorId = entry.actorId?.trim()\n if (actorId === 'api') return 'API key'\n if (actorId && !actorId.startsWith('system:')) {\n return entry.actorEmailNow || `Account ${actorId}`\n }\n const target = entry.target ?? undefined\n if ((target?.type ?? entry.type) === 'workflow') {\n const name = target?.name?.trim()\n return name ? `Workflow ${name}` : 'Workflow'\n }\n if (entry.staffActorEmail) {\n return `${PLATFORM_BRAND_NAME} staff (${entry.staffActorEmail})`\n }\n return PLATFORM_BRAND_NAME\n}\n\n/**\n * A deep link to the resource an entry points at, or `undefined` when it\n * cannot be built (missing context, or a type with nowhere sensible to go).\n * `buildRoute` emits `<orgSlug?>` placeholders for missing params, so every\n * branch guards its inputs and returns `undefined` rather than a dead link.\n */\nexport function activityHref(\n entry: ActivityEntryLike,\n ctx: ActivityLinkContext,\n): string | undefined {\n const { orgSlug, host } = ctx\n if (!orgSlug) return undefined\n const target = entry.target ?? undefined\n const type = target?.type ?? entry.type\n const id = target?.id ?? entry.targetId\n const versionId = target?.versionId\n\n // Host-scoped entries: every host route needs both orgSlug and host.\n if (host) {\n switch (type) {\n case 'host':\n return buildRoute(Route.HOST_DASHBOARD, { orgSlug, host })\n case 'screen':\n // The screen detail page is version-addressed; without a versionId\n // there is no \"current\" pointer to route through, so land on the list.\n return id && versionId\n ? buildRoute(Route.SCREEN_DETAILS, {\n orgSlug,\n host,\n screenId: id,\n versionId,\n })\n : buildRoute(Route.HOST_SCREENS, { orgSlug, host })\n case 'component':\n return id\n ? buildRoute(Route.COMPONENT_DETAILS, {\n orgSlug,\n host,\n componentId: id,\n })\n : buildRoute(Route.HOST_COMPONENTS, { orgSlug, host })\n case 'template':\n return id\n ? buildRoute(Route.TEMPLATE_DETAILS, {\n orgSlug,\n host,\n templateId: id,\n })\n : buildRoute(Route.HOST_TEMPLATES, { orgSlug, host })\n case 'layout':\n return id\n ? buildRoute(Route.LAYOUT_DETAILS, { orgSlug, host, layoutId: id })\n : buildRoute(Route.HOST_LAYOUTS, { orgSlug, host })\n case 'theme':\n return buildRoute(Route.HOST_SETUP_THEME, { orgSlug, host })\n case 'media':\n return buildRoute(Route.HOST_MEDIA, { orgSlug, host })\n case 'content':\n return buildRoute(Route.HOST_CONTENT, { orgSlug, host })\n case 'variable':\n return buildRoute(Route.HOST_DATA, { orgSlug, host })\n case 'function':\n return buildRoute(Route.HOST_LOGIC, { orgSlug, host })\n case 'workflow':\n return buildRoute(Route.HOST_AUTOMATION, { orgSlug, host })\n case 'member':\n // Site team is managed on the Setup page — there is no per-member\n // host route.\n return buildRoute(Route.HOST_SETUP, { orgSlug, host })\n default:\n // A CRM record's own page, or its list when the entry named no id —\n // a deleted record still has a list to land on. The CRM is a plugin\n // hub, so its addresses come from the shared builder rather than\n // the route table, the same door the console's search uses.\n if (type && CRM_TARGET_KINDS.has(type)) {\n const kind = type as CrmRecordKind\n return id\n ? crmRecordHref({ orgSlug, host }, kind, id)\n : crmSectionHref({ orgSlug, host }, CRM_RECORD_SECTIONS[kind])\n }\n return undefined\n }\n }\n\n // Org-scoped entries.\n switch (type) {\n case 'org':\n return buildRoute(Route.ORG_SETTINGS, { orgSlug })\n case 'member':\n return id\n ? buildRoute(Route.MANAGE_TEAM_MEMBER, { orgSlug, uid: id })\n : buildRoute(Route.MANAGE_TEAM, { orgSlug })\n case 'invite':\n return buildRoute(Route.MANAGE_TEAM, { orgSlug })\n default: {\n // A CRM record acted on at the ORGANIZATION level (AGL-2634) — a deal\n // moved from the org board, a merge, a bulk bar's action — opens in\n // the org-level hub. A lead's org address needs its site, which the\n // entry does not carry, and a bulk line names no record: both land\n // on the section.\n const section = type ? ORG_CRM_SECTIONS[type] : undefined\n if (!section) return undefined\n const hub = `${buildRoute(Route.ORG_CRM, { orgSlug })}/${section}`\n return id && type !== 'lead' && type !== 'task'\n ? `${hub}/${encodeURIComponent(id)}`\n : hub\n }\n }\n}\n\n/**\n * How an automation run ended (AGL-2171).\n *\n * `skipped` is a first-class outcome, not an absence. An unmet condition\n * used to be a bare `continue` that wrote nothing, so \"why didn't my\n * automation fire?\" — the most common support question about automations —\n * had no answer anywhere in the product, while `/product/workflows`\n * advertises an amber `Skipped` row that answers it.\n */\nexport type ActionRunResult = 'succeeded' | 'failed' | 'skipped'\n\n/** A stored run entry: an activity document with the run fields on it. */\nexport interface ActionRunEntryLike extends ActivityEntryLike {\n result?: string\n trigger?: string\n summary?: string\n}\n\n/**\n * `formSubmission` → `Form submitted`.\n *\n * The console rendered the raw camelCase identifier in the trigger select\n * and in every run row. The words are `HOST_EVENT_LABELS` in `workflows.ts`\n * — one map, so the picker an author chose from and the run row they read\n * afterwards cannot name the same event differently.\n */\nexport function actionTriggerLabel(event: string | undefined): string {\n return hostEventLabel(event)\n}\n\n/**\n * The run's outcome, tolerating rows written before it was recorded.\n *\n * A legacy entry carries only the prose `action` string, so it is read\n * back: `with errors:` means it failed and `Action ran on` means it\n * succeeded. Anything else is not a run at all — the activity collection\n * also holds publishes, media saves and member changes, and calling one of\n * those `succeeded` would put it in the run history under a verdict that\n * means nothing for it.\n */\nexport function actionRunResult(\n entry: ActionRunEntryLike,\n): ActionRunResult | undefined {\n const stored = String(entry.result ?? '').trim()\n if (stored === 'succeeded' || stored === 'failed' || stored === 'skipped') {\n return stored\n }\n const action = String(entry.action ?? '')\n if (!action.startsWith('Action ran on')) return undefined\n return action.includes('with errors:') ? 'failed' : 'succeeded'\n}\n\n/**\n * The `What happened` column: `Sent email · saved to Leads · webhook 200`.\n *\n * Falls back to the legacy prose, minus its `Action ran on <event>` prefix\n * — that fact is the Trigger column now, and repeating it in the summary\n * would make every historic row say the same thing twice.\n */\nexport function actionRunSummary(entry: ActionRunEntryLike): string {\n const summary = String(entry.summary ?? '').trim()\n if (summary) return summary\n const action = String(entry.action ?? '').trim()\n const errors = action.match(/with errors:\\s*(.+)$/)\n if (errors) return errors[1]\n // A pre-AGL-2171 success recorded nothing but the fact that it ran.\n if (action.startsWith('Action ran on')) return 'Ran'\n return action\n}\n"],"names":["Route","buildRoute","CRM_RECORD_SECTIONS","crmRecordHref","crmSectionHref","PLATFORM_BRAND_NAME","hostEventLabel","duplicateActivityActionLabel","pluginActivityActionLabel","pluginActivityGroupForAction","pluginActivityTargetNoun","TYPE_LABELS","host","screen","layout","theme","media","content","variable","function","workflow","member","component","template","org","invite","subscription","role","aiJob","contact","lead","company","deal","CRM_TARGET_KINDS","Set","Object","keys","ORG_CRM_SECTIONS","task","activityTypeLabel","type","known","noun","charAt","toUpperCase","slice","activityTargetLabel","target","name","activityActionLabel","action","stored","trim","activityEntryGroupId","entry","id","activityPrimaryText","apiKeyActorLabel","activityActorLabel","actorEmail","apiKeyName","actorId","startsWith","actorEmailNow","undefined","staffActorEmail","activityHref","ctx","orgSlug","targetId","versionId","HOST_DASHBOARD","SCREEN_DETAILS","screenId","HOST_SCREENS","COMPONENT_DETAILS","componentId","HOST_COMPONENTS","TEMPLATE_DETAILS","templateId","HOST_TEMPLATES","LAYOUT_DETAILS","layoutId","HOST_LAYOUTS","HOST_SETUP_THEME","HOST_MEDIA","HOST_CONTENT","HOST_DATA","HOST_LOGIC","HOST_AUTOMATION","HOST_SETUP","has","kind","ORG_SETTINGS","MANAGE_TEAM_MEMBER","uid","MANAGE_TEAM","section","hub","ORG_CRM","encodeURIComponent","actionTriggerLabel","event","actionRunResult","String","result","includes","actionRunSummary","summary","errors","match"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,mEAAmE;AACnE,2EAA2E;AAC3E,6EAA6E;AAC7E,0EAA0E;AAC1E,2EAA2E;AAC3E,6EAA6E;AAC7E,yEAAyE;AACzE,0EAA0E;AAC1E,yEAAyE;AACzE,+CAA+C;AAE/C,SAASA,KAAK,EAAEC,UAAU,QAAQ,sBAAkB;AACpD,SACEC,mBAAmB,EACnBC,aAAa,EACbC,cAAc,QAET,4BAAwB;AAC/B,SAASC,mBAAmB,QAAQ,sBAAkB;AACtD,SAASC,cAAc,QAAQ,iBAAa;AAC5C,SAASC,4BAA4B,QAAQ,0BAAsB;AACnE,SACEC,yBAAyB,EACzBC,4BAA4B,EAC5BC,wBAAwB,QACnB,+CAA2C;AAqElD,wEAAwE,GACxE,MAAMC,cAAsC;IAC1CC,MAAM;IACNC,QAAQ;IACRC,QAAQ;IACRC,OAAO;IACPC,OAAO;IACPC,SAAS;IACTC,UAAU;IACVC,UAAU;IACVC,UAAU;IACVC,QAAQ;IACRC,WAAW;IACXC,UAAU;IACVC,KAAK;IACLC,QAAQ;IACRC,cAAc;IACd,oDAAoD;IACpDC,MAAM;IACN,2EAA2E;IAC3E,0CAA0C;IAC1CC,OAAO;IACP,yEAAyE;IACzE,qEAAqE;IACrE,gEAAgE;IAChEC,SAAS;IACTC,MAAM;IACNC,SAAS;IACTC,MAAM;AACR;AAEA,yEAAyE,GACzE,MAAMC,mBAAmB,IAAIC,IAAYC,OAAOC,IAAI,CAAClC;AAErD;;;;CAIC,GACD,MAAMmC,mBAA2C,aAC5CnC;IACHoC,MAAM;;AAGR;;;;CAIC,GACD,OAAO,SAASC,kBAAkBC,IAAwB;QAI3C9B;IAHb,IAAI,CAAC8B,MAAM,OAAO;IAClB,MAAMC,QAAQ9B,WAAW,CAAC6B,KAAK;IAC/B,IAAIC,OAAO,OAAOA;IAClB,MAAMC,QAAOhC,4BAAAA,yBAAyB8B,iBAAzB9B,4BAAkC8B;IAC/C,OAAOE,KAAKC,MAAM,CAAC,GAAGC,WAAW,KAAKF,KAAKG,KAAK,CAAC;AACnD;AAEA;;;;CAIC,GACD,OAAO,SAASC,oBACdC,MAA6C;IAE7C,IAAIA,0BAAAA,OAAQC,IAAI,EAAE,OAAOD,OAAOC,IAAI;IACpC,OAAOT,kBAAkBQ,0BAAAA,OAAQP,IAAI;AACvC;AAEA;;;;;;CAMC,GACD,OAAO,SAASS,oBAAoBC,MAA0B;cAG1D1C,OAAAA;IAFF,MAAM2C,iBAASD,0BAAAA,OAAQE,IAAI,qBAAM;IACjC,QACE5C,SAAAA,6BAAAA,0BAA0B2C,mBAA1B3C,6BACAD,6BAA6B4C,mBAD7B3C,QAEA2C;AAEJ;AAEA;;;CAGC,GACD,OAAO,SAASE,qBAAqBC,KAAwB;QACpD7C,+BAA6B6C;IAApC,QAAO7C,gCAAAA,8BAA6B6C,gBAAAA,MAAMJ,MAAM,qBAAZI,cAAcF,IAAI,wBAA/C3C,8BAAoD8C,EAAE;AAC/D;AAEA;;;;CAIC,GACD,OAAO,SAASC,oBAAoBF,KAAwB;QAE7CA,oBAAAA;IADb,MAAMJ,SAASD,oBAAoBK,MAAMJ,MAAM;IAC/C,MAAMF,QAAOM,gBAAAA,MAAMP,MAAM,sBAAZO,qBAAAA,cAAcN,IAAI,qBAAlBM,mBAAoBF,IAAI;IACrC,IAAIF,UAAUF,MAAM,OAAO,GAAGE,OAAO,GAAG,EAAEF,MAAM;IAChD,IAAIE,QAAQ,OAAOA;IACnB,OAAOJ,oBAAoBQ,MAAMP,MAAM;AACzC;AAEA,8EAA8E,GAC9E,OAAO,SAASU,iBAAiBT,IAAY;IAC3C,OAAO,CAAC,QAAQ,EAAEA,MAAM;AAC1B;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASU,mBAAmBJ,KAAwB;QAQ1CA;QALCA;IAFhB,IAAIA,MAAMK,UAAU,EAAE,OAAOL,MAAMK,UAAU;IAC7C,IAAIL,MAAMM,UAAU,EAAE,OAAOH,iBAAiBH,MAAMM,UAAU;IAC9D,MAAMC,WAAUP,iBAAAA,MAAMO,OAAO,qBAAbP,eAAeF,IAAI;IACnC,IAAIS,YAAY,OAAO,OAAO;IAC9B,IAAIA,WAAW,CAACA,QAAQC,UAAU,CAAC,YAAY;QAC7C,OAAOR,MAAMS,aAAa,IAAI,CAAC,QAAQ,EAAEF,SAAS;IACpD;IACA,MAAMd,UAASO,gBAAAA,MAAMP,MAAM,YAAZO,gBAAgBU;IAC/B,IAAI,SAACjB,0BAAAA,OAAQP,IAAI,mBAAIc,MAAMd,IAAI,MAAM,YAAY;YAClCO;QAAb,MAAMC,OAAOD,2BAAAA,eAAAA,OAAQC,IAAI,qBAAZD,aAAcK,IAAI;QAC/B,OAAOJ,OAAO,CAAC,SAAS,EAAEA,MAAM,GAAG;IACrC;IACA,IAAIM,MAAMW,eAAe,EAAE;QACzB,OAAO,GAAG5D,oBAAoB,QAAQ,EAAEiD,MAAMW,eAAe,CAAC,CAAC,CAAC;IAClE;IACA,OAAO5D;AACT;AAEA;;;;;CAKC,GACD,OAAO,SAAS6D,aACdZ,KAAwB,EACxBa,GAAwB;QAITb;IAFf,MAAM,EAAEc,OAAO,EAAExD,IAAI,EAAE,GAAGuD;IAC1B,IAAI,CAACC,SAAS,OAAOJ;IACrB,MAAMjB,UAASO,gBAAAA,MAAMP,MAAM,YAAZO,gBAAgBU;IAC/B,MAAMxB,eAAOO,0BAAAA,OAAQP,IAAI,mBAAIc,MAAMd,IAAI;IACvC,MAAMe,cAAKR,0BAAAA,OAAQQ,EAAE,oBAAID,MAAMe,QAAQ;IACvC,MAAMC,YAAYvB,0BAAAA,OAAQuB,SAAS;IAEnC,qEAAqE;IACrE,IAAI1D,MAAM;QACR,OAAQ4B;YACN,KAAK;gBACH,OAAOvC,WAAWD,MAAMuE,cAAc,EAAE;oBAAEH;oBAASxD;gBAAK;YAC1D,KAAK;gBACH,mEAAmE;gBACnE,uEAAuE;gBACvE,OAAO2C,MAAMe,YACTrE,WAAWD,MAAMwE,cAAc,EAAE;oBAC/BJ;oBACAxD;oBACA6D,UAAUlB;oBACVe;gBACF,KACArE,WAAWD,MAAM0E,YAAY,EAAE;oBAAEN;oBAASxD;gBAAK;YACrD,KAAK;gBACH,OAAO2C,KACHtD,WAAWD,MAAM2E,iBAAiB,EAAE;oBAClCP;oBACAxD;oBACAgE,aAAarB;gBACf,KACAtD,WAAWD,MAAM6E,eAAe,EAAE;oBAAET;oBAASxD;gBAAK;YACxD,KAAK;gBACH,OAAO2C,KACHtD,WAAWD,MAAM8E,gBAAgB,EAAE;oBACjCV;oBACAxD;oBACAmE,YAAYxB;gBACd,KACAtD,WAAWD,MAAMgF,cAAc,EAAE;oBAAEZ;oBAASxD;gBAAK;YACvD,KAAK;gBACH,OAAO2C,KACHtD,WAAWD,MAAMiF,cAAc,EAAE;oBAAEb;oBAASxD;oBAAMsE,UAAU3B;gBAAG,KAC/DtD,WAAWD,MAAMmF,YAAY,EAAE;oBAAEf;oBAASxD;gBAAK;YACrD,KAAK;gBACH,OAAOX,WAAWD,MAAMoF,gBAAgB,EAAE;oBAAEhB;oBAASxD;gBAAK;YAC5D,KAAK;gBACH,OAAOX,WAAWD,MAAMqF,UAAU,EAAE;oBAAEjB;oBAASxD;gBAAK;YACtD,KAAK;gBACH,OAAOX,WAAWD,MAAMsF,YAAY,EAAE;oBAAElB;oBAASxD;gBAAK;YACxD,KAAK;gBACH,OAAOX,WAAWD,MAAMuF,SAAS,EAAE;oBAAEnB;oBAASxD;gBAAK;YACrD,KAAK;gBACH,OAAOX,WAAWD,MAAMwF,UAAU,EAAE;oBAAEpB;oBAASxD;gBAAK;YACtD,KAAK;gBACH,OAAOX,WAAWD,MAAMyF,eAAe,EAAE;oBAAErB;oBAASxD;gBAAK;YAC3D,KAAK;gBACH,kEAAkE;gBAClE,cAAc;gBACd,OAAOX,WAAWD,MAAM0F,UAAU,EAAE;oBAAEtB;oBAASxD;gBAAK;YACtD;gBACE,oEAAoE;gBACpE,oEAAoE;gBACpE,iEAAiE;gBACjE,4DAA4D;gBAC5D,IAAI4B,QAAQP,iBAAiB0D,GAAG,CAACnD,OAAO;oBACtC,MAAMoD,OAAOpD;oBACb,OAAOe,KACHpD,cAAc;wBAAEiE;wBAASxD;oBAAK,GAAGgF,MAAMrC,MACvCnD,eAAe;wBAAEgE;wBAASxD;oBAAK,GAAGV,mBAAmB,CAAC0F,KAAK;gBACjE;gBACA,OAAO5B;QACX;IACF;IAEA,sBAAsB;IACtB,OAAQxB;QACN,KAAK;YACH,OAAOvC,WAAWD,MAAM6F,YAAY,EAAE;gBAAEzB;YAAQ;QAClD,KAAK;YACH,OAAOb,KACHtD,WAAWD,MAAM8F,kBAAkB,EAAE;gBAAE1B;gBAAS2B,KAAKxC;YAAG,KACxDtD,WAAWD,MAAMgG,WAAW,EAAE;gBAAE5B;YAAQ;QAC9C,KAAK;YACH,OAAOnE,WAAWD,MAAMgG,WAAW,EAAE;gBAAE5B;YAAQ;QACjD;YAAS;gBACP,sEAAsE;gBACtE,oEAAoE;gBACpE,oEAAoE;gBACpE,mEAAmE;gBACnE,kBAAkB;gBAClB,MAAM6B,UAAUzD,OAAOH,gBAAgB,CAACG,KAAK,GAAGwB;gBAChD,IAAI,CAACiC,SAAS,OAAOjC;gBACrB,MAAMkC,MAAM,GAAGjG,WAAWD,MAAMmG,OAAO,EAAE;oBAAE/B;gBAAQ,GAAG,CAAC,EAAE6B,SAAS;gBAClE,OAAO1C,MAAMf,SAAS,UAAUA,SAAS,SACrC,GAAG0D,IAAI,CAAC,EAAEE,mBAAmB7C,KAAK,GAClC2C;YACN;IACF;AACF;AAoBA;;;;;;;CAOC,GACD,OAAO,SAASG,mBAAmBC,KAAyB;IAC1D,OAAOhG,eAAegG;AACxB;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASC,gBACdjD,KAAyB;QAEHA,eAIAA;IAJtB,MAAMH,SAASqD,QAAOlD,gBAAAA,MAAMmD,MAAM,YAAZnD,gBAAgB,IAAIF,IAAI;IAC9C,IAAID,WAAW,eAAeA,WAAW,YAAYA,WAAW,WAAW;QACzE,OAAOA;IACT;IACA,MAAMD,SAASsD,QAAOlD,gBAAAA,MAAMJ,MAAM,YAAZI,gBAAgB;IACtC,IAAI,CAACJ,OAAOY,UAAU,CAAC,kBAAkB,OAAOE;IAChD,OAAOd,OAAOwD,QAAQ,CAAC,kBAAkB,WAAW;AACtD;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,iBAAiBrD,KAAyB;QACjCA,gBAEDA;IAFtB,MAAMsD,UAAUJ,QAAOlD,iBAAAA,MAAMsD,OAAO,YAAbtD,iBAAiB,IAAIF,IAAI;IAChD,IAAIwD,SAAS,OAAOA;IACpB,MAAM1D,SAASsD,QAAOlD,gBAAAA,MAAMJ,MAAM,YAAZI,gBAAgB,IAAIF,IAAI;IAC9C,MAAMyD,SAAS3D,OAAO4D,KAAK,CAAC;IAC5B,IAAID,QAAQ,OAAOA,MAAM,CAAC,EAAE;IAC5B,oEAAoE;IACpE,IAAI3D,OAAOY,UAAU,CAAC,kBAAkB,OAAO;IAC/C,OAAOZ;AACT"}
|
|
@@ -137,6 +137,20 @@ export interface PluginApiRouteOptions {
|
|
|
137
137
|
* authenticates the link itself, by a signature it verifies.
|
|
138
138
|
*/
|
|
139
139
|
recipientLink?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* The route OPENS A CARD PAYMENT for whoever calls it: a Checkout Session,
|
|
142
|
+
* a PaymentIntent or a SetupIntent (AGL-3363).
|
|
143
|
+
*
|
|
144
|
+
* The tenant dispatcher holds such a route to the card-testing counters
|
|
145
|
+
* (`card-payment-velocity.ts`) on top of its ordinary write limit, so a
|
|
146
|
+
* plugin's payment door is covered by declaring itself here rather than by
|
|
147
|
+
* carrying a limiter of its own. A console door (a signed-in merchant's
|
|
148
|
+
* POS or draft order) does not declare it: a register ringing up a queue
|
|
149
|
+
* is not a card tester. The 3-D Secure sweep
|
|
150
|
+
* (`card-payments-request-3ds.spec.ts`) classifies every file that creates
|
|
151
|
+
* a payment, so a new visitor door cannot skip the declaration.
|
|
152
|
+
*/
|
|
153
|
+
cardPayment?: boolean;
|
|
140
154
|
}
|
|
141
155
|
export { setRegisteringPluginId, getRegisteringPluginId, } from './registering-plugin';
|
|
142
156
|
/**
|
|
@@ -180,6 +194,12 @@ export declare function resolvePluginApiMatch(path: string): PluginApiMatch | un
|
|
|
180
194
|
* before their enablement and release gates (AGL-2981).
|
|
181
195
|
*/
|
|
182
196
|
export declare function isPluginRecipientLinkRoute(path: string): boolean;
|
|
197
|
+
/**
|
|
198
|
+
* Whether a request path resolves to a route registered with
|
|
199
|
+
* {@link PluginApiRouteOptions.cardPayment} — what the tenant dispatcher
|
|
200
|
+
* asks before its card-testing counters (AGL-3363).
|
|
201
|
+
*/
|
|
202
|
+
export declare function isPluginCardPaymentRoute(path: string): boolean;
|
|
183
203
|
/**
|
|
184
204
|
* The subject a request to a registered route names, or `null` (AGL-2978).
|
|
185
205
|
*
|
|
@@ -157,6 +157,15 @@ export function unregisterPluginApiRoute(path) {
|
|
|
157
157
|
const matched = matchRegisteredApiKey(path);
|
|
158
158
|
return matched ? ((_apiRouteOptions_get = apiRouteOptions.get(matched.key)) == null ? void 0 : _apiRouteOptions_get.recipientLink) === true : false;
|
|
159
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Whether a request path resolves to a route registered with
|
|
162
|
+
* {@link PluginApiRouteOptions.cardPayment} — what the tenant dispatcher
|
|
163
|
+
* asks before its card-testing counters (AGL-3363).
|
|
164
|
+
*/ export function isPluginCardPaymentRoute(path) {
|
|
165
|
+
var _apiRouteOptions_get;
|
|
166
|
+
const matched = matchRegisteredApiKey(path);
|
|
167
|
+
return matched ? ((_apiRouteOptions_get = apiRouteOptions.get(matched.key)) == null ? void 0 : _apiRouteOptions_get.cardPayment) === true : false;
|
|
168
|
+
}
|
|
160
169
|
/** An id a subject may carry: a non-empty path segment, not a path. */ const SUBJECT_ID = /^[^/\s]{1,128}$/;
|
|
161
170
|
const subjectId = (value)=>typeof value === 'string' && SUBJECT_ID.test(value) ? value : null;
|
|
162
171
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/api-plugins.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Server half of the plugin pattern (AGL-396): the API-route registry, the\n * server counterpart to the ConsoleExtension registry. A feature plugin\n * registers request handlers here from its `/server` entry point (never its\n * client barrel, so firebase-admin and other server-only deps stay out of\n * the browser bundle). The app ships one catch-all dispatcher route per\n * Next app that resolves a request path to a registered handler — so moving\n * a feature's API into its plugin needs no new app route and keeps the same\n * URL. Reference implementation: events-calendar `events/list` (AGL-396).\n *\n * The request/response shapes are structural (not `next` types) so plugins\n * stay framework-light; `NextApiRequest`/`NextApiResponse` satisfy them, so\n * the dispatcher passes Next's objects straight through.\n */\n\nexport interface PluginApiRequest {\n method?: string\n query: Partial<Record<string, string | string[]>>\n // `any` mirrors NextApiRequest.body (parsed JSON/form/text); handlers\n // validate it themselves, as they did as Next routes.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n body: any\n /**\n * The unparsed request text, for handlers that verify signatures over the\n * exact payload (Stripe/Svix webhooks registered as plugin API paths).\n * UTF-8 re-encoding of the text is byte-identical for valid UTF-8 JSON.\n */\n rawBody?: string\n headers: Partial<Record<string, string | string[]>>\n cookies: Partial<Record<string, string>>\n /**\n * Node socket — the client-address fallback (`remoteAddress`).\n *\n * On the console's Pages Router this is the real transport peer. Behind the\n * App Router adapter there is no socket, and `api-adapter.ts` fills it from\n * the shared client-address reader so a handler falling back to it gets the\n * same trusted hop rather than a caller-supplied one.\n */\n socket: { remoteAddress?: string }\n}\n\nexport interface PluginApiResponse {\n status(code: number): PluginApiResponse\n json(body: unknown): void\n send(body: unknown): void\n setHeader(name: string, value: string | number | readonly string[]): void\n redirect(url: string): void\n redirect(status: number, url: string): void\n end(): void\n}\n\nexport type PluginApiHandler = (\n req: PluginApiRequest,\n res: PluginApiResponse,\n) => void | Promise<void>\n\n/**\n * A handler in the Web shape (AGL-2939): the dispatcher's own `Request`\n * in, a `Response` out — with a streamed body when the door streams, as\n * the assistant's chat and a job's event feed do. `params` carries the\n * dispatcher's path segments and any `:name` segment the registered path\n * declared.\n */\nexport type PluginWebApiHandler = (\n request: Request,\n context: { params: Record<string, string | string[]> },\n) => Response | Promise<Response>\n\n/** What a route registers: the (req, res) shape, or a Web handler. */\nexport type PluginApiRoute = PluginApiHandler | { web: PluginWebApiHandler }\n\n/** A registered route matched to a request path, with its `:name` segments filled. */\nexport interface PluginApiMatch {\n route: PluginApiRoute\n params: Record<string, string>\n}\n\n/**\n * Who a request is FOR, as far as the dispatchers' release gate is concerned\n * (AGL-2978).\n *\n * The gate buckets a rollout and reads per-org overrides on an org id, and a\n * dispatcher learns that id from the site a request names (`hostId`). Two\n * kinds of request name no site and still belong to one organization:\n *\n * - a request to an ORGANIZATION-level surface (`ConsoleExtension.orgNavItems`,\n * AGL-2974), which names its org rather than a site;\n * - a request that arrives with no bearer token by design — a provider's\n * OAuth redirect back to the platform, which carries only what the platform\n * signed into it.\n *\n * Without a subject the gate treats both as anonymous, so a plugin released to\n * one organization by override refused that organization's own requests, and\n * a staff member previewing a dark plugin could not complete a redirect their\n * own session had started.\n */\nexport interface PluginApiRequestSubject {\n /** The organization: the rollout bucket and the override key. */\n orgId: string | null\n /**\n * The account the request acts for, read ONLY when the request carries no\n * bearer token — a token speaks for itself. A route may name one only when\n * it verified a signature binding the uid to the request, because the gate\n * lets a staff account named here preview a released-off plugin.\n */\n uid?: string | null\n}\n\n/** Reads a request's subject; answers `null` when the request names none. */\nexport type PluginApiSubjectResolver = (\n request: Request,\n) => PluginApiRequestSubject | null | Promise<PluginApiRequestSubject | null>\n\n/** What a registration may say about its route beyond the handler. */\nexport interface PluginApiRouteOptions {\n /**\n * How a request to this route names its subject when it names no site.\n * Consulted by both dispatchers before their release gate, and only for a\n * request with no `hostId`: a named site always decides the org.\n */\n subject?: PluginApiSubjectResolver\n /**\n * The route answers a link the platform MAILED to somebody — an\n * unsubscribe in a message's `List-Unsubscribe` header — rather than a door\n * a member uses (AGL-2981).\n *\n * Such a link has to keep working whether or not its plugin is released,\n * or switched on, for the workspace NOW. A recipient's way out does not\n * close when a rollout is paused or a workspace turns the plugin off:\n * CAN-SPAM holds an opt-out open for thirty days after the send, and an\n * unsubscribe that 404s the day a kill switch is flipped is one nobody can\n * use. So both dispatchers skip their per-site enablement and release\n * gates for it — and nothing else: lockdown and the rate limit still\n * apply. The flag says nothing about who may act, so the route\n * authenticates the link itself, by a signature it verifies.\n */\n recipientLink?: boolean\n}\n\n/** Leading/trailing slashes stripped so '/events/list' and 'events/list' key alike. */\nfunction normalizeApiPath(path: string): string {\n return path.replace(/^\\/+|\\/+$/g, '')\n}\n\nconst apiRoutes = new Map<string, PluginApiRoute>()\nconst apiRouteOwners = new Map<string, string>()\nconst apiRouteOptions = new Map<string, PluginApiRouteOptions>()\n\n/**\n * The \"currently registering plugin\" marker moved to a leaf module\n * (`registering-plugin`) so `site-page-hooks` can read it too — this file is\n * only reachable through the `/server` entry, and importing it from the shared\n * plugin-manager barrel formed a cycle (AGL-1289). Re-exported here so the\n * plugin loader's existing import keeps working unchanged.\n */\nimport { getRegisteringPluginId } from './registering-plugin'\n\nexport {\n setRegisteringPluginId,\n getRegisteringPluginId,\n} from './registering-plugin'\n\n/**\n * Registers a plugin API handler at a host-relative path (e.g.\n * 'events/list'), served by the app dispatcher at `/api/events/list`.\n *\n * Idempotent by path FOR ITS OWNER — re-registration by the plugin that\n * holds the path replaces the previous handler, which is what a hot reload,\n * a second surface, and a repeated init all depend on.\n *\n * A different plugin is REFUSED (AGL-2484). This map is process-global and\n * was last-writer-wins on the path alone, while Aglyn's own commission-taking\n * handlers sit at `marketplace/checkout` and `commerce/checkout` — and the\n * tenant dispatcher calls `ensureRemoteServerBundles()` after the first-party\n * loader has run, so a remote bundle registering either path was simply the\n * later writer. Getting there needs `PLUGIN_REMOTE_SERVER=enabled` (off by\n * default), realm trust and a valid signature, so this is the last of several\n * doors rather than the only one; it is also the one the registry could close\n * by itself, because it already recorded who registered what.\n *\n * Anonymous registration — no loader marker — is an IDENTITY here, not a\n * wildcard: it may replace itself and nothing else may take it.\n */\nexport function registerPluginApiRoute(\n path: string,\n handler: PluginApiRoute,\n options?: PluginApiRouteOptions,\n): void {\n const key = normalizeApiPath(path)\n const owner = getRegisteringPluginId() ?? ANONYMOUS_OWNER\n const incumbent = apiRouteOwners.get(key)\n if (incumbent !== undefined && incumbent !== owner) {\n // Loud, because the silent version of this is a payment route quietly\n // answering from someone else's code. Refusing is the safe direction:\n // the incumbent keeps serving.\n console.error(\n `[plugins] refused API route \"${key}\" to \"${owner}\": already ` +\n `registered by \"${incumbent}\"`,\n )\n return\n }\n apiRoutes.set(key, handler)\n apiRouteOwners.set(key, owner)\n // Replaced with the handler, never merged: a re-registration that drops its\n // subject resolver must not keep answering with the previous one.\n if (options) apiRouteOptions.set(key, options)\n else apiRouteOptions.delete(key)\n}\n\n/** The marker for a registration made outside any loader context. */\nconst ANONYMOUS_OWNER = ''\n\n/** The plugin that registered a path, for per-org enablement gating. */\nexport function pluginIdForRegisteredApiPath(path: string): string | undefined {\n const owner = apiRouteOwners.get(normalizeApiPath(path))\n // An anonymous registration has no plugin to gate on, and this has always\n // answered `undefined` for one — the sentinel must not leak out as an id.\n return owner === undefined || owner === ANONYMOUS_OWNER ? undefined : owner\n}\n\nexport function unregisterPluginApiRoute(path: string): void {\n const key = normalizeApiPath(path)\n apiRoutes.delete(key)\n // Release the CLAIM too, or the refusal above outlives the route and the\n // path can never be re-registered for the life of the process.\n apiRouteOwners.delete(key)\n apiRouteOptions.delete(key)\n}\n\n/**\n * The registered key a request path resolves to, with its `:name` segments.\n *\n * An exact registration wins; otherwise a registration whose path carries\n * `:name` segments matches segment for segment. One matcher for the route and\n * for its options, so a request can never be answered by one registration and\n * gated with another's subject.\n */\nfunction matchRegisteredApiKey(\n path: string,\n): { key: string; params: Record<string, string> } | undefined {\n const key = normalizeApiPath(path)\n if (apiRoutes.has(key)) return { key, params: {} }\n const segments = key.split('/')\n for (const registered of apiRoutes.keys()) {\n if (!registered.includes(':')) continue\n const pattern = registered.split('/')\n if (pattern.length !== segments.length) continue\n const params: Record<string, string> = {}\n let matched = true\n for (let index = 0; index < pattern.length; index += 1) {\n const part = pattern[index]\n if (part.startsWith(':') && segments[index]) {\n params[part.slice(1)] = segments[index]\n } else if (part !== segments[index]) {\n matched = false\n break\n }\n }\n if (matched) return { key: registered, params }\n }\n return undefined\n}\n\n/**\n * The route for a request path, or undefined when nothing is registered.\n *\n * An exact registration wins; otherwise a registration whose path carries\n * `:name` segments matches segment for segment (`ai/jobs/:jobId/cancel`\n * answers `ai/jobs/abc/cancel`), and the named segments come back as\n * `params`. Patterns are tried in registration order and a static segment\n * beats a named one only by being registered exactly.\n */\nexport function resolvePluginApiMatch(path: string): PluginApiMatch | undefined {\n const matched = matchRegisteredApiKey(path)\n if (!matched) return undefined\n const route = apiRoutes.get(matched.key)\n return route ? { route, params: matched.params } : undefined\n}\n\n/**\n * Whether a request path resolves to a route registered as a\n * {@link PluginApiRouteOptions.recipientLink} — what both dispatchers ask\n * before their enablement and release gates (AGL-2981).\n */\nexport function isPluginRecipientLinkRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.recipientLink === true : false\n}\n\n/** An id a subject may carry: a non-empty path segment, not a path. */\nconst SUBJECT_ID = /^[^/\\s]{1,128}$/\n\nconst subjectId = (value: unknown): string | null =>\n typeof value === 'string' && SUBJECT_ID.test(value) ? value : null\n\n/**\n * The subject a request to a registered route names, or `null` (AGL-2978).\n *\n * `null` when the route declared no resolver, when the resolver answered\n * nothing, and when it THREW: a resolver that cannot read its request has\n * named no subject, and the gate then treats the request as anonymous — the\n * conservative direction. The resolver reads a clone, so the handler still\n * receives the untouched body. Ids are held to a plain path segment, so a\n * resolver cannot hand the gate a path to read overrides from.\n */\nexport async function resolvePluginApiRequestSubject(\n path: string,\n request: Request,\n): Promise<PluginApiRequestSubject | null> {\n const matched = matchRegisteredApiKey(path)\n const resolve = matched ? apiRouteOptions.get(matched.key)?.subject : undefined\n if (!resolve) return null\n try {\n const subject = await resolve(request.clone())\n if (!subject) return null\n const orgId = subjectId(subject.orgId)\n const uid = subjectId(subject.uid)\n if (!orgId && !uid) return null\n return { orgId, ...(uid ? { uid } : {}) }\n } catch {\n return null\n }\n}\n\n/**\n * The legacy (node-style) handler at a request path, or undefined when\n * nothing is registered there or the route is a web handler — a web route\n * is reached through `resolvePluginApiMatch` and `runPluginApiMatch`, since\n * it takes the `Request` and the filled `:name` segments, not a `req`/`res`\n * pair.\n */\nexport function resolvePluginApiRoute(path: string): PluginApiHandler | undefined {\n const route = resolvePluginApiMatch(path)?.route\n return typeof route === 'function' ? route : undefined\n}\n\n/**\n * Runs a matched route against the dispatcher's request: a Web handler is\n * called as it is, and a (req, res) handler goes through the adapter the\n * caller supplies — the adapter lives on the `/server` entry, which this\n * module may not import.\n */\nexport async function runPluginApiMatch(\n match: PluginApiMatch,\n request: Request,\n params: Record<string, string | string[]>,\n runLegacy: (\n handler: PluginApiHandler,\n request: Request,\n params: Record<string, string | string[]>,\n ) => Promise<Response>,\n): Promise<Response> {\n const merged = { ...params, ...match.params }\n const route = match.route as { web?: PluginWebApiHandler }\n if (typeof route === 'object' && route !== null && typeof route.web === 'function') {\n return route.web(request, { params: merged })\n }\n return runLegacy(match.route as PluginApiHandler, request, merged)\n}\n\n/** Registered paths, for diagnostics. */\nexport function listPluginApiRoutes(): string[] {\n return Array.from(apiRoutes.keys())\n}\n"],"names":["normalizeApiPath","path","replace","apiRoutes","Map","apiRouteOwners","apiRouteOptions","getRegisteringPluginId","setRegisteringPluginId","registerPluginApiRoute","handler","options","key","owner","ANONYMOUS_OWNER","incumbent","get","undefined","console","error","set","delete","pluginIdForRegisteredApiPath","unregisterPluginApiRoute","matchRegisteredApiKey","has","params","segments","split","registered","keys","includes","pattern","length","matched","index","part","startsWith","slice","resolvePluginApiMatch","route","isPluginRecipientLinkRoute","recipientLink","SUBJECT_ID","subjectId","value","test","resolvePluginApiRequestSubject","request","resolve","subject","clone","orgId","uid","resolvePluginApiRoute","runPluginApiMatch","match","runLegacy","merged","web","listPluginApiRoutes","Array","from"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/api-plugins.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Server half of the plugin pattern (AGL-396): the API-route registry, the\n * server counterpart to the ConsoleExtension registry. A feature plugin\n * registers request handlers here from its `/server` entry point (never its\n * client barrel, so firebase-admin and other server-only deps stay out of\n * the browser bundle). The app ships one catch-all dispatcher route per\n * Next app that resolves a request path to a registered handler — so moving\n * a feature's API into its plugin needs no new app route and keeps the same\n * URL. Reference implementation: events-calendar `events/list` (AGL-396).\n *\n * The request/response shapes are structural (not `next` types) so plugins\n * stay framework-light; `NextApiRequest`/`NextApiResponse` satisfy them, so\n * the dispatcher passes Next's objects straight through.\n */\n\nexport interface PluginApiRequest {\n method?: string\n query: Partial<Record<string, string | string[]>>\n // `any` mirrors NextApiRequest.body (parsed JSON/form/text); handlers\n // validate it themselves, as they did as Next routes.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n body: any\n /**\n * The unparsed request text, for handlers that verify signatures over the\n * exact payload (Stripe/Svix webhooks registered as plugin API paths).\n * UTF-8 re-encoding of the text is byte-identical for valid UTF-8 JSON.\n */\n rawBody?: string\n headers: Partial<Record<string, string | string[]>>\n cookies: Partial<Record<string, string>>\n /**\n * Node socket — the client-address fallback (`remoteAddress`).\n *\n * On the console's Pages Router this is the real transport peer. Behind the\n * App Router adapter there is no socket, and `api-adapter.ts` fills it from\n * the shared client-address reader so a handler falling back to it gets the\n * same trusted hop rather than a caller-supplied one.\n */\n socket: { remoteAddress?: string }\n}\n\nexport interface PluginApiResponse {\n status(code: number): PluginApiResponse\n json(body: unknown): void\n send(body: unknown): void\n setHeader(name: string, value: string | number | readonly string[]): void\n redirect(url: string): void\n redirect(status: number, url: string): void\n end(): void\n}\n\nexport type PluginApiHandler = (\n req: PluginApiRequest,\n res: PluginApiResponse,\n) => void | Promise<void>\n\n/**\n * A handler in the Web shape (AGL-2939): the dispatcher's own `Request`\n * in, a `Response` out — with a streamed body when the door streams, as\n * the assistant's chat and a job's event feed do. `params` carries the\n * dispatcher's path segments and any `:name` segment the registered path\n * declared.\n */\nexport type PluginWebApiHandler = (\n request: Request,\n context: { params: Record<string, string | string[]> },\n) => Response | Promise<Response>\n\n/** What a route registers: the (req, res) shape, or a Web handler. */\nexport type PluginApiRoute = PluginApiHandler | { web: PluginWebApiHandler }\n\n/** A registered route matched to a request path, with its `:name` segments filled. */\nexport interface PluginApiMatch {\n route: PluginApiRoute\n params: Record<string, string>\n}\n\n/**\n * Who a request is FOR, as far as the dispatchers' release gate is concerned\n * (AGL-2978).\n *\n * The gate buckets a rollout and reads per-org overrides on an org id, and a\n * dispatcher learns that id from the site a request names (`hostId`). Two\n * kinds of request name no site and still belong to one organization:\n *\n * - a request to an ORGANIZATION-level surface (`ConsoleExtension.orgNavItems`,\n * AGL-2974), which names its org rather than a site;\n * - a request that arrives with no bearer token by design — a provider's\n * OAuth redirect back to the platform, which carries only what the platform\n * signed into it.\n *\n * Without a subject the gate treats both as anonymous, so a plugin released to\n * one organization by override refused that organization's own requests, and\n * a staff member previewing a dark plugin could not complete a redirect their\n * own session had started.\n */\nexport interface PluginApiRequestSubject {\n /** The organization: the rollout bucket and the override key. */\n orgId: string | null\n /**\n * The account the request acts for, read ONLY when the request carries no\n * bearer token — a token speaks for itself. A route may name one only when\n * it verified a signature binding the uid to the request, because the gate\n * lets a staff account named here preview a released-off plugin.\n */\n uid?: string | null\n}\n\n/** Reads a request's subject; answers `null` when the request names none. */\nexport type PluginApiSubjectResolver = (\n request: Request,\n) => PluginApiRequestSubject | null | Promise<PluginApiRequestSubject | null>\n\n/** What a registration may say about its route beyond the handler. */\nexport interface PluginApiRouteOptions {\n /**\n * How a request to this route names its subject when it names no site.\n * Consulted by both dispatchers before their release gate, and only for a\n * request with no `hostId`: a named site always decides the org.\n */\n subject?: PluginApiSubjectResolver\n /**\n * The route answers a link the platform MAILED to somebody — an\n * unsubscribe in a message's `List-Unsubscribe` header — rather than a door\n * a member uses (AGL-2981).\n *\n * Such a link has to keep working whether or not its plugin is released,\n * or switched on, for the workspace NOW. A recipient's way out does not\n * close when a rollout is paused or a workspace turns the plugin off:\n * CAN-SPAM holds an opt-out open for thirty days after the send, and an\n * unsubscribe that 404s the day a kill switch is flipped is one nobody can\n * use. So both dispatchers skip their per-site enablement and release\n * gates for it — and nothing else: lockdown and the rate limit still\n * apply. The flag says nothing about who may act, so the route\n * authenticates the link itself, by a signature it verifies.\n */\n recipientLink?: boolean\n /**\n * The route OPENS A CARD PAYMENT for whoever calls it: a Checkout Session,\n * a PaymentIntent or a SetupIntent (AGL-3363).\n *\n * The tenant dispatcher holds such a route to the card-testing counters\n * (`card-payment-velocity.ts`) on top of its ordinary write limit, so a\n * plugin's payment door is covered by declaring itself here rather than by\n * carrying a limiter of its own. A console door (a signed-in merchant's\n * POS or draft order) does not declare it: a register ringing up a queue\n * is not a card tester. The 3-D Secure sweep\n * (`card-payments-request-3ds.spec.ts`) classifies every file that creates\n * a payment, so a new visitor door cannot skip the declaration.\n */\n cardPayment?: boolean\n}\n\n/** Leading/trailing slashes stripped so '/events/list' and 'events/list' key alike. */\nfunction normalizeApiPath(path: string): string {\n return path.replace(/^\\/+|\\/+$/g, '')\n}\n\nconst apiRoutes = new Map<string, PluginApiRoute>()\nconst apiRouteOwners = new Map<string, string>()\nconst apiRouteOptions = new Map<string, PluginApiRouteOptions>()\n\n/**\n * The \"currently registering plugin\" marker moved to a leaf module\n * (`registering-plugin`) so `site-page-hooks` can read it too — this file is\n * only reachable through the `/server` entry, and importing it from the shared\n * plugin-manager barrel formed a cycle (AGL-1289). Re-exported here so the\n * plugin loader's existing import keeps working unchanged.\n */\nimport { getRegisteringPluginId } from './registering-plugin'\n\nexport {\n setRegisteringPluginId,\n getRegisteringPluginId,\n} from './registering-plugin'\n\n/**\n * Registers a plugin API handler at a host-relative path (e.g.\n * 'events/list'), served by the app dispatcher at `/api/events/list`.\n *\n * Idempotent by path FOR ITS OWNER — re-registration by the plugin that\n * holds the path replaces the previous handler, which is what a hot reload,\n * a second surface, and a repeated init all depend on.\n *\n * A different plugin is REFUSED (AGL-2484). This map is process-global and\n * was last-writer-wins on the path alone, while Aglyn's own commission-taking\n * handlers sit at `marketplace/checkout` and `commerce/checkout` — and the\n * tenant dispatcher calls `ensureRemoteServerBundles()` after the first-party\n * loader has run, so a remote bundle registering either path was simply the\n * later writer. Getting there needs `PLUGIN_REMOTE_SERVER=enabled` (off by\n * default), realm trust and a valid signature, so this is the last of several\n * doors rather than the only one; it is also the one the registry could close\n * by itself, because it already recorded who registered what.\n *\n * Anonymous registration — no loader marker — is an IDENTITY here, not a\n * wildcard: it may replace itself and nothing else may take it.\n */\nexport function registerPluginApiRoute(\n path: string,\n handler: PluginApiRoute,\n options?: PluginApiRouteOptions,\n): void {\n const key = normalizeApiPath(path)\n const owner = getRegisteringPluginId() ?? ANONYMOUS_OWNER\n const incumbent = apiRouteOwners.get(key)\n if (incumbent !== undefined && incumbent !== owner) {\n // Loud, because the silent version of this is a payment route quietly\n // answering from someone else's code. Refusing is the safe direction:\n // the incumbent keeps serving.\n console.error(\n `[plugins] refused API route \"${key}\" to \"${owner}\": already ` +\n `registered by \"${incumbent}\"`,\n )\n return\n }\n apiRoutes.set(key, handler)\n apiRouteOwners.set(key, owner)\n // Replaced with the handler, never merged: a re-registration that drops its\n // subject resolver must not keep answering with the previous one.\n if (options) apiRouteOptions.set(key, options)\n else apiRouteOptions.delete(key)\n}\n\n/** The marker for a registration made outside any loader context. */\nconst ANONYMOUS_OWNER = ''\n\n/** The plugin that registered a path, for per-org enablement gating. */\nexport function pluginIdForRegisteredApiPath(path: string): string | undefined {\n const owner = apiRouteOwners.get(normalizeApiPath(path))\n // An anonymous registration has no plugin to gate on, and this has always\n // answered `undefined` for one — the sentinel must not leak out as an id.\n return owner === undefined || owner === ANONYMOUS_OWNER ? undefined : owner\n}\n\nexport function unregisterPluginApiRoute(path: string): void {\n const key = normalizeApiPath(path)\n apiRoutes.delete(key)\n // Release the CLAIM too, or the refusal above outlives the route and the\n // path can never be re-registered for the life of the process.\n apiRouteOwners.delete(key)\n apiRouteOptions.delete(key)\n}\n\n/**\n * The registered key a request path resolves to, with its `:name` segments.\n *\n * An exact registration wins; otherwise a registration whose path carries\n * `:name` segments matches segment for segment. One matcher for the route and\n * for its options, so a request can never be answered by one registration and\n * gated with another's subject.\n */\nfunction matchRegisteredApiKey(\n path: string,\n): { key: string; params: Record<string, string> } | undefined {\n const key = normalizeApiPath(path)\n if (apiRoutes.has(key)) return { key, params: {} }\n const segments = key.split('/')\n for (const registered of apiRoutes.keys()) {\n if (!registered.includes(':')) continue\n const pattern = registered.split('/')\n if (pattern.length !== segments.length) continue\n const params: Record<string, string> = {}\n let matched = true\n for (let index = 0; index < pattern.length; index += 1) {\n const part = pattern[index]\n if (part.startsWith(':') && segments[index]) {\n params[part.slice(1)] = segments[index]\n } else if (part !== segments[index]) {\n matched = false\n break\n }\n }\n if (matched) return { key: registered, params }\n }\n return undefined\n}\n\n/**\n * The route for a request path, or undefined when nothing is registered.\n *\n * An exact registration wins; otherwise a registration whose path carries\n * `:name` segments matches segment for segment (`ai/jobs/:jobId/cancel`\n * answers `ai/jobs/abc/cancel`), and the named segments come back as\n * `params`. Patterns are tried in registration order and a static segment\n * beats a named one only by being registered exactly.\n */\nexport function resolvePluginApiMatch(path: string): PluginApiMatch | undefined {\n const matched = matchRegisteredApiKey(path)\n if (!matched) return undefined\n const route = apiRoutes.get(matched.key)\n return route ? { route, params: matched.params } : undefined\n}\n\n/**\n * Whether a request path resolves to a route registered as a\n * {@link PluginApiRouteOptions.recipientLink} — what both dispatchers ask\n * before their enablement and release gates (AGL-2981).\n */\nexport function isPluginRecipientLinkRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.recipientLink === true : false\n}\n\n/**\n * Whether a request path resolves to a route registered with\n * {@link PluginApiRouteOptions.cardPayment} — what the tenant dispatcher\n * asks before its card-testing counters (AGL-3363).\n */\nexport function isPluginCardPaymentRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.cardPayment === true : false\n}\n\n/** An id a subject may carry: a non-empty path segment, not a path. */\nconst SUBJECT_ID = /^[^/\\s]{1,128}$/\n\nconst subjectId = (value: unknown): string | null =>\n typeof value === 'string' && SUBJECT_ID.test(value) ? value : null\n\n/**\n * The subject a request to a registered route names, or `null` (AGL-2978).\n *\n * `null` when the route declared no resolver, when the resolver answered\n * nothing, and when it THREW: a resolver that cannot read its request has\n * named no subject, and the gate then treats the request as anonymous — the\n * conservative direction. The resolver reads a clone, so the handler still\n * receives the untouched body. Ids are held to a plain path segment, so a\n * resolver cannot hand the gate a path to read overrides from.\n */\nexport async function resolvePluginApiRequestSubject(\n path: string,\n request: Request,\n): Promise<PluginApiRequestSubject | null> {\n const matched = matchRegisteredApiKey(path)\n const resolve = matched ? apiRouteOptions.get(matched.key)?.subject : undefined\n if (!resolve) return null\n try {\n const subject = await resolve(request.clone())\n if (!subject) return null\n const orgId = subjectId(subject.orgId)\n const uid = subjectId(subject.uid)\n if (!orgId && !uid) return null\n return { orgId, ...(uid ? { uid } : {}) }\n } catch {\n return null\n }\n}\n\n/**\n * The legacy (node-style) handler at a request path, or undefined when\n * nothing is registered there or the route is a web handler — a web route\n * is reached through `resolvePluginApiMatch` and `runPluginApiMatch`, since\n * it takes the `Request` and the filled `:name` segments, not a `req`/`res`\n * pair.\n */\nexport function resolvePluginApiRoute(path: string): PluginApiHandler | undefined {\n const route = resolvePluginApiMatch(path)?.route\n return typeof route === 'function' ? route : undefined\n}\n\n/**\n * Runs a matched route against the dispatcher's request: a Web handler is\n * called as it is, and a (req, res) handler goes through the adapter the\n * caller supplies — the adapter lives on the `/server` entry, which this\n * module may not import.\n */\nexport async function runPluginApiMatch(\n match: PluginApiMatch,\n request: Request,\n params: Record<string, string | string[]>,\n runLegacy: (\n handler: PluginApiHandler,\n request: Request,\n params: Record<string, string | string[]>,\n ) => Promise<Response>,\n): Promise<Response> {\n const merged = { ...params, ...match.params }\n const route = match.route as { web?: PluginWebApiHandler }\n if (typeof route === 'object' && route !== null && typeof route.web === 'function') {\n return route.web(request, { params: merged })\n }\n return runLegacy(match.route as PluginApiHandler, request, merged)\n}\n\n/** Registered paths, for diagnostics. */\nexport function listPluginApiRoutes(): string[] {\n return Array.from(apiRoutes.keys())\n}\n"],"names":["normalizeApiPath","path","replace","apiRoutes","Map","apiRouteOwners","apiRouteOptions","getRegisteringPluginId","setRegisteringPluginId","registerPluginApiRoute","handler","options","key","owner","ANONYMOUS_OWNER","incumbent","get","undefined","console","error","set","delete","pluginIdForRegisteredApiPath","unregisterPluginApiRoute","matchRegisteredApiKey","has","params","segments","split","registered","keys","includes","pattern","length","matched","index","part","startsWith","slice","resolvePluginApiMatch","route","isPluginRecipientLinkRoute","recipientLink","isPluginCardPaymentRoute","cardPayment","SUBJECT_ID","subjectId","value","test","resolvePluginApiRequestSubject","request","resolve","subject","clone","orgId","uid","resolvePluginApiRoute","runPluginApiMatch","match","runLegacy","merged","web","listPluginApiRoutes","Array","from"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;CAaC;AA4ID,qFAAqF,GACrF,SAASA,iBAAiBC,IAAY;IACpC,OAAOA,KAAKC,OAAO,CAAC,cAAc;AACpC;AAEA,MAAMC,YAAY,IAAIC;AACtB,MAAMC,iBAAiB,IAAID;AAC3B,MAAME,kBAAkB,IAAIF;AAE5B;;;;;;CAMC,GACD,SAASG,sBAAsB,QAAQ,0BAAsB;AAE7D,SACEC,sBAAsB,EACtBD,sBAAsB,QACjB,0BAAsB;AAE7B;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASE,uBACdR,IAAY,EACZS,OAAuB,EACvBC,OAA+B;QAGjBJ;IADd,MAAMK,MAAMZ,iBAAiBC;IAC7B,MAAMY,SAAQN,0BAAAA,oCAAAA,0BAA4BO;IAC1C,MAAMC,YAAYV,eAAeW,GAAG,CAACJ;IACrC,IAAIG,cAAcE,aAAaF,cAAcF,OAAO;QAClD,sEAAsE;QACtE,sEAAsE;QACtE,+BAA+B;QAC/BK,QAAQC,KAAK,CACX,CAAC,6BAA6B,EAAEP,IAAI,MAAM,EAAEC,MAAM,WAAW,CAAC,GAC5D,CAAC,eAAe,EAAEE,UAAU,CAAC,CAAC;QAElC;IACF;IACAZ,UAAUiB,GAAG,CAACR,KAAKF;IACnBL,eAAee,GAAG,CAACR,KAAKC;IACxB,4EAA4E;IAC5E,kEAAkE;IAClE,IAAIF,SAASL,gBAAgBc,GAAG,CAACR,KAAKD;SACjCL,gBAAgBe,MAAM,CAACT;AAC9B;AAEA,mEAAmE,GACnE,MAAME,kBAAkB;AAExB,sEAAsE,GACtE,OAAO,SAASQ,6BAA6BrB,IAAY;IACvD,MAAMY,QAAQR,eAAeW,GAAG,CAAChB,iBAAiBC;IAClD,0EAA0E;IAC1E,0EAA0E;IAC1E,OAAOY,UAAUI,aAAaJ,UAAUC,kBAAkBG,YAAYJ;AACxE;AAEA,OAAO,SAASU,yBAAyBtB,IAAY;IACnD,MAAMW,MAAMZ,iBAAiBC;IAC7BE,UAAUkB,MAAM,CAACT;IACjB,yEAAyE;IACzE,+DAA+D;IAC/DP,eAAegB,MAAM,CAACT;IACtBN,gBAAgBe,MAAM,CAACT;AACzB;AAEA;;;;;;;CAOC,GACD,SAASY,sBACPvB,IAAY;IAEZ,MAAMW,MAAMZ,iBAAiBC;IAC7B,IAAIE,UAAUsB,GAAG,CAACb,MAAM,OAAO;QAAEA;QAAKc,QAAQ,CAAC;IAAE;IACjD,MAAMC,WAAWf,IAAIgB,KAAK,CAAC;IAC3B,KAAK,MAAMC,cAAc1B,UAAU2B,IAAI,GAAI;QACzC,IAAI,CAACD,WAAWE,QAAQ,CAAC,MAAM;QAC/B,MAAMC,UAAUH,WAAWD,KAAK,CAAC;QACjC,IAAII,QAAQC,MAAM,KAAKN,SAASM,MAAM,EAAE;QACxC,MAAMP,SAAiC,CAAC;QACxC,IAAIQ,UAAU;QACd,IAAK,IAAIC,QAAQ,GAAGA,QAAQH,QAAQC,MAAM,EAAEE,SAAS,EAAG;YACtD,MAAMC,OAAOJ,OAAO,CAACG,MAAM;YAC3B,IAAIC,KAAKC,UAAU,CAAC,QAAQV,QAAQ,CAACQ,MAAM,EAAE;gBAC3CT,MAAM,CAACU,KAAKE,KAAK,CAAC,GAAG,GAAGX,QAAQ,CAACQ,MAAM;YACzC,OAAO,IAAIC,SAAST,QAAQ,CAACQ,MAAM,EAAE;gBACnCD,UAAU;gBACV;YACF;QACF;QACA,IAAIA,SAAS,OAAO;YAAEtB,KAAKiB;YAAYH;QAAO;IAChD;IACA,OAAOT;AACT;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASsB,sBAAsBtC,IAAY;IAChD,MAAMiC,UAAUV,sBAAsBvB;IACtC,IAAI,CAACiC,SAAS,OAAOjB;IACrB,MAAMuB,QAAQrC,UAAUa,GAAG,CAACkB,QAAQtB,GAAG;IACvC,OAAO4B,QAAQ;QAAEA;QAAOd,QAAQQ,QAAQR,MAAM;IAAC,IAAIT;AACrD;AAEA;;;;CAIC,GACD,OAAO,SAASwB,2BAA2BxC,IAAY;QAEpCK;IADjB,MAAM4B,UAAUV,sBAAsBvB;IACtC,OAAOiC,UAAU5B,EAAAA,uBAAAA,gBAAgBU,GAAG,CAACkB,QAAQtB,GAAG,sBAA/BN,qBAAkCoC,aAAa,MAAK,OAAO;AAC9E;AAEA;;;;CAIC,GACD,OAAO,SAASC,yBAAyB1C,IAAY;QAElCK;IADjB,MAAM4B,UAAUV,sBAAsBvB;IACtC,OAAOiC,UAAU5B,EAAAA,uBAAAA,gBAAgBU,GAAG,CAACkB,QAAQtB,GAAG,sBAA/BN,qBAAkCsC,WAAW,MAAK,OAAO;AAC5E;AAEA,qEAAqE,GACrE,MAAMC,aAAa;AAEnB,MAAMC,YAAY,CAACC,QACjB,OAAOA,UAAU,YAAYF,WAAWG,IAAI,CAACD,SAASA,QAAQ;AAEhE;;;;;;;;;CASC,GACD,OAAO,eAAeE,+BACpBhD,IAAY,EACZiD,OAAgB;QAGU5C;IAD1B,MAAM4B,UAAUV,sBAAsBvB;IACtC,MAAMkD,UAAUjB,WAAU5B,uBAAAA,gBAAgBU,GAAG,CAACkB,QAAQtB,GAAG,sBAA/BN,qBAAkC8C,OAAO,GAAGnC;IACtE,IAAI,CAACkC,SAAS,OAAO;IACrB,IAAI;QACF,MAAMC,UAAU,MAAMD,QAAQD,QAAQG,KAAK;QAC3C,IAAI,CAACD,SAAS,OAAO;QACrB,MAAME,QAAQR,UAAUM,QAAQE,KAAK;QACrC,MAAMC,MAAMT,UAAUM,QAAQG,GAAG;QACjC,IAAI,CAACD,SAAS,CAACC,KAAK,OAAO;QAC3B,OAAO;YAAED;WAAWC,MAAM;YAAEA;QAAI,IAAI,CAAC;IACvC,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,sBAAsBvD,IAAY;QAClCsC;IAAd,MAAMC,SAAQD,yBAAAA,sBAAsBtC,0BAAtBsC,uBAA6BC,KAAK;IAChD,OAAO,OAAOA,UAAU,aAAaA,QAAQvB;AAC/C;AAEA;;;;;CAKC,GACD,OAAO,eAAewC,kBACpBC,KAAqB,EACrBR,OAAgB,EAChBxB,MAAyC,EACzCiC,SAIsB;IAEtB,MAAMC,SAAS,aAAKlC,QAAWgC,MAAMhC,MAAM;IAC3C,MAAMc,QAAQkB,MAAMlB,KAAK;IACzB,IAAI,OAAOA,UAAU,YAAYA,UAAU,QAAQ,OAAOA,MAAMqB,GAAG,KAAK,YAAY;QAClF,OAAOrB,MAAMqB,GAAG,CAACX,SAAS;YAAExB,QAAQkC;QAAO;IAC7C;IACA,OAAOD,UAAUD,MAAMlB,KAAK,EAAsBU,SAASU;AAC7D;AAEA,uCAAuC,GACvC,OAAO,SAASE;IACd,OAAOC,MAAMC,IAAI,CAAC7D,UAAU2B,IAAI;AAClC"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/** One fixed window. */
|
|
18
|
+
export interface CardPaymentVelocityWindow {
|
|
19
|
+
limit: number;
|
|
20
|
+
windowMs: number;
|
|
21
|
+
}
|
|
22
|
+
export declare const CARD_PAYMENT_VELOCITY: {
|
|
23
|
+
/** Payment doors one address may open on one site. */
|
|
24
|
+
readonly perVisitor: {
|
|
25
|
+
readonly limit: 10;
|
|
26
|
+
readonly windowMs: number;
|
|
27
|
+
};
|
|
28
|
+
/** …on a site whose workspace is young (`isYoungWorkspaceAge`). */
|
|
29
|
+
readonly perVisitorYoung: {
|
|
30
|
+
readonly limit: 5;
|
|
31
|
+
readonly windowMs: number;
|
|
32
|
+
};
|
|
33
|
+
/** Payment doors one address may open across every site. */
|
|
34
|
+
readonly perAddress: {
|
|
35
|
+
readonly limit: 30;
|
|
36
|
+
readonly windowMs: number;
|
|
37
|
+
};
|
|
38
|
+
/** Payment doors one site may see before staff are told. Refuses nothing. */
|
|
39
|
+
readonly perSite: {
|
|
40
|
+
readonly limit: 150;
|
|
41
|
+
readonly windowMs: number;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
/** The per-visitor key: one bucket per (site, address). */
|
|
45
|
+
export declare function cardPaymentVisitorKey(hostId: string, ip: string): string;
|
|
46
|
+
/** The per-address key, across every site. */
|
|
47
|
+
export declare function cardPaymentAddressKey(ip: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* The per-site key. `''` answers null: a request that named no site has no
|
|
50
|
+
* site to alarm about, and the two counters above still hold it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function cardPaymentSiteKey(hostId: string): string | null;
|
|
53
|
+
/**
|
|
54
|
+
* The abuse-queue row id for a site's alarm: one per site per UTC day, so a
|
|
55
|
+
* sustained attack is one row that counts, not a row per window.
|
|
56
|
+
*/
|
|
57
|
+
export declare function cardPaymentAlarmDay(nowMs: number): string;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /*==========================================
|
|
17
|
+
* CARD-TESTING VELOCITY ON A VISITOR'S PAYMENT DOOR (AGL-3363).
|
|
18
|
+
*
|
|
19
|
+
* The pure policy; the durable half is `card-payment-velocity.ts` in
|
|
20
|
+
* tenant-data-admin, beside the visitor-write limiter it tightens.
|
|
21
|
+
*
|
|
22
|
+
* A public storefront checkout is the classic card-testing target: a script
|
|
23
|
+
* opens payment after payment from one address, or from many addresses
|
|
24
|
+
* against one shop, to learn which stolen cards still authorize. The
|
|
25
|
+
* dispatcher's visitor-write limit (120 writes a minute per site and
|
|
26
|
+
* address, `plugin-api-rate-limit.ts`) is sized for a shopper typing into a
|
|
27
|
+
* quantity box and never meant to bound payments. So a route that OPENS A
|
|
28
|
+
* CARD PAYMENT — a Checkout Session, a PaymentIntent, a SetupIntent —
|
|
29
|
+
* declares itself (`registerPluginApiRoute(path, handler, { cardPayment:
|
|
30
|
+
* true })`) and meets three more counters in the tenant dispatcher:
|
|
31
|
+
*
|
|
32
|
+
* 1. PER VISITOR, per (site, address): {@link CARD_PAYMENT_VELOCITY}
|
|
33
|
+
* `perVisitor`. A shopper opens checkout, backs out, changes the cart
|
|
34
|
+
* and opens it again; ten in ten minutes is several times that, and a
|
|
35
|
+
* tester wants hundreds. A young workspace's site gets the tighter
|
|
36
|
+
* `perVisitorYoung`: the 9/26 incident's workspace was days old.
|
|
37
|
+
* 2. PER ADDRESS, across every site: `perAddress`. Testers rotate merchants
|
|
38
|
+
* so no one shop sees the volume. The visitor-write policy dropped a
|
|
39
|
+
* platform-wide per-address limiter because it tripled the cost of every
|
|
40
|
+
* add-to-cart; a payment door is a small share of writes and each one it
|
|
41
|
+
* admits is a real Stripe object, so the second counter is paid for here.
|
|
42
|
+
* 3. PER SITE, every address together: `perSite`. It REFUSES NOTHING. A
|
|
43
|
+
* site-wide refusal is a denial of service any stranger can aim at a
|
|
44
|
+
* merchant (the visitor-write policy's own argument), and a busy shop's
|
|
45
|
+
* launch looks like this too. Crossing it files one urgent abuse-queue
|
|
46
|
+
* row for the site, beside the seller-pattern row, so staff see a site
|
|
47
|
+
* under attack — or a merchant testing cards through their own shop.
|
|
48
|
+
*
|
|
49
|
+
* What it cannot see: the card. Hosted Checkout collects it on Stripe's
|
|
50
|
+
* page, so a per-card-fingerprint velocity is Radar's to enforce (Checkout
|
|
51
|
+
* applies Stripe's card-testing protection by default; see the staff doc).
|
|
52
|
+
*=========================================*/ /** One fixed window. */ const TEN_MINUTES = 10 * 60000;
|
|
53
|
+
export const CARD_PAYMENT_VELOCITY = {
|
|
54
|
+
/** Payment doors one address may open on one site. */ perVisitor: {
|
|
55
|
+
limit: 10,
|
|
56
|
+
windowMs: TEN_MINUTES
|
|
57
|
+
},
|
|
58
|
+
/** …on a site whose workspace is young (`isYoungWorkspaceAge`). */ perVisitorYoung: {
|
|
59
|
+
limit: 5,
|
|
60
|
+
windowMs: TEN_MINUTES
|
|
61
|
+
},
|
|
62
|
+
/** Payment doors one address may open across every site. */ perAddress: {
|
|
63
|
+
limit: 30,
|
|
64
|
+
windowMs: TEN_MINUTES
|
|
65
|
+
},
|
|
66
|
+
/** Payment doors one site may see before staff are told. Refuses nothing. */ perSite: {
|
|
67
|
+
limit: 150,
|
|
68
|
+
windowMs: TEN_MINUTES
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/** The per-visitor key: one bucket per (site, address). */ export function cardPaymentVisitorKey(hostId, ip) {
|
|
72
|
+
return `cardpay:visitor:${hostId || '-'}:${ip || 'unknown'}`;
|
|
73
|
+
}
|
|
74
|
+
/** The per-address key, across every site. */ export function cardPaymentAddressKey(ip) {
|
|
75
|
+
return `cardpay:address:${ip || 'unknown'}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The per-site key. `''` answers null: a request that named no site has no
|
|
79
|
+
* site to alarm about, and the two counters above still hold it.
|
|
80
|
+
*/ export function cardPaymentSiteKey(hostId) {
|
|
81
|
+
return hostId ? `cardpay:site:${hostId}` : null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The abuse-queue row id for a site's alarm: one per site per UTC day, so a
|
|
85
|
+
* sustained attack is one row that counts, not a row per window.
|
|
86
|
+
*/ export function cardPaymentAlarmDay(nowMs) {
|
|
87
|
+
return new Date(nowMs).toISOString().slice(0, 10);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
//# sourceMappingURL=card-payment-velocity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/card-payment-velocity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * CARD-TESTING VELOCITY ON A VISITOR'S PAYMENT DOOR (AGL-3363).\n *\n * The pure policy; the durable half is `card-payment-velocity.ts` in\n * tenant-data-admin, beside the visitor-write limiter it tightens.\n *\n * A public storefront checkout is the classic card-testing target: a script\n * opens payment after payment from one address, or from many addresses\n * against one shop, to learn which stolen cards still authorize. The\n * dispatcher's visitor-write limit (120 writes a minute per site and\n * address, `plugin-api-rate-limit.ts`) is sized for a shopper typing into a\n * quantity box and never meant to bound payments. So a route that OPENS A\n * CARD PAYMENT — a Checkout Session, a PaymentIntent, a SetupIntent —\n * declares itself (`registerPluginApiRoute(path, handler, { cardPayment:\n * true })`) and meets three more counters in the tenant dispatcher:\n *\n * 1. PER VISITOR, per (site, address): {@link CARD_PAYMENT_VELOCITY}\n * `perVisitor`. A shopper opens checkout, backs out, changes the cart\n * and opens it again; ten in ten minutes is several times that, and a\n * tester wants hundreds. A young workspace's site gets the tighter\n * `perVisitorYoung`: the 9/26 incident's workspace was days old.\n * 2. PER ADDRESS, across every site: `perAddress`. Testers rotate merchants\n * so no one shop sees the volume. The visitor-write policy dropped a\n * platform-wide per-address limiter because it tripled the cost of every\n * add-to-cart; a payment door is a small share of writes and each one it\n * admits is a real Stripe object, so the second counter is paid for here.\n * 3. PER SITE, every address together: `perSite`. It REFUSES NOTHING. A\n * site-wide refusal is a denial of service any stranger can aim at a\n * merchant (the visitor-write policy's own argument), and a busy shop's\n * launch looks like this too. Crossing it files one urgent abuse-queue\n * row for the site, beside the seller-pattern row, so staff see a site\n * under attack — or a merchant testing cards through their own shop.\n *\n * What it cannot see: the card. Hosted Checkout collects it on Stripe's\n * page, so a per-card-fingerprint velocity is Radar's to enforce (Checkout\n * applies Stripe's card-testing protection by default; see the staff doc).\n *=========================================*/\n\n/** One fixed window. */\nexport interface CardPaymentVelocityWindow {\n limit: number\n windowMs: number\n}\n\nconst TEN_MINUTES = 10 * 60_000\n\nexport const CARD_PAYMENT_VELOCITY = {\n /** Payment doors one address may open on one site. */\n perVisitor: { limit: 10, windowMs: TEN_MINUTES },\n /** …on a site whose workspace is young (`isYoungWorkspaceAge`). */\n perVisitorYoung: { limit: 5, windowMs: TEN_MINUTES },\n /** Payment doors one address may open across every site. */\n perAddress: { limit: 30, windowMs: TEN_MINUTES },\n /** Payment doors one site may see before staff are told. Refuses nothing. */\n perSite: { limit: 150, windowMs: TEN_MINUTES },\n} as const satisfies Record<string, CardPaymentVelocityWindow>\n\n/** The per-visitor key: one bucket per (site, address). */\nexport function cardPaymentVisitorKey(hostId: string, ip: string): string {\n return `cardpay:visitor:${hostId || '-'}:${ip || 'unknown'}`\n}\n\n/** The per-address key, across every site. */\nexport function cardPaymentAddressKey(ip: string): string {\n return `cardpay:address:${ip || 'unknown'}`\n}\n\n/**\n * The per-site key. `''` answers null: a request that named no site has no\n * site to alarm about, and the two counters above still hold it.\n */\nexport function cardPaymentSiteKey(hostId: string): string | null {\n return hostId ? `cardpay:site:${hostId}` : null\n}\n\n/**\n * The abuse-queue row id for a site's alarm: one per site per UTC day, so a\n * sustained attack is one row that counts, not a row per window.\n */\nexport function cardPaymentAlarmDay(nowMs: number): string {\n return new Date(nowMs).toISOString().slice(0, 10)\n}\n"],"names":["TEN_MINUTES","CARD_PAYMENT_VELOCITY","perVisitor","limit","windowMs","perVisitorYoung","perAddress","perSite","cardPaymentVisitorKey","hostId","ip","cardPaymentAddressKey","cardPaymentSiteKey","cardPaymentAlarmDay","nowMs","Date","toISOString","slice"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAoC2C,GAE3C,sBAAsB,GAMtB,MAAMA,cAAc,KAAK;AAEzB,OAAO,MAAMC,wBAAwB;IACnC,oDAAoD,GACpDC,YAAY;QAAEC,OAAO;QAAIC,UAAUJ;IAAY;IAC/C,iEAAiE,GACjEK,iBAAiB;QAAEF,OAAO;QAAGC,UAAUJ;IAAY;IACnD,0DAA0D,GAC1DM,YAAY;QAAEH,OAAO;QAAIC,UAAUJ;IAAY;IAC/C,2EAA2E,GAC3EO,SAAS;QAAEJ,OAAO;QAAKC,UAAUJ;IAAY;AAC/C,EAA8D;AAE9D,yDAAyD,GACzD,OAAO,SAASQ,sBAAsBC,MAAc,EAAEC,EAAU;IAC9D,OAAO,CAAC,gBAAgB,EAAED,UAAU,IAAI,CAAC,EAAEC,MAAM,WAAW;AAC9D;AAEA,4CAA4C,GAC5C,OAAO,SAASC,sBAAsBD,EAAU;IAC9C,OAAO,CAAC,gBAAgB,EAAEA,MAAM,WAAW;AAC7C;AAEA;;;CAGC,GACD,OAAO,SAASE,mBAAmBH,MAAc;IAC/C,OAAOA,SAAS,CAAC,aAAa,EAAEA,QAAQ,GAAG;AAC7C;AAEA;;;CAGC,GACD,OAAO,SAASI,oBAAoBC,KAAa;IAC/C,OAAO,IAAIC,KAAKD,OAAOE,WAAW,GAAGC,KAAK,CAAC,GAAG;AAChD"}
|
|
@@ -169,6 +169,12 @@ export interface CrmDigestEmailInput {
|
|
|
169
169
|
hostName: (hostId: string) => string;
|
|
170
170
|
/** The brand's support line, already prefixed, or empty. */
|
|
171
171
|
supportLine?: string;
|
|
172
|
+
/**
|
|
173
|
+
* Leave out the closing paragraph and the support line (AGL-3367), for the
|
|
174
|
+
* copy that goes inside a system email: its footer says why the mail came
|
|
175
|
+
* and where to get help, and its body links the settings page.
|
|
176
|
+
*/
|
|
177
|
+
omitClosing?: boolean;
|
|
172
178
|
}
|
|
173
179
|
/**
|
|
174
180
|
* The plain-text email. Text rather than a designed template because the
|
|
@@ -264,6 +264,7 @@ function section(heading, lines) {
|
|
|
264
264
|
for (const hostId of leadHosts){
|
|
265
265
|
lines.push(`Leads on ${input.hostName(hostId)}: ${input.leadsUrl(hostId)}`);
|
|
266
266
|
}
|
|
267
|
+
if (input.omitClosing) return lines.join('\n').trimEnd();
|
|
267
268
|
lines.push('', 'You get this each morning because the Daily CRM digest is on in your ' + `notification settings: ${input.settingsUrl}`);
|
|
268
269
|
return lines.join('\n') + ((_input_supportLine = input.supportLine) != null ? _input_supportLine : '');
|
|
269
270
|
}
|