@nominalso/vibe-host 0.1.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
@@ -20,14 +20,28 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ BridgeError: () => BridgeError,
24
+ HttpBridgeError: () => HttpBridgeError,
23
25
  VibeAppHost: () => VibeAppHost
24
26
  });
25
27
  module.exports = __toCommonJS(index_exports);
26
28
 
27
29
  // package.json
28
- var version = "0.1.0";
30
+ var version = "0.3.0";
29
31
 
30
32
  // ../protocol-types/dist/index.js
33
+ function normalizeSubroute(subroute) {
34
+ const normalized = subroute.startsWith("/") ? subroute : `/${subroute}`;
35
+ const path = normalized.split(/[?#]/)[0];
36
+ let decoded = path;
37
+ let prev;
38
+ do {
39
+ prev = decoded;
40
+ decoded = decoded.replace(/%25/gi, "%").replace(/%2e/gi, ".").replace(/%2f/gi, "/");
41
+ } while (decoded !== prev);
42
+ if (decoded.split("/").some((segment) => segment === "..")) return null;
43
+ return normalized;
44
+ }
31
45
  var PROTOCOL_ID = "nominal-vibe-bridge";
32
46
  var PROTOCOL_VERSION = 1;
33
47
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
@@ -44,6 +58,47 @@ function isBridgeMessage(data) {
44
58
  if (CORRELATED_KINDS_SET.has(d.kind) && typeof d.requestId !== "string") return false;
45
59
  return true;
46
60
  }
61
+ var BridgeError = class extends Error {
62
+ constructor(code, message) {
63
+ super(message);
64
+ this.code = code;
65
+ this.name = "BridgeError";
66
+ }
67
+ };
68
+ var HttpBridgeError = class extends BridgeError {
69
+ constructor(status, message) {
70
+ super("REQUEST_FAILED", message);
71
+ this.status = status;
72
+ this.name = "HttpBridgeError";
73
+ }
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
+ }
47
102
 
48
103
  // ../api-client/dist/index.js
49
104
  var import_client_fetch = require("@hey-api/client-fetch");
@@ -375,10 +430,6 @@ var getSubsidiaryParentCurrenciesApiTenancySubsidiarySubsidiaryIdParentCurrencie
375
430
  };
376
431
  function createVibeApiClient({ baseUrl, stripApiPrefix }) {
377
432
  const client2 = (0, import_client_fetch2.createClient)((0, import_client_fetch2.createConfig)({ baseUrl }));
378
- client2.interceptors.request.use((request) => {
379
- console.log("[vibe-api-client] request url:", request.url);
380
- return request;
381
- });
382
433
  if (stripApiPrefix) {
383
434
  client2.interceptors.request.use(
384
435
  (request) => new Request(request.url.replace(`${baseUrl}/api/`, `${baseUrl}/`), request)
@@ -387,16 +438,28 @@ function createVibeApiClient({ baseUrl, stripApiPrefix }) {
387
438
  return client2;
388
439
  }
389
440
 
390
- // src/handlerMap.ts
441
+ // src/handlers/call.ts
391
442
  async function call(fn, payload, client2) {
392
- const { data } = await fn({ ...payload, client: client2 });
393
- if (data == null) throw new Error("API returned no data");
443
+ const { data, response } = await fn({ ...payload, client: client2 });
444
+ if (data == null) {
445
+ if (response && !response.ok) {
446
+ throw new HttpBridgeError(
447
+ response.status,
448
+ `API request failed with status ${response.status}`
449
+ );
450
+ }
451
+ if (!response) {
452
+ throw new Error("API request produced no response");
453
+ }
454
+ return null;
455
+ }
394
456
  return data;
395
457
  }
396
- function createHandlerMap(options) {
397
- const client2 = createVibeApiClient(options);
458
+
459
+ // src/handlers/accounting.ts
460
+ function accountingHandlers(client2) {
398
461
  return {
399
- // Accounting — chart of accounts
462
+ // Chart of accounts
400
463
  GET_CHART_OF_ACCOUNTS: (p) => call(getCoaFlatApiAccountingSubsidiarySubsidiaryIdCoaAccountGet, p, client2),
401
464
  GET_COA_TREE: (p) => call(getCoaTreeApiAccountingSubsidiarySubsidiaryIdCoaGet, p, client2),
402
465
  GET_COA_FLAT_SIMPLE: (p) => call(
@@ -421,7 +484,7 @@ function createHandlerMap(options) {
421
484
  ),
422
485
  GET_ACCOUNTS: (p) => call(getAccountsApiAccountingAccountGet, p, client2),
423
486
  GET_ACCOUNT: (p) => call(getAccountApiAccountingAccountAccountIdGet, p, client2),
424
- // Accounting — exchange rates
487
+ // Exchange rates
425
488
  GET_CONVERSION_RATES: (p) => call(getConversionRateApiAccountingCurrencyCurrencyConversionRatesGet, p, client2),
426
489
  GET_EFFECTIVE_EXCHANGE_RATE: (p) => call(getEffectiveExchangeRateApiAccountingConsolidationExchangeRateGet, p, client2),
427
490
  GET_EXCHANGE_RATE_BY_DATE: (p) => call(
@@ -429,7 +492,7 @@ function createHandlerMap(options) {
429
492
  p,
430
493
  client2
431
494
  ),
432
- // Accounting — dimensions
495
+ // Dimensions
433
496
  GET_DIMENSIONS: (p) => call(getDimensionsApiAccountingDimensionGet, p, client2),
434
497
  GET_DIMENSION_VALUES: (p) => call(getDimensionValuesApiAccountingDimensionValueGet, p, client2),
435
498
  GET_DIMENSION_VALUES_HIERARCHICAL: (p) => call(
@@ -442,7 +505,7 @@ function createHandlerMap(options) {
442
505
  p,
443
506
  client2
444
507
  ),
445
- // Accounting — journal entries
508
+ // Journal entries
446
509
  GET_JOURNAL_ENTRIES: (p) => call(
447
510
  getSubsidiaryJournalApiAccountingSubsidiarySubsidiaryIdJournalEntryGet,
448
511
  p,
@@ -457,8 +520,14 @@ function createHandlerMap(options) {
457
520
  getJournalEntryApiAccountingSubsidiarySubsidiaryIdJournalEntryJournalEntryIdGet,
458
521
  p,
459
522
  client2
460
- ),
461
- // Activity — period instances
523
+ )
524
+ };
525
+ }
526
+
527
+ // src/handlers/activity.ts
528
+ function activityHandlers(client2) {
529
+ return {
530
+ // Period instances
462
531
  GET_PERIODS: (p) => call(getPeriodInstancesApiActivityPeriodInstanceGet, p, client2),
463
532
  GET_PERIOD_INSTANCE: (p) => call(getPeriodInstanceApiActivityPeriodInstancePeriodInstanceIdGet, p, client2),
464
533
  GET_PERIOD_INSTANCE_BY_SLUG: (p) => call(
@@ -471,14 +540,14 @@ function createHandlerMap(options) {
471
540
  p,
472
541
  client2
473
542
  ),
474
- // Activity — activity definitions
543
+ // Activity definitions
475
544
  GET_ACTIVITY_DEFINITIONS: (p) => call(getActivityDefinitionsApiActivityActivityDefinitionGet, p, client2),
476
545
  GET_ACTIVITY_DEFINITION: (p) => call(
477
546
  getActivityDefinitionByIdApiActivityActivityDefinitionActivityDefinitionIdGet,
478
547
  p,
479
548
  client2
480
549
  ),
481
- // Activity — activity instances
550
+ // Activity instances
482
551
  GET_ACTIVITY_INSTANCES: (p) => call(getActivityInstancesApiActivityActivityInstanceGet, p, client2),
483
552
  GET_ACTIVITY_INSTANCE: (p) => call(getActivityInstanceApiActivityActivityInstanceActivityInstanceIdGet, p, client2),
484
553
  GET_ACTIVITY_INSTANCE_BY_PERIOD: (p) => call(
@@ -496,28 +565,43 @@ function createHandlerMap(options) {
496
565
  p,
497
566
  client2
498
567
  ),
499
- // Activity — task definitions
568
+ // Task definitions
500
569
  GET_TASK_DEFINITIONS: (p) => call(getAllTasksDefinitionsApiActivityTaskDefinitionGet, p, client2),
501
570
  GET_TASK_DEFINITION: (p) => call(getTaskDefinitionByIdApiActivityTaskDefinitionTaskDefinitionIdGet, p, client2),
502
571
  CREATE_TASK_DEFINITION: (p) => call(createTaskDefinitionApiActivityTaskDefinitionPost, p, client2),
503
572
  UPDATE_TASK_DEFINITION: (p) => call(updateTaskDefinitionApiActivityTaskDefinitionTaskDefinitionIdPut, p, client2),
504
573
  GET_TASK_DEFINITIONS_BY_FILTER: (p) => call(getAllTasksDefinitionsByFilterApiActivityTaskDefinitionQueryPost, p, client2),
505
- // Activity — task instances
574
+ // Task instances
506
575
  GET_TASK_INSTANCES: (p) => call(getAllTaskInstancesApiActivityTaskInstanceGet, p, client2),
507
576
  GET_TASK_INSTANCE: (p) => call(getTaskInstanceByIdApiActivityTaskInstanceTaskInstanceIdGet, p, client2),
508
577
  POST_TASK_OUTPUT: (p) => call(updateTaskInstanceApiActivityTaskInstanceTaskInstanceIdPut, p, client2),
509
- GET_TASK_INSTANCES_BY_FILTER: (p) => call(getAllTaskInstancesByFilterApiActivityTaskInstanceQueryPost, p, client2),
510
- // Audit trail
578
+ GET_TASK_INSTANCES_BY_FILTER: (p) => call(getAllTaskInstancesByFilterApiActivityTaskInstanceQueryPost, p, client2)
579
+ };
580
+ }
581
+
582
+ // src/handlers/audit-trail.ts
583
+ function auditTrailHandlers(client2) {
584
+ return {
511
585
  GET_AUDIT_EVENTS: (p) => call(getEventsApiAuditTrailEventGet, p, client2),
512
- GET_ENTITY_AUDIT_EVENTS: (p) => call(getEntityEventsApiAuditTrailEventEntityTargetTypeTargetIdPost, p, client2),
513
- // Period manager
586
+ GET_ENTITY_AUDIT_EVENTS: (p) => call(getEntityEventsApiAuditTrailEventEntityTargetTypeTargetIdPost, p, client2)
587
+ };
588
+ }
589
+
590
+ // src/handlers/period-manager.ts
591
+ function periodManagerHandlers(client2) {
592
+ return {
514
593
  GET_FISCAL_CALENDARS: (p) => call(
515
594
  getFiscalCalendarsBySubsidiaryApiPeriodManagerFiscalCalendarBySubsidiarySubsidiaryIdGet,
516
595
  p,
517
596
  client2
518
597
  ),
519
- GET_FISCAL_CALENDAR: (p) => call(getFiscalCalendarByIdApiPeriodManagerFiscalCalendarCalendarIdGet, p, client2),
520
- // Tenancy
598
+ GET_FISCAL_CALENDAR: (p) => call(getFiscalCalendarByIdApiPeriodManagerFiscalCalendarCalendarIdGet, p, client2)
599
+ };
600
+ }
601
+
602
+ // src/handlers/tenancy.ts
603
+ function tenancyHandlers(client2) {
604
+ return {
521
605
  GET_SUBSIDIARIES: (p) => call(getAllSubsidiariesApiTenancySubsidiaryGet, p, client2),
522
606
  GET_SUBSIDIARY: (p) => call(getSubsidiaryApiTenancySubsidiarySubsidiaryIdGet, p, client2),
523
607
  GET_SUBSIDIARY_PARENT_CURRENCIES: (p) => call(
@@ -529,46 +613,122 @@ function createHandlerMap(options) {
529
613
  };
530
614
  }
531
615
 
532
- // src/uploadHandler.ts
533
- async function uploadHandler(payload, sendProgress) {
534
- const attachmentId = crypto.randomUUID();
535
- for (const progress of [25, 50, 75, 100]) {
536
- await new Promise((r) => setTimeout(r, 400));
537
- sendProgress({ attachmentId, progress });
616
+ // src/handlerMap.ts
617
+ function createHandlerMap(options) {
618
+ const client2 = createVibeApiClient(options);
619
+ return {
620
+ ...accountingHandlers(client2),
621
+ ...activityHandlers(client2),
622
+ ...auditTrailHandlers(client2),
623
+ ...periodManagerHandlers(client2),
624
+ ...tenancyHandlers(client2)
625
+ };
626
+ }
627
+
628
+ // src/rateLimiter.ts
629
+ var DEFAULT_RATE_LIMIT = 60;
630
+ function createRateLimiter(limit = DEFAULT_RATE_LIMIT, windowMs = 6e4) {
631
+ return { timestamps: [], limit, windowMs };
632
+ }
633
+ function checkRateLimit(state) {
634
+ const now = Date.now();
635
+ const cutoff = now - state.windowMs;
636
+ state.timestamps = state.timestamps.filter((t) => t > cutoff);
637
+ if (state.timestamps.length >= state.limit) {
638
+ return false;
538
639
  }
539
- return { attachmentId, name: payload.fileName };
640
+ state.timestamps.push(now);
641
+ return true;
642
+ }
643
+
644
+ // src/unwired.ts
645
+ function warnUnwired(command) {
646
+ return () => {
647
+ console.warn(`[vibe-host] Received "${command}" command but no handler is wired \u2014 ignoring.`);
648
+ };
649
+ }
650
+ function rejectUnwired(op) {
651
+ return () => Promise.reject(
652
+ new BridgeError("NOT_WIRED", `[vibe-host] Received "${op}" request but no handler is wired.`)
653
+ );
540
654
  }
541
655
 
542
656
  // src/VibeAppHost.ts
543
657
  var VibeAppHost = class {
544
- constructor({ trustedOrigins, getContext, appBasePath, clientConfig }) {
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();
545
665
  this.iframeWindow = null;
666
+ /**
667
+ * Source windows we've already logged a successful handshake for. The bridge
668
+ * polls `GET_CONTEXT` on an interval until it connects, so without this the
669
+ * "Vibe App connected" line repeats on every poll (SSOT §13 F3).
670
+ */
671
+ this.handshakeLoggedWindows = /* @__PURE__ */ new WeakSet();
546
672
  this.kindHandlers = {
547
673
  request: (msg, source, origin) => this.handleRequest(msg, source, origin),
548
674
  command: (msg) => this.dispatchCommand(msg)
549
675
  };
550
- this.commandHandlers = {
551
- REPORT_SUBROUTE: (payload) => {
552
- const newPath = payload.subroute === "/" ? this.appBasePath : `${this.appBasePath}${payload.subroute}`;
553
- const method = payload.replace ? "replaceState" : "pushState";
554
- window.history[method](null, "", newPath);
555
- }
556
- };
676
+ const { trustedOrigins, getContext, appBasePath, clientConfig } = opts;
557
677
  this.trustedOrigins = new Set(trustedOrigins);
678
+ this.exactOrigins = trustedOrigins.filter((entry) => !isOriginPattern(entry));
558
679
  this.getContext = getContext;
559
680
  this.appBasePath = appBasePath;
681
+ this.onRequestComplete = opts.onRequestComplete;
682
+ this.rateLimiter = createRateLimiter(opts.rateLimit ?? DEFAULT_RATE_LIMIT);
560
683
  this.boundHandleMessage = this.handleMessage.bind(this);
561
684
  this.boundHandlePopState = this.handlePopState.bind(this);
562
- this.handlers = this.initializeHandlers(clientConfig || { baseUrl: "", stripApiPrefix: false });
685
+ this.handlers = this.initializeHandlers(
686
+ clientConfig || { baseUrl: "", stripApiPrefix: false },
687
+ opts
688
+ );
689
+ this.commandHandlers = this.buildCommandHandlers(opts);
563
690
  }
564
691
  dispatchCommand(msg) {
565
692
  const handler = this.commandHandlers[msg.type];
566
- handler(msg.payload);
693
+ if (!handler) {
694
+ console.warn(`[vibe-host] Received unknown command "${msg.type}" \u2014 ignoring.`);
695
+ return;
696
+ }
697
+ try {
698
+ handler(msg.payload);
699
+ } catch (err) {
700
+ console.warn(`[vibe-host] Command "${msg.type}" handler threw \u2014 ignoring.`, err);
701
+ }
567
702
  }
568
- initializeHandlers(clientConfig) {
703
+ /**
704
+ * Commands: `REPORT_SUBROUTE` is handled internally (transport + traversal
705
+ * guard, item `l`); navigation/UI side effects are injected by the consumer,
706
+ * falling back to warn-and-ignore (DP4).
707
+ */
708
+ buildCommandHandlers(opts) {
709
+ return {
710
+ REPORT_SUBROUTE: (payload) => {
711
+ const safe = normalizeSubroute(payload.subroute);
712
+ if (safe === null) return;
713
+ const newPath = safe === "/" ? this.appBasePath : `${this.appBasePath}${safe}`;
714
+ const method = payload.replace ? "replaceState" : "pushState";
715
+ window.history[method](null, "", newPath);
716
+ },
717
+ NAVIGATE: opts.onNavigate ?? warnUnwired("NAVIGATE"),
718
+ SET_SIDE_NAV: opts.onSetSideNav ?? warnUnwired("SET_SIDE_NAV"),
719
+ SWITCH_SUBSIDIARY: opts.onSwitchSubsidiary ?? warnUnwired("SWITCH_SUBSIDIARY")
720
+ };
721
+ }
722
+ /**
723
+ * Requests: the built-in API map (hey-api), plus the injected/awaited side
724
+ * effects with safe defaults — unwired `UPLOAD_FILE` rejects with a coded
725
+ * error; unwired `INVALIDATE_CACHE` resolves to a no-op (DP4).
726
+ */
727
+ initializeHandlers(clientConfig, opts) {
569
728
  return {
570
729
  ...createHandlerMap(clientConfig),
571
- UPLOAD_FILE: uploadHandler
730
+ UPLOAD_FILE: opts.onUpload ?? rejectUnwired("UPLOAD_FILE"),
731
+ INVALIDATE_CACHE: opts.onInvalidateCache ?? (async () => ({ invalidated: false }))
572
732
  };
573
733
  }
574
734
  /**
@@ -589,6 +749,7 @@ var VibeAppHost = class {
589
749
  window.removeEventListener("message", this.boundHandleMessage);
590
750
  window.removeEventListener("popstate", this.boundHandlePopState);
591
751
  this.iframeWindow = null;
752
+ this.connectedOrigins.clear();
592
753
  };
593
754
  }
594
755
  /**
@@ -608,7 +769,7 @@ var VibeAppHost = class {
608
769
  type: "CONTEXT_PUSH",
609
770
  payload: { ...this.getContext(), hostVersion: version }
610
771
  };
611
- for (const origin of this.trustedOrigins) {
772
+ for (const origin of this.concreteTargets()) {
612
773
  target.postMessage(message, origin);
613
774
  }
614
775
  }
@@ -628,24 +789,55 @@ var VibeAppHost = class {
628
789
  type,
629
790
  payload
630
791
  };
631
- for (const origin of this.trustedOrigins) {
792
+ for (const origin of this.concreteTargets()) {
632
793
  this.iframeWindow.postMessage(message, origin);
633
794
  }
634
795
  }
635
796
  async handleMessage(event) {
636
- if (!this.trustedOrigins.has(event.origin)) return;
797
+ if (!isOriginAllowed(event.origin, this.trustedOrigins, { allowPatterns: true })) return;
637
798
  if (!isBridgeMessage(event.data)) return;
799
+ this.connectedOrigins.add(event.origin);
638
800
  await this.kindHandlers[event.data.kind]?.(event.data, event.source, event.origin);
639
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
+ }
640
811
  async handleRequest(msg, source, origin) {
641
812
  const { requestId, type } = msg;
642
813
  if (type === "GET_CONTEXT") {
643
- const ctx = { ...this.getContext(), hostVersion: version };
644
- const bridgeVersion = msg.payload?.bridgeVersion ?? "unknown";
645
- console.log(
646
- `[vibe-host] Vibe App connected \u2014 bridge: ${bridgeVersion}, host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
647
- );
648
- this.respond(source, origin, { kind: "response", type, requestId, ok: true, data: ctx });
814
+ try {
815
+ const ctx = { ...this.getContext(), hostVersion: version };
816
+ if (!this.handshakeLoggedWindows.has(source)) {
817
+ this.handshakeLoggedWindows.add(source);
818
+ const bridgeVersion = msg.payload?.bridgeVersion ?? "unknown";
819
+ console.log(
820
+ `[vibe-host] Vibe App connected \u2014 bridge: ${bridgeVersion}, host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
821
+ );
822
+ }
823
+ this.respond(source, origin, { kind: "response", type, requestId, ok: true, data: ctx });
824
+ } catch (err) {
825
+ const error = err instanceof Error ? err.message : String(err);
826
+ const code = err instanceof BridgeError ? err.code : "CONTEXT_ERROR";
827
+ this.respond(source, origin, { kind: "response", type, requestId, ok: false, error, code });
828
+ }
829
+ return;
830
+ }
831
+ if (!checkRateLimit(this.rateLimiter)) {
832
+ this.respond(source, origin, {
833
+ kind: "response",
834
+ type,
835
+ requestId,
836
+ ok: false,
837
+ error: `Too many requests. Limit is ${this.rateLimiter.limit} per minute.`,
838
+ code: "RATE_LIMITED"
839
+ });
840
+ this.onRequestComplete?.({ type, ok: false, payload: msg.payload });
649
841
  return;
650
842
  }
651
843
  const sendProgress = (payload) => {
@@ -663,9 +855,21 @@ var VibeAppHost = class {
663
855
  const handler = this.handlers[type];
664
856
  const data = await handler(msg.payload, sendProgress);
665
857
  this.respond(source, origin, { kind: "response", type, requestId, ok: true, data });
858
+ this.onRequestComplete?.({ type, ok: true, payload: msg.payload });
666
859
  } catch (err) {
667
860
  const error = err instanceof Error ? err.message : String(err);
668
- this.respond(source, origin, { kind: "response", type, requestId, ok: false, error });
861
+ const code = err instanceof BridgeError ? err.code : void 0;
862
+ const status = err instanceof HttpBridgeError ? err.status : void 0;
863
+ this.respond(source, origin, {
864
+ kind: "response",
865
+ type,
866
+ requestId,
867
+ ok: false,
868
+ error,
869
+ code,
870
+ status
871
+ });
872
+ this.onRequestComplete?.({ type, ok: false, payload: msg.payload });
669
873
  }
670
874
  }
671
875
  respond(target, origin, response) {
@@ -677,5 +881,7 @@ var VibeAppHost = class {
677
881
  };
678
882
  // Annotate the CommonJS export names for ESM import in node:
679
883
  0 && (module.exports = {
884
+ BridgeError,
885
+ HttpBridgeError,
680
886
  VibeAppHost
681
887
  });