@usethrottle/extension-bridge 0.2.0 → 1.1.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 ADDED
@@ -0,0 +1,95 @@
1
+ # @usethrottle/extension-bridge
2
+
3
+ Runtime bridge for Throttle extensions rendered inside dashboard iframes. The
4
+ package includes the iframe-side client, the dashboard host helper, shared
5
+ postMessage protocol types, and a Node webhook verifier.
6
+
7
+ ```bash
8
+ npm install @usethrottle/extension-bridge
9
+ ```
10
+
11
+ ## Iframe Client
12
+
13
+ ```ts
14
+ import { createBridge } from '@usethrottle/extension-bridge';
15
+
16
+ const bridge = createBridge({
17
+ targetOrigin: 'https://dashboard.usethrottle.dev',
18
+ });
19
+
20
+ const context = await bridge.ready;
21
+
22
+ console.log(context.environment.environmentId);
23
+ console.log(context.environment.environmentSlug);
24
+
25
+ const orders = await bridge.api.get('/api/v1/orders');
26
+ bridge.toast('Loaded orders', 'success');
27
+ ```
28
+
29
+ `bridge.ready` resolves to a `SessionContext`:
30
+
31
+ ```ts
32
+ type SessionContext = {
33
+ user: { id: string; email: string };
34
+ workspace: { id: string; slug: string };
35
+ application: { id: string; slug: string };
36
+ environment: {
37
+ environmentId: string;
38
+ environmentSlug: string;
39
+ environmentKind: 'production' | 'non_production';
40
+ providerEnvironment: 'production' | 'sandbox';
41
+ };
42
+ installationId: string;
43
+ extensionId: string;
44
+ version: string;
45
+ role: string;
46
+ scopes: string[];
47
+ };
48
+ ```
49
+
50
+ Production is the only production-provider environment. Every custom workspace
51
+ environment is non-production and uses sandbox provider credentials, so extension
52
+ API calls and webhooks stay isolated by `environment.environmentId`.
53
+
54
+ ## Dashboard Host
55
+
56
+ ```ts
57
+ import { createBridgeHost } from '@usethrottle/extension-bridge';
58
+
59
+ const host = createBridgeHost({
60
+ iframe,
61
+ targetOrigin: new URL(iframe.src).origin,
62
+ apiBaseUrl: 'https://api.usethrottle.dev',
63
+ async mintToken() {
64
+ return fetch('/api/extension-launch-token').then((res) => res.json());
65
+ },
66
+ onResize(height) {
67
+ iframe.style.height = `${height}px`;
68
+ },
69
+ });
70
+
71
+ host.destroy();
72
+ ```
73
+
74
+ `mintToken` must return `{ token, expiresAt, context }`, where `context` is the
75
+ same `SessionContext` shape above.
76
+
77
+ ## Webhook Verification
78
+
79
+ ```ts
80
+ import { verifyWebhook } from '@usethrottle/extension-bridge/webhook';
81
+
82
+ const ok = verifyWebhook(rawBody, signatureHeader, process.env.THROTTLE_WEBHOOK_SECRET!);
83
+ ```
84
+
85
+ The verifier accepts the `X-Throttle-Signature` format used by Throttle outbound
86
+ webhooks and returns `boolean`.
87
+
88
+ For Web Crypto runtimes such as Cloudflare Workers, Deno, Bun, or browser-like
89
+ extension backends, import the async verifier:
90
+
91
+ ```ts
92
+ import { verifyWebhook } from '@usethrottle/extension-bridge/verify';
93
+
94
+ const ok = await verifyWebhook(rawBody, signatureHeader, secret);
95
+ ```
@@ -10,4 +10,4 @@ export {
10
10
  BRIDGE_SOURCE_HOST,
11
11
  BRIDGE_PROTOCOL_VERSION
12
12
  };
13
- //# sourceMappingURL=chunk-XBGZRIHG.js.map
13
+ //# sourceMappingURL=chunk-LP3EXT2W.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/protocol.ts"],"sourcesContent":["/**\n * @usethrottle/extension-bridge — postMessage protocol types\n *\n * Both the extension iframe and the dashboard host import from this file\n * to ensure both sides of the channel use identical message shapes.\n */\n\n/** Discriminant prefix used in all bridge message payloads. */\nexport const MESSAGE_NAMESPACE = 'throttle:bridge' as const;\n\n/** `source` field set on every message sent BY the extension iframe. */\nexport const BRIDGE_SOURCE_IFRAME = 'throttle-extension-bridge' as const;\n\n/** `source` field set on every message sent BY the dashboard host. */\nexport const BRIDGE_SOURCE_HOST = 'throttle-extension-host' as const;\n\n/** Incremented when the handshake protocol changes in a breaking way. */\nexport const BRIDGE_PROTOCOL_VERSION = 1 as const;\n\nexport interface SessionContext {\n user: { id: string; email: string };\n workspace: { id: string; slug: string };\n application: { id: string; slug: string };\n environment: {\n environmentId: string;\n environmentSlug: string;\n environmentKind: 'production' | 'non_production';\n providerEnvironment: 'production' | 'sandbox';\n };\n installationId: string;\n extensionId: string;\n version: string;\n role: string;\n scopes: string[];\n}\n\nexport interface ReadyMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'ready'; }\nexport interface RefreshMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'refresh'; }\nexport interface ResizeMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'resize'; height: number; }\nexport interface ToastMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'toast'; message: string; level?: 'info' | 'success' | 'warning' | 'error'; }\nexport interface NavigateMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'navigate'; path: string; }\nexport type IframeToHostMessage = ReadyMessage | RefreshMessage | ResizeMessage | ToastMessage | NavigateMessage;\n\nexport interface SessionMessage {\n source: typeof BRIDGE_SOURCE_HOST;\n type: 'session';\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl: string;\n}\nexport type HostToIframeMessage = SessionMessage;\n"],"mappings":";AAQO,IAAM,oBAAoB;AAG1B,IAAM,uBAAuB;AAG7B,IAAM,qBAAqB;AAG3B,IAAM,0BAA0B;","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/protocol.ts"],"sourcesContent":["/**\n * @usethrottle/extension-bridge\n *\n * Runtime bridge for Throttle extensions running inside a dashboard iframe.\n *\n * Two sides:\n * - IFRAME (extension) side: `createBridge()` — handles the handshake, exposes\n * `bridge.api` for authenticated REST calls, and helpers for communicating\n * resize/toast/navigate events back to the host.\n * - HOST (dashboard) side: `createBridgeHost()` — listens for messages from the\n * iframe, calls `mintToken()` on `ready`/`refresh`, and routes UI events to\n * optional callbacks.\n *\n * WEBHOOK verification: import `verifyWebhook` from the subpath entry\n * `@usethrottle/extension-bridge/webhook` (node-only; uses `node:crypto`).\n */\n\n// Re-export protocol types + constants so dashboard host imports the SAME shapes\nexport type {\n SessionContext,\n IframeToHostMessage,\n HostToIframeMessage,\n ReadyMessage,\n RefreshMessage,\n ResizeMessage,\n ToastMessage,\n NavigateMessage,\n SessionMessage,\n} from './protocol.js';\n\nexport {\n MESSAGE_NAMESPACE,\n BRIDGE_SOURCE_IFRAME,\n BRIDGE_SOURCE_HOST,\n BRIDGE_PROTOCOL_VERSION,\n} from './protocol.js';\n\n// ---------------------------------------------------------------------------\n// Internal imports from protocol\n// ---------------------------------------------------------------------------\n\nimport {\n BRIDGE_SOURCE_IFRAME,\n BRIDGE_SOURCE_HOST,\n} from './protocol.js';\n\nimport type { SessionContext } from './protocol.js';\n\n// ---------------------------------------------------------------------------\n// IFRAME side — Bridge\n// ---------------------------------------------------------------------------\n\nexport interface BridgeApiClient {\n get(path: string): Promise<unknown>;\n post(path: string, body?: unknown): Promise<unknown>;\n patch(path: string, body?: unknown): Promise<unknown>;\n del(path: string): Promise<unknown>;\n}\n\nexport interface Bridge {\n /** Resolves when the first `session` message arrives from the host. */\n ready: Promise<SessionContext>;\n /** Typed REST client. Each method fetches with `Authorization: Bearer <token>`. */\n api: BridgeApiClient;\n /** Ask the host to resize the iframe to `heightPx` pixels. */\n resize(heightPx: number): void;\n /** Ask the host to show a toast notification. */\n toast(message: string, level?: 'info' | 'success' | 'warning' | 'error'): void;\n /** Ask the host to navigate the parent application. */\n navigate(path: string): void;\n /** Return the current session token, or null if no session has been received yet. */\n getToken(): string | null;\n /** Remove the message listener and clean up. */\n destroy(): void;\n}\n\n// Canonical spec name aliases for public consumers\nexport type ThrottleBridge = Bridge;\nexport type BridgeSessionContext = SessionContext;\nexport type CreateBridgeOptions = CreateBridgeOpts;\n\nexport interface CreateBridgeOpts {\n /**\n * Base URL for `bridge.api` calls.\n * Overridden by the `apiBaseUrl` field in the host's `session` message.\n * Defaults to `https://api.usethrottle.dev` if neither is present.\n */\n apiBaseUrl?: string;\n /**\n * Expected origin of the parent dashboard. When set, inbound messages from\n * other origins are silently ignored.\n *\n * When sending postMessage to the parent, the library targets:\n * 1. `targetOrigin` if explicitly provided.\n * 2. `document.referrer` origin if available.\n * 3. `'*'` as a last resort (logs a console.warn).\n *\n * Set this in production to prevent cross-origin token leaks.\n */\n targetOrigin?: string;\n}\n\n/**\n * Create a bridge instance inside an extension iframe.\n *\n * Call this once at startup. Immediately posts a `ready` message to the\n * parent window, then waits for the host to reply with a `session` message.\n */\nexport function createBridge(opts: CreateBridgeOpts = {}): Bridge {\n const { targetOrigin } = opts;\n // apiBaseUrl can be overridden by the session message; start with the option\n // or the default Throttle API base.\n let resolvedApiBaseUrl = opts.apiBaseUrl ?? 'https://api.usethrottle.dev';\n\n let resolveReady!: (ctx: SessionContext) => void;\n\n const readyPromise = new Promise<SessionContext>((resolve) => {\n resolveReady = resolve;\n });\n\n // Current token state\n let currentToken: string | null = null;\n let currentExpiresAt: number | null = null; // Unix seconds\n\n // Pending refresh waiters\n let refreshPromise: Promise<SessionContext> | null = null;\n let resolveRefresh: ((ctx: SessionContext) => void) | null = null;\n\n function isTokenExpiringSoon(): boolean {\n if (!currentToken || !currentExpiresAt) return true;\n return currentExpiresAt - Date.now() / 1000 < 60;\n }\n\n function requestRefresh(): Promise<SessionContext> {\n if (!refreshPromise) {\n refreshPromise = new Promise<SessionContext>((resolve) => {\n resolveRefresh = resolve;\n });\n postToParent({ type: 'refresh' });\n }\n return refreshPromise;\n }\n\n function handleSession(msg: {\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl?: string;\n }) {\n currentToken = msg.token;\n currentExpiresAt = Math.floor(new Date(msg.expiresAt).getTime() / 1000);\n\n // Host may override the API base URL\n if (msg.apiBaseUrl) {\n resolvedApiBaseUrl = msg.apiBaseUrl;\n }\n\n // Resolve the initial ready promise (idempotent — Promise ignores second resolve)\n resolveReady(msg.context);\n\n // Resolve any pending refresh waiter\n if (resolveRefresh) {\n const r = resolveRefresh;\n resolveRefresh = null;\n refreshPromise = null;\n r(msg.context);\n }\n }\n\n // Determine the target origin for outgoing postMessage calls.\n function getParentTarget(): string {\n if (targetOrigin) return targetOrigin;\n if (typeof document !== 'undefined' && document.referrer) {\n try {\n return new URL(document.referrer).origin;\n } catch {\n // ignore\n }\n }\n // Last resort — wildcard. Warn the developer.\n if (typeof console !== 'undefined') {\n console.warn(\n '[ThrottleBridge] targetOrigin not set and document.referrer unavailable; ' +\n 'posting to parent with origin=\"*\". Set `targetOrigin` in production.',\n );\n }\n return '*';\n }\n\n function onMessage(event: MessageEvent) {\n // Source-window guard: only accept messages from the direct parent.\n // Skip the check when event.source is null (e.g. jsdom synthetic events in tests).\n if (typeof window !== 'undefined' && event.source !== null && event.source !== window.parent) return;\n\n // Origin guard\n if (targetOrigin && event.origin !== targetOrigin) return;\n\n const data = event.data as Record<string, unknown>;\n if (!data || typeof data !== 'object') return;\n if (data['source'] !== BRIDGE_SOURCE_HOST) return;\n\n if (data['type'] === 'session') {\n handleSession(data as Parameters<typeof handleSession>[0]);\n }\n }\n\n if (typeof window !== 'undefined') {\n window.addEventListener('message', onMessage);\n // Announce readiness to the parent\n postToParent({ type: 'ready' });\n }\n\n function postToParent(payload: Record<string, unknown>) {\n if (typeof window !== 'undefined' && window.parent !== window) {\n window.parent.postMessage(\n { source: BRIDGE_SOURCE_IFRAME, ...payload },\n getParentTarget(),\n );\n }\n }\n\n async function apiFetch(method: string, path: string, body?: unknown): Promise<unknown> {\n // Proactively refresh if within 60s of expiry\n if (isTokenExpiringSoon()) {\n await requestRefresh();\n }\n\n const doFetch = async (token: string) => {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${token}`,\n };\n const init: RequestInit = { method, headers };\n if (body !== undefined) {\n init.body = JSON.stringify(body);\n }\n const res = await fetch(`${resolvedApiBaseUrl}${path}`, init);\n return res;\n };\n\n let res = await doFetch(currentToken!);\n\n if (res.status === 401) {\n // Token rejected — request a fresh one and retry once\n await requestRefresh();\n res = await doFetch(currentToken!);\n }\n\n if (!res.ok) {\n const text = await res.text().catch(() => '');\n throw new Error(`ThrottleBridge fetch error: ${res.status} ${res.statusText} — ${text}`);\n }\n\n return res.json();\n }\n\n const api: BridgeApiClient = {\n get: (path) => apiFetch('GET', path),\n post: (path, body) => apiFetch('POST', path, body),\n patch: (path, body) => apiFetch('PATCH', path, body),\n del: (path) => apiFetch('DELETE', path),\n };\n\n function destroy() {\n if (typeof window !== 'undefined') {\n window.removeEventListener('message', onMessage);\n }\n }\n\n return {\n ready: readyPromise,\n api,\n resize(heightPx: number) {\n postToParent({ type: 'resize', height: heightPx });\n },\n toast(message: string, level?: 'info' | 'success' | 'warning' | 'error') {\n postToParent({ type: 'toast', message, level });\n },\n navigate(path: string) {\n postToParent({ type: 'navigate', path });\n },\n getToken() {\n return currentToken;\n },\n destroy,\n };\n}\n\n// ---------------------------------------------------------------------------\n// HOST side — BridgeHost\n// ---------------------------------------------------------------------------\n\nexport interface MintTokenResult {\n token: string;\n /** ISO 8601 expiry timestamp. */\n expiresAt: string;\n context: SessionContext;\n}\n\nexport interface CreateBridgeHostOpts {\n iframe: HTMLIFrameElement;\n /** Origin of the extension iframe. Messages from other origins are ignored. */\n targetOrigin: string;\n /** Called on `ready` and `refresh` messages to produce a fresh signed token. */\n mintToken: () => Promise<MintTokenResult>;\n /**\n * Throttle API base URL to send to the iframe in the `session` message.\n * Defaults to `https://api.usethrottle.dev`.\n */\n apiBaseUrl?: string;\n /** Called when the extension requests an iframe height change. */\n onResize?: (heightPx: number) => void;\n /** Called when the extension requests a toast notification. */\n onToast?: (message: string, level?: 'info' | 'success' | 'warning' | 'error') => void;\n /** Called when the extension requests a navigation. */\n onNavigate?: (path: string) => void;\n}\n\nexport interface BridgeHost {\n /** Remove the message listener and clean up. */\n destroy(): void;\n}\n\n/**\n * Create a bridge host inside the dashboard.\n *\n * Attaches a `message` listener on `window`. Validates that messages come from\n * `opts.iframe.contentWindow` at `opts.targetOrigin`.\n */\nexport function createBridgeHost(opts: CreateBridgeHostOpts): BridgeHost {\n const {\n iframe,\n targetOrigin,\n mintToken,\n apiBaseUrl = 'https://api.usethrottle.dev',\n onResize,\n onToast,\n onNavigate,\n } = opts;\n\n async function sendSession() {\n const { token, expiresAt, context } = await mintToken();\n iframe.contentWindow?.postMessage(\n {\n source: BRIDGE_SOURCE_HOST,\n type: 'session',\n token,\n expiresAt,\n context,\n apiBaseUrl,\n },\n targetOrigin,\n );\n }\n\n async function onMessage(event: MessageEvent) {\n // Source + origin guard\n if (event.source !== iframe.contentWindow) return;\n if (event.origin !== targetOrigin) return;\n\n const data = event.data as Record<string, unknown>;\n if (!data || typeof data !== 'object') return;\n if (data['source'] !== BRIDGE_SOURCE_IFRAME) return;\n\n switch (data['type']) {\n case 'ready':\n case 'refresh':\n await sendSession();\n break;\n\n case 'resize':\n onResize?.(data['height'] as number);\n break;\n\n case 'toast':\n onToast?.(data['message'] as string, data['level'] as 'info' | 'success' | 'warning' | 'error' | undefined);\n break;\n\n case 'navigate':\n onNavigate?.(data['path'] as string);\n break;\n }\n }\n\n const boundListener = (event: Event) => {\n void onMessage(event as MessageEvent);\n };\n\n if (typeof window !== 'undefined') {\n window.addEventListener('message', boundListener);\n }\n\n return {\n destroy() {\n if (typeof window !== 'undefined') {\n window.removeEventListener('message', boundListener);\n }\n },\n };\n}\n","/**\n * @usethrottle/extension-bridge — postMessage protocol types\n *\n * Both the extension iframe and the dashboard host import from this file\n * to ensure both sides of the channel use identical message shapes.\n */\n\n/** Discriminant prefix used in all bridge message payloads. */\nexport const MESSAGE_NAMESPACE = 'throttle:bridge' as const;\n\n/** `source` field set on every message sent BY the extension iframe. */\nexport const BRIDGE_SOURCE_IFRAME = 'throttle-extension-bridge' as const;\n\n/** `source` field set on every message sent BY the dashboard host. */\nexport const BRIDGE_SOURCE_HOST = 'throttle-extension-host' as const;\n\n/** Incremented when the handshake protocol changes in a breaking way. */\nexport const BRIDGE_PROTOCOL_VERSION = 1 as const;\n\nexport interface SessionContext {\n user: { id: string; email: string };\n workspace: { id: string; slug: string };\n application: { id: string; slug: string };\n mode: 'test' | 'live';\n installationId: string;\n extensionId: string;\n version: string;\n role: string;\n scopes: string[];\n}\n\nexport interface ReadyMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'ready'; }\nexport interface RefreshMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'refresh'; }\nexport interface ResizeMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'resize'; height: number; }\nexport interface ToastMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'toast'; message: string; level?: 'info' | 'success' | 'warning' | 'error'; }\nexport interface NavigateMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'navigate'; path: string; }\nexport type IframeToHostMessage = ReadyMessage | RefreshMessage | ResizeMessage | ToastMessage | NavigateMessage;\n\nexport interface SessionMessage {\n source: typeof BRIDGE_SOURCE_HOST;\n type: 'session';\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl: string;\n}\nexport type HostToIframeMessage = SessionMessage;\n"],"mappings":"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","names":["res"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/protocol.ts"],"sourcesContent":["/**\n * @usethrottle/extension-bridge\n *\n * Runtime bridge for Throttle extensions running inside a dashboard iframe.\n *\n * Two sides:\n * - IFRAME (extension) side: `createBridge()` — handles the handshake, exposes\n * `bridge.api` for authenticated REST calls, and helpers for communicating\n * resize/toast/navigate events back to the host.\n * - HOST (dashboard) side: `createBridgeHost()` — listens for messages from the\n * iframe, calls `mintToken()` on `ready`/`refresh`, and routes UI events to\n * optional callbacks.\n *\n * WEBHOOK verification: import `verifyWebhook` from the subpath entry\n * `@usethrottle/extension-bridge/webhook` (node-only; uses `node:crypto`).\n */\n\n// Re-export protocol types + constants so dashboard host imports the SAME shapes\nexport type {\n SessionContext,\n IframeToHostMessage,\n HostToIframeMessage,\n ReadyMessage,\n RefreshMessage,\n ResizeMessage,\n ToastMessage,\n NavigateMessage,\n SessionMessage,\n} from './protocol.js';\n\nexport {\n MESSAGE_NAMESPACE,\n BRIDGE_SOURCE_IFRAME,\n BRIDGE_SOURCE_HOST,\n BRIDGE_PROTOCOL_VERSION,\n} from './protocol.js';\n\n// ---------------------------------------------------------------------------\n// Internal imports from protocol\n// ---------------------------------------------------------------------------\n\nimport {\n BRIDGE_SOURCE_IFRAME,\n BRIDGE_SOURCE_HOST,\n} from './protocol.js';\n\nimport type { SessionContext } from './protocol.js';\n\n// ---------------------------------------------------------------------------\n// IFRAME side — Bridge\n// ---------------------------------------------------------------------------\n\nexport interface BridgeApiClient {\n get(path: string): Promise<unknown>;\n post(path: string, body?: unknown): Promise<unknown>;\n patch(path: string, body?: unknown): Promise<unknown>;\n del(path: string): Promise<unknown>;\n}\n\nexport interface Bridge {\n /** Resolves when the first `session` message arrives from the host. */\n ready: Promise<SessionContext>;\n /** Typed REST client. Each method fetches with `Authorization: Bearer <token>`. */\n api: BridgeApiClient;\n /** Ask the host to resize the iframe to `heightPx` pixels. */\n resize(heightPx: number): void;\n /** Ask the host to show a toast notification. */\n toast(message: string, level?: 'info' | 'success' | 'warning' | 'error'): void;\n /** Ask the host to navigate the parent application. */\n navigate(path: string): void;\n /** Return the current session token, or null if no session has been received yet. */\n getToken(): string | null;\n /** Remove the message listener and clean up. */\n destroy(): void;\n}\n\n// Canonical spec name aliases for public consumers\nexport type ThrottleBridge = Bridge;\nexport type BridgeSessionContext = SessionContext;\nexport type CreateBridgeOptions = CreateBridgeOpts;\n\nexport interface CreateBridgeOpts {\n /**\n * Base URL for `bridge.api` calls.\n * Overridden by the `apiBaseUrl` field in the host's `session` message.\n * Defaults to `https://api.usethrottle.dev` if neither is present.\n */\n apiBaseUrl?: string;\n /**\n * Expected origin of the parent dashboard. When set, inbound messages from\n * other origins are silently ignored.\n *\n * When sending postMessage to the parent, the library targets:\n * 1. `targetOrigin` if explicitly provided.\n * 2. `document.referrer` origin if available.\n * 3. `'*'` as a last resort (logs a console.warn).\n *\n * Set this in production to prevent cross-origin token leaks.\n */\n targetOrigin?: string;\n}\n\n/**\n * Create a bridge instance inside an extension iframe.\n *\n * Call this once at startup. Immediately posts a `ready` message to the\n * parent window, then waits for the host to reply with a `session` message.\n */\nexport function createBridge(opts: CreateBridgeOpts = {}): Bridge {\n const { targetOrigin } = opts;\n // apiBaseUrl can be overridden by the session message; start with the option\n // or the default Throttle API base.\n let resolvedApiBaseUrl = opts.apiBaseUrl ?? 'https://api.usethrottle.dev';\n\n let resolveReady!: (ctx: SessionContext) => void;\n\n const readyPromise = new Promise<SessionContext>((resolve) => {\n resolveReady = resolve;\n });\n\n // Current token state\n let currentToken: string | null = null;\n let currentExpiresAt: number | null = null; // Unix seconds\n\n // Pending refresh waiters\n let refreshPromise: Promise<SessionContext> | null = null;\n let resolveRefresh: ((ctx: SessionContext) => void) | null = null;\n\n function isTokenExpiringSoon(): boolean {\n if (!currentToken || !currentExpiresAt) return true;\n return currentExpiresAt - Date.now() / 1000 < 60;\n }\n\n function requestRefresh(): Promise<SessionContext> {\n if (!refreshPromise) {\n refreshPromise = new Promise<SessionContext>((resolve) => {\n resolveRefresh = resolve;\n });\n postToParent({ type: 'refresh' });\n }\n return refreshPromise;\n }\n\n function handleSession(msg: {\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl?: string;\n }) {\n currentToken = msg.token;\n currentExpiresAt = Math.floor(new Date(msg.expiresAt).getTime() / 1000);\n\n // Host may override the API base URL\n if (msg.apiBaseUrl) {\n resolvedApiBaseUrl = msg.apiBaseUrl;\n }\n\n // Resolve the initial ready promise (idempotent — Promise ignores second resolve)\n resolveReady(msg.context);\n\n // Resolve any pending refresh waiter\n if (resolveRefresh) {\n const r = resolveRefresh;\n resolveRefresh = null;\n refreshPromise = null;\n r(msg.context);\n }\n }\n\n // Determine the target origin for outgoing postMessage calls.\n function getParentTarget(): string {\n if (targetOrigin) return targetOrigin;\n if (typeof document !== 'undefined' && document.referrer) {\n try {\n return new URL(document.referrer).origin;\n } catch {\n // ignore\n }\n }\n // Last resort — wildcard. Warn the developer.\n if (typeof console !== 'undefined') {\n console.warn(\n '[ThrottleBridge] targetOrigin not set and document.referrer unavailable; ' +\n 'posting to parent with origin=\"*\". Set `targetOrigin` in production.',\n );\n }\n return '*';\n }\n\n function onMessage(event: MessageEvent) {\n // Source-window guard: only accept messages from the direct parent.\n // Skip the check when event.source is null (e.g. jsdom synthetic events in tests).\n if (typeof window !== 'undefined' && event.source !== null && event.source !== window.parent) return;\n\n // Origin guard\n if (targetOrigin && event.origin !== targetOrigin) return;\n\n const data = event.data as Record<string, unknown>;\n if (!data || typeof data !== 'object') return;\n if (data['source'] !== BRIDGE_SOURCE_HOST) return;\n\n if (data['type'] === 'session') {\n handleSession(data as Parameters<typeof handleSession>[0]);\n }\n }\n\n if (typeof window !== 'undefined') {\n window.addEventListener('message', onMessage);\n // Announce readiness to the parent\n postToParent({ type: 'ready' });\n }\n\n function postToParent(payload: Record<string, unknown>) {\n if (typeof window !== 'undefined' && window.parent !== window) {\n window.parent.postMessage(\n { source: BRIDGE_SOURCE_IFRAME, ...payload },\n getParentTarget(),\n );\n }\n }\n\n async function apiFetch(method: string, path: string, body?: unknown): Promise<unknown> {\n // Proactively refresh if within 60s of expiry\n if (isTokenExpiringSoon()) {\n await requestRefresh();\n }\n\n const doFetch = async (token: string) => {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${token}`,\n };\n const init: RequestInit = { method, headers };\n if (body !== undefined) {\n init.body = JSON.stringify(body);\n }\n const res = await fetch(`${resolvedApiBaseUrl}${path}`, init);\n return res;\n };\n\n let res = await doFetch(currentToken!);\n\n if (res.status === 401) {\n // Token rejected — request a fresh one and retry once\n await requestRefresh();\n res = await doFetch(currentToken!);\n }\n\n if (!res.ok) {\n const text = await res.text().catch(() => '');\n throw new Error(`ThrottleBridge fetch error: ${res.status} ${res.statusText} — ${text}`);\n }\n\n return res.json();\n }\n\n const api: BridgeApiClient = {\n get: (path) => apiFetch('GET', path),\n post: (path, body) => apiFetch('POST', path, body),\n patch: (path, body) => apiFetch('PATCH', path, body),\n del: (path) => apiFetch('DELETE', path),\n };\n\n function destroy() {\n if (typeof window !== 'undefined') {\n window.removeEventListener('message', onMessage);\n }\n }\n\n return {\n ready: readyPromise,\n api,\n resize(heightPx: number) {\n postToParent({ type: 'resize', height: heightPx });\n },\n toast(message: string, level?: 'info' | 'success' | 'warning' | 'error') {\n postToParent({ type: 'toast', message, level });\n },\n navigate(path: string) {\n postToParent({ type: 'navigate', path });\n },\n getToken() {\n return currentToken;\n },\n destroy,\n };\n}\n\n// ---------------------------------------------------------------------------\n// HOST side — BridgeHost\n// ---------------------------------------------------------------------------\n\nexport interface MintTokenResult {\n token: string;\n /** ISO 8601 expiry timestamp. */\n expiresAt: string;\n context: SessionContext;\n}\n\nexport interface CreateBridgeHostOpts {\n iframe: HTMLIFrameElement;\n /** Origin of the extension iframe. Messages from other origins are ignored. */\n targetOrigin: string;\n /** Called on `ready` and `refresh` messages to produce a fresh signed token. */\n mintToken: () => Promise<MintTokenResult>;\n /**\n * Throttle API base URL to send to the iframe in the `session` message.\n * Defaults to `https://api.usethrottle.dev`.\n */\n apiBaseUrl?: string;\n /** Called when the extension requests an iframe height change. */\n onResize?: (heightPx: number) => void;\n /** Called when the extension requests a toast notification. */\n onToast?: (message: string, level?: 'info' | 'success' | 'warning' | 'error') => void;\n /** Called when the extension requests a navigation. */\n onNavigate?: (path: string) => void;\n}\n\nexport interface BridgeHost {\n /** Remove the message listener and clean up. */\n destroy(): void;\n}\n\n/**\n * Create a bridge host inside the dashboard.\n *\n * Attaches a `message` listener on `window`. Validates that messages come from\n * `opts.iframe.contentWindow` at `opts.targetOrigin`.\n */\nexport function createBridgeHost(opts: CreateBridgeHostOpts): BridgeHost {\n const {\n iframe,\n targetOrigin,\n mintToken,\n apiBaseUrl = 'https://api.usethrottle.dev',\n onResize,\n onToast,\n onNavigate,\n } = opts;\n\n async function sendSession() {\n const { token, expiresAt, context } = await mintToken();\n iframe.contentWindow?.postMessage(\n {\n source: BRIDGE_SOURCE_HOST,\n type: 'session',\n token,\n expiresAt,\n context,\n apiBaseUrl,\n },\n targetOrigin,\n );\n }\n\n async function onMessage(event: MessageEvent) {\n // Source + origin guard\n if (event.source !== iframe.contentWindow) return;\n if (event.origin !== targetOrigin) return;\n\n const data = event.data as Record<string, unknown>;\n if (!data || typeof data !== 'object') return;\n if (data['source'] !== BRIDGE_SOURCE_IFRAME) return;\n\n switch (data['type']) {\n case 'ready':\n case 'refresh':\n await sendSession();\n break;\n\n case 'resize':\n onResize?.(data['height'] as number);\n break;\n\n case 'toast':\n onToast?.(data['message'] as string, data['level'] as 'info' | 'success' | 'warning' | 'error' | undefined);\n break;\n\n case 'navigate':\n onNavigate?.(data['path'] as string);\n break;\n }\n }\n\n const boundListener = (event: Event) => {\n void onMessage(event as MessageEvent);\n };\n\n if (typeof window !== 'undefined') {\n window.addEventListener('message', boundListener);\n }\n\n return {\n destroy() {\n if (typeof window !== 'undefined') {\n window.removeEventListener('message', boundListener);\n }\n },\n };\n}\n","/**\n * @usethrottle/extension-bridge — postMessage protocol types\n *\n * Both the extension iframe and the dashboard host import from this file\n * to ensure both sides of the channel use identical message shapes.\n */\n\n/** Discriminant prefix used in all bridge message payloads. */\nexport const MESSAGE_NAMESPACE = 'throttle:bridge' as const;\n\n/** `source` field set on every message sent BY the extension iframe. */\nexport const BRIDGE_SOURCE_IFRAME = 'throttle-extension-bridge' as const;\n\n/** `source` field set on every message sent BY the dashboard host. */\nexport const BRIDGE_SOURCE_HOST = 'throttle-extension-host' as const;\n\n/** Incremented when the handshake protocol changes in a breaking way. */\nexport const BRIDGE_PROTOCOL_VERSION = 1 as const;\n\nexport interface SessionContext {\n user: { id: string; email: string };\n workspace: { id: string; slug: string };\n application: { id: string; slug: string };\n environment: {\n environmentId: string;\n environmentSlug: string;\n environmentKind: 'production' | 'non_production';\n providerEnvironment: 'production' | 'sandbox';\n };\n installationId: string;\n extensionId: string;\n version: string;\n role: string;\n scopes: string[];\n}\n\nexport interface ReadyMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'ready'; }\nexport interface RefreshMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'refresh'; }\nexport interface ResizeMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'resize'; height: number; }\nexport interface ToastMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'toast'; message: string; level?: 'info' | 'success' | 'warning' | 'error'; }\nexport interface NavigateMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'navigate'; path: string; }\nexport type IframeToHostMessage = ReadyMessage | RefreshMessage | ResizeMessage | ToastMessage | NavigateMessage;\n\nexport interface SessionMessage {\n source: typeof BRIDGE_SOURCE_HOST;\n type: 'session';\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl: string;\n}\nexport type HostToIframeMessage = SessionMessage;\n"],"mappings":"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","names":["res"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/protocol.ts"],"sourcesContent":["/**\n * @usethrottle/extension-bridge\n *\n * Runtime bridge for Throttle extensions running inside a dashboard iframe.\n *\n * Two sides:\n * - IFRAME (extension) side: `createBridge()` — handles the handshake, exposes\n * `bridge.api` for authenticated REST calls, and helpers for communicating\n * resize/toast/navigate events back to the host.\n * - HOST (dashboard) side: `createBridgeHost()` — listens for messages from the\n * iframe, calls `mintToken()` on `ready`/`refresh`, and routes UI events to\n * optional callbacks.\n *\n * WEBHOOK verification: import `verifyWebhook` from the subpath entry\n * `@usethrottle/extension-bridge/webhook` (node-only; uses `node:crypto`).\n */\n\n// Re-export protocol types + constants so dashboard host imports the SAME shapes\nexport type {\n SessionContext,\n IframeToHostMessage,\n HostToIframeMessage,\n ReadyMessage,\n RefreshMessage,\n ResizeMessage,\n ToastMessage,\n NavigateMessage,\n SessionMessage,\n} from './protocol.js';\n\nexport {\n MESSAGE_NAMESPACE,\n BRIDGE_SOURCE_IFRAME,\n BRIDGE_SOURCE_HOST,\n BRIDGE_PROTOCOL_VERSION,\n} from './protocol.js';\n\n// ---------------------------------------------------------------------------\n// Internal imports from protocol\n// ---------------------------------------------------------------------------\n\nimport {\n BRIDGE_SOURCE_IFRAME,\n BRIDGE_SOURCE_HOST,\n} from './protocol.js';\n\nimport type { SessionContext } from './protocol.js';\n\n// ---------------------------------------------------------------------------\n// IFRAME side — Bridge\n// ---------------------------------------------------------------------------\n\nexport interface BridgeApiClient {\n get(path: string): Promise<unknown>;\n post(path: string, body?: unknown): Promise<unknown>;\n patch(path: string, body?: unknown): Promise<unknown>;\n del(path: string): Promise<unknown>;\n}\n\nexport interface Bridge {\n /** Resolves when the first `session` message arrives from the host. */\n ready: Promise<SessionContext>;\n /** Typed REST client. Each method fetches with `Authorization: Bearer <token>`. */\n api: BridgeApiClient;\n /** Ask the host to resize the iframe to `heightPx` pixels. */\n resize(heightPx: number): void;\n /** Ask the host to show a toast notification. */\n toast(message: string, level?: 'info' | 'success' | 'warning' | 'error'): void;\n /** Ask the host to navigate the parent application. */\n navigate(path: string): void;\n /** Return the current session token, or null if no session has been received yet. */\n getToken(): string | null;\n /** Remove the message listener and clean up. */\n destroy(): void;\n}\n\n// Canonical spec name aliases for public consumers\nexport type ThrottleBridge = Bridge;\nexport type BridgeSessionContext = SessionContext;\nexport type CreateBridgeOptions = CreateBridgeOpts;\n\nexport interface CreateBridgeOpts {\n /**\n * Base URL for `bridge.api` calls.\n * Overridden by the `apiBaseUrl` field in the host's `session` message.\n * Defaults to `https://api.usethrottle.dev` if neither is present.\n */\n apiBaseUrl?: string;\n /**\n * Expected origin of the parent dashboard. When set, inbound messages from\n * other origins are silently ignored.\n *\n * When sending postMessage to the parent, the library targets:\n * 1. `targetOrigin` if explicitly provided.\n * 2. `document.referrer` origin if available.\n * 3. `'*'` as a last resort (logs a console.warn).\n *\n * Set this in production to prevent cross-origin token leaks.\n */\n targetOrigin?: string;\n}\n\n/**\n * Create a bridge instance inside an extension iframe.\n *\n * Call this once at startup. Immediately posts a `ready` message to the\n * parent window, then waits for the host to reply with a `session` message.\n */\nexport function createBridge(opts: CreateBridgeOpts = {}): Bridge {\n const { targetOrigin } = opts;\n // apiBaseUrl can be overridden by the session message; start with the option\n // or the default Throttle API base.\n let resolvedApiBaseUrl = opts.apiBaseUrl ?? 'https://api.usethrottle.dev';\n\n let resolveReady!: (ctx: SessionContext) => void;\n\n const readyPromise = new Promise<SessionContext>((resolve) => {\n resolveReady = resolve;\n });\n\n // Current token state\n let currentToken: string | null = null;\n let currentExpiresAt: number | null = null; // Unix seconds\n\n // Pending refresh waiters\n let refreshPromise: Promise<SessionContext> | null = null;\n let resolveRefresh: ((ctx: SessionContext) => void) | null = null;\n\n function isTokenExpiringSoon(): boolean {\n if (!currentToken || !currentExpiresAt) return true;\n return currentExpiresAt - Date.now() / 1000 < 60;\n }\n\n function requestRefresh(): Promise<SessionContext> {\n if (!refreshPromise) {\n refreshPromise = new Promise<SessionContext>((resolve) => {\n resolveRefresh = resolve;\n });\n postToParent({ type: 'refresh' });\n }\n return refreshPromise;\n }\n\n function handleSession(msg: {\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl?: string;\n }) {\n currentToken = msg.token;\n currentExpiresAt = Math.floor(new Date(msg.expiresAt).getTime() / 1000);\n\n // Host may override the API base URL\n if (msg.apiBaseUrl) {\n resolvedApiBaseUrl = msg.apiBaseUrl;\n }\n\n // Resolve the initial ready promise (idempotent — Promise ignores second resolve)\n resolveReady(msg.context);\n\n // Resolve any pending refresh waiter\n if (resolveRefresh) {\n const r = resolveRefresh;\n resolveRefresh = null;\n refreshPromise = null;\n r(msg.context);\n }\n }\n\n // Determine the target origin for outgoing postMessage calls.\n function getParentTarget(): string {\n if (targetOrigin) return targetOrigin;\n if (typeof document !== 'undefined' && document.referrer) {\n try {\n return new URL(document.referrer).origin;\n } catch {\n // ignore\n }\n }\n // Last resort — wildcard. Warn the developer.\n if (typeof console !== 'undefined') {\n console.warn(\n '[ThrottleBridge] targetOrigin not set and document.referrer unavailable; ' +\n 'posting to parent with origin=\"*\". Set `targetOrigin` in production.',\n );\n }\n return '*';\n }\n\n function onMessage(event: MessageEvent) {\n // Source-window guard: only accept messages from the direct parent.\n // Skip the check when event.source is null (e.g. jsdom synthetic events in tests).\n if (typeof window !== 'undefined' && event.source !== null && event.source !== window.parent) return;\n\n // Origin guard\n if (targetOrigin && event.origin !== targetOrigin) return;\n\n const data = event.data as Record<string, unknown>;\n if (!data || typeof data !== 'object') return;\n if (data['source'] !== BRIDGE_SOURCE_HOST) return;\n\n if (data['type'] === 'session') {\n handleSession(data as Parameters<typeof handleSession>[0]);\n }\n }\n\n if (typeof window !== 'undefined') {\n window.addEventListener('message', onMessage);\n // Announce readiness to the parent\n postToParent({ type: 'ready' });\n }\n\n function postToParent(payload: Record<string, unknown>) {\n if (typeof window !== 'undefined' && window.parent !== window) {\n window.parent.postMessage(\n { source: BRIDGE_SOURCE_IFRAME, ...payload },\n getParentTarget(),\n );\n }\n }\n\n async function apiFetch(method: string, path: string, body?: unknown): Promise<unknown> {\n // Proactively refresh if within 60s of expiry\n if (isTokenExpiringSoon()) {\n await requestRefresh();\n }\n\n const doFetch = async (token: string) => {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${token}`,\n };\n const init: RequestInit = { method, headers };\n if (body !== undefined) {\n init.body = JSON.stringify(body);\n }\n const res = await fetch(`${resolvedApiBaseUrl}${path}`, init);\n return res;\n };\n\n let res = await doFetch(currentToken!);\n\n if (res.status === 401) {\n // Token rejected — request a fresh one and retry once\n await requestRefresh();\n res = await doFetch(currentToken!);\n }\n\n if (!res.ok) {\n const text = await res.text().catch(() => '');\n throw new Error(`ThrottleBridge fetch error: ${res.status} ${res.statusText} — ${text}`);\n }\n\n return res.json();\n }\n\n const api: BridgeApiClient = {\n get: (path) => apiFetch('GET', path),\n post: (path, body) => apiFetch('POST', path, body),\n patch: (path, body) => apiFetch('PATCH', path, body),\n del: (path) => apiFetch('DELETE', path),\n };\n\n function destroy() {\n if (typeof window !== 'undefined') {\n window.removeEventListener('message', onMessage);\n }\n }\n\n return {\n ready: readyPromise,\n api,\n resize(heightPx: number) {\n postToParent({ type: 'resize', height: heightPx });\n },\n toast(message: string, level?: 'info' | 'success' | 'warning' | 'error') {\n postToParent({ type: 'toast', message, level });\n },\n navigate(path: string) {\n postToParent({ type: 'navigate', path });\n },\n getToken() {\n return currentToken;\n },\n destroy,\n };\n}\n\n// ---------------------------------------------------------------------------\n// HOST side — BridgeHost\n// ---------------------------------------------------------------------------\n\nexport interface MintTokenResult {\n token: string;\n /** ISO 8601 expiry timestamp. */\n expiresAt: string;\n context: SessionContext;\n}\n\nexport interface CreateBridgeHostOpts {\n iframe: HTMLIFrameElement;\n /** Origin of the extension iframe. Messages from other origins are ignored. */\n targetOrigin: string;\n /** Called on `ready` and `refresh` messages to produce a fresh signed token. */\n mintToken: () => Promise<MintTokenResult>;\n /**\n * Throttle API base URL to send to the iframe in the `session` message.\n * Defaults to `https://api.usethrottle.dev`.\n */\n apiBaseUrl?: string;\n /** Called when the extension requests an iframe height change. */\n onResize?: (heightPx: number) => void;\n /** Called when the extension requests a toast notification. */\n onToast?: (message: string, level?: 'info' | 'success' | 'warning' | 'error') => void;\n /** Called when the extension requests a navigation. */\n onNavigate?: (path: string) => void;\n}\n\nexport interface BridgeHost {\n /** Remove the message listener and clean up. */\n destroy(): void;\n}\n\n/**\n * Create a bridge host inside the dashboard.\n *\n * Attaches a `message` listener on `window`. Validates that messages come from\n * `opts.iframe.contentWindow` at `opts.targetOrigin`.\n */\nexport function createBridgeHost(opts: CreateBridgeHostOpts): BridgeHost {\n const {\n iframe,\n targetOrigin,\n mintToken,\n apiBaseUrl = 'https://api.usethrottle.dev',\n onResize,\n onToast,\n onNavigate,\n } = opts;\n\n async function sendSession() {\n const { token, expiresAt, context } = await mintToken();\n iframe.contentWindow?.postMessage(\n {\n source: BRIDGE_SOURCE_HOST,\n type: 'session',\n token,\n expiresAt,\n context,\n apiBaseUrl,\n },\n targetOrigin,\n );\n }\n\n async function onMessage(event: MessageEvent) {\n // Source + origin guard\n if (event.source !== iframe.contentWindow) return;\n if (event.origin !== targetOrigin) return;\n\n const data = event.data as Record<string, unknown>;\n if (!data || typeof data !== 'object') return;\n if (data['source'] !== BRIDGE_SOURCE_IFRAME) return;\n\n switch (data['type']) {\n case 'ready':\n case 'refresh':\n await sendSession();\n break;\n\n case 'resize':\n onResize?.(data['height'] as number);\n break;\n\n case 'toast':\n onToast?.(data['message'] as string, data['level'] as 'info' | 'success' | 'warning' | 'error' | undefined);\n break;\n\n case 'navigate':\n onNavigate?.(data['path'] as string);\n break;\n }\n }\n\n const boundListener = (event: Event) => {\n void onMessage(event as MessageEvent);\n };\n\n if (typeof window !== 'undefined') {\n window.addEventListener('message', boundListener);\n }\n\n return {\n destroy() {\n if (typeof window !== 'undefined') {\n window.removeEventListener('message', boundListener);\n }\n },\n };\n}\n","/**\n * @usethrottle/extension-bridge — postMessage protocol types\n *\n * Both the extension iframe and the dashboard host import from this file\n * to ensure both sides of the channel use identical message shapes.\n */\n\n/** Discriminant prefix used in all bridge message payloads. */\nexport const MESSAGE_NAMESPACE = 'throttle:bridge' as const;\n\n/** `source` field set on every message sent BY the extension iframe. */\nexport const BRIDGE_SOURCE_IFRAME = 'throttle-extension-bridge' as const;\n\n/** `source` field set on every message sent BY the dashboard host. */\nexport const BRIDGE_SOURCE_HOST = 'throttle-extension-host' as const;\n\n/** Incremented when the handshake protocol changes in a breaking way. */\nexport const BRIDGE_PROTOCOL_VERSION = 1 as const;\n\nexport interface SessionContext {\n user: { id: string; email: string };\n workspace: { id: string; slug: string };\n application: { id: string; slug: string };\n mode: 'test' | 'live';\n installationId: string;\n extensionId: string;\n version: string;\n role: string;\n scopes: string[];\n}\n\nexport interface ReadyMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'ready'; }\nexport interface RefreshMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'refresh'; }\nexport interface ResizeMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'resize'; height: number; }\nexport interface ToastMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'toast'; message: string; level?: 'info' | 'success' | 'warning' | 'error'; }\nexport interface NavigateMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'navigate'; path: string; }\nexport type IframeToHostMessage = ReadyMessage | RefreshMessage | ResizeMessage | ToastMessage | NavigateMessage;\n\nexport interface SessionMessage {\n source: typeof BRIDGE_SOURCE_HOST;\n type: 'session';\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl: string;\n}\nexport type HostToIframeMessage = SessionMessage;\n"],"mappings":"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","names":["res"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/protocol.ts"],"sourcesContent":["/**\n * @usethrottle/extension-bridge\n *\n * Runtime bridge for Throttle extensions running inside a dashboard iframe.\n *\n * Two sides:\n * - IFRAME (extension) side: `createBridge()` — handles the handshake, exposes\n * `bridge.api` for authenticated REST calls, and helpers for communicating\n * resize/toast/navigate events back to the host.\n * - HOST (dashboard) side: `createBridgeHost()` — listens for messages from the\n * iframe, calls `mintToken()` on `ready`/`refresh`, and routes UI events to\n * optional callbacks.\n *\n * WEBHOOK verification: import `verifyWebhook` from the subpath entry\n * `@usethrottle/extension-bridge/webhook` (node-only; uses `node:crypto`).\n */\n\n// Re-export protocol types + constants so dashboard host imports the SAME shapes\nexport type {\n SessionContext,\n IframeToHostMessage,\n HostToIframeMessage,\n ReadyMessage,\n RefreshMessage,\n ResizeMessage,\n ToastMessage,\n NavigateMessage,\n SessionMessage,\n} from './protocol.js';\n\nexport {\n MESSAGE_NAMESPACE,\n BRIDGE_SOURCE_IFRAME,\n BRIDGE_SOURCE_HOST,\n BRIDGE_PROTOCOL_VERSION,\n} from './protocol.js';\n\n// ---------------------------------------------------------------------------\n// Internal imports from protocol\n// ---------------------------------------------------------------------------\n\nimport {\n BRIDGE_SOURCE_IFRAME,\n BRIDGE_SOURCE_HOST,\n} from './protocol.js';\n\nimport type { SessionContext } from './protocol.js';\n\n// ---------------------------------------------------------------------------\n// IFRAME side — Bridge\n// ---------------------------------------------------------------------------\n\nexport interface BridgeApiClient {\n get(path: string): Promise<unknown>;\n post(path: string, body?: unknown): Promise<unknown>;\n patch(path: string, body?: unknown): Promise<unknown>;\n del(path: string): Promise<unknown>;\n}\n\nexport interface Bridge {\n /** Resolves when the first `session` message arrives from the host. */\n ready: Promise<SessionContext>;\n /** Typed REST client. Each method fetches with `Authorization: Bearer <token>`. */\n api: BridgeApiClient;\n /** Ask the host to resize the iframe to `heightPx` pixels. */\n resize(heightPx: number): void;\n /** Ask the host to show a toast notification. */\n toast(message: string, level?: 'info' | 'success' | 'warning' | 'error'): void;\n /** Ask the host to navigate the parent application. */\n navigate(path: string): void;\n /** Return the current session token, or null if no session has been received yet. */\n getToken(): string | null;\n /** Remove the message listener and clean up. */\n destroy(): void;\n}\n\n// Canonical spec name aliases for public consumers\nexport type ThrottleBridge = Bridge;\nexport type BridgeSessionContext = SessionContext;\nexport type CreateBridgeOptions = CreateBridgeOpts;\n\nexport interface CreateBridgeOpts {\n /**\n * Base URL for `bridge.api` calls.\n * Overridden by the `apiBaseUrl` field in the host's `session` message.\n * Defaults to `https://api.usethrottle.dev` if neither is present.\n */\n apiBaseUrl?: string;\n /**\n * Expected origin of the parent dashboard. When set, inbound messages from\n * other origins are silently ignored.\n *\n * When sending postMessage to the parent, the library targets:\n * 1. `targetOrigin` if explicitly provided.\n * 2. `document.referrer` origin if available.\n * 3. `'*'` as a last resort (logs a console.warn).\n *\n * Set this in production to prevent cross-origin token leaks.\n */\n targetOrigin?: string;\n}\n\n/**\n * Create a bridge instance inside an extension iframe.\n *\n * Call this once at startup. Immediately posts a `ready` message to the\n * parent window, then waits for the host to reply with a `session` message.\n */\nexport function createBridge(opts: CreateBridgeOpts = {}): Bridge {\n const { targetOrigin } = opts;\n // apiBaseUrl can be overridden by the session message; start with the option\n // or the default Throttle API base.\n let resolvedApiBaseUrl = opts.apiBaseUrl ?? 'https://api.usethrottle.dev';\n\n let resolveReady!: (ctx: SessionContext) => void;\n\n const readyPromise = new Promise<SessionContext>((resolve) => {\n resolveReady = resolve;\n });\n\n // Current token state\n let currentToken: string | null = null;\n let currentExpiresAt: number | null = null; // Unix seconds\n\n // Pending refresh waiters\n let refreshPromise: Promise<SessionContext> | null = null;\n let resolveRefresh: ((ctx: SessionContext) => void) | null = null;\n\n function isTokenExpiringSoon(): boolean {\n if (!currentToken || !currentExpiresAt) return true;\n return currentExpiresAt - Date.now() / 1000 < 60;\n }\n\n function requestRefresh(): Promise<SessionContext> {\n if (!refreshPromise) {\n refreshPromise = new Promise<SessionContext>((resolve) => {\n resolveRefresh = resolve;\n });\n postToParent({ type: 'refresh' });\n }\n return refreshPromise;\n }\n\n function handleSession(msg: {\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl?: string;\n }) {\n currentToken = msg.token;\n currentExpiresAt = Math.floor(new Date(msg.expiresAt).getTime() / 1000);\n\n // Host may override the API base URL\n if (msg.apiBaseUrl) {\n resolvedApiBaseUrl = msg.apiBaseUrl;\n }\n\n // Resolve the initial ready promise (idempotent — Promise ignores second resolve)\n resolveReady(msg.context);\n\n // Resolve any pending refresh waiter\n if (resolveRefresh) {\n const r = resolveRefresh;\n resolveRefresh = null;\n refreshPromise = null;\n r(msg.context);\n }\n }\n\n // Determine the target origin for outgoing postMessage calls.\n function getParentTarget(): string {\n if (targetOrigin) return targetOrigin;\n if (typeof document !== 'undefined' && document.referrer) {\n try {\n return new URL(document.referrer).origin;\n } catch {\n // ignore\n }\n }\n // Last resort — wildcard. Warn the developer.\n if (typeof console !== 'undefined') {\n console.warn(\n '[ThrottleBridge] targetOrigin not set and document.referrer unavailable; ' +\n 'posting to parent with origin=\"*\". Set `targetOrigin` in production.',\n );\n }\n return '*';\n }\n\n function onMessage(event: MessageEvent) {\n // Source-window guard: only accept messages from the direct parent.\n // Skip the check when event.source is null (e.g. jsdom synthetic events in tests).\n if (typeof window !== 'undefined' && event.source !== null && event.source !== window.parent) return;\n\n // Origin guard\n if (targetOrigin && event.origin !== targetOrigin) return;\n\n const data = event.data as Record<string, unknown>;\n if (!data || typeof data !== 'object') return;\n if (data['source'] !== BRIDGE_SOURCE_HOST) return;\n\n if (data['type'] === 'session') {\n handleSession(data as Parameters<typeof handleSession>[0]);\n }\n }\n\n if (typeof window !== 'undefined') {\n window.addEventListener('message', onMessage);\n // Announce readiness to the parent\n postToParent({ type: 'ready' });\n }\n\n function postToParent(payload: Record<string, unknown>) {\n if (typeof window !== 'undefined' && window.parent !== window) {\n window.parent.postMessage(\n { source: BRIDGE_SOURCE_IFRAME, ...payload },\n getParentTarget(),\n );\n }\n }\n\n async function apiFetch(method: string, path: string, body?: unknown): Promise<unknown> {\n // Proactively refresh if within 60s of expiry\n if (isTokenExpiringSoon()) {\n await requestRefresh();\n }\n\n const doFetch = async (token: string) => {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${token}`,\n };\n const init: RequestInit = { method, headers };\n if (body !== undefined) {\n init.body = JSON.stringify(body);\n }\n const res = await fetch(`${resolvedApiBaseUrl}${path}`, init);\n return res;\n };\n\n let res = await doFetch(currentToken!);\n\n if (res.status === 401) {\n // Token rejected — request a fresh one and retry once\n await requestRefresh();\n res = await doFetch(currentToken!);\n }\n\n if (!res.ok) {\n const text = await res.text().catch(() => '');\n throw new Error(`ThrottleBridge fetch error: ${res.status} ${res.statusText} — ${text}`);\n }\n\n return res.json();\n }\n\n const api: BridgeApiClient = {\n get: (path) => apiFetch('GET', path),\n post: (path, body) => apiFetch('POST', path, body),\n patch: (path, body) => apiFetch('PATCH', path, body),\n del: (path) => apiFetch('DELETE', path),\n };\n\n function destroy() {\n if (typeof window !== 'undefined') {\n window.removeEventListener('message', onMessage);\n }\n }\n\n return {\n ready: readyPromise,\n api,\n resize(heightPx: number) {\n postToParent({ type: 'resize', height: heightPx });\n },\n toast(message: string, level?: 'info' | 'success' | 'warning' | 'error') {\n postToParent({ type: 'toast', message, level });\n },\n navigate(path: string) {\n postToParent({ type: 'navigate', path });\n },\n getToken() {\n return currentToken;\n },\n destroy,\n };\n}\n\n// ---------------------------------------------------------------------------\n// HOST side — BridgeHost\n// ---------------------------------------------------------------------------\n\nexport interface MintTokenResult {\n token: string;\n /** ISO 8601 expiry timestamp. */\n expiresAt: string;\n context: SessionContext;\n}\n\nexport interface CreateBridgeHostOpts {\n iframe: HTMLIFrameElement;\n /** Origin of the extension iframe. Messages from other origins are ignored. */\n targetOrigin: string;\n /** Called on `ready` and `refresh` messages to produce a fresh signed token. */\n mintToken: () => Promise<MintTokenResult>;\n /**\n * Throttle API base URL to send to the iframe in the `session` message.\n * Defaults to `https://api.usethrottle.dev`.\n */\n apiBaseUrl?: string;\n /** Called when the extension requests an iframe height change. */\n onResize?: (heightPx: number) => void;\n /** Called when the extension requests a toast notification. */\n onToast?: (message: string, level?: 'info' | 'success' | 'warning' | 'error') => void;\n /** Called when the extension requests a navigation. */\n onNavigate?: (path: string) => void;\n}\n\nexport interface BridgeHost {\n /** Remove the message listener and clean up. */\n destroy(): void;\n}\n\n/**\n * Create a bridge host inside the dashboard.\n *\n * Attaches a `message` listener on `window`. Validates that messages come from\n * `opts.iframe.contentWindow` at `opts.targetOrigin`.\n */\nexport function createBridgeHost(opts: CreateBridgeHostOpts): BridgeHost {\n const {\n iframe,\n targetOrigin,\n mintToken,\n apiBaseUrl = 'https://api.usethrottle.dev',\n onResize,\n onToast,\n onNavigate,\n } = opts;\n\n async function sendSession() {\n const { token, expiresAt, context } = await mintToken();\n iframe.contentWindow?.postMessage(\n {\n source: BRIDGE_SOURCE_HOST,\n type: 'session',\n token,\n expiresAt,\n context,\n apiBaseUrl,\n },\n targetOrigin,\n );\n }\n\n async function onMessage(event: MessageEvent) {\n // Source + origin guard\n if (event.source !== iframe.contentWindow) return;\n if (event.origin !== targetOrigin) return;\n\n const data = event.data as Record<string, unknown>;\n if (!data || typeof data !== 'object') return;\n if (data['source'] !== BRIDGE_SOURCE_IFRAME) return;\n\n switch (data['type']) {\n case 'ready':\n case 'refresh':\n await sendSession();\n break;\n\n case 'resize':\n onResize?.(data['height'] as number);\n break;\n\n case 'toast':\n onToast?.(data['message'] as string, data['level'] as 'info' | 'success' | 'warning' | 'error' | undefined);\n break;\n\n case 'navigate':\n onNavigate?.(data['path'] as string);\n break;\n }\n }\n\n const boundListener = (event: Event) => {\n void onMessage(event as MessageEvent);\n };\n\n if (typeof window !== 'undefined') {\n window.addEventListener('message', boundListener);\n }\n\n return {\n destroy() {\n if (typeof window !== 'undefined') {\n window.removeEventListener('message', boundListener);\n }\n },\n };\n}\n","/**\n * @usethrottle/extension-bridge — postMessage protocol types\n *\n * Both the extension iframe and the dashboard host import from this file\n * to ensure both sides of the channel use identical message shapes.\n */\n\n/** Discriminant prefix used in all bridge message payloads. */\nexport const MESSAGE_NAMESPACE = 'throttle:bridge' as const;\n\n/** `source` field set on every message sent BY the extension iframe. */\nexport const BRIDGE_SOURCE_IFRAME = 'throttle-extension-bridge' as const;\n\n/** `source` field set on every message sent BY the dashboard host. */\nexport const BRIDGE_SOURCE_HOST = 'throttle-extension-host' as const;\n\n/** Incremented when the handshake protocol changes in a breaking way. */\nexport const BRIDGE_PROTOCOL_VERSION = 1 as const;\n\nexport interface SessionContext {\n user: { id: string; email: string };\n workspace: { id: string; slug: string };\n application: { id: string; slug: string };\n environment: {\n environmentId: string;\n environmentSlug: string;\n environmentKind: 'production' | 'non_production';\n providerEnvironment: 'production' | 'sandbox';\n };\n installationId: string;\n extensionId: string;\n version: string;\n role: string;\n scopes: string[];\n}\n\nexport interface ReadyMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'ready'; }\nexport interface RefreshMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'refresh'; }\nexport interface ResizeMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'resize'; height: number; }\nexport interface ToastMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'toast'; message: string; level?: 'info' | 'success' | 'warning' | 'error'; }\nexport interface NavigateMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'navigate'; path: string; }\nexport type IframeToHostMessage = ReadyMessage | RefreshMessage | ResizeMessage | ToastMessage | NavigateMessage;\n\nexport interface SessionMessage {\n source: typeof BRIDGE_SOURCE_HOST;\n type: 'session';\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl: string;\n}\nexport type HostToIframeMessage = SessionMessage;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACQO,IAAM,oBAAoB;AAG1B,IAAM,uBAAuB;AAG7B,IAAM,qBAAqB;AAG3B,IAAM,0BAA0B;;;AD2FhC,SAAS,aAAa,OAAyB,CAAC,GAAW;AAChE,QAAM,EAAE,aAAa,IAAI;AAGzB,MAAI,qBAAqB,KAAK,cAAc;AAE5C,MAAI;AAEJ,QAAM,eAAe,IAAI,QAAwB,CAAC,YAAY;AAC5D,mBAAe;AAAA,EACjB,CAAC;AAGD,MAAI,eAA8B;AAClC,MAAI,mBAAkC;AAGtC,MAAI,iBAAiD;AACrD,MAAI,iBAAyD;AAE7D,WAAS,sBAA+B;AACtC,QAAI,CAAC,gBAAgB,CAAC,iBAAkB,QAAO;AAC/C,WAAO,mBAAmB,KAAK,IAAI,IAAI,MAAO;AAAA,EAChD;AAEA,WAAS,iBAA0C;AACjD,QAAI,CAAC,gBAAgB;AACnB,uBAAiB,IAAI,QAAwB,CAAC,YAAY;AACxD,yBAAiB;AAAA,MACnB,CAAC;AACD,mBAAa,EAAE,MAAM,UAAU,CAAC;AAAA,IAClC;AACA,WAAO;AAAA,EACT;AAEA,WAAS,cAAc,KAKpB;AACD,mBAAe,IAAI;AACnB,uBAAmB,KAAK,MAAM,IAAI,KAAK,IAAI,SAAS,EAAE,QAAQ,IAAI,GAAI;AAGtE,QAAI,IAAI,YAAY;AAClB,2BAAqB,IAAI;AAAA,IAC3B;AAGA,iBAAa,IAAI,OAAO;AAGxB,QAAI,gBAAgB;AAClB,YAAM,IAAI;AACV,uBAAiB;AACjB,uBAAiB;AACjB,QAAE,IAAI,OAAO;AAAA,IACf;AAAA,EACF;AAGA,WAAS,kBAA0B;AACjC,QAAI,aAAc,QAAO;AACzB,QAAI,OAAO,aAAa,eAAe,SAAS,UAAU;AACxD,UAAI;AACF,eAAO,IAAI,IAAI,SAAS,QAAQ,EAAE;AAAA,MACpC,QAAQ;AAAA,MAER;AAAA,IACF;AAEA,QAAI,OAAO,YAAY,aAAa;AAClC,cAAQ;AAAA,QACN;AAAA,MAEF;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,WAAS,UAAU,OAAqB;AAGtC,QAAI,OAAO,WAAW,eAAe,MAAM,WAAW,QAAQ,MAAM,WAAW,OAAO,OAAQ;AAG9F,QAAI,gBAAgB,MAAM,WAAW,aAAc;AAEnD,UAAM,OAAO,MAAM;AACnB,QAAI,CAAC,QAAQ,OAAO,SAAS,SAAU;AACvC,QAAI,KAAK,QAAQ,MAAM,mBAAoB;AAE3C,QAAI,KAAK,MAAM,MAAM,WAAW;AAC9B,oBAAc,IAA2C;AAAA,IAC3D;AAAA,EACF;AAEA,MAAI,OAAO,WAAW,aAAa;AACjC,WAAO,iBAAiB,WAAW,SAAS;AAE5C,iBAAa,EAAE,MAAM,QAAQ,CAAC;AAAA,EAChC;AAEA,WAAS,aAAa,SAAkC;AACtD,QAAI,OAAO,WAAW,eAAe,OAAO,WAAW,QAAQ;AAC7D,aAAO,OAAO;AAAA,QACZ,EAAE,QAAQ,sBAAsB,GAAG,QAAQ;AAAA,QAC3C,gBAAgB;AAAA,MAClB;AAAA,IACF;AAAA,EACF;AAEA,iBAAe,SAAS,QAAgB,MAAc,MAAkC;AAEtF,QAAI,oBAAoB,GAAG;AACzB,YAAM,eAAe;AAAA,IACvB;AAEA,UAAM,UAAU,OAAO,UAAkB;AACvC,YAAM,UAAkC;AAAA,QACtC,gBAAgB;AAAA,QAChB,eAAe,UAAU,KAAK;AAAA,MAChC;AACA,YAAM,OAAoB,EAAE,QAAQ,QAAQ;AAC5C,UAAI,SAAS,QAAW;AACtB,aAAK,OAAO,KAAK,UAAU,IAAI;AAAA,MACjC;AACA,YAAMA,OAAM,MAAM,MAAM,GAAG,kBAAkB,GAAG,IAAI,IAAI,IAAI;AAC5D,aAAOA;AAAA,IACT;AAEA,QAAI,MAAM,MAAM,QAAQ,YAAa;AAErC,QAAI,IAAI,WAAW,KAAK;AAEtB,YAAM,eAAe;AACrB,YAAM,MAAM,QAAQ,YAAa;AAAA,IACnC;AAEA,QAAI,CAAC,IAAI,IAAI;AACX,YAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,EAAE;AAC5C,YAAM,IAAI,MAAM,+BAA+B,IAAI,MAAM,IAAI,IAAI,UAAU,WAAM,IAAI,EAAE;AAAA,IACzF;AAEA,WAAO,IAAI,KAAK;AAAA,EAClB;AAEA,QAAM,MAAuB;AAAA,IAC3B,KAAK,CAAC,SAAS,SAAS,OAAO,IAAI;AAAA,IACnC,MAAM,CAAC,MAAM,SAAS,SAAS,QAAQ,MAAM,IAAI;AAAA,IACjD,OAAO,CAAC,MAAM,SAAS,SAAS,SAAS,MAAM,IAAI;AAAA,IACnD,KAAK,CAAC,SAAS,SAAS,UAAU,IAAI;AAAA,EACxC;AAEA,WAAS,UAAU;AACjB,QAAI,OAAO,WAAW,aAAa;AACjC,aAAO,oBAAoB,WAAW,SAAS;AAAA,IACjD;AAAA,EACF;AAEA,SAAO;AAAA,IACL,OAAO;AAAA,IACP;AAAA,IACA,OAAO,UAAkB;AACvB,mBAAa,EAAE,MAAM,UAAU,QAAQ,SAAS,CAAC;AAAA,IACnD;AAAA,IACA,MAAM,SAAiB,OAAkD;AACvE,mBAAa,EAAE,MAAM,SAAS,SAAS,MAAM,CAAC;AAAA,IAChD;AAAA,IACA,SAAS,MAAc;AACrB,mBAAa,EAAE,MAAM,YAAY,KAAK,CAAC;AAAA,IACzC;AAAA,IACA,WAAW;AACT,aAAO;AAAA,IACT;AAAA,IACA;AAAA,EACF;AACF;AA2CO,SAAS,iBAAiB,MAAwC;AACvE,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA,aAAa;AAAA,IACb;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI;AAEJ,iBAAe,cAAc;AAC3B,UAAM,EAAE,OAAO,WAAW,QAAQ,IAAI,MAAM,UAAU;AACtD,WAAO,eAAe;AAAA,MACpB;AAAA,QACE,QAAQ;AAAA,QACR,MAAM;AAAA,QACN;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,iBAAe,UAAU,OAAqB;AAE5C,QAAI,MAAM,WAAW,OAAO,cAAe;AAC3C,QAAI,MAAM,WAAW,aAAc;AAEnC,UAAM,OAAO,MAAM;AACnB,QAAI,CAAC,QAAQ,OAAO,SAAS,SAAU;AACvC,QAAI,KAAK,QAAQ,MAAM,qBAAsB;AAE7C,YAAQ,KAAK,MAAM,GAAG;AAAA,MACpB,KAAK;AAAA,MACL,KAAK;AACH,cAAM,YAAY;AAClB;AAAA,MAEF,KAAK;AACH,mBAAW,KAAK,QAAQ,CAAW;AACnC;AAAA,MAEF,KAAK;AACH,kBAAU,KAAK,SAAS,GAAa,KAAK,OAAO,CAAyD;AAC1G;AAAA,MAEF,KAAK;AACH,qBAAa,KAAK,MAAM,CAAW;AACnC;AAAA,IACJ;AAAA,EACF;AAEA,QAAM,gBAAgB,CAAC,UAAiB;AACtC,SAAK,UAAU,KAAqB;AAAA,EACtC;AAEA,MAAI,OAAO,WAAW,aAAa;AACjC,WAAO,iBAAiB,WAAW,aAAa;AAAA,EAClD;AAEA,SAAO;AAAA,IACL,UAAU;AACR,UAAI,OAAO,WAAW,aAAa;AACjC,eAAO,oBAAoB,WAAW,aAAa;AAAA,MACrD;AAAA,IACF;AAAA,EACF;AACF;","names":["res"]}
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  BRIDGE_SOURCE_HOST,
4
4
  BRIDGE_SOURCE_IFRAME,
5
5
  MESSAGE_NAMESPACE
6
- } from "./chunk-XBGZRIHG.js";
6
+ } from "./chunk-LP3EXT2W.js";
7
7
 
8
8
  // src/index.ts
9
9
  function createBridge(opts = {}) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/protocol.ts"],"sourcesContent":["/**\n * @usethrottle/extension-bridge — postMessage protocol types\n *\n * Both the extension iframe and the dashboard host import from this file\n * to ensure both sides of the channel use identical message shapes.\n */\n\n/** Discriminant prefix used in all bridge message payloads. */\nexport const MESSAGE_NAMESPACE = 'throttle:bridge' as const;\n\n/** `source` field set on every message sent BY the extension iframe. */\nexport const BRIDGE_SOURCE_IFRAME = 'throttle-extension-bridge' as const;\n\n/** `source` field set on every message sent BY the dashboard host. */\nexport const BRIDGE_SOURCE_HOST = 'throttle-extension-host' as const;\n\n/** Incremented when the handshake protocol changes in a breaking way. */\nexport const BRIDGE_PROTOCOL_VERSION = 1 as const;\n\nexport interface SessionContext {\n user: { id: string; email: string };\n workspace: { id: string; slug: string };\n application: { id: string; slug: string };\n mode: 'test' | 'live';\n installationId: string;\n extensionId: string;\n version: string;\n role: string;\n scopes: string[];\n}\n\nexport interface ReadyMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'ready'; }\nexport interface RefreshMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'refresh'; }\nexport interface ResizeMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'resize'; height: number; }\nexport interface ToastMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'toast'; message: string; level?: 'info' | 'success' | 'warning' | 'error'; }\nexport interface NavigateMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'navigate'; path: string; }\nexport type IframeToHostMessage = ReadyMessage | RefreshMessage | ResizeMessage | ToastMessage | NavigateMessage;\n\nexport interface SessionMessage {\n source: typeof BRIDGE_SOURCE_HOST;\n type: 'session';\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl: string;\n}\nexport type HostToIframeMessage = SessionMessage;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQO,IAAM,oBAAoB;AAG1B,IAAM,uBAAuB;AAG7B,IAAM,qBAAqB;AAG3B,IAAM,0BAA0B;","names":[]}
1
+ {"version":3,"sources":["../src/protocol.ts"],"sourcesContent":["/**\n * @usethrottle/extension-bridge — postMessage protocol types\n *\n * Both the extension iframe and the dashboard host import from this file\n * to ensure both sides of the channel use identical message shapes.\n */\n\n/** Discriminant prefix used in all bridge message payloads. */\nexport const MESSAGE_NAMESPACE = 'throttle:bridge' as const;\n\n/** `source` field set on every message sent BY the extension iframe. */\nexport const BRIDGE_SOURCE_IFRAME = 'throttle-extension-bridge' as const;\n\n/** `source` field set on every message sent BY the dashboard host. */\nexport const BRIDGE_SOURCE_HOST = 'throttle-extension-host' as const;\n\n/** Incremented when the handshake protocol changes in a breaking way. */\nexport const BRIDGE_PROTOCOL_VERSION = 1 as const;\n\nexport interface SessionContext {\n user: { id: string; email: string };\n workspace: { id: string; slug: string };\n application: { id: string; slug: string };\n environment: {\n environmentId: string;\n environmentSlug: string;\n environmentKind: 'production' | 'non_production';\n providerEnvironment: 'production' | 'sandbox';\n };\n installationId: string;\n extensionId: string;\n version: string;\n role: string;\n scopes: string[];\n}\n\nexport interface ReadyMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'ready'; }\nexport interface RefreshMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'refresh'; }\nexport interface ResizeMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'resize'; height: number; }\nexport interface ToastMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'toast'; message: string; level?: 'info' | 'success' | 'warning' | 'error'; }\nexport interface NavigateMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'navigate'; path: string; }\nexport type IframeToHostMessage = ReadyMessage | RefreshMessage | ResizeMessage | ToastMessage | NavigateMessage;\n\nexport interface SessionMessage {\n source: typeof BRIDGE_SOURCE_HOST;\n type: 'session';\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl: string;\n}\nexport type HostToIframeMessage = SessionMessage;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQO,IAAM,oBAAoB;AAG1B,IAAM,uBAAuB;AAG7B,IAAM,qBAAqB;AAG3B,IAAM,0BAA0B;","names":[]}
@@ -25,7 +25,12 @@ interface SessionContext {
25
25
  id: string;
26
26
  slug: string;
27
27
  };
28
- mode: 'test' | 'live';
28
+ environment: {
29
+ environmentId: string;
30
+ environmentSlug: string;
31
+ environmentKind: 'production' | 'non_production';
32
+ providerEnvironment: 'production' | 'sandbox';
33
+ };
29
34
  installationId: string;
30
35
  extensionId: string;
31
36
  version: string;
@@ -25,7 +25,12 @@ interface SessionContext {
25
25
  id: string;
26
26
  slug: string;
27
27
  };
28
- mode: 'test' | 'live';
28
+ environment: {
29
+ environmentId: string;
30
+ environmentSlug: string;
31
+ environmentKind: 'production' | 'non_production';
32
+ providerEnvironment: 'production' | 'sandbox';
33
+ };
29
34
  installationId: string;
30
35
  extensionId: string;
31
36
  version: string;
package/dist/protocol.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  BRIDGE_SOURCE_HOST,
4
4
  BRIDGE_SOURCE_IFRAME,
5
5
  MESSAGE_NAMESPACE
6
- } from "./chunk-XBGZRIHG.js";
6
+ } from "./chunk-LP3EXT2W.js";
7
7
  export {
8
8
  BRIDGE_PROTOCOL_VERSION,
9
9
  BRIDGE_SOURCE_HOST,
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/verify.ts
21
+ var verify_exports = {};
22
+ __export(verify_exports, {
23
+ verifyWebhook: () => verifyWebhook
24
+ });
25
+ module.exports = __toCommonJS(verify_exports);
26
+ async function verifyWebhook(rawBody, signatureHeader, secret, opts) {
27
+ try {
28
+ if (!signatureHeader || typeof signatureHeader !== "string") return false;
29
+ const tolerance = opts?.toleranceSeconds ?? 300;
30
+ const nowSeconds = opts?.now ?? Math.floor(Date.now() / 1e3);
31
+ const parts = {};
32
+ for (const chunk of signatureHeader.split(",")) {
33
+ const eqIdx = chunk.indexOf("=");
34
+ if (eqIdx <= 0) continue;
35
+ const k = chunk.slice(0, eqIdx).trim();
36
+ const v = chunk.slice(eqIdx + 1).trim();
37
+ if (k && v) parts[k] = v;
38
+ }
39
+ const ts = parts.t ? Number.parseInt(parts.t, 10) : NaN;
40
+ const v1 = parts.v1;
41
+ if (!Number.isFinite(ts) || !v1 || !/^[0-9a-fA-F]+$/.test(v1)) return false;
42
+ if (Math.abs(nowSeconds - ts) > tolerance) return false;
43
+ const enc = new TextEncoder();
44
+ const keyMaterial = await globalThis.crypto.subtle.importKey(
45
+ "raw",
46
+ enc.encode(secret),
47
+ { name: "HMAC", hash: "SHA-256" },
48
+ false,
49
+ ["sign"]
50
+ );
51
+ const signedPayload = `${ts}.${rawBody}`;
52
+ const signatureBuffer = await globalThis.crypto.subtle.sign(
53
+ "HMAC",
54
+ keyMaterial,
55
+ enc.encode(signedPayload)
56
+ );
57
+ const computed = Array.from(new Uint8Array(signatureBuffer)).map((b2) => b2.toString(16).padStart(2, "0")).join("");
58
+ if (computed.length !== v1.length) return false;
59
+ const a = computed.toLowerCase();
60
+ const b = v1.toLowerCase();
61
+ let diff = 0;
62
+ for (let i = 0; i < a.length; i++) {
63
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
64
+ }
65
+ return diff === 0;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+ // Annotate the CommonJS export names for ESM import in node:
71
+ 0 && (module.exports = {
72
+ verifyWebhook
73
+ });
74
+ //# sourceMappingURL=verify.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/verify.ts"],"sourcesContent":["/**\n * `verifyWebhook` — universal HMAC-SHA256 webhook signature verifier.\n *\n * Uses the Web Crypto API (`globalThis.crypto.subtle`) so this module works in:\n * - Modern Node.js (18+) where `globalThis.crypto` is available natively\n * - Browser environments (extension iframes, Cloudflare Workers, Deno, Bun)\n *\n * Wire format (byte-compatible with `signOutbound` in @platform/webhooks):\n * Header: `t=<unix-seconds>,v1=<hex-hmac>`\n * Signed payload: `<timestamp>.<rawBody>` (UTF-8 encoded)\n * Algorithm: HMAC-SHA256\n *\n * @param rawBody - The raw request body string (do NOT parse before verifying)\n * @param signatureHeader - The `X-Throttle-Signature` header value\n * @param secret - The endpoint's signing secret\n * @param opts.toleranceSeconds - Replay-protection window in seconds (default 300)\n * @param opts.now - Override the current timestamp for testing\n */\nexport async function verifyWebhook(\n rawBody: string,\n signatureHeader: string,\n secret: string,\n opts?: { toleranceSeconds?: number; now?: number },\n): Promise<boolean> {\n try {\n if (!signatureHeader || typeof signatureHeader !== 'string') return false;\n\n const tolerance = opts?.toleranceSeconds ?? 300;\n const nowSeconds = opts?.now ?? Math.floor(Date.now() / 1000);\n\n // Parse `t=<ts>,v1=<hex>` — handle both `t=1,v1=abc` and `v1=abc,t=1` ordering\n const parts: Record<string, string> = {};\n for (const chunk of signatureHeader.split(',')) {\n const eqIdx = chunk.indexOf('=');\n if (eqIdx <= 0) continue;\n const k = chunk.slice(0, eqIdx).trim();\n const v = chunk.slice(eqIdx + 1).trim();\n if (k && v) parts[k] = v;\n }\n\n const ts = parts.t ? Number.parseInt(parts.t, 10) : NaN;\n const v1 = parts.v1;\n\n if (!Number.isFinite(ts) || !v1 || !/^[0-9a-fA-F]+$/.test(v1)) return false;\n if (Math.abs(nowSeconds - ts) > tolerance) return false;\n\n // Import the secret as an HMAC-SHA256 key via Web Crypto\n const enc = new TextEncoder();\n const keyMaterial = await globalThis.crypto.subtle.importKey(\n 'raw',\n enc.encode(secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n\n // Compute HMAC over `${ts}.${rawBody}` — identical to signOutbound's signed payload\n const signedPayload = `${ts}.${rawBody}`;\n const signatureBuffer = await globalThis.crypto.subtle.sign(\n 'HMAC',\n keyMaterial,\n enc.encode(signedPayload),\n );\n\n // Hex-encode the computed signature\n const computed = Array.from(new Uint8Array(signatureBuffer))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n\n // Constant-time comparison: compare hex strings byte-by-byte via XOR accumulator\n if (computed.length !== v1.length) return false;\n\n // Normalise both to lower-case before comparing to handle uppercase hex in header\n const a = computed.toLowerCase();\n const b = v1.toLowerCase();\n\n let diff = 0;\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n return diff === 0;\n } catch {\n // Never throw — return false on any error (bad key material, missing Web Crypto, etc.)\n return false;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBA,eAAsB,cACpB,SACA,iBACA,QACA,MACkB;AAClB,MAAI;AACF,QAAI,CAAC,mBAAmB,OAAO,oBAAoB,SAAU,QAAO;AAEpE,UAAM,YAAY,MAAM,oBAAoB;AAC5C,UAAM,aAAa,MAAM,OAAO,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAG5D,UAAM,QAAgC,CAAC;AACvC,eAAW,SAAS,gBAAgB,MAAM,GAAG,GAAG;AAC9C,YAAM,QAAQ,MAAM,QAAQ,GAAG;AAC/B,UAAI,SAAS,EAAG;AAChB,YAAM,IAAI,MAAM,MAAM,GAAG,KAAK,EAAE,KAAK;AACrC,YAAM,IAAI,MAAM,MAAM,QAAQ,CAAC,EAAE,KAAK;AACtC,UAAI,KAAK,EAAG,OAAM,CAAC,IAAI;AAAA,IACzB;AAEA,UAAM,KAAK,MAAM,IAAI,OAAO,SAAS,MAAM,GAAG,EAAE,IAAI;AACpD,UAAM,KAAK,MAAM;AAEjB,QAAI,CAAC,OAAO,SAAS,EAAE,KAAK,CAAC,MAAM,CAAC,iBAAiB,KAAK,EAAE,EAAG,QAAO;AACtE,QAAI,KAAK,IAAI,aAAa,EAAE,IAAI,UAAW,QAAO;AAGlD,UAAM,MAAM,IAAI,YAAY;AAC5B,UAAM,cAAc,MAAM,WAAW,OAAO,OAAO;AAAA,MACjD;AAAA,MACA,IAAI,OAAO,MAAM;AAAA,MACjB,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,MAChC;AAAA,MACA,CAAC,MAAM;AAAA,IACT;AAGA,UAAM,gBAAgB,GAAG,EAAE,IAAI,OAAO;AACtC,UAAM,kBAAkB,MAAM,WAAW,OAAO,OAAO;AAAA,MACrD;AAAA,MACA;AAAA,MACA,IAAI,OAAO,aAAa;AAAA,IAC1B;AAGA,UAAM,WAAW,MAAM,KAAK,IAAI,WAAW,eAAe,CAAC,EACxD,IAAI,CAACA,OAAMA,GAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAC1C,KAAK,EAAE;AAGV,QAAI,SAAS,WAAW,GAAG,OAAQ,QAAO;AAG1C,UAAM,IAAI,SAAS,YAAY;AAC/B,UAAM,IAAI,GAAG,YAAY;AAEzB,QAAI,OAAO;AACX,aAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;AACjC,cAAQ,EAAE,WAAW,CAAC,IAAI,EAAE,WAAW,CAAC;AAAA,IAC1C;AACA,WAAO,SAAS;AAAA,EAClB,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;","names":["b"]}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `verifyWebhook` — universal HMAC-SHA256 webhook signature verifier.
3
+ *
4
+ * Uses the Web Crypto API (`globalThis.crypto.subtle`) so this module works in:
5
+ * - Modern Node.js (18+) where `globalThis.crypto` is available natively
6
+ * - Browser environments (extension iframes, Cloudflare Workers, Deno, Bun)
7
+ *
8
+ * Wire format (byte-compatible with `signOutbound` in @platform/webhooks):
9
+ * Header: `t=<unix-seconds>,v1=<hex-hmac>`
10
+ * Signed payload: `<timestamp>.<rawBody>` (UTF-8 encoded)
11
+ * Algorithm: HMAC-SHA256
12
+ *
13
+ * @param rawBody - The raw request body string (do NOT parse before verifying)
14
+ * @param signatureHeader - The `X-Throttle-Signature` header value
15
+ * @param secret - The endpoint's signing secret
16
+ * @param opts.toleranceSeconds - Replay-protection window in seconds (default 300)
17
+ * @param opts.now - Override the current timestamp for testing
18
+ */
19
+ declare function verifyWebhook(rawBody: string, signatureHeader: string, secret: string, opts?: {
20
+ toleranceSeconds?: number;
21
+ now?: number;
22
+ }): Promise<boolean>;
23
+
24
+ export { verifyWebhook };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `verifyWebhook` — universal HMAC-SHA256 webhook signature verifier.
3
+ *
4
+ * Uses the Web Crypto API (`globalThis.crypto.subtle`) so this module works in:
5
+ * - Modern Node.js (18+) where `globalThis.crypto` is available natively
6
+ * - Browser environments (extension iframes, Cloudflare Workers, Deno, Bun)
7
+ *
8
+ * Wire format (byte-compatible with `signOutbound` in @platform/webhooks):
9
+ * Header: `t=<unix-seconds>,v1=<hex-hmac>`
10
+ * Signed payload: `<timestamp>.<rawBody>` (UTF-8 encoded)
11
+ * Algorithm: HMAC-SHA256
12
+ *
13
+ * @param rawBody - The raw request body string (do NOT parse before verifying)
14
+ * @param signatureHeader - The `X-Throttle-Signature` header value
15
+ * @param secret - The endpoint's signing secret
16
+ * @param opts.toleranceSeconds - Replay-protection window in seconds (default 300)
17
+ * @param opts.now - Override the current timestamp for testing
18
+ */
19
+ declare function verifyWebhook(rawBody: string, signatureHeader: string, secret: string, opts?: {
20
+ toleranceSeconds?: number;
21
+ now?: number;
22
+ }): Promise<boolean>;
23
+
24
+ export { verifyWebhook };
package/dist/verify.js ADDED
@@ -0,0 +1,49 @@
1
+ // src/verify.ts
2
+ async function verifyWebhook(rawBody, signatureHeader, secret, opts) {
3
+ try {
4
+ if (!signatureHeader || typeof signatureHeader !== "string") return false;
5
+ const tolerance = opts?.toleranceSeconds ?? 300;
6
+ const nowSeconds = opts?.now ?? Math.floor(Date.now() / 1e3);
7
+ const parts = {};
8
+ for (const chunk of signatureHeader.split(",")) {
9
+ const eqIdx = chunk.indexOf("=");
10
+ if (eqIdx <= 0) continue;
11
+ const k = chunk.slice(0, eqIdx).trim();
12
+ const v = chunk.slice(eqIdx + 1).trim();
13
+ if (k && v) parts[k] = v;
14
+ }
15
+ const ts = parts.t ? Number.parseInt(parts.t, 10) : NaN;
16
+ const v1 = parts.v1;
17
+ if (!Number.isFinite(ts) || !v1 || !/^[0-9a-fA-F]+$/.test(v1)) return false;
18
+ if (Math.abs(nowSeconds - ts) > tolerance) return false;
19
+ const enc = new TextEncoder();
20
+ const keyMaterial = await globalThis.crypto.subtle.importKey(
21
+ "raw",
22
+ enc.encode(secret),
23
+ { name: "HMAC", hash: "SHA-256" },
24
+ false,
25
+ ["sign"]
26
+ );
27
+ const signedPayload = `${ts}.${rawBody}`;
28
+ const signatureBuffer = await globalThis.crypto.subtle.sign(
29
+ "HMAC",
30
+ keyMaterial,
31
+ enc.encode(signedPayload)
32
+ );
33
+ const computed = Array.from(new Uint8Array(signatureBuffer)).map((b2) => b2.toString(16).padStart(2, "0")).join("");
34
+ if (computed.length !== v1.length) return false;
35
+ const a = computed.toLowerCase();
36
+ const b = v1.toLowerCase();
37
+ let diff = 0;
38
+ for (let i = 0; i < a.length; i++) {
39
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
40
+ }
41
+ return diff === 0;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+ export {
47
+ verifyWebhook
48
+ };
49
+ //# sourceMappingURL=verify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/verify.ts"],"sourcesContent":["/**\n * `verifyWebhook` — universal HMAC-SHA256 webhook signature verifier.\n *\n * Uses the Web Crypto API (`globalThis.crypto.subtle`) so this module works in:\n * - Modern Node.js (18+) where `globalThis.crypto` is available natively\n * - Browser environments (extension iframes, Cloudflare Workers, Deno, Bun)\n *\n * Wire format (byte-compatible with `signOutbound` in @platform/webhooks):\n * Header: `t=<unix-seconds>,v1=<hex-hmac>`\n * Signed payload: `<timestamp>.<rawBody>` (UTF-8 encoded)\n * Algorithm: HMAC-SHA256\n *\n * @param rawBody - The raw request body string (do NOT parse before verifying)\n * @param signatureHeader - The `X-Throttle-Signature` header value\n * @param secret - The endpoint's signing secret\n * @param opts.toleranceSeconds - Replay-protection window in seconds (default 300)\n * @param opts.now - Override the current timestamp for testing\n */\nexport async function verifyWebhook(\n rawBody: string,\n signatureHeader: string,\n secret: string,\n opts?: { toleranceSeconds?: number; now?: number },\n): Promise<boolean> {\n try {\n if (!signatureHeader || typeof signatureHeader !== 'string') return false;\n\n const tolerance = opts?.toleranceSeconds ?? 300;\n const nowSeconds = opts?.now ?? Math.floor(Date.now() / 1000);\n\n // Parse `t=<ts>,v1=<hex>` — handle both `t=1,v1=abc` and `v1=abc,t=1` ordering\n const parts: Record<string, string> = {};\n for (const chunk of signatureHeader.split(',')) {\n const eqIdx = chunk.indexOf('=');\n if (eqIdx <= 0) continue;\n const k = chunk.slice(0, eqIdx).trim();\n const v = chunk.slice(eqIdx + 1).trim();\n if (k && v) parts[k] = v;\n }\n\n const ts = parts.t ? Number.parseInt(parts.t, 10) : NaN;\n const v1 = parts.v1;\n\n if (!Number.isFinite(ts) || !v1 || !/^[0-9a-fA-F]+$/.test(v1)) return false;\n if (Math.abs(nowSeconds - ts) > tolerance) return false;\n\n // Import the secret as an HMAC-SHA256 key via Web Crypto\n const enc = new TextEncoder();\n const keyMaterial = await globalThis.crypto.subtle.importKey(\n 'raw',\n enc.encode(secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n\n // Compute HMAC over `${ts}.${rawBody}` — identical to signOutbound's signed payload\n const signedPayload = `${ts}.${rawBody}`;\n const signatureBuffer = await globalThis.crypto.subtle.sign(\n 'HMAC',\n keyMaterial,\n enc.encode(signedPayload),\n );\n\n // Hex-encode the computed signature\n const computed = Array.from(new Uint8Array(signatureBuffer))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n\n // Constant-time comparison: compare hex strings byte-by-byte via XOR accumulator\n if (computed.length !== v1.length) return false;\n\n // Normalise both to lower-case before comparing to handle uppercase hex in header\n const a = computed.toLowerCase();\n const b = v1.toLowerCase();\n\n let diff = 0;\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n return diff === 0;\n } catch {\n // Never throw — return false on any error (bad key material, missing Web Crypto, etc.)\n return false;\n }\n}\n"],"mappings":";AAkBA,eAAsB,cACpB,SACA,iBACA,QACA,MACkB;AAClB,MAAI;AACF,QAAI,CAAC,mBAAmB,OAAO,oBAAoB,SAAU,QAAO;AAEpE,UAAM,YAAY,MAAM,oBAAoB;AAC5C,UAAM,aAAa,MAAM,OAAO,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAG5D,UAAM,QAAgC,CAAC;AACvC,eAAW,SAAS,gBAAgB,MAAM,GAAG,GAAG;AAC9C,YAAM,QAAQ,MAAM,QAAQ,GAAG;AAC/B,UAAI,SAAS,EAAG;AAChB,YAAM,IAAI,MAAM,MAAM,GAAG,KAAK,EAAE,KAAK;AACrC,YAAM,IAAI,MAAM,MAAM,QAAQ,CAAC,EAAE,KAAK;AACtC,UAAI,KAAK,EAAG,OAAM,CAAC,IAAI;AAAA,IACzB;AAEA,UAAM,KAAK,MAAM,IAAI,OAAO,SAAS,MAAM,GAAG,EAAE,IAAI;AACpD,UAAM,KAAK,MAAM;AAEjB,QAAI,CAAC,OAAO,SAAS,EAAE,KAAK,CAAC,MAAM,CAAC,iBAAiB,KAAK,EAAE,EAAG,QAAO;AACtE,QAAI,KAAK,IAAI,aAAa,EAAE,IAAI,UAAW,QAAO;AAGlD,UAAM,MAAM,IAAI,YAAY;AAC5B,UAAM,cAAc,MAAM,WAAW,OAAO,OAAO;AAAA,MACjD;AAAA,MACA,IAAI,OAAO,MAAM;AAAA,MACjB,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,MAChC;AAAA,MACA,CAAC,MAAM;AAAA,IACT;AAGA,UAAM,gBAAgB,GAAG,EAAE,IAAI,OAAO;AACtC,UAAM,kBAAkB,MAAM,WAAW,OAAO,OAAO;AAAA,MACrD;AAAA,MACA;AAAA,MACA,IAAI,OAAO,aAAa;AAAA,IAC1B;AAGA,UAAM,WAAW,MAAM,KAAK,IAAI,WAAW,eAAe,CAAC,EACxD,IAAI,CAACA,OAAMA,GAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAC1C,KAAK,EAAE;AAGV,QAAI,SAAS,WAAW,GAAG,OAAQ,QAAO;AAG1C,UAAM,IAAI,SAAS,YAAY;AAC/B,UAAM,IAAI,GAAG,YAAY;AAEzB,QAAI,OAAO;AACX,aAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;AACjC,cAAQ,EAAE,WAAW,CAAC,IAAI,EAAE,WAAW,CAAC;AAAA,IAC1C;AACA,WAAO,SAAS;AAAA,EAClB,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;","names":["b"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usethrottle/extension-bridge",
3
- "version": "0.2.0",
3
+ "version": "1.1.0",
4
4
  "description": "iframe bridge runtime for Throttle extensions — host helper + webhook verifier.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -19,6 +19,11 @@
19
19
  "import": "./dist/webhook.js",
20
20
  "require": "./dist/webhook.cjs"
21
21
  },
22
+ "./verify": {
23
+ "types": "./dist/verify.d.ts",
24
+ "import": "./dist/verify.js",
25
+ "require": "./dist/verify.cjs"
26
+ },
22
27
  "./protocol": {
23
28
  "types": "./dist/protocol.d.ts",
24
29
  "import": "./dist/protocol.js",
@@ -29,11 +34,6 @@
29
34
  "dist",
30
35
  "README.md"
31
36
  ],
32
- "scripts": {
33
- "build": "tsup",
34
- "test": "vitest run",
35
- "typecheck": "tsc --noEmit"
36
- },
37
37
  "dependencies": {},
38
38
  "devDependencies": {
39
39
  "tsup": "^8.0.0",
@@ -47,5 +47,10 @@
47
47
  },
48
48
  "engines": {
49
49
  "node": ">=20"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup",
53
+ "test": "vitest run",
54
+ "typecheck": "tsc --noEmit"
50
55
  }
51
- }
56
+ }
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/protocol.ts"],"sourcesContent":["/**\n * @usethrottle/extension-bridge — postMessage protocol types\n *\n * Both the extension iframe and the dashboard host import from this file\n * to ensure both sides of the channel use identical message shapes.\n */\n\n/** Discriminant prefix used in all bridge message payloads. */\nexport const MESSAGE_NAMESPACE = 'throttle:bridge' as const;\n\n/** `source` field set on every message sent BY the extension iframe. */\nexport const BRIDGE_SOURCE_IFRAME = 'throttle-extension-bridge' as const;\n\n/** `source` field set on every message sent BY the dashboard host. */\nexport const BRIDGE_SOURCE_HOST = 'throttle-extension-host' as const;\n\n/** Incremented when the handshake protocol changes in a breaking way. */\nexport const BRIDGE_PROTOCOL_VERSION = 1 as const;\n\nexport interface SessionContext {\n user: { id: string; email: string };\n workspace: { id: string; slug: string };\n application: { id: string; slug: string };\n mode: 'test' | 'live';\n installationId: string;\n extensionId: string;\n version: string;\n role: string;\n scopes: string[];\n}\n\nexport interface ReadyMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'ready'; }\nexport interface RefreshMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'refresh'; }\nexport interface ResizeMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'resize'; height: number; }\nexport interface ToastMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'toast'; message: string; level?: 'info' | 'success' | 'warning' | 'error'; }\nexport interface NavigateMessage { source: typeof BRIDGE_SOURCE_IFRAME; type: 'navigate'; path: string; }\nexport type IframeToHostMessage = ReadyMessage | RefreshMessage | ResizeMessage | ToastMessage | NavigateMessage;\n\nexport interface SessionMessage {\n source: typeof BRIDGE_SOURCE_HOST;\n type: 'session';\n token: string;\n expiresAt: string;\n context: SessionContext;\n apiBaseUrl: string;\n}\nexport type HostToIframeMessage = SessionMessage;\n"],"mappings":";AAQO,IAAM,oBAAoB;AAG1B,IAAM,uBAAuB;AAG7B,IAAM,qBAAqB;AAG3B,IAAM,0BAA0B;","names":[]}