@aglyn/aglyn 1.0.0-beta.200 → 1.0.0-beta.201
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/artifact-list-keys.js +10 -0
- package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +19 -0
- package/src/lib/app-utils/crm.js +27 -1
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
- package/src/lib/app-utils/docs-help.generated.js +10 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +78 -64
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/email-state.d.ts +7 -3
- package/src/lib/app-utils/email-state.js +22 -6
- package/src/lib/app-utils/email-state.js.map +1 -1
- package/src/lib/app-utils/forms.d.ts +101 -9
- package/src/lib/app-utils/forms.js +137 -7
- package/src/lib/app-utils/forms.js.map +1 -1
- package/src/lib/app-utils/health-report.d.ts +3 -3
- package/src/lib/app-utils/health-report.js +9 -9
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/host-naming.d.ts +12 -7
- package/src/lib/app-utils/host-naming.js +18 -3
- package/src/lib/app-utils/host-naming.js.map +1 -1
- package/src/lib/app-utils/lockdown.d.ts +19 -0
- package/src/lib/app-utils/lockdown.js +21 -0
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/media-embedded-fields.d.ts +40 -0
- package/src/lib/app-utils/media-embedded-fields.js +77 -0
- package/src/lib/app-utils/media-embedded-fields.js.map +1 -1
- package/src/lib/app-utils/media-metadata.d.ts +17 -1
- package/src/lib/app-utils/media-metadata.js +22 -2
- package/src/lib/app-utils/media-metadata.js.map +1 -1
- package/src/lib/app-utils/payment-risk.d.ts +75 -0
- package/src/lib/app-utils/payment-risk.js +139 -0
- package/src/lib/app-utils/payment-risk.js.map +1 -0
- package/src/lib/app-utils/plugin-api-request-host.d.ts +29 -0
- package/src/lib/app-utils/plugin-api-request-host.js +90 -0
- package/src/lib/app-utils/plugin-api-request-host.js.map +1 -0
- package/src/lib/app-utils/plugin-bundle-checks.d.ts +3 -2
- package/src/lib/app-utils/plugin-bundle-checks.js +76 -5
- package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
- package/src/lib/app-utils/webhook-delivery.js +4 -1
- package/src/lib/app-utils/webhook-delivery.js.map +1 -1
- 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-activity-actions.d.ts +9 -1
- package/src/lib/plugin-manager/plugin-activity-actions.js +11 -1
- package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
- package/src/lib/plugin-manager/plugin-events.d.ts +22 -0
- package/src/lib/plugin-manager/plugin-events.js.map +1 -1
- package/src/lib/plugin-manager/plugin-jobs.d.ts +8 -4
- package/src/lib/plugin-manager/plugin-jobs.js +8 -4
- package/src/lib/plugin-manager/plugin-jobs.js.map +1 -1
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
* side effects. The claim is a fold over all of them, never a break.
|
|
40
40
|
*/ export async function runBillingWebhookHandlers(event) {
|
|
41
41
|
let claimed = false;
|
|
42
|
+
const hostIds = [];
|
|
42
43
|
for (const handler of handlers){
|
|
43
44
|
// Narrowed through the union rather than read off it: the handler's
|
|
44
45
|
// return type includes `void` — the shape every handler that does not
|
|
@@ -46,9 +47,12 @@
|
|
|
46
47
|
// with `strictNullChecks` off.
|
|
47
48
|
const result = await handler(event);
|
|
48
49
|
if ((result == null ? void 0 : result.claimed) === true) claimed = true;
|
|
50
|
+
const hostId = typeof (result == null ? void 0 : result.hostId) === 'string' ? result.hostId : '';
|
|
51
|
+
if (hostId && !hostIds.includes(hostId)) hostIds.push(hostId);
|
|
49
52
|
}
|
|
50
53
|
return {
|
|
51
|
-
claimed
|
|
54
|
+
claimed,
|
|
55
|
+
hostIds
|
|
52
56
|
};
|
|
53
57
|
}
|
|
54
58
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/billing-webhook-hooks.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 * Billing-webhook extension point (AGL-418): plugins register handlers for\n * the platform Stripe webhook from their `/server` entries — the console's\n * webhook route verifies the signature, syncs the org's plan/subscription\n * (the only core-billing concern), then fans the event out here. Handlers\n * self-select on `type` + `object.metadata.type` exactly as the old inline\n * sections did (commerce orders/carts/drafts/reservations/subscriptions,\n * booking payments, marketplace purchases). Errors PROPAGATE: a throwing\n * handler fails the webhook with a 500 so Stripe redelivers — identical to\n * the pre-extraction behavior, and every section is idempotent by doc key.\n */\n\nexport interface BillingWebhookEvent {\n /** Stripe event type, e.g. 'checkout.session.completed'. */\n type: string\n /** `event.data.object` — the session/subscription/invoice payload. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n object: any\n /** The full parsed Stripe event, for handlers that need ids/metadata. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n event: any\n /** The webhook request's Host header — for absolute callback URLs. */\n requestHost?: string\n}\n\n/**\n * What a handler may tell the route about the event it just saw (AGL-2429).\n *\n * Handlers have always returned `void`, which made \"a plugin handled this\"\n * and \"every plugin ignored this\" the same observation from the route's side.\n * On most events that is harmless — the route does not care who consumed a\n * `checkout.session.completed`. On a CHARGEBACK it is the whole question: an\n * event nobody claimed is money that moved with nothing recording it, and it\n * is indistinguishable from the ordinary case where a plugin quietly did its\n * job. See the `charge.dispute.*` section of the console's webhook route.\n *\n * `claimed` is deliberately opt-in and deliberately narrow. A handler sets it\n * when it RECOGNISED the event as its own — found the order, the purchase,\n * the booking — not when it merely ran. Returning nothing keeps the old\n * meaning, so every handler that does not care is unaffected.\n */\nexport interface BillingWebhookHandlerResult {\n /** True when this handler recognised the event as belonging to it. */\n claimed?: boolean\n}\n\nexport type BillingWebhookHandler = (\n event: BillingWebhookEvent,\n) =>\n | Promise<void | BillingWebhookHandlerResult>\n | void\n | BillingWebhookHandlerResult\n\n/** What `runBillingWebhookHandlers` reports back about a whole dispatch. */\nexport interface BillingWebhookDispatchResult {\n /** True when ANY handler claimed the event. */\n claimed: boolean\n}\n\nconst handlers: BillingWebhookHandler[] = []\n\n/**\n * Registers a webhook handler. The plugin loader guarantees each plugin's\n * register fn runs once per process, so no dedupe is needed here.\n */\nexport function registerBillingWebhookHandler(\n handler: BillingWebhookHandler,\n): void {\n handlers.push(handler)\n}\n\n/**\n * Runs every registered handler sequentially; the first throw propagates.\n *\n * EVERY handler runs even after one has claimed the event: two plugins may\n * legitimately care about the same event, and short-circuiting on the first\n * claim would turn a reporting signal into a dispatch rule and silently drop\n * side effects. The claim is a fold over all of them, never a break.\n */\nexport async function runBillingWebhookHandlers(\n event: BillingWebhookEvent,\n): Promise<BillingWebhookDispatchResult> {\n let claimed = false\n for (const handler of handlers) {\n // Narrowed through the union rather than read off it: the handler's\n // return type includes `void` — the shape every handler that does not\n // care still returns — and a property read on `void` is a type error even\n // with `strictNullChecks` off.\n const result = (await handler(event)) as\n | BillingWebhookHandlerResult\n | undefined\n if (result?.claimed === true) claimed = true\n }\n return { claimed }\n}\n"],"names":["handlers","registerBillingWebhookHandler","handler","push","runBillingWebhookHandlers","event","claimed","result"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;CAUC,
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/billing-webhook-hooks.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 * Billing-webhook extension point (AGL-418): plugins register handlers for\n * the platform Stripe webhook from their `/server` entries — the console's\n * webhook route verifies the signature, syncs the org's plan/subscription\n * (the only core-billing concern), then fans the event out here. Handlers\n * self-select on `type` + `object.metadata.type` exactly as the old inline\n * sections did (commerce orders/carts/drafts/reservations/subscriptions,\n * booking payments, marketplace purchases). Errors PROPAGATE: a throwing\n * handler fails the webhook with a 500 so Stripe redelivers — identical to\n * the pre-extraction behavior, and every section is idempotent by doc key.\n */\n\nexport interface BillingWebhookEvent {\n /** Stripe event type, e.g. 'checkout.session.completed'. */\n type: string\n /** `event.data.object` — the session/subscription/invoice payload. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n object: any\n /** The full parsed Stripe event, for handlers that need ids/metadata. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n event: any\n /** The webhook request's Host header — for absolute callback URLs. */\n requestHost?: string\n}\n\n/**\n * What a handler may tell the route about the event it just saw (AGL-2429).\n *\n * Handlers have always returned `void`, which made \"a plugin handled this\"\n * and \"every plugin ignored this\" the same observation from the route's side.\n * On most events that is harmless — the route does not care who consumed a\n * `checkout.session.completed`. On a CHARGEBACK it is the whole question: an\n * event nobody claimed is money that moved with nothing recording it, and it\n * is indistinguishable from the ordinary case where a plugin quietly did its\n * job. See the `charge.dispute.*` section of the console's webhook route.\n *\n * `claimed` is deliberately opt-in and deliberately narrow. A handler sets it\n * when it RECOGNISED the event as its own — found the order, the purchase,\n * the booking — not when it merely ran. Returning nothing keeps the old\n * meaning, so every handler that does not care is unaffected.\n */\nexport interface BillingWebhookHandlerResult {\n /** True when this handler recognised the event as belonging to it. */\n claimed?: boolean\n /**\n * The site the claimed record belongs to (AGL-3360), when the handler\n * knows it. The route reads it for a charge's fraud signal: the seller's\n * pattern row names the sites the charges were for, whichever plugin sold\n * them, without the route knowing what an order or a booking is.\n */\n hostId?: string\n}\n\nexport type BillingWebhookHandler = (\n event: BillingWebhookEvent,\n) =>\n | Promise<void | BillingWebhookHandlerResult>\n | void\n | BillingWebhookHandlerResult\n\n/** What `runBillingWebhookHandlers` reports back about a whole dispatch. */\nexport interface BillingWebhookDispatchResult {\n /** True when ANY handler claimed the event. */\n claimed: boolean\n /** Every distinct `hostId` a handler reported, in handler order. */\n hostIds: string[]\n}\n\nconst handlers: BillingWebhookHandler[] = []\n\n/**\n * Registers a webhook handler. The plugin loader guarantees each plugin's\n * register fn runs once per process, so no dedupe is needed here.\n */\nexport function registerBillingWebhookHandler(\n handler: BillingWebhookHandler,\n): void {\n handlers.push(handler)\n}\n\n/**\n * Runs every registered handler sequentially; the first throw propagates.\n *\n * EVERY handler runs even after one has claimed the event: two plugins may\n * legitimately care about the same event, and short-circuiting on the first\n * claim would turn a reporting signal into a dispatch rule and silently drop\n * side effects. The claim is a fold over all of them, never a break.\n */\nexport async function runBillingWebhookHandlers(\n event: BillingWebhookEvent,\n): Promise<BillingWebhookDispatchResult> {\n let claimed = false\n const hostIds: string[] = []\n for (const handler of handlers) {\n // Narrowed through the union rather than read off it: the handler's\n // return type includes `void` — the shape every handler that does not\n // care still returns — and a property read on `void` is a type error even\n // with `strictNullChecks` off.\n const result = (await handler(event)) as\n | BillingWebhookHandlerResult\n | undefined\n if (result?.claimed === true) claimed = true\n const hostId = typeof result?.hostId === 'string' ? result.hostId : ''\n if (hostId && !hostIds.includes(hostId)) hostIds.push(hostId)\n }\n return { claimed, hostIds }\n}\n"],"names":["handlers","registerBillingWebhookHandler","handler","push","runBillingWebhookHandlers","event","claimed","hostIds","result","hostId","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;CAUC,GA0DD,MAAMA,WAAoC,EAAE;AAE5C;;;CAGC,GACD,OAAO,SAASC,8BACdC,OAA8B;IAE9BF,SAASG,IAAI,CAACD;AAChB;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeE,0BACpBC,KAA0B;IAE1B,IAAIC,UAAU;IACd,MAAMC,UAAoB,EAAE;IAC5B,KAAK,MAAML,WAAWF,SAAU;QAC9B,oEAAoE;QACpE,sEAAsE;QACtE,0EAA0E;QAC1E,+BAA+B;QAC/B,MAAMQ,SAAU,MAAMN,QAAQG;QAG9B,IAAIG,CAAAA,0BAAAA,OAAQF,OAAO,MAAK,MAAMA,UAAU;QACxC,MAAMG,SAAS,QAAOD,0BAAAA,OAAQC,MAAM,MAAK,WAAWD,OAAOC,MAAM,GAAG;QACpE,IAAIA,UAAU,CAACF,QAAQG,QAAQ,CAACD,SAASF,QAAQJ,IAAI,CAACM;IACxD;IACA,OAAO;QAAEH;QAASC;IAAQ;AAC5B"}
|
|
@@ -86,10 +86,18 @@ export interface PluginActivityRegistration {
|
|
|
86
86
|
group: PluginActivityGroup;
|
|
87
87
|
actions: readonly PluginActivityAction[];
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* The most codes one group may hold. A group's chip asks `action is any of`
|
|
91
|
+
* its codes, which the feeds' queries serve as one Firestore `in` — capped at
|
|
92
|
+
* thirty values — so a larger group would be a chip the list always refuses.
|
|
93
|
+
*/
|
|
94
|
+
export declare const PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS = 30;
|
|
89
95
|
/**
|
|
90
96
|
* Idempotent per plugin — re-registration replaces that plugin's catalog. A
|
|
91
97
|
* code already declared by a DIFFERENT plugin is refused for the whole
|
|
92
|
-
* registration: two owners for one code would give the row two labels.
|
|
98
|
+
* registration: two owners for one code would give the row two labels. A
|
|
99
|
+
* group of more than `PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS` codes is refused
|
|
100
|
+
* too, since its chip could never be applied.
|
|
93
101
|
*/
|
|
94
102
|
export declare function registerPluginActivityActions(registration: PluginActivityRegistration): void;
|
|
95
103
|
/** Every registration, in registration order. */
|
|
@@ -41,13 +41,23 @@
|
|
|
41
41
|
return (_PLUGIN_TARGET_TYPE_exec = PLUGIN_TARGET_TYPE.exec(type)) == null ? void 0 : _PLUGIN_TARGET_TYPE_exec[2];
|
|
42
42
|
}
|
|
43
43
|
const registrations = new Map();
|
|
44
|
+
/**
|
|
45
|
+
* The most codes one group may hold. A group's chip asks `action is any of`
|
|
46
|
+
* its codes, which the feeds' queries serve as one Firestore `in` — capped at
|
|
47
|
+
* thirty values — so a larger group would be a chip the list always refuses.
|
|
48
|
+
*/ export const PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS = 30;
|
|
44
49
|
/**
|
|
45
50
|
* Idempotent per plugin — re-registration replaces that plugin's catalog. A
|
|
46
51
|
* code already declared by a DIFFERENT plugin is refused for the whole
|
|
47
|
-
* registration: two owners for one code would give the row two labels.
|
|
52
|
+
* registration: two owners for one code would give the row two labels. A
|
|
53
|
+
* group of more than `PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS` codes is refused
|
|
54
|
+
* too, since its chip could never be applied.
|
|
48
55
|
*/ export function registerPluginActivityActions(registration) {
|
|
49
56
|
const pluginId = registration.pluginId.trim();
|
|
50
57
|
if (!pluginId) throw new Error('plugin activity actions need a pluginId');
|
|
58
|
+
if (registration.actions.length > PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS) {
|
|
59
|
+
throw new Error(`activity group "${registration.group.id}" declares ` + `${registration.actions.length} actions; its filter chip can ask for at most ` + `${PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS}, refused "${pluginId}"`);
|
|
60
|
+
}
|
|
51
61
|
for (const action of registration.actions){
|
|
52
62
|
const owner = ownerOfActivityAction(action.key);
|
|
53
63
|
if (owner && owner !== pluginId) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-activity-actions.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 * Plugin-declared activity actions (AGL-2940).\n *\n * Most rows in `orgs/{orgId}/activity` and `hosts/{hostId}/activity` store\n * a prose action written at the call site. A plugin whose rows are read by\n * more than a person — a filter chip, a staff facet, an actor table — stores\n * a CODE (`ai.job.output`) and declares here what the code means: the label\n * a reader sees, the scope it is written at, and the group its filter chip\n * belongs to. The activity renderers read this catalog for labels and\n * filters, so a second plugin with coded rows gets a chip and a facet group\n * by registering, with no edit to the renderers.\n *\n * Declared at module scope from both the client barrel and the `/server`\n * entry (the AGL-428 pattern), with an explicit `pluginId`: module scope\n * runs before the loader's ownership marker is set.\n */\n\nexport type PluginActivityScope = 'org' | 'host' | 'staff'\n\n/**\n * A resource a PLUGIN files an org activity row under (AGL-2978): the\n * plugin's id, a colon and the plugin's own noun — `acme-mail:mailbox`.\n *\n * Core's activity targets are a closed list of core's own resources. A\n * plugin's resource is not one of them and is never added to that list; the\n * org log's target type accepts any namespaced type instead, and the feed\n * reads the noun after the colon for a reader. The namespace keeps two\n * plugins' nouns from meaning one thing.\n */\nexport type PluginActivityTargetType = `${string}:${string}`\n\n/** A namespaced type as the log stores it: `pluginId:noun`, both parts plain. */\nconst PLUGIN_TARGET_TYPE = /^([a-z][a-z0-9-]*):([A-Za-z][A-Za-z0-9]*)$/\n\n/** Whether a stored target type is a plugin's namespaced one. */\nexport function isPluginActivityTargetType(type: unknown): type is PluginActivityTargetType {\n return typeof type === 'string' && PLUGIN_TARGET_TYPE.test(type)\n}\n\n/**\n * The noun a plugin's namespaced type names — `acme-mail:mailbox` →\n * `mailbox` — or `undefined` for any other type.\n */\nexport function pluginActivityTargetNoun(type: unknown): string | undefined {\n if (typeof type !== 'string') return undefined\n return PLUGIN_TARGET_TYPE.exec(type)?.[2]\n}\n\nexport interface PluginActivityAction {\n /** The stored action code — dotted, stable, never a sentence. */\n key: string\n /** What a person reads for the code in the feed and the actor table. */\n label: string\n /** Which log(s) the writers put the code in. */\n scope: PluginActivityScope | readonly PluginActivityScope[]\n /** The feed target types rows carrying this code file their output under. */\n resourceTypes?: readonly string[]\n}\n\n/**\n * The filter a plugin's coded rows share: one chip in the org feed and the\n * actor table, one facet group on the staff audit page.\n */\nexport interface PluginActivityGroup {\n /** Stable group id — the facet's value (`ai`). */\n id: string\n /** The chip and the facet option (`AI`). */\n label: string\n /**\n * Staff audit actions that belong to the group without carrying one of the\n * registered codes — rows written by core on the plugin's behalf, matched\n * by prefix (`billing.assistOverage.`).\n */\n staffAuditPrefixes?: readonly string[]\n /**\n * Staff audit actions a plugin's staff doors write when staff READ\n * something rather than change it (AGL-2939): the audit log files them as\n * an access. Matched exactly.\n */\n staffAuditAccessActions?: readonly string[]\n}\n\nexport interface PluginActivityRegistration {\n pluginId: string\n group: PluginActivityGroup\n actions: readonly PluginActivityAction[]\n}\n\nconst registrations = new Map<string, PluginActivityRegistration>()\n\n/**\n * Idempotent per plugin — re-registration replaces that plugin's catalog. A\n * code already declared by a DIFFERENT plugin is refused for the whole\n * registration: two owners for one code would give the row two labels.\n */\nexport function registerPluginActivityActions(\n registration: PluginActivityRegistration,\n): void {\n const pluginId = registration.pluginId.trim()\n if (!pluginId) throw new Error('plugin activity actions need a pluginId')\n for (const action of registration.actions) {\n const owner = ownerOfActivityAction(action.key)\n if (owner && owner !== pluginId) {\n throw new Error(\n `activity action \"${action.key}\" is already declared by \"${owner}\"; ` +\n `refused \"${pluginId}\"`,\n )\n }\n }\n registrations.set(pluginId, registration)\n}\n\nfunction ownerOfActivityAction(key: string): string | undefined {\n for (const registration of registrations.values()) {\n if (registration.actions.some((action) => action.key === key)) {\n return registration.pluginId\n }\n }\n return undefined\n}\n\n/** Every registration, in registration order. */\nexport function listPluginActivityRegistrations(): PluginActivityRegistration[] {\n return [...registrations.values()]\n}\n\n/** Every declared action across plugins. */\nexport function listPluginActivityActions(): PluginActivityAction[] {\n return listPluginActivityRegistrations().flatMap((entry) => [...entry.actions])\n}\n\n/** The readable label for a declared code; `undefined` for any other action. */\nexport function pluginActivityActionLabel(action: unknown): string | undefined {\n if (typeof action !== 'string') return undefined\n for (const registration of registrations.values()) {\n const match = registration.actions.find((entry) => entry.key === action)\n if (match) return match.label\n }\n return undefined\n}\n\n/** The group a stored action's code belongs to, or `undefined`. */\nexport function pluginActivityGroupForAction(\n action: unknown,\n): PluginActivityGroup | undefined {\n if (typeof action !== 'string') return undefined\n for (const registration of registrations.values()) {\n if (registration.actions.some((entry) => entry.key === action)) {\n return registration.group\n }\n }\n return undefined\n}\n\n/**\n * The filter chips the feeds offer: one per registered group, each with the\n * codes its chip keeps. What an `action isAnyOf …` request sends.\n */\nexport function listPluginActivityFilters(): Array<{\n group: PluginActivityGroup\n actions: string[]\n}> {\n return listPluginActivityRegistrations().map((entry) => ({\n group: entry.group,\n actions: entry.actions.map((action) => action.key),\n }))\n}\n\n/**\n * The staff audit facet group for an action: a registered group when the\n * action is one of its codes or starts with one of its prefixes, else the\n * action's leading namespace (`billing`, `org`, `plugins`).\n */\nexport function pluginStaffAuditActionGroup(action: unknown): string {\n const text = typeof action === 'string' ? action.trim() : ''\n if (!text) return ''\n const byCode = pluginActivityGroupForAction(text)\n if (byCode) return byCode.id\n for (const registration of registrations.values()) {\n const prefixes = registration.group.staffAuditPrefixes ?? []\n if (prefixes.some((prefix) => text.startsWith(prefix))) {\n return registration.group.id\n }\n }\n const dot = text.indexOf('.')\n return dot > 0 ? text.slice(0, dot) : text\n}\n\n/** Whether a staff audit action is a read some plugin declared (AGL-2939). */\nexport function isPluginStaffAuditAccess(action: unknown): boolean {\n if (typeof action !== 'string' || !action) return false\n for (const registration of registrations.values()) {\n if (registration.group.staffAuditAccessActions?.includes(action)) return true\n }\n return false\n}\n\n/** How a facet group reads in the menu: a registered label, else the id. */\nexport function pluginStaffAuditActionGroupLabel(group: string): string {\n for (const registration of registrations.values()) {\n if (registration.group.id === group) return registration.group.label\n }\n return group\n}\n\n/** Test seam: forget every registration. */\nexport function resetPluginActivityActionsForTests(): void {\n registrations.clear()\n}\n"],"names":["PLUGIN_TARGET_TYPE","isPluginActivityTargetType","type","test","pluginActivityTargetNoun","undefined","exec","registrations","Map","registerPluginActivityActions","registration","pluginId","trim","Error","action","actions","owner","ownerOfActivityAction","key","set","values","some","listPluginActivityRegistrations","listPluginActivityActions","flatMap","entry","pluginActivityActionLabel","match","find","label","pluginActivityGroupForAction","group","listPluginActivityFilters","map","pluginStaffAuditActionGroup","text","byCode","id","prefixes","staffAuditPrefixes","prefix","startsWith","dot","indexOf","slice","isPluginStaffAuditAccess","staffAuditAccessActions","includes","pluginStaffAuditActionGroupLabel","resetPluginActivityActionsForTests","clear"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-activity-actions.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 * Plugin-declared activity actions (AGL-2940).\n *\n * Most rows in `orgs/{orgId}/activity` and `hosts/{hostId}/activity` store\n * a prose action written at the call site. A plugin whose rows are read by\n * more than a person — a filter chip, a staff facet, an actor table — stores\n * a CODE (`ai.job.output`) and declares here what the code means: the label\n * a reader sees, the scope it is written at, and the group its filter chip\n * belongs to. The activity renderers read this catalog for labels and\n * filters, so a second plugin with coded rows gets a chip and a facet group\n * by registering, with no edit to the renderers.\n *\n * Declared at module scope from both the client barrel and the `/server`\n * entry (the AGL-428 pattern), with an explicit `pluginId`: module scope\n * runs before the loader's ownership marker is set.\n */\n\nexport type PluginActivityScope = 'org' | 'host' | 'staff'\n\n/**\n * A resource a PLUGIN files an org activity row under (AGL-2978): the\n * plugin's id, a colon and the plugin's own noun — `acme-mail:mailbox`.\n *\n * Core's activity targets are a closed list of core's own resources. A\n * plugin's resource is not one of them and is never added to that list; the\n * org log's target type accepts any namespaced type instead, and the feed\n * reads the noun after the colon for a reader. The namespace keeps two\n * plugins' nouns from meaning one thing.\n */\nexport type PluginActivityTargetType = `${string}:${string}`\n\n/** A namespaced type as the log stores it: `pluginId:noun`, both parts plain. */\nconst PLUGIN_TARGET_TYPE = /^([a-z][a-z0-9-]*):([A-Za-z][A-Za-z0-9]*)$/\n\n/** Whether a stored target type is a plugin's namespaced one. */\nexport function isPluginActivityTargetType(type: unknown): type is PluginActivityTargetType {\n return typeof type === 'string' && PLUGIN_TARGET_TYPE.test(type)\n}\n\n/**\n * The noun a plugin's namespaced type names — `acme-mail:mailbox` →\n * `mailbox` — or `undefined` for any other type.\n */\nexport function pluginActivityTargetNoun(type: unknown): string | undefined {\n if (typeof type !== 'string') return undefined\n return PLUGIN_TARGET_TYPE.exec(type)?.[2]\n}\n\nexport interface PluginActivityAction {\n /** The stored action code — dotted, stable, never a sentence. */\n key: string\n /** What a person reads for the code in the feed and the actor table. */\n label: string\n /** Which log(s) the writers put the code in. */\n scope: PluginActivityScope | readonly PluginActivityScope[]\n /** The feed target types rows carrying this code file their output under. */\n resourceTypes?: readonly string[]\n}\n\n/**\n * The filter a plugin's coded rows share: one chip in the org feed and the\n * actor table, one facet group on the staff audit page.\n */\nexport interface PluginActivityGroup {\n /** Stable group id — the facet's value (`ai`). */\n id: string\n /** The chip and the facet option (`AI`). */\n label: string\n /**\n * Staff audit actions that belong to the group without carrying one of the\n * registered codes — rows written by core on the plugin's behalf, matched\n * by prefix (`billing.assistOverage.`).\n */\n staffAuditPrefixes?: readonly string[]\n /**\n * Staff audit actions a plugin's staff doors write when staff READ\n * something rather than change it (AGL-2939): the audit log files them as\n * an access. Matched exactly.\n */\n staffAuditAccessActions?: readonly string[]\n}\n\nexport interface PluginActivityRegistration {\n pluginId: string\n group: PluginActivityGroup\n actions: readonly PluginActivityAction[]\n}\n\nconst registrations = new Map<string, PluginActivityRegistration>()\n\n/**\n * The most codes one group may hold. A group's chip asks `action is any of`\n * its codes, which the feeds' queries serve as one Firestore `in` — capped at\n * thirty values — so a larger group would be a chip the list always refuses.\n */\nexport const PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS = 30\n\n/**\n * Idempotent per plugin — re-registration replaces that plugin's catalog. A\n * code already declared by a DIFFERENT plugin is refused for the whole\n * registration: two owners for one code would give the row two labels. A\n * group of more than `PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS` codes is refused\n * too, since its chip could never be applied.\n */\nexport function registerPluginActivityActions(\n registration: PluginActivityRegistration,\n): void {\n const pluginId = registration.pluginId.trim()\n if (!pluginId) throw new Error('plugin activity actions need a pluginId')\n if (registration.actions.length > PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS) {\n throw new Error(\n `activity group \"${registration.group.id}\" declares ` +\n `${registration.actions.length} actions; its filter chip can ask for at most ` +\n `${PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS}, refused \"${pluginId}\"`,\n )\n }\n for (const action of registration.actions) {\n const owner = ownerOfActivityAction(action.key)\n if (owner && owner !== pluginId) {\n throw new Error(\n `activity action \"${action.key}\" is already declared by \"${owner}\"; ` +\n `refused \"${pluginId}\"`,\n )\n }\n }\n registrations.set(pluginId, registration)\n}\n\nfunction ownerOfActivityAction(key: string): string | undefined {\n for (const registration of registrations.values()) {\n if (registration.actions.some((action) => action.key === key)) {\n return registration.pluginId\n }\n }\n return undefined\n}\n\n/** Every registration, in registration order. */\nexport function listPluginActivityRegistrations(): PluginActivityRegistration[] {\n return [...registrations.values()]\n}\n\n/** Every declared action across plugins. */\nexport function listPluginActivityActions(): PluginActivityAction[] {\n return listPluginActivityRegistrations().flatMap((entry) => [...entry.actions])\n}\n\n/** The readable label for a declared code; `undefined` for any other action. */\nexport function pluginActivityActionLabel(action: unknown): string | undefined {\n if (typeof action !== 'string') return undefined\n for (const registration of registrations.values()) {\n const match = registration.actions.find((entry) => entry.key === action)\n if (match) return match.label\n }\n return undefined\n}\n\n/** The group a stored action's code belongs to, or `undefined`. */\nexport function pluginActivityGroupForAction(\n action: unknown,\n): PluginActivityGroup | undefined {\n if (typeof action !== 'string') return undefined\n for (const registration of registrations.values()) {\n if (registration.actions.some((entry) => entry.key === action)) {\n return registration.group\n }\n }\n return undefined\n}\n\n/**\n * The filter chips the feeds offer: one per registered group, each with the\n * codes its chip keeps. What an `action isAnyOf …` request sends.\n */\nexport function listPluginActivityFilters(): Array<{\n group: PluginActivityGroup\n actions: string[]\n}> {\n return listPluginActivityRegistrations().map((entry) => ({\n group: entry.group,\n actions: entry.actions.map((action) => action.key),\n }))\n}\n\n/**\n * The staff audit facet group for an action: a registered group when the\n * action is one of its codes or starts with one of its prefixes, else the\n * action's leading namespace (`billing`, `org`, `plugins`).\n */\nexport function pluginStaffAuditActionGroup(action: unknown): string {\n const text = typeof action === 'string' ? action.trim() : ''\n if (!text) return ''\n const byCode = pluginActivityGroupForAction(text)\n if (byCode) return byCode.id\n for (const registration of registrations.values()) {\n const prefixes = registration.group.staffAuditPrefixes ?? []\n if (prefixes.some((prefix) => text.startsWith(prefix))) {\n return registration.group.id\n }\n }\n const dot = text.indexOf('.')\n return dot > 0 ? text.slice(0, dot) : text\n}\n\n/** Whether a staff audit action is a read some plugin declared (AGL-2939). */\nexport function isPluginStaffAuditAccess(action: unknown): boolean {\n if (typeof action !== 'string' || !action) return false\n for (const registration of registrations.values()) {\n if (registration.group.staffAuditAccessActions?.includes(action)) return true\n }\n return false\n}\n\n/** How a facet group reads in the menu: a registered label, else the id. */\nexport function pluginStaffAuditActionGroupLabel(group: string): string {\n for (const registration of registrations.values()) {\n if (registration.group.id === group) return registration.group.label\n }\n return group\n}\n\n/** Test seam: forget every registration. */\nexport function resetPluginActivityActionsForTests(): void {\n registrations.clear()\n}\n"],"names":["PLUGIN_TARGET_TYPE","isPluginActivityTargetType","type","test","pluginActivityTargetNoun","undefined","exec","registrations","Map","PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS","registerPluginActivityActions","registration","pluginId","trim","Error","actions","length","group","id","action","owner","ownerOfActivityAction","key","set","values","some","listPluginActivityRegistrations","listPluginActivityActions","flatMap","entry","pluginActivityActionLabel","match","find","label","pluginActivityGroupForAction","listPluginActivityFilters","map","pluginStaffAuditActionGroup","text","byCode","prefixes","staffAuditPrefixes","prefix","startsWith","dot","indexOf","slice","isPluginStaffAuditAccess","staffAuditAccessActions","includes","pluginStaffAuditActionGroupLabel","resetPluginActivityActionsForTests","clear"],"mappings":"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"}
|
|
@@ -198,6 +198,14 @@ export type PluginContactCaptured =
|
|
|
198
198
|
record: 'lead';
|
|
199
199
|
leadId: string;
|
|
200
200
|
created: boolean;
|
|
201
|
+
/**
|
|
202
|
+
* Whether this capture's surface was new on the lead — a new person,
|
|
203
|
+
* or one first met through another door. A form counts its leads by
|
|
204
|
+
* it (AGL-3330): the people it filed, not the times they came back.
|
|
205
|
+
* Absent from a writer that does not say, which a caller reads as
|
|
206
|
+
* `created`.
|
|
207
|
+
*/
|
|
208
|
+
sourceAdded?: boolean;
|
|
201
209
|
} | {
|
|
202
210
|
ok: false;
|
|
203
211
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-contact-capture.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from './plugin-services'\n\n/**\n * A person met on a site, handed to whichever plugin keeps people (AGL-3124).\n *\n * Four silos capture the same thing and none of them is the record system: a\n * form submission, a site member signing up, an order, a booking. Each holds\n * an address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each one imports the writer, which\n * makes the highest-fan-in edge in the repo — thirty files in one plugin alone\n * — and makes the record system's plugin something a storefront cannot take a\n * payment without.\n *\n * So the silo stops importing and starts DECLARING: it registers the capture\n * door it owns, and hands a capture to this seam. The plugin that keeps people\n * registers the writer and does every part of the work that is a record\n * system's to do:\n *\n * - normalizing and keying the address, and deciding whether this is a new\n * person or a visit from one the workspace already holds;\n * - the audience band, the erasure rows, and every other refusal — answered,\n * never thrown, so a capture that is refused never costs the silo the order\n * or the submission it was recording;\n * - what a stage, an owner, a company and a tag mean, and what the arrival\n * of a new person sets off.\n *\n * The silo decides none of that. It reports what it saw.\n *\n * ## Why the profile is opaque\n *\n * `profile` is the owner's own field names with scalar values, not a core\n * type. A person's record shape belongs to the plugin that models it, and a\n * typed core shape would be that model spelled in the platform again — which\n * is the defect this seam exists to retire, not a smaller version of it. A key\n * the owner does not document is the owner's to ignore.\n *\n * `source` is the silo's own word for its door, and it is declared rather than\n * enumerated here for the same reason: core listing `form`, `member`, `order`\n * and `booking` would be core holding the plugin set again. A silo registers\n * its source with {@link registerPluginContactSource}, which is what lets a\n * timeline label an entry and a link point back at the thing that captured the\n * person, without the platform knowing what any of them are.\n *\n * ## One writer; many silos\n *\n * A workspace keeps one set of people, so the writer is a slot: a second\n * plugin's writer is refused naming both, and the incumbent keeps serving. A\n * SOURCE is per silo and there are many, but one source word has one owner —\n * two plugins claiming `order` would make the label and the link ambiguous at\n * the point they are read.\n *\n * ## The caller proves who is asking\n *\n * Like `plugin-record-timeline`, the registry authenticates nobody. A silo has\n * already decided, in its own terms and before it asks, that the capture is\n * the workspace's to record — that the submission was accepted, that the order\n * was paid.\n *\n * Server-side: reached by its own subpath, never through\n * `plugin-manager/index.ts`.\n */\n\n/** The person, as the capturing silo saw them. */\nexport interface PluginContactIdentity {\n /**\n * The address they gave. Raw: the owner normalizes and keys on it, and a\n * silo that normalized first would be keeping a second copy of that rule.\n */\n email: unknown\n name?: string | null\n}\n\n/** What happened, in the capturing silo's own words. */\nexport interface PluginContactInteraction {\n /** The silo's declared source word — see {@link registerPluginContactSource}. */\n source: string\n /** When it happened, epoch ms; the owner uses now when absent. */\n atMs?: number\n /**\n * The document it happened on, in the silo's terms\n * (`formSubmissions/{id}`, `orders/{id}`), so a timeline entry can point\n * back at it. Absent where the door leaves nothing to open.\n */\n refId?: string | null\n /** One line a person reads on the timeline. */\n summary?: string | null\n}\n\nexport interface PluginContactCaptureRequest {\n orgId: string\n /** The site the person was met on: the brand whose form or checkout it was. */\n hostId: string\n identity: PluginContactIdentity\n interaction: PluginContactInteraction\n /**\n * An explicit marketing opt-in the capture surface carried, recorded against\n * `hostId`. Absent is not consent, and filing a capture under a campaign is\n * not consent either.\n */\n marketingConsent?: boolean\n /**\n * The disclosure key the capture surface rendered beside that opt-in\n * (`consentGroupDisclosureKey`, AGL-3320), passed through as the surface\n * sent it. The owner records the opt-in for the site's whole consent group\n * only when this is the group's current key, and for `hostId` alone\n * otherwise — so a silo whose surface shows no disclosure sends none.\n */\n disclosedConsentGroup?: string\n /** Tags the silo puts on this capture; the owner adds, never replaces. */\n tags?: readonly string[]\n /** The campaigns the capture SURFACE is filed under — the merchant's own act. */\n campaignIds?: readonly string[]\n /** Money the capture was worth, in cents, where the silo took some. */\n purchaseCents?: number\n /** The currency {@link purchaseCents} is in, lowercase, when the silo knows. */\n purchaseCurrency?: string\n /**\n * The earliest stage this capture describes, in the OWNER's words. A floor\n * and not a value: the owner fills an empty stage and advances an earlier\n * one, and never moves anybody back, so a repeat customer filling in a\n * contact form is still a customer.\n */\n lifecycleFloor?: string\n /**\n * What kind of door this is, which decides WHICH RECORD the person lands\n * on (AGL-3232) — the owner's rule is Salesforce's: one person is one\n * record, a lead until they are qualified and a contact after.\n *\n * - `lead`: a lead surface — a form whose author routes it to leads, a\n * booking request. The person is filed as a LEAD and nothing else,\n * unless the workspace already holds them as a contact, in which case\n * the capture lands on the contact and no lead is filed.\n * - `relationship`: an act that makes the person a known relationship —\n * a member account, a purchase. The person is filed as a CONTACT, and\n * an open lead the site held for them is stamped converted onto it.\n * - `touch`, the default: everything else — a form without lead routing,\n * a newsletter opt-in. The capture lands on the open lead when the site\n * holds one for the address, and on the contact otherwise.\n */\n surface?: 'lead' | 'relationship' | 'touch'\n /**\n * Profile fields the silo knows, keyed by the owner's own field names.\n * Only the keys given are written, so a silo that knows the phone number\n * does not blank a title somebody typed.\n */\n profile?: Readonly<Record<string, unknown>>\n /**\n * Facts about THIS CAPTURE that the owner models and the platform does not\n * (AGL-3080) — which form was filled in, which page it was on, where the\n * visitor arrived from.\n *\n * Opaque for the reason {@link profile} is: every one of these is a concept\n * belonging to the plugin that keeps people or to the silo that saw it, and\n * a typed core field for each would be those models spelled out in the\n * platform again. `profile` describes the PERSON and outlives the visit;\n * this describes the visit and does not.\n *\n * A key the owner does not recognize is the owner's to ignore, and a silo\n * that sends nothing here loses nothing: every field is an enrichment of a\n * capture that is already complete without it.\n */\n detail?: Readonly<Record<string, unknown>>\n}\n\n/**\n * The owner's answer. `created: false` is a visit by somebody the workspace\n * already held, which is an interaction and not a new person.\n *\n * A refusal is RETURNED. Every one of these silos has already done the thing\n * it was recording, so a throw here would cost an accepted submission or a\n * paid order for a record it could not keep.\n */\nexport type PluginContactCaptured =\n /** The capture landed on a contact — the record every capture used to make. */\n | { ok: true; record: 'contact'; contactId: string; created: boolean }\n /**\n * The capture landed on a LEAD (AGL-3232): a lead surface met somebody the\n * workspace does not hold as a contact, or a touch reached an open lead.\n * `leadId` is the person key the lead is filed under on `hostId`.\n */\n | { ok: true; record: 'lead'; leadId: string; created: boolean }\n | {\n ok: false\n /**\n * `invalid-email`: nothing usable to key a person on. `band`: the\n * workspace is at the audience it may hold. `erased`: the workspace\n * erased this person and a capture must not quietly rebuild them.\n * `error`: the owner failed, and said so rather than silently.\n */\n reason: 'invalid-email' | 'band' | 'erased' | 'error'\n /** Customer-safe: a caller may show or log it as it stands. */\n error: string\n }\n\nexport interface PluginContactCaptureWriter {\n /** Records one capture; a refusal is returned, never thrown. */\n capture(\n request: PluginContactCaptureRequest,\n ): Promise<PluginContactCaptured>\n}\n\n/** A capture door a silo owns. */\nexport interface PluginContactSourceDeclaration {\n /** The silo's word for the door, as its captures carry it. */\n source: string\n /** What a timeline entry from this door says: `Form submission`. */\n label: string\n /**\n * What a link from that entry says when there is something to open —\n * `Open submission`. Absent where the door leaves nothing to open, which\n * is the honest answer for an import or a by-hand add.\n */\n openLabel?: string\n /**\n * The record kind the silo's own document is, for\n * `plugin-record-routes` to address it with. Absent where the door has no\n * record of its own to open.\n */\n recordKind?: string\n}\n\n/** A source declaration with the silo that made it. */\nexport type ResolvedPluginContactSource = PluginContactSourceDeclaration & {\n pluginId: string\n}\n\nexport const PLUGIN_CONTACT_CAPTURE =\n definePluginServiceContract<PluginContactCaptureWriter>(\n 'core.contact-capture',\n { multiple: false },\n )\n\nexport const PLUGIN_CONTACT_SOURCES =\n definePluginServiceContract<PluginContactSourceDeclaration>(\n 'core.contact-capture-sources',\n { multiple: true },\n )\n\n/**\n * Registers the plugin that keeps people. The owner is the loader's marker\n * when a register fn is running, else `options.pluginId`; with neither the\n * registration throws, and a second plugin's writer is refused naming both —\n * the incumbent keeps serving.\n */\nexport function registerPluginContactCaptureWriter(\n writer: PluginContactCaptureWriter,\n options?: { pluginId?: string },\n): void {\n registerPluginService(PLUGIN_CONTACT_CAPTURE, writer, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n })\n}\n\n/** Declares a capture door. One source word has one owner. */\nexport function registerPluginContactSource(\n declaration: PluginContactSourceDeclaration,\n options?: { pluginId?: string },\n): void {\n const key = declaration.source?.trim() ?? ''\n if (!key) throw new Error('a contact capture source needs a source word')\n if (!declaration.label?.trim()) {\n throw new Error(`contact capture source \"${key}\" needs a label`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n const incumbent = resolvePluginServices(PLUGIN_CONTACT_SOURCES).find(\n (entry) => entry.key === key,\n )\n if (incumbent && pluginId && incumbent.pluginId !== pluginId) {\n throw new Error(\n `contact capture source \"${key}\" is already declared by ` +\n `\"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(\n PLUGIN_CONTACT_SOURCES,\n { ...declaration, source: key },\n {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n key,\n },\n )\n}\n\nexport interface ResolvedPluginContactCaptureWriter {\n /** The plugin that keeps people. */\n pluginId: string\n writer: PluginContactCaptureWriter\n}\n\n/** The writer with its owner, or `null` when no plugin keeps people here. */\nexport function pluginContactCaptureWriter(): ResolvedPluginContactCaptureWriter | null {\n const entry = resolvePluginServices(PLUGIN_CONTACT_CAPTURE)[0]\n return entry ? { pluginId: entry.pluginId, writer: entry.impl } : null\n}\n\n/**\n * Hands one capture to the plugin that keeps people, or answers `null` when\n * no plugin does.\n *\n * `null` and a refusal are different answers and a silo treats them\n * differently: `null` is a workspace with no record system, where there is\n * nothing to record and nothing has gone wrong; a refusal is a record system\n * that considered this person and declined.\n */\nexport async function capturePluginContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured | null> {\n const found = pluginContactCaptureWriter()\n if (!found) return null\n return found.writer.capture(request)\n}\n\n/** Every declared capture door, with its silo, in registration order. */\nexport function listPluginContactSources(): ResolvedPluginContactSource[] {\n return resolvePluginServices(PLUGIN_CONTACT_SOURCES).map((entry) => ({\n ...entry.impl,\n pluginId: entry.pluginId,\n }))\n}\n\n/** One declared capture door by its source word, or `null`. */\nexport function pluginContactSource(\n source: string,\n): ResolvedPluginContactSource | null {\n const key = source.trim()\n const entry = resolvePluginServices(PLUGIN_CONTACT_SOURCES).find(\n (one) => one.key === key,\n )\n return entry ? { ...entry.impl, pluginId: entry.pluginId } : null\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","PLUGIN_CONTACT_CAPTURE","multiple","PLUGIN_CONTACT_SOURCES","registerPluginContactCaptureWriter","writer","options","pluginId","registerPluginContactSource","declaration","key","source","trim","Error","label","incumbent","find","entry","pluginContactCaptureWriter","impl","capturePluginContact","request","found","capture","listPluginContactSources","map","pluginContactSource","one"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,sBAAsB,QAAQ,qCAAiC;AACxE,SACEC,2BAA2B,EAC3BC,qBAAqB,EACrBC,qBAAqB,QAChB,uBAAmB;AAkO1B,OAAO,MAAMC,yBACXH,4BACE,wBACA;IAAEI,UAAU;AAAM,GACnB;AAEH,OAAO,MAAMC,yBACXL,4BACE,gCACA;IAAEI,UAAU;AAAK,GAClB;AAEH;;;;;CAKC,GACD,OAAO,SAASE,mCACdC,MAAkC,EAClCC,OAA+B;IAE/BP,sBAAsBE,wBAAwBI,QAAQ,aAChDC,CAAAA,2BAAAA,QAASC,QAAQ,IAAG;QAAEA,UAAUD,QAAQC,QAAQ;IAAC,IAAI,CAAC;AAE9D;AAEA,4DAA4D,GAC5D,OAAO,SAASC,4BACdC,WAA2C,EAC3CH,OAA+B;cAObT,OAAAA;QALNY,qBAEPA;IAFL,MAAMC,eAAMD,sBAAAA,YAAYE,MAAM,qBAAlBF,oBAAoBG,IAAI,qBAAM;IAC1C,IAAI,CAACF,KAAK,MAAM,IAAIG,MAAM;IAC1B,IAAI,GAACJ,qBAAAA,YAAYK,KAAK,qBAAjBL,mBAAmBG,IAAI,KAAI;QAC9B,MAAM,IAAIC,MAAM,CAAC,wBAAwB,EAAEH,IAAI,eAAe,CAAC;IACjE;IACA,MAAMH,WAAW,EAACV,SAAAA,0BAAAA,oCAAAA,0BAA4BS,2BAAAA,QAASC,QAAQ,YAA7CV,QAAiD,IAAIe,IAAI;IAC3E,MAAMG,YAAYf,sBAAsBG,wBAAwBa,IAAI,CAClE,CAACC,QAAUA,MAAMP,GAAG,KAAKA;IAE3B,IAAIK,aAAaR,YAAYQ,UAAUR,QAAQ,KAAKA,UAAU;QAC5D,MAAM,IAAIM,MACR,CAAC,wBAAwB,EAAEH,IAAI,yBAAyB,CAAC,GACvD,CAAC,CAAC,EAAEK,UAAUR,QAAQ,CAAC,YAAY,EAAEA,SAAS,CAAC,CAAC;IAEtD;IACAR,sBACEI,wBACA,aAAKM;QAAaE,QAAQD;QAC1B,aACMJ,CAAAA,2BAAAA,QAASC,QAAQ,IAAG;QAAEA,UAAUD,QAAQC,QAAQ;IAAC,IAAI,CAAC;QAC1DG;;AAGN;AAQA,2EAA2E,GAC3E,OAAO,SAASQ;IACd,MAAMD,QAAQjB,sBAAsBC,uBAAuB,CAAC,EAAE;IAC9D,OAAOgB,QAAQ;QAAEV,UAAUU,MAAMV,QAAQ;QAAEF,QAAQY,MAAME,IAAI;IAAC,IAAI;AACpE;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeC,qBACpBC,OAAoC;IAEpC,MAAMC,QAAQJ;IACd,IAAI,CAACI,OAAO,OAAO;IACnB,OAAOA,MAAMjB,MAAM,CAACkB,OAAO,CAACF;AAC9B;AAEA,uEAAuE,GACvE,OAAO,SAASG;IACd,OAAOxB,sBAAsBG,wBAAwBsB,GAAG,CAAC,CAACR,QAAW,aAChEA,MAAME,IAAI;YACbZ,UAAUU,MAAMV,QAAQ;;AAE5B;AAEA,6DAA6D,GAC7D,OAAO,SAASmB,oBACdf,MAAc;IAEd,MAAMD,MAAMC,OAAOC,IAAI;IACvB,MAAMK,QAAQjB,sBAAsBG,wBAAwBa,IAAI,CAC9D,CAACW,MAAQA,IAAIjB,GAAG,KAAKA;IAEvB,OAAOO,QAAQ,aAAKA,MAAME,IAAI;QAAEZ,UAAUU,MAAMV,QAAQ;SAAK;AAC/D"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-contact-capture.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from './plugin-services'\n\n/**\n * A person met on a site, handed to whichever plugin keeps people (AGL-3124).\n *\n * Four silos capture the same thing and none of them is the record system: a\n * form submission, a site member signing up, an order, a booking. Each holds\n * an address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each one imports the writer, which\n * makes the highest-fan-in edge in the repo — thirty files in one plugin alone\n * — and makes the record system's plugin something a storefront cannot take a\n * payment without.\n *\n * So the silo stops importing and starts DECLARING: it registers the capture\n * door it owns, and hands a capture to this seam. The plugin that keeps people\n * registers the writer and does every part of the work that is a record\n * system's to do:\n *\n * - normalizing and keying the address, and deciding whether this is a new\n * person or a visit from one the workspace already holds;\n * - the audience band, the erasure rows, and every other refusal — answered,\n * never thrown, so a capture that is refused never costs the silo the order\n * or the submission it was recording;\n * - what a stage, an owner, a company and a tag mean, and what the arrival\n * of a new person sets off.\n *\n * The silo decides none of that. It reports what it saw.\n *\n * ## Why the profile is opaque\n *\n * `profile` is the owner's own field names with scalar values, not a core\n * type. A person's record shape belongs to the plugin that models it, and a\n * typed core shape would be that model spelled in the platform again — which\n * is the defect this seam exists to retire, not a smaller version of it. A key\n * the owner does not document is the owner's to ignore.\n *\n * `source` is the silo's own word for its door, and it is declared rather than\n * enumerated here for the same reason: core listing `form`, `member`, `order`\n * and `booking` would be core holding the plugin set again. A silo registers\n * its source with {@link registerPluginContactSource}, which is what lets a\n * timeline label an entry and a link point back at the thing that captured the\n * person, without the platform knowing what any of them are.\n *\n * ## One writer; many silos\n *\n * A workspace keeps one set of people, so the writer is a slot: a second\n * plugin's writer is refused naming both, and the incumbent keeps serving. A\n * SOURCE is per silo and there are many, but one source word has one owner —\n * two plugins claiming `order` would make the label and the link ambiguous at\n * the point they are read.\n *\n * ## The caller proves who is asking\n *\n * Like `plugin-record-timeline`, the registry authenticates nobody. A silo has\n * already decided, in its own terms and before it asks, that the capture is\n * the workspace's to record — that the submission was accepted, that the order\n * was paid.\n *\n * Server-side: reached by its own subpath, never through\n * `plugin-manager/index.ts`.\n */\n\n/** The person, as the capturing silo saw them. */\nexport interface PluginContactIdentity {\n /**\n * The address they gave. Raw: the owner normalizes and keys on it, and a\n * silo that normalized first would be keeping a second copy of that rule.\n */\n email: unknown\n name?: string | null\n}\n\n/** What happened, in the capturing silo's own words. */\nexport interface PluginContactInteraction {\n /** The silo's declared source word — see {@link registerPluginContactSource}. */\n source: string\n /** When it happened, epoch ms; the owner uses now when absent. */\n atMs?: number\n /**\n * The document it happened on, in the silo's terms\n * (`formSubmissions/{id}`, `orders/{id}`), so a timeline entry can point\n * back at it. Absent where the door leaves nothing to open.\n */\n refId?: string | null\n /** One line a person reads on the timeline. */\n summary?: string | null\n}\n\nexport interface PluginContactCaptureRequest {\n orgId: string\n /** The site the person was met on: the brand whose form or checkout it was. */\n hostId: string\n identity: PluginContactIdentity\n interaction: PluginContactInteraction\n /**\n * An explicit marketing opt-in the capture surface carried, recorded against\n * `hostId`. Absent is not consent, and filing a capture under a campaign is\n * not consent either.\n */\n marketingConsent?: boolean\n /**\n * The disclosure key the capture surface rendered beside that opt-in\n * (`consentGroupDisclosureKey`, AGL-3320), passed through as the surface\n * sent it. The owner records the opt-in for the site's whole consent group\n * only when this is the group's current key, and for `hostId` alone\n * otherwise — so a silo whose surface shows no disclosure sends none.\n */\n disclosedConsentGroup?: string\n /** Tags the silo puts on this capture; the owner adds, never replaces. */\n tags?: readonly string[]\n /** The campaigns the capture SURFACE is filed under — the merchant's own act. */\n campaignIds?: readonly string[]\n /** Money the capture was worth, in cents, where the silo took some. */\n purchaseCents?: number\n /** The currency {@link purchaseCents} is in, lowercase, when the silo knows. */\n purchaseCurrency?: string\n /**\n * The earliest stage this capture describes, in the OWNER's words. A floor\n * and not a value: the owner fills an empty stage and advances an earlier\n * one, and never moves anybody back, so a repeat customer filling in a\n * contact form is still a customer.\n */\n lifecycleFloor?: string\n /**\n * What kind of door this is, which decides WHICH RECORD the person lands\n * on (AGL-3232) — the owner's rule is Salesforce's: one person is one\n * record, a lead until they are qualified and a contact after.\n *\n * - `lead`: a lead surface — a form whose author routes it to leads, a\n * booking request. The person is filed as a LEAD and nothing else,\n * unless the workspace already holds them as a contact, in which case\n * the capture lands on the contact and no lead is filed.\n * - `relationship`: an act that makes the person a known relationship —\n * a member account, a purchase. The person is filed as a CONTACT, and\n * an open lead the site held for them is stamped converted onto it.\n * - `touch`, the default: everything else — a form without lead routing,\n * a newsletter opt-in. The capture lands on the open lead when the site\n * holds one for the address, and on the contact otherwise.\n */\n surface?: 'lead' | 'relationship' | 'touch'\n /**\n * Profile fields the silo knows, keyed by the owner's own field names.\n * Only the keys given are written, so a silo that knows the phone number\n * does not blank a title somebody typed.\n */\n profile?: Readonly<Record<string, unknown>>\n /**\n * Facts about THIS CAPTURE that the owner models and the platform does not\n * (AGL-3080) — which form was filled in, which page it was on, where the\n * visitor arrived from.\n *\n * Opaque for the reason {@link profile} is: every one of these is a concept\n * belonging to the plugin that keeps people or to the silo that saw it, and\n * a typed core field for each would be those models spelled out in the\n * platform again. `profile` describes the PERSON and outlives the visit;\n * this describes the visit and does not.\n *\n * A key the owner does not recognize is the owner's to ignore, and a silo\n * that sends nothing here loses nothing: every field is an enrichment of a\n * capture that is already complete without it.\n */\n detail?: Readonly<Record<string, unknown>>\n}\n\n/**\n * The owner's answer. `created: false` is a visit by somebody the workspace\n * already held, which is an interaction and not a new person.\n *\n * A refusal is RETURNED. Every one of these silos has already done the thing\n * it was recording, so a throw here would cost an accepted submission or a\n * paid order for a record it could not keep.\n */\nexport type PluginContactCaptured =\n /** The capture landed on a contact — the record every capture used to make. */\n | { ok: true; record: 'contact'; contactId: string; created: boolean }\n /**\n * The capture landed on a LEAD (AGL-3232): a lead surface met somebody the\n * workspace does not hold as a contact, or a touch reached an open lead.\n * `leadId` is the person key the lead is filed under on `hostId`.\n */\n | {\n ok: true\n record: 'lead'\n leadId: string\n created: boolean\n /**\n * Whether this capture's surface was new on the lead — a new person,\n * or one first met through another door. A form counts its leads by\n * it (AGL-3330): the people it filed, not the times they came back.\n * Absent from a writer that does not say, which a caller reads as\n * `created`.\n */\n sourceAdded?: boolean\n }\n | {\n ok: false\n /**\n * `invalid-email`: nothing usable to key a person on. `band`: the\n * workspace is at the audience it may hold. `erased`: the workspace\n * erased this person and a capture must not quietly rebuild them.\n * `error`: the owner failed, and said so rather than silently.\n */\n reason: 'invalid-email' | 'band' | 'erased' | 'error'\n /** Customer-safe: a caller may show or log it as it stands. */\n error: string\n }\n\nexport interface PluginContactCaptureWriter {\n /** Records one capture; a refusal is returned, never thrown. */\n capture(\n request: PluginContactCaptureRequest,\n ): Promise<PluginContactCaptured>\n}\n\n/** A capture door a silo owns. */\nexport interface PluginContactSourceDeclaration {\n /** The silo's word for the door, as its captures carry it. */\n source: string\n /** What a timeline entry from this door says: `Form submission`. */\n label: string\n /**\n * What a link from that entry says when there is something to open —\n * `Open submission`. Absent where the door leaves nothing to open, which\n * is the honest answer for an import or a by-hand add.\n */\n openLabel?: string\n /**\n * The record kind the silo's own document is, for\n * `plugin-record-routes` to address it with. Absent where the door has no\n * record of its own to open.\n */\n recordKind?: string\n}\n\n/** A source declaration with the silo that made it. */\nexport type ResolvedPluginContactSource = PluginContactSourceDeclaration & {\n pluginId: string\n}\n\nexport const PLUGIN_CONTACT_CAPTURE =\n definePluginServiceContract<PluginContactCaptureWriter>(\n 'core.contact-capture',\n { multiple: false },\n )\n\nexport const PLUGIN_CONTACT_SOURCES =\n definePluginServiceContract<PluginContactSourceDeclaration>(\n 'core.contact-capture-sources',\n { multiple: true },\n )\n\n/**\n * Registers the plugin that keeps people. The owner is the loader's marker\n * when a register fn is running, else `options.pluginId`; with neither the\n * registration throws, and a second plugin's writer is refused naming both —\n * the incumbent keeps serving.\n */\nexport function registerPluginContactCaptureWriter(\n writer: PluginContactCaptureWriter,\n options?: { pluginId?: string },\n): void {\n registerPluginService(PLUGIN_CONTACT_CAPTURE, writer, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n })\n}\n\n/** Declares a capture door. One source word has one owner. */\nexport function registerPluginContactSource(\n declaration: PluginContactSourceDeclaration,\n options?: { pluginId?: string },\n): void {\n const key = declaration.source?.trim() ?? ''\n if (!key) throw new Error('a contact capture source needs a source word')\n if (!declaration.label?.trim()) {\n throw new Error(`contact capture source \"${key}\" needs a label`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n const incumbent = resolvePluginServices(PLUGIN_CONTACT_SOURCES).find(\n (entry) => entry.key === key,\n )\n if (incumbent && pluginId && incumbent.pluginId !== pluginId) {\n throw new Error(\n `contact capture source \"${key}\" is already declared by ` +\n `\"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(\n PLUGIN_CONTACT_SOURCES,\n { ...declaration, source: key },\n {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n key,\n },\n )\n}\n\nexport interface ResolvedPluginContactCaptureWriter {\n /** The plugin that keeps people. */\n pluginId: string\n writer: PluginContactCaptureWriter\n}\n\n/** The writer with its owner, or `null` when no plugin keeps people here. */\nexport function pluginContactCaptureWriter(): ResolvedPluginContactCaptureWriter | null {\n const entry = resolvePluginServices(PLUGIN_CONTACT_CAPTURE)[0]\n return entry ? { pluginId: entry.pluginId, writer: entry.impl } : null\n}\n\n/**\n * Hands one capture to the plugin that keeps people, or answers `null` when\n * no plugin does.\n *\n * `null` and a refusal are different answers and a silo treats them\n * differently: `null` is a workspace with no record system, where there is\n * nothing to record and nothing has gone wrong; a refusal is a record system\n * that considered this person and declined.\n */\nexport async function capturePluginContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured | null> {\n const found = pluginContactCaptureWriter()\n if (!found) return null\n return found.writer.capture(request)\n}\n\n/** Every declared capture door, with its silo, in registration order. */\nexport function listPluginContactSources(): ResolvedPluginContactSource[] {\n return resolvePluginServices(PLUGIN_CONTACT_SOURCES).map((entry) => ({\n ...entry.impl,\n pluginId: entry.pluginId,\n }))\n}\n\n/** One declared capture door by its source word, or `null`. */\nexport function pluginContactSource(\n source: string,\n): ResolvedPluginContactSource | null {\n const key = source.trim()\n const entry = resolvePluginServices(PLUGIN_CONTACT_SOURCES).find(\n (one) => one.key === key,\n )\n return entry ? { ...entry.impl, pluginId: entry.pluginId } : null\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","PLUGIN_CONTACT_CAPTURE","multiple","PLUGIN_CONTACT_SOURCES","registerPluginContactCaptureWriter","writer","options","pluginId","registerPluginContactSource","declaration","key","source","trim","Error","label","incumbent","find","entry","pluginContactCaptureWriter","impl","capturePluginContact","request","found","capture","listPluginContactSources","map","pluginContactSource","one"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,sBAAsB,QAAQ,qCAAiC;AACxE,SACEC,2BAA2B,EAC3BC,qBAAqB,EACrBC,qBAAqB,QAChB,uBAAmB;AA+O1B,OAAO,MAAMC,yBACXH,4BACE,wBACA;IAAEI,UAAU;AAAM,GACnB;AAEH,OAAO,MAAMC,yBACXL,4BACE,gCACA;IAAEI,UAAU;AAAK,GAClB;AAEH;;;;;CAKC,GACD,OAAO,SAASE,mCACdC,MAAkC,EAClCC,OAA+B;IAE/BP,sBAAsBE,wBAAwBI,QAAQ,aAChDC,CAAAA,2BAAAA,QAASC,QAAQ,IAAG;QAAEA,UAAUD,QAAQC,QAAQ;IAAC,IAAI,CAAC;AAE9D;AAEA,4DAA4D,GAC5D,OAAO,SAASC,4BACdC,WAA2C,EAC3CH,OAA+B;cAObT,OAAAA;QALNY,qBAEPA;IAFL,MAAMC,eAAMD,sBAAAA,YAAYE,MAAM,qBAAlBF,oBAAoBG,IAAI,qBAAM;IAC1C,IAAI,CAACF,KAAK,MAAM,IAAIG,MAAM;IAC1B,IAAI,GAACJ,qBAAAA,YAAYK,KAAK,qBAAjBL,mBAAmBG,IAAI,KAAI;QAC9B,MAAM,IAAIC,MAAM,CAAC,wBAAwB,EAAEH,IAAI,eAAe,CAAC;IACjE;IACA,MAAMH,WAAW,EAACV,SAAAA,0BAAAA,oCAAAA,0BAA4BS,2BAAAA,QAASC,QAAQ,YAA7CV,QAAiD,IAAIe,IAAI;IAC3E,MAAMG,YAAYf,sBAAsBG,wBAAwBa,IAAI,CAClE,CAACC,QAAUA,MAAMP,GAAG,KAAKA;IAE3B,IAAIK,aAAaR,YAAYQ,UAAUR,QAAQ,KAAKA,UAAU;QAC5D,MAAM,IAAIM,MACR,CAAC,wBAAwB,EAAEH,IAAI,yBAAyB,CAAC,GACvD,CAAC,CAAC,EAAEK,UAAUR,QAAQ,CAAC,YAAY,EAAEA,SAAS,CAAC,CAAC;IAEtD;IACAR,sBACEI,wBACA,aAAKM;QAAaE,QAAQD;QAC1B,aACMJ,CAAAA,2BAAAA,QAASC,QAAQ,IAAG;QAAEA,UAAUD,QAAQC,QAAQ;IAAC,IAAI,CAAC;QAC1DG;;AAGN;AAQA,2EAA2E,GAC3E,OAAO,SAASQ;IACd,MAAMD,QAAQjB,sBAAsBC,uBAAuB,CAAC,EAAE;IAC9D,OAAOgB,QAAQ;QAAEV,UAAUU,MAAMV,QAAQ;QAAEF,QAAQY,MAAME,IAAI;IAAC,IAAI;AACpE;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeC,qBACpBC,OAAoC;IAEpC,MAAMC,QAAQJ;IACd,IAAI,CAACI,OAAO,OAAO;IACnB,OAAOA,MAAMjB,MAAM,CAACkB,OAAO,CAACF;AAC9B;AAEA,uEAAuE,GACvE,OAAO,SAASG;IACd,OAAOxB,sBAAsBG,wBAAwBsB,GAAG,CAAC,CAACR,QAAW,aAChEA,MAAME,IAAI;YACbZ,UAAUU,MAAMV,QAAQ;;AAE5B;AAEA,6DAA6D,GAC7D,OAAO,SAASmB,oBACdf,MAAc;IAEd,MAAMD,MAAMC,OAAOC,IAAI;IACvB,MAAMK,QAAQjB,sBAAsBG,wBAAwBa,IAAI,CAC9D,CAACW,MAAQA,IAAIjB,GAAG,KAAKA;IAEvB,OAAOO,QAAQ,aAAKA,MAAME,IAAI;QAAEZ,UAAUU,MAAMV,QAAQ;SAAK;AAC/D"}
|
|
@@ -107,6 +107,28 @@ export interface PluginEventPayloads {
|
|
|
107
107
|
orgId: string;
|
|
108
108
|
defaultType: string | null;
|
|
109
109
|
};
|
|
110
|
+
/**
|
|
111
|
+
* Records were REMOVED from a collection, by a path that is not the
|
|
112
|
+
* owning plugin's own (AGL-3330): a deletion through the public API, a
|
|
113
|
+
* person's erasure.
|
|
114
|
+
*
|
|
115
|
+
* Raised after the delete has landed, with each removed document's data
|
|
116
|
+
* as it stood, so a plugin that keeps a figure over those rows — a counter
|
|
117
|
+
* no increment can take back — can recount what they fed. Core names the
|
|
118
|
+
* collection and takes no view of what it holds.
|
|
119
|
+
*
|
|
120
|
+
* `hostIds` are the sites the records were on: the one site for a site's
|
|
121
|
+
* collection, every site of the organization for an organization row.
|
|
122
|
+
*/
|
|
123
|
+
'host.records.removed': {
|
|
124
|
+
orgId: string | null;
|
|
125
|
+
hostIds: readonly string[];
|
|
126
|
+
collection: string;
|
|
127
|
+
records: ReadonlyArray<{
|
|
128
|
+
id: string;
|
|
129
|
+
data: Readonly<Record<string, unknown>>;
|
|
130
|
+
}>;
|
|
131
|
+
};
|
|
110
132
|
/** One permission key moved on a subject of the org. */
|
|
111
133
|
'org.permissions.changed': {
|
|
112
134
|
orgId: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-events.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 * Platform events a plugin listens to (AGL-2939, AGL-2940).\n *\n * Core raises a small number of facts about the workspace that a plugin\n * may care about — a seat add-on was bought or dropped, a permission key\n * moved on a member, a role or a site — and a plugin subscribes from its\n * declarations rather than core calling the plugin's own writer. The\n * billing-webhook hooks are the precedent for one vendor's events; this is\n * the same shape for the platform's own.\n *\n * Handlers run sequentially and are ISOLATED: a plugin that throws is\n * logged and the next one runs, because these are consequences of a write\n * that has already happened — an activity row, a notification — and a\n * failure in one must not undo or hide the write for the customer.\n */\n\nimport type { OrgSeatAddons } from '../foundation'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Who did it, as the activity logs record an actor. */\nexport interface PluginEventActor {\n uid: string | null\n email: string | null\n}\n\n/** The payload each platform event carries. */\nexport interface PluginEventPayloads {\n /** An org's purchased seat add-ons were written (a purchase, a removal, a webhook mirror). */\n 'org.seatAddons.changed': {\n orgId: string\n actor: PluginEventActor\n before: OrgSeatAddons | null | undefined\n after: OrgSeatAddons | null | undefined\n }\n /**\n * The workspace's Stripe invoice was paid, in full, by a real charge.\n *\n * Raised from the platform billing webhook once the invoice has resolved\n * to a workspace, so a plugin that bills its own usage learns the outcome\n * of the invoice it asked for without subscribing to Stripe itself. The\n * invoice's own `metadata` travels with it, unread by core: a plugin that\n * stamped `pluginId` on the invoice it created recognizes its own by it,\n * and every other handler ignores the event.\n *\n * `paidOutOfBand` is Stripe's own flag for an invoice marked paid without\n * a charge. A plugin counting payment history must not count one: it is a\n * staff action, not evidence that money moved.\n */\n 'billing.invoice.paid': {\n orgId: string\n invoiceId: string\n amountPaidCents: number\n currency: string\n paidOutOfBand: boolean\n metadata: Readonly<Record<string, string>>\n }\n /** A charge attempt on the workspace's invoice failed or needs action. */\n 'billing.invoice.failed': {\n orgId: string\n invoiceId: string\n amountDueCents: number\n metadata: Readonly<Record<string, string>>\n }\n /**\n * An invoice was closed without being paid — voided, or written off as\n * uncollectible.\n *\n * ONE event for both Stripe events because they are one fact to a plugin:\n * this invoice will never be paid and nothing more is owed on it. A\n * handler that must tell them apart reads `reason`; one that only needs\n * to stop waiting does not.\n */\n 'billing.invoice.closed': {\n orgId: string\n invoiceId: string\n reason: 'voided' | 'uncollectible'\n metadata: Readonly<Record<string, string>>\n }\n /** A cardholder disputed a charge of the workspace's. */\n 'billing.dispute.opened': {\n orgId: string\n chargeId: string\n invoiceId: string | null\n amountCents: number\n }\n /**\n * The workspace's default payment method changed, or its type did.\n *\n * `defaultType` is Stripe's own type string (`card`, `us_bank_account`,\n * `link`, …), or `null` when the customer has no default. Core states the\n * fact and takes no view: which types a plugin will extend credit against\n * is the plugin's decision, not the platform's.\n */\n 'billing.paymentMethod.changed': {\n orgId: string\n defaultType: string | null\n }\n /** One permission key moved on a subject of the org. */\n 'org.permissions.changed': {\n orgId: string\n actor: PluginEventActor\n subject: {\n type: 'org' | 'role' | 'member' | 'host'\n id?: string | null\n name?: string | null\n }\n permission: string\n granted: boolean\n }\n}\n\nexport type PluginEventName = keyof PluginEventPayloads\n\nexport type PluginEventHandler<E extends PluginEventName> = (\n payload: PluginEventPayloads[E],\n) => void | Promise<void>\n\ninterface Registration {\n pluginId: string\n handler: PluginEventHandler<PluginEventName>\n}\n\nconst handlers = new Map<PluginEventName, Registration[]>()\n\n/**\n * Subscribes to an event. Owner = the loader's marker inside a register fn,\n * else `options.pluginId`; a subscription with neither throws. A plugin\n * re-subscribing replaces its earlier handler for that event, so a module\n * evaluated twice does not write two rows.\n */\nexport function registerPluginEventHandler<E extends PluginEventName>(\n event: E,\n handler: PluginEventHandler<E>,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin event \"${event}\" subscribed with no owner: pass { pluginId } ` +\n 'when subscribing outside a plugin register fn',\n )\n }\n const list = (handlers.get(event) ?? []).filter((entry) => entry.pluginId !== pluginId)\n list.push({ pluginId, handler: handler as PluginEventHandler<PluginEventName> })\n handlers.set(event, list)\n}\n\n/**\n * Raises an event. Every handler runs, in subscription order; a throw is\n * logged with the plugin's id and does not stop the others.\n */\nexport async function runPluginEventHandlers<E extends PluginEventName>(\n event: E,\n payload: PluginEventPayloads[E],\n): Promise<{ handled: number; failed: string[] }> {\n let handled = 0\n const failed: string[] = []\n for (const { pluginId, handler } of handlers.get(event) ?? []) {\n try {\n await handler(payload)\n handled += 1\n } catch (error) {\n failed.push(pluginId)\n console.error(`[plugins] ${pluginId} failed on event ${event}`, error)\n }\n }\n return { handled, failed }\n}\n\n/** The plugins subscribed to an event, for diagnostics. */\nexport function listPluginEventHandlers(event: PluginEventName): string[] {\n return (handlers.get(event) ?? []).map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every subscription. */\nexport function resetPluginEventHandlersForTests(): void {\n handlers.clear()\n}\n"],"names":["getRegisteringPluginId","handlers","Map","registerPluginEventHandler","event","handler","options","pluginId","trim","Error","list","get","filter","entry","push","set","runPluginEventHandlers","payload","handled","failed","error","console","listPluginEventHandlers","map","resetPluginEventHandlersForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAGD,SAASA,sBAAsB,QAAQ,qCAAiC;
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-events.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 * Platform events a plugin listens to (AGL-2939, AGL-2940).\n *\n * Core raises a small number of facts about the workspace that a plugin\n * may care about — a seat add-on was bought or dropped, a permission key\n * moved on a member, a role or a site — and a plugin subscribes from its\n * declarations rather than core calling the plugin's own writer. The\n * billing-webhook hooks are the precedent for one vendor's events; this is\n * the same shape for the platform's own.\n *\n * Handlers run sequentially and are ISOLATED: a plugin that throws is\n * logged and the next one runs, because these are consequences of a write\n * that has already happened — an activity row, a notification — and a\n * failure in one must not undo or hide the write for the customer.\n */\n\nimport type { OrgSeatAddons } from '../foundation'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Who did it, as the activity logs record an actor. */\nexport interface PluginEventActor {\n uid: string | null\n email: string | null\n}\n\n/** The payload each platform event carries. */\nexport interface PluginEventPayloads {\n /** An org's purchased seat add-ons were written (a purchase, a removal, a webhook mirror). */\n 'org.seatAddons.changed': {\n orgId: string\n actor: PluginEventActor\n before: OrgSeatAddons | null | undefined\n after: OrgSeatAddons | null | undefined\n }\n /**\n * The workspace's Stripe invoice was paid, in full, by a real charge.\n *\n * Raised from the platform billing webhook once the invoice has resolved\n * to a workspace, so a plugin that bills its own usage learns the outcome\n * of the invoice it asked for without subscribing to Stripe itself. The\n * invoice's own `metadata` travels with it, unread by core: a plugin that\n * stamped `pluginId` on the invoice it created recognizes its own by it,\n * and every other handler ignores the event.\n *\n * `paidOutOfBand` is Stripe's own flag for an invoice marked paid without\n * a charge. A plugin counting payment history must not count one: it is a\n * staff action, not evidence that money moved.\n */\n 'billing.invoice.paid': {\n orgId: string\n invoiceId: string\n amountPaidCents: number\n currency: string\n paidOutOfBand: boolean\n metadata: Readonly<Record<string, string>>\n }\n /** A charge attempt on the workspace's invoice failed or needs action. */\n 'billing.invoice.failed': {\n orgId: string\n invoiceId: string\n amountDueCents: number\n metadata: Readonly<Record<string, string>>\n }\n /**\n * An invoice was closed without being paid — voided, or written off as\n * uncollectible.\n *\n * ONE event for both Stripe events because they are one fact to a plugin:\n * this invoice will never be paid and nothing more is owed on it. A\n * handler that must tell them apart reads `reason`; one that only needs\n * to stop waiting does not.\n */\n 'billing.invoice.closed': {\n orgId: string\n invoiceId: string\n reason: 'voided' | 'uncollectible'\n metadata: Readonly<Record<string, string>>\n }\n /** A cardholder disputed a charge of the workspace's. */\n 'billing.dispute.opened': {\n orgId: string\n chargeId: string\n invoiceId: string | null\n amountCents: number\n }\n /**\n * The workspace's default payment method changed, or its type did.\n *\n * `defaultType` is Stripe's own type string (`card`, `us_bank_account`,\n * `link`, …), or `null` when the customer has no default. Core states the\n * fact and takes no view: which types a plugin will extend credit against\n * is the plugin's decision, not the platform's.\n */\n 'billing.paymentMethod.changed': {\n orgId: string\n defaultType: string | null\n }\n /**\n * Records were REMOVED from a collection, by a path that is not the\n * owning plugin's own (AGL-3330): a deletion through the public API, a\n * person's erasure.\n *\n * Raised after the delete has landed, with each removed document's data\n * as it stood, so a plugin that keeps a figure over those rows — a counter\n * no increment can take back — can recount what they fed. Core names the\n * collection and takes no view of what it holds.\n *\n * `hostIds` are the sites the records were on: the one site for a site's\n * collection, every site of the organization for an organization row.\n */\n 'host.records.removed': {\n orgId: string | null\n hostIds: readonly string[]\n collection: string\n records: ReadonlyArray<{ id: string; data: Readonly<Record<string, unknown>> }>\n }\n /** One permission key moved on a subject of the org. */\n 'org.permissions.changed': {\n orgId: string\n actor: PluginEventActor\n subject: {\n type: 'org' | 'role' | 'member' | 'host'\n id?: string | null\n name?: string | null\n }\n permission: string\n granted: boolean\n }\n}\n\nexport type PluginEventName = keyof PluginEventPayloads\n\nexport type PluginEventHandler<E extends PluginEventName> = (\n payload: PluginEventPayloads[E],\n) => void | Promise<void>\n\ninterface Registration {\n pluginId: string\n handler: PluginEventHandler<PluginEventName>\n}\n\nconst handlers = new Map<PluginEventName, Registration[]>()\n\n/**\n * Subscribes to an event. Owner = the loader's marker inside a register fn,\n * else `options.pluginId`; a subscription with neither throws. A plugin\n * re-subscribing replaces its earlier handler for that event, so a module\n * evaluated twice does not write two rows.\n */\nexport function registerPluginEventHandler<E extends PluginEventName>(\n event: E,\n handler: PluginEventHandler<E>,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin event \"${event}\" subscribed with no owner: pass { pluginId } ` +\n 'when subscribing outside a plugin register fn',\n )\n }\n const list = (handlers.get(event) ?? []).filter((entry) => entry.pluginId !== pluginId)\n list.push({ pluginId, handler: handler as PluginEventHandler<PluginEventName> })\n handlers.set(event, list)\n}\n\n/**\n * Raises an event. Every handler runs, in subscription order; a throw is\n * logged with the plugin's id and does not stop the others.\n */\nexport async function runPluginEventHandlers<E extends PluginEventName>(\n event: E,\n payload: PluginEventPayloads[E],\n): Promise<{ handled: number; failed: string[] }> {\n let handled = 0\n const failed: string[] = []\n for (const { pluginId, handler } of handlers.get(event) ?? []) {\n try {\n await handler(payload)\n handled += 1\n } catch (error) {\n failed.push(pluginId)\n console.error(`[plugins] ${pluginId} failed on event ${event}`, error)\n }\n }\n return { handled, failed }\n}\n\n/** The plugins subscribed to an event, for diagnostics. */\nexport function listPluginEventHandlers(event: PluginEventName): string[] {\n return (handlers.get(event) ?? []).map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every subscription. */\nexport function resetPluginEventHandlersForTests(): void {\n handlers.clear()\n}\n"],"names":["getRegisteringPluginId","handlers","Map","registerPluginEventHandler","event","handler","options","pluginId","trim","Error","list","get","filter","entry","push","set","runPluginEventHandlers","payload","handled","failed","error","console","listPluginEventHandlers","map","resetPluginEventHandlersForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAGD,SAASA,sBAAsB,QAAQ,qCAAiC;AA4HxE,MAAMC,WAAW,IAAIC;AAErB;;;;;CAKC,GACD,OAAO,SAASC,2BACdC,KAAQ,EACRC,OAA8B,EAC9BC,OAA+B;QAEbN,MAAAA,yBAOJC;IAPd,MAAMM,WAAW,EAACP,QAAAA,0BAAAA,oCAAAA,0BAA4BM,2BAAAA,QAASC,QAAQ,YAA7CP,OAAiD,IAAIQ,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,CAAC,cAAc,EAAEL,MAAM,8CAA8C,CAAC,GACpE;IAEN;IACA,MAAMM,OAAO,EAACT,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEW,MAAM,CAAC,CAACC,QAAUA,MAAMN,QAAQ,KAAKA;IAC9EG,KAAKI,IAAI,CAAC;QAAEP;QAAUF,SAASA;IAA+C;IAC9EJ,SAASc,GAAG,CAACX,OAAOM;AACtB;AAEA;;;CAGC,GACD,OAAO,eAAeM,uBACpBZ,KAAQ,EACRa,OAA+B;QAIKhB;IAFpC,IAAIiB,UAAU;IACd,MAAMC,SAAmB,EAAE;IAC3B,KAAK,MAAM,EAAEZ,QAAQ,EAAEF,OAAO,EAAE,KAAIJ,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,CAAE;QAC7D,IAAI;YACF,MAAMI,QAAQY;YACdC,WAAW;QACb,EAAE,OAAOE,OAAO;YACdD,OAAOL,IAAI,CAACP;YACZc,QAAQD,KAAK,CAAC,CAAC,UAAU,EAAEb,SAAS,iBAAiB,EAAEH,OAAO,EAAEgB;QAClE;IACF;IACA,OAAO;QAAEF;QAASC;IAAO;AAC3B;AAEA,yDAAyD,GACzD,OAAO,SAASG,wBAAwBlB,KAAsB;QACpDH;IAAR,OAAO,EAACA,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEsB,GAAG,CAAC,CAACV,QAAUA,MAAMN,QAAQ;AAClE;AAEA,0CAA0C,GAC1C,OAAO,SAASiB;IACdvB,SAASwB,KAAK;AAChB"}
|
|
@@ -61,10 +61,14 @@ export interface PluginJobHostGate {
|
|
|
61
61
|
isLocked(hostId: string): Promise<boolean>;
|
|
62
62
|
}
|
|
63
63
|
/**
|
|
64
|
-
* The gate as the runner builds it.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
64
|
+
* The gate as the runner builds it.
|
|
65
|
+
*
|
|
66
|
+
* The MANUAL doors — `commerce/process-abandoned`, `commerce/process-restock`,
|
|
67
|
+
* `bookings/reminders` — do NOT use it (AGL-3356). They are served by the
|
|
68
|
+
* console, which registers no resolver, so this answered "not locked" for
|
|
69
|
+
* every site there and a forced pass mailed a suspended site's customers.
|
|
70
|
+
* They take `siteLockdownJobGate()` from the admin lib instead: the same
|
|
71
|
+
* function the tenant registers here, needing no registration at all.
|
|
68
72
|
*
|
|
69
73
|
* NO resolver registered answers "not locked", loudly and once. That is the
|
|
70
74
|
* fail-open direction on purpose: a deployment that has not wired a carrier
|
|
@@ -85,10 +85,14 @@
|
|
|
85
85
|
hostLockedResolver = null;
|
|
86
86
|
}
|
|
87
87
|
/**
|
|
88
|
-
* The gate as the runner builds it.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
88
|
+
* The gate as the runner builds it.
|
|
89
|
+
*
|
|
90
|
+
* The MANUAL doors — `commerce/process-abandoned`, `commerce/process-restock`,
|
|
91
|
+
* `bookings/reminders` — do NOT use it (AGL-3356). They are served by the
|
|
92
|
+
* console, which registers no resolver, so this answered "not locked" for
|
|
93
|
+
* every site there and a forced pass mailed a suspended site's customers.
|
|
94
|
+
* They take `siteLockdownJobGate()` from the admin lib instead: the same
|
|
95
|
+
* function the tenant registers here, needing no registration at all.
|
|
92
96
|
*
|
|
93
97
|
* NO resolver registered answers "not locked", loudly and once. That is the
|
|
94
98
|
* fail-open direction on purpose: a deployment that has not wired a carrier
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-jobs.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 * Plugin scheduled jobs (AGL-435, Strapi cron parity): `/server` entries\n * register named jobs with an interval; a guarded platform runner route\n * (`/api/plugins/run-jobs`, secret header, invoked by the deployment's\n * scheduler) executes the due ones. The REGISTRY is pure — due-ness and\n * last-run persistence belong to the runner, so core stays storage-free.\n * Handlers must be idempotent and bounded (they share the API process).\n */\n\n/*==========================================\n * THE LOCKDOWN GATE FOR BACKGROUND WORK (AGL-2495, from the AGL-1621 drill).\n *\n * A job runs on PLATFORM credentials, from a secret-gated route, with no\n * visitor, no session and no org — so none of the gates that cover the\n * visitor-facing write paths are anywhere near it. That is exactly the shape\n * the drill found in `publish-schedule-job.ts`: a scheduled publish firing on\n * a locked host. The commerce and bookings beats are the sharper version of\n * the same thing, because they touch orders, stock and money for a host that\n * may be suspended for abuse, non-payment or a legal takedown.\n *\n * ## Why the gate lives HERE and not at the six call sites\n *\n * Six call-site edits close six holes and guarantee the seventh is forgotten.\n * Gating in the job contract means the seventh job is covered the day it is\n * written: `lockdown` below is REQUIRED, so a new `registerPluginJob({…})`\n * does not compile until its author has answered \"what does this touch\".\n *\n * (The nx boundary is NOT why. `libs/plugins/*` CAN import\n * `@aglyn/tenant-data-admin` — it carries `scope:data` and `scope:aglyn`,\n * both on the `aglyn:addons` allowlist, and 196 files in `libs/plugins`\n * already do. The AGL-2495 sweep recorded the opposite as its reason for\n * deferring; it was wrong about the constraint and right about the design.)\n *\n * ## Why core cannot just call `getSiteLockdown` itself\n *\n * `@aglyn/tenant-data-admin` imports `@aglyn/aglyn/server`, so a core import\n * of it is a project CYCLE. Hence the same registry shape the platform uses\n * for every other app↔core edge (`registerOrderFulfilmentService`,\n * `registerBillingWebhookHandler`, `registerSitePageResolver`): the host app\n * registers the resolver from a place that may import the admin lib, and core\n * looks it up. Nothing is imported statically in either direction and a\n * self-host build with a different carrier registers its own.\n *\n * ## Fail-open is INHERITED, not re-decided\n *\n * The gate asks whatever the app registered — on the tenant that is\n * `getSiteLockdown`, which owns the fail-open/fail-closed decision (and the\n * takedown-class ratchet on top of it). This module deliberately holds no\n * parallel notion of what a lock is: it has one question and no opinion.\n *=========================================*/\n\n/**\n * The one question a beat asks before touching a host: is a lock active that\n * forbids this write? Resolved by the host app, never by core.\n */\nexport type PluginJobHostLockedResolver = (\n hostId: string,\n) => Promise<boolean> | boolean\n\nlet hostLockedResolver: PluginJobHostLockedResolver | null = null\n\n/**\n * Registers the platform's per-host lockdown verdict for background work,\n * from a place that may import the admin data lib — on this platform,\n * `apps/tenant/utils/plugin-job-lockdown.ts`, loaded for its side effect by\n * the runner route beside the core job registrations.\n *\n * Idempotent and last-registration-wins, unlike `registerOrderFulfilmentService`\n * next door: that one holds a capability with a plugin OWNER, where a second\n * claimant is a misconfiguration. This is infrastructure the deployment\n * supplies exactly once, and a process that loads the module twice must not\n * throw on the second pass.\n */\nexport function registerPluginJobHostLockdown(\n resolver: PluginJobHostLockedResolver,\n): void {\n hostLockedResolver = resolver\n}\n\n/** Has the deployment wired a verdict? Reported by the runner route. */\nexport function hasPluginJobHostLockdown(): boolean {\n return hostLockedResolver !== null\n}\n\n/** Test seam: forget the registration. */\nexport function resetPluginJobHostLockdownForTests(): void {\n hostLockedResolver = null\n}\n\n/**\n * The gate handed to a `per-host` job. One method, so there is exactly one\n * thing to call and one thing for the coverage guard to look for:\n *\n * if (await gate.isLocked(hostId)) continue\n *\n * SKIPPED, NOT DROPPED is the caller's obligation and the reason this\n * answers a question rather than performing the skip: `continue` leaves the\n * row exactly as it was, so the work lands on the next beat after the lift.\n * A gate that swallowed the row — stamping it done, deleting it, advancing a\n * cursor past it — would turn a pause into a cancellation, which is what\n * `publish-schedule-job.ts` argued at length and what every job here copies.\n */\nexport interface PluginJobHostGate {\n isLocked(hostId: string): Promise<boolean>\n}\n\n/**\n * The gate as the runner builds it. Exported because the MANUAL doors —\n * `commerce/process-abandoned`, `bookings/reminders` and the other\n * `x-cron-secret` entry points that still exist for ops — call the same scan\n * functions and must ask the same question. A forced pass is still a pass.\n *\n * NO resolver registered answers \"not locked\", loudly and once. That is the\n * fail-open direction on purpose: a deployment that has not wired a carrier\n * (a self-host, a test process) must not have every background job welded\n * shut by infrastructure it never asked for. The loss is real and is why\n * `hasPluginJobHostLockdown` is reported by the runner route rather than\n * left to be inferred from nothing happening.\n */\nexport function pluginJobHostGate(): PluginJobHostGate {\n return {\n isLocked: async (hostId: string) => {\n if (!hostId) return false\n if (!hostLockedResolver) {\n warnMissingResolver()\n return false\n }\n return (await hostLockedResolver(hostId)) === true\n },\n }\n}\n\nlet warnedMissingResolver = false\nfunction warnMissingResolver(): void {\n if (warnedMissingResolver) return\n warnedMissingResolver = true\n console.error(\n '[plugin-jobs] no host lockdown resolver is registered — background ' +\n 'jobs are running UNGATED. Register one with ' +\n 'registerPluginJobHostLockdown() from the app that owns the runner.',\n )\n}\n\n/** Test seam: let the \"warned once\" latch be re-armed. */\nexport function resetPluginJobLockdownWarningForTests(): void {\n warnedMissingResolver = false\n}\n\n/**\n * What a job touches, and therefore what the runner owes it. REQUIRED on\n * every registration — the point of the field is that a new job cannot\n * compile without its author answering.\n */\nexport type PluginJobLockdown =\n /**\n * The job acts FOR HOSTS. The runner injects a {@link PluginJobHostGate}\n * and the handler must ask it for every host it is about to touch.\n */\n | { scope: 'per-host' }\n /**\n * The job touches nothing a lock could be about. `reason` is the argument,\n * and `lockdown-tenant-api-coverage.spec.ts` holds the set by name so a new\n * one has to be argued rather than declared.\n */\n | { scope: 'platform'; reason: string }\n\nexport interface PluginJob {\n pluginId: string\n /** Stable job name, unique within the plugin ('expire-stale-holds'). */\n name: string\n intervalMinutes: number\n description?: string\n /** See {@link PluginJobLockdown}. Required, deliberately. */\n lockdown: PluginJobLockdown\n handler: (gate: PluginJobHostGate) => Promise<void> | void\n}\n\nconst jobs = new Map<string, PluginJob>()\n\nexport const pluginJobKey = (job: Pick<PluginJob, 'pluginId' | 'name'>) =>\n `${job.pluginId}:${job.name}`\n\n/** Idempotent per pluginId:name. */\nexport function registerPluginJob(job: PluginJob): void {\n jobs.set(pluginJobKey(job), job)\n}\n\nexport function listPluginJobs(): PluginJob[] {\n return [...jobs.values()]\n}\n\nexport interface PluginJobResult {\n key: string\n ok: boolean\n error?: string\n}\n\n/**\n * The gate a `platform`-scoped job gets: one that REFUSES to answer.\n *\n * A declaration of `platform` scope is a claim that there is no host to ask\n * about. If such a job asks anyway, the claim is false and the registration\n * is the thing that needs fixing — so the runner surfaces it as that job's\n * own failure (isolated, like every other job error) instead of quietly\n * answering \"not locked\" and letting a mislabelled job mutate a locked site.\n *\n * This is the one place the contract is enforced at RUNTIME rather than by\n * the type or by the guard, and it is enforceable precisely because asking\n * is the only way to get an answer.\n */\nfunction refusingGate(key: string): PluginJobHostGate {\n return {\n isLocked: async (hostId: string) => {\n throw new Error(\n `plugin job ${key} declared lockdown scope 'platform' but asked ` +\n `about host \"${hostId}\". Declare { scope: 'per-host' } instead.`,\n )\n },\n }\n}\n\n/**\n * Runs the jobs `due` selects (default: all), sequentially with error\n * isolation — one broken job never blocks the rest.\n *\n * Every handler is handed a {@link PluginJobHostGate} (AGL-2495). It is a\n * parameter rather than something the handler imports so that the runner\n * stays the single place the verdict comes from: swap what\n * `registerPluginJobHostLockdown` holds and every job on the beat changes\n * behaviour together, including one written next month.\n */\nexport async function runPluginJobs(\n due?: (job: PluginJob) => boolean,\n): Promise<PluginJobResult[]> {\n const results: PluginJobResult[] = []\n for (const job of jobs.values()) {\n if (due && !due(job)) continue\n const key = pluginJobKey(job)\n try {\n await job.handler(\n job.lockdown?.scope === 'platform'\n ? refusingGate(key)\n : pluginJobHostGate(),\n )\n results.push({ key, ok: true })\n } catch (error) {\n console.error(`plugin job ${key} failed:`, error)\n results.push({ key, ok: false, error: String(error) })\n }\n }\n return results\n}\n"],"names":["hostLockedResolver","registerPluginJobHostLockdown","resolver","hasPluginJobHostLockdown","resetPluginJobHostLockdownForTests","pluginJobHostGate","isLocked","hostId","warnMissingResolver","warnedMissingResolver","console","error","resetPluginJobLockdownWarningForTests","jobs","Map","pluginJobKey","job","pluginId","name","registerPluginJob","set","listPluginJobs","values","refusingGate","key","Error","runPluginJobs","due","results","handler","lockdown","scope","push","ok","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;CAOC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAwC2C,GAE3C;;;CAGC,GAKD,IAAIA,qBAAyD;AAE7D;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,8BACdC,QAAqC;IAErCF,qBAAqBE;AACvB;AAEA,sEAAsE,GACtE,OAAO,SAASC;IACd,OAAOH,uBAAuB;AAChC;AAEA,wCAAwC,GACxC,OAAO,SAASI;IACdJ,qBAAqB;AACvB;AAmBA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASK;IACd,OAAO;QACLC,UAAU,OAAOC;YACf,IAAI,CAACA,QAAQ,OAAO;YACpB,IAAI,CAACP,oBAAoB;gBACvBQ;gBACA,OAAO;YACT;YACA,OAAO,AAAC,MAAMR,mBAAmBO,YAAa;QAChD;IACF;AACF;AAEA,IAAIE,wBAAwB;AAC5B,SAASD;IACP,IAAIC,uBAAuB;IAC3BA,wBAAwB;IACxBC,QAAQC,KAAK,CACX,wEACE,iDACA;AAEN;AAEA,wDAAwD,GACxD,OAAO,SAASC;IACdH,wBAAwB;AAC1B;AA+BA,MAAMI,OAAO,IAAIC;AAEjB,OAAO,MAAMC,eAAe,CAACC,MAC3B,GAAGA,IAAIC,QAAQ,CAAC,CAAC,EAAED,IAAIE,IAAI,EAAE,CAAA;AAE/B,kCAAkC,GAClC,OAAO,SAASC,kBAAkBH,GAAc;IAC9CH,KAAKO,GAAG,CAACL,aAAaC,MAAMA;AAC9B;AAEA,OAAO,SAASK;IACd,OAAO;WAAIR,KAAKS,MAAM;KAAG;AAC3B;AAQA;;;;;;;;;;;;CAYC,GACD,SAASC,aAAaC,GAAW;IAC/B,OAAO;QACLlB,UAAU,OAAOC;YACf,MAAM,IAAIkB,MACR,CAAC,WAAW,EAAED,IAAI,8CAA8C,CAAC,GAC/D,CAAC,YAAY,EAAEjB,OAAO,yCAAyC,CAAC;QAEtE;IACF;AACF;AAEA;;;;;;;;;CASC,GACD,OAAO,eAAemB,cACpBC,GAAiC;IAEjC,MAAMC,UAA6B,EAAE;IACrC,KAAK,MAAMZ,OAAOH,KAAKS,MAAM,GAAI;QAC/B,IAAIK,OAAO,CAACA,IAAIX,MAAM;QACtB,MAAMQ,MAAMT,aAAaC;QACzB,IAAI;gBAEAA;YADF,MAAMA,IAAIa,OAAO,CACfb,EAAAA,gBAAAA,IAAIc,QAAQ,qBAAZd,cAAce,KAAK,MAAK,aACpBR,aAAaC,OACbnB;YAENuB,QAAQI,IAAI,CAAC;gBAAER;gBAAKS,IAAI;YAAK;QAC/B,EAAE,OAAOtB,OAAO;YACdD,QAAQC,KAAK,CAAC,CAAC,WAAW,EAAEa,IAAI,QAAQ,CAAC,EAAEb;YAC3CiB,QAAQI,IAAI,CAAC;gBAAER;gBAAKS,IAAI;gBAAOtB,OAAOuB,OAAOvB;YAAO;QACtD;IACF;IACA,OAAOiB;AACT"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-jobs.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 * Plugin scheduled jobs (AGL-435, Strapi cron parity): `/server` entries\n * register named jobs with an interval; a guarded platform runner route\n * (`/api/plugins/run-jobs`, secret header, invoked by the deployment's\n * scheduler) executes the due ones. The REGISTRY is pure — due-ness and\n * last-run persistence belong to the runner, so core stays storage-free.\n * Handlers must be idempotent and bounded (they share the API process).\n */\n\n/*==========================================\n * THE LOCKDOWN GATE FOR BACKGROUND WORK (AGL-2495, from the AGL-1621 drill).\n *\n * A job runs on PLATFORM credentials, from a secret-gated route, with no\n * visitor, no session and no org — so none of the gates that cover the\n * visitor-facing write paths are anywhere near it. That is exactly the shape\n * the drill found in `publish-schedule-job.ts`: a scheduled publish firing on\n * a locked host. The commerce and bookings beats are the sharper version of\n * the same thing, because they touch orders, stock and money for a host that\n * may be suspended for abuse, non-payment or a legal takedown.\n *\n * ## Why the gate lives HERE and not at the six call sites\n *\n * Six call-site edits close six holes and guarantee the seventh is forgotten.\n * Gating in the job contract means the seventh job is covered the day it is\n * written: `lockdown` below is REQUIRED, so a new `registerPluginJob({…})`\n * does not compile until its author has answered \"what does this touch\".\n *\n * (The nx boundary is NOT why. `libs/plugins/*` CAN import\n * `@aglyn/tenant-data-admin` — it carries `scope:data` and `scope:aglyn`,\n * both on the `aglyn:addons` allowlist, and 196 files in `libs/plugins`\n * already do. The AGL-2495 sweep recorded the opposite as its reason for\n * deferring; it was wrong about the constraint and right about the design.)\n *\n * ## Why core cannot just call `getSiteLockdown` itself\n *\n * `@aglyn/tenant-data-admin` imports `@aglyn/aglyn/server`, so a core import\n * of it is a project CYCLE. Hence the same registry shape the platform uses\n * for every other app↔core edge (`registerOrderFulfilmentService`,\n * `registerBillingWebhookHandler`, `registerSitePageResolver`): the host app\n * registers the resolver from a place that may import the admin lib, and core\n * looks it up. Nothing is imported statically in either direction and a\n * self-host build with a different carrier registers its own.\n *\n * ## Fail-open is INHERITED, not re-decided\n *\n * The gate asks whatever the app registered — on the tenant that is\n * `getSiteLockdown`, which owns the fail-open/fail-closed decision (and the\n * takedown-class ratchet on top of it). This module deliberately holds no\n * parallel notion of what a lock is: it has one question and no opinion.\n *=========================================*/\n\n/**\n * The one question a beat asks before touching a host: is a lock active that\n * forbids this write? Resolved by the host app, never by core.\n */\nexport type PluginJobHostLockedResolver = (\n hostId: string,\n) => Promise<boolean> | boolean\n\nlet hostLockedResolver: PluginJobHostLockedResolver | null = null\n\n/**\n * Registers the platform's per-host lockdown verdict for background work,\n * from a place that may import the admin data lib — on this platform,\n * `apps/tenant/utils/plugin-job-lockdown.ts`, loaded for its side effect by\n * the runner route beside the core job registrations.\n *\n * Idempotent and last-registration-wins, unlike `registerOrderFulfilmentService`\n * next door: that one holds a capability with a plugin OWNER, where a second\n * claimant is a misconfiguration. This is infrastructure the deployment\n * supplies exactly once, and a process that loads the module twice must not\n * throw on the second pass.\n */\nexport function registerPluginJobHostLockdown(\n resolver: PluginJobHostLockedResolver,\n): void {\n hostLockedResolver = resolver\n}\n\n/** Has the deployment wired a verdict? Reported by the runner route. */\nexport function hasPluginJobHostLockdown(): boolean {\n return hostLockedResolver !== null\n}\n\n/** Test seam: forget the registration. */\nexport function resetPluginJobHostLockdownForTests(): void {\n hostLockedResolver = null\n}\n\n/**\n * The gate handed to a `per-host` job. One method, so there is exactly one\n * thing to call and one thing for the coverage guard to look for:\n *\n * if (await gate.isLocked(hostId)) continue\n *\n * SKIPPED, NOT DROPPED is the caller's obligation and the reason this\n * answers a question rather than performing the skip: `continue` leaves the\n * row exactly as it was, so the work lands on the next beat after the lift.\n * A gate that swallowed the row — stamping it done, deleting it, advancing a\n * cursor past it — would turn a pause into a cancellation, which is what\n * `publish-schedule-job.ts` argued at length and what every job here copies.\n */\nexport interface PluginJobHostGate {\n isLocked(hostId: string): Promise<boolean>\n}\n\n/**\n * The gate as the runner builds it.\n *\n * The MANUAL doors — `commerce/process-abandoned`, `commerce/process-restock`,\n * `bookings/reminders` — do NOT use it (AGL-3356). They are served by the\n * console, which registers no resolver, so this answered \"not locked\" for\n * every site there and a forced pass mailed a suspended site's customers.\n * They take `siteLockdownJobGate()` from the admin lib instead: the same\n * function the tenant registers here, needing no registration at all.\n *\n * NO resolver registered answers \"not locked\", loudly and once. That is the\n * fail-open direction on purpose: a deployment that has not wired a carrier\n * (a self-host, a test process) must not have every background job welded\n * shut by infrastructure it never asked for. The loss is real and is why\n * `hasPluginJobHostLockdown` is reported by the runner route rather than\n * left to be inferred from nothing happening.\n */\nexport function pluginJobHostGate(): PluginJobHostGate {\n return {\n isLocked: async (hostId: string) => {\n if (!hostId) return false\n if (!hostLockedResolver) {\n warnMissingResolver()\n return false\n }\n return (await hostLockedResolver(hostId)) === true\n },\n }\n}\n\nlet warnedMissingResolver = false\nfunction warnMissingResolver(): void {\n if (warnedMissingResolver) return\n warnedMissingResolver = true\n console.error(\n '[plugin-jobs] no host lockdown resolver is registered — background ' +\n 'jobs are running UNGATED. Register one with ' +\n 'registerPluginJobHostLockdown() from the app that owns the runner.',\n )\n}\n\n/** Test seam: let the \"warned once\" latch be re-armed. */\nexport function resetPluginJobLockdownWarningForTests(): void {\n warnedMissingResolver = false\n}\n\n/**\n * What a job touches, and therefore what the runner owes it. REQUIRED on\n * every registration — the point of the field is that a new job cannot\n * compile without its author answering.\n */\nexport type PluginJobLockdown =\n /**\n * The job acts FOR HOSTS. The runner injects a {@link PluginJobHostGate}\n * and the handler must ask it for every host it is about to touch.\n */\n | { scope: 'per-host' }\n /**\n * The job touches nothing a lock could be about. `reason` is the argument,\n * and `lockdown-tenant-api-coverage.spec.ts` holds the set by name so a new\n * one has to be argued rather than declared.\n */\n | { scope: 'platform'; reason: string }\n\nexport interface PluginJob {\n pluginId: string\n /** Stable job name, unique within the plugin ('expire-stale-holds'). */\n name: string\n intervalMinutes: number\n description?: string\n /** See {@link PluginJobLockdown}. Required, deliberately. */\n lockdown: PluginJobLockdown\n handler: (gate: PluginJobHostGate) => Promise<void> | void\n}\n\nconst jobs = new Map<string, PluginJob>()\n\nexport const pluginJobKey = (job: Pick<PluginJob, 'pluginId' | 'name'>) =>\n `${job.pluginId}:${job.name}`\n\n/** Idempotent per pluginId:name. */\nexport function registerPluginJob(job: PluginJob): void {\n jobs.set(pluginJobKey(job), job)\n}\n\nexport function listPluginJobs(): PluginJob[] {\n return [...jobs.values()]\n}\n\nexport interface PluginJobResult {\n key: string\n ok: boolean\n error?: string\n}\n\n/**\n * The gate a `platform`-scoped job gets: one that REFUSES to answer.\n *\n * A declaration of `platform` scope is a claim that there is no host to ask\n * about. If such a job asks anyway, the claim is false and the registration\n * is the thing that needs fixing — so the runner surfaces it as that job's\n * own failure (isolated, like every other job error) instead of quietly\n * answering \"not locked\" and letting a mislabelled job mutate a locked site.\n *\n * This is the one place the contract is enforced at RUNTIME rather than by\n * the type or by the guard, and it is enforceable precisely because asking\n * is the only way to get an answer.\n */\nfunction refusingGate(key: string): PluginJobHostGate {\n return {\n isLocked: async (hostId: string) => {\n throw new Error(\n `plugin job ${key} declared lockdown scope 'platform' but asked ` +\n `about host \"${hostId}\". Declare { scope: 'per-host' } instead.`,\n )\n },\n }\n}\n\n/**\n * Runs the jobs `due` selects (default: all), sequentially with error\n * isolation — one broken job never blocks the rest.\n *\n * Every handler is handed a {@link PluginJobHostGate} (AGL-2495). It is a\n * parameter rather than something the handler imports so that the runner\n * stays the single place the verdict comes from: swap what\n * `registerPluginJobHostLockdown` holds and every job on the beat changes\n * behaviour together, including one written next month.\n */\nexport async function runPluginJobs(\n due?: (job: PluginJob) => boolean,\n): Promise<PluginJobResult[]> {\n const results: PluginJobResult[] = []\n for (const job of jobs.values()) {\n if (due && !due(job)) continue\n const key = pluginJobKey(job)\n try {\n await job.handler(\n job.lockdown?.scope === 'platform'\n ? refusingGate(key)\n : pluginJobHostGate(),\n )\n results.push({ key, ok: true })\n } catch (error) {\n console.error(`plugin job ${key} failed:`, error)\n results.push({ key, ok: false, error: String(error) })\n }\n }\n return results\n}\n"],"names":["hostLockedResolver","registerPluginJobHostLockdown","resolver","hasPluginJobHostLockdown","resetPluginJobHostLockdownForTests","pluginJobHostGate","isLocked","hostId","warnMissingResolver","warnedMissingResolver","console","error","resetPluginJobLockdownWarningForTests","jobs","Map","pluginJobKey","job","pluginId","name","registerPluginJob","set","listPluginJobs","values","refusingGate","key","Error","runPluginJobs","due","results","handler","lockdown","scope","push","ok","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;CAOC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAwC2C,GAE3C;;;CAGC,GAKD,IAAIA,qBAAyD;AAE7D;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,8BACdC,QAAqC;IAErCF,qBAAqBE;AACvB;AAEA,sEAAsE,GACtE,OAAO,SAASC;IACd,OAAOH,uBAAuB;AAChC;AAEA,wCAAwC,GACxC,OAAO,SAASI;IACdJ,qBAAqB;AACvB;AAmBA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASK;IACd,OAAO;QACLC,UAAU,OAAOC;YACf,IAAI,CAACA,QAAQ,OAAO;YACpB,IAAI,CAACP,oBAAoB;gBACvBQ;gBACA,OAAO;YACT;YACA,OAAO,AAAC,MAAMR,mBAAmBO,YAAa;QAChD;IACF;AACF;AAEA,IAAIE,wBAAwB;AAC5B,SAASD;IACP,IAAIC,uBAAuB;IAC3BA,wBAAwB;IACxBC,QAAQC,KAAK,CACX,wEACE,iDACA;AAEN;AAEA,wDAAwD,GACxD,OAAO,SAASC;IACdH,wBAAwB;AAC1B;AA+BA,MAAMI,OAAO,IAAIC;AAEjB,OAAO,MAAMC,eAAe,CAACC,MAC3B,GAAGA,IAAIC,QAAQ,CAAC,CAAC,EAAED,IAAIE,IAAI,EAAE,CAAA;AAE/B,kCAAkC,GAClC,OAAO,SAASC,kBAAkBH,GAAc;IAC9CH,KAAKO,GAAG,CAACL,aAAaC,MAAMA;AAC9B;AAEA,OAAO,SAASK;IACd,OAAO;WAAIR,KAAKS,MAAM;KAAG;AAC3B;AAQA;;;;;;;;;;;;CAYC,GACD,SAASC,aAAaC,GAAW;IAC/B,OAAO;QACLlB,UAAU,OAAOC;YACf,MAAM,IAAIkB,MACR,CAAC,WAAW,EAAED,IAAI,8CAA8C,CAAC,GAC/D,CAAC,YAAY,EAAEjB,OAAO,yCAAyC,CAAC;QAEtE;IACF;AACF;AAEA;;;;;;;;;CASC,GACD,OAAO,eAAemB,cACpBC,GAAiC;IAEjC,MAAMC,UAA6B,EAAE;IACrC,KAAK,MAAMZ,OAAOH,KAAKS,MAAM,GAAI;QAC/B,IAAIK,OAAO,CAACA,IAAIX,MAAM;QACtB,MAAMQ,MAAMT,aAAaC;QACzB,IAAI;gBAEAA;YADF,MAAMA,IAAIa,OAAO,CACfb,EAAAA,gBAAAA,IAAIc,QAAQ,qBAAZd,cAAce,KAAK,MAAK,aACpBR,aAAaC,OACbnB;YAENuB,QAAQI,IAAI,CAAC;gBAAER;gBAAKS,IAAI;YAAK;QAC/B,EAAE,OAAOtB,OAAO;YACdD,QAAQC,KAAK,CAAC,CAAC,WAAW,EAAEa,IAAI,QAAQ,CAAC,EAAEb;YAC3CiB,QAAQI,IAAI,CAAC;gBAAER;gBAAKS,IAAI;gBAAOtB,OAAOuB,OAAOvB;YAAO;QACtD;IACF;IACA,OAAOiB;AACT"}
|