@nominalso/vibe-bridge 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/AGENTS.md CHANGED
@@ -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,8 +30,8 @@ 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 }>`.
@@ -52,7 +50,7 @@ The full method/operation list is in `README.md` ("Operation catalog") and in `d
52
50
  ## Gotchas (wrong → right)
53
51
 
54
52
  - **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.
53
+ - **`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
54
  - **`upload()` takes a `File`**, not a path or `FormData`.
57
55
  - **Writes go through `postTaskOutput` only.** Vibe Apps never write Nominal data directly.
58
56
  - **Call `bridge.destroy()` on unmount** to remove listeners and reject pending requests.
@@ -60,5 +58,5 @@ The full method/operation list is in `README.md` ("Operation catalog") and in `d
60
58
  ## Don't
61
59
 
62
60
  - 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.
61
+ - 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
62
  - 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
 
@@ -17,11 +17,10 @@ Zero runtime dependencies. Ships ESM + CJS and self-contained TypeScript types.
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
 
@@ -115,7 +133,7 @@ import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
115
133
  function useVibeBridge() {
116
134
  const [ctx, setCtx] = useState<ContextPayload | null>(null)
117
135
  useEffect(() => {
118
- const bridge = new VibeAppBridge({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
136
+ const bridge = new VibeAppBridge()
119
137
  bridge.connect().then(setCtx).catch(console.error)
120
138
  return () => bridge.destroy()
121
139
  }, [])
@@ -172,13 +190,15 @@ const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: ctx.su
172
190
  ```
173
191
 
174
192
  ```ts
175
- // ❌ WRONG — parentOrigin must be an ORIGIN, not a URL with a path, and must match exactly.
193
+ // ❌ WRONG — each entry must be an ORIGIN (scheme + host + port), not a URL with a path.
176
194
  new VibeAppBridge({ parentOrigin: 'https://app.nominal.so/some/path' })
177
195
  // ❌ a trailing slash or wrong port also fails → connect() times out after 10s.
178
196
  new VibeAppBridge({ parentOrigin: 'http://localhost:3000/' })
179
197
 
180
- // ✅ CORRECT — scheme + host + port only, exact match to the embedding app.
198
+ // ✅ CORRECT — scheme + host + port only, matching the embedding app.
181
199
  new VibeAppBridge({ parentOrigin: 'https://app.nominal.so' })
200
+ // ✅ CORRECT — a list with a preview pattern (honoured only on a preview host).
201
+ new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
182
202
  ```
183
203
 
184
204
  ```ts
package/dist/index.cjs CHANGED
@@ -28,7 +28,7 @@ __export(index_exports, {
28
28
  module.exports = __toCommonJS(index_exports);
29
29
 
30
30
  // package.json
31
- var version = "0.2.0";
31
+ var version = "0.3.0";
32
32
 
33
33
  // ../protocol-types/dist/index.js
34
34
  function normalizeSubroute(subroute) {
@@ -73,6 +73,33 @@ var HttpBridgeError = class extends BridgeError {
73
73
  this.name = "HttpBridgeError";
74
74
  }
75
75
  };
76
+ function isOriginPattern(entry) {
77
+ return entry.includes("*");
78
+ }
79
+ var patternCache = /* @__PURE__ */ new Map();
80
+ function patternToRegExp(pattern) {
81
+ const cached = patternCache.get(pattern);
82
+ if (cached) return cached;
83
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
84
+ const body = escaped.replace(/\\\*/g, "[^./:@?#\\s]+");
85
+ const compiled = new RegExp(`^${body}$`);
86
+ patternCache.set(pattern, compiled);
87
+ return compiled;
88
+ }
89
+ function matchesOrigin(origin, entry) {
90
+ if (!isOriginPattern(entry)) return origin === entry;
91
+ return patternToRegExp(entry).test(origin);
92
+ }
93
+ function isOriginAllowed(origin, allowlist, { allowPatterns }) {
94
+ for (const entry of allowlist) {
95
+ if (isOriginPattern(entry)) {
96
+ if (allowPatterns && matchesOrigin(origin, entry)) return true;
97
+ } else if (origin === entry) {
98
+ return true;
99
+ }
100
+ }
101
+ return false;
102
+ }
76
103
 
77
104
  // src/data-methods.ts
78
105
  var BridgeDataMethods = class {
@@ -391,13 +418,72 @@ function resolveRequestTimeout(type, requestTimeout) {
391
418
  }
392
419
 
393
420
  // src/VibeAppBridge.ts
421
+ var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
422
+ var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
423
+ function isPreviewSelfHost(hostname) {
424
+ return PREVIEW_HOST_RE.test(hostname);
425
+ }
426
+ function resolveActualParentOrigin() {
427
+ if (typeof window === "undefined") return null;
428
+ try {
429
+ const ancestors = window.location.ancestorOrigins;
430
+ if (ancestors && ancestors.length > 0 && ancestors[0]) return ancestors[0];
431
+ } catch {
432
+ }
433
+ try {
434
+ if (document.referrer) return new URL(document.referrer).origin;
435
+ } catch {
436
+ }
437
+ return null;
438
+ }
439
+ function resolveOriginPolicy(configured) {
440
+ if (typeof window !== "undefined") {
441
+ const override = window[PARENT_ORIGIN_OVERRIDE_KEY];
442
+ if (typeof override === "string" && override.length > 0) {
443
+ console.log(`[vibe-bridge] Using dev-bridge parentOrigin override: ${override}`);
444
+ return { targetOrigin: override, isTrusted: (o) => o === override };
445
+ }
446
+ }
447
+ const rawList = configured === void 0 ? [] : Array.isArray(configured) ? configured : [configured];
448
+ const allowlist = rawList.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
449
+ if (allowlist.length === 0) {
450
+ const actual2 = resolveActualParentOrigin();
451
+ if (actual2) return { targetOrigin: actual2, isTrusted: (o) => o === actual2 };
452
+ console.warn(
453
+ "[vibe-bridge] No `parentOrigin` set and the embedding host could not be auto-detected (no ancestorOrigins/referrer). Pass `parentOrigin` explicitly."
454
+ );
455
+ return { targetOrigin: null, isTrusted: () => false };
456
+ }
457
+ const exactEntries = allowlist.filter((entry) => !isOriginPattern(entry));
458
+ const hasPatterns = exactEntries.length !== allowlist.length;
459
+ if (allowlist.length === 1 && !hasPatterns) {
460
+ const only = allowlist[0];
461
+ return { targetOrigin: only, isTrusted: (o) => o === only };
462
+ }
463
+ const allowPatterns = hasPatterns && typeof window !== "undefined" && isPreviewSelfHost(window.location.hostname);
464
+ const isTrusted = (o) => isOriginAllowed(o, allowlist, { allowPatterns });
465
+ const actual = resolveActualParentOrigin();
466
+ let targetOrigin = null;
467
+ if (actual && isTrusted(actual)) {
468
+ targetOrigin = actual;
469
+ } else if (actual === null && !allowPatterns && exactEntries.length === 1) {
470
+ targetOrigin = exactEntries[0];
471
+ }
472
+ if (targetOrigin === null) {
473
+ console.warn(
474
+ "[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." : ".")
475
+ );
476
+ }
477
+ return { targetOrigin, isTrusted };
478
+ }
394
479
  var VibeAppBridge = class extends BridgeDataMethods {
395
- constructor({ parentOrigin, requestTimeout }) {
480
+ constructor({ parentOrigin, requestTimeout } = {}) {
396
481
  super();
397
482
  this.pendingRequests = /* @__PURE__ */ new Map();
398
483
  this.context = null;
399
484
  this.connectPromise = null;
400
485
  this.connectPollInterval = null;
486
+ this.connectTimeout = null;
401
487
  this.onContextReceived = null;
402
488
  this.onContextError = null;
403
489
  /**
@@ -452,7 +538,9 @@ var VibeAppBridge = class extends BridgeDataMethods {
452
538
  this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
453
539
  }
454
540
  };
455
- this.parentOrigin = parentOrigin;
541
+ const policy = resolveOriginPolicy(parentOrigin);
542
+ this.targetOrigin = policy.targetOrigin;
543
+ this.isTrustedOrigin = policy.isTrusted;
456
544
  this.requestTimeout = requestTimeout;
457
545
  this.boundHandleMessage = this.handleMessage.bind(this);
458
546
  window.addEventListener("message", this.boundHandleMessage);
@@ -488,6 +576,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
488
576
  connect() {
489
577
  if (this.context) return Promise.resolve(this.context);
490
578
  if (this.connectPromise) return this.connectPromise;
579
+ if (this.targetOrigin === null) {
580
+ return Promise.reject(
581
+ new BridgeError(
582
+ "PARENT_ORIGIN_UNRESOLVED",
583
+ "Could not resolve a parent origin to connect to. Check `parentOrigin` against the embedding host."
584
+ )
585
+ );
586
+ }
491
587
  this.connectPromise = new Promise((resolve, reject) => {
492
588
  const timer = setTimeout(() => {
493
589
  this.stopConnectPolling();
@@ -496,6 +592,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
496
592
  this.onContextError = null;
497
593
  reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
498
594
  }, CONNECT_TIMEOUT_MS);
595
+ this.connectTimeout = timer;
499
596
  this.onContextError = (error) => {
500
597
  clearTimeout(timer);
501
598
  this.stopConnectPolling();
@@ -538,7 +635,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
538
635
  requestId,
539
636
  payload: { bridgeVersion: version }
540
637
  };
541
- window.parent.postMessage(message, this.parentOrigin);
638
+ this.postToParent(message);
542
639
  };
543
640
  poll();
544
641
  this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
@@ -620,6 +717,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
620
717
  }
621
718
  destroy() {
622
719
  this.teardownNavigationTracking();
720
+ this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
623
721
  this.stopConnectPolling();
624
722
  window.removeEventListener("message", this.boundHandleMessage);
625
723
  for (const pending of this.pendingRequests.values()) {
@@ -638,6 +736,10 @@ var VibeAppBridge = class extends BridgeDataMethods {
638
736
  clearInterval(this.connectPollInterval);
639
737
  this.connectPollInterval = null;
640
738
  }
739
+ if (this.connectTimeout !== null) {
740
+ clearTimeout(this.connectTimeout);
741
+ this.connectTimeout = null;
742
+ }
641
743
  this.connectPollIds.clear();
642
744
  }
643
745
  /**
@@ -694,10 +796,13 @@ var VibeAppBridge = class extends BridgeDataMethods {
694
796
  this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
695
797
  }
696
798
  sendCommand(type, payload) {
697
- window.parent.postMessage(
698
- { __protocol: PROTOCOL_ID, __version: PROTOCOL_VERSION, kind: "command", type, payload },
699
- this.parentOrigin
700
- );
799
+ this.postToParent({
800
+ __protocol: PROTOCOL_ID,
801
+ __version: PROTOCOL_VERSION,
802
+ kind: "command",
803
+ type,
804
+ payload
805
+ });
701
806
  }
702
807
  /**
703
808
  * Low-level typed request for **any** operation in {@link RequestRegistry}.
@@ -740,11 +845,20 @@ var VibeAppBridge = class extends BridgeDataMethods {
740
845
  requestId,
741
846
  payload
742
847
  };
743
- window.parent.postMessage(message, this.parentOrigin);
848
+ this.postToParent(message);
744
849
  });
745
850
  }
851
+ /**
852
+ * Posts to the embedding host. No-op when no concrete target origin could be
853
+ * resolved (a config/embedding error) — `connect()` rejects fast in that case
854
+ * so calls never silently hang waiting on a timeout.
855
+ */
856
+ postToParent(message) {
857
+ if (this.targetOrigin === null) return;
858
+ window.parent.postMessage(message, this.targetOrigin);
859
+ }
746
860
  handleMessage(event) {
747
- if (event.origin !== this.parentOrigin) return;
861
+ if (!this.isTrustedOrigin(event.origin)) return;
748
862
  if (!isBridgeMessage(event.data)) return;
749
863
  this.kindHandlers[event.data.kind]?.(event.data);
750
864
  }
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- var version = "0.2.0";
1
+ var version = "0.3.0";
2
2
 
3
3
  type AccountingAccountDimensionValues = {
4
4
  account_id: string;
@@ -3659,13 +3659,34 @@ declare abstract class BridgeDataMethods {
3659
3659
 
3660
3660
  interface VibeAppBridgeOptions {
3661
3661
  /**
3662
- * Origin of the Nominal app embedding this iframe (e.g.
3663
- * `https://app.nominal.so` or `http://localhost:3000`). Must match the host
3664
- * origin **exactly** messages from any other origin are ignored, and the
3665
- * bridge only `postMessage`s to this origin. Drive it from an env var so the
3666
- * same code works locally and in production.
3662
+ * Origin(s) of the Nominal app embedding this iframe. **Optional.**
3663
+ *
3664
+ * **Omit it** (the recommended default) and the bridge auto-detects the
3665
+ * Nominal page that actually embeds it and talks only to that origin. This is
3666
+ * safe because Nominal serves Vibe Apps behind a `frame-ancestors` CSP, so
3667
+ * only nom-ui can frame the app — a hostile site can't embed it to intercept
3668
+ * what the iframe sends out (`postTaskOutput` bodies, file uploads). Most apps
3669
+ * need nothing here.
3670
+ *
3671
+ * Provide a value to **pin explicitly** — defense in depth, or when deploying
3672
+ * outside Nominal's edge:
3673
+ *
3674
+ * ```ts
3675
+ * parentOrigin: 'https://app.nominal.so' // one exact origin
3676
+ * parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] // exact + preview pattern
3677
+ * ```
3678
+ *
3679
+ * A pattern's `*` matches exactly one DNS label or port — anchored, scheme
3680
+ * literal — so `https://*.vercel.app` matches `https://pr-7.vercel.app` but
3681
+ * not `https://a.b.vercel.app` or `https://pr-7.vercel.app.evil.com`. Patterns
3682
+ * are honoured only when this app's own page is served from a recognised
3683
+ * preview host (`*.vercel.app`, `*.lovable.app`, `localhost`, …); on a
3684
+ * production custom domain only exact origins match.
3685
+ *
3686
+ * If neither an explicit value nor an auto-detected parent is available,
3687
+ * `connect()` rejects with `BridgeError` code `PARENT_ORIGIN_UNRESOLVED`.
3667
3688
  */
3668
- parentOrigin: string;
3689
+ parentOrigin?: string | string[];
3669
3690
  /**
3670
3691
  * Global per-request timeout in milliseconds before a call rejects with a
3671
3692
  * `BridgeError` whose `code` is `'TIMEOUT'`.
@@ -3702,13 +3723,17 @@ interface VibeAppBridgeOptions {
3702
3723
  * ```
3703
3724
  */
3704
3725
  declare class VibeAppBridge extends BridgeDataMethods {
3705
- private readonly parentOrigin;
3726
+ /** Concrete origin to `postMessage` to, or `null` if none could be resolved. */
3727
+ private readonly targetOrigin;
3728
+ /** Validates an inbound `event.origin` against the configured allowlist. */
3729
+ private readonly isTrustedOrigin;
3706
3730
  private readonly requestTimeout;
3707
3731
  private readonly pendingRequests;
3708
3732
  private readonly boundHandleMessage;
3709
3733
  private context;
3710
3734
  private connectPromise;
3711
3735
  private connectPollInterval;
3736
+ private connectTimeout;
3712
3737
  private onContextReceived;
3713
3738
  private onContextError;
3714
3739
  /**
@@ -3726,7 +3751,7 @@ declare class VibeAppBridge extends BridgeDataMethods {
3726
3751
  private readonly kindHandlers;
3727
3752
  private dispatchPush;
3728
3753
  private readonly pushHandlers;
3729
- constructor({ parentOrigin, requestTimeout }: VibeAppBridgeOptions);
3754
+ constructor({ parentOrigin, requestTimeout }?: VibeAppBridgeOptions);
3730
3755
  /**
3731
3756
  * Connects to the host and returns the tenant/user {@link ContextPayload}.
3732
3757
  * **Call this once on app init and await it before any other method** — data
@@ -3842,6 +3867,12 @@ declare class VibeAppBridge extends BridgeDataMethods {
3842
3867
  * ```
3843
3868
  */
3844
3869
  request<K extends keyof RequestRegistry>(type: K, payload: RequestRegistry[K]['payload'], onProgress?: (payload: unknown) => void): Promise<RequestRegistry[K]['data']>;
3870
+ /**
3871
+ * Posts to the embedding host. No-op when no concrete target origin could be
3872
+ * resolved (a config/embedding error) — `connect()` rejects fast in that case
3873
+ * so calls never silently hang waiting on a timeout.
3874
+ */
3875
+ private postToParent;
3845
3876
  private handleMessage;
3846
3877
  private handleResponse;
3847
3878
  private handleProgress;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- var version = "0.2.0";
1
+ var version = "0.3.0";
2
2
 
3
3
  type AccountingAccountDimensionValues = {
4
4
  account_id: string;
@@ -3659,13 +3659,34 @@ declare abstract class BridgeDataMethods {
3659
3659
 
3660
3660
  interface VibeAppBridgeOptions {
3661
3661
  /**
3662
- * Origin of the Nominal app embedding this iframe (e.g.
3663
- * `https://app.nominal.so` or `http://localhost:3000`). Must match the host
3664
- * origin **exactly** messages from any other origin are ignored, and the
3665
- * bridge only `postMessage`s to this origin. Drive it from an env var so the
3666
- * same code works locally and in production.
3662
+ * Origin(s) of the Nominal app embedding this iframe. **Optional.**
3663
+ *
3664
+ * **Omit it** (the recommended default) and the bridge auto-detects the
3665
+ * Nominal page that actually embeds it and talks only to that origin. This is
3666
+ * safe because Nominal serves Vibe Apps behind a `frame-ancestors` CSP, so
3667
+ * only nom-ui can frame the app — a hostile site can't embed it to intercept
3668
+ * what the iframe sends out (`postTaskOutput` bodies, file uploads). Most apps
3669
+ * need nothing here.
3670
+ *
3671
+ * Provide a value to **pin explicitly** — defense in depth, or when deploying
3672
+ * outside Nominal's edge:
3673
+ *
3674
+ * ```ts
3675
+ * parentOrigin: 'https://app.nominal.so' // one exact origin
3676
+ * parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] // exact + preview pattern
3677
+ * ```
3678
+ *
3679
+ * A pattern's `*` matches exactly one DNS label or port — anchored, scheme
3680
+ * literal — so `https://*.vercel.app` matches `https://pr-7.vercel.app` but
3681
+ * not `https://a.b.vercel.app` or `https://pr-7.vercel.app.evil.com`. Patterns
3682
+ * are honoured only when this app's own page is served from a recognised
3683
+ * preview host (`*.vercel.app`, `*.lovable.app`, `localhost`, …); on a
3684
+ * production custom domain only exact origins match.
3685
+ *
3686
+ * If neither an explicit value nor an auto-detected parent is available,
3687
+ * `connect()` rejects with `BridgeError` code `PARENT_ORIGIN_UNRESOLVED`.
3667
3688
  */
3668
- parentOrigin: string;
3689
+ parentOrigin?: string | string[];
3669
3690
  /**
3670
3691
  * Global per-request timeout in milliseconds before a call rejects with a
3671
3692
  * `BridgeError` whose `code` is `'TIMEOUT'`.
@@ -3702,13 +3723,17 @@ interface VibeAppBridgeOptions {
3702
3723
  * ```
3703
3724
  */
3704
3725
  declare class VibeAppBridge extends BridgeDataMethods {
3705
- private readonly parentOrigin;
3726
+ /** Concrete origin to `postMessage` to, or `null` if none could be resolved. */
3727
+ private readonly targetOrigin;
3728
+ /** Validates an inbound `event.origin` against the configured allowlist. */
3729
+ private readonly isTrustedOrigin;
3706
3730
  private readonly requestTimeout;
3707
3731
  private readonly pendingRequests;
3708
3732
  private readonly boundHandleMessage;
3709
3733
  private context;
3710
3734
  private connectPromise;
3711
3735
  private connectPollInterval;
3736
+ private connectTimeout;
3712
3737
  private onContextReceived;
3713
3738
  private onContextError;
3714
3739
  /**
@@ -3726,7 +3751,7 @@ declare class VibeAppBridge extends BridgeDataMethods {
3726
3751
  private readonly kindHandlers;
3727
3752
  private dispatchPush;
3728
3753
  private readonly pushHandlers;
3729
- constructor({ parentOrigin, requestTimeout }: VibeAppBridgeOptions);
3754
+ constructor({ parentOrigin, requestTimeout }?: VibeAppBridgeOptions);
3730
3755
  /**
3731
3756
  * Connects to the host and returns the tenant/user {@link ContextPayload}.
3732
3757
  * **Call this once on app init and await it before any other method** — data
@@ -3842,6 +3867,12 @@ declare class VibeAppBridge extends BridgeDataMethods {
3842
3867
  * ```
3843
3868
  */
3844
3869
  request<K extends keyof RequestRegistry>(type: K, payload: RequestRegistry[K]['payload'], onProgress?: (payload: unknown) => void): Promise<RequestRegistry[K]['data']>;
3870
+ /**
3871
+ * Posts to the embedding host. No-op when no concrete target origin could be
3872
+ * resolved (a config/embedding error) — `connect()` rejects fast in that case
3873
+ * so calls never silently hang waiting on a timeout.
3874
+ */
3875
+ private postToParent;
3845
3876
  private handleMessage;
3846
3877
  private handleResponse;
3847
3878
  private handleProgress;
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
  // src/data-methods.ts
49
76
  var BridgeDataMethods = class {
@@ -362,13 +389,72 @@ function resolveRequestTimeout(type, requestTimeout) {
362
389
  }
363
390
 
364
391
  // src/VibeAppBridge.ts
392
+ var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
393
+ var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
394
+ function isPreviewSelfHost(hostname) {
395
+ return PREVIEW_HOST_RE.test(hostname);
396
+ }
397
+ function resolveActualParentOrigin() {
398
+ if (typeof window === "undefined") return null;
399
+ try {
400
+ const ancestors = window.location.ancestorOrigins;
401
+ if (ancestors && ancestors.length > 0 && ancestors[0]) return ancestors[0];
402
+ } catch {
403
+ }
404
+ try {
405
+ if (document.referrer) return new URL(document.referrer).origin;
406
+ } catch {
407
+ }
408
+ return null;
409
+ }
410
+ function resolveOriginPolicy(configured) {
411
+ if (typeof window !== "undefined") {
412
+ const override = window[PARENT_ORIGIN_OVERRIDE_KEY];
413
+ if (typeof override === "string" && override.length > 0) {
414
+ console.log(`[vibe-bridge] Using dev-bridge parentOrigin override: ${override}`);
415
+ return { targetOrigin: override, isTrusted: (o) => o === override };
416
+ }
417
+ }
418
+ const rawList = configured === void 0 ? [] : Array.isArray(configured) ? configured : [configured];
419
+ const allowlist = rawList.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
420
+ if (allowlist.length === 0) {
421
+ const actual2 = resolveActualParentOrigin();
422
+ if (actual2) return { targetOrigin: actual2, isTrusted: (o) => o === actual2 };
423
+ console.warn(
424
+ "[vibe-bridge] No `parentOrigin` set and the embedding host could not be auto-detected (no ancestorOrigins/referrer). Pass `parentOrigin` explicitly."
425
+ );
426
+ return { targetOrigin: null, isTrusted: () => false };
427
+ }
428
+ const exactEntries = allowlist.filter((entry) => !isOriginPattern(entry));
429
+ const hasPatterns = exactEntries.length !== allowlist.length;
430
+ if (allowlist.length === 1 && !hasPatterns) {
431
+ const only = allowlist[0];
432
+ return { targetOrigin: only, isTrusted: (o) => o === only };
433
+ }
434
+ const allowPatterns = hasPatterns && typeof window !== "undefined" && isPreviewSelfHost(window.location.hostname);
435
+ const isTrusted = (o) => isOriginAllowed(o, allowlist, { allowPatterns });
436
+ const actual = resolveActualParentOrigin();
437
+ let targetOrigin = null;
438
+ if (actual && isTrusted(actual)) {
439
+ targetOrigin = actual;
440
+ } else if (actual === null && !allowPatterns && exactEntries.length === 1) {
441
+ targetOrigin = exactEntries[0];
442
+ }
443
+ if (targetOrigin === null) {
444
+ console.warn(
445
+ "[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." : ".")
446
+ );
447
+ }
448
+ return { targetOrigin, isTrusted };
449
+ }
365
450
  var VibeAppBridge = class extends BridgeDataMethods {
366
- constructor({ parentOrigin, requestTimeout }) {
451
+ constructor({ parentOrigin, requestTimeout } = {}) {
367
452
  super();
368
453
  this.pendingRequests = /* @__PURE__ */ new Map();
369
454
  this.context = null;
370
455
  this.connectPromise = null;
371
456
  this.connectPollInterval = null;
457
+ this.connectTimeout = null;
372
458
  this.onContextReceived = null;
373
459
  this.onContextError = null;
374
460
  /**
@@ -423,7 +509,9 @@ var VibeAppBridge = class extends BridgeDataMethods {
423
509
  this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
424
510
  }
425
511
  };
426
- this.parentOrigin = parentOrigin;
512
+ const policy = resolveOriginPolicy(parentOrigin);
513
+ this.targetOrigin = policy.targetOrigin;
514
+ this.isTrustedOrigin = policy.isTrusted;
427
515
  this.requestTimeout = requestTimeout;
428
516
  this.boundHandleMessage = this.handleMessage.bind(this);
429
517
  window.addEventListener("message", this.boundHandleMessage);
@@ -459,6 +547,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
459
547
  connect() {
460
548
  if (this.context) return Promise.resolve(this.context);
461
549
  if (this.connectPromise) return this.connectPromise;
550
+ if (this.targetOrigin === null) {
551
+ return Promise.reject(
552
+ new BridgeError(
553
+ "PARENT_ORIGIN_UNRESOLVED",
554
+ "Could not resolve a parent origin to connect to. Check `parentOrigin` against the embedding host."
555
+ )
556
+ );
557
+ }
462
558
  this.connectPromise = new Promise((resolve, reject) => {
463
559
  const timer = setTimeout(() => {
464
560
  this.stopConnectPolling();
@@ -467,6 +563,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
467
563
  this.onContextError = null;
468
564
  reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
469
565
  }, CONNECT_TIMEOUT_MS);
566
+ this.connectTimeout = timer;
470
567
  this.onContextError = (error) => {
471
568
  clearTimeout(timer);
472
569
  this.stopConnectPolling();
@@ -509,7 +606,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
509
606
  requestId,
510
607
  payload: { bridgeVersion: version }
511
608
  };
512
- window.parent.postMessage(message, this.parentOrigin);
609
+ this.postToParent(message);
513
610
  };
514
611
  poll();
515
612
  this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
@@ -591,6 +688,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
591
688
  }
592
689
  destroy() {
593
690
  this.teardownNavigationTracking();
691
+ this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
594
692
  this.stopConnectPolling();
595
693
  window.removeEventListener("message", this.boundHandleMessage);
596
694
  for (const pending of this.pendingRequests.values()) {
@@ -609,6 +707,10 @@ var VibeAppBridge = class extends BridgeDataMethods {
609
707
  clearInterval(this.connectPollInterval);
610
708
  this.connectPollInterval = null;
611
709
  }
710
+ if (this.connectTimeout !== null) {
711
+ clearTimeout(this.connectTimeout);
712
+ this.connectTimeout = null;
713
+ }
612
714
  this.connectPollIds.clear();
613
715
  }
614
716
  /**
@@ -665,10 +767,13 @@ var VibeAppBridge = class extends BridgeDataMethods {
665
767
  this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
666
768
  }
667
769
  sendCommand(type, payload) {
668
- window.parent.postMessage(
669
- { __protocol: PROTOCOL_ID, __version: PROTOCOL_VERSION, kind: "command", type, payload },
670
- this.parentOrigin
671
- );
770
+ this.postToParent({
771
+ __protocol: PROTOCOL_ID,
772
+ __version: PROTOCOL_VERSION,
773
+ kind: "command",
774
+ type,
775
+ payload
776
+ });
672
777
  }
673
778
  /**
674
779
  * Low-level typed request for **any** operation in {@link RequestRegistry}.
@@ -711,11 +816,20 @@ var VibeAppBridge = class extends BridgeDataMethods {
711
816
  requestId,
712
817
  payload
713
818
  };
714
- window.parent.postMessage(message, this.parentOrigin);
819
+ this.postToParent(message);
715
820
  });
716
821
  }
822
+ /**
823
+ * Posts to the embedding host. No-op when no concrete target origin could be
824
+ * resolved (a config/embedding error) — `connect()` rejects fast in that case
825
+ * so calls never silently hang waiting on a timeout.
826
+ */
827
+ postToParent(message) {
828
+ if (this.targetOrigin === null) return;
829
+ window.parent.postMessage(message, this.targetOrigin);
830
+ }
717
831
  handleMessage(event) {
718
- if (event.origin !== this.parentOrigin) return;
832
+ if (!this.isTrustedOrigin(event.origin)) return;
719
833
  if (!isBridgeMessage(event.data)) return;
720
834
  this.kindHandlers[event.data.kind]?.(event.data);
721
835
  }
@@ -34,7 +34,7 @@ The host pushes context to the iframe on load, but the iframe's listener may not
34
34
  4. The bridge begins auto-tracking SPA navigation (see below).
35
35
  5. If nothing arrives within **10 seconds**, `connect()` rejects with a `BridgeError` (code `'TIMEOUT'`, message `Bridge connect timed out`).
36
36
 
37
- A timeout almost always means a `parentOrigin` mismatch or the host never mounted.
37
+ A timeout almost always means a `parentOrigin` mismatch or the host never mounted. A separate, immediate rejection — `BridgeError` code `PARENT_ORIGIN_UNRESOLVED` — means no concrete parent origin could be resolved at all (e.g. a pattern-only `parentOrigin` on a non-preview host); fix `parentOrigin` rather than waiting on the timeout.
38
38
 
39
39
  ## Initial deep link
40
40
 
@@ -15,9 +15,7 @@ No runtime dependencies. Ships ESM + CJS with self-contained TypeScript types.
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,
20
- })
18
+ const bridge = new VibeAppBridge() // auto-detects the embedding Nominal host
21
19
 
22
20
  async function main() {
23
21
  const ctx: ContextPayload = await bridge.connect()
@@ -32,20 +30,24 @@ async function main() {
32
30
  main().catch(console.error)
33
31
  ```
34
32
 
35
- ## `parentOrigin`
33
+ ## `parentOrigin` (optional)
36
34
 
37
- `parentOrigin` is the **origin** (scheme + host + port, no path, no trailing slash) of the Nominal app that embeds your iframe. It must match exactly the bridge ignores messages from any other origin and only posts to this one.
35
+ By default you pass **nothing** the bridge auto-detects the Nominal app that embeds your iframe and talks only to it. This is safe because Nominal serves Vibe Apps behind a `frame-ancestors` CSP, so only nom-ui can frame the app. Most apps need nothing here.
38
36
 
39
- Drive it from an environment variable so the same code works locally and in production:
37
+ Pass `parentOrigin` to **pin the host origin** explicitly (defense in depth, or when running outside Nominal's edge). It's an **origin** — scheme + host + port, no path, no trailing slash. Drive it from an env var, or use a list / **glob patterns** for dynamic preview deployments:
40
38
 
41
- ```sh
42
- # .env (committed standalone/dev default)
43
- VITE_PARENT_ORIGIN=http://localhost:5173
39
+ ```ts
40
+ new VibeAppBridge({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
41
+ new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
42
+ ```
44
43
 
45
- # .env.local (git-ignored — when testing inside nom-ui locally)
44
+ ```sh
45
+ # .env.local — when pinning explicitly while testing inside nom-ui locally
46
46
  VITE_PARENT_ORIGIN=http://localhost:3000
47
47
  ```
48
48
 
49
+ A pattern's `*` matches exactly one DNS label or port (anchored, scheme literal). Patterns are honoured **only when the app's own page is served from a recognised preview host** (`*.vercel.app`, `*.lovable.app`, `localhost`, …); on a production custom domain they are ignored and only exact origins match. Keep at least one exact origin in the list.
50
+
49
51
  ## Next
50
52
 
51
53
  - [`connect-lifecycle.md`](./connect-lifecycle.md) — how `connect()` works and the connection lifecycle.
package/llms.txt CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  > Iframe-side TypeScript SDK for building Nominal Vibe Apps — standalone browser apps (often built with Lovable) embedded in the Nominal platform via a cross-origin iframe. Connects the 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. Browser-only, zero runtime dependencies.
4
4
 
5
- The integration is always the same: construct `VibeAppBridge` with the host `parentOrigin`, `await bridge.connect()` once (it returns the tenant/user `ContextPayload`), then call any of the ~46 typed data methods, `upload(file, options)`, or `postTaskOutput(...)`. Tear down with `bridge.destroy()`. The only write path into Nominal is `postTaskOutput`.
5
+ The integration is always the same: `new VibeAppBridge()` (no config `parentOrigin` is optional and the bridge auto-detects the embedding Nominal host; pass it only to pin explicitly or to allow preview origins), `await bridge.connect()` once (it returns the tenant/user `ContextPayload`), then call any of the ~46 typed data methods, `upload(file, options)`, or `postTaskOutput(...)`. Tear down with `bridge.destroy()`. The only write path into Nominal is `postTaskOutput`.
6
6
 
7
7
  ## Docs
8
8
 
9
9
  - [README](./README.md): install, quickstart, full API, common mistakes, and the operation catalog.
10
- - [Getting started](./docs/getting-started.md): install + minimal app + `parentOrigin` setup.
10
+ - [Getting started](./docs/getting-started.md): install + minimal app + optional `parentOrigin` pinning.
11
11
  - [Connection lifecycle](./docs/connect-lifecycle.md): `connect()` semantics, `ContextPayload`, timeout, subroute sync, teardown.
12
12
  - [Fetching data](./docs/data-fetching.md): named methods vs the typed `request()` escape hatch, payload shapes, errors/timeouts, operation list.
13
13
  - [File upload](./docs/file-upload.md): `upload(file, options)`, progress events, constraints.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nominalso/vibe-bridge",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",