@nominalso/vibe-host 0.2.0 → 0.3.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
@@ -18,7 +18,8 @@ Externalizes only `@hey-api/client-fetch`; TypeScript types are self-contained.
18
18
  import { VibeAppHost } from '@nominalso/vibe-host'
19
19
 
20
20
  const host = new VibeAppHost({
21
- // Iframe origin(s) allowed to talk to this host must match exactly.
21
+ // Iframe origin(s) allowed to talk to this host. Exact origins, or glob
22
+ // patterns (e.g. 'https://*.vercel.app') — add patterns only in non-prod.
22
23
  trustedOrigins: ['https://my-vibe-app.lovable.app'],
23
24
  // This Vibe App's base path in nom-ui (used to sync the browser URL).
24
25
  appBasePath: `/${tenant}/${subsidiary}/apps/${slug}`,
@@ -37,15 +38,28 @@ const unmount = host.mount()
37
38
 
38
39
  ### `new VibeAppHost(options)`
39
40
 
40
- | Option | Type | Description |
41
- | ---------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
42
- | `trustedOrigins` | `string[]` | Iframe origins allowed to talk to this host. Messages from other origins are ignored. |
43
- | `getContext` | `() => HostContext` | Returns the current context to send to the iframe (`hostVersion` is injected automatically). |
44
- | `appBasePath` | `string` | Base URL path of this Vibe App in nom-ui, e.g. `/acme/1/apps/fixed-assets`. |
45
- | `clientConfig` | `VibeApiClientOptions?` | Points the built-in handler map at the Nominal API. Defaults to `{ baseUrl: '', stripApiPrefix: false }`. |
41
+ | Option | Type | Description |
42
+ | ---------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
+ | `trustedOrigins` | `string[]` | Iframe origins allowed to talk to this host — exact origins or glob patterns (`https://*.vercel.app`, honoured unconditionally, so add only in non-prod). Messages from other origins are ignored. |
44
+ | `getContext` | `() => HostContext` | Returns the current context to send to the iframe (`hostVersion` is injected automatically). |
45
+ | `appBasePath` | `string` | Base URL path of this Vibe App in nom-ui, e.g. `/acme/1/apps/fixed-assets`. |
46
+ | `clientConfig` | `VibeApiClientOptions?` | Points the built-in handler map at the Nominal API. Defaults to `{ baseUrl: '', stripApiPrefix: false }`. |
46
47
 
47
48
  `VibeApiClientOptions` is `{ baseUrl: string; stripApiPrefix?: boolean }`. Set `stripApiPrefix: true` when routing through a proxy whose convention expects paths without the `/api` prefix.
48
49
 
50
+ #### Testing preview deployments with `trustedOrigins` patterns
51
+
52
+ A pattern entry's `*` matches exactly one DNS label or port — anchored, scheme literal — so `https://*.vercel.app` matches `https://pr-7.vercel.app` but not `https://a.b.vercel.app` or `https://pr-7.vercel.app.evil.com`. Patterns are honoured unconditionally, so **gate the list on your own deploy environment** — keep production exact-only:
53
+
54
+ ```ts
55
+ const trustedOrigins =
56
+ process.env.NOM_ENV === 'production'
57
+ ? ['https://app.nominal.so']
58
+ : ['https://app.nominal.so', 'https://*.vercel.app', 'https://*.lovable.app']
59
+ ```
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.
62
+
49
63
  ### `mount(iframeWindow?): () => void`
50
64
 
51
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`.
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.2.0";
30
+ var version = "0.3.0";
31
31
 
32
32
  // ../protocol-types/dist/index.js
33
33
  function normalizeSubroute(subroute) {
@@ -72,6 +72,33 @@ var HttpBridgeError = class extends BridgeError {
72
72
  this.name = "HttpBridgeError";
73
73
  }
74
74
  };
75
+ function isOriginPattern(entry) {
76
+ return entry.includes("*");
77
+ }
78
+ var patternCache = /* @__PURE__ */ new Map();
79
+ function patternToRegExp(pattern) {
80
+ const cached = patternCache.get(pattern);
81
+ if (cached) return cached;
82
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
83
+ const body = escaped.replace(/\\\*/g, "[^./:@?#\\s]+");
84
+ const compiled = new RegExp(`^${body}$`);
85
+ patternCache.set(pattern, compiled);
86
+ return compiled;
87
+ }
88
+ function matchesOrigin(origin, entry) {
89
+ if (!isOriginPattern(entry)) return origin === entry;
90
+ return patternToRegExp(entry).test(origin);
91
+ }
92
+ function isOriginAllowed(origin, allowlist, { allowPatterns }) {
93
+ for (const entry of allowlist) {
94
+ if (isOriginPattern(entry)) {
95
+ if (allowPatterns && matchesOrigin(origin, entry)) return true;
96
+ } else if (origin === entry) {
97
+ return true;
98
+ }
99
+ }
100
+ return false;
101
+ }
75
102
 
76
103
  // ../api-client/dist/index.js
77
104
  var import_client_fetch = require("@hey-api/client-fetch");
@@ -629,6 +656,12 @@ function rejectUnwired(op) {
629
656
  // src/VibeAppHost.ts
630
657
  var VibeAppHost = class {
631
658
  constructor(opts) {
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.
663
+ */
664
+ this.connectedOrigins = /* @__PURE__ */ new Set();
632
665
  this.iframeWindow = null;
633
666
  /**
634
667
  * Source windows we've already logged a successful handshake for. The bridge
@@ -642,6 +675,7 @@ var VibeAppHost = class {
642
675
  };
643
676
  const { trustedOrigins, getContext, appBasePath, clientConfig } = opts;
644
677
  this.trustedOrigins = new Set(trustedOrigins);
678
+ this.exactOrigins = trustedOrigins.filter((entry) => !isOriginPattern(entry));
645
679
  this.getContext = getContext;
646
680
  this.appBasePath = appBasePath;
647
681
  this.onRequestComplete = opts.onRequestComplete;
@@ -715,6 +749,7 @@ var VibeAppHost = class {
715
749
  window.removeEventListener("message", this.boundHandleMessage);
716
750
  window.removeEventListener("popstate", this.boundHandlePopState);
717
751
  this.iframeWindow = null;
752
+ this.connectedOrigins.clear();
718
753
  };
719
754
  }
720
755
  /**
@@ -734,7 +769,7 @@ var VibeAppHost = class {
734
769
  type: "CONTEXT_PUSH",
735
770
  payload: { ...this.getContext(), hostVersion: version }
736
771
  };
737
- for (const origin of this.trustedOrigins) {
772
+ for (const origin of this.concreteTargets()) {
738
773
  target.postMessage(message, origin);
739
774
  }
740
775
  }
@@ -754,15 +789,25 @@ var VibeAppHost = class {
754
789
  type,
755
790
  payload
756
791
  };
757
- for (const origin of this.trustedOrigins) {
792
+ for (const origin of this.concreteTargets()) {
758
793
  this.iframeWindow.postMessage(message, origin);
759
794
  }
760
795
  }
761
796
  async handleMessage(event) {
762
- if (!this.trustedOrigins.has(event.origin)) return;
797
+ if (!isOriginAllowed(event.origin, this.trustedOrigins, { allowPatterns: true })) return;
763
798
  if (!isBridgeMessage(event.data)) return;
799
+ this.connectedOrigins.add(event.origin);
764
800
  await this.kindHandlers[event.data.kind]?.(event.data, event.source, event.origin);
765
801
  }
802
+ /**
803
+ * 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.
807
+ */
808
+ concreteTargets() {
809
+ return /* @__PURE__ */ new Set([...this.exactOrigins, ...this.connectedOrigins]);
810
+ }
766
811
  async handleRequest(msg, source, origin) {
767
812
  const { requestId, type } = msg;
768
813
  if (type === "GET_CONTEXT") {
package/dist/index.d.cts CHANGED
@@ -3549,8 +3549,21 @@ interface RequestCompleteInfo {
3549
3549
  interface VibeAppHostOptions {
3550
3550
  /**
3551
3551
  * Iframe origins allowed to talk to this host. Messages from any other origin
3552
- * are ignored, and context/pushes are only sent to these origins. Must match
3553
- * the Vibe App's origin exactly (e.g. `'https://my-vibe-app.lovable.app'`).
3552
+ * are ignored, and context/pushes are only sent to allowed origins.
3553
+ *
3554
+ * Each entry is either an exact origin (`'https://my-vibe-app.lovable.app'`)
3555
+ * or a **glob pattern** containing `*` (`'https://*.vercel.app'`), where `*`
3556
+ * matches exactly one DNS label or port — anchored, scheme literal — so
3557
+ * `https://*.vercel.app` matches `https://pr-7.vercel.app` but not
3558
+ * `https://a.b.vercel.app` or `https://pr-7.vercel.app.evil.com`.
3559
+ *
3560
+ * Patterns are honoured unconditionally, so **only add them for non-production
3561
+ * environments** — gate this list on your own deploy env (e.g. pass exact
3562
+ * origins only in production, exact + `*.vercel.app`/`*.lovable.app` in
3563
+ * staging/dev) to test against dynamic preview deployments. Because you cannot
3564
+ * `postMessage` to a pattern, proactive context/subroute pushes target the
3565
+ * exact entries plus any origin seen on a validated inbound message; pattern-
3566
+ * matched iframes still receive context via their `connect()` poll response.
3554
3567
  */
3555
3568
  trustedOrigins: string[];
3556
3569
  /** Returns the current context to send to the iframe on connect. */
@@ -3621,7 +3634,21 @@ interface VibeAppHostOptions {
3621
3634
  * ```
3622
3635
  */
3623
3636
  declare class VibeAppHost {
3637
+ /**
3638
+ * Configured allowlist. Entries may be exact origins or glob patterns
3639
+ * (`https://*.vercel.app`); inbound messages are validated against it with
3640
+ * {@link isOriginAllowed}. Pattern entries are honoured — nom-ui is expected
3641
+ * to supply patterns only in non-production environments.
3642
+ */
3624
3643
  private readonly trustedOrigins;
3644
+ /** The exact (non-pattern) subset of {@link trustedOrigins} — see {@link concreteTargets}. */
3645
+ private readonly exactOrigins;
3646
+ /**
3647
+ * Concrete iframe origins observed on validated inbound messages. Because you
3648
+ * cannot `postMessage` to a glob pattern, proactive pushes target these
3649
+ * learned origins (plus the exact allowlist entries) rather than the patterns.
3650
+ */
3651
+ private readonly connectedOrigins;
3625
3652
  private readonly handlers;
3626
3653
  private readonly commandHandlers;
3627
3654
  private readonly getContext;
@@ -3672,6 +3699,13 @@ declare class VibeAppHost {
3672
3699
  private handlePopState;
3673
3700
  private pushToIframe;
3674
3701
  private handleMessage;
3702
+ /**
3703
+ * Concrete origins a proactive push may target: the exact allowlist entries
3704
+ * plus any origins seen on validated inbound messages. Never includes glob
3705
+ * patterns (you cannot `postMessage` to one). When only exact origins are
3706
+ * configured this equals the configured set, so behaviour is unchanged.
3707
+ */
3708
+ private concreteTargets;
3675
3709
  private handleRequest;
3676
3710
  private respond;
3677
3711
  }
package/dist/index.d.ts CHANGED
@@ -3549,8 +3549,21 @@ interface RequestCompleteInfo {
3549
3549
  interface VibeAppHostOptions {
3550
3550
  /**
3551
3551
  * Iframe origins allowed to talk to this host. Messages from any other origin
3552
- * are ignored, and context/pushes are only sent to these origins. Must match
3553
- * the Vibe App's origin exactly (e.g. `'https://my-vibe-app.lovable.app'`).
3552
+ * are ignored, and context/pushes are only sent to allowed origins.
3553
+ *
3554
+ * Each entry is either an exact origin (`'https://my-vibe-app.lovable.app'`)
3555
+ * or a **glob pattern** containing `*` (`'https://*.vercel.app'`), where `*`
3556
+ * matches exactly one DNS label or port — anchored, scheme literal — so
3557
+ * `https://*.vercel.app` matches `https://pr-7.vercel.app` but not
3558
+ * `https://a.b.vercel.app` or `https://pr-7.vercel.app.evil.com`.
3559
+ *
3560
+ * Patterns are honoured unconditionally, so **only add them for non-production
3561
+ * environments** — gate this list on your own deploy env (e.g. pass exact
3562
+ * origins only in production, exact + `*.vercel.app`/`*.lovable.app` in
3563
+ * staging/dev) to test against dynamic preview deployments. Because you cannot
3564
+ * `postMessage` to a pattern, proactive context/subroute pushes target the
3565
+ * exact entries plus any origin seen on a validated inbound message; pattern-
3566
+ * matched iframes still receive context via their `connect()` poll response.
3554
3567
  */
3555
3568
  trustedOrigins: string[];
3556
3569
  /** Returns the current context to send to the iframe on connect. */
@@ -3621,7 +3634,21 @@ interface VibeAppHostOptions {
3621
3634
  * ```
3622
3635
  */
3623
3636
  declare class VibeAppHost {
3637
+ /**
3638
+ * Configured allowlist. Entries may be exact origins or glob patterns
3639
+ * (`https://*.vercel.app`); inbound messages are validated against it with
3640
+ * {@link isOriginAllowed}. Pattern entries are honoured — nom-ui is expected
3641
+ * to supply patterns only in non-production environments.
3642
+ */
3624
3643
  private readonly trustedOrigins;
3644
+ /** The exact (non-pattern) subset of {@link trustedOrigins} — see {@link concreteTargets}. */
3645
+ private readonly exactOrigins;
3646
+ /**
3647
+ * Concrete iframe origins observed on validated inbound messages. Because you
3648
+ * cannot `postMessage` to a glob pattern, proactive pushes target these
3649
+ * learned origins (plus the exact allowlist entries) rather than the patterns.
3650
+ */
3651
+ private readonly connectedOrigins;
3625
3652
  private readonly handlers;
3626
3653
  private readonly commandHandlers;
3627
3654
  private readonly getContext;
@@ -3672,6 +3699,13 @@ declare class VibeAppHost {
3672
3699
  private handlePopState;
3673
3700
  private pushToIframe;
3674
3701
  private handleMessage;
3702
+ /**
3703
+ * Concrete origins a proactive push may target: the exact allowlist entries
3704
+ * plus any origins seen on validated inbound messages. Never includes glob
3705
+ * patterns (you cannot `postMessage` to one). When only exact origins are
3706
+ * configured this equals the configured set, so behaviour is unchanged.
3707
+ */
3708
+ private concreteTargets;
3675
3709
  private handleRequest;
3676
3710
  private respond;
3677
3711
  }
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // package.json
2
- var version = "0.2.0";
2
+ var version = "0.3.0";
3
3
 
4
4
  // ../protocol-types/dist/index.js
5
5
  function normalizeSubroute(subroute) {
@@ -44,6 +44,33 @@ var HttpBridgeError = class extends BridgeError {
44
44
  this.name = "HttpBridgeError";
45
45
  }
46
46
  };
47
+ function isOriginPattern(entry) {
48
+ return entry.includes("*");
49
+ }
50
+ var patternCache = /* @__PURE__ */ new Map();
51
+ function patternToRegExp(pattern) {
52
+ const cached = patternCache.get(pattern);
53
+ if (cached) return cached;
54
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
+ const body = escaped.replace(/\\\*/g, "[^./:@?#\\s]+");
56
+ const compiled = new RegExp(`^${body}$`);
57
+ patternCache.set(pattern, compiled);
58
+ return compiled;
59
+ }
60
+ function matchesOrigin(origin, entry) {
61
+ if (!isOriginPattern(entry)) return origin === entry;
62
+ return patternToRegExp(entry).test(origin);
63
+ }
64
+ function isOriginAllowed(origin, allowlist, { allowPatterns }) {
65
+ for (const entry of allowlist) {
66
+ if (isOriginPattern(entry)) {
67
+ if (allowPatterns && matchesOrigin(origin, entry)) return true;
68
+ } else if (origin === entry) {
69
+ return true;
70
+ }
71
+ }
72
+ return false;
73
+ }
47
74
 
48
75
  // ../api-client/dist/index.js
49
76
  import {
@@ -604,6 +631,12 @@ function rejectUnwired(op) {
604
631
  // src/VibeAppHost.ts
605
632
  var VibeAppHost = class {
606
633
  constructor(opts) {
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.
638
+ */
639
+ this.connectedOrigins = /* @__PURE__ */ new Set();
607
640
  this.iframeWindow = null;
608
641
  /**
609
642
  * Source windows we've already logged a successful handshake for. The bridge
@@ -617,6 +650,7 @@ var VibeAppHost = class {
617
650
  };
618
651
  const { trustedOrigins, getContext, appBasePath, clientConfig } = opts;
619
652
  this.trustedOrigins = new Set(trustedOrigins);
653
+ this.exactOrigins = trustedOrigins.filter((entry) => !isOriginPattern(entry));
620
654
  this.getContext = getContext;
621
655
  this.appBasePath = appBasePath;
622
656
  this.onRequestComplete = opts.onRequestComplete;
@@ -690,6 +724,7 @@ var VibeAppHost = class {
690
724
  window.removeEventListener("message", this.boundHandleMessage);
691
725
  window.removeEventListener("popstate", this.boundHandlePopState);
692
726
  this.iframeWindow = null;
727
+ this.connectedOrigins.clear();
693
728
  };
694
729
  }
695
730
  /**
@@ -709,7 +744,7 @@ var VibeAppHost = class {
709
744
  type: "CONTEXT_PUSH",
710
745
  payload: { ...this.getContext(), hostVersion: version }
711
746
  };
712
- for (const origin of this.trustedOrigins) {
747
+ for (const origin of this.concreteTargets()) {
713
748
  target.postMessage(message, origin);
714
749
  }
715
750
  }
@@ -729,15 +764,25 @@ var VibeAppHost = class {
729
764
  type,
730
765
  payload
731
766
  };
732
- for (const origin of this.trustedOrigins) {
767
+ for (const origin of this.concreteTargets()) {
733
768
  this.iframeWindow.postMessage(message, origin);
734
769
  }
735
770
  }
736
771
  async handleMessage(event) {
737
- if (!this.trustedOrigins.has(event.origin)) return;
772
+ if (!isOriginAllowed(event.origin, this.trustedOrigins, { allowPatterns: true })) return;
738
773
  if (!isBridgeMessage(event.data)) return;
774
+ this.connectedOrigins.add(event.origin);
739
775
  await this.kindHandlers[event.data.kind]?.(event.data, event.source, event.origin);
740
776
  }
777
+ /**
778
+ * 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.
782
+ */
783
+ concreteTargets() {
784
+ return /* @__PURE__ */ new Set([...this.exactOrigins, ...this.connectedOrigins]);
785
+ }
741
786
  async handleRequest(msg, source, origin) {
742
787
  const { requestId, type } = msg;
743
788
  if (type === "GET_CONTEXT") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nominalso/vibe-host",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",