@nominalso/vibe-host 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -29,9 +29,9 @@ const host = new VibeAppHost({
29
29
  clientConfig: { baseUrl: '/api/proxy', stripApiPrefix: true },
30
30
  })
31
31
 
32
- // Start listening BEFORE the iframe loads, then push context on its `load` event.
32
+ // Start listening. The iframe identifies itself via its CONNECT handshake, and
33
+ // the host replies with its context — no iframe window needed up front.
33
34
  const unmount = host.mount()
34
- // once the iframe has loaded: host.pushContextTo(iframe.contentWindow)
35
35
  ```
36
36
 
37
37
  ## API
@@ -58,56 +58,40 @@ const trustedOrigins =
58
58
  : ['https://app.nominal.so', 'https://*.vercel.app', 'https://*.lovable.app']
59
59
  ```
60
60
 
61
- Because you cannot `postMessage` to a pattern, proactive context/subroute pushes target the exact entries plus any origin seen on a validated inbound message; a pattern-matched iframe still receives context via its `connect()` poll response.
61
+ Because you cannot `postMessage` to a pattern, proactive pushes target the exact allowlist entries plus the connected iframe's learned origin; a pattern-matched iframe is reached via the concrete origin learned from its `CONNECT` handshake.
62
62
 
63
- ### `mount(iframeWindow?): () => void`
63
+ ### `mount(): () => void`
64
64
 
65
- Starts listening for bridge messages and sets up browser back/forward sync. Returns a cleanup function. Pass `iframeWindow` to immediately push context; if omitted, the host answers `GET_CONTEXT` polls but won't proactively push until you call `pushContextTo`.
65
+ Starts listening for bridge messages and sets up browser back/forward sync. Returns a cleanup function. Takes no iframe window the iframe identifies itself via its `CONNECT` handshake (and re-identifies on every reload), and the host replies with its context.
66
66
 
67
- ### `pushContextTo(iframeWindow): void`
67
+ ### `refreshContext(): void`
68
68
 
69
- Pushes the current context to the iframe and stores the window reference for future pushes (e.g. subroute sync). Call once the iframe has loaded if you called `mount()` with no argument.
69
+ Re-reads `getContext()` and pushes it to the connected iframe (e.g. after a tenant/subsidiary switch), so the app reacts via `bridge.onContextChange`. No-op until the iframe has connected.
70
+
71
+ ### `notifyAuthChange(auth): void`
72
+
73
+ Pushes a Nominal auth change (logout, or identity/tenant switch) to the connected iframe, so the app reacts via `bridge.onAuthChange`. No-op until the iframe has connected.
70
74
 
71
75
  ### Exports
72
76
 
73
- `VibeAppHost`, and the types `VibeAppHostOptions`, `HostContext`, `HostHandlers`, `RequestHandlers`, `ContextPayload`, `VibeApiClientOptions`.
77
+ `VibeAppHost`, and the types `VibeAppHostOptions`, `HostContext`, `HostHandlers`, `RequestHandlers`, `ContextPayload`, `AuthPayload`, `VibeApiClientOptions`.
74
78
 
75
79
  ## Mounting inside Next.js (nom-ui)
76
80
 
77
- The iframe is server-rendered, so it may start loading before React hydrates, and cross-origin `contentDocument` is always `null`. The robust pattern: `mount()` (no arg) immediately to start listening, then `pushContextTo(iframe.contentWindow)` from the iframe's `load` event.
81
+ Just `mount()` in an effect — no iframe-window handling, no `load`-event dance. The iframe is server-rendered and may load before React hydrates, but that's fine: its bridge re-sends `CONNECT` until the host (now listening) answers, so there's no race.
78
82
 
79
83
  ```tsx
80
84
  'use client'
81
- import { useEffect, useRef } from 'react'
85
+ import { useEffect } from 'react'
82
86
  import { VibeAppHost } from '@nominalso/vibe-host'
83
87
 
84
88
  function VibeAppFrame({ host, src }: { host: VibeAppHost; src: string }) {
85
- const iframeRef = useRef<HTMLIFrameElement>(null)
86
-
87
- useEffect(() => {
88
- const iframe = iframeRef.current
89
- if (!iframe) return
90
- const unmount = host.mount() // listen before the iframe loads
91
- const onLoad = () => iframe.contentWindow && host.pushContextTo(iframe.contentWindow)
92
- iframe.addEventListener('load', onLoad)
93
- return () => {
94
- iframe.removeEventListener('load', onLoad)
95
- unmount()
96
- }
97
- }, [host])
98
-
99
- return (
100
- <iframe
101
- ref={iframeRef}
102
- src={src}
103
- sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
104
- />
105
- )
89
+ useEffect(() => host.mount(), [host]) // returns the unmount cleanup
90
+
91
+ return <iframe src={src} sandbox="allow-scripts allow-forms allow-same-origin allow-popups" />
106
92
  }
107
93
  ```
108
94
 
109
- Calling `contentWindow.postMessage` while the iframe is still at `about:blank` throws a `DOMException` — that's why context is pushed from the `load` event, not on effect setup.
110
-
111
95
  ## How it fits together
112
96
 
113
97
  The iframe side is [`@nominalso/vibe-bridge`](https://www.npmjs.com/package/@nominalso/vibe-bridge). See the [repository](https://github.com/nominalso/vibe-apps-sdk#readme) for the full protocol and architecture.
package/dist/index.cjs CHANGED
@@ -27,7 +27,7 @@ __export(index_exports, {
27
27
  module.exports = __toCommonJS(index_exports);
28
28
 
29
29
  // package.json
30
- var version = "0.4.0";
30
+ var version = "0.5.0";
31
31
 
32
32
  // ../protocol-types/dist/index.js
33
33
  function normalizeSubroute(subroute) {
@@ -43,7 +43,7 @@ function normalizeSubroute(subroute) {
43
43
  return normalized;
44
44
  }
45
45
  var PROTOCOL_ID = "nominal-vibe-bridge";
46
- var PROTOCOL_VERSION = 1;
46
+ var PROTOCOL_VERSION = 2;
47
47
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
48
48
  var CORRELATED_KINDS = ["request", "response", "progress"];
49
49
  var BRIDGE_KINDS_SET = new Set(BRIDGE_KINDS);
@@ -657,18 +657,20 @@ function rejectUnwired(op) {
657
657
  var VibeAppHost = class {
658
658
  constructor(opts) {
659
659
  /**
660
- * Concrete iframe origins observed on validated inbound messages. Because you
661
- * cannot `postMessage` to a glob pattern, proactive pushes target these
662
- * learned origins (plus the exact allowlist entries) rather than the patterns.
660
+ * The connected iframe, as two facets of one thing: `connectedWindow` is its
661
+ * `Window` (identity + the single push target) and `connectedOrigin` is its
662
+ * origin (what you `postMessage` to you cannot post to a `Window` ref, nor to
663
+ * a glob). Both are written together by {@link handleConnect} (the sole writer)
664
+ * and cleared together on unmount, so they can never diverge.
665
+ *
666
+ * There is deliberately no other window reference. The connected window can
667
+ * only be learned from each `CONNECT` handshake's `event.source`, never at
668
+ * mount: a reloaded/remounted iframe is a *new* `Window` with the same origin,
669
+ * so a mount-time window would be stale. The gate blocks every non-`CONNECT`
670
+ * message whose source isn't `connectedWindow`; every proactive push targets it.
663
671
  */
664
- this.connectedOrigins = /* @__PURE__ */ new Set();
665
- this.iframeWindow = null;
666
- /**
667
- * Source windows we've already logged a successful handshake for. The bridge
668
- * polls `GET_CONTEXT` on an interval until it connects, so without this the
669
- * "Vibe App connected" line repeats on every poll (SSOT §13 F3).
670
- */
671
- this.handshakeLoggedWindows = /* @__PURE__ */ new WeakSet();
672
+ this.connectedWindow = null;
673
+ this.connectedOrigin = null;
672
674
  this.kindHandlers = {
673
675
  request: (msg, source, origin) => this.handleRequest(msg, source, origin),
674
676
  command: (msg) => this.dispatchCommand(msg)
@@ -677,6 +679,7 @@ var VibeAppHost = class {
677
679
  this.trustedOrigins = new Set(trustedOrigins);
678
680
  this.exactOrigins = trustedOrigins.filter((entry) => !isOriginPattern(entry));
679
681
  this.getContext = getContext;
682
+ this.getPostHog = opts.getPostHog;
680
683
  this.appBasePath = appBasePath;
681
684
  this.onRequestComplete = opts.onRequestComplete;
682
685
  this.rateLimiter = createRateLimiter(opts.rateLimit ?? DEFAULT_RATE_LIMIT);
@@ -689,7 +692,8 @@ var VibeAppHost = class {
689
692
  this.commandHandlers = this.buildCommandHandlers(opts);
690
693
  }
691
694
  dispatchCommand(msg) {
692
- const handler = this.commandHandlers[msg.type];
695
+ const handlers = this.commandHandlers;
696
+ const handler = handlers[msg.type];
693
697
  if (!handler) {
694
698
  console.warn(`[vibe-host] Received unknown command "${msg.type}" \u2014 ignoring.`);
695
699
  return;
@@ -735,31 +739,41 @@ var VibeAppHost = class {
735
739
  * Starts listening for bridge messages and sets up browser back/forward sync.
736
740
  * Returns a cleanup function to call on unmount.
737
741
  *
738
- * Pass `iframeWindow` to immediately push context to the iframe. If omitted,
739
- * the host will respond to GET_CONTEXT polls from the bridge but won't
740
- * proactively push call `pushContextTo(iframeWindow)` once the iframe has
741
- * loaded to trigger the proactive push.
742
+ * Takes no iframe window: the host learns the iframe from its `CONNECT`
743
+ * handshake (and re-learns it on every reload), then delivers context as the
744
+ * reply to that handshake. Just call `mount()` once the host exists.
742
745
  */
743
- mount(iframeWindow) {
744
- if (iframeWindow) this.iframeWindow = iframeWindow;
746
+ mount() {
745
747
  window.addEventListener("message", this.boundHandleMessage);
746
748
  window.addEventListener("popstate", this.boundHandlePopState);
747
- if (iframeWindow) this.pushContext(iframeWindow);
748
749
  return () => {
749
750
  window.removeEventListener("message", this.boundHandleMessage);
750
751
  window.removeEventListener("popstate", this.boundHandlePopState);
751
- this.iframeWindow = null;
752
- this.connectedOrigins.clear();
752
+ this.connectedWindow = null;
753
+ this.connectedOrigin = null;
753
754
  };
754
755
  }
755
756
  /**
756
- * Pushes the current context to the iframe and updates the stored iframe
757
- * window reference. Call this once the iframe has loaded if `mount()` was
758
- * called without an `iframeWindow`.
757
+ * Re-reads `getContext()` and re-pushes it to the connected iframe, so an open
758
+ * app reacts to a tenant/subsidiary switch or period close without reloading.
759
+ * PostHog is not re-sent (it rides only the connect handshake). No-op until an
760
+ * iframe is mounted.
761
+ */
762
+ refreshContext() {
763
+ if (!this.connectedWindow) return;
764
+ this.pushContext(this.connectedWindow);
765
+ }
766
+ /**
767
+ * Notifies the connected iframe of a Nominal auth change — a logout
768
+ * (`authenticated: false`) or an identity/tenant switch (`authenticated: true`
769
+ * with a new `userId`/`tenant`) — via `AUTH_CHANGED`, so the app re-authenticates
770
+ * or signs its own backend out. No-op until the iframe has connected. When auth
771
+ * and context change together, call this *before* {@link refreshContext} ("auth
772
+ * change, then context").
759
773
  */
760
- pushContextTo(iframeWindow) {
761
- this.iframeWindow = iframeWindow;
762
- this.pushContext(iframeWindow);
774
+ notifyAuthChange(auth) {
775
+ if (!this.connectedWindow) return;
776
+ this.pushToIframe("AUTH_CHANGED", auth);
763
777
  }
764
778
  pushContext(target) {
765
779
  const message = {
@@ -774,14 +788,14 @@ var VibeAppHost = class {
774
788
  }
775
789
  }
776
790
  handlePopState() {
777
- if (!this.iframeWindow) return;
791
+ if (!this.connectedWindow) return;
778
792
  const currentPath = window.location.pathname;
779
793
  if (!currentPath.startsWith(this.appBasePath)) return;
780
794
  const subroute = currentPath.slice(this.appBasePath.length) || "/";
781
795
  this.pushToIframe("SUBROUTE_PUSH", { subroute });
782
796
  }
783
797
  pushToIframe(type, payload) {
784
- if (!this.iframeWindow) return;
798
+ if (!this.connectedWindow) return;
785
799
  const message = {
786
800
  __protocol: PROTOCOL_ID,
787
801
  __version: PROTOCOL_VERSION,
@@ -790,44 +804,95 @@ var VibeAppHost = class {
790
804
  payload
791
805
  };
792
806
  for (const origin of this.concreteTargets()) {
793
- this.iframeWindow.postMessage(message, origin);
807
+ this.connectedWindow.postMessage(message, origin);
794
808
  }
795
809
  }
810
+ /**
811
+ * Sends the host's PostHog config to a freshly connected iframe as a one-time
812
+ * POSTHOG_PUSH. No-op when no `getPostHog` source is configured or it returns
813
+ * nothing (recording disabled). Targets the exact handshaking window/origin
814
+ * (no `concreteTargets` fan-out) since it fires from a validated inbound poll.
815
+ */
816
+ pushPostHog(target, origin) {
817
+ const payload = this.getPostHog?.();
818
+ if (!payload) return;
819
+ const message = {
820
+ __protocol: PROTOCOL_ID,
821
+ __version: PROTOCOL_VERSION,
822
+ kind: "push",
823
+ type: "POSTHOG_PUSH",
824
+ payload
825
+ };
826
+ target.postMessage(message, origin);
827
+ }
796
828
  async handleMessage(event) {
797
829
  if (!isOriginAllowed(event.origin, this.trustedOrigins, { allowPatterns: true })) return;
798
830
  if (!isBridgeMessage(event.data)) return;
799
- this.connectedOrigins.add(event.origin);
800
- await this.kindHandlers[event.data.kind]?.(event.data, event.source, event.origin);
831
+ const msg = event.data;
832
+ const source = event.source;
833
+ if (msg.kind === "command" && msg.type === "CONNECT") {
834
+ this.handleConnect(source, event.origin);
835
+ return;
836
+ }
837
+ if (source !== this.connectedWindow) {
838
+ if (msg.kind === "request") {
839
+ this.respond(source, event.origin, {
840
+ kind: "response",
841
+ type: msg.type,
842
+ requestId: msg.requestId,
843
+ ok: false,
844
+ error: "Vibe App is not connected \u2014 call connect() first.",
845
+ code: "NOT_CONNECTED"
846
+ });
847
+ }
848
+ return;
849
+ }
850
+ await this.kindHandlers[msg.kind]?.(msg, source, event.origin);
851
+ }
852
+ /**
853
+ * Handles the CONNECT handshake: records the connecting window/origin (which
854
+ * opens the connection gate for it) and pushes the initial context + one-time
855
+ * PostHog config. Only the first CONNECT per origin pushes — later retries are
856
+ * ignored, so the bridge receives exactly one initial CONTEXT_PUSH (no
857
+ * spurious onContextChange). A reloaded iframe is a new window and re-connects
858
+ * normally. If `getContext()` throws, the window is left unconnected so the
859
+ * bridge's retry can recover.
860
+ */
861
+ handleConnect(source, origin) {
862
+ if (source === this.connectedWindow) return;
863
+ try {
864
+ const ctx = { ...this.getContext(), hostVersion: version };
865
+ this.connectedWindow = source;
866
+ this.connectedOrigin = origin;
867
+ console.log(
868
+ `[vibe-host] Vibe App connected \u2014 host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
869
+ );
870
+ const message = {
871
+ __protocol: PROTOCOL_ID,
872
+ __version: PROTOCOL_VERSION,
873
+ kind: "push",
874
+ type: "CONTEXT_PUSH",
875
+ payload: ctx
876
+ };
877
+ source.postMessage(message, origin);
878
+ this.pushPostHog(source, origin);
879
+ } catch (err) {
880
+ console.error("[vibe-host] getContext threw during connect \u2014 context not delivered.", err);
881
+ }
801
882
  }
802
883
  /**
803
884
  * Concrete origins a proactive push may target: the exact allowlist entries
804
- * plus any origins seen on validated inbound messages. Never includes glob
805
- * patterns (you cannot `postMessage` to one). When only exact origins are
806
- * configured this equals the configured set, so behaviour is unchanged.
885
+ * plus the connected iframe's learned origin. Never includes glob patterns
886
+ * (you cannot `postMessage` to one). When only exact origins are configured
887
+ * this equals the configured set, so behaviour is unchanged.
807
888
  */
808
889
  concreteTargets() {
809
- return /* @__PURE__ */ new Set([...this.exactOrigins, ...this.connectedOrigins]);
890
+ const targets = new Set(this.exactOrigins);
891
+ if (this.connectedOrigin) targets.add(this.connectedOrigin);
892
+ return targets;
810
893
  }
811
894
  async handleRequest(msg, source, origin) {
812
895
  const { requestId, type } = msg;
813
- if (type === "GET_CONTEXT") {
814
- try {
815
- const ctx = { ...this.getContext(), hostVersion: version };
816
- if (!this.handshakeLoggedWindows.has(source)) {
817
- this.handshakeLoggedWindows.add(source);
818
- const bridgeVersion = msg.payload?.bridgeVersion ?? "unknown";
819
- console.log(
820
- `[vibe-host] Vibe App connected \u2014 bridge: ${bridgeVersion}, host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
821
- );
822
- }
823
- this.respond(source, origin, { kind: "response", type, requestId, ok: true, data: ctx });
824
- } catch (err) {
825
- const error = err instanceof Error ? err.message : String(err);
826
- const code = err instanceof BridgeError ? err.code : "CONTEXT_ERROR";
827
- this.respond(source, origin, { kind: "response", type, requestId, ok: false, error, code });
828
- }
829
- return;
830
- }
831
896
  if (!checkRateLimit(this.rateLimiter)) {
832
897
  this.respond(source, origin, {
833
898
  kind: "response",
package/dist/index.d.cts CHANGED
@@ -1390,6 +1390,7 @@ type ActivityProviderOperationErrorType = 'Connection Error' | 'Authentication E
1390
1390
  type ActivityReconciliationDefinitionData = {
1391
1391
  type: ActivityReconciliationType;
1392
1392
  task_definition_id: string;
1393
+ currency?: ActivityCurrency | null;
1393
1394
  side_a_entity_type: ActivityReconciliationEntityType;
1394
1395
  side_a_entity_id: string;
1395
1396
  side_b_entity_type: ActivityReconciliationEntityType;
@@ -1403,7 +1404,6 @@ type ActivityReconciliationInstanceDataInput = {
1403
1404
  task_instance_id: string;
1404
1405
  start_date: string;
1405
1406
  end_date: string;
1406
- currency?: ActivityCurrency | null;
1407
1407
  side_a_value?: number | string | null;
1408
1408
  side_b_value?: number | string | null;
1409
1409
  reconciling_items_balance?: number | string | null;
@@ -1417,7 +1417,6 @@ type ActivityReconciliationInstanceDataOutput = {
1417
1417
  task_instance_id: string;
1418
1418
  start_date: string;
1419
1419
  end_date: string;
1420
- currency?: ActivityCurrency | null;
1421
1420
  side_a_value?: string | null;
1422
1421
  side_b_value?: string | null;
1423
1422
  reconciling_items_balance?: string | null;
@@ -3188,9 +3187,10 @@ interface BridgeSubsidiary {
3188
3187
  *
3189
3188
  * The host (nom-ui) is the single source of truth for these values — it knows
3190
3189
  * the project key, the env-aware ingestion host, and the resolved person — and
3191
- * delivers them over the bridge so the iframe never hardcodes any of it. When
3192
- * the host omits the {@link ContextPayload.posthog} block entirely (older host,
3193
- * or recording disabled for this env/app), the bridge initializes nothing.
3190
+ * delivers it over the bridge (as a one-time `POSTHOG_PUSH`, separate from
3191
+ * {@link ContextPayload}) so the iframe never hardcodes any of it. When the host
3192
+ * has no PostHog config (recording disabled for this env/app), it sends no
3193
+ * `POSTHOG_PUSH` and the bridge initializes nothing.
3194
3194
  */
3195
3195
  interface PostHogContext {
3196
3196
  /** Project API key — the same PostHog project the host (nom-ui) uses. */
@@ -3228,6 +3228,27 @@ interface PostHogContext {
3228
3228
  */
3229
3229
  enabled: boolean;
3230
3230
  }
3231
+ /**
3232
+ * Authentication-state change the host pushes to the iframe via `AUTH_CHANGED`.
3233
+ * It carries the security principal the embedded app authenticates and scopes
3234
+ * its own data by. Sent when the Nominal session changes: a logout
3235
+ * (`authenticated: false`) or an identity/tenant switch (`authenticated: true`
3236
+ * with a new `userId`/`tenant`). The initial state is implicit at connect — the
3237
+ * iframe only loads when authenticated, and the current identity/tenant are
3238
+ * {@link ContextPayload.user} / {@link ContextPayload.tenant}, so apps baseline
3239
+ * off those and react to this push.
3240
+ *
3241
+ * Subsidiary/period and other view-level state are NOT here — they ride the
3242
+ * follow-up `CONTEXT_PUSH` (`onContextChange`), per "auth change, then context".
3243
+ */
3244
+ interface AuthPayload {
3245
+ /** Whether the Nominal user is still authenticated. */
3246
+ authenticated: boolean;
3247
+ /** The effective Nominal user id when authenticated; omitted on logout. */
3248
+ userId?: string;
3249
+ /** The tenant the user is acting in — the app's data-scoping key (RLS). */
3250
+ tenant?: string;
3251
+ }
3231
3252
  interface ContextPayload {
3232
3253
  tenant: string;
3233
3254
  subsidiaryId: number;
@@ -3240,21 +3261,9 @@ interface ContextPayload {
3240
3261
  subroute?: string;
3241
3262
  /** Slug of the last closed accounting period, e.g. "mar-2026". */
3242
3263
  lastClosedPeriodSlug?: string;
3243
- /**
3244
- * PostHog config for in-iframe session recording + custom events. **Absent
3245
- * when recording is off** (older host, or disabled for this env/app), in which
3246
- * case the bridge initializes no analytics. Optional so old hosts/bridges stay
3247
- * compatible.
3248
- */
3249
- posthog?: PostHogContext;
3250
3264
  /** Version of the host SDK (@nominalso/vibe-host) running in nom-ui. */
3251
3265
  hostVersion: string;
3252
3266
  }
3253
- /** Payload sent by the bridge when requesting context from the host. */
3254
- interface GetContextPayload {
3255
- /** Version of the bridge SDK (@nominalso/vibe-bridge) running in the iframe. */
3256
- bridgeVersion: string;
3257
- }
3258
3267
 
3259
3268
  interface UploadPayload {
3260
3269
  buffer: ArrayBuffer;
@@ -3506,10 +3515,6 @@ interface RequestRegistry {
3506
3515
  payload: InvalidateCachePayload;
3507
3516
  data: InvalidateResult;
3508
3517
  };
3509
- GET_CONTEXT: {
3510
- payload: GetContextPayload;
3511
- data: ContextPayload;
3512
- };
3513
3518
  UPLOAD_FILE: {
3514
3519
  payload: UploadPayload;
3515
3520
  data: UploadResponse;
@@ -3623,6 +3628,13 @@ interface VibeAppHostOptions {
3623
3628
  trustedOrigins: string[];
3624
3629
  /** Returns the current context to send to the iframe on connect. */
3625
3630
  getContext: () => HostContext;
3631
+ /**
3632
+ * Optional PostHog config source. Read **once per iframe** at connect and
3633
+ * delivered via a one-time `POSTHOG_PUSH`, kept separate from `getContext` so
3634
+ * it never re-sends when context or auth later change. Return `undefined` (or
3635
+ * a config with `enabled: false`) to disable in-iframe recording.
3636
+ */
3637
+ getPostHog?: () => PostHogContext | undefined;
3626
3638
  /**
3627
3639
  * The base URL path of this Vibe App in nom-ui, e.g. `/acme/1/apps/fixed-assets`.
3628
3640
  * Used to sync the browser URL with the iframe's internal subroute.
@@ -3675,18 +3687,11 @@ interface VibeAppHostOptions {
3675
3687
  * onUpload: (p, sendProgress) => uploadToS3(p, sendProgress),
3676
3688
  * })
3677
3689
  *
3678
- * useEffect(() => {
3679
- * const iframe = iframeRef.current
3680
- * if (!iframe) return
3681
- * const unmount = host.mount() // listen before the iframe loads
3682
- * const onLoad = () => iframe.contentWindow && host.pushContextTo(iframe.contentWindow)
3683
- * iframe.addEventListener('load', onLoad)
3684
- * return () => {
3685
- * iframe.removeEventListener('load', onLoad)
3686
- * unmount()
3687
- * }
3688
- * }, [host])
3690
+ * useEffect(() => host.mount(), [host]) // returns the unmount cleanup
3689
3691
  * ```
3692
+ *
3693
+ * `mount()` just starts listening; the iframe identifies itself via its
3694
+ * `CONNECT` handshake, so the host never needs the iframe's `Window` up front.
3690
3695
  */
3691
3696
  declare class VibeAppHost {
3692
3697
  /**
@@ -3699,26 +3704,29 @@ declare class VibeAppHost {
3699
3704
  /** The exact (non-pattern) subset of {@link trustedOrigins} — see {@link concreteTargets}. */
3700
3705
  private readonly exactOrigins;
3701
3706
  /**
3702
- * Concrete iframe origins observed on validated inbound messages. Because you
3703
- * cannot `postMessage` to a glob pattern, proactive pushes target these
3704
- * learned origins (plus the exact allowlist entries) rather than the patterns.
3707
+ * The connected iframe, as two facets of one thing: `connectedWindow` is its
3708
+ * `Window` (identity + the single push target) and `connectedOrigin` is its
3709
+ * origin (what you `postMessage` to you cannot post to a `Window` ref, nor to
3710
+ * a glob). Both are written together by {@link handleConnect} (the sole writer)
3711
+ * and cleared together on unmount, so they can never diverge.
3712
+ *
3713
+ * There is deliberately no other window reference. The connected window can
3714
+ * only be learned from each `CONNECT` handshake's `event.source`, never at
3715
+ * mount: a reloaded/remounted iframe is a *new* `Window` with the same origin,
3716
+ * so a mount-time window would be stale. The gate blocks every non-`CONNECT`
3717
+ * message whose source isn't `connectedWindow`; every proactive push targets it.
3705
3718
  */
3706
- private readonly connectedOrigins;
3719
+ private connectedWindow;
3720
+ private connectedOrigin;
3707
3721
  private readonly handlers;
3708
3722
  private readonly commandHandlers;
3709
3723
  private readonly getContext;
3724
+ private readonly getPostHog?;
3710
3725
  private readonly appBasePath;
3711
3726
  private readonly onRequestComplete?;
3712
3727
  private readonly rateLimiter;
3713
3728
  private readonly boundHandleMessage;
3714
3729
  private readonly boundHandlePopState;
3715
- private iframeWindow;
3716
- /**
3717
- * Source windows we've already logged a successful handshake for. The bridge
3718
- * polls `GET_CONTEXT` on an interval until it connects, so without this the
3719
- * "Vibe App connected" line repeats on every poll (SSOT §13 F3).
3720
- */
3721
- private readonly handshakeLoggedWindows;
3722
3730
  private readonly kindHandlers;
3723
3731
  private dispatchCommand;
3724
3732
  /**
@@ -3738,31 +3746,57 @@ declare class VibeAppHost {
3738
3746
  * Starts listening for bridge messages and sets up browser back/forward sync.
3739
3747
  * Returns a cleanup function to call on unmount.
3740
3748
  *
3741
- * Pass `iframeWindow` to immediately push context to the iframe. If omitted,
3742
- * the host will respond to GET_CONTEXT polls from the bridge but won't
3743
- * proactively push call `pushContextTo(iframeWindow)` once the iframe has
3744
- * loaded to trigger the proactive push.
3749
+ * Takes no iframe window: the host learns the iframe from its `CONNECT`
3750
+ * handshake (and re-learns it on every reload), then delivers context as the
3751
+ * reply to that handshake. Just call `mount()` once the host exists.
3745
3752
  */
3746
- mount(iframeWindow?: Window): () => void;
3753
+ mount(): () => void;
3747
3754
  /**
3748
- * Pushes the current context to the iframe and updates the stored iframe
3749
- * window reference. Call this once the iframe has loaded if `mount()` was
3750
- * called without an `iframeWindow`.
3755
+ * Re-reads `getContext()` and re-pushes it to the connected iframe, so an open
3756
+ * app reacts to a tenant/subsidiary switch or period close without reloading.
3757
+ * PostHog is not re-sent (it rides only the connect handshake). No-op until an
3758
+ * iframe is mounted.
3751
3759
  */
3752
- pushContextTo(iframeWindow: Window): void;
3760
+ refreshContext(): void;
3761
+ /**
3762
+ * Notifies the connected iframe of a Nominal auth change — a logout
3763
+ * (`authenticated: false`) or an identity/tenant switch (`authenticated: true`
3764
+ * with a new `userId`/`tenant`) — via `AUTH_CHANGED`, so the app re-authenticates
3765
+ * or signs its own backend out. No-op until the iframe has connected. When auth
3766
+ * and context change together, call this *before* {@link refreshContext} ("auth
3767
+ * change, then context").
3768
+ */
3769
+ notifyAuthChange(auth: AuthPayload): void;
3753
3770
  private pushContext;
3754
3771
  private handlePopState;
3755
3772
  private pushToIframe;
3773
+ /**
3774
+ * Sends the host's PostHog config to a freshly connected iframe as a one-time
3775
+ * POSTHOG_PUSH. No-op when no `getPostHog` source is configured or it returns
3776
+ * nothing (recording disabled). Targets the exact handshaking window/origin
3777
+ * (no `concreteTargets` fan-out) since it fires from a validated inbound poll.
3778
+ */
3779
+ private pushPostHog;
3756
3780
  private handleMessage;
3781
+ /**
3782
+ * Handles the CONNECT handshake: records the connecting window/origin (which
3783
+ * opens the connection gate for it) and pushes the initial context + one-time
3784
+ * PostHog config. Only the first CONNECT per origin pushes — later retries are
3785
+ * ignored, so the bridge receives exactly one initial CONTEXT_PUSH (no
3786
+ * spurious onContextChange). A reloaded iframe is a new window and re-connects
3787
+ * normally. If `getContext()` throws, the window is left unconnected so the
3788
+ * bridge's retry can recover.
3789
+ */
3790
+ private handleConnect;
3757
3791
  /**
3758
3792
  * Concrete origins a proactive push may target: the exact allowlist entries
3759
- * plus any origins seen on validated inbound messages. Never includes glob
3760
- * patterns (you cannot `postMessage` to one). When only exact origins are
3761
- * configured this equals the configured set, so behaviour is unchanged.
3793
+ * plus the connected iframe's learned origin. Never includes glob patterns
3794
+ * (you cannot `postMessage` to one). When only exact origins are configured
3795
+ * this equals the configured set, so behaviour is unchanged.
3762
3796
  */
3763
3797
  private concreteTargets;
3764
3798
  private handleRequest;
3765
3799
  private respond;
3766
3800
  }
3767
3801
 
3768
- export { BridgeError, type ContextPayload, type HostContext, type HostHandlers, HttpBridgeError, type InvalidateCachePayload, type InvalidateResult, type NavigateAction, type NavigatePayload, type PostHogContext, type RequestCompleteInfo, type RequestHandlers, type SetSideNavPayload, type SwitchSubsidiaryPayload, type UploadPayload, type UploadProgress, type UploadResponse, type VibeApiClientOptions, VibeAppHost, type VibeAppHostOptions };
3802
+ export { type AuthPayload, BridgeError, type ContextPayload, type HostContext, type HostHandlers, HttpBridgeError, type InvalidateCachePayload, type InvalidateResult, type NavigateAction, type NavigatePayload, type PostHogContext, type RequestCompleteInfo, type RequestHandlers, type SetSideNavPayload, type SwitchSubsidiaryPayload, type UploadPayload, type UploadProgress, type UploadResponse, type VibeApiClientOptions, VibeAppHost, type VibeAppHostOptions };
package/dist/index.d.ts CHANGED
@@ -1390,6 +1390,7 @@ type ActivityProviderOperationErrorType = 'Connection Error' | 'Authentication E
1390
1390
  type ActivityReconciliationDefinitionData = {
1391
1391
  type: ActivityReconciliationType;
1392
1392
  task_definition_id: string;
1393
+ currency?: ActivityCurrency | null;
1393
1394
  side_a_entity_type: ActivityReconciliationEntityType;
1394
1395
  side_a_entity_id: string;
1395
1396
  side_b_entity_type: ActivityReconciliationEntityType;
@@ -1403,7 +1404,6 @@ type ActivityReconciliationInstanceDataInput = {
1403
1404
  task_instance_id: string;
1404
1405
  start_date: string;
1405
1406
  end_date: string;
1406
- currency?: ActivityCurrency | null;
1407
1407
  side_a_value?: number | string | null;
1408
1408
  side_b_value?: number | string | null;
1409
1409
  reconciling_items_balance?: number | string | null;
@@ -1417,7 +1417,6 @@ type ActivityReconciliationInstanceDataOutput = {
1417
1417
  task_instance_id: string;
1418
1418
  start_date: string;
1419
1419
  end_date: string;
1420
- currency?: ActivityCurrency | null;
1421
1420
  side_a_value?: string | null;
1422
1421
  side_b_value?: string | null;
1423
1422
  reconciling_items_balance?: string | null;
@@ -3188,9 +3187,10 @@ interface BridgeSubsidiary {
3188
3187
  *
3189
3188
  * The host (nom-ui) is the single source of truth for these values — it knows
3190
3189
  * the project key, the env-aware ingestion host, and the resolved person — and
3191
- * delivers them over the bridge so the iframe never hardcodes any of it. When
3192
- * the host omits the {@link ContextPayload.posthog} block entirely (older host,
3193
- * or recording disabled for this env/app), the bridge initializes nothing.
3190
+ * delivers it over the bridge (as a one-time `POSTHOG_PUSH`, separate from
3191
+ * {@link ContextPayload}) so the iframe never hardcodes any of it. When the host
3192
+ * has no PostHog config (recording disabled for this env/app), it sends no
3193
+ * `POSTHOG_PUSH` and the bridge initializes nothing.
3194
3194
  */
3195
3195
  interface PostHogContext {
3196
3196
  /** Project API key — the same PostHog project the host (nom-ui) uses. */
@@ -3228,6 +3228,27 @@ interface PostHogContext {
3228
3228
  */
3229
3229
  enabled: boolean;
3230
3230
  }
3231
+ /**
3232
+ * Authentication-state change the host pushes to the iframe via `AUTH_CHANGED`.
3233
+ * It carries the security principal the embedded app authenticates and scopes
3234
+ * its own data by. Sent when the Nominal session changes: a logout
3235
+ * (`authenticated: false`) or an identity/tenant switch (`authenticated: true`
3236
+ * with a new `userId`/`tenant`). The initial state is implicit at connect — the
3237
+ * iframe only loads when authenticated, and the current identity/tenant are
3238
+ * {@link ContextPayload.user} / {@link ContextPayload.tenant}, so apps baseline
3239
+ * off those and react to this push.
3240
+ *
3241
+ * Subsidiary/period and other view-level state are NOT here — they ride the
3242
+ * follow-up `CONTEXT_PUSH` (`onContextChange`), per "auth change, then context".
3243
+ */
3244
+ interface AuthPayload {
3245
+ /** Whether the Nominal user is still authenticated. */
3246
+ authenticated: boolean;
3247
+ /** The effective Nominal user id when authenticated; omitted on logout. */
3248
+ userId?: string;
3249
+ /** The tenant the user is acting in — the app's data-scoping key (RLS). */
3250
+ tenant?: string;
3251
+ }
3231
3252
  interface ContextPayload {
3232
3253
  tenant: string;
3233
3254
  subsidiaryId: number;
@@ -3240,21 +3261,9 @@ interface ContextPayload {
3240
3261
  subroute?: string;
3241
3262
  /** Slug of the last closed accounting period, e.g. "mar-2026". */
3242
3263
  lastClosedPeriodSlug?: string;
3243
- /**
3244
- * PostHog config for in-iframe session recording + custom events. **Absent
3245
- * when recording is off** (older host, or disabled for this env/app), in which
3246
- * case the bridge initializes no analytics. Optional so old hosts/bridges stay
3247
- * compatible.
3248
- */
3249
- posthog?: PostHogContext;
3250
3264
  /** Version of the host SDK (@nominalso/vibe-host) running in nom-ui. */
3251
3265
  hostVersion: string;
3252
3266
  }
3253
- /** Payload sent by the bridge when requesting context from the host. */
3254
- interface GetContextPayload {
3255
- /** Version of the bridge SDK (@nominalso/vibe-bridge) running in the iframe. */
3256
- bridgeVersion: string;
3257
- }
3258
3267
 
3259
3268
  interface UploadPayload {
3260
3269
  buffer: ArrayBuffer;
@@ -3506,10 +3515,6 @@ interface RequestRegistry {
3506
3515
  payload: InvalidateCachePayload;
3507
3516
  data: InvalidateResult;
3508
3517
  };
3509
- GET_CONTEXT: {
3510
- payload: GetContextPayload;
3511
- data: ContextPayload;
3512
- };
3513
3518
  UPLOAD_FILE: {
3514
3519
  payload: UploadPayload;
3515
3520
  data: UploadResponse;
@@ -3623,6 +3628,13 @@ interface VibeAppHostOptions {
3623
3628
  trustedOrigins: string[];
3624
3629
  /** Returns the current context to send to the iframe on connect. */
3625
3630
  getContext: () => HostContext;
3631
+ /**
3632
+ * Optional PostHog config source. Read **once per iframe** at connect and
3633
+ * delivered via a one-time `POSTHOG_PUSH`, kept separate from `getContext` so
3634
+ * it never re-sends when context or auth later change. Return `undefined` (or
3635
+ * a config with `enabled: false`) to disable in-iframe recording.
3636
+ */
3637
+ getPostHog?: () => PostHogContext | undefined;
3626
3638
  /**
3627
3639
  * The base URL path of this Vibe App in nom-ui, e.g. `/acme/1/apps/fixed-assets`.
3628
3640
  * Used to sync the browser URL with the iframe's internal subroute.
@@ -3675,18 +3687,11 @@ interface VibeAppHostOptions {
3675
3687
  * onUpload: (p, sendProgress) => uploadToS3(p, sendProgress),
3676
3688
  * })
3677
3689
  *
3678
- * useEffect(() => {
3679
- * const iframe = iframeRef.current
3680
- * if (!iframe) return
3681
- * const unmount = host.mount() // listen before the iframe loads
3682
- * const onLoad = () => iframe.contentWindow && host.pushContextTo(iframe.contentWindow)
3683
- * iframe.addEventListener('load', onLoad)
3684
- * return () => {
3685
- * iframe.removeEventListener('load', onLoad)
3686
- * unmount()
3687
- * }
3688
- * }, [host])
3690
+ * useEffect(() => host.mount(), [host]) // returns the unmount cleanup
3689
3691
  * ```
3692
+ *
3693
+ * `mount()` just starts listening; the iframe identifies itself via its
3694
+ * `CONNECT` handshake, so the host never needs the iframe's `Window` up front.
3690
3695
  */
3691
3696
  declare class VibeAppHost {
3692
3697
  /**
@@ -3699,26 +3704,29 @@ declare class VibeAppHost {
3699
3704
  /** The exact (non-pattern) subset of {@link trustedOrigins} — see {@link concreteTargets}. */
3700
3705
  private readonly exactOrigins;
3701
3706
  /**
3702
- * Concrete iframe origins observed on validated inbound messages. Because you
3703
- * cannot `postMessage` to a glob pattern, proactive pushes target these
3704
- * learned origins (plus the exact allowlist entries) rather than the patterns.
3707
+ * The connected iframe, as two facets of one thing: `connectedWindow` is its
3708
+ * `Window` (identity + the single push target) and `connectedOrigin` is its
3709
+ * origin (what you `postMessage` to you cannot post to a `Window` ref, nor to
3710
+ * a glob). Both are written together by {@link handleConnect} (the sole writer)
3711
+ * and cleared together on unmount, so they can never diverge.
3712
+ *
3713
+ * There is deliberately no other window reference. The connected window can
3714
+ * only be learned from each `CONNECT` handshake's `event.source`, never at
3715
+ * mount: a reloaded/remounted iframe is a *new* `Window` with the same origin,
3716
+ * so a mount-time window would be stale. The gate blocks every non-`CONNECT`
3717
+ * message whose source isn't `connectedWindow`; every proactive push targets it.
3705
3718
  */
3706
- private readonly connectedOrigins;
3719
+ private connectedWindow;
3720
+ private connectedOrigin;
3707
3721
  private readonly handlers;
3708
3722
  private readonly commandHandlers;
3709
3723
  private readonly getContext;
3724
+ private readonly getPostHog?;
3710
3725
  private readonly appBasePath;
3711
3726
  private readonly onRequestComplete?;
3712
3727
  private readonly rateLimiter;
3713
3728
  private readonly boundHandleMessage;
3714
3729
  private readonly boundHandlePopState;
3715
- private iframeWindow;
3716
- /**
3717
- * Source windows we've already logged a successful handshake for. The bridge
3718
- * polls `GET_CONTEXT` on an interval until it connects, so without this the
3719
- * "Vibe App connected" line repeats on every poll (SSOT §13 F3).
3720
- */
3721
- private readonly handshakeLoggedWindows;
3722
3730
  private readonly kindHandlers;
3723
3731
  private dispatchCommand;
3724
3732
  /**
@@ -3738,31 +3746,57 @@ declare class VibeAppHost {
3738
3746
  * Starts listening for bridge messages and sets up browser back/forward sync.
3739
3747
  * Returns a cleanup function to call on unmount.
3740
3748
  *
3741
- * Pass `iframeWindow` to immediately push context to the iframe. If omitted,
3742
- * the host will respond to GET_CONTEXT polls from the bridge but won't
3743
- * proactively push call `pushContextTo(iframeWindow)` once the iframe has
3744
- * loaded to trigger the proactive push.
3749
+ * Takes no iframe window: the host learns the iframe from its `CONNECT`
3750
+ * handshake (and re-learns it on every reload), then delivers context as the
3751
+ * reply to that handshake. Just call `mount()` once the host exists.
3745
3752
  */
3746
- mount(iframeWindow?: Window): () => void;
3753
+ mount(): () => void;
3747
3754
  /**
3748
- * Pushes the current context to the iframe and updates the stored iframe
3749
- * window reference. Call this once the iframe has loaded if `mount()` was
3750
- * called without an `iframeWindow`.
3755
+ * Re-reads `getContext()` and re-pushes it to the connected iframe, so an open
3756
+ * app reacts to a tenant/subsidiary switch or period close without reloading.
3757
+ * PostHog is not re-sent (it rides only the connect handshake). No-op until an
3758
+ * iframe is mounted.
3751
3759
  */
3752
- pushContextTo(iframeWindow: Window): void;
3760
+ refreshContext(): void;
3761
+ /**
3762
+ * Notifies the connected iframe of a Nominal auth change — a logout
3763
+ * (`authenticated: false`) or an identity/tenant switch (`authenticated: true`
3764
+ * with a new `userId`/`tenant`) — via `AUTH_CHANGED`, so the app re-authenticates
3765
+ * or signs its own backend out. No-op until the iframe has connected. When auth
3766
+ * and context change together, call this *before* {@link refreshContext} ("auth
3767
+ * change, then context").
3768
+ */
3769
+ notifyAuthChange(auth: AuthPayload): void;
3753
3770
  private pushContext;
3754
3771
  private handlePopState;
3755
3772
  private pushToIframe;
3773
+ /**
3774
+ * Sends the host's PostHog config to a freshly connected iframe as a one-time
3775
+ * POSTHOG_PUSH. No-op when no `getPostHog` source is configured or it returns
3776
+ * nothing (recording disabled). Targets the exact handshaking window/origin
3777
+ * (no `concreteTargets` fan-out) since it fires from a validated inbound poll.
3778
+ */
3779
+ private pushPostHog;
3756
3780
  private handleMessage;
3781
+ /**
3782
+ * Handles the CONNECT handshake: records the connecting window/origin (which
3783
+ * opens the connection gate for it) and pushes the initial context + one-time
3784
+ * PostHog config. Only the first CONNECT per origin pushes — later retries are
3785
+ * ignored, so the bridge receives exactly one initial CONTEXT_PUSH (no
3786
+ * spurious onContextChange). A reloaded iframe is a new window and re-connects
3787
+ * normally. If `getContext()` throws, the window is left unconnected so the
3788
+ * bridge's retry can recover.
3789
+ */
3790
+ private handleConnect;
3757
3791
  /**
3758
3792
  * Concrete origins a proactive push may target: the exact allowlist entries
3759
- * plus any origins seen on validated inbound messages. Never includes glob
3760
- * patterns (you cannot `postMessage` to one). When only exact origins are
3761
- * configured this equals the configured set, so behaviour is unchanged.
3793
+ * plus the connected iframe's learned origin. Never includes glob patterns
3794
+ * (you cannot `postMessage` to one). When only exact origins are configured
3795
+ * this equals the configured set, so behaviour is unchanged.
3762
3796
  */
3763
3797
  private concreteTargets;
3764
3798
  private handleRequest;
3765
3799
  private respond;
3766
3800
  }
3767
3801
 
3768
- export { BridgeError, type ContextPayload, type HostContext, type HostHandlers, HttpBridgeError, type InvalidateCachePayload, type InvalidateResult, type NavigateAction, type NavigatePayload, type PostHogContext, type RequestCompleteInfo, type RequestHandlers, type SetSideNavPayload, type SwitchSubsidiaryPayload, type UploadPayload, type UploadProgress, type UploadResponse, type VibeApiClientOptions, VibeAppHost, type VibeAppHostOptions };
3802
+ export { type AuthPayload, BridgeError, type ContextPayload, type HostContext, type HostHandlers, HttpBridgeError, type InvalidateCachePayload, type InvalidateResult, type NavigateAction, type NavigatePayload, type PostHogContext, type RequestCompleteInfo, type RequestHandlers, type SetSideNavPayload, type SwitchSubsidiaryPayload, type UploadPayload, type UploadProgress, type UploadResponse, type VibeApiClientOptions, VibeAppHost, type VibeAppHostOptions };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // package.json
2
- var version = "0.4.0";
2
+ var version = "0.5.0";
3
3
 
4
4
  // ../protocol-types/dist/index.js
5
5
  function normalizeSubroute(subroute) {
@@ -15,7 +15,7 @@ function normalizeSubroute(subroute) {
15
15
  return normalized;
16
16
  }
17
17
  var PROTOCOL_ID = "nominal-vibe-bridge";
18
- var PROTOCOL_VERSION = 1;
18
+ var PROTOCOL_VERSION = 2;
19
19
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
20
20
  var CORRELATED_KINDS = ["request", "response", "progress"];
21
21
  var BRIDGE_KINDS_SET = new Set(BRIDGE_KINDS);
@@ -632,18 +632,20 @@ function rejectUnwired(op) {
632
632
  var VibeAppHost = class {
633
633
  constructor(opts) {
634
634
  /**
635
- * Concrete iframe origins observed on validated inbound messages. Because you
636
- * cannot `postMessage` to a glob pattern, proactive pushes target these
637
- * learned origins (plus the exact allowlist entries) rather than the patterns.
635
+ * The connected iframe, as two facets of one thing: `connectedWindow` is its
636
+ * `Window` (identity + the single push target) and `connectedOrigin` is its
637
+ * origin (what you `postMessage` to you cannot post to a `Window` ref, nor to
638
+ * a glob). Both are written together by {@link handleConnect} (the sole writer)
639
+ * and cleared together on unmount, so they can never diverge.
640
+ *
641
+ * There is deliberately no other window reference. The connected window can
642
+ * only be learned from each `CONNECT` handshake's `event.source`, never at
643
+ * mount: a reloaded/remounted iframe is a *new* `Window` with the same origin,
644
+ * so a mount-time window would be stale. The gate blocks every non-`CONNECT`
645
+ * message whose source isn't `connectedWindow`; every proactive push targets it.
638
646
  */
639
- this.connectedOrigins = /* @__PURE__ */ new Set();
640
- this.iframeWindow = null;
641
- /**
642
- * Source windows we've already logged a successful handshake for. The bridge
643
- * polls `GET_CONTEXT` on an interval until it connects, so without this the
644
- * "Vibe App connected" line repeats on every poll (SSOT §13 F3).
645
- */
646
- this.handshakeLoggedWindows = /* @__PURE__ */ new WeakSet();
647
+ this.connectedWindow = null;
648
+ this.connectedOrigin = null;
647
649
  this.kindHandlers = {
648
650
  request: (msg, source, origin) => this.handleRequest(msg, source, origin),
649
651
  command: (msg) => this.dispatchCommand(msg)
@@ -652,6 +654,7 @@ var VibeAppHost = class {
652
654
  this.trustedOrigins = new Set(trustedOrigins);
653
655
  this.exactOrigins = trustedOrigins.filter((entry) => !isOriginPattern(entry));
654
656
  this.getContext = getContext;
657
+ this.getPostHog = opts.getPostHog;
655
658
  this.appBasePath = appBasePath;
656
659
  this.onRequestComplete = opts.onRequestComplete;
657
660
  this.rateLimiter = createRateLimiter(opts.rateLimit ?? DEFAULT_RATE_LIMIT);
@@ -664,7 +667,8 @@ var VibeAppHost = class {
664
667
  this.commandHandlers = this.buildCommandHandlers(opts);
665
668
  }
666
669
  dispatchCommand(msg) {
667
- const handler = this.commandHandlers[msg.type];
670
+ const handlers = this.commandHandlers;
671
+ const handler = handlers[msg.type];
668
672
  if (!handler) {
669
673
  console.warn(`[vibe-host] Received unknown command "${msg.type}" \u2014 ignoring.`);
670
674
  return;
@@ -710,31 +714,41 @@ var VibeAppHost = class {
710
714
  * Starts listening for bridge messages and sets up browser back/forward sync.
711
715
  * Returns a cleanup function to call on unmount.
712
716
  *
713
- * Pass `iframeWindow` to immediately push context to the iframe. If omitted,
714
- * the host will respond to GET_CONTEXT polls from the bridge but won't
715
- * proactively push call `pushContextTo(iframeWindow)` once the iframe has
716
- * loaded to trigger the proactive push.
717
+ * Takes no iframe window: the host learns the iframe from its `CONNECT`
718
+ * handshake (and re-learns it on every reload), then delivers context as the
719
+ * reply to that handshake. Just call `mount()` once the host exists.
717
720
  */
718
- mount(iframeWindow) {
719
- if (iframeWindow) this.iframeWindow = iframeWindow;
721
+ mount() {
720
722
  window.addEventListener("message", this.boundHandleMessage);
721
723
  window.addEventListener("popstate", this.boundHandlePopState);
722
- if (iframeWindow) this.pushContext(iframeWindow);
723
724
  return () => {
724
725
  window.removeEventListener("message", this.boundHandleMessage);
725
726
  window.removeEventListener("popstate", this.boundHandlePopState);
726
- this.iframeWindow = null;
727
- this.connectedOrigins.clear();
727
+ this.connectedWindow = null;
728
+ this.connectedOrigin = null;
728
729
  };
729
730
  }
730
731
  /**
731
- * Pushes the current context to the iframe and updates the stored iframe
732
- * window reference. Call this once the iframe has loaded if `mount()` was
733
- * called without an `iframeWindow`.
732
+ * Re-reads `getContext()` and re-pushes it to the connected iframe, so an open
733
+ * app reacts to a tenant/subsidiary switch or period close without reloading.
734
+ * PostHog is not re-sent (it rides only the connect handshake). No-op until an
735
+ * iframe is mounted.
736
+ */
737
+ refreshContext() {
738
+ if (!this.connectedWindow) return;
739
+ this.pushContext(this.connectedWindow);
740
+ }
741
+ /**
742
+ * Notifies the connected iframe of a Nominal auth change — a logout
743
+ * (`authenticated: false`) or an identity/tenant switch (`authenticated: true`
744
+ * with a new `userId`/`tenant`) — via `AUTH_CHANGED`, so the app re-authenticates
745
+ * or signs its own backend out. No-op until the iframe has connected. When auth
746
+ * and context change together, call this *before* {@link refreshContext} ("auth
747
+ * change, then context").
734
748
  */
735
- pushContextTo(iframeWindow) {
736
- this.iframeWindow = iframeWindow;
737
- this.pushContext(iframeWindow);
749
+ notifyAuthChange(auth) {
750
+ if (!this.connectedWindow) return;
751
+ this.pushToIframe("AUTH_CHANGED", auth);
738
752
  }
739
753
  pushContext(target) {
740
754
  const message = {
@@ -749,14 +763,14 @@ var VibeAppHost = class {
749
763
  }
750
764
  }
751
765
  handlePopState() {
752
- if (!this.iframeWindow) return;
766
+ if (!this.connectedWindow) return;
753
767
  const currentPath = window.location.pathname;
754
768
  if (!currentPath.startsWith(this.appBasePath)) return;
755
769
  const subroute = currentPath.slice(this.appBasePath.length) || "/";
756
770
  this.pushToIframe("SUBROUTE_PUSH", { subroute });
757
771
  }
758
772
  pushToIframe(type, payload) {
759
- if (!this.iframeWindow) return;
773
+ if (!this.connectedWindow) return;
760
774
  const message = {
761
775
  __protocol: PROTOCOL_ID,
762
776
  __version: PROTOCOL_VERSION,
@@ -765,44 +779,95 @@ var VibeAppHost = class {
765
779
  payload
766
780
  };
767
781
  for (const origin of this.concreteTargets()) {
768
- this.iframeWindow.postMessage(message, origin);
782
+ this.connectedWindow.postMessage(message, origin);
769
783
  }
770
784
  }
785
+ /**
786
+ * Sends the host's PostHog config to a freshly connected iframe as a one-time
787
+ * POSTHOG_PUSH. No-op when no `getPostHog` source is configured or it returns
788
+ * nothing (recording disabled). Targets the exact handshaking window/origin
789
+ * (no `concreteTargets` fan-out) since it fires from a validated inbound poll.
790
+ */
791
+ pushPostHog(target, origin) {
792
+ const payload = this.getPostHog?.();
793
+ if (!payload) return;
794
+ const message = {
795
+ __protocol: PROTOCOL_ID,
796
+ __version: PROTOCOL_VERSION,
797
+ kind: "push",
798
+ type: "POSTHOG_PUSH",
799
+ payload
800
+ };
801
+ target.postMessage(message, origin);
802
+ }
771
803
  async handleMessage(event) {
772
804
  if (!isOriginAllowed(event.origin, this.trustedOrigins, { allowPatterns: true })) return;
773
805
  if (!isBridgeMessage(event.data)) return;
774
- this.connectedOrigins.add(event.origin);
775
- await this.kindHandlers[event.data.kind]?.(event.data, event.source, event.origin);
806
+ const msg = event.data;
807
+ const source = event.source;
808
+ if (msg.kind === "command" && msg.type === "CONNECT") {
809
+ this.handleConnect(source, event.origin);
810
+ return;
811
+ }
812
+ if (source !== this.connectedWindow) {
813
+ if (msg.kind === "request") {
814
+ this.respond(source, event.origin, {
815
+ kind: "response",
816
+ type: msg.type,
817
+ requestId: msg.requestId,
818
+ ok: false,
819
+ error: "Vibe App is not connected \u2014 call connect() first.",
820
+ code: "NOT_CONNECTED"
821
+ });
822
+ }
823
+ return;
824
+ }
825
+ await this.kindHandlers[msg.kind]?.(msg, source, event.origin);
826
+ }
827
+ /**
828
+ * Handles the CONNECT handshake: records the connecting window/origin (which
829
+ * opens the connection gate for it) and pushes the initial context + one-time
830
+ * PostHog config. Only the first CONNECT per origin pushes — later retries are
831
+ * ignored, so the bridge receives exactly one initial CONTEXT_PUSH (no
832
+ * spurious onContextChange). A reloaded iframe is a new window and re-connects
833
+ * normally. If `getContext()` throws, the window is left unconnected so the
834
+ * bridge's retry can recover.
835
+ */
836
+ handleConnect(source, origin) {
837
+ if (source === this.connectedWindow) return;
838
+ try {
839
+ const ctx = { ...this.getContext(), hostVersion: version };
840
+ this.connectedWindow = source;
841
+ this.connectedOrigin = origin;
842
+ console.log(
843
+ `[vibe-host] Vibe App connected \u2014 host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
844
+ );
845
+ const message = {
846
+ __protocol: PROTOCOL_ID,
847
+ __version: PROTOCOL_VERSION,
848
+ kind: "push",
849
+ type: "CONTEXT_PUSH",
850
+ payload: ctx
851
+ };
852
+ source.postMessage(message, origin);
853
+ this.pushPostHog(source, origin);
854
+ } catch (err) {
855
+ console.error("[vibe-host] getContext threw during connect \u2014 context not delivered.", err);
856
+ }
776
857
  }
777
858
  /**
778
859
  * Concrete origins a proactive push may target: the exact allowlist entries
779
- * plus any origins seen on validated inbound messages. Never includes glob
780
- * patterns (you cannot `postMessage` to one). When only exact origins are
781
- * configured this equals the configured set, so behaviour is unchanged.
860
+ * plus the connected iframe's learned origin. Never includes glob patterns
861
+ * (you cannot `postMessage` to one). When only exact origins are configured
862
+ * this equals the configured set, so behaviour is unchanged.
782
863
  */
783
864
  concreteTargets() {
784
- return /* @__PURE__ */ new Set([...this.exactOrigins, ...this.connectedOrigins]);
865
+ const targets = new Set(this.exactOrigins);
866
+ if (this.connectedOrigin) targets.add(this.connectedOrigin);
867
+ return targets;
785
868
  }
786
869
  async handleRequest(msg, source, origin) {
787
870
  const { requestId, type } = msg;
788
- if (type === "GET_CONTEXT") {
789
- try {
790
- const ctx = { ...this.getContext(), hostVersion: version };
791
- if (!this.handshakeLoggedWindows.has(source)) {
792
- this.handshakeLoggedWindows.add(source);
793
- const bridgeVersion = msg.payload?.bridgeVersion ?? "unknown";
794
- console.log(
795
- `[vibe-host] Vibe App connected \u2014 bridge: ${bridgeVersion}, host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
796
- );
797
- }
798
- this.respond(source, origin, { kind: "response", type, requestId, ok: true, data: ctx });
799
- } catch (err) {
800
- const error = err instanceof Error ? err.message : String(err);
801
- const code = err instanceof BridgeError ? err.code : "CONTEXT_ERROR";
802
- this.respond(source, origin, { kind: "response", type, requestId, ok: false, error, code });
803
- }
804
- return;
805
- }
806
871
  if (!checkRateLimit(this.rateLimiter)) {
807
872
  this.respond(source, origin, {
808
873
  kind: "response",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nominalso/vibe-host",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Host-side SDK for embedding Nominal Vibe Apps — receives bridge requests over a typed postMessage protocol and dispatches them to Nominal APIs (used by nom-ui).",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/nominalso/vibe-apps-sdk#readme",