@hubspot/app-connect-sdk 1.0.0-alpha.13 → 1.0.0-alpha.15

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.
Files changed (33) hide show
  1. package/.turbo/turbo-format$colon$check.log +1 -1
  2. package/.turbo/turbo-test.log +63 -58
  3. package/.turbo/turbo-tsdown.log +12 -12
  4. package/build/tsconfig.browser.tsbuildinfo +1 -1
  5. package/build/tsconfig.server.tsbuildinfo +1 -1
  6. package/dist/browser/{create-crdncXsh.js → create-D-oTtxX-.js} +252 -92
  7. package/dist/browser/create-D-oTtxX-.js.map +1 -0
  8. package/dist/browser/index.d.ts +2 -2
  9. package/dist/browser/index.js +1 -1
  10. package/dist/browser/react/lovable.d.ts +8 -0
  11. package/dist/browser/react/lovable.js +6 -3
  12. package/dist/browser/react/lovable.js.map +1 -1
  13. package/dist/browser/react.d.ts +1 -1
  14. package/dist/browser/{types-rTQw6A54.d.ts → types-DkAmHcZt.d.ts} +22 -7
  15. package/dist/server/shared/constants.js.map +1 -1
  16. package/package.json +3 -3
  17. package/src/browser/app-connect-controller/README.md +5 -2
  18. package/src/browser/app-connect-controller/connect-start.test.ts +156 -0
  19. package/src/browser/app-connect-controller/connect-start.ts +18 -3
  20. package/src/browser/app-connect-controller/init.test.ts +43 -2
  21. package/src/browser/app-connect-controller/init.ts +23 -48
  22. package/src/browser/app-connect-controller/oauth-complete.test.ts +106 -0
  23. package/src/browser/app-connect-controller/oauth-complete.ts +53 -0
  24. package/src/browser/app-connect-controller/oauth-popup.test.ts +192 -0
  25. package/src/browser/app-connect-controller/oauth-popup.ts +152 -0
  26. package/src/browser/app-connect-controller/utils/iframe-utils.ts +12 -0
  27. package/src/browser/app-connect-controller/utils/resolve-oauth-connect-mode.test.ts +35 -0
  28. package/src/browser/app-connect-controller/utils/resolve-oauth-connect-mode.ts +21 -0
  29. package/src/browser/index.ts +1 -0
  30. package/src/browser/react/lovable/LovableHubSpotAppConnect.tsx +12 -2
  31. package/src/browser/types.ts +21 -5
  32. package/src/shared/constants.ts +9 -0
  33. package/dist/browser/create-crdncXsh.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"constants.js","names":[],"sources":["../../../src/shared/constants.ts"],"sourcesContent":["/**\n * Constants whose values are part of the contract between the browser\n * controller and the server-side hubspot-connect routes. Both halves\n * import from this module so the wire format stays in sync.\n */\n\n/**\n * Query parameter on the OAuth return URL that carries the new access\n * token's expiry (Unix epoch milliseconds). The browser controller\n * sets this in the URL after a successful `auth/complete` call and\n * then strips it during `initAppConnect` via `history.replaceState`.\n */\nexport const EXPIRES_AT_URL_PARAM = '__hs_expires_at';\n\n/**\n * Path the browser visits after HubSpot's authorize endpoint\n * redirects back to the app. Mounted on the **frontend** origin (not\n * the SDK's edge function host) so all OAuth-related cookies live in\n * the `(frontend, edge)` CHIPS partition.\n *\n * The SDK's `auth/init-session` builds the OAuth `redirect_uri` as\n * `${requestOrigin}${HUBSPOT_FRONTEND_CALLBACK_PATH}`. The browser\n * controller, on `start()`, recognizes this path on `window.location`\n * and forwards `?code` + `?state` to the SDK's `auth/complete`\n * endpoint via a credentialed cross-site fetch. The host app must\n * register `${app_origin}${HUBSPOT_FRONTEND_CALLBACK_PATH}` as a\n * redirect URI in its HubSpot app settings.\n */\nexport const OAUTH_CALLBACK_PATH = '/__hubspot_oauth_callback';\n\n/**\n * Query parameter on the `auth/complete` POST request carrying the\n * authorization `code` HubSpot returned to the frontend callback.\n */\nexport const AUTH_COMPLETE_CODE_PARAM = 'code';\n\n/**\n * Query parameter on the `auth/complete` POST request carrying the\n * OAuth `state` HubSpot echoed back to the frontend callback.\n */\nexport const AUTH_COMPLETE_STATE_PARAM = 'state';\n"],"mappings":";;;;;;;;;;;;;;;AA4BA,MAAa,sBAAsB;;;;;AAMnC,MAAa,2BAA2B;;;;;AAMxC,MAAa,4BAA4B"}
1
+ {"version":3,"file":"constants.js","names":[],"sources":["../../../src/shared/constants.ts"],"sourcesContent":["/**\n * Constants whose values are part of the contract between the browser\n * controller and the server-side hubspot-connect routes. Both halves\n * import from this module so the wire format stays in sync.\n */\n\n/**\n * Query parameter on the OAuth return URL that carries the new access\n * token's expiry (Unix epoch milliseconds). The browser controller\n * sets this in the URL after a successful `auth/complete` call and\n * then strips it during `initAppConnect` via `history.replaceState`.\n */\nexport const EXPIRES_AT_URL_PARAM = '__hs_expires_at';\n\n/**\n * Path the browser visits after HubSpot's authorize endpoint\n * redirects back to the app. Mounted on the **frontend** origin (not\n * the SDK's edge function host) so all OAuth-related cookies live in\n * the `(frontend, edge)` CHIPS partition.\n *\n * The SDK's `auth/init-session` builds the OAuth `redirect_uri` as\n * `${requestOrigin}${HUBSPOT_FRONTEND_CALLBACK_PATH}`. The browser\n * controller, on `start()`, recognizes this path on `window.location`\n * and forwards `?code` + `?state` to the SDK's `auth/complete`\n * endpoint via a credentialed cross-site fetch. The host app must\n * register `${app_origin}${HUBSPOT_FRONTEND_CALLBACK_PATH}` as a\n * redirect URI in its HubSpot app settings.\n */\nexport const OAUTH_CALLBACK_PATH = '/__hubspot_oauth_callback';\n\n/**\n * Query parameter on the `auth/complete` POST request carrying the\n * authorization `code` HubSpot returned to the frontend callback.\n */\nexport const AUTH_COMPLETE_CODE_PARAM = 'code';\n\n/**\n * Query parameter on the `auth/complete` POST request carrying the\n * OAuth `state` HubSpot echoed back to the frontend callback.\n */\nexport const AUTH_COMPLETE_STATE_PARAM = 'state';\n\n/**\n * `postMessage` `data.type` value the OAuth popup sends to its opener\n * with the authorization `code` and `state` from the callback URL. The\n * opener POSTs them to `auth/complete` so credentialed cookies stay in\n * the same CHIPS partition as `auth/init-session`.\n */\nexport const OAUTH_POPUP_CALLBACK_MESSAGE_TYPE =\n 'hubspot-app-connect:oauth-callback';\n"],"mappings":";;;;;;;;;;;;;;;AA4BA,MAAa,sBAAsB;;;;;AAMnC,MAAa,2BAA2B;;;;;AAMxC,MAAa,4BAA4B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubspot/app-connect-sdk",
3
- "version": "1.0.0-alpha.13",
3
+ "version": "1.0.0-alpha.15",
4
4
  "description": "HubSpot App Connect SDK (alpha release). Documentation and integration guidance forthcoming.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -45,8 +45,8 @@
45
45
  "typescript": "6.0.3",
46
46
  "vitest": "4.1.6",
47
47
  "@private/eslint-config": "0.1.0",
48
- "@private/tsconfig": "0.1.0",
49
- "@private/prettier-config": "0.1.0"
48
+ "@private/prettier-config": "0.1.0",
49
+ "@private/tsconfig": "0.1.0"
50
50
  },
51
51
  "scripts": {
52
52
  "clean": "rm -rf dist build *.tsbuildinfo node_modules .turbo",
@@ -57,8 +57,11 @@ flowchart TD
57
57
  ## Module map
58
58
 
59
59
  - [create.ts](./create.ts) — factory; the only file [`../index.ts`](../index.ts) imports from. Wires the context, defines `connectToHubSpot` / `disconnectFromHubSpot`, and applies `memoizeLast` to `getSnapshot`.
60
- - [init.ts](./init.ts) — runs once on `start()`. Reads `?__hs_expires_at=…` from `window.location`, persists it via [`utils/session-utils.ts`](./utils/session-utils.ts), and scrubs the parameter from the address bar with `history.replaceState`.
61
- - [connect-start.ts](./connect-start.ts) — `GET`s the SDK's `/auth/init-session` route, then full-page redirects to HubSpot's `authorize` URL. The `return_path` is the current path + query so the user lands back where they started.
60
+ - [init.ts](./init.ts) — runs once on `start()`. Redirect flow: POSTs `code` + `state` to `/auth/complete`, persists `expires_at`, `history.replaceState`s to `return_path`. Popup flow (`window.opener`): relays `code` + `state` to the opener and closes (no `auth/complete` in the popup).
61
+ - [connect-start.ts](./connect-start.ts) — `GET`s `/auth/init-session`, then redirects or opens a popup per `config.oauthConnectMode` (`auto` uses a popup when embedded in an iframe). See [oauth-popup.ts](./oauth-popup.ts).
62
+ - [oauth-popup.ts](./oauth-popup.ts) — opener waits for popup `postMessage` with `code` + `state`, then POSTs `/auth/complete`.
63
+ - [oauth-complete.ts](./oauth-complete.ts) — shared credentialed `POST /auth/complete` used by redirect init and the opener popup handler.
64
+ - [utils/resolve-oauth-connect-mode.ts](./utils/resolve-oauth-connect-mode.ts) / [utils/iframe-utils.ts](./utils/iframe-utils.ts) — map `oauthConnectMode` + iframe detection to redirect vs popup.
62
65
  - [disconnect.ts](./disconnect.ts) — `POST`s `/auth/logout`, clears local session storage, and redirects to the server-supplied `redirect_to`. Errors are caught and surfaced via `state.error`.
63
66
  - [refresh.ts](./refresh.ts) — subscribes to the store and (re)schedules a `/auth/refresh` call whenever `expiresAt` changes. Exposes `RefreshSchedulerHandle.stop()` for teardown.
64
67
  - [view-state.ts](./view-state.ts) — `getDerivedStatus` and `SERVER_VIEW` (the SSR snapshot returned by `getServerSnapshot`).
@@ -0,0 +1,156 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { noopLogger } from '../../shared/logger.ts';
4
+ import { startHubSpotConnection } from './connect-start.ts';
5
+ import type {
6
+ AppConnectContext,
7
+ AppConnectInternalState,
8
+ SessionStorage,
9
+ } from './types.ts';
10
+ import { createStore } from './utils/store-utils.ts';
11
+
12
+ const HUBSPOT_CONNECT_BASE_URL =
13
+ 'https://edge.example.com/functions/v1/hubspot-connect';
14
+
15
+ function createInMemorySessionStorage(): SessionStorage {
16
+ const map = new Map<string, string>();
17
+ return {
18
+ getItem: (key) => map.get(key) ?? null,
19
+ setItem: (key, value) => {
20
+ map.set(key, value);
21
+ },
22
+ removeItem: (key) => {
23
+ map.delete(key);
24
+ },
25
+ };
26
+ }
27
+
28
+ function createTestContext(
29
+ oauthConnectMode?: AppConnectContext['config']['oauthConnectMode']
30
+ ): AppConnectContext {
31
+ const initialState: AppConnectInternalState = {
32
+ isInitComplete: true,
33
+ isConnectInFlight: true,
34
+ isSessionConnected: false,
35
+ isDisconnectInFlight: false,
36
+ error: null,
37
+ expiresAt: null,
38
+ };
39
+ return {
40
+ config: {
41
+ hubSpotConnectBaseUrl: HUBSPOT_CONNECT_BASE_URL,
42
+ ...(oauthConnectMode !== undefined ? { oauthConnectMode } : {}),
43
+ },
44
+ logger: noopLogger,
45
+ sessionStorage: createInMemorySessionStorage(),
46
+ store: createStore<AppConnectInternalState>(initialState),
47
+ };
48
+ }
49
+
50
+ describe('startHubSpotConnection', () => {
51
+ beforeEach(() => {
52
+ vi.useFakeTimers();
53
+ });
54
+
55
+ afterEach(() => {
56
+ vi.unstubAllGlobals();
57
+ vi.restoreAllMocks();
58
+ vi.useRealTimers();
59
+ });
60
+
61
+ it('redirects the browser when oauthConnectMode is redirect', async () => {
62
+ const top = {};
63
+ const location = {
64
+ origin: 'https://app.example.com',
65
+ pathname: '/dashboard',
66
+ search: '?tab=1',
67
+ href: '',
68
+ };
69
+ vi.stubGlobal('window', {
70
+ self: top,
71
+ top,
72
+ location,
73
+ open: vi.fn(),
74
+ addEventListener: vi.fn(),
75
+ removeEventListener: vi.fn(),
76
+ });
77
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
78
+ new Response(
79
+ JSON.stringify({
80
+ authorization_url: 'https://auth.example/authorize',
81
+ }),
82
+ { status: 200 }
83
+ )
84
+ );
85
+
86
+ const context = createTestContext('redirect');
87
+ const connectPromise = startHubSpotConnection(context);
88
+ await vi.advanceTimersByTimeAsync(500);
89
+ await connectPromise;
90
+
91
+ expect(location.href).toBe('https://auth.example/authorize');
92
+ expect(window.open).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('opens a popup when oauthConnectMode is popup', async () => {
96
+ const top = {};
97
+ const popup = { closed: false, close: vi.fn() };
98
+ const messageListeners: Array<(event: MessageEvent) => void> = [];
99
+
100
+ vi.stubGlobal('window', {
101
+ self: top,
102
+ top,
103
+ location: {
104
+ origin: 'https://app.example.com',
105
+ pathname: '/dashboard',
106
+ search: '',
107
+ },
108
+ open: vi.fn(() => popup),
109
+ addEventListener: (
110
+ type: string,
111
+ listener: (event: MessageEvent) => void
112
+ ) => {
113
+ if (type === 'message') messageListeners.push(listener);
114
+ },
115
+ removeEventListener: vi.fn(),
116
+ });
117
+ const expiresAt = Date.now() + 1800 * 1000;
118
+ vi.spyOn(globalThis, 'fetch')
119
+ .mockResolvedValueOnce(
120
+ new Response(
121
+ JSON.stringify({
122
+ authorization_url: 'https://auth.example/authorize',
123
+ }),
124
+ { status: 200 }
125
+ )
126
+ )
127
+ .mockResolvedValueOnce(
128
+ new Response(
129
+ JSON.stringify({ expires_at: expiresAt, return_path: '/dashboard' }),
130
+ { status: 200 }
131
+ )
132
+ );
133
+
134
+ const context = createTestContext('popup');
135
+ const connectPromise = startHubSpotConnection(context);
136
+ await vi.advanceTimersByTimeAsync(500);
137
+
138
+ expect(window.open).toHaveBeenCalledWith(
139
+ 'https://auth.example/authorize',
140
+ expect.any(String),
141
+ expect.stringContaining('popup=yes')
142
+ );
143
+
144
+ messageListeners[0]!({
145
+ origin: 'https://app.example.com',
146
+ data: {
147
+ type: 'hubspot-app-connect:oauth-callback',
148
+ code: 'auth-code',
149
+ state: 'auth-state',
150
+ },
151
+ } as MessageEvent);
152
+
153
+ await connectPromise;
154
+ expect(context.store.getSnapshot().expiresAt).toBe(expiresAt);
155
+ });
156
+ });
@@ -1,5 +1,7 @@
1
1
  import type { InitSessionResponse } from '../../shared/wire-types.ts';
2
+ import { waitForHubSpotOAuthPopup } from './oauth-popup.ts';
2
3
  import type { AppConnectContext } from './types.ts';
4
+ import { resolveOAuthConnectMode } from './utils/resolve-oauth-connect-mode.ts';
3
5
  import { delay } from './utils/timeout-utils.ts';
4
6
 
5
7
  /** Extra wait before redirect so the connect progress UI is visible; set to `0` to disable. */
@@ -10,12 +12,14 @@ const ARTIFICIAL_CONNECT_REDIRECT_DELAY_MS = 500;
10
12
  *
11
13
  * 1. Calls the SDK's `auth/init-session` route to mint a fresh PKCE
12
14
  * verifier + state and obtain HubSpot's `authorize` URL.
13
- * 2. Navigates the browser to that URL (full-page redirect).
15
+ * 2. Navigates to that URL via full-page redirect, or opens it in a
16
+ * popup when embedded in an iframe or when `oauthConnectMode` is
17
+ * `'popup'`.
14
18
  *
15
19
  * The `return_path` is the current path + query so the user lands
16
- * back where they started after authorizing.
20
+ * back where they started after authorizing (redirect mode only).
17
21
  *
18
- * Throws when the init call fails. Does not return after the redirect
22
+ * Throws when the init call fails. Does not return after a redirect
19
23
  * begins because the page is unloaded.
20
24
  */
21
25
  export async function startHubSpotConnection(
@@ -41,5 +45,16 @@ export async function startHubSpotConnection(
41
45
 
42
46
  await delay(ARTIFICIAL_CONNECT_REDIRECT_DELAY_MS);
43
47
 
48
+ const connectMode = resolveOAuthConnectMode(
49
+ config.oauthConnectMode !== undefined
50
+ ? { oauthConnectMode: config.oauthConnectMode }
51
+ : {}
52
+ );
53
+
54
+ if (connectMode === 'popup') {
55
+ await waitForHubSpotOAuthPopup({ context, authorizationUrl });
56
+ return;
57
+ }
58
+
44
59
  window.location.href = authorizationUrl;
45
60
  }
@@ -1,6 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
- import { OAUTH_CALLBACK_PATH } from '../../shared/constants.ts';
3
+ import {
4
+ OAUTH_CALLBACK_PATH,
5
+ OAUTH_POPUP_CALLBACK_MESSAGE_TYPE,
6
+ } from '../../shared/constants.ts';
4
7
  import { noopLogger } from '../../shared/logger.ts';
5
8
  import { EXPIRES_AT_KEY } from './constants.ts';
6
9
  import { initAppConnect } from './init.ts';
@@ -46,6 +49,7 @@ function createTestContext(): AppConnectContext {
46
49
 
47
50
  interface FakeWindowOptions {
48
51
  href: string;
52
+ opener?: { closed: boolean; postMessage: ReturnType<typeof vi.fn> };
49
53
  }
50
54
 
51
55
  interface FakeWindow {
@@ -53,17 +57,23 @@ interface FakeWindow {
53
57
  windowProxy: {
54
58
  location: Pick<Location, 'pathname' | 'search' | 'origin'>;
55
59
  };
60
+ close: ReturnType<typeof vi.fn>;
61
+ openerPostMessage: ReturnType<typeof vi.fn> | undefined;
56
62
  }
57
63
 
58
64
  function installFakeWindow(options: FakeWindowOptions): FakeWindow {
59
65
  const url = new URL(options.href);
60
66
  const calls: Array<[unknown, string, string]> = [];
67
+ const close = vi.fn();
68
+ const openerPostMessage = options.opener?.postMessage;
61
69
  const fakeWindow = {
62
70
  location: {
63
71
  pathname: url.pathname,
64
72
  search: url.search,
65
73
  origin: url.origin,
66
74
  },
75
+ opener: options.opener,
76
+ close,
67
77
  };
68
78
  const fakeHistory = {
69
79
  replaceState: (state: unknown, title: string, newUrl: string) => {
@@ -75,7 +85,12 @@ function installFakeWindow(options: FakeWindowOptions): FakeWindow {
75
85
  };
76
86
  vi.stubGlobal('window', fakeWindow);
77
87
  vi.stubGlobal('history', fakeHistory);
78
- return { history: { calls }, windowProxy: fakeWindow };
88
+ return {
89
+ history: { calls },
90
+ windowProxy: fakeWindow,
91
+ close,
92
+ openerPostMessage,
93
+ };
79
94
  }
80
95
 
81
96
  describe('initAppConnect', () => {
@@ -153,6 +168,32 @@ describe('initAppConnect', () => {
153
168
  expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
154
169
  });
155
170
 
171
+ it('relays code and state to the opener without calling auth/complete when in an OAuth popup', async () => {
172
+ const openerPostMessage = vi.fn();
173
+ const fake = installFakeWindow({
174
+ href: `https://app.example.com${OAUTH_CALLBACK_PATH}?code=auth-code&state=auth-state`,
175
+ opener: { closed: false, postMessage: openerPostMessage },
176
+ });
177
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
178
+ const context = createTestContext();
179
+
180
+ await initAppConnect(context);
181
+
182
+ expect(fetchSpy).not.toHaveBeenCalled();
183
+ expect(openerPostMessage).toHaveBeenCalledWith(
184
+ {
185
+ type: OAUTH_POPUP_CALLBACK_MESSAGE_TYPE,
186
+ code: 'auth-code',
187
+ state: 'auth-state',
188
+ },
189
+ 'https://app.example.com'
190
+ );
191
+ expect(fake.close).toHaveBeenCalled();
192
+ expect(fake.history.calls).toHaveLength(0);
193
+ expect(context.store.getSnapshot().expiresAt).toBeNull();
194
+ expect(window.location.pathname).toBe(OAUTH_CALLBACK_PATH);
195
+ });
196
+
156
197
  it('skips the callback step on the callback path when code or state are missing', async () => {
157
198
  installFakeWindow({
158
199
  href: `https://app.example.com${OAUTH_CALLBACK_PATH}`,
@@ -3,32 +3,30 @@ import {
3
3
  AUTH_COMPLETE_STATE_PARAM,
4
4
  OAUTH_CALLBACK_PATH,
5
5
  } from '../../shared/constants.ts';
6
- import type { AuthCompleteResponse } from '../../shared/wire-types.ts';
7
- import { EXPIRES_AT_KEY } from './constants.ts';
6
+ import { completeHubSpotOAuthSession } from './oauth-complete.ts';
7
+ import {
8
+ hasOAuthPopupOpener,
9
+ relayOAuthCallbackToOpener,
10
+ } from './oauth-popup.ts';
8
11
  import type { AppConnectContext } from './types.ts';
9
- import { clearSessionStorage } from './utils/session-utils.ts';
12
+ import { storeExpiresAt } from './utils/session-utils.ts';
10
13
 
11
14
  /**
12
15
  * On `controller.start()`:
13
16
  *
14
17
  * 1. If the browser has been redirected back to the SDK's frontend
15
- * OAuth callback path (`HUBSPOT_FRONTEND_CALLBACK_PATH`) with
16
- * `?code` + `?state`, POST those values to the SDK's
17
- * `auth/complete` endpoint to finish the token exchange. The
18
- * server sets the durable session cookies on the response (in the
19
- * same `(frontend, edge)` CHIPS partition the SDK reads them from
20
- * on subsequent API fetches) and returns `{ expires_at,
21
- * return_path }`. Replace the URL with `${return_path}?
22
- * ${EXPIRES_AT_URL_PARAM}=${expires_at}` so the rest of init
23
- * runs against the page the user actually started the connect
24
- * flow from.
25
- * 2. Pick up `?__hs_expires_at` from `window.location` (placed there
26
- * by step 1, or by an in-progress refresh hop), persist it to the
27
- * controller's store + sessionStorage, and strip it from the
28
- * address bar so it isn't logged or bookmarked.
18
+ * OAuth callback path (`/__hubspot_oauth_callback`) with `?code` +
19
+ * `?state`:
20
+ * - **Redirect flow** (no `window.opener`): POST to `auth/complete`
21
+ * from this window, persist `expires_at`, and `history.replaceState`
22
+ * to `return_path`.
23
+ * - **Popup flow** (`window.opener` present): relay `code` + `state`
24
+ * to the opener via `postMessage` and close. The opener POSTs to
25
+ * `auth/complete` so partitioned cookies match `init-session`.
26
+ * 2. Pick up `?__hs_expires_at` from `window.location` (refresh hop),
27
+ * persist it, and strip it from the address bar.
29
28
  *
30
- * A no-op when neither set of parameters is present (every page
31
- * load other than the OAuth return trip).
29
+ * A no-op when neither set of parameters is present.
32
30
  */
33
31
  export async function initAppConnect(
34
32
  context: AppConnectContext
@@ -44,38 +42,15 @@ async function consumeOAuthCallback(context: AppConnectContext): Promise<void> {
44
42
  const state = params.get(AUTH_COMPLETE_STATE_PARAM);
45
43
  if (!code || !state) return;
46
44
 
47
- const completeUrl = new URL(
48
- `${context.config.hubSpotConnectBaseUrl}/auth/complete`,
49
- window.location.origin
50
- );
51
- completeUrl.searchParams.set(AUTH_COMPLETE_CODE_PARAM, code);
52
- completeUrl.searchParams.set(AUTH_COMPLETE_STATE_PARAM, state);
53
-
54
- let response: Response;
55
- try {
56
- response = await fetch(completeUrl.toString(), {
57
- method: 'POST',
58
- credentials: 'include',
59
- });
60
- } catch (err) {
61
- clearSessionStorage(context);
62
- throw new Error(
63
- `Failed to complete HubSpot OAuth: ${err instanceof Error ? err.message : String(err)}`
64
- );
45
+ if (hasOAuthPopupOpener()) {
46
+ relayOAuthCallbackToOpener({ code, state });
47
+ return;
65
48
  }
66
49
 
67
- if (!response.ok) {
68
- clearSessionStorage(context);
69
- throw new Error(
70
- `Failed to complete HubSpot OAuth: ${response.status} ${response.statusText}`
71
- );
72
- }
73
-
74
- const body = (await response.json()) as AuthCompleteResponse;
75
-
50
+ const body = await completeHubSpotOAuthSession(context, { code, state });
76
51
  const { expires_at: expiresAt, return_path: returnPath } = body;
77
- context.store.setState({ expiresAt });
78
- context.sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAt));
52
+
53
+ storeExpiresAt({ context, expiresAtMs: expiresAt });
79
54
 
80
55
  const targetUrl = new URL(returnPath, window.location.origin);
81
56
  history.replaceState(
@@ -0,0 +1,106 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { noopLogger } from '../../shared/logger.ts';
4
+ import { EXPIRES_AT_KEY } from './constants.ts';
5
+ import { completeHubSpotOAuthSession } from './oauth-complete.ts';
6
+ import type {
7
+ AppConnectContext,
8
+ AppConnectInternalState,
9
+ SessionStorage,
10
+ } from './types.ts';
11
+ import { createStore } from './utils/store-utils.ts';
12
+
13
+ const HUBSPOT_CONNECT_BASE_URL =
14
+ 'https://edge.example.com/functions/v1/hubspot-connect';
15
+
16
+ function createInMemorySessionStorage(): SessionStorage {
17
+ const map = new Map<string, string>();
18
+ return {
19
+ getItem: (key) => map.get(key) ?? null,
20
+ setItem: (key, value) => {
21
+ map.set(key, value);
22
+ },
23
+ removeItem: (key) => {
24
+ map.delete(key);
25
+ },
26
+ };
27
+ }
28
+
29
+ function createTestContext(): AppConnectContext {
30
+ const initialState: AppConnectInternalState = {
31
+ isInitComplete: true,
32
+ isConnectInFlight: false,
33
+ isSessionConnected: false,
34
+ isDisconnectInFlight: false,
35
+ error: null,
36
+ expiresAt: null,
37
+ };
38
+ return {
39
+ config: { hubSpotConnectBaseUrl: HUBSPOT_CONNECT_BASE_URL },
40
+ logger: noopLogger,
41
+ sessionStorage: createInMemorySessionStorage(),
42
+ store: createStore<AppConnectInternalState>(initialState),
43
+ };
44
+ }
45
+
46
+ describe('completeHubSpotOAuthSession', () => {
47
+ afterEach(() => {
48
+ vi.unstubAllGlobals();
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ it('POSTs code and state to auth/complete with credentials', async () => {
53
+ const expiresAt = Date.now() + 1800 * 1000;
54
+ vi.stubGlobal('window', {
55
+ location: { origin: 'https://app.example.com' },
56
+ });
57
+ const fetchSpy = vi
58
+ .spyOn(globalThis, 'fetch')
59
+ .mockResolvedValue(
60
+ new Response(
61
+ JSON.stringify({ expires_at: expiresAt, return_path: '/home' }),
62
+ { status: 200 }
63
+ )
64
+ );
65
+ const context = createTestContext();
66
+
67
+ const body = await completeHubSpotOAuthSession(context, {
68
+ code: 'my-code',
69
+ state: 'my-state',
70
+ });
71
+
72
+ expect(body.expires_at).toBe(expiresAt);
73
+ expect(body.return_path).toBe('/home');
74
+ const [calledUrl, calledInit] = fetchSpy.mock.calls[0]!;
75
+ const url = new URL(calledUrl as string);
76
+ expect(url.origin + url.pathname).toBe(
77
+ `${HUBSPOT_CONNECT_BASE_URL}/auth/complete`
78
+ );
79
+ expect(url.searchParams.get('code')).toBe('my-code');
80
+ expect(url.searchParams.get('state')).toBe('my-state');
81
+ expect((calledInit as RequestInit).method).toBe('POST');
82
+ expect((calledInit as RequestInit).credentials).toBe('include');
83
+ });
84
+
85
+ it('clears session storage when auth/complete fails', async () => {
86
+ vi.stubGlobal('window', {
87
+ location: { origin: 'https://app.example.com' },
88
+ });
89
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
90
+ new Response('{"error":"State mismatch"}', {
91
+ status: 403,
92
+ statusText: 'Forbidden',
93
+ })
94
+ );
95
+ const context = createTestContext();
96
+ context.sessionStorage.setItem(EXPIRES_AT_KEY, '999');
97
+
98
+ await expect(
99
+ completeHubSpotOAuthSession(context, {
100
+ code: 'code',
101
+ state: 'bad-state',
102
+ })
103
+ ).rejects.toThrow(/Failed to complete/);
104
+ expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
105
+ });
106
+ });
@@ -0,0 +1,53 @@
1
+ import {
2
+ AUTH_COMPLETE_CODE_PARAM,
3
+ AUTH_COMPLETE_STATE_PARAM,
4
+ } from '../../shared/constants.ts';
5
+ import type { AuthCompleteResponse } from '../../shared/wire-types.ts';
6
+ import type { AppConnectContext } from './types.ts';
7
+ import { clearSessionStorage } from './utils/session-utils.ts';
8
+
9
+ interface CompleteHubSpotOAuthSessionOptions {
10
+ code: string;
11
+ state: string;
12
+ }
13
+
14
+ /**
15
+ * Finishes the OAuth token exchange by POSTing `code` and `state` to the
16
+ * SDK's `auth/complete` route with credentialed cookies from the current
17
+ * browsing context (must match the partition used by `auth/init-session`).
18
+ */
19
+ export async function completeHubSpotOAuthSession(
20
+ context: AppConnectContext,
21
+ options: CompleteHubSpotOAuthSessionOptions
22
+ ): Promise<AuthCompleteResponse> {
23
+ const { code, state } = options;
24
+
25
+ const completeUrl = new URL(
26
+ `${context.config.hubSpotConnectBaseUrl}/auth/complete`,
27
+ window.location.origin
28
+ );
29
+ completeUrl.searchParams.set(AUTH_COMPLETE_CODE_PARAM, code);
30
+ completeUrl.searchParams.set(AUTH_COMPLETE_STATE_PARAM, state);
31
+
32
+ let response: Response;
33
+ try {
34
+ response = await fetch(completeUrl.toString(), {
35
+ method: 'POST',
36
+ credentials: 'include',
37
+ });
38
+ } catch (err) {
39
+ clearSessionStorage(context);
40
+ throw new Error(
41
+ `Failed to complete HubSpot OAuth: ${err instanceof Error ? err.message : String(err)}`
42
+ );
43
+ }
44
+
45
+ if (!response.ok) {
46
+ clearSessionStorage(context);
47
+ throw new Error(
48
+ `Failed to complete HubSpot OAuth: ${response.status} ${response.statusText}`
49
+ );
50
+ }
51
+
52
+ return (await response.json()) as AuthCompleteResponse;
53
+ }