@atlaskit/rovo-triggers 10.37.0 → 11.0.0

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 (24) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/cjs/common/utils/extension-context-bridge/ExtensionContextBridgeClient.js +7 -6
  3. package/dist/cjs/common/utils/extension-context-bridge/ExtensionContextBridgeHost.js +34 -17
  4. package/dist/cjs/common/utils/extension-context-bridge/analytics.js +87 -0
  5. package/dist/cjs/common/utils/extension-context-bridge/constants.js +11 -5
  6. package/dist/cjs/common/utils/extension-context-bridge/registry.js +33 -15
  7. package/dist/es2019/common/utils/extension-context-bridge/ExtensionContextBridgeClient.js +7 -6
  8. package/dist/es2019/common/utils/extension-context-bridge/ExtensionContextBridgeHost.js +34 -16
  9. package/dist/es2019/common/utils/extension-context-bridge/analytics.js +76 -0
  10. package/dist/es2019/common/utils/extension-context-bridge/constants.js +10 -4
  11. package/dist/es2019/common/utils/extension-context-bridge/registry.js +29 -12
  12. package/dist/esm/common/utils/extension-context-bridge/ExtensionContextBridgeClient.js +7 -6
  13. package/dist/esm/common/utils/extension-context-bridge/ExtensionContextBridgeHost.js +34 -17
  14. package/dist/esm/common/utils/extension-context-bridge/analytics.js +80 -0
  15. package/dist/esm/common/utils/extension-context-bridge/constants.js +10 -4
  16. package/dist/esm/common/utils/extension-context-bridge/registry.js +34 -15
  17. package/dist/types/common/utils/extension-context-bridge/ExtensionContextBridgeHost.d.ts +8 -1
  18. package/dist/types/common/utils/extension-context-bridge/analytics.d.ts +55 -0
  19. package/dist/types/common/utils/extension-context-bridge/constants.d.ts +10 -4
  20. package/dist/types/common/utils/extension-context-bridge/policy.d.ts +1 -2
  21. package/dist/types/common/utils/extension-context-bridge/registry.d.ts +14 -1
  22. package/dist/types/types.d.ts +1 -1
  23. package/extension-context-bridge/analytics/package.json +10 -0
  24. package/package.json +4 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # @atlaskit/rovo-triggers
2
2
 
3
+ ## 11.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - [`de8897276a43e`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/de8897276a43e) -
8
+ Replace the `rovo-ext_context_bridge` feature gate with the `rovo-ext_context_bridge_exp`
9
+ experiment, read through its boolean `isEnabled` parameter.
10
+
11
+ **Breaking:** `@atlaskit/rovo-triggers/extension-context-bridge/constants` no longer exports
12
+ `BRIDGE_FEATURE_GATE`. Use `EXT_CONTEXT_BRIDGE_EXPERIMENT` instead, which holds the experiment key
13
+ rather than the gate key:
14
+
15
+ ```diff
16
+ - import { BRIDGE_FEATURE_GATE } from '@atlaskit/rovo-triggers/extension-context-bridge/constants';
17
+ + import { EXT_CONTEXT_BRIDGE_EXPERIMENT } from '@atlaskit/rovo-triggers/extension-context-bridge/constants';
18
+ ```
19
+
20
+ `ExtensionContextBridgeHost`, `ExtensionContextBridgeClient` and `ChatOpenerSubscriber` read the
21
+ experiment through `UNSAFE_expValNoExposure`, so none of them log exposure: the Host mounts at the
22
+ root of every page of an opted-in product, and exposure there would count product page loads
23
+ rather than extension users. The Rovo browser extension owns the single exposure call.
24
+
25
+ No behaviour change for consumers beyond the assignment source. The
26
+ `extensionContextBridgeEnabled` prop on `ChatOpenerSubscriber` is unchanged and still required to
27
+ opt in.
28
+
29
+ ### Minor Changes
30
+
31
+ - [`487372cfb38b7`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/487372cfb38b7) -
32
+ Send the `remix_custom_edit` experience, source page ID, and required editor context for editor
33
+ Custom Remix requests behind the `aifc_remix_custom_page_edit` gate.
34
+
35
+ ## 10.38.0
36
+
37
+ ### Minor Changes
38
+
39
+ - [`2124250692aba`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/2124250692aba) -
40
+ ASIMO-5048: report what the extension context bridge's inbound relay policy decided for each
41
+ payload a product publishes, so a refusal is observable instead of silent.
42
+
43
+ `ExtensionContextBridgeHost` takes a new optional `onRelayDecision` callback, with its contract on
44
+ a new `./extension-context-bridge/analytics` entrypoint, and the serializer now returns a
45
+ discriminated result so a refusal names its reason. `ChatOpenerSubscriber` wires the callback to
46
+ the analytics client it already holds, emitting a `rovoExtensionBridgePayload evaluated`
47
+ operational event into the host product's stream.
48
+
49
+ Product to extension direction only. Deduplicated to one report per distinct outcome per mount,
50
+ and only active while `rovo-ext_context_bridge` is on.
51
+
52
+ ### Patch Changes
53
+
54
+ - Updated dependencies
55
+
3
56
  ## 10.37.0
4
57
 
5
58
  ### Minor Changes
@@ -7,7 +7,7 @@ Object.defineProperty(exports, "__esModule", {
7
7
  exports.ExtensionContextBridgeClient = void 0;
8
8
  var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
9
9
  var _react = require("react");
10
- var _fg = require("@atlaskit/platform-feature-flags/fg");
10
+ var _unsafeExpValNoExposure = require("@atlaskit/platform-feature-experiments/unsafe-exp-val-no-exposure");
11
11
  var _main = require("../../../main");
12
12
  var _constants = require("./constants");
13
13
  var _policy = require("./policy");
@@ -60,8 +60,9 @@ var isChatFocused = function isChatFocused() {
60
60
  var ExtensionContextBridgeClient = exports.ExtensionContextBridgeClient = function ExtensionContextBridgeClient(_ref) {
61
61
  var transport = _ref.transport,
62
62
  allowedOrigins = _ref.allowedOrigins;
63
- // Self-gated. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
64
- var active = (0, _fg.fg)('rovo-ext_context_bridge');
63
+ // Self-gated. Matches `EXT_CONTEXT_BRIDGE_EXPERIMENT` in `./constants`. No-exposure read: the
64
+ // extension decides whether to mount this and owns the exposure call.
65
+ var active = (0, _unsafeExpValNoExposure.UNSAFE_expValNoExposure)('rovo-ext_context_bridge_exp', 'isEnabled', false) === true;
65
66
  var publish = (0, _main.usePublish)('ai-mate');
66
67
  var lastKnownSelectionRef = (0, _react.useRef)(null);
67
68
  var activeTransport = (0, _react.useMemo)(function () {
@@ -161,12 +162,12 @@ var ExtensionContextBridgeClient = exports.ExtensionContextBridgeClient = functi
161
162
  if (!(0, _policy.shouldRelayToProduct)(payload.type)) {
162
163
  return;
163
164
  }
164
- var serialized = (0, _registry.serializePayload)(payload);
165
- if (!serialized) {
165
+ var result = (0, _registry.serializePayload)(payload);
166
+ if (!result.ok) {
166
167
  return;
167
168
  }
168
169
  try {
169
- activeTransport.send((0, _defineProperty2.default)((0, _defineProperty2.default)((0, _defineProperty2.default)({}, _constants.BRIDGE_MESSAGE_MARKER, true), "direction", _constants.BRIDGE_TO_PRODUCT), "payload", serialized));
170
+ activeTransport.send((0, _defineProperty2.default)((0, _defineProperty2.default)((0, _defineProperty2.default)({}, _constants.BRIDGE_MESSAGE_MARKER, true), "direction", _constants.BRIDGE_TO_PRODUCT), "payload", result.payload));
170
171
  } catch (_unused3) {
171
172
  // Never let bridging break the chat iframe.
172
173
  }
@@ -6,10 +6,10 @@ Object.defineProperty(exports, "__esModule", {
6
6
  });
7
7
  exports.ExtensionContextBridgeHost = void 0;
8
8
  var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
9
- var _typeof2 = _interopRequireDefault(require("@babel/runtime/helpers/typeof"));
10
9
  var _react = require("react");
11
- var _fg = require("@atlaskit/platform-feature-flags/fg");
10
+ var _unsafeExpValNoExposure = require("@atlaskit/platform-feature-experiments/unsafe-exp-val-no-exposure");
12
11
  var _main = require("../../../main");
12
+ var _analytics = require("./analytics");
13
13
  var _constants = require("./constants");
14
14
  var _policy = require("./policy");
15
15
  var _registry = require("./registry");
@@ -40,14 +40,8 @@ var stampProductContentId = function stampProductContentId(serialized, targetCon
40
40
  * else is keyed by payload type, of which there is one live instance at a time.
41
41
  */
42
42
  var getContextSlotKey = function getContextSlotKey(payload) {
43
- if (payload.type === 'set-message-context') {
44
- var data = payload.data;
45
- var contextKey = (0, _typeof2.default)(data) === 'object' && data !== null && 'contextKey' in data ? data.contextKey : undefined;
46
- if (typeof contextKey === 'string') {
47
- return "".concat(payload.type, ":").concat(contextKey);
48
- }
49
- }
50
- return payload.type;
43
+ var contextKey = (0, _analytics.getContextKey)(payload);
44
+ return contextKey === undefined ? payload.type : "".concat(payload.type, ":").concat(contextKey);
51
45
  };
52
46
  /**
53
47
  * Product-window half of the bridge. Observes the `ai-mate` bus and mirrors allowlisted context out
@@ -59,10 +53,15 @@ var getContextSlotKey = function getContextSlotKey(payload) {
59
53
  var ExtensionContextBridgeHost = exports.ExtensionContextBridgeHost = function ExtensionContextBridgeHost(_ref) {
60
54
  var transport = _ref.transport,
61
55
  getContentId = _ref.getContentId,
62
- isAiEnabled = _ref.isAiEnabled;
56
+ isAiEnabled = _ref.isAiEnabled,
57
+ onRelayDecision = _ref.onRelayDecision;
63
58
  // Self-gated (not taken from the consumer) so this cannot mount by mistake wherever
64
- // `ChatOpenerSubscriber` renders. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
65
- var active = (0, _fg.fg)('rovo-ext_context_bridge');
59
+ // `ChatOpenerSubscriber` renders. Matches `EXT_CONTEXT_BRIDGE_EXPERIMENT` in `./constants`.
60
+ //
61
+ // No-exposure read on purpose: this component mounts on every page of an opted-in product, so
62
+ // firing exposure here would measure product page loads instead of extension users. The
63
+ // extension logs the one exposure. See `./constants`.
64
+ var active = (0, _unsafeExpValNoExposure.UNSAFE_expValNoExposure)('rovo-ext_context_bridge_exp', 'isEnabled', false) === true;
66
65
  var activeTransport = (0, _react.useMemo)(function () {
67
66
  if (!active) {
68
67
  return undefined;
@@ -87,6 +86,9 @@ var ExtensionContextBridgeHost = exports.ExtensionContextBridgeHost = function E
87
86
  */
88
87
  var lastRelayedRef = (0, _react.useRef)(new Map());
89
88
 
89
+ /** Reported decisions, so a per-keystroke payload yields one event rather than thousands. */
90
+ var reportedDecisionsRef = (0, _react.useRef)(new Set());
91
+
90
92
  // Answers `request-context` from the chat, which cannot observe context published before it
91
93
  // mounted. Products such as Jira publish once per navigation, so without this a chat opened after
92
94
  // the page settled would describe the wrong view — or none at all.
@@ -124,17 +126,32 @@ var ExtensionContextBridgeHost = exports.ExtensionContextBridgeHost = function E
124
126
  if (isAiEnabled === false) {
125
127
  return;
126
128
  }
127
- // Loop guard.
129
+ // Loop guard. Not reported: our own echo, not a decision about the product.
128
130
  if (payload.interactionSource === _constants.BRIDGE_SOURCE) {
129
131
  return;
130
132
  }
131
- if ((0, _policy.getInboundDecision)(payload.type) !== 'allow') {
133
+ var report = function report(decision) {
134
+ (0, _analytics.reportRelayDecision)(reportedDecisionsRef.current, onRelayDecision, {
135
+ payloadType: payload.type,
136
+ contextKey: (0, _analytics.getContextKey)(payload),
137
+ relayed: decision === 'allow',
138
+ decision: decision,
139
+ publisherSource: payload.source,
140
+ payloadProduct: payload.product
141
+ });
142
+ };
143
+ var inboundDecision = (0, _policy.getInboundDecision)(payload.type);
144
+ if (inboundDecision !== 'allow') {
145
+ report(inboundDecision);
132
146
  return;
133
147
  }
134
- var serialized = (0, _registry.serializePayload)(payload);
135
- if (!serialized) {
148
+ var result = (0, _registry.serializePayload)(payload);
149
+ if (!result.ok) {
150
+ report(result.reason);
136
151
  return;
137
152
  }
153
+ report('allow');
154
+ var serialized = result.payload;
138
155
  stampProductContentId(serialized, getContentId === null || getContentId === void 0 ? void 0 : getContentId());
139
156
  var message = (0, _defineProperty2.default)((0, _defineProperty2.default)((0, _defineProperty2.default)({}, _constants.BRIDGE_MESSAGE_MARKER, true), "direction", _constants.BRIDGE_TO_EXTENSION), "payload", serialized);
140
157
  // Keyed by context slot, not by message, so a re-publish replaces rather than accumulates.
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.reportRelayDecision = exports.getContextKey = void 0;
8
+ var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
9
+ var _typeof2 = _interopRequireDefault(require("@babel/runtime/helpers/typeof"));
10
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
11
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
12
+ /**
13
+ * Inbound relay decision reporting: what the product asked the bridge to relay, and what the policy
14
+ * in `./policy` / `./registry` decided. Every refusal is otherwise silent, so the allowlist can only
15
+ * be widened from data with this.
16
+ *
17
+ * Product → extension only, hence no direction field. The outbound allowlist has one entry and is
18
+ * owned by the chat, so products cannot drift into it.
19
+ *
20
+ * Consumer-supplied callback rather than an event fired here, for the same reason as the Host's
21
+ * `getContentId`: this package has no analytics dependency.
22
+ */
23
+
24
+ /**
25
+ * Why `./registry` refused to serialise a payload. Kept distinct because the fixes differ:
26
+ * `context-key-not-allowlisted` is a list we can widen, the rest are publisher-side shape problems.
27
+ */
28
+
29
+ /** Every outcome the bridge can reach for a payload it was asked to relay. */
30
+
31
+ /** A slot identifier by contract, capped in case a publisher ever interpolates an id into one. */
32
+ var MAX_CONTEXT_KEY_LENGTH = 80;
33
+
34
+ /**
35
+ * (payload types × decisions) is finite and far below this; the cap is for `contextKey`, which is
36
+ * not. A publisher generating keys would otherwise grow the dedupe set for the life of the page.
37
+ */
38
+ var MAX_REPORTED_DECISIONS = 50;
39
+ /**
40
+ * The context slot a `set-message-context` payload addresses. Shape-checked rather than cast: runs
41
+ * against the raw bus payload, the serialized form, and types carrying neither. The Host also keys
42
+ * its replay map on this.
43
+ */
44
+ var getContextKey = exports.getContextKey = function getContextKey(payload) {
45
+ if (payload.type !== 'set-message-context') {
46
+ return undefined;
47
+ }
48
+ var data = payload.data;
49
+ var contextKey = (0, _typeof2.default)(data) === 'object' && data !== null && 'contextKey' in data ? data.contextKey : undefined;
50
+ return typeof contextKey === 'string' ? contextKey : undefined;
51
+ };
52
+
53
+ /** Identity of a decision for deduplication purposes. */
54
+ var getDecisionKey = function getDecisionKey(_ref) {
55
+ var payloadType = _ref.payloadType,
56
+ contextKey = _ref.contextKey,
57
+ decision = _ref.decision;
58
+ return "".concat(payloadType, "|").concat(contextKey !== null && contextKey !== void 0 ? contextKey : '', "|").concat(decision);
59
+ };
60
+
61
+ /**
62
+ * Report a relay decision at most once per distinct outcome. Deduplicated because the bridge sees
63
+ * the whole product bus and `editor-context-payload` recurs on roughly every keystroke; which keys
64
+ * exist and how they were decided is the question, not how often each recurred.
65
+ *
66
+ * `seen` is caller-owned (a ref in the Host), so the window is one mount. Never throws.
67
+ */
68
+ var reportRelayDecision = exports.reportRelayDecision = function reportRelayDecision(seen, onRelayDecision, decision) {
69
+ var _decision$contextKey;
70
+ if (!onRelayDecision) {
71
+ return;
72
+ }
73
+ var truncatedContextKey = (_decision$contextKey = decision.contextKey) === null || _decision$contextKey === void 0 ? void 0 : _decision$contextKey.slice(0, MAX_CONTEXT_KEY_LENGTH);
74
+ var reportable = _objectSpread(_objectSpread({}, decision), {}, {
75
+ contextKey: truncatedContextKey
76
+ });
77
+ var key = getDecisionKey(reportable);
78
+ if (seen.has(key) || seen.size >= MAX_REPORTED_DECISIONS) {
79
+ return;
80
+ }
81
+ seen.add(key);
82
+ try {
83
+ onRelayDecision(reportable);
84
+ } catch (_unused) {
85
+ // Reporting is observability, never a reason for the relay to fail.
86
+ }
87
+ };
@@ -3,13 +3,19 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.BRIDGE_TO_PRODUCT = exports.BRIDGE_TO_EXTENSION = exports.BRIDGE_SOURCE = exports.BRIDGE_MESSAGE_MARKER = exports.BRIDGE_FEATURE_GATE = exports.BRIDGE_CONTROL_REQUEST_CONTEXT = void 0;
6
+ exports.EXT_CONTEXT_BRIDGE_EXPERIMENT = exports.BRIDGE_TO_PRODUCT = exports.BRIDGE_TO_EXTENSION = exports.BRIDGE_SOURCE = exports.BRIDGE_MESSAGE_MARKER = exports.BRIDGE_CONTROL_REQUEST_CONTEXT = void 0;
7
7
  /**
8
- * Feature gate for the extension context bridge; both Host and Client self-gate on it.
9
- * `fg()` requires a static literal, so call sites inline this value rather than importing it;
10
- * tests resolve the gate through this constant so the two cannot drift apart.
8
+ * Statsig experiment for the extension context bridge; both Host and Client self-gate on its
9
+ * boolean `isEnabled` parameter. Call sites inline this value rather than importing it, since the
10
+ * experiment runtime takes a static literal; tests resolve the experiment through this constant so
11
+ * the two cannot drift apart.
12
+ *
13
+ * Read here **without** logging exposure. Host mounts at the root of every page of an opted-in
14
+ * product, so an exposure-firing read would count product page loads rather than extension users.
15
+ * The extension owns the single exposure call, at the point the user is on a page the bridge can
16
+ * act on.
11
17
  */
12
- var BRIDGE_FEATURE_GATE = exports.BRIDGE_FEATURE_GATE = 'rovo-ext_context_bridge';
18
+ var EXT_CONTEXT_BRIDGE_EXPERIMENT = exports.EXT_CONTEXT_BRIDGE_EXPERIMENT = 'rovo-ext_context_bridge_exp';
13
19
 
14
20
  /** Marker on every bridge `postMessage`, so listeners can ignore unrelated messages. */
15
21
  var BRIDGE_MESSAGE_MARKER = exports.BRIDGE_MESSAGE_MARKER = '__rovoExtContextBridge';
@@ -11,6 +11,18 @@ var _constants = require("./constants");
11
11
  var _policy = require("./policy");
12
12
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
13
13
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
14
+ /**
15
+ * Outcome of serialising a payload for the wire. A bare `| null` would collapse five distinct
16
+ * refusals into one, notably a non-allowlisted context key versus a merge-style publisher, which
17
+ * need different fixes. `reason` is reported as analytics (see `./analytics`).
18
+ */
19
+
20
+ var dropped = function dropped(reason) {
21
+ return {
22
+ ok: false,
23
+ reason: reason
24
+ };
25
+ };
14
26
  var base = function base(payload) {
15
27
  return {
16
28
  type: payload.type,
@@ -50,33 +62,36 @@ var SHIMS = {
50
62
  serialize: function serialize(payload) {
51
63
  var data = payload.data;
52
64
  if (!data || typeof data.setContext !== 'function') {
53
- return null;
65
+ return dropped('missing-set-context');
54
66
  }
55
67
  // Checked before `setContext` runs, so a non-allowlisted key never invokes product code.
56
68
  if (!(0, _policy.isRelayableContextKey)(data.contextKey)) {
57
- return null;
69
+ return dropped('context-key-not-allowlisted');
58
70
  }
59
71
  var value;
60
72
  try {
61
73
  value = data.setContext((0, _defineProperty2.default)({}, MERGE_PROBE_KEY, true));
62
74
  } catch (_unused) {
63
- return null;
75
+ return dropped('set-context-threw');
64
76
  }
65
77
  // Sentinel survived: merge-style, cannot be resolved to a standalone value.
66
78
  if ((0, _typeof2.default)(value) === 'object' && value !== null && MERGE_PROBE_KEY in value) {
67
- return null;
79
+ return dropped('merge-style-publisher');
68
80
  }
69
81
  try {
70
82
  JSON.stringify(value);
71
83
  } catch (_unused2) {
72
- return null;
84
+ return dropped('not-serializable');
73
85
  }
74
- return _objectSpread(_objectSpread({}, base(payload)), {}, {
75
- data: {
76
- contextKey: data.contextKey,
77
- value: value
78
- }
79
- });
86
+ return {
87
+ ok: true,
88
+ payload: _objectSpread(_objectSpread({}, base(payload)), {}, {
89
+ data: {
90
+ contextKey: data.contextKey,
91
+ value: value
92
+ }
93
+ })
94
+ };
80
95
  },
81
96
  deserialize: function deserialize(serialized) {
82
97
  var _serialized$source;
@@ -115,11 +130,14 @@ var serializePayload = exports.serializePayload = function serializePayload(payl
115
130
  try {
116
131
  data = rawData === undefined ? undefined : JSON.parse(JSON.stringify(rawData));
117
132
  } catch (_unused3) {
118
- return null;
133
+ return dropped('not-serializable');
119
134
  }
120
- return _objectSpread(_objectSpread({}, base(payload)), {}, {
121
- data: data
122
- });
135
+ return {
136
+ ok: true,
137
+ payload: _objectSpread(_objectSpread({}, base(payload)), {}, {
138
+ data: data
139
+ })
140
+ };
123
141
  };
124
142
 
125
143
  /** Reconstruct a payload from the wire, stamping the loop-guard sentinel. */
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useRef } from 'react';
2
- import { fg } from '@atlaskit/platform-feature-flags/fg';
2
+ import { UNSAFE_expValNoExposure } from '@atlaskit/platform-feature-experiments/unsafe-exp-val-no-exposure';
3
3
  import { usePublish, useSubscribeAll } from '../../../main';
4
4
  import { BRIDGE_CONTROL_REQUEST_CONTEXT, BRIDGE_MESSAGE_MARKER, BRIDGE_SOURCE, BRIDGE_TO_EXTENSION, BRIDGE_TO_PRODUCT } from './constants';
5
5
  import { shouldRelayToProduct } from './policy';
@@ -52,8 +52,9 @@ export const ExtensionContextBridgeClient = ({
52
52
  transport,
53
53
  allowedOrigins
54
54
  }) => {
55
- // Self-gated. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
56
- const active = fg('rovo-ext_context_bridge');
55
+ // Self-gated. Matches `EXT_CONTEXT_BRIDGE_EXPERIMENT` in `./constants`. No-exposure read: the
56
+ // extension decides whether to mount this and owns the exposure call.
57
+ const active = UNSAFE_expValNoExposure('rovo-ext_context_bridge_exp', 'isEnabled', false) === true;
57
58
  const publish = usePublish('ai-mate');
58
59
  const lastKnownSelectionRef = useRef(null);
59
60
  const activeTransport = useMemo(() => {
@@ -159,15 +160,15 @@ export const ExtensionContextBridgeClient = ({
159
160
  if (!shouldRelayToProduct(payload.type)) {
160
161
  return;
161
162
  }
162
- const serialized = serializePayload(payload);
163
- if (!serialized) {
163
+ const result = serializePayload(payload);
164
+ if (!result.ok) {
164
165
  return;
165
166
  }
166
167
  try {
167
168
  activeTransport.send({
168
169
  [BRIDGE_MESSAGE_MARKER]: true,
169
170
  direction: BRIDGE_TO_PRODUCT,
170
- payload: serialized
171
+ payload: result.payload
171
172
  });
172
173
  } catch {
173
174
  // Never let bridging break the chat iframe.
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useRef } from 'react';
2
- import { fg } from '@atlaskit/platform-feature-flags/fg';
2
+ import { UNSAFE_expValNoExposure } from '@atlaskit/platform-feature-experiments/unsafe-exp-val-no-exposure';
3
3
  import { useSubscribeAll } from '../../../main';
4
+ import { getContextKey, reportRelayDecision } from './analytics';
4
5
  import { BRIDGE_CONTROL_REQUEST_CONTEXT, BRIDGE_MESSAGE_MARKER, BRIDGE_SOURCE, BRIDGE_TO_EXTENSION, BRIDGE_TO_PRODUCT } from './constants';
5
6
  import { getInboundDecision } from './policy';
6
7
  import { serializePayload } from './registry';
@@ -31,14 +32,8 @@ const stampProductContentId = (serialized, targetContentId) => {
31
32
  * else is keyed by payload type, of which there is one live instance at a time.
32
33
  */
33
34
  const getContextSlotKey = payload => {
34
- if (payload.type === 'set-message-context') {
35
- const data = payload.data;
36
- const contextKey = typeof data === 'object' && data !== null && 'contextKey' in data ? data.contextKey : undefined;
37
- if (typeof contextKey === 'string') {
38
- return `${payload.type}:${contextKey}`;
39
- }
40
- }
41
- return payload.type;
35
+ const contextKey = getContextKey(payload);
36
+ return contextKey === undefined ? payload.type : `${payload.type}:${contextKey}`;
42
37
  };
43
38
  /**
44
39
  * Product-window half of the bridge. Observes the `ai-mate` bus and mirrors allowlisted context out
@@ -50,11 +45,16 @@ const getContextSlotKey = payload => {
50
45
  export const ExtensionContextBridgeHost = ({
51
46
  transport,
52
47
  getContentId,
53
- isAiEnabled
48
+ isAiEnabled,
49
+ onRelayDecision
54
50
  }) => {
55
51
  // Self-gated (not taken from the consumer) so this cannot mount by mistake wherever
56
- // `ChatOpenerSubscriber` renders. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
57
- const active = fg('rovo-ext_context_bridge');
52
+ // `ChatOpenerSubscriber` renders. Matches `EXT_CONTEXT_BRIDGE_EXPERIMENT` in `./constants`.
53
+ //
54
+ // No-exposure read on purpose: this component mounts on every page of an opted-in product, so
55
+ // firing exposure here would measure product page loads instead of extension users. The
56
+ // extension logs the one exposure. See `./constants`.
57
+ const active = UNSAFE_expValNoExposure('rovo-ext_context_bridge_exp', 'isEnabled', false) === true;
58
58
  const activeTransport = useMemo(() => {
59
59
  if (!active) {
60
60
  return undefined;
@@ -79,6 +79,9 @@ export const ExtensionContextBridgeHost = ({
79
79
  */
80
80
  const lastRelayedRef = useRef(new Map());
81
81
 
82
+ /** Reported decisions, so a per-keystroke payload yields one event rather than thousands. */
83
+ const reportedDecisionsRef = useRef(new Set());
84
+
82
85
  // Answers `request-context` from the chat, which cannot observe context published before it
83
86
  // mounted. Products such as Jira publish once per navigation, so without this a chat opened after
84
87
  // the page settled would describe the wrong view — or none at all.
@@ -116,17 +119,32 @@ export const ExtensionContextBridgeHost = ({
116
119
  if (isAiEnabled === false) {
117
120
  return;
118
121
  }
119
- // Loop guard.
122
+ // Loop guard. Not reported: our own echo, not a decision about the product.
120
123
  if (payload.interactionSource === BRIDGE_SOURCE) {
121
124
  return;
122
125
  }
123
- if (getInboundDecision(payload.type) !== 'allow') {
126
+ const report = decision => {
127
+ reportRelayDecision(reportedDecisionsRef.current, onRelayDecision, {
128
+ payloadType: payload.type,
129
+ contextKey: getContextKey(payload),
130
+ relayed: decision === 'allow',
131
+ decision,
132
+ publisherSource: payload.source,
133
+ payloadProduct: payload.product
134
+ });
135
+ };
136
+ const inboundDecision = getInboundDecision(payload.type);
137
+ if (inboundDecision !== 'allow') {
138
+ report(inboundDecision);
124
139
  return;
125
140
  }
126
- const serialized = serializePayload(payload);
127
- if (!serialized) {
141
+ const result = serializePayload(payload);
142
+ if (!result.ok) {
143
+ report(result.reason);
128
144
  return;
129
145
  }
146
+ report('allow');
147
+ const serialized = result.payload;
130
148
  stampProductContentId(serialized, getContentId === null || getContentId === void 0 ? void 0 : getContentId());
131
149
  const message = {
132
150
  [BRIDGE_MESSAGE_MARKER]: true,
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Inbound relay decision reporting: what the product asked the bridge to relay, and what the policy
3
+ * in `./policy` / `./registry` decided. Every refusal is otherwise silent, so the allowlist can only
4
+ * be widened from data with this.
5
+ *
6
+ * Product → extension only, hence no direction field. The outbound allowlist has one entry and is
7
+ * owned by the chat, so products cannot drift into it.
8
+ *
9
+ * Consumer-supplied callback rather than an event fired here, for the same reason as the Host's
10
+ * `getContentId`: this package has no analytics dependency.
11
+ */
12
+
13
+ /**
14
+ * Why `./registry` refused to serialise a payload. Kept distinct because the fixes differ:
15
+ * `context-key-not-allowlisted` is a list we can widen, the rest are publisher-side shape problems.
16
+ */
17
+
18
+ /** Every outcome the bridge can reach for a payload it was asked to relay. */
19
+
20
+ /** A slot identifier by contract, capped in case a publisher ever interpolates an id into one. */
21
+ const MAX_CONTEXT_KEY_LENGTH = 80;
22
+
23
+ /**
24
+ * (payload types × decisions) is finite and far below this; the cap is for `contextKey`, which is
25
+ * not. A publisher generating keys would otherwise grow the dedupe set for the life of the page.
26
+ */
27
+ const MAX_REPORTED_DECISIONS = 50;
28
+ /**
29
+ * The context slot a `set-message-context` payload addresses. Shape-checked rather than cast: runs
30
+ * against the raw bus payload, the serialized form, and types carrying neither. The Host also keys
31
+ * its replay map on this.
32
+ */
33
+ export const getContextKey = payload => {
34
+ if (payload.type !== 'set-message-context') {
35
+ return undefined;
36
+ }
37
+ const data = payload.data;
38
+ const contextKey = typeof data === 'object' && data !== null && 'contextKey' in data ? data.contextKey : undefined;
39
+ return typeof contextKey === 'string' ? contextKey : undefined;
40
+ };
41
+
42
+ /** Identity of a decision for deduplication purposes. */
43
+ const getDecisionKey = ({
44
+ payloadType,
45
+ contextKey,
46
+ decision
47
+ }) => `${payloadType}|${contextKey !== null && contextKey !== void 0 ? contextKey : ''}|${decision}`;
48
+
49
+ /**
50
+ * Report a relay decision at most once per distinct outcome. Deduplicated because the bridge sees
51
+ * the whole product bus and `editor-context-payload` recurs on roughly every keystroke; which keys
52
+ * exist and how they were decided is the question, not how often each recurred.
53
+ *
54
+ * `seen` is caller-owned (a ref in the Host), so the window is one mount. Never throws.
55
+ */
56
+ export const reportRelayDecision = (seen, onRelayDecision, decision) => {
57
+ var _decision$contextKey;
58
+ if (!onRelayDecision) {
59
+ return;
60
+ }
61
+ const truncatedContextKey = (_decision$contextKey = decision.contextKey) === null || _decision$contextKey === void 0 ? void 0 : _decision$contextKey.slice(0, MAX_CONTEXT_KEY_LENGTH);
62
+ const reportable = {
63
+ ...decision,
64
+ contextKey: truncatedContextKey
65
+ };
66
+ const key = getDecisionKey(reportable);
67
+ if (seen.has(key) || seen.size >= MAX_REPORTED_DECISIONS) {
68
+ return;
69
+ }
70
+ seen.add(key);
71
+ try {
72
+ onRelayDecision(reportable);
73
+ } catch {
74
+ // Reporting is observability, never a reason for the relay to fail.
75
+ }
76
+ };
@@ -1,9 +1,15 @@
1
1
  /**
2
- * Feature gate for the extension context bridge; both Host and Client self-gate on it.
3
- * `fg()` requires a static literal, so call sites inline this value rather than importing it;
4
- * tests resolve the gate through this constant so the two cannot drift apart.
2
+ * Statsig experiment for the extension context bridge; both Host and Client self-gate on its
3
+ * boolean `isEnabled` parameter. Call sites inline this value rather than importing it, since the
4
+ * experiment runtime takes a static literal; tests resolve the experiment through this constant so
5
+ * the two cannot drift apart.
6
+ *
7
+ * Read here **without** logging exposure. Host mounts at the root of every page of an opted-in
8
+ * product, so an exposure-firing read would count product page loads rather than extension users.
9
+ * The extension owns the single exposure call, at the point the user is on a page the bridge can
10
+ * act on.
5
11
  */
6
- export const BRIDGE_FEATURE_GATE = 'rovo-ext_context_bridge';
12
+ export const EXT_CONTEXT_BRIDGE_EXPERIMENT = 'rovo-ext_context_bridge_exp';
7
13
 
8
14
  /** Marker on every bridge `postMessage`, so listeners can ignore unrelated messages. */
9
15
  export const BRIDGE_MESSAGE_MARKER = '__rovoExtContextBridge';
@@ -1,5 +1,16 @@
1
1
  import { BRIDGE_SOURCE } from './constants';
2
2
  import { isRelayableContextKey } from './policy';
3
+
4
+ /**
5
+ * Outcome of serialising a payload for the wire. A bare `| null` would collapse five distinct
6
+ * refusals into one, notably a non-allowlisted context key versus a merge-style publisher, which
7
+ * need different fixes. `reason` is reported as analytics (see `./analytics`).
8
+ */
9
+
10
+ const dropped = reason => ({
11
+ ok: false,
12
+ reason
13
+ });
3
14
  const base = payload => ({
4
15
  type: payload.type,
5
16
  source: payload.source,
@@ -36,11 +47,11 @@ const SHIMS = {
36
47
  serialize: payload => {
37
48
  const data = payload.data;
38
49
  if (!data || typeof data.setContext !== 'function') {
39
- return null;
50
+ return dropped('missing-set-context');
40
51
  }
41
52
  // Checked before `setContext` runs, so a non-allowlisted key never invokes product code.
42
53
  if (!isRelayableContextKey(data.contextKey)) {
43
- return null;
54
+ return dropped('context-key-not-allowlisted');
44
55
  }
45
56
  let value;
46
57
  try {
@@ -48,22 +59,25 @@ const SHIMS = {
48
59
  [MERGE_PROBE_KEY]: true
49
60
  });
50
61
  } catch {
51
- return null;
62
+ return dropped('set-context-threw');
52
63
  }
53
64
  // Sentinel survived: merge-style, cannot be resolved to a standalone value.
54
65
  if (typeof value === 'object' && value !== null && MERGE_PROBE_KEY in value) {
55
- return null;
66
+ return dropped('merge-style-publisher');
56
67
  }
57
68
  try {
58
69
  JSON.stringify(value);
59
70
  } catch {
60
- return null;
71
+ return dropped('not-serializable');
61
72
  }
62
73
  return {
63
- ...base(payload),
64
- data: {
65
- contextKey: data.contextKey,
66
- value
74
+ ok: true,
75
+ payload: {
76
+ ...base(payload),
77
+ data: {
78
+ contextKey: data.contextKey,
79
+ value
80
+ }
67
81
  }
68
82
  };
69
83
  },
@@ -100,11 +114,14 @@ export const serializePayload = payload => {
100
114
  try {
101
115
  data = rawData === undefined ? undefined : JSON.parse(JSON.stringify(rawData));
102
116
  } catch {
103
- return null;
117
+ return dropped('not-serializable');
104
118
  }
105
119
  return {
106
- ...base(payload),
107
- data
120
+ ok: true,
121
+ payload: {
122
+ ...base(payload),
123
+ data
124
+ }
108
125
  };
109
126
  };
110
127
 
@@ -2,7 +2,7 @@ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
2
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
3
3
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
4
4
  import { useEffect, useMemo, useRef } from 'react';
5
- import { fg } from '@atlaskit/platform-feature-flags/fg';
5
+ import { UNSAFE_expValNoExposure } from '@atlaskit/platform-feature-experiments/unsafe-exp-val-no-exposure';
6
6
  import { usePublish, useSubscribeAll } from '../../../main';
7
7
  import { BRIDGE_CONTROL_REQUEST_CONTEXT, BRIDGE_MESSAGE_MARKER, BRIDGE_SOURCE, BRIDGE_TO_EXTENSION, BRIDGE_TO_PRODUCT } from './constants';
8
8
  import { shouldRelayToProduct } from './policy';
@@ -54,8 +54,9 @@ var isChatFocused = function isChatFocused() {
54
54
  export var ExtensionContextBridgeClient = function ExtensionContextBridgeClient(_ref) {
55
55
  var transport = _ref.transport,
56
56
  allowedOrigins = _ref.allowedOrigins;
57
- // Self-gated. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
58
- var active = fg('rovo-ext_context_bridge');
57
+ // Self-gated. Matches `EXT_CONTEXT_BRIDGE_EXPERIMENT` in `./constants`. No-exposure read: the
58
+ // extension decides whether to mount this and owns the exposure call.
59
+ var active = UNSAFE_expValNoExposure('rovo-ext_context_bridge_exp', 'isEnabled', false) === true;
59
60
  var publish = usePublish('ai-mate');
60
61
  var lastKnownSelectionRef = useRef(null);
61
62
  var activeTransport = useMemo(function () {
@@ -155,12 +156,12 @@ export var ExtensionContextBridgeClient = function ExtensionContextBridgeClient(
155
156
  if (!shouldRelayToProduct(payload.type)) {
156
157
  return;
157
158
  }
158
- var serialized = serializePayload(payload);
159
- if (!serialized) {
159
+ var result = serializePayload(payload);
160
+ if (!result.ok) {
160
161
  return;
161
162
  }
162
163
  try {
163
- activeTransport.send(_defineProperty(_defineProperty(_defineProperty({}, BRIDGE_MESSAGE_MARKER, true), "direction", BRIDGE_TO_PRODUCT), "payload", serialized));
164
+ activeTransport.send(_defineProperty(_defineProperty(_defineProperty({}, BRIDGE_MESSAGE_MARKER, true), "direction", BRIDGE_TO_PRODUCT), "payload", result.payload));
164
165
  } catch (_unused3) {
165
166
  // Never let bridging break the chat iframe.
166
167
  }
@@ -1,8 +1,8 @@
1
1
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
- import _typeof from "@babel/runtime/helpers/typeof";
3
2
  import { useEffect, useMemo, useRef } from 'react';
4
- import { fg } from '@atlaskit/platform-feature-flags/fg';
3
+ import { UNSAFE_expValNoExposure } from '@atlaskit/platform-feature-experiments/unsafe-exp-val-no-exposure';
5
4
  import { useSubscribeAll } from '../../../main';
5
+ import { getContextKey, reportRelayDecision } from './analytics';
6
6
  import { BRIDGE_CONTROL_REQUEST_CONTEXT, BRIDGE_MESSAGE_MARKER, BRIDGE_SOURCE, BRIDGE_TO_EXTENSION, BRIDGE_TO_PRODUCT } from './constants';
7
7
  import { getInboundDecision } from './policy';
8
8
  import { serializePayload } from './registry';
@@ -33,14 +33,8 @@ var stampProductContentId = function stampProductContentId(serialized, targetCon
33
33
  * else is keyed by payload type, of which there is one live instance at a time.
34
34
  */
35
35
  var getContextSlotKey = function getContextSlotKey(payload) {
36
- if (payload.type === 'set-message-context') {
37
- var data = payload.data;
38
- var contextKey = _typeof(data) === 'object' && data !== null && 'contextKey' in data ? data.contextKey : undefined;
39
- if (typeof contextKey === 'string') {
40
- return "".concat(payload.type, ":").concat(contextKey);
41
- }
42
- }
43
- return payload.type;
36
+ var contextKey = getContextKey(payload);
37
+ return contextKey === undefined ? payload.type : "".concat(payload.type, ":").concat(contextKey);
44
38
  };
45
39
  /**
46
40
  * Product-window half of the bridge. Observes the `ai-mate` bus and mirrors allowlisted context out
@@ -52,10 +46,15 @@ var getContextSlotKey = function getContextSlotKey(payload) {
52
46
  export var ExtensionContextBridgeHost = function ExtensionContextBridgeHost(_ref) {
53
47
  var transport = _ref.transport,
54
48
  getContentId = _ref.getContentId,
55
- isAiEnabled = _ref.isAiEnabled;
49
+ isAiEnabled = _ref.isAiEnabled,
50
+ onRelayDecision = _ref.onRelayDecision;
56
51
  // Self-gated (not taken from the consumer) so this cannot mount by mistake wherever
57
- // `ChatOpenerSubscriber` renders. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
58
- var active = fg('rovo-ext_context_bridge');
52
+ // `ChatOpenerSubscriber` renders. Matches `EXT_CONTEXT_BRIDGE_EXPERIMENT` in `./constants`.
53
+ //
54
+ // No-exposure read on purpose: this component mounts on every page of an opted-in product, so
55
+ // firing exposure here would measure product page loads instead of extension users. The
56
+ // extension logs the one exposure. See `./constants`.
57
+ var active = UNSAFE_expValNoExposure('rovo-ext_context_bridge_exp', 'isEnabled', false) === true;
59
58
  var activeTransport = useMemo(function () {
60
59
  if (!active) {
61
60
  return undefined;
@@ -80,6 +79,9 @@ export var ExtensionContextBridgeHost = function ExtensionContextBridgeHost(_ref
80
79
  */
81
80
  var lastRelayedRef = useRef(new Map());
82
81
 
82
+ /** Reported decisions, so a per-keystroke payload yields one event rather than thousands. */
83
+ var reportedDecisionsRef = useRef(new Set());
84
+
83
85
  // Answers `request-context` from the chat, which cannot observe context published before it
84
86
  // mounted. Products such as Jira publish once per navigation, so without this a chat opened after
85
87
  // the page settled would describe the wrong view — or none at all.
@@ -117,17 +119,32 @@ export var ExtensionContextBridgeHost = function ExtensionContextBridgeHost(_ref
117
119
  if (isAiEnabled === false) {
118
120
  return;
119
121
  }
120
- // Loop guard.
122
+ // Loop guard. Not reported: our own echo, not a decision about the product.
121
123
  if (payload.interactionSource === BRIDGE_SOURCE) {
122
124
  return;
123
125
  }
124
- if (getInboundDecision(payload.type) !== 'allow') {
126
+ var report = function report(decision) {
127
+ reportRelayDecision(reportedDecisionsRef.current, onRelayDecision, {
128
+ payloadType: payload.type,
129
+ contextKey: getContextKey(payload),
130
+ relayed: decision === 'allow',
131
+ decision: decision,
132
+ publisherSource: payload.source,
133
+ payloadProduct: payload.product
134
+ });
135
+ };
136
+ var inboundDecision = getInboundDecision(payload.type);
137
+ if (inboundDecision !== 'allow') {
138
+ report(inboundDecision);
125
139
  return;
126
140
  }
127
- var serialized = serializePayload(payload);
128
- if (!serialized) {
141
+ var result = serializePayload(payload);
142
+ if (!result.ok) {
143
+ report(result.reason);
129
144
  return;
130
145
  }
146
+ report('allow');
147
+ var serialized = result.payload;
131
148
  stampProductContentId(serialized, getContentId === null || getContentId === void 0 ? void 0 : getContentId());
132
149
  var message = _defineProperty(_defineProperty(_defineProperty({}, BRIDGE_MESSAGE_MARKER, true), "direction", BRIDGE_TO_EXTENSION), "payload", serialized);
133
150
  // Keyed by context slot, not by message, so a re-publish replaces rather than accumulates.
@@ -0,0 +1,80 @@
1
+ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
+ import _typeof from "@babel/runtime/helpers/typeof";
3
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
4
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
5
+ /**
6
+ * Inbound relay decision reporting: what the product asked the bridge to relay, and what the policy
7
+ * in `./policy` / `./registry` decided. Every refusal is otherwise silent, so the allowlist can only
8
+ * be widened from data with this.
9
+ *
10
+ * Product → extension only, hence no direction field. The outbound allowlist has one entry and is
11
+ * owned by the chat, so products cannot drift into it.
12
+ *
13
+ * Consumer-supplied callback rather than an event fired here, for the same reason as the Host's
14
+ * `getContentId`: this package has no analytics dependency.
15
+ */
16
+
17
+ /**
18
+ * Why `./registry` refused to serialise a payload. Kept distinct because the fixes differ:
19
+ * `context-key-not-allowlisted` is a list we can widen, the rest are publisher-side shape problems.
20
+ */
21
+
22
+ /** Every outcome the bridge can reach for a payload it was asked to relay. */
23
+
24
+ /** A slot identifier by contract, capped in case a publisher ever interpolates an id into one. */
25
+ var MAX_CONTEXT_KEY_LENGTH = 80;
26
+
27
+ /**
28
+ * (payload types × decisions) is finite and far below this; the cap is for `contextKey`, which is
29
+ * not. A publisher generating keys would otherwise grow the dedupe set for the life of the page.
30
+ */
31
+ var MAX_REPORTED_DECISIONS = 50;
32
+ /**
33
+ * The context slot a `set-message-context` payload addresses. Shape-checked rather than cast: runs
34
+ * against the raw bus payload, the serialized form, and types carrying neither. The Host also keys
35
+ * its replay map on this.
36
+ */
37
+ export var getContextKey = function getContextKey(payload) {
38
+ if (payload.type !== 'set-message-context') {
39
+ return undefined;
40
+ }
41
+ var data = payload.data;
42
+ var contextKey = _typeof(data) === 'object' && data !== null && 'contextKey' in data ? data.contextKey : undefined;
43
+ return typeof contextKey === 'string' ? contextKey : undefined;
44
+ };
45
+
46
+ /** Identity of a decision for deduplication purposes. */
47
+ var getDecisionKey = function getDecisionKey(_ref) {
48
+ var payloadType = _ref.payloadType,
49
+ contextKey = _ref.contextKey,
50
+ decision = _ref.decision;
51
+ return "".concat(payloadType, "|").concat(contextKey !== null && contextKey !== void 0 ? contextKey : '', "|").concat(decision);
52
+ };
53
+
54
+ /**
55
+ * Report a relay decision at most once per distinct outcome. Deduplicated because the bridge sees
56
+ * the whole product bus and `editor-context-payload` recurs on roughly every keystroke; which keys
57
+ * exist and how they were decided is the question, not how often each recurred.
58
+ *
59
+ * `seen` is caller-owned (a ref in the Host), so the window is one mount. Never throws.
60
+ */
61
+ export var reportRelayDecision = function reportRelayDecision(seen, onRelayDecision, decision) {
62
+ var _decision$contextKey;
63
+ if (!onRelayDecision) {
64
+ return;
65
+ }
66
+ var truncatedContextKey = (_decision$contextKey = decision.contextKey) === null || _decision$contextKey === void 0 ? void 0 : _decision$contextKey.slice(0, MAX_CONTEXT_KEY_LENGTH);
67
+ var reportable = _objectSpread(_objectSpread({}, decision), {}, {
68
+ contextKey: truncatedContextKey
69
+ });
70
+ var key = getDecisionKey(reportable);
71
+ if (seen.has(key) || seen.size >= MAX_REPORTED_DECISIONS) {
72
+ return;
73
+ }
74
+ seen.add(key);
75
+ try {
76
+ onRelayDecision(reportable);
77
+ } catch (_unused) {
78
+ // Reporting is observability, never a reason for the relay to fail.
79
+ }
80
+ };
@@ -1,9 +1,15 @@
1
1
  /**
2
- * Feature gate for the extension context bridge; both Host and Client self-gate on it.
3
- * `fg()` requires a static literal, so call sites inline this value rather than importing it;
4
- * tests resolve the gate through this constant so the two cannot drift apart.
2
+ * Statsig experiment for the extension context bridge; both Host and Client self-gate on its
3
+ * boolean `isEnabled` parameter. Call sites inline this value rather than importing it, since the
4
+ * experiment runtime takes a static literal; tests resolve the experiment through this constant so
5
+ * the two cannot drift apart.
6
+ *
7
+ * Read here **without** logging exposure. Host mounts at the root of every page of an opted-in
8
+ * product, so an exposure-firing read would count product page loads rather than extension users.
9
+ * The extension owns the single exposure call, at the point the user is on a page the bridge can
10
+ * act on.
5
11
  */
6
- export var BRIDGE_FEATURE_GATE = 'rovo-ext_context_bridge';
12
+ export var EXT_CONTEXT_BRIDGE_EXPERIMENT = 'rovo-ext_context_bridge_exp';
7
13
 
8
14
  /** Marker on every bridge `postMessage`, so listeners can ignore unrelated messages. */
9
15
  export var BRIDGE_MESSAGE_MARKER = '__rovoExtContextBridge';
@@ -4,6 +4,19 @@ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbol
4
4
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
5
5
  import { BRIDGE_SOURCE } from './constants';
6
6
  import { isRelayableContextKey } from './policy';
7
+
8
+ /**
9
+ * Outcome of serialising a payload for the wire. A bare `| null` would collapse five distinct
10
+ * refusals into one, notably a non-allowlisted context key versus a merge-style publisher, which
11
+ * need different fixes. `reason` is reported as analytics (see `./analytics`).
12
+ */
13
+
14
+ var dropped = function dropped(reason) {
15
+ return {
16
+ ok: false,
17
+ reason: reason
18
+ };
19
+ };
7
20
  var base = function base(payload) {
8
21
  return {
9
22
  type: payload.type,
@@ -43,33 +56,36 @@ var SHIMS = {
43
56
  serialize: function serialize(payload) {
44
57
  var data = payload.data;
45
58
  if (!data || typeof data.setContext !== 'function') {
46
- return null;
59
+ return dropped('missing-set-context');
47
60
  }
48
61
  // Checked before `setContext` runs, so a non-allowlisted key never invokes product code.
49
62
  if (!isRelayableContextKey(data.contextKey)) {
50
- return null;
63
+ return dropped('context-key-not-allowlisted');
51
64
  }
52
65
  var value;
53
66
  try {
54
67
  value = data.setContext(_defineProperty({}, MERGE_PROBE_KEY, true));
55
68
  } catch (_unused) {
56
- return null;
69
+ return dropped('set-context-threw');
57
70
  }
58
71
  // Sentinel survived: merge-style, cannot be resolved to a standalone value.
59
72
  if (_typeof(value) === 'object' && value !== null && MERGE_PROBE_KEY in value) {
60
- return null;
73
+ return dropped('merge-style-publisher');
61
74
  }
62
75
  try {
63
76
  JSON.stringify(value);
64
77
  } catch (_unused2) {
65
- return null;
78
+ return dropped('not-serializable');
66
79
  }
67
- return _objectSpread(_objectSpread({}, base(payload)), {}, {
68
- data: {
69
- contextKey: data.contextKey,
70
- value: value
71
- }
72
- });
80
+ return {
81
+ ok: true,
82
+ payload: _objectSpread(_objectSpread({}, base(payload)), {}, {
83
+ data: {
84
+ contextKey: data.contextKey,
85
+ value: value
86
+ }
87
+ })
88
+ };
73
89
  },
74
90
  deserialize: function deserialize(serialized) {
75
91
  var _serialized$source;
@@ -108,11 +124,14 @@ export var serializePayload = function serializePayload(payload) {
108
124
  try {
109
125
  data = rawData === undefined ? undefined : JSON.parse(JSON.stringify(rawData));
110
126
  } catch (_unused3) {
111
- return null;
127
+ return dropped('not-serializable');
112
128
  }
113
- return _objectSpread(_objectSpread({}, base(payload)), {}, {
114
- data: data
115
- });
129
+ return {
130
+ ok: true,
131
+ payload: _objectSpread(_objectSpread({}, base(payload)), {}, {
132
+ data: data
133
+ })
134
+ };
116
135
  };
117
136
 
118
137
  /** Reconstruct a payload from the wire, stamping the loop-guard sentinel. */
@@ -1,3 +1,4 @@
1
+ import { type OnBridgeRelayDecision } from './analytics';
1
2
  import { type Transport } from './transport';
2
3
  type Props = {
3
4
  /** Override the transport (tests). Defaults to same-window `postMessage`. */
@@ -14,6 +15,12 @@ type Props = {
14
15
  * mount passes it explicitly, so that only covers bespoke embeddings.
15
16
  */
16
17
  isAiEnabled?: boolean;
18
+ /**
19
+ * Called with what the relay policy decided for each payload this product published, at most once
20
+ * per distinct outcome per mount. Consumer-supplied because the analytics client lives outside
21
+ * this package. Omitted, nothing is reported. See `./analytics`.
22
+ */
23
+ onRelayDecision?: OnBridgeRelayDecision;
17
24
  };
18
25
  /**
19
26
  * Product-window half of the bridge. Observes the `ai-mate` bus and mirrors allowlisted context out
@@ -22,5 +29,5 @@ type Props = {
22
29
  * The reverse direction is handled by the sibling `RovoPostMessagePubsubListener` in
23
30
  * `ChatOpenerSubscriber`, not here. Headless; mounts at product root via `ChatOpenerSubscriber`.
24
31
  */
25
- export declare const ExtensionContextBridgeHost: ({ transport, getContentId, isAiEnabled, }: Props) => null;
32
+ export declare const ExtensionContextBridgeHost: ({ transport, getContentId, isAiEnabled, onRelayDecision, }: Props) => null;
26
33
  export {};
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Inbound relay decision reporting: what the product asked the bridge to relay, and what the policy
3
+ * in `./policy` / `./registry` decided. Every refusal is otherwise silent, so the allowlist can only
4
+ * be widened from data with this.
5
+ *
6
+ * Product → extension only, hence no direction field. The outbound allowlist has one entry and is
7
+ * owned by the chat, so products cannot drift into it.
8
+ *
9
+ * Consumer-supplied callback rather than an event fired here, for the same reason as the Host's
10
+ * `getContentId`: this package has no analytics dependency.
11
+ */
12
+ import { type Payload } from '../../../types';
13
+ import { type InboundDecision } from './policy';
14
+ /**
15
+ * Why `./registry` refused to serialise a payload. Kept distinct because the fixes differ:
16
+ * `context-key-not-allowlisted` is a list we can widen, the rest are publisher-side shape problems.
17
+ */
18
+ export type SerializationDropReason = 'missing-set-context' | 'context-key-not-allowlisted' | 'set-context-threw' | 'merge-style-publisher' | 'not-serializable';
19
+ /** Every outcome the bridge can reach for a payload it was asked to relay. */
20
+ export type RelayDecision = InboundDecision | SerializationDropReason;
21
+ export type BridgeRelayDecision = {
22
+ /** The bus payload type, e.g. `editor-context-payload`. */
23
+ payloadType: Payload['type'];
24
+ /** Context slot for `set-message-context`; undefined for every other type. */
25
+ contextKey?: string;
26
+ /**
27
+ * Whether the policy admitted it. Reported before the `postMessage`, so a throwing transport
28
+ * reads as a broken channel elsewhere rather than a policy refusal here.
29
+ */
30
+ relayed: boolean;
31
+ /** Why, in the policy's own vocabulary. */
32
+ decision: RelayDecision;
33
+ /** The surface that published it, i.e. the payload's own `source`. */
34
+ publisherSource?: string;
35
+ /** The product the publisher stamped on the payload. */
36
+ payloadProduct?: string;
37
+ };
38
+ export type OnBridgeRelayDecision = (decision: BridgeRelayDecision) => void;
39
+ /**
40
+ * The context slot a `set-message-context` payload addresses. Shape-checked rather than cast: runs
41
+ * against the raw bus payload, the serialized form, and types carrying neither. The Host also keys
42
+ * its replay map on this.
43
+ */
44
+ export declare const getContextKey: (payload: {
45
+ type: string;
46
+ data?: unknown;
47
+ }) => string | undefined;
48
+ /**
49
+ * Report a relay decision at most once per distinct outcome. Deduplicated because the bridge sees
50
+ * the whole product bus and `editor-context-payload` recurs on roughly every keystroke; which keys
51
+ * exist and how they were decided is the question, not how often each recurred.
52
+ *
53
+ * `seen` is caller-owned (a ref in the Host), so the window is one mount. Never throws.
54
+ */
55
+ export declare const reportRelayDecision: (seen: Set<string>, onRelayDecision: OnBridgeRelayDecision | undefined, decision: BridgeRelayDecision) => void;
@@ -1,9 +1,15 @@
1
1
  /**
2
- * Feature gate for the extension context bridge; both Host and Client self-gate on it.
3
- * `fg()` requires a static literal, so call sites inline this value rather than importing it;
4
- * tests resolve the gate through this constant so the two cannot drift apart.
2
+ * Statsig experiment for the extension context bridge; both Host and Client self-gate on its
3
+ * boolean `isEnabled` parameter. Call sites inline this value rather than importing it, since the
4
+ * experiment runtime takes a static literal; tests resolve the experiment through this constant so
5
+ * the two cannot drift apart.
6
+ *
7
+ * Read here **without** logging exposure. Host mounts at the root of every page of an opted-in
8
+ * product, so an exposure-firing read would count product page loads rather than extension users.
9
+ * The extension owns the single exposure call, at the point the user is on a page the bridge can
10
+ * act on.
5
11
  */
6
- export declare const BRIDGE_FEATURE_GATE = "rovo-ext_context_bridge";
12
+ export declare const EXT_CONTEXT_BRIDGE_EXPERIMENT = "rovo-ext_context_bridge_exp";
7
13
  /** Marker on every bridge `postMessage`, so listeners can ignore unrelated messages. */
8
14
  export declare const BRIDGE_MESSAGE_MARKER: '__rovoExtContextBridge';
9
15
  /** Read context flows product -> extension; actions flow extension -> product. */
@@ -2,9 +2,8 @@ import { type Payload } from '../../../types';
2
2
  /** True if a `set-message-context` payload carrying this `contextKey` may cross to the extension. */
3
3
  export declare const isRelayableContextKey: (contextKey: string) => boolean;
4
4
  /** Why a product-bus payload was or was not relayed. */
5
- type InboundDecision = 'allow' | 'not-allowlisted' | 'denylisted';
5
+ export type InboundDecision = 'allow' | 'not-allowlisted' | 'denylisted';
6
6
  /** Classify a product-bus payload. Relay iff `allow`. */
7
7
  export declare const getInboundDecision: (type: Payload['type']) => InboundDecision;
8
8
  /** True if a chat-bus payload of this type may be relayed back to the product. */
9
9
  export declare const shouldRelayToProduct: (type: Payload['type']) => boolean;
10
- export {};
@@ -1,6 +1,19 @@
1
1
  import { type Payload } from '../../../types';
2
+ import { type SerializationDropReason } from './analytics';
2
3
  import { type SerializedPayload } from './transport';
4
+ /**
5
+ * Outcome of serialising a payload for the wire. A bare `| null` would collapse five distinct
6
+ * refusals into one, notably a non-allowlisted context key versus a merge-style publisher, which
7
+ * need different fixes. `reason` is reported as analytics (see `./analytics`).
8
+ */
9
+ export type SerializationResult = {
10
+ ok: true;
11
+ payload: SerializedPayload;
12
+ } | {
13
+ ok: false;
14
+ reason: SerializationDropReason;
15
+ };
3
16
  /** Serialise a payload for the wire, using a shim if one exists, else a generic JSON round-trip. */
4
- export declare const serializePayload: (payload: Payload) => SerializedPayload | null;
17
+ export declare const serializePayload: (payload: Payload) => SerializationResult;
5
18
  /** Reconstruct a payload from the wire, stamping the loop-guard sentinel. */
6
19
  export declare const deserializePayload: (serialized: SerializedPayload) => Payload | null;
@@ -100,7 +100,7 @@ export type ChatCreationContextParams = {
100
100
  source?: string;
101
101
  shouldUseExistingContent?: boolean;
102
102
  isViewMode?: boolean;
103
- experience?: 'cwr' | 'cwr_type' | 'cwr_edit' | 'cwr_existing' | 'inline_edit' | 'remix' | 'remix_edit' | 'remix_object' | 'remix_custom' | 'chat_edit' | 'chat_view' | 'keep_existing_page_structure';
103
+ experience?: 'cwr' | 'cwr_type' | 'cwr_edit' | 'cwr_existing' | 'inline_edit' | 'remix' | 'remix_edit' | 'remix_object' | 'remix_custom' | 'remix_custom_edit' | 'chat_edit' | 'chat_view' | 'keep_existing_page_structure';
104
104
  contentTypes?: string[];
105
105
  blocks?: string[];
106
106
  spaceKey?: string;
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@atlaskit/rovo-triggers/extension-context-bridge/analytics",
3
+ "main": "../../dist/cjs/common/utils/extension-context-bridge/analytics.js",
4
+ "module": "../../dist/esm/common/utils/extension-context-bridge/analytics.js",
5
+ "module:es2019": "../../dist/es2019/common/utils/extension-context-bridge/analytics.js",
6
+ "sideEffects": [
7
+ "*.compiled.css"
8
+ ],
9
+ "types": "../../dist/types/common/utils/extension-context-bridge/analytics.d.ts"
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/rovo-triggers",
3
- "version": "10.37.0",
3
+ "version": "11.0.0",
4
4
  "description": "Provides various trigger events to drive Rovo Chat functionality, such as a publish-subscribe and URL parameter hooks",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "publishConfig": {
@@ -29,6 +29,7 @@
29
29
  "atlaskit:src": "src/index.ts",
30
30
  "dependencies": {
31
31
  "@atlaskit/adf-schema": "^57.4.0",
32
+ "@atlaskit/platform-feature-experiments": "^0.3.0",
32
33
  "@atlaskit/platform-feature-flags": "^2.2.0",
33
34
  "@atlaskit/react-compiler-gating": "^0.2.0",
34
35
  "@babel/runtime": "^7.0.0",
@@ -44,7 +45,8 @@
44
45
  "@atlaskit/primitives": "^22.5.0",
45
46
  "@atlaskit/ssr": "workspace:^",
46
47
  "@atlassian/a11y-jest-testing": "^0.17.0",
47
- "@atlassian/conversation-assistant-service-api": "^17.12.0",
48
+ "@atlassian/conversation-assistant-service-api": "^18.0.0",
49
+ "@atlassian/experiment-test-utils": "^0.2.0",
48
50
  "@atlassian/feature-flags-test-utils": "^1.3.0",
49
51
  "@atlassian/testing-library": "^0.11.0",
50
52
  "@atlassian/workbench": "workspace:*",
@@ -54,9 +56,6 @@
54
56
  "wait-for-expect": "^1.2.0"
55
57
  },
56
58
  "platform-feature-flags": {
57
- "rovo-ext_context_bridge": {
58
- "type": "boolean"
59
- },
60
59
  "rovo_chat_replace_url_param_history": {
61
60
  "type": "boolean"
62
61
  }