@nominalso/vibe-bridge 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/dist/index.cjs CHANGED
@@ -38,7 +38,7 @@ __export(index_exports, {
38
38
  module.exports = __toCommonJS(index_exports);
39
39
 
40
40
  // package.json
41
- var version = "0.4.0";
41
+ var version = "0.5.0";
42
42
 
43
43
  // ../protocol-types/dist/index.js
44
44
  function normalizeSubroute(subroute) {
@@ -54,7 +54,7 @@ function normalizeSubroute(subroute) {
54
54
  return normalized;
55
55
  }
56
56
  var PROTOCOL_ID = "nominal-vibe-bridge";
57
- var PROTOCOL_VERSION = 1;
57
+ var PROTOCOL_VERSION = 2;
58
58
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
59
59
  var CORRELATED_KINDS = ["request", "response", "progress"];
60
60
  var BRIDGE_KINDS_SET = new Set(BRIDGE_KINDS);
@@ -409,8 +409,6 @@ var CONNECT_POLL_MS = 500;
409
409
  var DEFAULT_TIMEOUTS = {
410
410
  /** Typed Nominal API reads/writes (`GET_*` / `POST_*` / `CREATE_*` / ...). */
411
411
  api: 3e4,
412
- /** `GET_CONTEXT` re-fetch (the initial fetch is governed by `connect()`). */
413
- context: 5e3,
414
412
  /** `INVALIDATE_CACHE`. */
415
413
  invalidate: 1e4,
416
414
  /** `UPLOAD_FILE` — large payloads + presigned S3 PUT take a while. */
@@ -423,8 +421,6 @@ function resolveRequestTimeout(type, requestTimeout) {
423
421
  return DEFAULT_TIMEOUTS.upload;
424
422
  case "INVALIDATE_CACHE":
425
423
  return DEFAULT_TIMEOUTS.invalidate;
426
- case "GET_CONTEXT":
427
- return DEFAULT_TIMEOUTS.context;
428
424
  default:
429
425
  return DEFAULT_TIMEOUTS.api;
430
426
  }
@@ -512,13 +508,20 @@ var VibeAppBridge = class extends BridgeDataMethods {
512
508
  this.connectPollInterval = null;
513
509
  this.connectTimeout = null;
514
510
  this.onContextReceived = null;
515
- this.onContextError = null;
511
+ /** Rejects an in-flight connect() (used by destroy()); `null` once settled. */
512
+ this.rejectConnect = null;
516
513
  /**
517
- * Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
518
- * late response from a *previous* connect attempt, which would otherwise
519
- * resolve/reject the new one (a stale error could reject a healthy reconnect).
514
+ * Persistent subscriber for post-connect context changes (tenant/subsidiary
515
+ * switch, period close, …). Distinct from {@link onContextReceived}, which only
516
+ * resolves the connect handshake and is cleared afterward. `null` until
517
+ * {@link onContextChange} is called.
520
518
  */
521
- this.connectPollIds = /* @__PURE__ */ new Set();
519
+ this.contextChangeCallback = null;
520
+ /**
521
+ * Persistent subscriber for host auth changes (Nominal logout or
522
+ * identity/tenant switch). `null` until {@link onAuthChange} is called.
523
+ */
524
+ this.authChangeCallback = null;
522
525
  this.lastReportedSubroute = null;
523
526
  this.suppressSubrouteReport = false;
524
527
  this.subrouteCallback = null;
@@ -531,7 +534,21 @@ var VibeAppBridge = class extends BridgeDataMethods {
531
534
  progress: (msg) => this.handleProgress(msg)
532
535
  };
533
536
  this.pushHandlers = {
534
- CONTEXT_PUSH: (payload) => this.onContextReceived?.(payload),
537
+ CONTEXT_PUSH: (payload) => {
538
+ if (this.onContextReceived) {
539
+ this.onContextReceived(payload);
540
+ } else if (this.context) {
541
+ this.context = payload;
542
+ this.contextChangeCallback?.(payload);
543
+ }
544
+ },
545
+ // Persistent (not nulled after connect): the host delivers PostHog once at
546
+ // connect via its own push, and initPostHog is idempotent, so this can run
547
+ // whenever the message arrives, independent of how connect() resolved.
548
+ POSTHOG_PUSH: (payload) => this.initPostHog(payload),
549
+ // Persistent: the host pushes auth changes (logout, identity/tenant
550
+ // switch) at any time after connect.
551
+ AUTH_CHANGED: (payload) => this.authChangeCallback?.(payload),
535
552
  SUBROUTE_PUSH: (payload) => {
536
553
  const safe = normalizeSubroute(payload.subroute);
537
554
  if (!safe) return;
@@ -582,11 +599,11 @@ var VibeAppBridge = class extends BridgeDataMethods {
582
599
  * methods, `upload()`, and subroute reporting all require an established
583
600
  * connection. Concurrent calls return the same promise.
584
601
  *
585
- * Actively polls the host every 500ms until a response arrives, which
586
- * handles the race condition where the host's initial context push arrives
587
- * before this listener is ready. Rejects with `Bridge connect timed out`
588
- * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
589
- * or the host hasn't mounted).
602
+ * Sends a `CONNECT` handshake, re-sending every 500ms until the host replies
603
+ * by pushing the context (so it works even if the host mounts after the
604
+ * iframe loads). Context is only ever delivered by push. Rejects with `Bridge
605
+ * connect timed out` after 10 seconds if no context arrives (usually a
606
+ * `parentOrigin` mismatch or the host hasn't mounted).
590
607
  *
591
608
  * After connecting, if the context includes an initial subroute (e.g. from
592
609
  * a deep link), the bridge navigates to it and starts auto-tracking
@@ -616,16 +633,16 @@ var VibeAppBridge = class extends BridgeDataMethods {
616
633
  this.stopConnectPolling();
617
634
  this.connectPromise = null;
618
635
  this.onContextReceived = null;
619
- this.onContextError = null;
636
+ this.rejectConnect = null;
620
637
  reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
621
638
  }, CONNECT_TIMEOUT_MS);
622
639
  this.connectTimeout = timer;
623
- this.onContextError = (error) => {
640
+ this.rejectConnect = (error) => {
624
641
  clearTimeout(timer);
625
642
  this.stopConnectPolling();
626
643
  this.connectPromise = null;
627
644
  this.onContextReceived = null;
628
- this.onContextError = null;
645
+ this.rejectConnect = null;
629
646
  reject(error);
630
647
  };
631
648
  this.onContextReceived = (ctx) => {
@@ -634,11 +651,10 @@ var VibeAppBridge = class extends BridgeDataMethods {
634
651
  this.context = ctx;
635
652
  this.connectPromise = null;
636
653
  this.onContextReceived = null;
637
- this.onContextError = null;
654
+ this.rejectConnect = null;
638
655
  console.log(
639
656
  `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
640
657
  );
641
- this.initPostHog(ctx);
642
658
  const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
643
659
  if (initialSubroute && initialSubroute !== "/") {
644
660
  this.withSubrouteSuppressed(() => {
@@ -650,23 +666,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
650
666
  this.lastReportedSubroute = window.location.pathname + window.location.search;
651
667
  }
652
668
  this.setupNavigationTracking();
669
+ this.warnMissingListeners();
653
670
  resolve(ctx);
654
671
  };
655
- const poll = () => {
656
- const requestId = crypto.randomUUID();
657
- this.connectPollIds.add(requestId);
658
- const message = {
659
- __protocol: PROTOCOL_ID,
660
- __version: PROTOCOL_VERSION,
661
- kind: "request",
662
- type: "GET_CONTEXT",
663
- requestId,
664
- payload: { bridgeVersion: version }
665
- };
666
- this.postToParent(message);
672
+ const sendConnect = () => {
673
+ this.sendCommand("CONNECT", { bridgeVersion: version });
667
674
  };
668
- poll();
669
- this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
675
+ sendConnect();
676
+ this.connectPollInterval = setInterval(sendConnect, CONNECT_POLL_MS);
670
677
  });
671
678
  return this.connectPromise;
672
679
  }
@@ -693,6 +700,56 @@ var VibeAppBridge = class extends BridgeDataMethods {
693
700
  this.subrouteCallback = null;
694
701
  };
695
702
  }
703
+ /**
704
+ * Subscribes to post-connect context changes (tenant/subsidiary switch, period
705
+ * close, …). The callback fires for each {@link ContextPayload} the host pushes
706
+ * *after* connect — not for the initial context, which {@link connect} already
707
+ * returns. Returns an unsubscribe function. Single subscriber: a second call
708
+ * replaces the first. Wire it **before {@link connect}** (the bridge warns at
709
+ * connect if it isn't).
710
+ */
711
+ onContextChange(callback) {
712
+ this.contextChangeCallback = callback;
713
+ return () => {
714
+ this.contextChangeCallback = null;
715
+ };
716
+ }
717
+ /**
718
+ * Subscribes to host auth changes: a Nominal logout (`authenticated: false` —
719
+ * sign your backend out) or an identity/tenant switch (`authenticated: true`
720
+ * with a new `userId`/`tenant` — re-authenticate). Fires for each change the
721
+ * host pushes after connect. Returns an unsubscribe function. Single
722
+ * subscriber: a second call replaces the first.
723
+ *
724
+ * **Wire this before {@link connect}** — without it the embedded app won't
725
+ * sign out when the user logs out of Nominal (the bridge warns at connect if
726
+ * it isn't wired).
727
+ */
728
+ onAuthChange(callback) {
729
+ this.authChangeCallback = callback;
730
+ return () => {
731
+ this.authChangeCallback = null;
732
+ };
733
+ }
734
+ /**
735
+ * At connect, nudge the app if it hasn't wired the change listeners: every
736
+ * embedded app should react to a Nominal logout ({@link onAuthChange}) and a
737
+ * tenant/subsidiary switch ({@link onContextChange}). Fires once during the
738
+ * connect flow (not speculatively later), so wire both **before** `connect()`.
739
+ * The skill/codegen does this by default; this catches hand-wired apps.
740
+ */
741
+ warnMissingListeners() {
742
+ if (!this.authChangeCallback) {
743
+ console.warn(
744
+ "[vibe-bridge] connected without onAuthChange \u2014 the app will not sign out on Nominal logout or react to an identity/tenant switch. Call bridge.onAuthChange() before connect()."
745
+ );
746
+ }
747
+ if (!this.contextChangeCallback) {
748
+ console.warn(
749
+ "[vibe-bridge] connected without onContextChange \u2014 the app will not react to a tenant/subsidiary/period switch. Call bridge.onContextChange() before connect()."
750
+ );
751
+ }
752
+ }
696
753
  // ---------------------------------------------------------------------------
697
754
  // Host navigation & UI commands
698
755
  //
@@ -773,7 +830,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
773
830
  }
774
831
  destroy() {
775
832
  this.teardownNavigationTracking();
776
- this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
833
+ this.rejectConnect?.(new BridgeError("DESTROYED", "Bridge destroyed"));
777
834
  this.stopConnectPolling();
778
835
  window.removeEventListener("message", this.boundHandleMessage);
779
836
  for (const pending of this.pendingRequests.values()) {
@@ -784,7 +841,9 @@ var VibeAppBridge = class extends BridgeDataMethods {
784
841
  this.posthogInitialized = false;
785
842
  this.connectPromise = null;
786
843
  this.onContextReceived = null;
787
- this.onContextError = null;
844
+ this.rejectConnect = null;
845
+ this.contextChangeCallback = null;
846
+ this.authChangeCallback = null;
788
847
  this.subrouteCallback = null;
789
848
  this.lastReportedSubroute = null;
790
849
  }
@@ -797,24 +856,23 @@ var VibeAppBridge = class extends BridgeDataMethods {
797
856
  clearTimeout(this.connectTimeout);
798
857
  this.connectTimeout = null;
799
858
  }
800
- this.connectPollIds.clear();
801
859
  }
802
860
  /**
803
861
  * Initializes PostHog session recording + analytics from the host-supplied
804
- * config, **only when the host opted in** (`ctx.posthog?.enabled`).
862
+ * config carried by a one-time `POSTHOG_PUSH`, **only when the host opted in**
863
+ * (`config.enabled`).
805
864
  *
806
- * The host is the single source of truth: when it sends no `posthog` block
807
- * (older nom-ui, or recording disabled for this env/app) or `enabled: false`,
808
- * this does nothing — `enabled` is the kill-switch even though `posthog-js` is
809
- * bundled into the bridge. Cross-origin replay needs `recordCrossOriginIframes`
810
- * on both sides so the parent receives the iframe's rrweb frames into one
811
- * stitched recording, and `identify()` ties the iframe's recording + events to
812
- * the same person as the host. Wrapped in try/catch so a PostHog failure is
813
- * best-effort and never blocks `connect()`.
865
+ * The host is the single source of truth: when it sends no `POSTHOG_PUSH`
866
+ * (recording disabled for this env/app) or `enabled: false`, this does
867
+ * nothing — `enabled` is the kill-switch even though `posthog-js` is bundled
868
+ * into the bridge. Cross-origin replay needs `recordCrossOriginIframes` on both
869
+ * sides so the parent receives the iframe's rrweb frames into one stitched
870
+ * recording, and `identify()` ties the iframe's recording + events to the same
871
+ * person as the host. Idempotent (the `posthogInitialized` guard) and wrapped
872
+ * in try/catch so a duplicate push or a PostHog failure is harmless.
814
873
  */
815
- initPostHog(ctx) {
816
- const config = ctx.posthog;
817
- if (!config?.enabled) return;
874
+ initPostHog(config) {
875
+ if (!config.enabled) return;
818
876
  if (this.posthogInitialized) return;
819
877
  if (typeof window === "undefined") return;
820
878
  if (!config.token || !config.apiHost || !config.distinctId) {
@@ -973,15 +1031,6 @@ var VibeAppBridge = class extends BridgeDataMethods {
973
1031
  this.kindHandlers[event.data.kind]?.(event.data);
974
1032
  }
975
1033
  handleResponse(msg) {
976
- if (msg.type === "GET_CONTEXT") {
977
- if (!this.connectPollIds.has(msg.requestId)) return;
978
- if (msg.ok) {
979
- this.onContextReceived?.(msg.data);
980
- } else {
981
- this.onContextError?.(new BridgeError(msg.code ?? "CONTEXT_ERROR", msg.error));
982
- }
983
- return;
984
- }
985
1034
  const pending = this.pendingRequests.get(msg.requestId);
986
1035
  if (!pending) return;
987
1036
  this.pendingRequests.delete(msg.requestId);
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- var version = "0.4.0";
1
+ var version = "0.5.0";
2
2
 
3
3
  type AccountingAccountDimensionValues = {
4
4
  account_id: string;
@@ -1392,6 +1392,7 @@ type ActivityProviderOperationErrorType = 'Connection Error' | 'Authentication E
1392
1392
  type ActivityReconciliationDefinitionData = {
1393
1393
  type: ActivityReconciliationType;
1394
1394
  task_definition_id: string;
1395
+ currency?: ActivityCurrency | null;
1395
1396
  side_a_entity_type: ActivityReconciliationEntityType;
1396
1397
  side_a_entity_id: string;
1397
1398
  side_b_entity_type: ActivityReconciliationEntityType;
@@ -1405,7 +1406,6 @@ type ActivityReconciliationInstanceDataInput = {
1405
1406
  task_instance_id: string;
1406
1407
  start_date: string;
1407
1408
  end_date: string;
1408
- currency?: ActivityCurrency | null;
1409
1409
  side_a_value?: number | string | null;
1410
1410
  side_b_value?: number | string | null;
1411
1411
  reconciling_items_balance?: number | string | null;
@@ -1419,7 +1419,6 @@ type ActivityReconciliationInstanceDataOutput = {
1419
1419
  task_instance_id: string;
1420
1420
  start_date: string;
1421
1421
  end_date: string;
1422
- currency?: ActivityCurrency | null;
1423
1422
  side_a_value?: string | null;
1424
1423
  side_b_value?: string | null;
1425
1424
  reconciling_items_balance?: string | null;
@@ -3190,9 +3189,10 @@ interface BridgeSubsidiary {
3190
3189
  *
3191
3190
  * The host (nom-ui) is the single source of truth for these values — it knows
3192
3191
  * the project key, the env-aware ingestion host, and the resolved person — and
3193
- * delivers them over the bridge so the iframe never hardcodes any of it. When
3194
- * the host omits the {@link ContextPayload.posthog} block entirely (older host,
3195
- * or recording disabled for this env/app), the bridge initializes nothing.
3192
+ * delivers it over the bridge (as a one-time `POSTHOG_PUSH`, separate from
3193
+ * {@link ContextPayload}) so the iframe never hardcodes any of it. When the host
3194
+ * has no PostHog config (recording disabled for this env/app), it sends no
3195
+ * `POSTHOG_PUSH` and the bridge initializes nothing.
3196
3196
  */
3197
3197
  interface PostHogContext {
3198
3198
  /** Project API key — the same PostHog project the host (nom-ui) uses. */
@@ -3230,6 +3230,27 @@ interface PostHogContext {
3230
3230
  */
3231
3231
  enabled: boolean;
3232
3232
  }
3233
+ /**
3234
+ * Authentication-state change the host pushes to the iframe via `AUTH_CHANGED`.
3235
+ * It carries the security principal the embedded app authenticates and scopes
3236
+ * its own data by. Sent when the Nominal session changes: a logout
3237
+ * (`authenticated: false`) or an identity/tenant switch (`authenticated: true`
3238
+ * with a new `userId`/`tenant`). The initial state is implicit at connect — the
3239
+ * iframe only loads when authenticated, and the current identity/tenant are
3240
+ * {@link ContextPayload.user} / {@link ContextPayload.tenant}, so apps baseline
3241
+ * off those and react to this push.
3242
+ *
3243
+ * Subsidiary/period and other view-level state are NOT here — they ride the
3244
+ * follow-up `CONTEXT_PUSH` (`onContextChange`), per "auth change, then context".
3245
+ */
3246
+ interface AuthPayload {
3247
+ /** Whether the Nominal user is still authenticated. */
3248
+ authenticated: boolean;
3249
+ /** The effective Nominal user id when authenticated; omitted on logout. */
3250
+ userId?: string;
3251
+ /** The tenant the user is acting in — the app's data-scoping key (RLS). */
3252
+ tenant?: string;
3253
+ }
3233
3254
  interface ContextPayload {
3234
3255
  tenant: string;
3235
3256
  subsidiaryId: number;
@@ -3242,21 +3263,9 @@ interface ContextPayload {
3242
3263
  subroute?: string;
3243
3264
  /** Slug of the last closed accounting period, e.g. "mar-2026". */
3244
3265
  lastClosedPeriodSlug?: string;
3245
- /**
3246
- * PostHog config for in-iframe session recording + custom events. **Absent
3247
- * when recording is off** (older host, or disabled for this env/app), in which
3248
- * case the bridge initializes no analytics. Optional so old hosts/bridges stay
3249
- * compatible.
3250
- */
3251
- posthog?: PostHogContext;
3252
3266
  /** Version of the host SDK (@nominalso/vibe-host) running in nom-ui. */
3253
3267
  hostVersion: string;
3254
3268
  }
3255
- /** Payload sent by the bridge when requesting context from the host. */
3256
- interface GetContextPayload {
3257
- /** Version of the bridge SDK (@nominalso/vibe-bridge) running in the iframe. */
3258
- bridgeVersion: string;
3259
- }
3260
3269
 
3261
3270
  interface UploadPayload {
3262
3271
  buffer: ArrayBuffer;
@@ -3490,10 +3499,6 @@ interface RequestRegistry {
3490
3499
  payload: InvalidateCachePayload;
3491
3500
  data: InvalidateResult;
3492
3501
  };
3493
- GET_CONTEXT: {
3494
- payload: GetContextPayload;
3495
- data: ContextPayload;
3496
- };
3497
3502
  UPLOAD_FILE: {
3498
3503
  payload: UploadPayload;
3499
3504
  data: UploadResponse;
@@ -3748,9 +3753,9 @@ interface VibeAppBridgeOptions {
3748
3753
  *
3749
3754
  * When omitted, each operation uses an operation-specific default tuned to
3750
3755
  * how long it realistically takes (API reads `30000`, `UPLOAD_FILE`
3751
- * `120000`, `INVALIDATE_CACHE` `10000`, `GET_CONTEXT` `5000`). Setting this
3752
- * overrides all of them with a single value — only do that if you have a
3753
- * specific reason; the defaults already match the host's expectations.
3756
+ * `120000`, `INVALIDATE_CACHE` `10000`). Setting this overrides all of them
3757
+ * with a single value — only do that if you have a specific reason; the
3758
+ * defaults already match the host's expectations.
3754
3759
  */
3755
3760
  requestTimeout?: number;
3756
3761
  }
@@ -3796,13 +3801,20 @@ declare class VibeAppBridge extends BridgeDataMethods {
3796
3801
  private connectPollInterval;
3797
3802
  private connectTimeout;
3798
3803
  private onContextReceived;
3799
- private onContextError;
3804
+ /** Rejects an in-flight connect() (used by destroy()); `null` once settled. */
3805
+ private rejectConnect;
3806
+ /**
3807
+ * Persistent subscriber for post-connect context changes (tenant/subsidiary
3808
+ * switch, period close, …). Distinct from {@link onContextReceived}, which only
3809
+ * resolves the connect handshake and is cleared afterward. `null` until
3810
+ * {@link onContextChange} is called.
3811
+ */
3812
+ private contextChangeCallback;
3800
3813
  /**
3801
- * Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
3802
- * late response from a *previous* connect attempt, which would otherwise
3803
- * resolve/reject the new one (a stale error could reject a healthy reconnect).
3814
+ * Persistent subscriber for host auth changes (Nominal logout or
3815
+ * identity/tenant switch). `null` until {@link onAuthChange} is called.
3804
3816
  */
3805
- private readonly connectPollIds;
3817
+ private authChangeCallback;
3806
3818
  private lastReportedSubroute;
3807
3819
  private suppressSubrouteReport;
3808
3820
  private subrouteCallback;
@@ -3819,11 +3831,11 @@ declare class VibeAppBridge extends BridgeDataMethods {
3819
3831
  * methods, `upload()`, and subroute reporting all require an established
3820
3832
  * connection. Concurrent calls return the same promise.
3821
3833
  *
3822
- * Actively polls the host every 500ms until a response arrives, which
3823
- * handles the race condition where the host's initial context push arrives
3824
- * before this listener is ready. Rejects with `Bridge connect timed out`
3825
- * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
3826
- * or the host hasn't mounted).
3834
+ * Sends a `CONNECT` handshake, re-sending every 500ms until the host replies
3835
+ * by pushing the context (so it works even if the host mounts after the
3836
+ * iframe loads). Context is only ever delivered by push. Rejects with `Bridge
3837
+ * connect timed out` after 10 seconds if no context arrives (usually a
3838
+ * `parentOrigin` mismatch or the host hasn't mounted).
3827
3839
  *
3828
3840
  * After connecting, if the context includes an initial subroute (e.g. from
3829
3841
  * a deep link), the bridge navigates to it and starts auto-tracking
@@ -3853,6 +3865,35 @@ declare class VibeAppBridge extends BridgeDataMethods {
3853
3865
  * Returns an unsubscribe function.
3854
3866
  */
3855
3867
  onSubrouteRequest(callback: (subroute: string) => void): () => void;
3868
+ /**
3869
+ * Subscribes to post-connect context changes (tenant/subsidiary switch, period
3870
+ * close, …). The callback fires for each {@link ContextPayload} the host pushes
3871
+ * *after* connect — not for the initial context, which {@link connect} already
3872
+ * returns. Returns an unsubscribe function. Single subscriber: a second call
3873
+ * replaces the first. Wire it **before {@link connect}** (the bridge warns at
3874
+ * connect if it isn't).
3875
+ */
3876
+ onContextChange(callback: (ctx: ContextPayload) => void): () => void;
3877
+ /**
3878
+ * Subscribes to host auth changes: a Nominal logout (`authenticated: false` —
3879
+ * sign your backend out) or an identity/tenant switch (`authenticated: true`
3880
+ * with a new `userId`/`tenant` — re-authenticate). Fires for each change the
3881
+ * host pushes after connect. Returns an unsubscribe function. Single
3882
+ * subscriber: a second call replaces the first.
3883
+ *
3884
+ * **Wire this before {@link connect}** — without it the embedded app won't
3885
+ * sign out when the user logs out of Nominal (the bridge warns at connect if
3886
+ * it isn't wired).
3887
+ */
3888
+ onAuthChange(callback: (auth: AuthPayload) => void): () => void;
3889
+ /**
3890
+ * At connect, nudge the app if it hasn't wired the change listeners: every
3891
+ * embedded app should react to a Nominal logout ({@link onAuthChange}) and a
3892
+ * tenant/subsidiary switch ({@link onContextChange}). Fires once during the
3893
+ * connect flow (not speculatively later), so wire both **before** `connect()`.
3894
+ * The skill/codegen does this by default; this catches hand-wired apps.
3895
+ */
3896
+ private warnMissingListeners;
3856
3897
  /**
3857
3898
  * Navigates the host to a raw path relative to this app's tenant/subsidiary
3858
3899
  * base. Fire-and-forget.
@@ -3931,16 +3972,17 @@ declare class VibeAppBridge extends BridgeDataMethods {
3931
3972
  private stopConnectPolling;
3932
3973
  /**
3933
3974
  * Initializes PostHog session recording + analytics from the host-supplied
3934
- * config, **only when the host opted in** (`ctx.posthog?.enabled`).
3975
+ * config carried by a one-time `POSTHOG_PUSH`, **only when the host opted in**
3976
+ * (`config.enabled`).
3935
3977
  *
3936
- * The host is the single source of truth: when it sends no `posthog` block
3937
- * (older nom-ui, or recording disabled for this env/app) or `enabled: false`,
3938
- * this does nothing — `enabled` is the kill-switch even though `posthog-js` is
3939
- * bundled into the bridge. Cross-origin replay needs `recordCrossOriginIframes`
3940
- * on both sides so the parent receives the iframe's rrweb frames into one
3941
- * stitched recording, and `identify()` ties the iframe's recording + events to
3942
- * the same person as the host. Wrapped in try/catch so a PostHog failure is
3943
- * best-effort and never blocks `connect()`.
3978
+ * The host is the single source of truth: when it sends no `POSTHOG_PUSH`
3979
+ * (recording disabled for this env/app) or `enabled: false`, this does
3980
+ * nothing — `enabled` is the kill-switch even though `posthog-js` is bundled
3981
+ * into the bridge. Cross-origin replay needs `recordCrossOriginIframes` on both
3982
+ * sides so the parent receives the iframe's rrweb frames into one stitched
3983
+ * recording, and `identify()` ties the iframe's recording + events to the same
3984
+ * person as the host. Idempotent (the `posthogInitialized` guard) and wrapped
3985
+ * in try/catch so a duplicate push or a PostHog failure is harmless.
3944
3986
  */
3945
3987
  private initPostHog;
3946
3988
  /**
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- var version = "0.4.0";
1
+ var version = "0.5.0";
2
2
 
3
3
  type AccountingAccountDimensionValues = {
4
4
  account_id: string;
@@ -1392,6 +1392,7 @@ type ActivityProviderOperationErrorType = 'Connection Error' | 'Authentication E
1392
1392
  type ActivityReconciliationDefinitionData = {
1393
1393
  type: ActivityReconciliationType;
1394
1394
  task_definition_id: string;
1395
+ currency?: ActivityCurrency | null;
1395
1396
  side_a_entity_type: ActivityReconciliationEntityType;
1396
1397
  side_a_entity_id: string;
1397
1398
  side_b_entity_type: ActivityReconciliationEntityType;
@@ -1405,7 +1406,6 @@ type ActivityReconciliationInstanceDataInput = {
1405
1406
  task_instance_id: string;
1406
1407
  start_date: string;
1407
1408
  end_date: string;
1408
- currency?: ActivityCurrency | null;
1409
1409
  side_a_value?: number | string | null;
1410
1410
  side_b_value?: number | string | null;
1411
1411
  reconciling_items_balance?: number | string | null;
@@ -1419,7 +1419,6 @@ type ActivityReconciliationInstanceDataOutput = {
1419
1419
  task_instance_id: string;
1420
1420
  start_date: string;
1421
1421
  end_date: string;
1422
- currency?: ActivityCurrency | null;
1423
1422
  side_a_value?: string | null;
1424
1423
  side_b_value?: string | null;
1425
1424
  reconciling_items_balance?: string | null;
@@ -3190,9 +3189,10 @@ interface BridgeSubsidiary {
3190
3189
  *
3191
3190
  * The host (nom-ui) is the single source of truth for these values — it knows
3192
3191
  * the project key, the env-aware ingestion host, and the resolved person — and
3193
- * delivers them over the bridge so the iframe never hardcodes any of it. When
3194
- * the host omits the {@link ContextPayload.posthog} block entirely (older host,
3195
- * or recording disabled for this env/app), the bridge initializes nothing.
3192
+ * delivers it over the bridge (as a one-time `POSTHOG_PUSH`, separate from
3193
+ * {@link ContextPayload}) so the iframe never hardcodes any of it. When the host
3194
+ * has no PostHog config (recording disabled for this env/app), it sends no
3195
+ * `POSTHOG_PUSH` and the bridge initializes nothing.
3196
3196
  */
3197
3197
  interface PostHogContext {
3198
3198
  /** Project API key — the same PostHog project the host (nom-ui) uses. */
@@ -3230,6 +3230,27 @@ interface PostHogContext {
3230
3230
  */
3231
3231
  enabled: boolean;
3232
3232
  }
3233
+ /**
3234
+ * Authentication-state change the host pushes to the iframe via `AUTH_CHANGED`.
3235
+ * It carries the security principal the embedded app authenticates and scopes
3236
+ * its own data by. Sent when the Nominal session changes: a logout
3237
+ * (`authenticated: false`) or an identity/tenant switch (`authenticated: true`
3238
+ * with a new `userId`/`tenant`). The initial state is implicit at connect — the
3239
+ * iframe only loads when authenticated, and the current identity/tenant are
3240
+ * {@link ContextPayload.user} / {@link ContextPayload.tenant}, so apps baseline
3241
+ * off those and react to this push.
3242
+ *
3243
+ * Subsidiary/period and other view-level state are NOT here — they ride the
3244
+ * follow-up `CONTEXT_PUSH` (`onContextChange`), per "auth change, then context".
3245
+ */
3246
+ interface AuthPayload {
3247
+ /** Whether the Nominal user is still authenticated. */
3248
+ authenticated: boolean;
3249
+ /** The effective Nominal user id when authenticated; omitted on logout. */
3250
+ userId?: string;
3251
+ /** The tenant the user is acting in — the app's data-scoping key (RLS). */
3252
+ tenant?: string;
3253
+ }
3233
3254
  interface ContextPayload {
3234
3255
  tenant: string;
3235
3256
  subsidiaryId: number;
@@ -3242,21 +3263,9 @@ interface ContextPayload {
3242
3263
  subroute?: string;
3243
3264
  /** Slug of the last closed accounting period, e.g. "mar-2026". */
3244
3265
  lastClosedPeriodSlug?: string;
3245
- /**
3246
- * PostHog config for in-iframe session recording + custom events. **Absent
3247
- * when recording is off** (older host, or disabled for this env/app), in which
3248
- * case the bridge initializes no analytics. Optional so old hosts/bridges stay
3249
- * compatible.
3250
- */
3251
- posthog?: PostHogContext;
3252
3266
  /** Version of the host SDK (@nominalso/vibe-host) running in nom-ui. */
3253
3267
  hostVersion: string;
3254
3268
  }
3255
- /** Payload sent by the bridge when requesting context from the host. */
3256
- interface GetContextPayload {
3257
- /** Version of the bridge SDK (@nominalso/vibe-bridge) running in the iframe. */
3258
- bridgeVersion: string;
3259
- }
3260
3269
 
3261
3270
  interface UploadPayload {
3262
3271
  buffer: ArrayBuffer;
@@ -3490,10 +3499,6 @@ interface RequestRegistry {
3490
3499
  payload: InvalidateCachePayload;
3491
3500
  data: InvalidateResult;
3492
3501
  };
3493
- GET_CONTEXT: {
3494
- payload: GetContextPayload;
3495
- data: ContextPayload;
3496
- };
3497
3502
  UPLOAD_FILE: {
3498
3503
  payload: UploadPayload;
3499
3504
  data: UploadResponse;
@@ -3748,9 +3753,9 @@ interface VibeAppBridgeOptions {
3748
3753
  *
3749
3754
  * When omitted, each operation uses an operation-specific default tuned to
3750
3755
  * how long it realistically takes (API reads `30000`, `UPLOAD_FILE`
3751
- * `120000`, `INVALIDATE_CACHE` `10000`, `GET_CONTEXT` `5000`). Setting this
3752
- * overrides all of them with a single value — only do that if you have a
3753
- * specific reason; the defaults already match the host's expectations.
3756
+ * `120000`, `INVALIDATE_CACHE` `10000`). Setting this overrides all of them
3757
+ * with a single value — only do that if you have a specific reason; the
3758
+ * defaults already match the host's expectations.
3754
3759
  */
3755
3760
  requestTimeout?: number;
3756
3761
  }
@@ -3796,13 +3801,20 @@ declare class VibeAppBridge extends BridgeDataMethods {
3796
3801
  private connectPollInterval;
3797
3802
  private connectTimeout;
3798
3803
  private onContextReceived;
3799
- private onContextError;
3804
+ /** Rejects an in-flight connect() (used by destroy()); `null` once settled. */
3805
+ private rejectConnect;
3806
+ /**
3807
+ * Persistent subscriber for post-connect context changes (tenant/subsidiary
3808
+ * switch, period close, …). Distinct from {@link onContextReceived}, which only
3809
+ * resolves the connect handshake and is cleared afterward. `null` until
3810
+ * {@link onContextChange} is called.
3811
+ */
3812
+ private contextChangeCallback;
3800
3813
  /**
3801
- * Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
3802
- * late response from a *previous* connect attempt, which would otherwise
3803
- * resolve/reject the new one (a stale error could reject a healthy reconnect).
3814
+ * Persistent subscriber for host auth changes (Nominal logout or
3815
+ * identity/tenant switch). `null` until {@link onAuthChange} is called.
3804
3816
  */
3805
- private readonly connectPollIds;
3817
+ private authChangeCallback;
3806
3818
  private lastReportedSubroute;
3807
3819
  private suppressSubrouteReport;
3808
3820
  private subrouteCallback;
@@ -3819,11 +3831,11 @@ declare class VibeAppBridge extends BridgeDataMethods {
3819
3831
  * methods, `upload()`, and subroute reporting all require an established
3820
3832
  * connection. Concurrent calls return the same promise.
3821
3833
  *
3822
- * Actively polls the host every 500ms until a response arrives, which
3823
- * handles the race condition where the host's initial context push arrives
3824
- * before this listener is ready. Rejects with `Bridge connect timed out`
3825
- * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
3826
- * or the host hasn't mounted).
3834
+ * Sends a `CONNECT` handshake, re-sending every 500ms until the host replies
3835
+ * by pushing the context (so it works even if the host mounts after the
3836
+ * iframe loads). Context is only ever delivered by push. Rejects with `Bridge
3837
+ * connect timed out` after 10 seconds if no context arrives (usually a
3838
+ * `parentOrigin` mismatch or the host hasn't mounted).
3827
3839
  *
3828
3840
  * After connecting, if the context includes an initial subroute (e.g. from
3829
3841
  * a deep link), the bridge navigates to it and starts auto-tracking
@@ -3853,6 +3865,35 @@ declare class VibeAppBridge extends BridgeDataMethods {
3853
3865
  * Returns an unsubscribe function.
3854
3866
  */
3855
3867
  onSubrouteRequest(callback: (subroute: string) => void): () => void;
3868
+ /**
3869
+ * Subscribes to post-connect context changes (tenant/subsidiary switch, period
3870
+ * close, …). The callback fires for each {@link ContextPayload} the host pushes
3871
+ * *after* connect — not for the initial context, which {@link connect} already
3872
+ * returns. Returns an unsubscribe function. Single subscriber: a second call
3873
+ * replaces the first. Wire it **before {@link connect}** (the bridge warns at
3874
+ * connect if it isn't).
3875
+ */
3876
+ onContextChange(callback: (ctx: ContextPayload) => void): () => void;
3877
+ /**
3878
+ * Subscribes to host auth changes: a Nominal logout (`authenticated: false` —
3879
+ * sign your backend out) or an identity/tenant switch (`authenticated: true`
3880
+ * with a new `userId`/`tenant` — re-authenticate). Fires for each change the
3881
+ * host pushes after connect. Returns an unsubscribe function. Single
3882
+ * subscriber: a second call replaces the first.
3883
+ *
3884
+ * **Wire this before {@link connect}** — without it the embedded app won't
3885
+ * sign out when the user logs out of Nominal (the bridge warns at connect if
3886
+ * it isn't wired).
3887
+ */
3888
+ onAuthChange(callback: (auth: AuthPayload) => void): () => void;
3889
+ /**
3890
+ * At connect, nudge the app if it hasn't wired the change listeners: every
3891
+ * embedded app should react to a Nominal logout ({@link onAuthChange}) and a
3892
+ * tenant/subsidiary switch ({@link onContextChange}). Fires once during the
3893
+ * connect flow (not speculatively later), so wire both **before** `connect()`.
3894
+ * The skill/codegen does this by default; this catches hand-wired apps.
3895
+ */
3896
+ private warnMissingListeners;
3856
3897
  /**
3857
3898
  * Navigates the host to a raw path relative to this app's tenant/subsidiary
3858
3899
  * base. Fire-and-forget.
@@ -3931,16 +3972,17 @@ declare class VibeAppBridge extends BridgeDataMethods {
3931
3972
  private stopConnectPolling;
3932
3973
  /**
3933
3974
  * Initializes PostHog session recording + analytics from the host-supplied
3934
- * config, **only when the host opted in** (`ctx.posthog?.enabled`).
3975
+ * config carried by a one-time `POSTHOG_PUSH`, **only when the host opted in**
3976
+ * (`config.enabled`).
3935
3977
  *
3936
- * The host is the single source of truth: when it sends no `posthog` block
3937
- * (older nom-ui, or recording disabled for this env/app) or `enabled: false`,
3938
- * this does nothing — `enabled` is the kill-switch even though `posthog-js` is
3939
- * bundled into the bridge. Cross-origin replay needs `recordCrossOriginIframes`
3940
- * on both sides so the parent receives the iframe's rrweb frames into one
3941
- * stitched recording, and `identify()` ties the iframe's recording + events to
3942
- * the same person as the host. Wrapped in try/catch so a PostHog failure is
3943
- * best-effort and never blocks `connect()`.
3978
+ * The host is the single source of truth: when it sends no `POSTHOG_PUSH`
3979
+ * (recording disabled for this env/app) or `enabled: false`, this does
3980
+ * nothing — `enabled` is the kill-switch even though `posthog-js` is bundled
3981
+ * into the bridge. Cross-origin replay needs `recordCrossOriginIframes` on both
3982
+ * sides so the parent receives the iframe's rrweb frames into one stitched
3983
+ * recording, and `identify()` ties the iframe's recording + events to the same
3984
+ * person as the host. Idempotent (the `posthogInitialized` guard) and wrapped
3985
+ * in try/catch so a duplicate push or a PostHog failure is harmless.
3944
3986
  */
3945
3987
  private initPostHog;
3946
3988
  /**
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);
@@ -370,8 +370,6 @@ var CONNECT_POLL_MS = 500;
370
370
  var DEFAULT_TIMEOUTS = {
371
371
  /** Typed Nominal API reads/writes (`GET_*` / `POST_*` / `CREATE_*` / ...). */
372
372
  api: 3e4,
373
- /** `GET_CONTEXT` re-fetch (the initial fetch is governed by `connect()`). */
374
- context: 5e3,
375
373
  /** `INVALIDATE_CACHE`. */
376
374
  invalidate: 1e4,
377
375
  /** `UPLOAD_FILE` — large payloads + presigned S3 PUT take a while. */
@@ -384,8 +382,6 @@ function resolveRequestTimeout(type, requestTimeout) {
384
382
  return DEFAULT_TIMEOUTS.upload;
385
383
  case "INVALIDATE_CACHE":
386
384
  return DEFAULT_TIMEOUTS.invalidate;
387
- case "GET_CONTEXT":
388
- return DEFAULT_TIMEOUTS.context;
389
385
  default:
390
386
  return DEFAULT_TIMEOUTS.api;
391
387
  }
@@ -473,13 +469,20 @@ var VibeAppBridge = class extends BridgeDataMethods {
473
469
  this.connectPollInterval = null;
474
470
  this.connectTimeout = null;
475
471
  this.onContextReceived = null;
476
- this.onContextError = null;
472
+ /** Rejects an in-flight connect() (used by destroy()); `null` once settled. */
473
+ this.rejectConnect = null;
477
474
  /**
478
- * Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
479
- * late response from a *previous* connect attempt, which would otherwise
480
- * resolve/reject the new one (a stale error could reject a healthy reconnect).
475
+ * Persistent subscriber for post-connect context changes (tenant/subsidiary
476
+ * switch, period close, …). Distinct from {@link onContextReceived}, which only
477
+ * resolves the connect handshake and is cleared afterward. `null` until
478
+ * {@link onContextChange} is called.
481
479
  */
482
- this.connectPollIds = /* @__PURE__ */ new Set();
480
+ this.contextChangeCallback = null;
481
+ /**
482
+ * Persistent subscriber for host auth changes (Nominal logout or
483
+ * identity/tenant switch). `null` until {@link onAuthChange} is called.
484
+ */
485
+ this.authChangeCallback = null;
483
486
  this.lastReportedSubroute = null;
484
487
  this.suppressSubrouteReport = false;
485
488
  this.subrouteCallback = null;
@@ -492,7 +495,21 @@ var VibeAppBridge = class extends BridgeDataMethods {
492
495
  progress: (msg) => this.handleProgress(msg)
493
496
  };
494
497
  this.pushHandlers = {
495
- CONTEXT_PUSH: (payload) => this.onContextReceived?.(payload),
498
+ CONTEXT_PUSH: (payload) => {
499
+ if (this.onContextReceived) {
500
+ this.onContextReceived(payload);
501
+ } else if (this.context) {
502
+ this.context = payload;
503
+ this.contextChangeCallback?.(payload);
504
+ }
505
+ },
506
+ // Persistent (not nulled after connect): the host delivers PostHog once at
507
+ // connect via its own push, and initPostHog is idempotent, so this can run
508
+ // whenever the message arrives, independent of how connect() resolved.
509
+ POSTHOG_PUSH: (payload) => this.initPostHog(payload),
510
+ // Persistent: the host pushes auth changes (logout, identity/tenant
511
+ // switch) at any time after connect.
512
+ AUTH_CHANGED: (payload) => this.authChangeCallback?.(payload),
496
513
  SUBROUTE_PUSH: (payload) => {
497
514
  const safe = normalizeSubroute(payload.subroute);
498
515
  if (!safe) return;
@@ -543,11 +560,11 @@ var VibeAppBridge = class extends BridgeDataMethods {
543
560
  * methods, `upload()`, and subroute reporting all require an established
544
561
  * connection. Concurrent calls return the same promise.
545
562
  *
546
- * Actively polls the host every 500ms until a response arrives, which
547
- * handles the race condition where the host's initial context push arrives
548
- * before this listener is ready. Rejects with `Bridge connect timed out`
549
- * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
550
- * or the host hasn't mounted).
563
+ * Sends a `CONNECT` handshake, re-sending every 500ms until the host replies
564
+ * by pushing the context (so it works even if the host mounts after the
565
+ * iframe loads). Context is only ever delivered by push. Rejects with `Bridge
566
+ * connect timed out` after 10 seconds if no context arrives (usually a
567
+ * `parentOrigin` mismatch or the host hasn't mounted).
551
568
  *
552
569
  * After connecting, if the context includes an initial subroute (e.g. from
553
570
  * a deep link), the bridge navigates to it and starts auto-tracking
@@ -577,16 +594,16 @@ var VibeAppBridge = class extends BridgeDataMethods {
577
594
  this.stopConnectPolling();
578
595
  this.connectPromise = null;
579
596
  this.onContextReceived = null;
580
- this.onContextError = null;
597
+ this.rejectConnect = null;
581
598
  reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
582
599
  }, CONNECT_TIMEOUT_MS);
583
600
  this.connectTimeout = timer;
584
- this.onContextError = (error) => {
601
+ this.rejectConnect = (error) => {
585
602
  clearTimeout(timer);
586
603
  this.stopConnectPolling();
587
604
  this.connectPromise = null;
588
605
  this.onContextReceived = null;
589
- this.onContextError = null;
606
+ this.rejectConnect = null;
590
607
  reject(error);
591
608
  };
592
609
  this.onContextReceived = (ctx) => {
@@ -595,11 +612,10 @@ var VibeAppBridge = class extends BridgeDataMethods {
595
612
  this.context = ctx;
596
613
  this.connectPromise = null;
597
614
  this.onContextReceived = null;
598
- this.onContextError = null;
615
+ this.rejectConnect = null;
599
616
  console.log(
600
617
  `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
601
618
  );
602
- this.initPostHog(ctx);
603
619
  const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
604
620
  if (initialSubroute && initialSubroute !== "/") {
605
621
  this.withSubrouteSuppressed(() => {
@@ -611,23 +627,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
611
627
  this.lastReportedSubroute = window.location.pathname + window.location.search;
612
628
  }
613
629
  this.setupNavigationTracking();
630
+ this.warnMissingListeners();
614
631
  resolve(ctx);
615
632
  };
616
- const poll = () => {
617
- const requestId = crypto.randomUUID();
618
- this.connectPollIds.add(requestId);
619
- const message = {
620
- __protocol: PROTOCOL_ID,
621
- __version: PROTOCOL_VERSION,
622
- kind: "request",
623
- type: "GET_CONTEXT",
624
- requestId,
625
- payload: { bridgeVersion: version }
626
- };
627
- this.postToParent(message);
633
+ const sendConnect = () => {
634
+ this.sendCommand("CONNECT", { bridgeVersion: version });
628
635
  };
629
- poll();
630
- this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
636
+ sendConnect();
637
+ this.connectPollInterval = setInterval(sendConnect, CONNECT_POLL_MS);
631
638
  });
632
639
  return this.connectPromise;
633
640
  }
@@ -654,6 +661,56 @@ var VibeAppBridge = class extends BridgeDataMethods {
654
661
  this.subrouteCallback = null;
655
662
  };
656
663
  }
664
+ /**
665
+ * Subscribes to post-connect context changes (tenant/subsidiary switch, period
666
+ * close, …). The callback fires for each {@link ContextPayload} the host pushes
667
+ * *after* connect — not for the initial context, which {@link connect} already
668
+ * returns. Returns an unsubscribe function. Single subscriber: a second call
669
+ * replaces the first. Wire it **before {@link connect}** (the bridge warns at
670
+ * connect if it isn't).
671
+ */
672
+ onContextChange(callback) {
673
+ this.contextChangeCallback = callback;
674
+ return () => {
675
+ this.contextChangeCallback = null;
676
+ };
677
+ }
678
+ /**
679
+ * Subscribes to host auth changes: a Nominal logout (`authenticated: false` —
680
+ * sign your backend out) or an identity/tenant switch (`authenticated: true`
681
+ * with a new `userId`/`tenant` — re-authenticate). Fires for each change the
682
+ * host pushes after connect. Returns an unsubscribe function. Single
683
+ * subscriber: a second call replaces the first.
684
+ *
685
+ * **Wire this before {@link connect}** — without it the embedded app won't
686
+ * sign out when the user logs out of Nominal (the bridge warns at connect if
687
+ * it isn't wired).
688
+ */
689
+ onAuthChange(callback) {
690
+ this.authChangeCallback = callback;
691
+ return () => {
692
+ this.authChangeCallback = null;
693
+ };
694
+ }
695
+ /**
696
+ * At connect, nudge the app if it hasn't wired the change listeners: every
697
+ * embedded app should react to a Nominal logout ({@link onAuthChange}) and a
698
+ * tenant/subsidiary switch ({@link onContextChange}). Fires once during the
699
+ * connect flow (not speculatively later), so wire both **before** `connect()`.
700
+ * The skill/codegen does this by default; this catches hand-wired apps.
701
+ */
702
+ warnMissingListeners() {
703
+ if (!this.authChangeCallback) {
704
+ console.warn(
705
+ "[vibe-bridge] connected without onAuthChange \u2014 the app will not sign out on Nominal logout or react to an identity/tenant switch. Call bridge.onAuthChange() before connect()."
706
+ );
707
+ }
708
+ if (!this.contextChangeCallback) {
709
+ console.warn(
710
+ "[vibe-bridge] connected without onContextChange \u2014 the app will not react to a tenant/subsidiary/period switch. Call bridge.onContextChange() before connect()."
711
+ );
712
+ }
713
+ }
657
714
  // ---------------------------------------------------------------------------
658
715
  // Host navigation & UI commands
659
716
  //
@@ -734,7 +791,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
734
791
  }
735
792
  destroy() {
736
793
  this.teardownNavigationTracking();
737
- this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
794
+ this.rejectConnect?.(new BridgeError("DESTROYED", "Bridge destroyed"));
738
795
  this.stopConnectPolling();
739
796
  window.removeEventListener("message", this.boundHandleMessage);
740
797
  for (const pending of this.pendingRequests.values()) {
@@ -745,7 +802,9 @@ var VibeAppBridge = class extends BridgeDataMethods {
745
802
  this.posthogInitialized = false;
746
803
  this.connectPromise = null;
747
804
  this.onContextReceived = null;
748
- this.onContextError = null;
805
+ this.rejectConnect = null;
806
+ this.contextChangeCallback = null;
807
+ this.authChangeCallback = null;
749
808
  this.subrouteCallback = null;
750
809
  this.lastReportedSubroute = null;
751
810
  }
@@ -758,24 +817,23 @@ var VibeAppBridge = class extends BridgeDataMethods {
758
817
  clearTimeout(this.connectTimeout);
759
818
  this.connectTimeout = null;
760
819
  }
761
- this.connectPollIds.clear();
762
820
  }
763
821
  /**
764
822
  * Initializes PostHog session recording + analytics from the host-supplied
765
- * config, **only when the host opted in** (`ctx.posthog?.enabled`).
823
+ * config carried by a one-time `POSTHOG_PUSH`, **only when the host opted in**
824
+ * (`config.enabled`).
766
825
  *
767
- * The host is the single source of truth: when it sends no `posthog` block
768
- * (older nom-ui, or recording disabled for this env/app) or `enabled: false`,
769
- * this does nothing — `enabled` is the kill-switch even though `posthog-js` is
770
- * bundled into the bridge. Cross-origin replay needs `recordCrossOriginIframes`
771
- * on both sides so the parent receives the iframe's rrweb frames into one
772
- * stitched recording, and `identify()` ties the iframe's recording + events to
773
- * the same person as the host. Wrapped in try/catch so a PostHog failure is
774
- * best-effort and never blocks `connect()`.
826
+ * The host is the single source of truth: when it sends no `POSTHOG_PUSH`
827
+ * (recording disabled for this env/app) or `enabled: false`, this does
828
+ * nothing — `enabled` is the kill-switch even though `posthog-js` is bundled
829
+ * into the bridge. Cross-origin replay needs `recordCrossOriginIframes` on both
830
+ * sides so the parent receives the iframe's rrweb frames into one stitched
831
+ * recording, and `identify()` ties the iframe's recording + events to the same
832
+ * person as the host. Idempotent (the `posthogInitialized` guard) and wrapped
833
+ * in try/catch so a duplicate push or a PostHog failure is harmless.
775
834
  */
776
- initPostHog(ctx) {
777
- const config = ctx.posthog;
778
- if (!config?.enabled) return;
835
+ initPostHog(config) {
836
+ if (!config.enabled) return;
779
837
  if (this.posthogInitialized) return;
780
838
  if (typeof window === "undefined") return;
781
839
  if (!config.token || !config.apiHost || !config.distinctId) {
@@ -934,15 +992,6 @@ var VibeAppBridge = class extends BridgeDataMethods {
934
992
  this.kindHandlers[event.data.kind]?.(event.data);
935
993
  }
936
994
  handleResponse(msg) {
937
- if (msg.type === "GET_CONTEXT") {
938
- if (!this.connectPollIds.has(msg.requestId)) return;
939
- if (msg.ok) {
940
- this.onContextReceived?.(msg.data);
941
- } else {
942
- this.onContextError?.(new BridgeError(msg.code ?? "CONTEXT_ERROR", msg.error));
943
- }
944
- return;
945
- }
946
995
  const pending = this.pendingRequests.get(msg.requestId);
947
996
  if (!pending) return;
948
997
  this.pendingRequests.delete(msg.requestId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nominalso/vibe-bridge",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Iframe-side SDK for building Nominal Vibe Apps — connects an embedded app to its Nominal host over a typed postMessage bridge (context, data fetch, file upload, subroute deep-linking).",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/nominalso/vibe-apps-sdk#readme",