@nominalso/vibe-bridge 0.2.0 → 0.4.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/AGENTS.md CHANGED
@@ -4,7 +4,7 @@ Instructions for AI coding agents (Claude Code, Cursor, Copilot, Lovable, etc.)
4
4
 
5
5
  ## What this package is
6
6
 
7
- `@nominalso/vibe-bridge` is the **iframe-side** SDK for a Nominal Vibe App — a standalone browser app (often built with Lovable) embedded in the Nominal platform via a cross-origin `<iframe>`. The app cannot call Nominal APIs directly; it talks to its Nominal host over a typed `postMessage` bridge. This package is that bridge. It is **browser-only** (uses `window`, `postMessage`, `crypto.randomUUID`) and has **zero runtime dependencies**.
7
+ `@nominalso/vibe-bridge` is the **iframe-side** SDK for a Nominal Vibe App — a standalone browser app (often built with Lovable) embedded in the Nominal platform via a cross-origin `<iframe>`. The app cannot call Nominal APIs directly; it talks to its Nominal host over a typed `postMessage` bridge. This package is that bridge. It is **browser-only** (uses `window`, `postMessage`, `crypto.randomUUID`) and bundles **`posthog-js`** (its only runtime dependency) for host-enabled in-iframe session recording.
8
8
 
9
9
  The host counterpart is `@nominalso/vibe-host` (used by Nominal's own app — you do not install it in a Vibe App).
10
10
 
@@ -15,9 +15,7 @@ The host counterpart is `@nominalso/vibe-host` (used by Nominal's own app — yo
15
15
  ```ts
16
16
  import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
17
17
 
18
- const bridge = new VibeAppBridge({
19
- parentOrigin: import.meta.env.VITE_PARENT_ORIGIN, // exact origin of the embedding Nominal app
20
- })
18
+ const bridge = new VibeAppBridge() // no config — auto-detects the embedding Nominal app
21
19
 
22
20
  const ctx: ContextPayload = await bridge.connect() // call ONCE, await before anything else
23
21
 
@@ -32,27 +30,30 @@ bridge.destroy() // on unmount
32
30
 
33
31
  ## Core surface
34
32
 
35
- - `new VibeAppBridge({ parentOrigin, requestTimeout? })` — `requestTimeout` is optional; omit it and each op uses a tuned default (API `30000`, `UPLOAD_FILE` `120000`, `INVALIDATE_CACHE` `10000`, `GET_CONTEXT` `5000` ms).
36
- - `connect(): Promise<ContextPayload>` — call once; rejects after 10 s on `parentOrigin` mismatch / host not mounted.
33
+ - `new VibeAppBridge({ parentOrigin?, requestTimeout? })` — both optional; `new VibeAppBridge()` auto-detects the host. `requestTimeout` omitted tuned per-op defaults (API `30000`, `UPLOAD_FILE` `120000`, `INVALIDATE_CACHE` `10000`, `GET_CONTEXT` `5000` ms).
34
+ - `connect(): Promise<ContextPayload>` — call once; rejects after 10 s on a host that never mounts, or immediately with code `PARENT_ORIGIN_UNRESOLVED` if no host could be detected/pinned.
37
35
  - ~46 typed data methods, e.g. `getChartOfAccounts`, `getSubsidiaries`, `getPeriods`, `getJournalEntries`, `getAccounts`, `postTaskOutput`. Each takes the operation's `payload` and returns its `data`.
38
36
  - `request('<OPERATION>', payload, onProgress?)` — typed escape hatch for any operation; `'<OPERATION>'` autocompletes and narrows the payload/return types.
39
37
  - `upload(file: File, { entityType, entityId?, onProgress? }): Promise<{ attachmentId, name }>`.
40
38
  - `navigate`/`navigateTo`/`refresh` and `ui.setSideNav`/`ui.switchSubsidiary` — drive the host's chrome (router, side nav, subsidiary switcher); `invalidateCache(...)` busts host caches.
41
39
  - `reportSubroute(subroute, { replace? })`, `onSubrouteRequest(cb): () => void` — usually unnecessary; standard SPA navigation is auto-tracked.
40
+ - `track(event: string, properties?): void` — capture a custom PostHog event. **No-op unless the host enabled recording** (see below); always safe to call.
42
41
  - `destroy()`.
43
42
 
43
+ **Session recording (PostHog).** The bridge bundles `posthog-js` and, on `connect()`, initializes it **only if the host sent a `posthog` block with `enabled: true`** (cross-origin replay stitched into the host's recording, masking mirroring nom-ui — inputs unmasked, password fields masked — attributed to the same person via `identify`). If the host sends nothing, the bridge initializes nothing — `enabled` is the kill-switch. You configure nothing in the app; just call `track(...)` for custom events. If you serve the app yourself, allow `connect-src https://*.i.posthog.com https://us-assets.i.posthog.com` and `worker-src blob:` in its CSP (no `script-src` entry — posthog is bundled, not from a CDN).
44
+
44
45
  A failed operation throws a `BridgeError` (`extends Error`) with a machine-readable `code` (`'RATE_LIMITED'`, `'FILE_TOO_LARGE'`, `'TIMEOUT'`, …); branch on `err.code` rather than the message. A failed Nominal API call throws the `HttpBridgeError` subtype (`code: 'REQUEST_FAILED'`) carrying the HTTP `status` — branch on `err.status` (e.g. `404`).
45
46
 
46
- Exported values/types: `VibeAppBridge`, `BridgeError`, `BRIDGE_VERSION`, and types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
47
+ Exported values/types: `VibeAppBridge`, `BridgeError`, `HttpBridgeError`, `BRIDGE_VERSION`, and types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `PostHogContext`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
47
48
 
48
- `ContextPayload` shape: `{ tenant, subsidiaryId, subsidiaries: { id, displayName }[], user: { id, displayName }, subroute?, lastClosedPeriodSlug?, hostVersion }`.
49
+ `ContextPayload` shape: `{ tenant, subsidiaryId, subsidiaries: { id, displayName }[], user: { id, displayName }, subroute?, lastClosedPeriodSlug?, posthog?: { token, apiHost, distinctId, enabled }, hostVersion }`. The `posthog` block is supplied by the host only when recording is on.
49
50
 
50
51
  The full method/operation list is in `README.md` ("Operation catalog") and in `docs/data-fetching.md`. The detailed wire-up guides are in `docs/`.
51
52
 
52
53
  ## Gotchas (wrong → right)
53
54
 
54
55
  - **Don't call data methods before `connect()` resolves.** Always `await bridge.connect()` first.
55
- - **`parentOrigin` is an origin, not a URL.** `https://app.nominal.so` ✅ — not `https://app.nominal.so/app` and not a trailing slash. A mismatch makes `connect()` time out after 10 s.
56
+ - **`parentOrigin` is optional** — omit it and the bridge auto-detects the embedding host. If you do pass it, it's an origin or list of origins (not a URL): `https://app.nominal.so` ✅ — not `https://app.nominal.so/app`, no trailing slash. For preview testing pass a list with a glob: `['https://app.nominal.so', 'https://*.vercel.app']` patterns match only when the app itself runs on a preview host.
56
57
  - **`upload()` takes a `File`**, not a path or `FormData`.
57
58
  - **Writes go through `postTaskOutput` only.** Vibe Apps never write Nominal data directly.
58
59
  - **Call `bridge.destroy()` on unmount** to remove listeners and reject pending requests.
@@ -60,5 +61,5 @@ The full method/operation list is in `README.md` ("Operation catalog") and in `d
60
61
  ## Don't
61
62
 
62
63
  - Don't poll or busy-wait for context — `await connect()` resolves when it's ready.
63
- - Don't hardcode `parentOrigin`; read it from an env var so dev and prod differ.
64
+ - Don't pass `parentOrigin` unless you mean to pin the host; the default auto-detects it. If you pin, read it from an env var (and add a preview pattern to the list when testing deploy previews).
64
65
  - Don't import from `@nominalso/vibe-protocol-types` / `vibe-api-types` — they are not published; all public types are re-exported from `@nominalso/vibe-bridge`.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Iframe-side SDK for building **Nominal Vibe Apps** — standalone web apps (typically built with [Lovable](https://lovable.dev)) embedded in the Nominal platform via a cross-origin `<iframe>`. The bridge connects your app to its Nominal host over a typed `postMessage` protocol: read Nominal data, submit Close-Management task outputs, upload files, and keep deep-link routing in sync.
4
4
 
5
- > **For AI agents / Lovable:** this is a browser-only SDK. The wiring is always the same — construct `VibeAppBridge` with the host `parentOrigin`, `await bridge.connect()` **once**, then call data methods. Copy the quickstart below verbatim; it is the complete happy path.
5
+ > **For AI agents / Lovable:** this is a browser-only SDK. The wiring is always the same — `new VibeAppBridge()` (no config — it auto-detects the Nominal host), `await bridge.connect()` **once**, then call data methods. Copy the quickstart below verbatim; it is the complete happy path.
6
6
 
7
7
  ## Install
8
8
 
@@ -10,18 +10,17 @@ Iframe-side SDK for building **Nominal Vibe Apps** — standalone web apps (typi
10
10
  npm install @nominalso/vibe-bridge
11
11
  ```
12
12
 
13
- Zero runtime dependencies. Ships ESM + CJS and self-contained TypeScript types.
13
+ Bundles [`posthog-js`](https://posthog.com/docs/libraries/js) (its only runtime dependency) so the host can enable in-iframe session recording — see [Session recording & custom events](#session-recording--custom-events). Ships ESM + CJS and self-contained TypeScript types.
14
14
 
15
15
  ## Quickstart
16
16
 
17
17
  ```ts
18
18
  import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
19
19
 
20
- // 1. Construct with the origin of the Nominal app embedding this iframe.
21
- // Must match exactly drive it from an env var.
22
- const bridge = new VibeAppBridge({
23
- parentOrigin: import.meta.env.VITE_PARENT_ORIGIN,
24
- })
20
+ // 1. Construct. No config needed — the bridge auto-detects the Nominal app
21
+ // that embeds it. (Pin the host origin explicitly only if you want to;
22
+ // see "Pinning the host origin" below.)
23
+ const bridge = new VibeAppBridge()
25
24
 
26
25
  // 2. Connect ONCE on init and await it before any other call.
27
26
  // Resolves with the tenant/user context (or rejects after 10s).
@@ -47,28 +46,47 @@ await bridge.postTaskOutput({ path: { task_instance_id: 'task-1' }, body: {} })
47
46
  bridge.destroy()
48
47
  ```
49
48
 
50
- `parentOrigin` via environment variable:
49
+ ### Pinning the host origin (optional)
51
50
 
52
- ```sh
53
- # .env (committed standalone/dev default)
54
- VITE_PARENT_ORIGIN=http://localhost:5173
51
+ By default you pass **nothing** — the bridge talks only to the Nominal page that
52
+ actually embeds it. That's safe because Nominal serves Vibe Apps behind a
53
+ `frame-ancestors` CSP, so only nom-ui can frame your app. Most apps need nothing
54
+ more.
55
+
56
+ Pass `parentOrigin` to pin explicitly — defense in depth, or when running
57
+ outside Nominal's edge. Drive it from an env var, or use a list / **glob
58
+ patterns** to accept dynamic preview deployments:
59
+
60
+ ```ts
61
+ new VibeAppBridge({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
62
+ new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
63
+ ```
55
64
 
56
- # .env.local (git-ignored — override when testing inside nom-ui)
65
+ ```sh
66
+ # .env.local
57
67
  VITE_PARENT_ORIGIN=http://localhost:3000
58
68
  ```
59
69
 
70
+ A pattern's `*` matches exactly one DNS label or port (anchored, scheme
71
+ literal): `https://*.vercel.app` matches `https://pr-7.vercel.app` but not
72
+ `https://a.b.vercel.app` or `https://pr-7.vercel.app.evil.com`. **Patterns are
73
+ honoured only when this app's own page is served from a recognised preview host**
74
+ (`*.vercel.app`, `*.lovable.app`, `localhost`, …) — on a production custom
75
+ domain pattern entries are ignored and only exact origins match. Always include
76
+ at least one exact origin alongside any patterns.
77
+
60
78
  ## API
61
79
 
62
80
  ### `new VibeAppBridge(options)`
63
81
 
64
- | Option | Type | Default | Description |
65
- | ---------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
66
- | `parentOrigin` | `string` | | Origin of the Nominal app embedding this iframe. **Must match the host origin exactly.** |
67
- | `requestTimeout` | `number` | _per-op_ | Global timeout (ms) before a call rejects with `BridgeError` code `'TIMEOUT'`. Omit to use per-operation defaults: API `30000`, `UPLOAD_FILE` `120000`, `INVALIDATE_CACHE` `10000`, `GET_CONTEXT` `5000`. |
82
+ | Option | Type | Default | Description |
83
+ | ---------------- | -------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
+ | `parentOrigin` | `string \| string[]` | _auto_ | **Optional.** Omit to auto-detect the embedding Nominal host. Pass to pin: exact origins always match; glob patterns (`https://*.vercel.app`) match only when this app runs on a preview host. See above. |
85
+ | `requestTimeout` | `number` | _per-op_ | Global timeout (ms) before a call rejects with `BridgeError` code `'TIMEOUT'`. Omit to use per-operation defaults: API `30000`, `UPLOAD_FILE` `120000`, `INVALIDATE_CACHE` `10000`, `GET_CONTEXT` `5000`. |
68
86
 
69
87
  ### `connect(): Promise<ContextPayload>`
70
88
 
71
- Call **once** on init and `await` it before anything else. Polls the host every 500 ms until context arrives; rejects with `Bridge connect timed out` after 10 s (usually a `parentOrigin` mismatch or the host hasn't mounted). Concurrent calls return the same promise.
89
+ Call **once** on init and `await` it before anything else. Polls the host every 500 ms until context arrives; rejects with `Bridge connect timed out` after 10 s (usually a `parentOrigin` mismatch or the host hasn't mounted). If no concrete parent origin can be resolved (e.g. a pattern-only `parentOrigin` on a non-preview host), it rejects immediately with `BridgeError` code `PARENT_ORIGIN_UNRESOLVED`. Concurrent calls return the same promise.
72
90
 
73
91
  ### Data operations
74
92
 
@@ -100,9 +118,31 @@ Uploads a `File` through the host (converts to `ArrayBuffer` first — sandboxed
100
118
 
101
119
  Removes listeners, rejects pending requests, and restores patched history methods. Call on unmount.
102
120
 
121
+ ### Session recording & custom events
122
+
123
+ The cross-origin iframe is invisible to the host's own session replay (same-origin policy), so the bridge bundles `posthog-js` and initializes it **itself** — but only when the host opts in. On `connect()`, if the resolved context carries a `posthog` block with `enabled: true`, the bridge:
124
+
125
+ - inits `posthog-js` with `recordCrossOriginIframes: true` (so your replay is **stitched into the host's recording**) and masking that mirrors nom-ui — `maskAllInputs: false` with `maskInputOptions: { password: true }` (inputs unmasked for richer insight, password fields kept masked);
126
+ - calls `identify()` with the host-resolved `distinctId`, so the recording and your events attribute to the **same PostHog person** as nom-ui;
127
+ - if the host also passes its `sessionId` (its `posthog.get_session_id()`), seeds it via `bootstrap.sessionID` so `track()` events share the host's **`$session_id`** and line up with the stitched replay.
128
+
129
+ The host is the single source of truth. **If it sends no `posthog` block (older host, or recording disabled for this env/app), the bridge initializes nothing** — `enabled` is the kill-switch even though `posthog-js` is bundled. You configure nothing in the app.
130
+
131
+ #### `track(event, properties?)`
132
+
133
+ Capture a custom product event. No-op until `connect()` has resolved with PostHog enabled by the host — so it's always safe to call.
134
+
135
+ ```ts
136
+ bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })
137
+ ```
138
+
139
+ Namespace your events (e.g. `vibe:<slug>:<action>`) so they stay filterable from the host's own events. Events are sent directly from the iframe's PostHog instance and tie to the same person via `identify()`. By default they carry the iframe instance's `$session_id`; when the host supplies its `sessionId` in the posthog context, the bridge seeds it so events share the host's `$session_id` and correlate with the stitched replay (best-effort — a one-time seed at connect, so a later host-session rotation can diverge).
140
+
141
+ > **App-side CSP.** Whoever serves your app must allow, on the app's own origin, `connect-src https://*.i.posthog.com https://us-assets.i.posthog.com` and `worker-src blob:` (recording runs in a web worker). No `script-src` entry is needed — `posthog-js` is bundled into your app via the bridge, not loaded from a CDN.
142
+
103
143
  ### Exports
104
144
 
105
- `VibeAppBridge`, `BridgeError`, `BRIDGE_VERSION`, and the types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
145
+ `VibeAppBridge`, `BridgeError`, `HttpBridgeError`, `BRIDGE_VERSION`, and the types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `PostHogContext`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
106
146
 
107
147
  ## Common recipes
108
148
 
@@ -115,7 +155,7 @@ import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
115
155
  function useVibeBridge() {
116
156
  const [ctx, setCtx] = useState<ContextPayload | null>(null)
117
157
  useEffect(() => {
118
- const bridge = new VibeAppBridge({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
158
+ const bridge = new VibeAppBridge()
119
159
  bridge.connect().then(setCtx).catch(console.error)
120
160
  return () => bridge.destroy()
121
161
  }, [])
@@ -172,13 +212,15 @@ const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: ctx.su
172
212
  ```
173
213
 
174
214
  ```ts
175
- // ❌ WRONG — parentOrigin must be an ORIGIN, not a URL with a path, and must match exactly.
215
+ // ❌ WRONG — each entry must be an ORIGIN (scheme + host + port), not a URL with a path.
176
216
  new VibeAppBridge({ parentOrigin: 'https://app.nominal.so/some/path' })
177
217
  // ❌ a trailing slash or wrong port also fails → connect() times out after 10s.
178
218
  new VibeAppBridge({ parentOrigin: 'http://localhost:3000/' })
179
219
 
180
- // ✅ CORRECT — scheme + host + port only, exact match to the embedding app.
220
+ // ✅ CORRECT — scheme + host + port only, matching the embedding app.
181
221
  new VibeAppBridge({ parentOrigin: 'https://app.nominal.so' })
222
+ // ✅ CORRECT — a list with a preview pattern (honoured only on a preview host).
223
+ new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
182
224
  ```
183
225
 
184
226
  ```ts
package/dist/index.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -28,7 +38,7 @@ __export(index_exports, {
28
38
  module.exports = __toCommonJS(index_exports);
29
39
 
30
40
  // package.json
31
- var version = "0.2.0";
41
+ var version = "0.4.0";
32
42
 
33
43
  // ../protocol-types/dist/index.js
34
44
  function normalizeSubroute(subroute) {
@@ -73,6 +83,36 @@ var HttpBridgeError = class extends BridgeError {
73
83
  this.name = "HttpBridgeError";
74
84
  }
75
85
  };
86
+ function isOriginPattern(entry) {
87
+ return entry.includes("*");
88
+ }
89
+ var patternCache = /* @__PURE__ */ new Map();
90
+ function patternToRegExp(pattern) {
91
+ const cached = patternCache.get(pattern);
92
+ if (cached) return cached;
93
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
94
+ const body = escaped.replace(/\\\*/g, "[^./:@?#\\s]+");
95
+ const compiled = new RegExp(`^${body}$`);
96
+ patternCache.set(pattern, compiled);
97
+ return compiled;
98
+ }
99
+ function matchesOrigin(origin, entry) {
100
+ if (!isOriginPattern(entry)) return origin === entry;
101
+ return patternToRegExp(entry).test(origin);
102
+ }
103
+ function isOriginAllowed(origin, allowlist, { allowPatterns }) {
104
+ for (const entry of allowlist) {
105
+ if (isOriginPattern(entry)) {
106
+ if (allowPatterns && matchesOrigin(origin, entry)) return true;
107
+ } else if (origin === entry) {
108
+ return true;
109
+ }
110
+ }
111
+ return false;
112
+ }
113
+
114
+ // src/VibeAppBridge.ts
115
+ var import_posthog_js = __toESM(require("posthog-js"), 1);
76
116
 
77
117
  // src/data-methods.ts
78
118
  var BridgeDataMethods = class {
@@ -391,13 +431,86 @@ function resolveRequestTimeout(type, requestTimeout) {
391
431
  }
392
432
 
393
433
  // src/VibeAppBridge.ts
434
+ function resolvePosthog(mod) {
435
+ let candidate = mod;
436
+ while (candidate && typeof candidate.init !== "function" && candidate.default) {
437
+ candidate = candidate.default;
438
+ }
439
+ return candidate;
440
+ }
441
+ var posthog = resolvePosthog(import_posthog_js.default);
442
+ var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
443
+ var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
444
+ function isPreviewSelfHost(hostname) {
445
+ return PREVIEW_HOST_RE.test(hostname);
446
+ }
447
+ function resolveActualParentOrigin() {
448
+ if (typeof window === "undefined") return null;
449
+ try {
450
+ const ancestors = window.location.ancestorOrigins;
451
+ if (ancestors && ancestors.length > 0 && ancestors[0]) return ancestors[0];
452
+ } catch {
453
+ }
454
+ try {
455
+ if (document.referrer) return new URL(document.referrer).origin;
456
+ } catch {
457
+ }
458
+ return null;
459
+ }
460
+ function resolveOriginPolicy(configured) {
461
+ if (typeof window !== "undefined") {
462
+ const override = window[PARENT_ORIGIN_OVERRIDE_KEY];
463
+ if (typeof override === "string" && override.length > 0) {
464
+ console.log(`[vibe-bridge] Using dev-bridge parentOrigin override: ${override}`);
465
+ return { targetOrigin: override, isTrusted: (o) => o === override };
466
+ }
467
+ }
468
+ const rawList = configured === void 0 ? [] : Array.isArray(configured) ? configured : [configured];
469
+ const allowlist = rawList.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
470
+ if (allowlist.length === 0) {
471
+ const actual2 = resolveActualParentOrigin();
472
+ if (actual2) return { targetOrigin: actual2, isTrusted: (o) => o === actual2 };
473
+ console.warn(
474
+ "[vibe-bridge] No `parentOrigin` set and the embedding host could not be auto-detected (no ancestorOrigins/referrer). Pass `parentOrigin` explicitly."
475
+ );
476
+ return { targetOrigin: null, isTrusted: () => false };
477
+ }
478
+ const exactEntries = allowlist.filter((entry) => !isOriginPattern(entry));
479
+ const hasPatterns = exactEntries.length !== allowlist.length;
480
+ if (allowlist.length === 1 && !hasPatterns) {
481
+ const only = allowlist[0];
482
+ return { targetOrigin: only, isTrusted: (o) => o === only };
483
+ }
484
+ const allowPatterns = hasPatterns && typeof window !== "undefined" && isPreviewSelfHost(window.location.hostname);
485
+ const isTrusted = (o) => isOriginAllowed(o, allowlist, { allowPatterns });
486
+ const actual = resolveActualParentOrigin();
487
+ let targetOrigin = null;
488
+ if (actual && isTrusted(actual)) {
489
+ targetOrigin = actual;
490
+ } else if (actual === null && !allowPatterns && exactEntries.length === 1) {
491
+ targetOrigin = exactEntries[0];
492
+ }
493
+ if (targetOrigin === null) {
494
+ console.warn(
495
+ "[vibe-bridge] Could not resolve a parent origin to post to. Check `parentOrigin` against the embedding host" + (hasPatterns && !allowPatterns ? " \u2014 pattern entries are ignored because this page is not served from a recognised preview host." : ".")
496
+ );
497
+ }
498
+ return { targetOrigin, isTrusted };
499
+ }
394
500
  var VibeAppBridge = class extends BridgeDataMethods {
395
- constructor({ parentOrigin, requestTimeout }) {
501
+ constructor({ parentOrigin, requestTimeout } = {}) {
396
502
  super();
397
503
  this.pendingRequests = /* @__PURE__ */ new Map();
398
504
  this.context = null;
505
+ /**
506
+ * Set once {@link initPostHog} has successfully called `posthog.init()`.
507
+ * Guards against a double-init and makes {@link track} a no-op until
508
+ * recording is actually live.
509
+ */
510
+ this.posthogInitialized = false;
399
511
  this.connectPromise = null;
400
512
  this.connectPollInterval = null;
513
+ this.connectTimeout = null;
401
514
  this.onContextReceived = null;
402
515
  this.onContextError = null;
403
516
  /**
@@ -452,7 +565,9 @@ var VibeAppBridge = class extends BridgeDataMethods {
452
565
  this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
453
566
  }
454
567
  };
455
- this.parentOrigin = parentOrigin;
568
+ const policy = resolveOriginPolicy(parentOrigin);
569
+ this.targetOrigin = policy.targetOrigin;
570
+ this.isTrustedOrigin = policy.isTrusted;
456
571
  this.requestTimeout = requestTimeout;
457
572
  this.boundHandleMessage = this.handleMessage.bind(this);
458
573
  window.addEventListener("message", this.boundHandleMessage);
@@ -488,6 +603,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
488
603
  connect() {
489
604
  if (this.context) return Promise.resolve(this.context);
490
605
  if (this.connectPromise) return this.connectPromise;
606
+ if (this.targetOrigin === null) {
607
+ return Promise.reject(
608
+ new BridgeError(
609
+ "PARENT_ORIGIN_UNRESOLVED",
610
+ "Could not resolve a parent origin to connect to. Check `parentOrigin` against the embedding host."
611
+ )
612
+ );
613
+ }
491
614
  this.connectPromise = new Promise((resolve, reject) => {
492
615
  const timer = setTimeout(() => {
493
616
  this.stopConnectPolling();
@@ -496,6 +619,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
496
619
  this.onContextError = null;
497
620
  reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
498
621
  }, CONNECT_TIMEOUT_MS);
622
+ this.connectTimeout = timer;
499
623
  this.onContextError = (error) => {
500
624
  clearTimeout(timer);
501
625
  this.stopConnectPolling();
@@ -514,6 +638,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
514
638
  console.log(
515
639
  `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
516
640
  );
641
+ this.initPostHog(ctx);
517
642
  const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
518
643
  if (initialSubroute && initialSubroute !== "/") {
519
644
  this.withSubrouteSuppressed(() => {
@@ -538,7 +663,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
538
663
  requestId,
539
664
  payload: { bridgeVersion: version }
540
665
  };
541
- window.parent.postMessage(message, this.parentOrigin);
666
+ this.postToParent(message);
542
667
  };
543
668
  poll();
544
669
  this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
@@ -618,8 +743,37 @@ var VibeAppBridge = class extends BridgeDataMethods {
618
743
  invalidateCache(payload) {
619
744
  return this.request("INVALIDATE_CACHE", payload);
620
745
  }
746
+ /**
747
+ * Captures a custom product event from inside the Vibe App, attributed to the
748
+ * same PostHog person as the host (via the `distinctId` the host supplied at
749
+ * connect).
750
+ *
751
+ * **No-op until {@link connect} has resolved with PostHog enabled by the
752
+ * host** — if the host didn't send a `posthog` block (older host, or recording
753
+ * disabled for this env/app), nothing is sent. App authors don't import
754
+ * `posthog-js` themselves; this is the supported event surface.
755
+ *
756
+ * The event is sent **directly** from the iframe's own PostHog instance (only
757
+ * the session *recording* is forwarded to the parent), so it carries the
758
+ * iframe instance's `$session_id` rather than the host's — events tie to the
759
+ * same person, but cross-boundary event↔replay timeline correlation is a known
760
+ * limitation.
761
+ *
762
+ * @param event Event name. Namespacing app events (e.g. `'vibe:<slug>:<action>'`)
763
+ * keeps them filterable from the host's own events.
764
+ * @param properties Optional event properties.
765
+ * @example
766
+ * ```ts
767
+ * bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })
768
+ * ```
769
+ */
770
+ track(event, properties) {
771
+ if (!this.posthogInitialized) return;
772
+ posthog.capture(event, properties);
773
+ }
621
774
  destroy() {
622
775
  this.teardownNavigationTracking();
776
+ this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
623
777
  this.stopConnectPolling();
624
778
  window.removeEventListener("message", this.boundHandleMessage);
625
779
  for (const pending of this.pendingRequests.values()) {
@@ -627,6 +781,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
627
781
  }
628
782
  this.pendingRequests.clear();
629
783
  this.context = null;
784
+ this.posthogInitialized = false;
630
785
  this.connectPromise = null;
631
786
  this.onContextReceived = null;
632
787
  this.onContextError = null;
@@ -638,8 +793,65 @@ var VibeAppBridge = class extends BridgeDataMethods {
638
793
  clearInterval(this.connectPollInterval);
639
794
  this.connectPollInterval = null;
640
795
  }
796
+ if (this.connectTimeout !== null) {
797
+ clearTimeout(this.connectTimeout);
798
+ this.connectTimeout = null;
799
+ }
641
800
  this.connectPollIds.clear();
642
801
  }
802
+ /**
803
+ * Initializes PostHog session recording + analytics from the host-supplied
804
+ * config, **only when the host opted in** (`ctx.posthog?.enabled`).
805
+ *
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()`.
814
+ */
815
+ initPostHog(ctx) {
816
+ const config = ctx.posthog;
817
+ if (!config?.enabled) return;
818
+ if (this.posthogInitialized) return;
819
+ if (typeof window === "undefined") return;
820
+ if (!config.token || !config.apiHost || !config.distinctId) {
821
+ console.warn(
822
+ "[vibe-bridge] PostHog enabled but token/apiHost/distinctId missing \u2014 skipping init."
823
+ );
824
+ return;
825
+ }
826
+ try {
827
+ posthog.init(config.token, {
828
+ api_host: config.apiHost,
829
+ // absolute ingestion host, NOT a relative /ingest proxy
830
+ person_profiles: "identified_only",
831
+ capture_pageview: false,
832
+ // the host owns pageviews; don't double-count
833
+ autocapture: false,
834
+ // opt-in events only, via track()
835
+ // Seed the host's session id so track() events share the host's
836
+ // $session_id and line up with the stitched replay. Replay stitching
837
+ // itself does not depend on this (recordCrossOriginIframes handles it).
838
+ ...config.sessionId ? { bootstrap: { sessionID: config.sessionId } } : {},
839
+ session_recording: {
840
+ recordCrossOriginIframes: true,
841
+ // REQUIRED so the parent receives our rrweb frames
842
+ // Mirror nom-ui's posture (PostHogProvider.tsx): unmask inputs for
843
+ // richer insight, but keep password fields masked.
844
+ maskAllInputs: false,
845
+ maskInputOptions: { password: true }
846
+ }
847
+ });
848
+ posthog.identify(config.distinctId);
849
+ this.posthogInitialized = true;
850
+ console.log("[vibe-bridge] PostHog session recording initialized");
851
+ } catch (err) {
852
+ console.warn("[vibe-bridge] PostHog init failed \u2014 continuing without recording.", err);
853
+ }
854
+ }
643
855
  /**
644
856
  * Monkey-patches `history.pushState` and `history.replaceState` to
645
857
  * auto-detect SPA navigation and report it to the host. Called after
@@ -694,10 +906,13 @@ var VibeAppBridge = class extends BridgeDataMethods {
694
906
  this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
695
907
  }
696
908
  sendCommand(type, payload) {
697
- window.parent.postMessage(
698
- { __protocol: PROTOCOL_ID, __version: PROTOCOL_VERSION, kind: "command", type, payload },
699
- this.parentOrigin
700
- );
909
+ this.postToParent({
910
+ __protocol: PROTOCOL_ID,
911
+ __version: PROTOCOL_VERSION,
912
+ kind: "command",
913
+ type,
914
+ payload
915
+ });
701
916
  }
702
917
  /**
703
918
  * Low-level typed request for **any** operation in {@link RequestRegistry}.
@@ -740,11 +955,20 @@ var VibeAppBridge = class extends BridgeDataMethods {
740
955
  requestId,
741
956
  payload
742
957
  };
743
- window.parent.postMessage(message, this.parentOrigin);
958
+ this.postToParent(message);
744
959
  });
745
960
  }
961
+ /**
962
+ * Posts to the embedding host. No-op when no concrete target origin could be
963
+ * resolved (a config/embedding error) — `connect()` rejects fast in that case
964
+ * so calls never silently hang waiting on a timeout.
965
+ */
966
+ postToParent(message) {
967
+ if (this.targetOrigin === null) return;
968
+ window.parent.postMessage(message, this.targetOrigin);
969
+ }
746
970
  handleMessage(event) {
747
- if (event.origin !== this.parentOrigin) return;
971
+ if (!this.isTrustedOrigin(event.origin)) return;
748
972
  if (!isBridgeMessage(event.data)) return;
749
973
  this.kindHandlers[event.data.kind]?.(event.data);
750
974
  }