@aglyn/aglyn 1.0.0-beta.268 → 1.0.0-beta.270

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/account-preferences.d.ts +248 -0
  3. package/src/lib/app-utils/account-preferences.js +434 -0
  4. package/src/lib/app-utils/account-preferences.js.map +1 -0
  5. package/src/lib/app-utils/country-options.d.ts +27 -0
  6. package/src/lib/app-utils/country-options.js +96 -0
  7. package/src/lib/app-utils/country-options.js.map +1 -0
  8. package/src/lib/app-utils/docs-help.generated.d.ts +6 -0
  9. package/src/lib/app-utils/docs-help.generated.js +13 -0
  10. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  11. package/src/lib/app-utils/docs-index.generated.js +17 -10
  12. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  13. package/src/lib/app-utils/element-motion.d.ts +14 -2
  14. package/src/lib/app-utils/element-motion.js +33 -13
  15. package/src/lib/app-utils/element-motion.js.map +1 -1
  16. package/src/lib/app-utils/mobile-push.d.ts +8 -0
  17. package/src/lib/app-utils/mobile-push.js +4 -1
  18. package/src/lib/app-utils/mobile-push.js.map +1 -1
  19. package/src/lib/app-utils/native-app-association.d.ts +3 -2
  20. package/src/lib/app-utils/native-app-association.js +4 -2
  21. package/src/lib/app-utils/native-app-association.js.map +1 -1
  22. package/src/lib/app-utils/native-nav.d.ts +265 -0
  23. package/src/lib/app-utils/native-nav.js +741 -0
  24. package/src/lib/app-utils/native-nav.js.map +1 -0
  25. package/src/lib/app-utils/native-themes.d.ts +249 -0
  26. package/src/lib/app-utils/native-themes.js +732 -0
  27. package/src/lib/app-utils/native-themes.js.map +1 -0
  28. package/src/lib/app-utils/notifications.d.ts +1 -1
  29. package/src/lib/app-utils/notifications.js +10 -0
  30. package/src/lib/app-utils/notifications.js.map +1 -1
  31. package/src/lib/app-utils/one-box-search.d.ts +108 -0
  32. package/src/lib/app-utils/one-box-search.js +167 -0
  33. package/src/lib/app-utils/one-box-search.js.map +1 -0
  34. package/src/lib/app-utils/org-permissions.js +10 -61
  35. package/src/lib/app-utils/org-permissions.js.map +1 -1
  36. package/src/lib/app-utils/org-role-grants.d.ts +95 -0
  37. package/src/lib/app-utils/org-role-grants.js +193 -0
  38. package/src/lib/app-utils/org-role-grants.js.map +1 -0
  39. package/src/lib/app-utils/plan-entitlements.d.ts +41 -1
  40. package/src/lib/app-utils/plan-entitlements.js +65 -3
  41. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  42. package/src/lib/app-utils/tenant-dns-targets.d.ts +62 -0
  43. package/src/lib/app-utils/tenant-dns-targets.js +80 -0
  44. package/src/lib/app-utils/tenant-dns-targets.js.map +1 -0
  45. package/src/lib/foundation/definitions/org-billing.types.d.ts +14 -0
  46. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  47. package/src/lib/foundation/definitions/platform.types.d.ts +9 -0
  48. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  49. package/src/lib/plugin-manager/feature-plugins.d.ts +38 -0
  50. package/src/lib/plugin-manager/feature-plugins.js +20 -0
  51. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  52. package/src/lib/plugin-manager/first-party-plugins.generated.js +22 -1
  53. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  54. package/src/lib/plugin-manager/operator-alerts.js +10 -1
  55. package/src/lib/plugin-manager/operator-alerts.js.map +1 -1
  56. package/src/lib/plugin-manager/plugin-config.js +10 -1
  57. package/src/lib/plugin-manager/plugin-config.js.map +1 -1
  58. package/src/lib/plugin-manager/plugin-consent-group-change.js +11 -2
  59. package/src/lib/plugin-manager/plugin-consent-group-change.js.map +1 -1
  60. package/src/lib/plugin-manager/plugin-console-crons.js +10 -1
  61. package/src/lib/plugin-manager/plugin-console-crons.js.map +1 -1
  62. package/src/lib/plugin-manager/plugin-domain-events.d.ts +4 -3
  63. package/src/lib/plugin-manager/plugin-domain-events.js.map +1 -1
  64. package/src/lib/plugin-manager/plugin-events.js +11 -2
  65. package/src/lib/plugin-manager/plugin-events.js.map +1 -1
  66. package/src/lib/plugin-manager/plugin-lead-conversion.js +10 -1
  67. package/src/lib/plugin-manager/plugin-lead-conversion.js.map +1 -1
  68. package/src/lib/plugin-manager/plugin-media-ingest.d.ts +25 -0
  69. package/src/lib/plugin-manager/plugin-media-ingest.js.map +1 -1
  70. package/src/lib/plugin-manager/plugin-membership-detach.js +10 -1
  71. package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
  72. package/src/lib/plugin-manager/plugin-person-matches.js +10 -1
  73. package/src/lib/plugin-manager/plugin-person-matches.js.map +1 -1
  74. package/src/lib/plugin-manager/plugin-search-sources.d.ts +85 -0
  75. package/src/lib/plugin-manager/plugin-search-sources.js +91 -0
  76. package/src/lib/plugin-manager/plugin-search-sources.js.map +1 -0
  77. package/src/lib/plugin-manager/plugin-services.js +6 -0
  78. package/src/lib/plugin-manager/plugin-services.js.map +1 -1
  79. package/src/lib/plugin-manager/plugin-usage-meters.js +10 -1
  80. package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -1
  81. package/src/lib/plugin-manager/usage-alert-contributors.js +10 -1
  82. package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
@@ -1,4 +1,5 @@
1
1
  import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ var _globalScope_REGISTRY_KEY;
2
3
  /**
3
4
  * @license
4
5
  * Copyright 2026 Aglyn LLC
@@ -28,7 +29,15 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
28
29
  * two plugins, or a plugin and core, from claiming one settings row.
29
30
  * Registering the same type again replaces it in place, so a declarations
30
31
  * module evaluated twice registers once.
31
- */ const registrations = new Map();
32
+ */ /**
33
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
34
+ * The alert types are registered at boot, from `instrumentation.ts`, and Next
35
+ * compiles that file apart from the routes, so the boot and a route each hold
36
+ * their own copy of this module. At module scope, the boot filled its copy and
37
+ * every route read an empty one (AGL-3412, AGL-3868).
38
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:operator-alerts');
39
+ const globalScope = globalThis;
40
+ const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = new Map();
32
41
  const CORE_TYPES = new Set(CORE_OPERATOR_ALERTS.map((entry)=>entry.type));
33
42
  /**
34
43
  * Registers a plugin's alert types. Throws on a type outside the plugin's
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/operator-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CORE_OPERATOR_ALERTS,\n type OperatorAlertDefinition,\n} from '../app-utils/operator-alerts'\n\n/**\n * Operator alerts a PLUGIN contributes (AGL-3377), beside core's own\n * `CORE_OPERATOR_ALERTS`.\n *\n * A plugin declares its alert types here, from its server declarations, in\n * the same shape core uses, and raises them through `raiseOperatorAlert`.\n * Core lists no plugin's types, so a plugin that is not installed adds\n * nothing to Staff → Operator alerts and a new plugin needs no core change.\n *\n * A plugin's types must start with its own id and a dot, which is what keeps\n * two plugins, or a plugin and core, from claiming one settings row.\n * Registering the same type again replaces it in place, so a declarations\n * module evaluated twice registers once.\n */\n\nconst registrations = new Map<string, OperatorAlertDefinition>()\n\nconst CORE_TYPES = new Set(CORE_OPERATOR_ALERTS.map((entry) => entry.type))\n\n/**\n * Registers a plugin's alert types. Throws on a type outside the plugin's\n * namespace or one core already owns: both are a programming error the\n * plugin's own tests should meet, not something to discover in production.\n */\nexport function registerOperatorAlerts(\n definitions: readonly OperatorAlertDefinition[],\n options: { pluginId: string },\n): void {\n const pluginId = String(options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error('operator alerts need a pluginId')\n for (const definition of definitions) {\n const type = String(definition?.type ?? '').trim()\n if (!type.startsWith(`${pluginId}.`) || type.length <= pluginId.length + 1) {\n throw new Error(\n `operator alert \"${type}\" must be named \"${pluginId}.<name>\" by the plugin that registers it`,\n )\n }\n if (CORE_TYPES.has(type)) {\n throw new Error(`operator alert \"${type}\" is core's`)\n }\n registrations.set(type, { ...definition, type, pluginId })\n }\n}\n\n/** Every alert type: core's, then each plugin's in type order. */\nexport function listOperatorAlerts(): OperatorAlertDefinition[] {\n const contributed = [...registrations.values()].sort((a, b) =>\n a.type < b.type ? -1 : a.type > b.type ? 1 : 0,\n )\n return [...CORE_OPERATOR_ALERTS, ...contributed]\n}\n\n/** One alert type, core's or a registered plugin's. */\nexport function getOperatorAlert(\n type: string,\n): OperatorAlertDefinition | undefined {\n return (\n CORE_OPERATOR_ALERTS.find((entry) => entry.type === type) ??\n registrations.get(type)\n )\n}\n\n/**\n * The alert a staff notification of this type is, if any: the registry\n * entry that names it as its `notificationType`. How `notifyStaff` routes a\n * notification it was handed into the alert pipeline.\n */\nexport function operatorAlertForNotificationType(\n notificationType: string,\n): OperatorAlertDefinition | undefined {\n return listOperatorAlerts().find(\n (entry) => entry.notificationType === notificationType && entry.type === notificationType,\n )\n}\n\n/** Test seam: forget every plugin's registration. */\nexport function resetOperatorAlertsForTests(): void {\n registrations.clear()\n}\n"],"names":["CORE_OPERATOR_ALERTS","registrations","Map","CORE_TYPES","Set","map","entry","type","registerOperatorAlerts","definitions","options","pluginId","String","trim","Error","definition","startsWith","length","has","set","listOperatorAlerts","contributed","values","sort","a","b","getOperatorAlert","find","get","operatorAlertForNotificationType","notificationType","resetOperatorAlertsForTests","clear"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,oBAAoB,QAEf,kCAA8B;AAErC;;;;;;;;;;;;;CAaC,GAED,MAAMC,gBAAgB,IAAIC;AAE1B,MAAMC,aAAa,IAAIC,IAAIJ,qBAAqBK,GAAG,CAAC,CAACC,QAAUA,MAAMC,IAAI;AAEzE;;;;CAIC,GACD,OAAO,SAASC,uBACdC,WAA+C,EAC/CC,OAA6B;;IAE7B,MAAMC,WAAWC,eAAOF,2BAAAA,QAASC,QAAQ,mBAAI,IAAIE,IAAI;IACrD,IAAI,CAACF,UAAU,MAAM,IAAIG,MAAM;IAC/B,KAAK,MAAMC,cAAcN,YAAa;;QACpC,MAAMF,OAAOK,gBAAOG,8BAAAA,WAAYR,IAAI,oBAAI,IAAIM,IAAI;QAChD,IAAI,CAACN,KAAKS,UAAU,CAAC,GAAGL,SAAS,CAAC,CAAC,KAAKJ,KAAKU,MAAM,IAAIN,SAASM,MAAM,GAAG,GAAG;YAC1E,MAAM,IAAIH,MACR,CAAC,gBAAgB,EAAEP,KAAK,iBAAiB,EAAEI,SAAS,wCAAwC,CAAC;QAEjG;QACA,IAAIR,WAAWe,GAAG,CAACX,OAAO;YACxB,MAAM,IAAIO,MAAM,CAAC,gBAAgB,EAAEP,KAAK,WAAW,CAAC;QACtD;QACAN,cAAckB,GAAG,CAACZ,MAAM,aAAKQ;YAAYR;YAAMI;;IACjD;AACF;AAEA,gEAAgE,GAChE,OAAO,SAASS;IACd,MAAMC,cAAc;WAAIpB,cAAcqB,MAAM;KAAG,CAACC,IAAI,CAAC,CAACC,GAAGC,IACvDD,EAAEjB,IAAI,GAAGkB,EAAElB,IAAI,GAAG,CAAC,IAAIiB,EAAEjB,IAAI,GAAGkB,EAAElB,IAAI,GAAG,IAAI;IAE/C,OAAO;WAAIP;WAAyBqB;KAAY;AAClD;AAEA,qDAAqD,GACrD,OAAO,SAASK,iBACdnB,IAAY;QAGVP;IADF,QACEA,6BAAAA,qBAAqB2B,IAAI,CAAC,CAACrB,QAAUA,MAAMC,IAAI,KAAKA,iBAApDP,6BACAC,cAAc2B,GAAG,CAACrB;AAEtB;AAEA;;;;CAIC,GACD,OAAO,SAASsB,iCACdC,gBAAwB;IAExB,OAAOV,qBAAqBO,IAAI,CAC9B,CAACrB,QAAUA,MAAMwB,gBAAgB,KAAKA,oBAAoBxB,MAAMC,IAAI,KAAKuB;AAE7E;AAEA,mDAAmD,GACnD,OAAO,SAASC;IACd9B,cAAc+B,KAAK;AACrB"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/operator-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CORE_OPERATOR_ALERTS,\n type OperatorAlertDefinition,\n} from '../app-utils/operator-alerts'\n\n/**\n * Operator alerts a PLUGIN contributes (AGL-3377), beside core's own\n * `CORE_OPERATOR_ALERTS`.\n *\n * A plugin declares its alert types here, from its server declarations, in\n * the same shape core uses, and raises them through `raiseOperatorAlert`.\n * Core lists no plugin's types, so a plugin that is not installed adds\n * nothing to Staff → Operator alerts and a new plugin needs no core change.\n *\n * A plugin's types must start with its own id and a dot, which is what keeps\n * two plugins, or a plugin and core, from claiming one settings row.\n * Registering the same type again replaces it in place, so a declarations\n * module evaluated twice registers once.\n */\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The alert types are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:operator-alerts')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Map<string, OperatorAlertDefinition>\n}\nconst registrations: Map<string, OperatorAlertDefinition> =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = new Map())\n\nconst CORE_TYPES = new Set(CORE_OPERATOR_ALERTS.map((entry) => entry.type))\n\n/**\n * Registers a plugin's alert types. Throws on a type outside the plugin's\n * namespace or one core already owns: both are a programming error the\n * plugin's own tests should meet, not something to discover in production.\n */\nexport function registerOperatorAlerts(\n definitions: readonly OperatorAlertDefinition[],\n options: { pluginId: string },\n): void {\n const pluginId = String(options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error('operator alerts need a pluginId')\n for (const definition of definitions) {\n const type = String(definition?.type ?? '').trim()\n if (!type.startsWith(`${pluginId}.`) || type.length <= pluginId.length + 1) {\n throw new Error(\n `operator alert \"${type}\" must be named \"${pluginId}.<name>\" by the plugin that registers it`,\n )\n }\n if (CORE_TYPES.has(type)) {\n throw new Error(`operator alert \"${type}\" is core's`)\n }\n registrations.set(type, { ...definition, type, pluginId })\n }\n}\n\n/** Every alert type: core's, then each plugin's in type order. */\nexport function listOperatorAlerts(): OperatorAlertDefinition[] {\n const contributed = [...registrations.values()].sort((a, b) =>\n a.type < b.type ? -1 : a.type > b.type ? 1 : 0,\n )\n return [...CORE_OPERATOR_ALERTS, ...contributed]\n}\n\n/** One alert type, core's or a registered plugin's. */\nexport function getOperatorAlert(\n type: string,\n): OperatorAlertDefinition | undefined {\n return (\n CORE_OPERATOR_ALERTS.find((entry) => entry.type === type) ??\n registrations.get(type)\n )\n}\n\n/**\n * The alert a staff notification of this type is, if any: the registry\n * entry that names it as its `notificationType`. How `notifyStaff` routes a\n * notification it was handed into the alert pipeline.\n */\nexport function operatorAlertForNotificationType(\n notificationType: string,\n): OperatorAlertDefinition | undefined {\n return listOperatorAlerts().find(\n (entry) => entry.notificationType === notificationType && entry.type === notificationType,\n )\n}\n\n/** Test seam: forget every plugin's registration. */\nexport function resetOperatorAlertsForTests(): void {\n registrations.clear()\n}\n"],"names":["globalScope","CORE_OPERATOR_ALERTS","REGISTRY_KEY","Symbol","for","globalThis","registrations","Map","CORE_TYPES","Set","map","entry","type","registerOperatorAlerts","definitions","options","pluginId","String","trim","Error","definition","startsWith","length","has","set","listOperatorAlerts","contributed","values","sort","a","b","getOperatorAlert","find","get","operatorAlertForNotificationType","notificationType","resetOperatorAlertsForTests","clear"],"mappings":";IAiDEA;AAjDF;;;;;;;;;;;;;;;CAeC,GAED,SACEC,oBAAoB,QAEf,kCAA8B;AAErC;;;;;;;;;;;;;CAaC,GAED;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,iBACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,IAAIK;AAEhE,MAAMC,aAAa,IAAIC,IAAIR,qBAAqBS,GAAG,CAAC,CAACC,QAAUA,MAAMC,IAAI;AAEzE;;;;CAIC,GACD,OAAO,SAASC,uBACdC,WAA+C,EAC/CC,OAA6B;;IAE7B,MAAMC,WAAWC,eAAOF,2BAAAA,QAASC,QAAQ,mBAAI,IAAIE,IAAI;IACrD,IAAI,CAACF,UAAU,MAAM,IAAIG,MAAM;IAC/B,KAAK,MAAMC,cAAcN,YAAa;;QACpC,MAAMF,OAAOK,gBAAOG,8BAAAA,WAAYR,IAAI,oBAAI,IAAIM,IAAI;QAChD,IAAI,CAACN,KAAKS,UAAU,CAAC,GAAGL,SAAS,CAAC,CAAC,KAAKJ,KAAKU,MAAM,IAAIN,SAASM,MAAM,GAAG,GAAG;YAC1E,MAAM,IAAIH,MACR,CAAC,gBAAgB,EAAEP,KAAK,iBAAiB,EAAEI,SAAS,wCAAwC,CAAC;QAEjG;QACA,IAAIR,WAAWe,GAAG,CAACX,OAAO;YACxB,MAAM,IAAIO,MAAM,CAAC,gBAAgB,EAAEP,KAAK,WAAW,CAAC;QACtD;QACAN,cAAckB,GAAG,CAACZ,MAAM,aAAKQ;YAAYR;YAAMI;;IACjD;AACF;AAEA,gEAAgE,GAChE,OAAO,SAASS;IACd,MAAMC,cAAc;WAAIpB,cAAcqB,MAAM;KAAG,CAACC,IAAI,CAAC,CAACC,GAAGC,IACvDD,EAAEjB,IAAI,GAAGkB,EAAElB,IAAI,GAAG,CAAC,IAAIiB,EAAEjB,IAAI,GAAGkB,EAAElB,IAAI,GAAG,IAAI;IAE/C,OAAO;WAAIX;WAAyByB;KAAY;AAClD;AAEA,qDAAqD,GACrD,OAAO,SAASK,iBACdnB,IAAY;QAGVX;IADF,QACEA,6BAAAA,qBAAqB+B,IAAI,CAAC,CAACrB,QAAUA,MAAMC,IAAI,KAAKA,iBAApDX,6BACAK,cAAc2B,GAAG,CAACrB;AAEtB;AAEA;;;;CAIC,GACD,OAAO,SAASsB,iCACdC,gBAAwB;IAExB,OAAOV,qBAAqBO,IAAI,CAC9B,CAACrB,QAAUA,MAAMwB,gBAAgB,KAAKA,oBAAoBxB,MAAMC,IAAI,KAAKuB;AAE7E;AAEA,mDAAmD,GACnD,OAAO,SAASC;IACd9B,cAAc+B,KAAK;AACrB"}
@@ -31,7 +31,16 @@
31
31
  * there. Values are coerced defensively on merge — the settings doc is
32
32
  * client-writable (org managers), so readers never trust its types.
33
33
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
34
- const schemas = new Map();
34
+ var _globalScope_REGISTRY_KEY;
35
+ /**
36
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
37
+ * The config schemas are registered at boot, from `instrumentation.ts`, and Next
38
+ * compiles that file apart from the routes, so the boot and a route each hold
39
+ * their own copy of this module. At module scope, the boot filled its copy and
40
+ * every route read an empty one (AGL-3412, AGL-3868).
41
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-config-schemas');
42
+ const globalScope = globalThis;
43
+ const schemas = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = new Map();
35
44
  /** Idempotent by pluginId — re-registration replaces the schema. */ export function registerPluginConfigSchema(schema) {
36
45
  schemas.set(schema.pluginId, schema);
37
46
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-config.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 * Per-plugin configuration (AGL-428) — Strapi `config` parity. A plugin\n * DECLARES its settings schema (fields + defaults + validation) at module\n * scope; the org stores overrides in `orgs/{orgId}/pluginSettings/\n * {pluginId}`; every consumer reads the DEFAULTS-MERGED view:\n *\n * - server handlers via `getPluginConfig(orgId, pluginId)`\n * (tenant-data-admin);\n * - client surfaces via `usePluginConfig(orgId, pluginId)`\n * (tenant-feature-instance);\n * - the console's Plugins & add-ons hub renders a generic settings form\n * from the schema, so a plugin gets a settings UI without writing one.\n *\n * Register at MODULE SCOPE in a file imported by both the client barrel\n * and the `/server` entry, so whichever surface loads first the schema is\n * there. Values are coerced defensively on merge — the settings doc is\n * client-writable (org managers), so readers never trust its types.\n */\n\nexport interface PluginConfigField {\n key: string\n label: string\n type: 'string' | 'number' | 'boolean' | 'select'\n description?: string\n /** For `select` fields. */\n options?: Array<{ value: string; label: string }>\n /** For `number` fields. */\n min?: number\n max?: number\n}\n\nexport interface PluginConfigSchema {\n pluginId: string\n fields: PluginConfigField[]\n defaults: Record<string, unknown>\n /**\n * What an operator must read before filling the form in (AGL-3700) — a\n * limit the settings cannot express, such as what a translation does NOT\n * do for search. Shown above the fields at both scopes.\n */\n notice?: string\n /**\n * The settings change what a published page serves (AGL-3700). A site-scope\n * save then drops that site's cached pages, so the change is live at once\n * rather than within the page cache's window.\n */\n affectsPublishedPages?: boolean\n /**\n * The settings card's own docs heading (AGL-3700): a docs help topic key and\n * one of that page's anchors. Each card's `?` names its own section, so a\n * plugin that documents its settings points here; without it the card links\n * the generic plugin-settings section. Names only — the console resolves\n * them against its registry, so no docs prose rides a site bundle.\n */\n help?: { topic: string; anchor: `#${string}` }\n /** Cross-field validation; returns an error message or null. */\n validate?: (values: Record<string, unknown>) => string | null\n}\n\nconst schemas = new Map<string, PluginConfigSchema>()\n\n/** Idempotent by pluginId — re-registration replaces the schema. */\nexport function registerPluginConfigSchema(schema: PluginConfigSchema): void {\n schemas.set(schema.pluginId, schema)\n}\n\nexport function getPluginConfigSchema(\n pluginId: string,\n): PluginConfigSchema | undefined {\n return schemas.get(pluginId)\n}\n\nexport function listPluginConfigSchemas(): PluginConfigSchema[] {\n return [...schemas.values()]\n}\n\nconst coerce = (\n field: PluginConfigField,\n raw: unknown,\n fallback: unknown,\n): unknown => {\n switch (field.type) {\n case 'boolean':\n return typeof raw === 'boolean' ? raw : fallback\n case 'number': {\n const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : NaN\n if (Number.isNaN(value)) return fallback\n if (field.min != null && value < field.min) return field.min\n if (field.max != null && value > field.max) return field.max\n return value\n }\n case 'select': {\n const value = typeof raw === 'string' ? raw : undefined\n return field.options?.some((option) => option.value === value)\n ? value\n : fallback\n }\n default:\n return typeof raw === 'string' ? raw : fallback\n }\n}\n\n/**\n * The defaults-merged, type-coerced config view. Safe against a missing\n * or junk settings doc — unknown keys are dropped, wrong-typed values\n * fall back to the declared default.\n */\nexport function mergePluginConfig(\n schema: PluginConfigSchema,\n stored: Record<string, unknown> | null | undefined,\n): Record<string, unknown> {\n const merged: Record<string, unknown> = { ...schema.defaults }\n if (!stored) return merged\n for (const field of schema.fields) {\n if (field.key in stored) {\n merged[field.key] = coerce(\n field,\n stored[field.key],\n schema.defaults[field.key],\n )\n }\n }\n return merged\n}\n\n/** Pre-save validation for the settings UI (coerce + custom validate). */\nexport function validatePluginConfigValues(\n schema: PluginConfigSchema,\n values: Record<string, unknown>,\n): { ok: boolean; error?: string } {\n const merged = mergePluginConfig(schema, values)\n const error = schema.validate?.(merged) ?? null\n return error ? { ok: false, error } : { ok: true }\n}\n\nconst MANIFEST_CONFIG_FIELD_LIMIT = 50\nconst FIELD_TYPES: ReadonlySet<string> = new Set([\n 'string',\n 'number',\n 'boolean',\n 'select',\n])\n\n/**\n * A plugin's declared fields, read from its MANIFEST rather than the registry\n * (AGL-428).\n *\n * The registry is a module-scope map, filled by `registerPluginConfigSchema`\n * at import time. A marketplace plugin cannot fill it: its bundle runs in a\n * sandboxed frame on a separate origin and never executes in the console\n * process, so there is no moment at which its registration could happen. The\n * settings form therefore rendered NOTHING for every plugin a workspace\n * installed, and `resolvePluginConfig` — which is entirely plugin-agnostic and\n * would have layered a third-party plugin's org default under a per-site\n * override without changing a line — was never reached for one.\n *\n * The manifest is denormalized onto every install pin, so it is the one\n * declaration of a marketplace plugin the console already holds.\n *\n * Everything here is re-validated rather than trusted. The manifest is\n * publisher-authored, it is stored on a client-readable document, and a form\n * built from it is a form an org manager saves through — so a junk `type`\n * drops the field rather than rendering an unknown control, and the field\n * count is capped so one manifest cannot turn a settings page into thousands\n * of inputs. Being reviewed once is not the same as being safe to render\n * verbatim forever.\n *\n * Returns `undefined` when the manifest declares nothing usable, which callers\n * must treat as \"this plugin has no settings\" rather than as an error: most\n * plugins take none.\n */\nexport function pluginConfigSchemaFromManifest(\n pluginId: string,\n manifest: unknown,\n): PluginConfigSchema | undefined {\n if (!pluginId) return undefined\n const declared = (manifest as { config?: unknown } | null | undefined)?.config\n const rawFields = (declared as { fields?: unknown } | undefined)?.fields\n if (!Array.isArray(rawFields)) return undefined\n const seen = new Set<string>()\n const fields: PluginConfigField[] = []\n for (const raw of rawFields) {\n if (fields.length >= MANIFEST_CONFIG_FIELD_LIMIT) break\n const entry = raw as Record<string, unknown> | null\n const key = typeof entry?.['key'] === 'string' ? entry['key'].trim() : ''\n // A duplicate key would give two controls one storage slot, so the second\n // is dropped rather than allowed to shadow the first.\n if (!key || seen.has(key)) continue\n const type = String(entry?.['type'] ?? 'string')\n if (!FIELD_TYPES.has(type)) continue\n const options = Array.isArray(entry?.['options'])\n ? (entry['options'] as unknown[])\n .map((option) => option as Record<string, unknown> | null)\n .filter(\n (option) =>\n typeof option?.['value'] === 'string' &&\n typeof option?.['label'] === 'string',\n )\n .map((option) => ({\n value: String(option?.['value']),\n label: String(option?.['label']),\n }))\n : undefined\n // A `select` with nothing to select is a dropdown that cannot be answered,\n // so it is dropped rather than rendered empty.\n if (type === 'select' && !options?.length) continue\n seen.add(key)\n fields.push({\n key,\n label:\n typeof entry?.['label'] === 'string' && entry['label'].trim()\n ? String(entry['label']).slice(0, 120)\n : key,\n type: type as PluginConfigField['type'],\n description:\n typeof entry?.['description'] === 'string'\n ? String(entry['description']).slice(0, 400)\n : undefined,\n options,\n min: typeof entry?.['min'] === 'number' ? entry['min'] : undefined,\n max: typeof entry?.['max'] === 'number' ? entry['max'] : undefined,\n })\n }\n if (!fields.length) return undefined\n const rawDefaults = (declared as { defaults?: unknown } | undefined)?.defaults\n const defaults: Record<string, unknown> = {}\n for (const field of fields) {\n // Defaults are coerced through the same path stored values take, so a\n // manifest default of the wrong type cannot become the value a site\n // inherits. An absent one falls back to the type's empty value rather than\n // `undefined`, which `pluginConfigOverrides` reads as \"not answered\".\n const supplied =\n rawDefaults && typeof rawDefaults === 'object'\n ? (rawDefaults as Record<string, unknown>)[field.key]\n : undefined\n const empty =\n field.type === 'boolean'\n ? false\n : field.type === 'number'\n ? (field.min ?? 0)\n : field.type === 'select'\n ? (field.options?.[0]?.value ?? '')\n : ''\n defaults[field.key] = coerce(field, supplied, empty)\n }\n return { pluginId, fields, defaults }\n}\n\n/**\n * The three levels a plugin setting can be answered at.\n *\n * A workspace sets a value once and every site it enabled the plugin on\n * follows it; a site that needs a different answer overrides that one field\n * and keeps inheriting the rest. Bookings is the shape this is for — one\n * booking horizon across a chain, with the flagship branch taking bookings\n * further out — and it generalizes to every plugin because none of them get\n * to invent their own inheritance.\n */\nexport interface PluginConfigSources {\n /** `orgs/{orgId}/pluginSettings/{pluginId}` — the workspace's answer. */\n org?: Record<string, unknown> | null\n /** `hosts/{hostId}/pluginSettings/{pluginId}` — only the keys this site overrides. */\n host?: Record<string, unknown> | null\n}\n\n/**\n * Which keys a site is answering for itself.\n *\n * A key is an override when it is PRESENT on the host document, and inherited\n * when it is absent. `undefined` counts as absent, deliberately: a UI that\n * writes `{key: undefined}` to mean \"stop overriding\" is doing the same thing\n * as one that never wrote the key, and treating the two differently would make\n * \"revert to the workspace value\" depend on which code path cleared it.\n *\n * ⚠️ Which is also why clearing an override must DELETE the key rather than\n * write an empty value. `setDoc(..., {merge: true})` leaves an existing field\n * exactly as it is when the new object omits it, so a form that drops empty\n * inputs cannot clear anything by saving — the override survives, invisibly,\n * and the site keeps ignoring a workspace value the operator has since\n * changed. Deleting is the only write that returns a field to inherited.\n *\n * Keys the schema does not declare are ignored, so a stale field left behind\n * by a plugin update does not read as an override of a setting that no longer\n * exists.\n */\nexport function pluginConfigOverrides(\n schema: PluginConfigSchema,\n host: Record<string, unknown> | null | undefined,\n): string[] {\n if (!host) return []\n return schema.fields\n .filter((field) => field.key in host && host[field.key] !== undefined)\n .map((field) => field.key)\n}\n\n/**\n * What a plugin actually runs with on one site.\n *\n * Schema defaults, then the workspace's stored values, then only the keys the\n * site overrides — each layer narrowing the one before it. Type coercion\n * happens once at the end rather than per level, so a site override of the\n * wrong type falls back to the WORKSPACE value it was trying to replace\n * instead of skipping past it to the schema default. Getting that backwards\n * would have one malformed site setting silently discard a workspace answer\n * the operator can see in their own console.\n */\nexport function resolvePluginConfig(\n schema: PluginConfigSchema,\n sources: PluginConfigSources,\n): Record<string, unknown> {\n const org = mergePluginConfig(schema, sources.org)\n const overrides = pluginConfigOverrides(schema, sources.host)\n if (!overrides.length) return org\n const host = sources.host as Record<string, unknown>\n const merged: Record<string, unknown> = { ...org }\n for (const field of schema.fields) {\n if (!overrides.includes(field.key)) continue\n merged[field.key] = coerce(field, host[field.key], org[field.key])\n }\n return merged\n}\n"],"names":["schemas","Map","registerPluginConfigSchema","schema","set","pluginId","getPluginConfigSchema","get","listPluginConfigSchemas","values","coerce","field","raw","fallback","type","value","Number","isFinite","NaN","isNaN","min","max","undefined","options","some","option","mergePluginConfig","stored","merged","defaults","fields","key","validatePluginConfigValues","error","validate","ok","MANIFEST_CONFIG_FIELD_LIMIT","FIELD_TYPES","Set","pluginConfigSchemaFromManifest","manifest","declared","config","rawFields","Array","isArray","seen","length","entry","trim","has","String","map","filter","label","add","push","slice","description","rawDefaults","supplied","empty","pluginConfigOverrides","host","resolvePluginConfig","sources","org","overrides","includes"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-config.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 * Per-plugin configuration (AGL-428) — Strapi `config` parity. A plugin\n * DECLARES its settings schema (fields + defaults + validation) at module\n * scope; the org stores overrides in `orgs/{orgId}/pluginSettings/\n * {pluginId}`; every consumer reads the DEFAULTS-MERGED view:\n *\n * - server handlers via `getPluginConfig(orgId, pluginId)`\n * (tenant-data-admin);\n * - client surfaces via `usePluginConfig(orgId, pluginId)`\n * (tenant-feature-instance);\n * - the console's Plugins & add-ons hub renders a generic settings form\n * from the schema, so a plugin gets a settings UI without writing one.\n *\n * Register at MODULE SCOPE in a file imported by both the client barrel\n * and the `/server` entry, so whichever surface loads first the schema is\n * there. Values are coerced defensively on merge — the settings doc is\n * client-writable (org managers), so readers never trust its types.\n */\n\nexport interface PluginConfigField {\n key: string\n label: string\n type: 'string' | 'number' | 'boolean' | 'select'\n description?: string\n /** For `select` fields. */\n options?: Array<{ value: string; label: string }>\n /** For `number` fields. */\n min?: number\n max?: number\n}\n\nexport interface PluginConfigSchema {\n pluginId: string\n fields: PluginConfigField[]\n defaults: Record<string, unknown>\n /**\n * What an operator must read before filling the form in (AGL-3700) — a\n * limit the settings cannot express, such as what a translation does NOT\n * do for search. Shown above the fields at both scopes.\n */\n notice?: string\n /**\n * The settings change what a published page serves (AGL-3700). A site-scope\n * save then drops that site's cached pages, so the change is live at once\n * rather than within the page cache's window.\n */\n affectsPublishedPages?: boolean\n /**\n * The settings card's own docs heading (AGL-3700): a docs help topic key and\n * one of that page's anchors. Each card's `?` names its own section, so a\n * plugin that documents its settings points here; without it the card links\n * the generic plugin-settings section. Names only — the console resolves\n * them against its registry, so no docs prose rides a site bundle.\n */\n help?: { topic: string; anchor: `#${string}` }\n /** Cross-field validation; returns an error message or null. */\n validate?: (values: Record<string, unknown>) => string | null\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The config schemas are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-config-schemas')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Map<string, PluginConfigSchema>\n}\nconst schemas: Map<string, PluginConfigSchema> =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = new Map())\n\n/** Idempotent by pluginId — re-registration replaces the schema. */\nexport function registerPluginConfigSchema(schema: PluginConfigSchema): void {\n schemas.set(schema.pluginId, schema)\n}\n\nexport function getPluginConfigSchema(\n pluginId: string,\n): PluginConfigSchema | undefined {\n return schemas.get(pluginId)\n}\n\nexport function listPluginConfigSchemas(): PluginConfigSchema[] {\n return [...schemas.values()]\n}\n\nconst coerce = (\n field: PluginConfigField,\n raw: unknown,\n fallback: unknown,\n): unknown => {\n switch (field.type) {\n case 'boolean':\n return typeof raw === 'boolean' ? raw : fallback\n case 'number': {\n const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : NaN\n if (Number.isNaN(value)) return fallback\n if (field.min != null && value < field.min) return field.min\n if (field.max != null && value > field.max) return field.max\n return value\n }\n case 'select': {\n const value = typeof raw === 'string' ? raw : undefined\n return field.options?.some((option) => option.value === value)\n ? value\n : fallback\n }\n default:\n return typeof raw === 'string' ? raw : fallback\n }\n}\n\n/**\n * The defaults-merged, type-coerced config view. Safe against a missing\n * or junk settings doc — unknown keys are dropped, wrong-typed values\n * fall back to the declared default.\n */\nexport function mergePluginConfig(\n schema: PluginConfigSchema,\n stored: Record<string, unknown> | null | undefined,\n): Record<string, unknown> {\n const merged: Record<string, unknown> = { ...schema.defaults }\n if (!stored) return merged\n for (const field of schema.fields) {\n if (field.key in stored) {\n merged[field.key] = coerce(\n field,\n stored[field.key],\n schema.defaults[field.key],\n )\n }\n }\n return merged\n}\n\n/** Pre-save validation for the settings UI (coerce + custom validate). */\nexport function validatePluginConfigValues(\n schema: PluginConfigSchema,\n values: Record<string, unknown>,\n): { ok: boolean; error?: string } {\n const merged = mergePluginConfig(schema, values)\n const error = schema.validate?.(merged) ?? null\n return error ? { ok: false, error } : { ok: true }\n}\n\nconst MANIFEST_CONFIG_FIELD_LIMIT = 50\nconst FIELD_TYPES: ReadonlySet<string> = new Set([\n 'string',\n 'number',\n 'boolean',\n 'select',\n])\n\n/**\n * A plugin's declared fields, read from its MANIFEST rather than the registry\n * (AGL-428).\n *\n * The registry is a module-scope map, filled by `registerPluginConfigSchema`\n * at import time. A marketplace plugin cannot fill it: its bundle runs in a\n * sandboxed frame on a separate origin and never executes in the console\n * process, so there is no moment at which its registration could happen. The\n * settings form therefore rendered NOTHING for every plugin a workspace\n * installed, and `resolvePluginConfig` — which is entirely plugin-agnostic and\n * would have layered a third-party plugin's org default under a per-site\n * override without changing a line — was never reached for one.\n *\n * The manifest is denormalized onto every install pin, so it is the one\n * declaration of a marketplace plugin the console already holds.\n *\n * Everything here is re-validated rather than trusted. The manifest is\n * publisher-authored, it is stored on a client-readable document, and a form\n * built from it is a form an org manager saves through — so a junk `type`\n * drops the field rather than rendering an unknown control, and the field\n * count is capped so one manifest cannot turn a settings page into thousands\n * of inputs. Being reviewed once is not the same as being safe to render\n * verbatim forever.\n *\n * Returns `undefined` when the manifest declares nothing usable, which callers\n * must treat as \"this plugin has no settings\" rather than as an error: most\n * plugins take none.\n */\nexport function pluginConfigSchemaFromManifest(\n pluginId: string,\n manifest: unknown,\n): PluginConfigSchema | undefined {\n if (!pluginId) return undefined\n const declared = (manifest as { config?: unknown } | null | undefined)?.config\n const rawFields = (declared as { fields?: unknown } | undefined)?.fields\n if (!Array.isArray(rawFields)) return undefined\n const seen = new Set<string>()\n const fields: PluginConfigField[] = []\n for (const raw of rawFields) {\n if (fields.length >= MANIFEST_CONFIG_FIELD_LIMIT) break\n const entry = raw as Record<string, unknown> | null\n const key = typeof entry?.['key'] === 'string' ? entry['key'].trim() : ''\n // A duplicate key would give two controls one storage slot, so the second\n // is dropped rather than allowed to shadow the first.\n if (!key || seen.has(key)) continue\n const type = String(entry?.['type'] ?? 'string')\n if (!FIELD_TYPES.has(type)) continue\n const options = Array.isArray(entry?.['options'])\n ? (entry['options'] as unknown[])\n .map((option) => option as Record<string, unknown> | null)\n .filter(\n (option) =>\n typeof option?.['value'] === 'string' &&\n typeof option?.['label'] === 'string',\n )\n .map((option) => ({\n value: String(option?.['value']),\n label: String(option?.['label']),\n }))\n : undefined\n // A `select` with nothing to select is a dropdown that cannot be answered,\n // so it is dropped rather than rendered empty.\n if (type === 'select' && !options?.length) continue\n seen.add(key)\n fields.push({\n key,\n label:\n typeof entry?.['label'] === 'string' && entry['label'].trim()\n ? String(entry['label']).slice(0, 120)\n : key,\n type: type as PluginConfigField['type'],\n description:\n typeof entry?.['description'] === 'string'\n ? String(entry['description']).slice(0, 400)\n : undefined,\n options,\n min: typeof entry?.['min'] === 'number' ? entry['min'] : undefined,\n max: typeof entry?.['max'] === 'number' ? entry['max'] : undefined,\n })\n }\n if (!fields.length) return undefined\n const rawDefaults = (declared as { defaults?: unknown } | undefined)?.defaults\n const defaults: Record<string, unknown> = {}\n for (const field of fields) {\n // Defaults are coerced through the same path stored values take, so a\n // manifest default of the wrong type cannot become the value a site\n // inherits. An absent one falls back to the type's empty value rather than\n // `undefined`, which `pluginConfigOverrides` reads as \"not answered\".\n const supplied =\n rawDefaults && typeof rawDefaults === 'object'\n ? (rawDefaults as Record<string, unknown>)[field.key]\n : undefined\n const empty =\n field.type === 'boolean'\n ? false\n : field.type === 'number'\n ? (field.min ?? 0)\n : field.type === 'select'\n ? (field.options?.[0]?.value ?? '')\n : ''\n defaults[field.key] = coerce(field, supplied, empty)\n }\n return { pluginId, fields, defaults }\n}\n\n/**\n * The three levels a plugin setting can be answered at.\n *\n * A workspace sets a value once and every site it enabled the plugin on\n * follows it; a site that needs a different answer overrides that one field\n * and keeps inheriting the rest. Bookings is the shape this is for — one\n * booking horizon across a chain, with the flagship branch taking bookings\n * further out — and it generalizes to every plugin because none of them get\n * to invent their own inheritance.\n */\nexport interface PluginConfigSources {\n /** `orgs/{orgId}/pluginSettings/{pluginId}` — the workspace's answer. */\n org?: Record<string, unknown> | null\n /** `hosts/{hostId}/pluginSettings/{pluginId}` — only the keys this site overrides. */\n host?: Record<string, unknown> | null\n}\n\n/**\n * Which keys a site is answering for itself.\n *\n * A key is an override when it is PRESENT on the host document, and inherited\n * when it is absent. `undefined` counts as absent, deliberately: a UI that\n * writes `{key: undefined}` to mean \"stop overriding\" is doing the same thing\n * as one that never wrote the key, and treating the two differently would make\n * \"revert to the workspace value\" depend on which code path cleared it.\n *\n * ⚠️ Which is also why clearing an override must DELETE the key rather than\n * write an empty value. `setDoc(..., {merge: true})` leaves an existing field\n * exactly as it is when the new object omits it, so a form that drops empty\n * inputs cannot clear anything by saving — the override survives, invisibly,\n * and the site keeps ignoring a workspace value the operator has since\n * changed. Deleting is the only write that returns a field to inherited.\n *\n * Keys the schema does not declare are ignored, so a stale field left behind\n * by a plugin update does not read as an override of a setting that no longer\n * exists.\n */\nexport function pluginConfigOverrides(\n schema: PluginConfigSchema,\n host: Record<string, unknown> | null | undefined,\n): string[] {\n if (!host) return []\n return schema.fields\n .filter((field) => field.key in host && host[field.key] !== undefined)\n .map((field) => field.key)\n}\n\n/**\n * What a plugin actually runs with on one site.\n *\n * Schema defaults, then the workspace's stored values, then only the keys the\n * site overrides — each layer narrowing the one before it. Type coercion\n * happens once at the end rather than per level, so a site override of the\n * wrong type falls back to the WORKSPACE value it was trying to replace\n * instead of skipping past it to the schema default. Getting that backwards\n * would have one malformed site setting silently discard a workspace answer\n * the operator can see in their own console.\n */\nexport function resolvePluginConfig(\n schema: PluginConfigSchema,\n sources: PluginConfigSources,\n): Record<string, unknown> {\n const org = mergePluginConfig(schema, sources.org)\n const overrides = pluginConfigOverrides(schema, sources.host)\n if (!overrides.length) return org\n const host = sources.host as Record<string, unknown>\n const merged: Record<string, unknown> = { ...org }\n for (const field of schema.fields) {\n if (!overrides.includes(field.key)) continue\n merged[field.key] = coerce(field, host[field.key], org[field.key])\n }\n return merged\n}\n"],"names":["globalScope","REGISTRY_KEY","Symbol","for","globalThis","schemas","Map","registerPluginConfigSchema","schema","set","pluginId","getPluginConfigSchema","get","listPluginConfigSchemas","values","coerce","field","raw","fallback","type","value","Number","isFinite","NaN","isNaN","min","max","undefined","options","some","option","mergePluginConfig","stored","merged","defaults","fields","key","validatePluginConfigValues","error","validate","ok","MANIFEST_CONFIG_FIELD_LIMIT","FIELD_TYPES","Set","pluginConfigSchemaFromManifest","manifest","declared","config","rawFields","Array","isArray","seen","length","entry","trim","has","String","map","filter","label","add","push","slice","description","rawDefaults","supplied","empty","pluginConfigOverrides","host","resolvePluginConfig","sources","org","overrides","includes"],"mappings":"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"}
@@ -54,8 +54,17 @@
54
54
  *
55
55
  * Registered from a plugin's declarations, like the erasure seams, so the
56
56
  * participant is in place in a process that never loaded the plugin's API.
57
- */ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
58
- const registrations = [];
57
+ */ var _globalScope_REGISTRY_KEY;
58
+ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
59
+ /**
60
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
61
+ * The participants are registered at boot, from `instrumentation.ts`, and Next
62
+ * compiles that file apart from the routes, so the boot and a route each hold
63
+ * their own copy of this module. At module scope, the boot filled its copy and
64
+ * every route read an empty one (AGL-3412, AGL-3868).
65
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-consent-group-participants');
66
+ const globalScope = globalThis;
67
+ const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = [];
59
68
  /**
60
69
  * Registers a plugin's participant. Owner = the loader's marker inside a
61
70
  * register fn, else `options.pluginId`; a participant with neither throws.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-consent-group-change.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 * A plugin's share of a CONSENT GROUP CHANGE (AGL-3320).\n *\n * The executor in `libs/tenant/data/admin` carries the refusals the platform\n * stores per site — the suppression list, the topic opt-outs, the pace — and\n * flips the declaration. A plugin that keeps its own per-site refusals, or\n * records keyed by a consent group's id, registers a participant here, and\n * every change runs it at three points:\n *\n * - `carry`, BEFORE the flip: copy the plugin's refusals onto the sites the\n * plan's carries name, so no site stops honoring an opt-out it was reading\n * across its group. Run twice — once, and again as the catch-up.\n * - `rehome`, AFTER the flip: move the records keyed by the plan's holder\n * flows. After, because every writer keys by the declaration in force, so\n * new writes land on the final key while the move folds the old one in.\n * - `sweep`, once the sweep delay has passed: the whole job again,\n * idempotently, for anything a long job wrote under the old declaration.\n *\n * ## NOT ISOLATED, on purpose\n *\n * The erasure seams log a throw and go on (`plugin-person-erasure.ts`), because\n * a partial erasure is better than none. Here the opposite holds: a skipped\n * carry is a refusal nobody honors once the flip happens. So a participant's\n * throw propagates, the executor records it against the job, and the change\n * does not flip until the participant succeeds — five failures in a row mark\n * the job stalled, and it keeps retrying.\n *\n * The PREVIEW is the exception, because it decides nothing: a participant\n * whose preview throws shows `null` lines and the review step says so.\n *\n * ## The contract a `run` keeps\n *\n * Resumable and idempotent. The executor hands back the `cursor` the last\n * call returned (`null` to start), and stops calling once `done` is true. A\n * run must return before `deadlineMs`, keep every write create-if-absent or a\n * restrictive merge, and write nothing on `dryRun`. A second full run over a\n * finished change must write nothing, because the catch-up and the sweep ARE\n * second runs.\n *\n * Registered from a plugin's declarations, like the erasure seams, so the\n * participant is in place in a process that never loaded the plugin's API.\n */\n\nimport type {\n ConsentGroupChangePlan,\n ConsentGroupChangePreviewLine,\n} from '../app-utils/consent-group-change'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Which point of the change a `run` is called for. */\nexport type ConsentGroupChangeParticipantPhase = 'carry' | 'rehome' | 'sweep'\n\nexport interface ConsentGroupChangePreviewRequest {\n orgId: string\n plan: ConsentGroupChangePlan\n}\n\nexport interface ConsentGroupChangeRunRequest {\n orgId: string\n changeId: string\n plan: ConsentGroupChangePlan\n phase: ConsentGroupChangeParticipantPhase\n /** Where the previous call stopped, or `null` to start the phase. */\n cursor: string | null\n /** Return by this instant; the executor calls again from the cursor. */\n deadlineMs: number\n /** Count, write nothing. */\n dryRun: boolean\n}\n\nexport interface ConsentGroupChangeRunResult {\n done: boolean\n cursor: string | null\n /** What THIS call did — the executor adds them up across calls. */\n counts: Record<string, number>\n}\n\nexport interface ConsentGroupChangeParticipant {\n preview(request: ConsentGroupChangePreviewRequest): Promise<ConsentGroupChangePreviewLine[]>\n run(request: ConsentGroupChangeRunRequest): Promise<ConsentGroupChangeRunResult>\n /**\n * The participant's clause of the \"Finished a consent group change\" line,\n * from its counts totaled over the whole change, or `null` to add nothing.\n */\n summarize?(counts: Readonly<Record<string, number>>): string | null\n}\n\ninterface Registration {\n pluginId: string\n participant: ConsentGroupChangeParticipant\n}\n\nconst registrations: Registration[] = []\n\n/**\n * Registers a plugin's participant. Owner = the loader's marker inside a\n * register fn, else `options.pluginId`; a participant with neither throws.\n * One per plugin: registering again replaces it in place, so a module\n * evaluated twice does not run twice.\n */\nexport function registerPluginConsentGroupParticipant(\n participant: ConsentGroupChangeParticipant,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a consent group participant was registered with no owner: pass ' +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, participant }\n else registrations.push({ pluginId, participant })\n}\n\n/** Every participant, in registration order — the order they run in. */\nexport function listPluginConsentGroupParticipants(): Array<{\n pluginId: string\n participant: ConsentGroupChangeParticipant\n}> {\n return registrations.map(({ pluginId, participant }) => ({ pluginId, participant }))\n}\n\n/**\n * Every participant's preview, by plugin id, in registration order: its\n * lines, or `null` for one that threw. Never throws.\n */\nexport async function previewPluginConsentGroupChange(\n request: ConsentGroupChangePreviewRequest,\n): Promise<Array<{ pluginId: string; lines: ConsentGroupChangePreviewLine[] | null }>> {\n const out: Array<{ pluginId: string; lines: ConsentGroupChangePreviewLine[] | null }> = []\n for (const { pluginId, participant } of [...registrations]) {\n try {\n out.push({ pluginId, lines: await participant.preview(request) })\n } catch (error) {\n out.push({ pluginId, lines: null })\n console.error(\n `[plugins] ${pluginId} failed to preview a consent group change in org ${request.orgId}`,\n error,\n )\n }\n }\n return out\n}\n\n/** Test seam: forget every participant. */\nexport function resetPluginConsentGroupParticipantsForTests(): void {\n registrations.length = 0\n}\n"],"names":["getRegisteringPluginId","registrations","registerPluginConsentGroupParticipant","participant","options","pluginId","trim","Error","index","findIndex","entry","push","listPluginConsentGroupParticipants","map","previewPluginConsentGroupChange","request","out","lines","preview","error","console","orgId","resetPluginConsentGroupParticipantsForTests","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCC,GAMD,SAASA,sBAAsB,QAAQ,qCAAiC;AA6CxE,MAAMC,gBAAgC,EAAE;AAExC;;;;;CAKC,GACD,OAAO,SAASC,sCACdC,WAA0C,EAC1CC,OAA+B;QAEbJ,MAAAA;IAAlB,MAAMK,WAAW,EAACL,QAAAA,0BAAAA,oCAAAA,0BAA4BI,2BAAAA,QAASC,QAAQ,YAA7CL,OAAiD,IAAIM,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,oEACE;IAEN;IACA,MAAMC,QAAQP,cAAcQ,SAAS,CAAC,CAACC,QAAUA,MAAML,QAAQ,KAAKA;IACpE,IAAIG,SAAS,GAAGP,aAAa,CAACO,MAAM,GAAG;QAAEH;QAAUF;IAAY;SAC1DF,cAAcU,IAAI,CAAC;QAAEN;QAAUF;IAAY;AAClD;AAEA,sEAAsE,GACtE,OAAO,SAASS;IAId,OAAOX,cAAcY,GAAG,CAAC,CAAC,EAAER,QAAQ,EAAEF,WAAW,EAAE,GAAM,CAAA;YAAEE;YAAUF;QAAY,CAAA;AACnF;AAEA;;;CAGC,GACD,OAAO,eAAeW,gCACpBC,OAAyC;IAEzC,MAAMC,MAAkF,EAAE;IAC1F,KAAK,MAAM,EAAEX,QAAQ,EAAEF,WAAW,EAAE,IAAI;WAAIF;KAAc,CAAE;QAC1D,IAAI;YACFe,IAAIL,IAAI,CAAC;gBAAEN;gBAAUY,OAAO,MAAMd,YAAYe,OAAO,CAACH;YAAS;QACjE,EAAE,OAAOI,OAAO;YACdH,IAAIL,IAAI,CAAC;gBAAEN;gBAAUY,OAAO;YAAK;YACjCG,QAAQD,KAAK,CACX,CAAC,UAAU,EAAEd,SAAS,iDAAiD,EAAEU,QAAQM,KAAK,EAAE,EACxFF;QAEJ;IACF;IACA,OAAOH;AACT;AAEA,yCAAyC,GACzC,OAAO,SAASM;IACdrB,cAAcsB,MAAM,GAAG;AACzB"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-consent-group-change.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 * A plugin's share of a CONSENT GROUP CHANGE (AGL-3320).\n *\n * The executor in `libs/tenant/data/admin` carries the refusals the platform\n * stores per site — the suppression list, the topic opt-outs, the pace — and\n * flips the declaration. A plugin that keeps its own per-site refusals, or\n * records keyed by a consent group's id, registers a participant here, and\n * every change runs it at three points:\n *\n * - `carry`, BEFORE the flip: copy the plugin's refusals onto the sites the\n * plan's carries name, so no site stops honoring an opt-out it was reading\n * across its group. Run twice — once, and again as the catch-up.\n * - `rehome`, AFTER the flip: move the records keyed by the plan's holder\n * flows. After, because every writer keys by the declaration in force, so\n * new writes land on the final key while the move folds the old one in.\n * - `sweep`, once the sweep delay has passed: the whole job again,\n * idempotently, for anything a long job wrote under the old declaration.\n *\n * ## NOT ISOLATED, on purpose\n *\n * The erasure seams log a throw and go on (`plugin-person-erasure.ts`), because\n * a partial erasure is better than none. Here the opposite holds: a skipped\n * carry is a refusal nobody honors once the flip happens. So a participant's\n * throw propagates, the executor records it against the job, and the change\n * does not flip until the participant succeeds — five failures in a row mark\n * the job stalled, and it keeps retrying.\n *\n * The PREVIEW is the exception, because it decides nothing: a participant\n * whose preview throws shows `null` lines and the review step says so.\n *\n * ## The contract a `run` keeps\n *\n * Resumable and idempotent. The executor hands back the `cursor` the last\n * call returned (`null` to start), and stops calling once `done` is true. A\n * run must return before `deadlineMs`, keep every write create-if-absent or a\n * restrictive merge, and write nothing on `dryRun`. A second full run over a\n * finished change must write nothing, because the catch-up and the sweep ARE\n * second runs.\n *\n * Registered from a plugin's declarations, like the erasure seams, so the\n * participant is in place in a process that never loaded the plugin's API.\n */\n\nimport type {\n ConsentGroupChangePlan,\n ConsentGroupChangePreviewLine,\n} from '../app-utils/consent-group-change'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Which point of the change a `run` is called for. */\nexport type ConsentGroupChangeParticipantPhase = 'carry' | 'rehome' | 'sweep'\n\nexport interface ConsentGroupChangePreviewRequest {\n orgId: string\n plan: ConsentGroupChangePlan\n}\n\nexport interface ConsentGroupChangeRunRequest {\n orgId: string\n changeId: string\n plan: ConsentGroupChangePlan\n phase: ConsentGroupChangeParticipantPhase\n /** Where the previous call stopped, or `null` to start the phase. */\n cursor: string | null\n /** Return by this instant; the executor calls again from the cursor. */\n deadlineMs: number\n /** Count, write nothing. */\n dryRun: boolean\n}\n\nexport interface ConsentGroupChangeRunResult {\n done: boolean\n cursor: string | null\n /** What THIS call did — the executor adds them up across calls. */\n counts: Record<string, number>\n}\n\nexport interface ConsentGroupChangeParticipant {\n preview(request: ConsentGroupChangePreviewRequest): Promise<ConsentGroupChangePreviewLine[]>\n run(request: ConsentGroupChangeRunRequest): Promise<ConsentGroupChangeRunResult>\n /**\n * The participant's clause of the \"Finished a consent group change\" line,\n * from its counts totaled over the whole change, or `null` to add nothing.\n */\n summarize?(counts: Readonly<Record<string, number>>): string | null\n}\n\ninterface Registration {\n pluginId: string\n participant: ConsentGroupChangeParticipant\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The participants are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-consent-group-participants')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Registration[]\n}\nconst registrations: Registration[] =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = [])\n\n/**\n * Registers a plugin's participant. Owner = the loader's marker inside a\n * register fn, else `options.pluginId`; a participant with neither throws.\n * One per plugin: registering again replaces it in place, so a module\n * evaluated twice does not run twice.\n */\nexport function registerPluginConsentGroupParticipant(\n participant: ConsentGroupChangeParticipant,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a consent group participant was registered with no owner: pass ' +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, participant }\n else registrations.push({ pluginId, participant })\n}\n\n/** Every participant, in registration order — the order they run in. */\nexport function listPluginConsentGroupParticipants(): Array<{\n pluginId: string\n participant: ConsentGroupChangeParticipant\n}> {\n return registrations.map(({ pluginId, participant }) => ({ pluginId, participant }))\n}\n\n/**\n * Every participant's preview, by plugin id, in registration order: its\n * lines, or `null` for one that threw. Never throws.\n */\nexport async function previewPluginConsentGroupChange(\n request: ConsentGroupChangePreviewRequest,\n): Promise<Array<{ pluginId: string; lines: ConsentGroupChangePreviewLine[] | null }>> {\n const out: Array<{ pluginId: string; lines: ConsentGroupChangePreviewLine[] | null }> = []\n for (const { pluginId, participant } of [...registrations]) {\n try {\n out.push({ pluginId, lines: await participant.preview(request) })\n } catch (error) {\n out.push({ pluginId, lines: null })\n console.error(\n `[plugins] ${pluginId} failed to preview a consent group change in org ${request.orgId}`,\n error,\n )\n }\n }\n return out\n}\n\n/** Test seam: forget every participant. */\nexport function resetPluginConsentGroupParticipantsForTests(): void {\n registrations.length = 0\n}\n"],"names":["globalScope","getRegisteringPluginId","REGISTRY_KEY","Symbol","for","globalThis","registrations","registerPluginConsentGroupParticipant","participant","options","pluginId","trim","Error","index","findIndex","entry","push","listPluginConsentGroupParticipants","map","previewPluginConsentGroupChange","request","out","lines","preview","error","console","orgId","resetPluginConsentGroupParticipantsForTests","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCC,OA+DCA;AAzDF,SAASC,sBAAsB,QAAQ,qCAAiC;AA6CxE;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,iBACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,EAAE;AAE9D;;;;;CAKC,GACD,OAAO,SAASK,sCACdC,WAA0C,EAC1CC,OAA+B;QAEbR,MAAAA;IAAlB,MAAMS,WAAW,EAACT,QAAAA,0BAAAA,oCAAAA,0BAA4BQ,2BAAAA,QAASC,QAAQ,YAA7CT,OAAiD,IAAIU,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,oEACE;IAEN;IACA,MAAMC,QAAQP,cAAcQ,SAAS,CAAC,CAACC,QAAUA,MAAML,QAAQ,KAAKA;IACpE,IAAIG,SAAS,GAAGP,aAAa,CAACO,MAAM,GAAG;QAAEH;QAAUF;IAAY;SAC1DF,cAAcU,IAAI,CAAC;QAAEN;QAAUF;IAAY;AAClD;AAEA,sEAAsE,GACtE,OAAO,SAASS;IAId,OAAOX,cAAcY,GAAG,CAAC,CAAC,EAAER,QAAQ,EAAEF,WAAW,EAAE,GAAM,CAAA;YAAEE;YAAUF;QAAY,CAAA;AACnF;AAEA;;;CAGC,GACD,OAAO,eAAeW,gCACpBC,OAAyC;IAEzC,MAAMC,MAAkF,EAAE;IAC1F,KAAK,MAAM,EAAEX,QAAQ,EAAEF,WAAW,EAAE,IAAI;WAAIF;KAAc,CAAE;QAC1D,IAAI;YACFe,IAAIL,IAAI,CAAC;gBAAEN;gBAAUY,OAAO,MAAMd,YAAYe,OAAO,CAACH;YAAS;QACjE,EAAE,OAAOI,OAAO;YACdH,IAAIL,IAAI,CAAC;gBAAEN;gBAAUY,OAAO;YAAK;YACjCG,QAAQD,KAAK,CACX,CAAC,UAAU,EAAEd,SAAS,iDAAiD,EAAEU,QAAQM,KAAK,EAAE,EACxFF;QAEJ;IACF;IACA,OAAOH;AACT;AAEA,yCAAyC,GACzC,OAAO,SAASM;IACdrB,cAAcsB,MAAM,GAAG;AACzB"}
@@ -1,3 +1,4 @@
1
+ var _globalScope_REGISTRY_KEY;
1
2
  /**
2
3
  * @license
3
4
  * Copyright 2026 Aglyn LLC
@@ -66,7 +67,15 @@ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
66
67
  * fifteen-minute sweep (`CONSOLE_FAST_CRON_SCHEDULE` in `cloud/functions`).
67
68
  */ export const PLUGIN_CONSOLE_CRON_SCHEDULE = '*/15 * * * *';
68
69
  /** How late a declared job may be before its row goes red: three missed ticks. */ export const PLUGIN_CONSOLE_CRON_GRACE_MINUTES = 45;
69
- const registrations = [];
70
+ /**
71
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
72
+ * The jobs are registered at boot, from `instrumentation.ts`, and Next
73
+ * compiles that file apart from the routes, so the boot and a route each hold
74
+ * their own copy of this module. At module scope, the boot filled its copy and
75
+ * every route read an empty one (AGL-3412, AGL-3868).
76
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-console-crons');
77
+ const globalScope = globalThis;
78
+ const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = [];
70
79
  /** A job id as a beat document and a health row can carry it. */ const JOB_ID = /^[a-z][a-z0-9-]*$/;
71
80
  /** Job ids the platform's own inventory holds. */ const PLATFORM_JOB_IDS = new Set([
72
81
  ...SCHEDULED_JOBS.map((job)=>job.id),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-console-crons.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 * A plugin's own scheduled jobs, run by the CONSOLE (AGL-2981).\n *\n * The platform already has a beat for work that belongs on the tenant — the\n * job runner behind `pluginJobsBeat` — and it is the wrong place for a job\n * that holds a credential only the console may hold: a provider key, a\n * sealed grant to a person's mailbox. Those jobs have to run in the console,\n * and until now the only way to get one there was a console route of its own\n * and a literal in the scheduler's route list, which is a plugin's route in\n * the platform's code.\n *\n * So a plugin DECLARES a console job here instead, and the platform runs\n * every declared job from one route, `/api/admin/plugin-crons`, which the\n * Cloud Scheduler job that drives the console's fifteen-minute sweeps posts\n * like any other of its routes. A plugin that adds a job adds a declaration;\n * the scheduler, its firewall rule and its deploy are unchanged.\n *\n * ## One schedule\n *\n * Every declared job runs on that tick: {@link PLUGIN_CONSOLE_CRON_SCHEDULE}.\n * A job that has nothing to do returns at once. A job that needs a slower\n * cadence keeps its own \"last ran\" mark and skips; one that needs a faster\n * one is not a job for this runner.\n *\n * ## Watched like every other job\n *\n * Each job has its own row on `/api/health/crons` —\n * {@link pluginConsoleCronScheduledJobs} — and the runner stamps that row's\n * `platformCronBeats` mark when it invokes the job, BEFORE the work, the rule\n * every console cron follows: the mark answers \"is this job still being\n * scheduled\", and a job that throws was still scheduled. A job that stops\n * being run — its plugin unregistered it, the route stopped being posted —\n * goes red on the board within its grace.\n *\n * ## Isolated, and bounded\n *\n * Jobs run concurrently, each in its own try: a throw is logged against its\n * plugin and reported as `null`, and never stops another job. Each is handed\n * a deadline, and a job that starts no new unit of work after it lets the\n * route answer before its caller gives up.\n *\n * Declared from a plugin's `consoleServerDeclarations` — the console's boot\n * registrations — with the job's body imported lazily, so boot pays for the\n * declaration and the tick pays for the work.\n */\n\nimport { SCHEDULED_JOBS, type ScheduledJob } from '../app-utils/health-report'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** The console route that runs every declared job, as the scheduler posts it. */\nexport const PLUGIN_CONSOLE_CRONS_ROUTE = '/api/admin/plugin-crons'\n\n/** The runner's own row on `/api/health/crons`: is the route being posted at all. */\nexport const PLUGIN_CONSOLE_CRONS_JOB_ID = 'plugin-console-crons'\n\n/**\n * The tick every declared job runs on, five-field UTC: the console's\n * fifteen-minute sweep (`CONSOLE_FAST_CRON_SCHEDULE` in `cloud/functions`).\n */\nexport const PLUGIN_CONSOLE_CRON_SCHEDULE = '*/15 * * * *'\n\n/** How late a declared job may be before its row goes red: three missed ticks. */\nexport const PLUGIN_CONSOLE_CRON_GRACE_MINUTES = 45\n\n/** What one run of a job is handed. */\nexport interface PluginConsoleCronContext {\n /** When the tick began, epoch ms. */\n nowMs: number\n /** Start no new unit of work after this, epoch ms. */\n deadlineMs: number\n}\n\n/** What a job reports for the tick's answer and log: counts, flags, a word — never content. */\nexport type PluginConsoleCronReport = Readonly<Record<string, number | boolean | string | null>>\n\nexport interface PluginConsoleCronJob {\n /**\n * The job's id: its `platformCronBeats` document and its health row.\n * Starts with the plugin's own id — `acme-mail-sync` for `acme-mail` — so\n * two plugins' jobs, and a plugin's job and the platform's, never share one.\n */\n id: string\n /** What the health board calls it. */\n label: string\n /** What stops happening when it stops, in a sentence the board renders. */\n drives: string\n run(context: PluginConsoleCronContext): Promise<PluginConsoleCronReport>\n}\n\n/** A declared job, without its body. */\nexport interface PluginConsoleCronDeclaration {\n pluginId: string\n id: string\n label: string\n drives: string\n}\n\ninterface Registration extends PluginConsoleCronDeclaration {\n run: PluginConsoleCronJob['run']\n}\n\nconst registrations: Registration[] = []\n\n/** A job id as a beat document and a health row can carry it. */\nconst JOB_ID = /^[a-z][a-z0-9-]*$/\n\n/** Job ids the platform's own inventory holds. */\nconst PLATFORM_JOB_IDS = new Set([...SCHEDULED_JOBS.map((job) => job.id), PLUGIN_CONSOLE_CRONS_JOB_ID])\n\n/**\n * Declares a plugin's console job. Owner = the loader's marker inside a\n * register fn, else `options.pluginId`; a job with neither throws, as does\n * an id that is not the plugin's own (`<pluginId>-…`), one the platform's\n * inventory holds, one another plugin declared, and a job with no label or\n * no consequence. The same plugin declaring the same id again replaces its\n * job in place.\n */\nexport function registerPluginConsoleCron(\n job: PluginConsoleCronJob,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin console job was registered with no owner: pass { pluginId } ' +\n 'when registering outside a plugin register fn',\n )\n }\n const id = String(job?.id ?? '').trim()\n if (!JOB_ID.test(id) || !id.startsWith(`${pluginId}-`) || id === `${pluginId}-`) {\n throw new Error(\n `plugin console job \"${id}\" from \"${pluginId}\" needs an id of its own: ` +\n `\"${pluginId}-\" and a lowercase name`,\n )\n }\n if (PLATFORM_JOB_IDS.has(id)) {\n throw new Error(`plugin console job \"${id}\" is a platform job's id; refused \"${pluginId}\"`)\n }\n const incumbent = registrations.find((entry) => entry.id === id)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `plugin console job \"${id}\" is already declared by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n const label = String(job.label ?? '').trim()\n const drives = String(job.drives ?? '').trim()\n if (!label || !drives || typeof job.run !== 'function') {\n throw new Error(`plugin console job \"${id}\" needs a label, what it drives, and a run function`)\n }\n const registration: Registration = { pluginId, id, label, drives, run: job.run }\n const index = registrations.findIndex((entry) => entry.id === id)\n if (index >= 0) registrations[index] = registration\n else registrations.push(registration)\n}\n\n/** Every declared job, in declaration order, without its body. */\nexport function listPluginConsoleCrons(): PluginConsoleCronDeclaration[] {\n return registrations.map(({ pluginId, id, label, drives }) => ({ pluginId, id, label, drives }))\n}\n\n/**\n * The declared jobs as rows of the scheduled-job inventory, for\n * `/api/health/crons` to judge beside the platform's own.\n */\nexport function pluginConsoleCronScheduledJobs(): ScheduledJob[] {\n return registrations.map((entry) => ({\n id: entry.id,\n label: entry.label,\n cron: PLUGIN_CONSOLE_CRON_SCHEDULE,\n runner: 'cloud-scheduler',\n target: `consoleFastCrons → console ${PLUGIN_CONSOLE_CRONS_ROUTE} (${entry.pluginId})`,\n graceMinutes: PLUGIN_CONSOLE_CRON_GRACE_MINUTES,\n drives: entry.drives,\n }))\n}\n\nexport interface RunPluginConsoleCronsOptions {\n nowMs: number\n deadlineMs: number\n /** Only these jobs, by id — a manual re-run of one. Absent runs every job. */\n jobIds?: readonly string[] | null\n /**\n * Stamps a job's `platformCronBeats` mark. Must not throw; the runner\n * awaits it before the job starts.\n */\n beat(jobId: string, nowMs: number): Promise<unknown>\n}\n\n/**\n * Runs the declared jobs — every one, or the ones named — concurrently,\n * each after its beat. Answers each job's report by id, or `null` for one\n * that threw. Never throws.\n */\nexport async function runPluginConsoleCrons(\n options: RunPluginConsoleCronsOptions,\n): Promise<Record<string, PluginConsoleCronReport | null>> {\n const wanted = options.jobIds?.length ? new Set(options.jobIds) : null\n const jobs = [...registrations].filter((entry) => !wanted || wanted.has(entry.id))\n const settled = await Promise.all(\n jobs.map(async (job): Promise<[string, PluginConsoleCronReport | null]> => {\n try {\n await options.beat(job.id, options.nowMs)\n } catch {\n // A beat that cannot be written must not take down the job it\n // describes; a write that keeps failing reads as a silent job.\n }\n try {\n return [job.id, await job.run({ nowMs: options.nowMs, deadlineMs: options.deadlineMs })]\n } catch (error) {\n console.error(`[plugins] ${job.pluginId} console job ${job.id} failed`, error)\n return [job.id, null]\n }\n }),\n )\n // Declaration order, whichever job finished first.\n return Object.fromEntries(settled)\n}\n\n/** Test seam: forget every declared job. */\nexport function resetPluginConsoleCronsForTests(): void {\n registrations.length = 0\n}\n"],"names":["SCHEDULED_JOBS","getRegisteringPluginId","PLUGIN_CONSOLE_CRONS_ROUTE","PLUGIN_CONSOLE_CRONS_JOB_ID","PLUGIN_CONSOLE_CRON_SCHEDULE","PLUGIN_CONSOLE_CRON_GRACE_MINUTES","registrations","JOB_ID","PLATFORM_JOB_IDS","Set","map","job","id","registerPluginConsoleCron","options","pluginId","trim","Error","String","test","startsWith","has","incumbent","find","entry","label","drives","run","registration","index","findIndex","push","listPluginConsoleCrons","pluginConsoleCronScheduledJobs","cron","runner","target","graceMinutes","runPluginConsoleCrons","wanted","jobIds","length","jobs","filter","settled","Promise","all","beat","nowMs","deadlineMs","error","console","Object","fromEntries","resetPluginConsoleCronsForTests"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-console-crons.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 * A plugin's own scheduled jobs, run by the CONSOLE (AGL-2981).\n *\n * The platform already has a beat for work that belongs on the tenant — the\n * job runner behind `pluginJobsBeat` — and it is the wrong place for a job\n * that holds a credential only the console may hold: a provider key, a\n * sealed grant to a person's mailbox. Those jobs have to run in the console,\n * and until now the only way to get one there was a console route of its own\n * and a literal in the scheduler's route list, which is a plugin's route in\n * the platform's code.\n *\n * So a plugin DECLARES a console job here instead, and the platform runs\n * every declared job from one route, `/api/admin/plugin-crons`, which the\n * Cloud Scheduler job that drives the console's fifteen-minute sweeps posts\n * like any other of its routes. A plugin that adds a job adds a declaration;\n * the scheduler, its firewall rule and its deploy are unchanged.\n *\n * ## One schedule\n *\n * Every declared job runs on that tick: {@link PLUGIN_CONSOLE_CRON_SCHEDULE}.\n * A job that has nothing to do returns at once. A job that needs a slower\n * cadence keeps its own \"last ran\" mark and skips; one that needs a faster\n * one is not a job for this runner.\n *\n * ## Watched like every other job\n *\n * Each job has its own row on `/api/health/crons` —\n * {@link pluginConsoleCronScheduledJobs} — and the runner stamps that row's\n * `platformCronBeats` mark when it invokes the job, BEFORE the work, the rule\n * every console cron follows: the mark answers \"is this job still being\n * scheduled\", and a job that throws was still scheduled. A job that stops\n * being run — its plugin unregistered it, the route stopped being posted —\n * goes red on the board within its grace.\n *\n * ## Isolated, and bounded\n *\n * Jobs run concurrently, each in its own try: a throw is logged against its\n * plugin and reported as `null`, and never stops another job. Each is handed\n * a deadline, and a job that starts no new unit of work after it lets the\n * route answer before its caller gives up.\n *\n * Declared from a plugin's `consoleServerDeclarations` — the console's boot\n * registrations — with the job's body imported lazily, so boot pays for the\n * declaration and the tick pays for the work.\n */\n\nimport { SCHEDULED_JOBS, type ScheduledJob } from '../app-utils/health-report'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** The console route that runs every declared job, as the scheduler posts it. */\nexport const PLUGIN_CONSOLE_CRONS_ROUTE = '/api/admin/plugin-crons'\n\n/** The runner's own row on `/api/health/crons`: is the route being posted at all. */\nexport const PLUGIN_CONSOLE_CRONS_JOB_ID = 'plugin-console-crons'\n\n/**\n * The tick every declared job runs on, five-field UTC: the console's\n * fifteen-minute sweep (`CONSOLE_FAST_CRON_SCHEDULE` in `cloud/functions`).\n */\nexport const PLUGIN_CONSOLE_CRON_SCHEDULE = '*/15 * * * *'\n\n/** How late a declared job may be before its row goes red: three missed ticks. */\nexport const PLUGIN_CONSOLE_CRON_GRACE_MINUTES = 45\n\n/** What one run of a job is handed. */\nexport interface PluginConsoleCronContext {\n /** When the tick began, epoch ms. */\n nowMs: number\n /** Start no new unit of work after this, epoch ms. */\n deadlineMs: number\n}\n\n/** What a job reports for the tick's answer and log: counts, flags, a word — never content. */\nexport type PluginConsoleCronReport = Readonly<Record<string, number | boolean | string | null>>\n\nexport interface PluginConsoleCronJob {\n /**\n * The job's id: its `platformCronBeats` document and its health row.\n * Starts with the plugin's own id — `acme-mail-sync` for `acme-mail` — so\n * two plugins' jobs, and a plugin's job and the platform's, never share one.\n */\n id: string\n /** What the health board calls it. */\n label: string\n /** What stops happening when it stops, in a sentence the board renders. */\n drives: string\n run(context: PluginConsoleCronContext): Promise<PluginConsoleCronReport>\n}\n\n/** A declared job, without its body. */\nexport interface PluginConsoleCronDeclaration {\n pluginId: string\n id: string\n label: string\n drives: string\n}\n\ninterface Registration extends PluginConsoleCronDeclaration {\n run: PluginConsoleCronJob['run']\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The jobs are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-console-crons')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Registration[]\n}\nconst registrations: Registration[] =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = [])\n\n/** A job id as a beat document and a health row can carry it. */\nconst JOB_ID = /^[a-z][a-z0-9-]*$/\n\n/** Job ids the platform's own inventory holds. */\nconst PLATFORM_JOB_IDS = new Set([...SCHEDULED_JOBS.map((job) => job.id), PLUGIN_CONSOLE_CRONS_JOB_ID])\n\n/**\n * Declares a plugin's console job. Owner = the loader's marker inside a\n * register fn, else `options.pluginId`; a job with neither throws, as does\n * an id that is not the plugin's own (`<pluginId>-…`), one the platform's\n * inventory holds, one another plugin declared, and a job with no label or\n * no consequence. The same plugin declaring the same id again replaces its\n * job in place.\n */\nexport function registerPluginConsoleCron(\n job: PluginConsoleCronJob,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin console job was registered with no owner: pass { pluginId } ' +\n 'when registering outside a plugin register fn',\n )\n }\n const id = String(job?.id ?? '').trim()\n if (!JOB_ID.test(id) || !id.startsWith(`${pluginId}-`) || id === `${pluginId}-`) {\n throw new Error(\n `plugin console job \"${id}\" from \"${pluginId}\" needs an id of its own: ` +\n `\"${pluginId}-\" and a lowercase name`,\n )\n }\n if (PLATFORM_JOB_IDS.has(id)) {\n throw new Error(`plugin console job \"${id}\" is a platform job's id; refused \"${pluginId}\"`)\n }\n const incumbent = registrations.find((entry) => entry.id === id)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `plugin console job \"${id}\" is already declared by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n const label = String(job.label ?? '').trim()\n const drives = String(job.drives ?? '').trim()\n if (!label || !drives || typeof job.run !== 'function') {\n throw new Error(`plugin console job \"${id}\" needs a label, what it drives, and a run function`)\n }\n const registration: Registration = { pluginId, id, label, drives, run: job.run }\n const index = registrations.findIndex((entry) => entry.id === id)\n if (index >= 0) registrations[index] = registration\n else registrations.push(registration)\n}\n\n/** Every declared job, in declaration order, without its body. */\nexport function listPluginConsoleCrons(): PluginConsoleCronDeclaration[] {\n return registrations.map(({ pluginId, id, label, drives }) => ({ pluginId, id, label, drives }))\n}\n\n/**\n * The declared jobs as rows of the scheduled-job inventory, for\n * `/api/health/crons` to judge beside the platform's own.\n */\nexport function pluginConsoleCronScheduledJobs(): ScheduledJob[] {\n return registrations.map((entry) => ({\n id: entry.id,\n label: entry.label,\n cron: PLUGIN_CONSOLE_CRON_SCHEDULE,\n runner: 'cloud-scheduler',\n target: `consoleFastCrons → console ${PLUGIN_CONSOLE_CRONS_ROUTE} (${entry.pluginId})`,\n graceMinutes: PLUGIN_CONSOLE_CRON_GRACE_MINUTES,\n drives: entry.drives,\n }))\n}\n\nexport interface RunPluginConsoleCronsOptions {\n nowMs: number\n deadlineMs: number\n /** Only these jobs, by id — a manual re-run of one. Absent runs every job. */\n jobIds?: readonly string[] | null\n /**\n * Stamps a job's `platformCronBeats` mark. Must not throw; the runner\n * awaits it before the job starts.\n */\n beat(jobId: string, nowMs: number): Promise<unknown>\n}\n\n/**\n * Runs the declared jobs — every one, or the ones named — concurrently,\n * each after its beat. Answers each job's report by id, or `null` for one\n * that threw. Never throws.\n */\nexport async function runPluginConsoleCrons(\n options: RunPluginConsoleCronsOptions,\n): Promise<Record<string, PluginConsoleCronReport | null>> {\n const wanted = options.jobIds?.length ? new Set(options.jobIds) : null\n const jobs = [...registrations].filter((entry) => !wanted || wanted.has(entry.id))\n const settled = await Promise.all(\n jobs.map(async (job): Promise<[string, PluginConsoleCronReport | null]> => {\n try {\n await options.beat(job.id, options.nowMs)\n } catch {\n // A beat that cannot be written must not take down the job it\n // describes; a write that keeps failing reads as a silent job.\n }\n try {\n return [job.id, await job.run({ nowMs: options.nowMs, deadlineMs: options.deadlineMs })]\n } catch (error) {\n console.error(`[plugins] ${job.pluginId} console job ${job.id} failed`, error)\n return [job.id, null]\n }\n }),\n )\n // Declaration order, whichever job finished first.\n return Object.fromEntries(settled)\n}\n\n/** Test seam: forget every declared job. */\nexport function resetPluginConsoleCronsForTests(): void {\n registrations.length = 0\n}\n"],"names":["globalScope","SCHEDULED_JOBS","getRegisteringPluginId","PLUGIN_CONSOLE_CRONS_ROUTE","PLUGIN_CONSOLE_CRONS_JOB_ID","PLUGIN_CONSOLE_CRON_SCHEDULE","PLUGIN_CONSOLE_CRON_GRACE_MINUTES","REGISTRY_KEY","Symbol","for","globalThis","registrations","JOB_ID","PLATFORM_JOB_IDS","Set","map","job","id","registerPluginConsoleCron","options","pluginId","trim","Error","String","test","startsWith","has","incumbent","find","entry","label","drives","run","registration","index","findIndex","push","listPluginConsoleCrons","pluginConsoleCronScheduledJobs","cron","runner","target","graceMinutes","runPluginConsoleCrons","wanted","jobIds","length","jobs","filter","settled","Promise","all","beat","nowMs","deadlineMs","error","console","Object","fromEntries","resetPluginConsoleCronsForTests"],"mappings":"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"}
@@ -33,9 +33,10 @@
33
33
  * carries `id`, stable across retries, for a subscriber to dedupe on.
34
34
  *
35
35
  * TYPED BY TOKEN. `definePluginDomainEvent<Payload>(name)` gives the raiser
36
- * and a subscriber the same payload type; a subscriber in another plugin
37
- * restates the shape it reads and defines its own token under the same
38
- * name, as a widget restates a zone's props.
36
+ * and a subscriber the same payload type. The raising plugin publishes its
37
+ * tokens and payloads in its contracts module, and a subscriber in another
38
+ * plugin imports them from `@aglyn/plugins-<id>/contracts` and names the
39
+ * raiser in its `uses` (AGL-3980, docs/PACKAGES.md); it never restates them.
39
40
  *
40
41
  * The registry lives on `globalThis` (AGL-3412): subscriptions register from
41
42
  * the plugins' server declarations at boot, in a module graph the routes and
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-domain-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\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/**\n * Events a PLUGIN raises for other plugins (AGL-3611).\n *\n * `plugin-events.ts` carries the platform's own facts — a seat add-on, a paid\n * invoice — with payloads core declares. This is the same idea for facts\n * that belong to a plugin: one plugin declares an event and its payload,\n * raises it when the thing happens, and any other plugin subscribes by the\n * event's name without importing the plugin that raises it. Core names\n * nothing a plugin raises.\n *\n * DELIVERY IS RELIABLE, NOT FIRE-AND-FORGET. Raising an event writes an\n * outbox document (`plugin-event-outbox.ts` in the admin data lib), in the\n * same transaction as the write it reports where the raiser has one, and a\n * scheduled drain hands it to each subscriber in turn. A subscriber that\n * throws is retried with backoff, alone: the ones that already took the\n * event are not called again. Delivery is AT LEAST ONCE, so every envelope\n * carries `id`, stable across retries, for a subscriber to dedupe on.\n *\n * TYPED BY TOKEN. `definePluginDomainEvent<Payload>(name)` gives the raiser\n * and a subscriber the same payload type; a subscriber in another plugin\n * restates the shape it reads and defines its own token under the same\n * name, as a widget restates a zone's props.\n *\n * The registry lives on `globalThis` (AGL-3412): subscriptions register from\n * the plugins' server declarations at boot, in a module graph the routes and\n * the job runner do not share.\n */\n\n/** A typed handle on one event name. */\nexport interface PluginDomainEvent<Payload> {\n readonly id: string\n /** Type-level only: the payload the event carries. */\n readonly __payload?: Payload\n}\n\n/** The payload an event token carries. */\nexport type PluginDomainEventPayload<Event> = Event extends PluginDomainEvent<infer Payload>\n ? Payload\n : never\n\nconst EVENT_NAME = /^[a-z][a-zA-Z0-9]*(\\.[a-z][a-zA-Z0-9_]*)+$/\n\n/**\n * A token for one event name: lower-case words joined by dots, the first\n * naming what the event is about (`order.paid`, `return.requested`).\n */\nexport function definePluginDomainEvent<Payload>(id: string): PluginDomainEvent<Payload> {\n const name = String(id ?? '').trim()\n if (!EVENT_NAME.test(name)) {\n throw new Error(`plugin event name \"${id}\" must be dotted lower-case words, e.g. \"order.paid\"`)\n }\n return { id: name }\n}\n\n/** How a plugin describes an event it raises, for pickers and the docs. */\nexport interface PluginDomainEventDeclaration {\n event: PluginDomainEvent<unknown> | string\n /** \"Order paid\". */\n label: string\n /** One sentence: when it is raised. */\n description: string\n /** The top-level payload keys, for a person writing a subscriber. */\n payloadKeys?: readonly string[]\n}\n\n/** A declared event, with the plugin that raises it. */\nexport interface DeclaredPluginDomainEvent {\n pluginId: string\n event: string\n label: string\n description: string\n payloadKeys: readonly string[]\n}\n\n/** What a subscriber is handed. */\nexport interface PluginDomainEventEnvelope<Payload = unknown> {\n /** Stable across retries: the idempotency key for a subscriber. */\n id: string\n event: string\n /** The site the event happened on. */\n hostId: string\n /** The site's org, when the raiser knew it. */\n orgId: string | null\n /** When the fact happened, not when it was delivered. */\n occurredAtMs: number\n /** 1 on the first delivery to this subscriber, then 2, 3, … */\n attempt: number\n payload: Payload\n}\n\nexport type PluginDomainEventHandler<Payload = unknown> = (\n envelope: PluginDomainEventEnvelope<Payload>,\n) => void | Promise<void>\n\ninterface Subscription {\n pluginId: string\n handler: PluginDomainEventHandler<unknown>\n}\n\ninterface Registry {\n declarations: Map<string, DeclaredPluginDomainEvent>\n subscriptions: Map<string, Subscription[]>\n}\n\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-domain-events')\nconst globalScope = globalThis as typeof globalThis & { [REGISTRY_KEY]?: Registry }\n\nfunction registry(): Registry {\n if (!globalScope[REGISTRY_KEY]) {\n globalScope[REGISTRY_KEY] = { declarations: new Map(), subscriptions: new Map() }\n }\n return globalScope[REGISTRY_KEY] as Registry\n}\n\nconst nameOf = (event: PluginDomainEvent<unknown> | string): string =>\n typeof event === 'string' ? event : event.id\n\nfunction ownerOf(kind: string, name: string, options?: { pluginId?: string }): string {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin event \"${name}\" ${kind} with no owner: pass { pluginId } outside a plugin register fn`,\n )\n }\n return pluginId\n}\n\n/**\n * Declares the events a plugin raises. One plugin owns a name: a second\n * plugin declaring it throws, so two plugins cannot raise one event with two\n * payloads. The owner declaring again replaces its entry.\n */\nexport function declarePluginDomainEvents(\n declarations: readonly PluginDomainEventDeclaration[],\n options?: { pluginId?: string },\n): void {\n for (const declaration of declarations) {\n const name = nameOf(declaration.event)\n if (!EVENT_NAME.test(name)) {\n throw new Error(`plugin event name \"${name}\" must be dotted lower-case words`)\n }\n const pluginId = ownerOf('declared', name, options)\n const existing = registry().declarations.get(name)\n if (existing && existing.pluginId !== pluginId) {\n throw new Error(\n `plugin event \"${name}\" is already declared by \"${existing.pluginId}\"; \"${pluginId}\" cannot declare it too`,\n )\n }\n registry().declarations.set(name, {\n pluginId,\n event: name,\n label: declaration.label,\n description: declaration.description,\n payloadKeys: [...(declaration.payloadKeys ?? [])],\n })\n }\n}\n\n/** Every declared event, sorted by name. */\nexport function listPluginDomainEvents(): DeclaredPluginDomainEvent[] {\n return [...registry().declarations.values()].sort((a, b) => a.event.localeCompare(b.event))\n}\n\n/** The declaration of one event, or `null` when no plugin declares it. */\nexport function pluginDomainEventDeclaration(event: string): DeclaredPluginDomainEvent | null {\n return registry().declarations.get(event) ?? null\n}\n\n/**\n * Subscribes to an event. A subscriber subscribing again to the same event\n * replaces its earlier handler, so a module evaluated twice delivers once.\n *\n * The subscriber is named by plugin id, or `pluginId:name` when a plugin\n * subscribes more than one handler to an event (its webhooks and its\n * workflow triggers, say) — and that name is what the outbox records as\n * delivered, so it must be stable.\n */\nexport function subscribePluginDomainEvent<Payload>(\n event: PluginDomainEvent<Payload> | string,\n handler: PluginDomainEventHandler<Payload>,\n options?: { pluginId?: string; name?: string },\n): void {\n const name = nameOf(event as PluginDomainEvent<unknown> | string)\n const owner = ownerOf('subscribed', name, options)\n const pluginId = options?.name ? `${owner}:${options.name}` : owner\n const list = (registry().subscriptions.get(name) ?? []).filter((entry) => entry.pluginId !== pluginId)\n list.push({ pluginId, handler: handler as PluginDomainEventHandler<unknown> })\n registry().subscriptions.set(name, list)\n}\n\n/** The plugins subscribed to an event, in subscription order. */\nexport function listPluginDomainEventSubscribers(event: string): string[] {\n return (registry().subscriptions.get(event) ?? []).map((entry) => entry.pluginId)\n}\n\n/** What one delivery pass came to. */\nexport interface PluginDomainEventDeliveryResult {\n /** Subscribers that took the event on this pass. */\n delivered: string[]\n /** Subscribers that threw, with what they said. */\n failed: Array<{ pluginId: string; error: string }>\n}\n\n/**\n * Hands an envelope to every subscriber not in `skip`, one after another. A\n * throw is caught and reported and does not stop the others. The outbox\n * drain calls this; a raiser never does.\n */\nexport async function deliverPluginDomainEvent(\n envelope: Omit<PluginDomainEventEnvelope, 'attempt'>,\n options: { skip?: ReadonlySet<string>; attempts?: Readonly<Record<string, number>> } = {},\n): Promise<PluginDomainEventDeliveryResult> {\n const result: PluginDomainEventDeliveryResult = { delivered: [], failed: [] }\n for (const { pluginId, handler } of registry().subscriptions.get(envelope.event) ?? []) {\n if (options.skip?.has(pluginId)) continue\n try {\n await handler({ ...envelope, attempt: (options.attempts?.[pluginId] ?? 0) + 1 })\n result.delivered.push(pluginId)\n } catch (error) {\n const message = String((error as Error)?.message ?? error).slice(0, 500)\n result.failed.push({ pluginId, error: message })\n console.error(`[plugin-events] ${pluginId} failed on ${envelope.event} ${envelope.id}`, error)\n }\n }\n return result\n}\n\n/** Test seam: forget every declaration and subscription. */\nexport function resetPluginDomainEventsForTests(): void {\n registry().declarations.clear()\n registry().subscriptions.clear()\n}\n"],"names":["getRegisteringPluginId","EVENT_NAME","definePluginDomainEvent","id","name","String","trim","test","Error","REGISTRY_KEY","Symbol","for","globalScope","globalThis","registry","declarations","Map","subscriptions","nameOf","event","ownerOf","kind","options","pluginId","declarePluginDomainEvents","declaration","existing","get","set","label","description","payloadKeys","listPluginDomainEvents","values","sort","a","b","localeCompare","pluginDomainEventDeclaration","subscribePluginDomainEvent","handler","owner","list","filter","entry","push","listPluginDomainEventSubscribers","map","deliverPluginDomainEvent","envelope","result","delivered","failed","skip","has","attempt","attempts","error","message","slice","console","resetPluginDomainEventsForTests","clear"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-domain-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\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/**\n * Events a PLUGIN raises for other plugins (AGL-3611).\n *\n * `plugin-events.ts` carries the platform's own facts — a seat add-on, a paid\n * invoice — with payloads core declares. This is the same idea for facts\n * that belong to a plugin: one plugin declares an event and its payload,\n * raises it when the thing happens, and any other plugin subscribes by the\n * event's name without importing the plugin that raises it. Core names\n * nothing a plugin raises.\n *\n * DELIVERY IS RELIABLE, NOT FIRE-AND-FORGET. Raising an event writes an\n * outbox document (`plugin-event-outbox.ts` in the admin data lib), in the\n * same transaction as the write it reports where the raiser has one, and a\n * scheduled drain hands it to each subscriber in turn. A subscriber that\n * throws is retried with backoff, alone: the ones that already took the\n * event are not called again. Delivery is AT LEAST ONCE, so every envelope\n * carries `id`, stable across retries, for a subscriber to dedupe on.\n *\n * TYPED BY TOKEN. `definePluginDomainEvent<Payload>(name)` gives the raiser\n * and a subscriber the same payload type. The raising plugin publishes its\n * tokens and payloads in its contracts module, and a subscriber in another\n * plugin imports them from `@aglyn/plugins-<id>/contracts` and names the\n * raiser in its `uses` (AGL-3980, docs/PACKAGES.md); it never restates them.\n *\n * The registry lives on `globalThis` (AGL-3412): subscriptions register from\n * the plugins' server declarations at boot, in a module graph the routes and\n * the job runner do not share.\n */\n\n/** A typed handle on one event name. */\nexport interface PluginDomainEvent<Payload> {\n readonly id: string\n /** Type-level only: the payload the event carries. */\n readonly __payload?: Payload\n}\n\n/** The payload an event token carries. */\nexport type PluginDomainEventPayload<Event> = Event extends PluginDomainEvent<infer Payload>\n ? Payload\n : never\n\nconst EVENT_NAME = /^[a-z][a-zA-Z0-9]*(\\.[a-z][a-zA-Z0-9_]*)+$/\n\n/**\n * A token for one event name: lower-case words joined by dots, the first\n * naming what the event is about (`order.paid`, `return.requested`).\n */\nexport function definePluginDomainEvent<Payload>(id: string): PluginDomainEvent<Payload> {\n const name = String(id ?? '').trim()\n if (!EVENT_NAME.test(name)) {\n throw new Error(`plugin event name \"${id}\" must be dotted lower-case words, e.g. \"order.paid\"`)\n }\n return { id: name }\n}\n\n/** How a plugin describes an event it raises, for pickers and the docs. */\nexport interface PluginDomainEventDeclaration {\n event: PluginDomainEvent<unknown> | string\n /** \"Order paid\". */\n label: string\n /** One sentence: when it is raised. */\n description: string\n /** The top-level payload keys, for a person writing a subscriber. */\n payloadKeys?: readonly string[]\n}\n\n/** A declared event, with the plugin that raises it. */\nexport interface DeclaredPluginDomainEvent {\n pluginId: string\n event: string\n label: string\n description: string\n payloadKeys: readonly string[]\n}\n\n/** What a subscriber is handed. */\nexport interface PluginDomainEventEnvelope<Payload = unknown> {\n /** Stable across retries: the idempotency key for a subscriber. */\n id: string\n event: string\n /** The site the event happened on. */\n hostId: string\n /** The site's org, when the raiser knew it. */\n orgId: string | null\n /** When the fact happened, not when it was delivered. */\n occurredAtMs: number\n /** 1 on the first delivery to this subscriber, then 2, 3, … */\n attempt: number\n payload: Payload\n}\n\nexport type PluginDomainEventHandler<Payload = unknown> = (\n envelope: PluginDomainEventEnvelope<Payload>,\n) => void | Promise<void>\n\ninterface Subscription {\n pluginId: string\n handler: PluginDomainEventHandler<unknown>\n}\n\ninterface Registry {\n declarations: Map<string, DeclaredPluginDomainEvent>\n subscriptions: Map<string, Subscription[]>\n}\n\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-domain-events')\nconst globalScope = globalThis as typeof globalThis & { [REGISTRY_KEY]?: Registry }\n\nfunction registry(): Registry {\n if (!globalScope[REGISTRY_KEY]) {\n globalScope[REGISTRY_KEY] = { declarations: new Map(), subscriptions: new Map() }\n }\n return globalScope[REGISTRY_KEY] as Registry\n}\n\nconst nameOf = (event: PluginDomainEvent<unknown> | string): string =>\n typeof event === 'string' ? event : event.id\n\nfunction ownerOf(kind: string, name: string, options?: { pluginId?: string }): string {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin event \"${name}\" ${kind} with no owner: pass { pluginId } outside a plugin register fn`,\n )\n }\n return pluginId\n}\n\n/**\n * Declares the events a plugin raises. One plugin owns a name: a second\n * plugin declaring it throws, so two plugins cannot raise one event with two\n * payloads. The owner declaring again replaces its entry.\n */\nexport function declarePluginDomainEvents(\n declarations: readonly PluginDomainEventDeclaration[],\n options?: { pluginId?: string },\n): void {\n for (const declaration of declarations) {\n const name = nameOf(declaration.event)\n if (!EVENT_NAME.test(name)) {\n throw new Error(`plugin event name \"${name}\" must be dotted lower-case words`)\n }\n const pluginId = ownerOf('declared', name, options)\n const existing = registry().declarations.get(name)\n if (existing && existing.pluginId !== pluginId) {\n throw new Error(\n `plugin event \"${name}\" is already declared by \"${existing.pluginId}\"; \"${pluginId}\" cannot declare it too`,\n )\n }\n registry().declarations.set(name, {\n pluginId,\n event: name,\n label: declaration.label,\n description: declaration.description,\n payloadKeys: [...(declaration.payloadKeys ?? [])],\n })\n }\n}\n\n/** Every declared event, sorted by name. */\nexport function listPluginDomainEvents(): DeclaredPluginDomainEvent[] {\n return [...registry().declarations.values()].sort((a, b) => a.event.localeCompare(b.event))\n}\n\n/** The declaration of one event, or `null` when no plugin declares it. */\nexport function pluginDomainEventDeclaration(event: string): DeclaredPluginDomainEvent | null {\n return registry().declarations.get(event) ?? null\n}\n\n/**\n * Subscribes to an event. A subscriber subscribing again to the same event\n * replaces its earlier handler, so a module evaluated twice delivers once.\n *\n * The subscriber is named by plugin id, or `pluginId:name` when a plugin\n * subscribes more than one handler to an event (its webhooks and its\n * workflow triggers, say) — and that name is what the outbox records as\n * delivered, so it must be stable.\n */\nexport function subscribePluginDomainEvent<Payload>(\n event: PluginDomainEvent<Payload> | string,\n handler: PluginDomainEventHandler<Payload>,\n options?: { pluginId?: string; name?: string },\n): void {\n const name = nameOf(event as PluginDomainEvent<unknown> | string)\n const owner = ownerOf('subscribed', name, options)\n const pluginId = options?.name ? `${owner}:${options.name}` : owner\n const list = (registry().subscriptions.get(name) ?? []).filter((entry) => entry.pluginId !== pluginId)\n list.push({ pluginId, handler: handler as PluginDomainEventHandler<unknown> })\n registry().subscriptions.set(name, list)\n}\n\n/** The plugins subscribed to an event, in subscription order. */\nexport function listPluginDomainEventSubscribers(event: string): string[] {\n return (registry().subscriptions.get(event) ?? []).map((entry) => entry.pluginId)\n}\n\n/** What one delivery pass came to. */\nexport interface PluginDomainEventDeliveryResult {\n /** Subscribers that took the event on this pass. */\n delivered: string[]\n /** Subscribers that threw, with what they said. */\n failed: Array<{ pluginId: string; error: string }>\n}\n\n/**\n * Hands an envelope to every subscriber not in `skip`, one after another. A\n * throw is caught and reported and does not stop the others. The outbox\n * drain calls this; a raiser never does.\n */\nexport async function deliverPluginDomainEvent(\n envelope: Omit<PluginDomainEventEnvelope, 'attempt'>,\n options: { skip?: ReadonlySet<string>; attempts?: Readonly<Record<string, number>> } = {},\n): Promise<PluginDomainEventDeliveryResult> {\n const result: PluginDomainEventDeliveryResult = { delivered: [], failed: [] }\n for (const { pluginId, handler } of registry().subscriptions.get(envelope.event) ?? []) {\n if (options.skip?.has(pluginId)) continue\n try {\n await handler({ ...envelope, attempt: (options.attempts?.[pluginId] ?? 0) + 1 })\n result.delivered.push(pluginId)\n } catch (error) {\n const message = String((error as Error)?.message ?? error).slice(0, 500)\n result.failed.push({ pluginId, error: message })\n console.error(`[plugin-events] ${pluginId} failed on ${envelope.event} ${envelope.id}`, error)\n }\n }\n return result\n}\n\n/** Test seam: forget every declaration and subscription. */\nexport function resetPluginDomainEventsForTests(): void {\n registry().declarations.clear()\n registry().subscriptions.clear()\n}\n"],"names":["getRegisteringPluginId","EVENT_NAME","definePluginDomainEvent","id","name","String","trim","test","Error","REGISTRY_KEY","Symbol","for","globalScope","globalThis","registry","declarations","Map","subscriptions","nameOf","event","ownerOf","kind","options","pluginId","declarePluginDomainEvents","declaration","existing","get","set","label","description","payloadKeys","listPluginDomainEvents","values","sort","a","b","localeCompare","pluginDomainEventDeclaration","subscribePluginDomainEvent","handler","owner","list","filter","entry","push","listPluginDomainEventSubscribers","map","deliverPluginDomainEvent","envelope","result","delivered","failed","skip","has","attempt","attempts","error","message","slice","console","resetPluginDomainEventsForTests","clear"],"mappings":"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"}
@@ -31,8 +31,17 @@
31
31
  * logged and the next one runs, because these are consequences of a write
32
32
  * that has already happened — an activity row, a notification — and a
33
33
  * failure in one must not undo or hide the write for the customer.
34
- */ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
35
- const handlers = new Map();
34
+ */ var _globalScope_REGISTRY_KEY;
35
+ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
36
+ /**
37
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
38
+ * The handlers are registered at boot, from `instrumentation.ts`, and Next
39
+ * compiles that file apart from the routes, so the boot and a route each hold
40
+ * their own copy of this module. At module scope, the boot filled its copy and
41
+ * every route read an empty one (AGL-3412, AGL-3868).
42
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-event-handlers');
43
+ const globalScope = globalThis;
44
+ const handlers = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = new Map();
36
45
  /**
37
46
  * Subscribes to an event. Owner = the loader's marker inside a register fn,
38
47
  * else `options.pluginId`; a subscription with neither throws. A plugin
@@ -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 * Events a PLUGIN raises for other plugins — an order paid, a return\n * refunded — are `plugin-domain-events.ts` (AGL-3611): declared by the plugin,\n * delivered through an outbox, at least once.\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 /**\n * A site's email was opened, or a link in it clicked (AGL-3605): the\n * delivery events one webhook delivery recorded for a send tagged with the\n * site, the FIRST of each type per message only — the delivery log decides\n * which those are, so a replay or a reader opening one email six times is\n * one event. Raised by the plugin whose webhook reads the provider, after\n * the log has recorded them; a subscriber that counts engagement of its own\n * reads it here rather than subscribing to the provider.\n */\n 'host.email.engaged': {\n hostId: string\n events: ReadonlyArray<{\n /** The recipient, lowercased. */\n to: string\n /** `opened` or `clicked`. */\n type: string\n /** When it happened, epoch ms. */\n at: number\n firstOfType: boolean\n }>\n }\n /**\n * A custom domain was connected to a site and the hosting provider accepted\n * it (AGL-3629). Raised once the claim is written, so `domain` is the site's\n * `cname` and no other site's. The site may still be awaiting a certificate\n * or a DNS change; a plugin that needs the domain registered somewhere — a\n * payment provider, a search console — does that now rather than waiting\n * for traffic.\n */\n 'host.domain.attached': {\n orgId: string | null\n hostId: string\n domain: string\n }\n /**\n * A domain stopped serving a site (AGL-3629): its custom domain was\n * disconnected, or the site was deleted — which releases its platform\n * subdomain too, raised as a second event with that name. A plugin undoes\n * whatever it registered for the name, and must check the name is not\n * still served by ANOTHER site first (a `www.` twin, a reused subdomain).\n */\n 'host.domain.released': {\n orgId: string | null\n hostId: string\n domain: string\n reason: 'detached' | 'site-deleted'\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;;;;;;;;;;;;;;;;;;CAkBC,GAGD,SAASA,sBAAsB,QAAQ,qCAAiC;AA2KxE,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"}
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 * Events a PLUGIN raises for other plugins — an order paid, a return\n * refunded — are `plugin-domain-events.ts` (AGL-3611): declared by the plugin,\n * delivered through an outbox, at least once.\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 /**\n * A site's email was opened, or a link in it clicked (AGL-3605): the\n * delivery events one webhook delivery recorded for a send tagged with the\n * site, the FIRST of each type per message only — the delivery log decides\n * which those are, so a replay or a reader opening one email six times is\n * one event. Raised by the plugin whose webhook reads the provider, after\n * the log has recorded them; a subscriber that counts engagement of its own\n * reads it here rather than subscribing to the provider.\n */\n 'host.email.engaged': {\n hostId: string\n events: ReadonlyArray<{\n /** The recipient, lowercased. */\n to: string\n /** `opened` or `clicked`. */\n type: string\n /** When it happened, epoch ms. */\n at: number\n firstOfType: boolean\n }>\n }\n /**\n * A custom domain was connected to a site and the hosting provider accepted\n * it (AGL-3629). Raised once the claim is written, so `domain` is the site's\n * `cname` and no other site's. The site may still be awaiting a certificate\n * or a DNS change; a plugin that needs the domain registered somewhere — a\n * payment provider, a search console — does that now rather than waiting\n * for traffic.\n */\n 'host.domain.attached': {\n orgId: string | null\n hostId: string\n domain: string\n }\n /**\n * A domain stopped serving a site (AGL-3629): its custom domain was\n * disconnected, or the site was deleted — which releases its platform\n * subdomain too, raised as a second event with that name. A plugin undoes\n * whatever it registered for the name, and must check the name is not\n * still served by ANOTHER site first (a `www.` twin, a reused subdomain).\n */\n 'host.domain.released': {\n orgId: string | null\n hostId: string\n domain: string\n reason: 'detached' | 'site-deleted'\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\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The handlers are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-event-handlers')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Map<PluginEventName, Registration[]>\n}\nconst handlers: Map<PluginEventName, Registration[]> =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = new Map())\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":["globalScope","getRegisteringPluginId","REGISTRY_KEY","Symbol","for","globalThis","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;;;;;;;;;;;;;;;;;;CAkBC,OA0LCA;AAvLF,SAASC,sBAAsB,QAAQ,qCAAiC;AA2KxE;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,YACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,IAAIK;AAEhE;;;;;CAKC,GACD,OAAO,SAASC,2BACdC,KAAQ,EACRC,OAA8B,EAC9BC,OAA+B;QAEbV,MAAAA,yBAOJK;IAPd,MAAMM,WAAW,EAACX,QAAAA,0BAAAA,oCAAAA,0BAA4BU,2BAAAA,QAASC,QAAQ,YAA7CX,OAAiD,IAAIY,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"}
@@ -1,4 +1,5 @@
1
1
  import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ var _globalScope_REGISTRY_KEY;
2
3
  /**
3
4
  * @license
4
5
  * Copyright 2026 Aglyn LLC
@@ -41,7 +42,15 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
41
42
  * the plugin's API surface. The core never imports a plugin; this is how a
42
43
  * conversion the tenancy runtime performs reaches a plugin's records.
43
44
  */ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
44
- const registrations = [];
45
+ /**
46
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
47
+ * The listeners are registered at boot, from `instrumentation.ts`, and Next
48
+ * compiles that file apart from the routes, so the boot and a route each hold
49
+ * their own copy of this module. At module scope, the boot filled its copy and
50
+ * every route read an empty one (AGL-3412, AGL-3868).
51
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-lead-conversion-listeners');
52
+ const globalScope = globalThis;
53
+ const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = [];
45
54
  /**
46
55
  * Registers a plugin's conversion listener. Owner = the loader's marker
47
56
  * inside a register fn, else `options.pluginId`; a listener with neither
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-lead-conversion.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 * A plugin's share of a LEAD CONVERSION (AGL-3233).\n *\n * When a lead becomes a contact — by the convert dialog, over the REST API,\n * or on its own when the person signs up or buys — the record system moves\n * what it filed on the lead onto the contact: the activities, the tasks.\n * A plugin that keeps records ABOUT the lead where those sweeps do not\n * reach — a sequence enrollment naming the lead, say — registers a\n * listener here, and the conversion runs every listener with the lead and\n * the contact it became, so the plugin can re-point its own records.\n *\n * Listeners run AFTER the lead is stamped converted, so a listener that\n * reads the lead finds `convertedContactId` on it, and after the record\n * system's own moves, so a listener that reads the contact's timeline\n * finds the lead's history already there.\n *\n * Listeners run in registration order and are ISOLATED: a throw is logged\n * against its plugin, recorded as `null`, and does not stop the next one or\n * the conversion — which has already happened. The `null` is how the\n * conversion's report says that plugin's records may still name the lead.\n *\n * Registered from a plugin's declarations — `serverDeclarations`, or\n * `consoleServerDeclarations` for a plugin whose server code runs only in\n * the console — so the listener is in place in a process that never loaded\n * the plugin's API surface. The core never imports a plugin; this is how a\n * conversion the tenancy runtime performs reaches a plugin's records.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Which door converted the lead. */\nexport type PluginLeadConversionBy =\n /** A member, from the console's convert dialog. */\n | 'member'\n /** An API key, over `POST /v1/leads/{id}/convert`. */\n | 'api'\n /** The person opened a member account. */\n | 'signup'\n /** The person bought something. */\n | 'purchase'\n /** The one-time migration to the one-record model. */\n | 'backfill'\n\n/** The lead that converted, and the contact it became. */\nexport interface PluginLeadConversionRequest {\n orgId: string\n /** The site the lead lived under. */\n hostId: string\n /** `hosts/{hostId}/leads/{leadId}` — the person key. */\n leadId: string\n /** `orgs/{orgId}/contacts/{contactId}`. */\n contactId: string\n /** The person's address, normalized. */\n email: string\n by: PluginLeadConversionBy\n}\n\n/**\n * What a listener reports: counts and flags, never content. A `null`\n * field is a figure the listener could not measure, which is not zero.\n */\nexport type PluginLeadConversionReport = Readonly<Record<string, number | boolean | null>>\n\nexport type PluginLeadConversionListener = (\n request: PluginLeadConversionRequest,\n) => Promise<PluginLeadConversionReport>\n\ninterface Registration {\n pluginId: string\n listener: PluginLeadConversionListener\n}\n\nconst registrations: Registration[] = []\n\n/**\n * Registers a plugin's conversion listener. Owner = the loader's marker\n * inside a register fn, else `options.pluginId`; a listener with neither\n * throws. One listener per plugin: registering again replaces the plugin's\n * earlier listener in place, so a module evaluated twice does not move\n * records twice.\n */\nexport function registerPluginLeadConversionListener(\n listener: PluginLeadConversionListener,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin lead-conversion listener was registered with no owner: pass ' +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, listener }\n else registrations.push({ pluginId, listener })\n}\n\n/** The plugins with a listener in place, in registration order. Only for specs and the guards. */\nexport function listPluginLeadConversionListeners(): readonly string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Only for specs: forgets every listener. */\nexport function resetPluginLeadConversionListenersForTests(): void {\n registrations.length = 0\n}\n\n/**\n * Runs every listener, in registration order, and answers each plugin's\n * report by plugin id: the listener's own report, or `null` for one that\n * threw. Never throws.\n */\nexport async function runPluginLeadConversionListeners(\n request: PluginLeadConversionRequest,\n): Promise<Record<string, PluginLeadConversionReport | null>> {\n const reports: Record<string, PluginLeadConversionReport | null> = {}\n for (const { pluginId, listener } of [...registrations]) {\n try {\n reports[pluginId] = await listener({ ...request })\n } catch (error) {\n reports[pluginId] = null\n // The ids, never the address: this line outlives the person's records.\n console.error(\n `[plugins] ${pluginId} failed to follow lead ${request.leadId} to contact ${request.contactId} in org ${request.orgId}`,\n error,\n )\n }\n }\n return reports\n}\n"],"names":["getRegisteringPluginId","registrations","registerPluginLeadConversionListener","listener","options","pluginId","trim","Error","index","findIndex","entry","push","listPluginLeadConversionListeners","map","resetPluginLeadConversionListenersForTests","length","runPluginLeadConversionListeners","request","reports","error","console","leadId","contactId","orgId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SAASA,sBAAsB,QAAQ,qCAAiC;AA4CxE,MAAMC,gBAAgC,EAAE;AAExC;;;;;;CAMC,GACD,OAAO,SAASC,qCACdC,QAAsC,EACtCC,OAA+B;QAEbJ,MAAAA;IAAlB,MAAMK,WAAW,EAACL,QAAAA,0BAAAA,oCAAAA,0BAA4BI,2BAAAA,QAASC,QAAQ,YAA7CL,OAAiD,IAAIM,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,0EACE;IAEN;IACA,MAAMC,QAAQP,cAAcQ,SAAS,CAAC,CAACC,QAAUA,MAAML,QAAQ,KAAKA;IACpE,IAAIG,SAAS,GAAGP,aAAa,CAACO,MAAM,GAAG;QAAEH;QAAUF;IAAS;SACvDF,cAAcU,IAAI,CAAC;QAAEN;QAAUF;IAAS;AAC/C;AAEA,gGAAgG,GAChG,OAAO,SAASS;IACd,OAAOX,cAAcY,GAAG,CAAC,CAACH,QAAUA,MAAML,QAAQ;AACpD;AAEA,4CAA4C,GAC5C,OAAO,SAASS;IACdb,cAAcc,MAAM,GAAG;AACzB;AAEA;;;;CAIC,GACD,OAAO,eAAeC,iCACpBC,OAAoC;IAEpC,MAAMC,UAA6D,CAAC;IACpE,KAAK,MAAM,EAAEb,QAAQ,EAAEF,QAAQ,EAAE,IAAI;WAAIF;KAAc,CAAE;QACvD,IAAI;YACFiB,OAAO,CAACb,SAAS,GAAG,MAAMF,SAAS,aAAKc;QAC1C,EAAE,OAAOE,OAAO;YACdD,OAAO,CAACb,SAAS,GAAG;YACpB,uEAAuE;YACvEe,QAAQD,KAAK,CACX,CAAC,UAAU,EAAEd,SAAS,uBAAuB,EAAEY,QAAQI,MAAM,CAAC,YAAY,EAAEJ,QAAQK,SAAS,CAAC,QAAQ,EAAEL,QAAQM,KAAK,EAAE,EACvHJ;QAEJ;IACF;IACA,OAAOD;AACT"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-lead-conversion.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 * A plugin's share of a LEAD CONVERSION (AGL-3233).\n *\n * When a lead becomes a contact — by the convert dialog, over the REST API,\n * or on its own when the person signs up or buys — the record system moves\n * what it filed on the lead onto the contact: the activities, the tasks.\n * A plugin that keeps records ABOUT the lead where those sweeps do not\n * reach — a sequence enrollment naming the lead, say — registers a\n * listener here, and the conversion runs every listener with the lead and\n * the contact it became, so the plugin can re-point its own records.\n *\n * Listeners run AFTER the lead is stamped converted, so a listener that\n * reads the lead finds `convertedContactId` on it, and after the record\n * system's own moves, so a listener that reads the contact's timeline\n * finds the lead's history already there.\n *\n * Listeners run in registration order and are ISOLATED: a throw is logged\n * against its plugin, recorded as `null`, and does not stop the next one or\n * the conversion — which has already happened. The `null` is how the\n * conversion's report says that plugin's records may still name the lead.\n *\n * Registered from a plugin's declarations — `serverDeclarations`, or\n * `consoleServerDeclarations` for a plugin whose server code runs only in\n * the console — so the listener is in place in a process that never loaded\n * the plugin's API surface. The core never imports a plugin; this is how a\n * conversion the tenancy runtime performs reaches a plugin's records.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Which door converted the lead. */\nexport type PluginLeadConversionBy =\n /** A member, from the console's convert dialog. */\n | 'member'\n /** An API key, over `POST /v1/leads/{id}/convert`. */\n | 'api'\n /** The person opened a member account. */\n | 'signup'\n /** The person bought something. */\n | 'purchase'\n /** The one-time migration to the one-record model. */\n | 'backfill'\n\n/** The lead that converted, and the contact it became. */\nexport interface PluginLeadConversionRequest {\n orgId: string\n /** The site the lead lived under. */\n hostId: string\n /** `hosts/{hostId}/leads/{leadId}` — the person key. */\n leadId: string\n /** `orgs/{orgId}/contacts/{contactId}`. */\n contactId: string\n /** The person's address, normalized. */\n email: string\n by: PluginLeadConversionBy\n}\n\n/**\n * What a listener reports: counts and flags, never content. A `null`\n * field is a figure the listener could not measure, which is not zero.\n */\nexport type PluginLeadConversionReport = Readonly<Record<string, number | boolean | null>>\n\nexport type PluginLeadConversionListener = (\n request: PluginLeadConversionRequest,\n) => Promise<PluginLeadConversionReport>\n\ninterface Registration {\n pluginId: string\n listener: PluginLeadConversionListener\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The listeners are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-lead-conversion-listeners')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Registration[]\n}\nconst registrations: Registration[] =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = [])\n\n/**\n * Registers a plugin's conversion listener. Owner = the loader's marker\n * inside a register fn, else `options.pluginId`; a listener with neither\n * throws. One listener per plugin: registering again replaces the plugin's\n * earlier listener in place, so a module evaluated twice does not move\n * records twice.\n */\nexport function registerPluginLeadConversionListener(\n listener: PluginLeadConversionListener,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin lead-conversion listener was registered with no owner: pass ' +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, listener }\n else registrations.push({ pluginId, listener })\n}\n\n/** The plugins with a listener in place, in registration order. Only for specs and the guards. */\nexport function listPluginLeadConversionListeners(): readonly string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Only for specs: forgets every listener. */\nexport function resetPluginLeadConversionListenersForTests(): void {\n registrations.length = 0\n}\n\n/**\n * Runs every listener, in registration order, and answers each plugin's\n * report by plugin id: the listener's own report, or `null` for one that\n * threw. Never throws.\n */\nexport async function runPluginLeadConversionListeners(\n request: PluginLeadConversionRequest,\n): Promise<Record<string, PluginLeadConversionReport | null>> {\n const reports: Record<string, PluginLeadConversionReport | null> = {}\n for (const { pluginId, listener } of [...registrations]) {\n try {\n reports[pluginId] = await listener({ ...request })\n } catch (error) {\n reports[pluginId] = null\n // The ids, never the address: this line outlives the person's records.\n console.error(\n `[plugins] ${pluginId} failed to follow lead ${request.leadId} to contact ${request.contactId} in org ${request.orgId}`,\n error,\n )\n }\n }\n return reports\n}\n"],"names":["globalScope","getRegisteringPluginId","REGISTRY_KEY","Symbol","for","globalThis","registrations","registerPluginLeadConversionListener","listener","options","pluginId","trim","Error","index","findIndex","entry","push","listPluginLeadConversionListeners","map","resetPluginLeadConversionListenersForTests","length","runPluginLeadConversionListeners","request","reports","error","console","leadId","contactId","orgId"],"mappings":";IAqGEA;AArGF;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SAASC,sBAAsB,QAAQ,qCAAiC;AA4CxE;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,iBACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,EAAE;AAE9D;;;;;;CAMC,GACD,OAAO,SAASK,qCACdC,QAAsC,EACtCC,OAA+B;QAEbR,MAAAA;IAAlB,MAAMS,WAAW,EAACT,QAAAA,0BAAAA,oCAAAA,0BAA4BQ,2BAAAA,QAASC,QAAQ,YAA7CT,OAAiD,IAAIU,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,0EACE;IAEN;IACA,MAAMC,QAAQP,cAAcQ,SAAS,CAAC,CAACC,QAAUA,MAAML,QAAQ,KAAKA;IACpE,IAAIG,SAAS,GAAGP,aAAa,CAACO,MAAM,GAAG;QAAEH;QAAUF;IAAS;SACvDF,cAAcU,IAAI,CAAC;QAAEN;QAAUF;IAAS;AAC/C;AAEA,gGAAgG,GAChG,OAAO,SAASS;IACd,OAAOX,cAAcY,GAAG,CAAC,CAACH,QAAUA,MAAML,QAAQ;AACpD;AAEA,4CAA4C,GAC5C,OAAO,SAASS;IACdb,cAAcc,MAAM,GAAG;AACzB;AAEA;;;;CAIC,GACD,OAAO,eAAeC,iCACpBC,OAAoC;IAEpC,MAAMC,UAA6D,CAAC;IACpE,KAAK,MAAM,EAAEb,QAAQ,EAAEF,QAAQ,EAAE,IAAI;WAAIF;KAAc,CAAE;QACvD,IAAI;YACFiB,OAAO,CAACb,SAAS,GAAG,MAAMF,SAAS,aAAKc;QAC1C,EAAE,OAAOE,OAAO;YACdD,OAAO,CAACb,SAAS,GAAG;YACpB,uEAAuE;YACvEe,QAAQD,KAAK,CACX,CAAC,UAAU,EAAEd,SAAS,uBAAuB,EAAEY,QAAQI,MAAM,CAAC,YAAY,EAAEJ,QAAQK,SAAS,CAAC,QAAQ,EAAEL,QAAQM,KAAK,EAAE,EACvHJ;QAEJ;IACF;IACA,OAAOD;AACT"}