@hubspot/app-connect-sdk 1.0.0-alpha.20 → 1.0.0-alpha.21

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 (41) hide show
  1. package/.turbo/turbo-format$colon$check.log +1 -1
  2. package/.turbo/turbo-test.log +59 -56
  3. package/.turbo/turbo-tsdown.log +18 -16
  4. package/build/tsconfig.browser.tsbuildinfo +1 -1
  5. package/build/tsconfig.server.tsbuildinfo +1 -1
  6. package/dist/browser/{HubSpotAppConnect-DFe9b90e.js → HubSpotAppConnect-721kYr9d.js} +14 -21
  7. package/dist/browser/{HubSpotAppConnect-DFe9b90e.js.map → HubSpotAppConnect-721kYr9d.js.map} +1 -1
  8. package/dist/browser/{create-BNQazCF-.js → create-DxEyGG-k.js} +82 -37
  9. package/dist/browser/create-DxEyGG-k.js.map +1 -0
  10. package/dist/browser/index.d.ts +1 -1
  11. package/dist/browser/index.js +1 -1
  12. package/dist/browser/react/lovable.d.ts +1 -1
  13. package/dist/browser/react/lovable.js +2 -2
  14. package/dist/browser/react.d.ts +1 -1
  15. package/dist/browser/react.js +1 -1
  16. package/dist/browser/{types-DkAmHcZt.d.ts → types-C3wed8dU.d.ts} +31 -1
  17. package/dist/server/hono/hubspot-connect-routes/auth-complete.js +4 -1
  18. package/dist/server/hono/hubspot-connect-routes/auth-complete.js.map +1 -1
  19. package/dist/server/hono/hubspot-connect-routes/whoami.js +51 -0
  20. package/dist/server/hono/hubspot-connect-routes/whoami.js.map +1 -0
  21. package/package.json +3 -3
  22. package/src/browser/app-connect-controller/connect-start.test.ts +1 -0
  23. package/src/browser/app-connect-controller/constants.ts +6 -4
  24. package/src/browser/app-connect-controller/create.ts +8 -2
  25. package/src/browser/app-connect-controller/disconnect.ts +1 -0
  26. package/src/browser/app-connect-controller/init.test.ts +30 -15
  27. package/src/browser/app-connect-controller/init.ts +2 -2
  28. package/src/browser/app-connect-controller/oauth-complete.test.ts +7 -3
  29. package/src/browser/app-connect-controller/oauth-popup.test.ts +1 -0
  30. package/src/browser/app-connect-controller/types.ts +3 -0
  31. package/src/browser/app-connect-controller/utils/session-utils.test.ts +73 -22
  32. package/src/browser/app-connect-controller/utils/session-utils.ts +74 -33
  33. package/src/browser/app-connect-controller/view-state.test.ts +1 -0
  34. package/src/browser/app-connect-controller/view-state.ts +1 -0
  35. package/src/browser/react/components/AppConnectHeader/AppConnectHeader.tsx +18 -29
  36. package/src/browser/types.ts +9 -0
  37. package/src/server/hono/hubspot-connect-routes/auth-complete.test.ts +129 -20
  38. package/src/server/hono/hubspot-connect-routes/auth-complete.ts +8 -1
  39. package/src/server/hono/hubspot-connect-routes/whoami.ts +74 -0
  40. package/src/shared/wire-types.ts +30 -0
  41. package/dist/browser/create-BNQazCF-.js.map +0 -1
@@ -1,55 +1,96 @@
1
- import { EXPIRES_AT_KEY } from '../constants.ts';
1
+ import type { AuthCompleteWhoami } from '../../../shared/wire-types.ts';
2
+ import { SESSION_STORAGE_KEY } from '../constants.ts';
2
3
  import type { AppConnectContext } from '../types.ts';
3
4
 
4
- interface StoreExpiresAtOptions {
5
- context: AppConnectContext;
6
- expiresAtMs: number;
5
+ interface StoredSession {
6
+ expiresAt: number;
7
+ whoami: AuthCompleteWhoami | null;
8
+ }
9
+
10
+ function readStoredSession(context: AppConnectContext): StoredSession | null {
11
+ const raw = context.sessionStorage.getItem(SESSION_STORAGE_KEY);
12
+ if (!raw) return null;
13
+ let parsed: StoredSession;
14
+ try {
15
+ parsed = JSON.parse(raw) as StoredSession;
16
+ } catch {
17
+ context.sessionStorage.removeItem(SESSION_STORAGE_KEY);
18
+ return null;
19
+ }
20
+ if (typeof parsed.expiresAt !== 'number' || isNaN(parsed.expiresAt)) {
21
+ context.sessionStorage.removeItem(SESSION_STORAGE_KEY);
22
+ return null;
23
+ }
24
+ if (Date.now() > parsed.expiresAt) {
25
+ context.sessionStorage.removeItem(SESSION_STORAGE_KEY);
26
+ return null;
27
+ }
28
+ return parsed;
29
+ }
30
+
31
+ function writeStoredSession(
32
+ context: AppConnectContext,
33
+ session: StoredSession
34
+ ): void {
35
+ context.sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
7
36
  }
8
37
 
9
38
  /**
10
- * Persists the access-token `expiresAt` (Unix epoch milliseconds) to
11
- * both the in-memory store and `sessionStorage` so it survives
12
- * full-page navigations within the same tab.
39
+ * Reads the persisted session blob from `sessionStorage`. Returns
40
+ * `null` (and auto-removes the entry) if the blob is missing,
41
+ * malformed, or already expired.
13
42
  */
14
- export function storeExpiresAt(options: StoreExpiresAtOptions): void {
15
- const { context, expiresAtMs } = options;
16
- context.store.setState({ expiresAt: expiresAtMs });
17
- context.sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAtMs));
43
+ export function getSessionFromSessionStorage(
44
+ context: AppConnectContext
45
+ ): StoredSession | null {
46
+ return readStoredSession(context);
18
47
  }
19
48
 
20
49
  /**
21
- * Reads the persisted `expiresAt` from `sessionStorage`. Removes the
22
- * value (and returns `null`) if it is malformed or already expired,
23
- * so a stale entry never reactivates a dead session.
50
+ * Persists both `expiresAt` and `whoami` to the in-memory store and
51
+ * `sessionStorage` as a single JSON blob. Called from `init.ts` after
52
+ * a successful OAuth token exchange.
24
53
  */
25
- export function getExpiresAtFromSessionStorage(
26
- context: AppConnectContext
27
- ): number | null {
28
- const raw = context.sessionStorage.getItem(EXPIRES_AT_KEY);
29
- if (!raw) return null;
30
- const val = parseInt(raw, 10);
31
- if (isNaN(val)) {
32
- context.sessionStorage.removeItem(EXPIRES_AT_KEY);
33
- return null;
34
- }
35
- if (Date.now() > val) {
36
- context.sessionStorage.removeItem(EXPIRES_AT_KEY);
37
- return null;
38
- }
39
- return val;
54
+ export function storeSession(options: {
55
+ context: AppConnectContext;
56
+ expiresAtMs: number;
57
+ whoami: AuthCompleteWhoami;
58
+ }): void {
59
+ const { context, expiresAtMs, whoami } = options;
60
+ context.store.setState({ expiresAt: expiresAtMs, whoami });
61
+ writeStoredSession(context, { expiresAt: expiresAtMs, whoami });
62
+ }
63
+
64
+ /**
65
+ * Updates only `expiresAt` in both the store and `sessionStorage`,
66
+ * preserving the existing `whoami` from in-memory store state. Called
67
+ * from `refresh.ts` after a successful token refresh.
68
+ */
69
+ export function storeExpiresAt(options: {
70
+ context: AppConnectContext;
71
+ expiresAtMs: number;
72
+ }): void {
73
+ const { context, expiresAtMs } = options;
74
+ const currentWhoami = context.store.getSnapshot().whoami;
75
+ context.store.setState({ expiresAt: expiresAtMs });
76
+ writeStoredSession(context, {
77
+ expiresAt: expiresAtMs,
78
+ whoami: currentWhoami,
79
+ });
40
80
  }
41
81
 
42
82
  /**
43
- * Clears the persisted session-storage state. Called on disconnect.
83
+ * Removes the single session-storage key, clearing all persisted
84
+ * session state. Called on disconnect and on auth-complete errors.
44
85
  */
45
86
  export function clearSessionStorage(context: AppConnectContext): void {
46
- context.sessionStorage.removeItem(EXPIRES_AT_KEY);
87
+ context.sessionStorage.removeItem(SESSION_STORAGE_KEY);
47
88
  }
48
89
 
49
90
  /**
50
91
  * Returns `true` when the controller has an `expiresAt` whose value
51
- * is still in the future. Used both to drive the UI status and to
52
- * decide whether the refresh scheduler should run.
92
+ * is still in the future. Used to drive the UI status and the refresh
93
+ * scheduler.
53
94
  */
54
95
  export function isClientSessionActive(context: AppConnectContext): boolean {
55
96
  const state = context.store.getSnapshot();
@@ -13,6 +13,7 @@ function makeState(
13
13
  isDisconnectInFlight: false,
14
14
  error: null,
15
15
  expiresAt: null,
16
+ whoami: null,
16
17
  ...overrides,
17
18
  };
18
19
  }
@@ -11,6 +11,7 @@ const noop = (): Promise<void> => Promise.resolve();
11
11
  export const SERVER_VIEW: AppConnectState = {
12
12
  status: 'initializing',
13
13
  error: null,
14
+ whoami: null,
14
15
  connectToHubSpot: noop,
15
16
  disconnectFromHubSpot: noop,
16
17
  };
@@ -9,28 +9,6 @@ import { LogoutIcon } from '../icons/LogoutIcon.tsx';
9
9
  import { ShareButton } from '../ShareButton/ShareButton.tsx';
10
10
  import { styles } from './AppConnectHeader.css.ts';
11
11
 
12
- interface FakeUser {
13
- firstName: string;
14
- lastName: string;
15
- email: string;
16
- }
17
-
18
- const FAKE_USER: FakeUser = {
19
- firstName: 'Gabby',
20
- lastName: 'Martinez',
21
- email: 'gabby.martinez@acmecorp.com',
22
- };
23
-
24
- function getUserInitials(user: FakeUser): string {
25
- const first = user.firstName.charAt(0).toUpperCase();
26
- const last = user.lastName.charAt(0).toUpperCase();
27
- return `${first}${last}`;
28
- }
29
-
30
- function getFullName(user: FakeUser): string {
31
- return `${user.firstName} ${user.lastName}`;
32
- }
33
-
34
12
  interface AppConnectHeaderProps {
35
13
  title: string;
36
14
  }
@@ -56,6 +34,8 @@ export function AppConnectHeader({ title }: AppConnectHeaderProps) {
56
34
  }
57
35
 
58
36
  function ViewingHubSpotContextRow() {
37
+ const { whoami } = useHubSpotAppConnect();
38
+ const hubLabel = whoami?.hub?.domain ?? whoami?.hub?.id ?? 'HubSpot';
59
39
  return (
60
40
  <div className={styles.contextRow}>
61
41
  <HubSpotDataSourceIcon className={styles.contextIcon} />
@@ -67,7 +47,7 @@ function ViewingHubSpotContextRow() {
67
47
  event.preventDefault();
68
48
  }}
69
49
  >
70
- Acme Corp · HubSpot
50
+ {hubLabel}
71
51
  <ExternalLinkIcon className={styles.contextExternalIcon} />
72
52
  </a>
73
53
  </div>
@@ -75,9 +55,16 @@ function ViewingHubSpotContextRow() {
75
55
  }
76
56
 
77
57
  function UserMenu() {
78
- const { disconnectFromHubSpot } = useHubSpotAppConnect();
79
- const initials = getUserInitials(FAKE_USER);
80
- const fullName = getFullName(FAKE_USER);
58
+ const { whoami, disconnectFromHubSpot } = useHubSpotAppConnect();
59
+ const user = whoami?.user;
60
+ const nameParts = [user?.firstName, user?.lastName].filter(
61
+ (p): p is string => typeof p === 'string' && p.length > 0
62
+ );
63
+ const initials =
64
+ nameParts.map((n) => n[0]!.toUpperCase()).join('') ||
65
+ user?.email?.[0]?.toUpperCase() ||
66
+ '?';
67
+ const displayName = nameParts.join(' ') || user?.email || 'HubSpot User';
81
68
 
82
69
  return (
83
70
  <Menu.Root modal={false}>
@@ -88,7 +75,7 @@ function UserMenu() {
88
75
  >
89
76
  {initials}
90
77
  </span>
91
- <span className={styles.triggerName}>{fullName}</span>
78
+ <span className={styles.triggerName}>{displayName}</span>
92
79
  <ChevronDownIcon className={styles.chevron} />
93
80
  </Menu.Trigger>
94
81
  <Menu.Portal>
@@ -102,8 +89,10 @@ function UserMenu() {
102
89
  {initials}
103
90
  </span>
104
91
  <div className={styles.userInfoText}>
105
- <span className={styles.userInfoName}>{fullName}</span>
106
- <span className={styles.userInfoEmail}>{FAKE_USER.email}</span>
92
+ <span className={styles.userInfoName}>{displayName}</span>
93
+ {user?.email ? (
94
+ <span className={styles.userInfoEmail}>{user.email}</span>
95
+ ) : null}
107
96
  </div>
108
97
  </div>
109
98
  <Menu.Separator className={styles.separator} />
@@ -1,3 +1,5 @@
1
+ import type { AuthCompleteWhoami } from '../shared/wire-types.ts';
2
+
1
3
  /**
2
4
  * How the browser navigates to HubSpot's OAuth authorize endpoint when
3
5
  * the user calls `connectToHubSpot`.
@@ -55,6 +57,13 @@ export interface AppConnectState {
55
57
  status: AppConnectStatus;
56
58
  /** Last error message that occurred, or `null` if no error. */
57
59
  error: string | null;
60
+ /**
61
+ * Identity of the connected HubSpot user and their account. Populated
62
+ * after a successful OAuth flow on a best-effort basis (fields may be
63
+ * absent if the access token lacked the required scopes). `null` before
64
+ * connect or after disconnect.
65
+ */
66
+ whoami: AuthCompleteWhoami | null;
58
67
  /**
59
68
  * Begins the OAuth connect flow. On success the browser either
60
69
  * redirects to HubSpot's authorize endpoint or opens it in a popup
@@ -78,6 +78,57 @@ function buildCompleteUrl(stateValue: string, code = 'test-auth-code'): string {
78
78
  return url.toString();
79
79
  }
80
80
 
81
+ const MOCK_TOKEN_RESPONSE = {
82
+ access_token: 'new-access-token',
83
+ refresh_token: 'new-refresh-token',
84
+ expires_in: 1800,
85
+ };
86
+
87
+ const MOCK_INTROSPECT_RESPONSE = {
88
+ active: true,
89
+ token_use: 'access_token',
90
+ hub_id: 42,
91
+ user_id: 99,
92
+ app_id: 1,
93
+ client_id: 'test-client-id',
94
+ is_private_distribution: false,
95
+ scopes: ['crm.objects.contacts.read'],
96
+ signed_access_token: {},
97
+ token: 'new-access-token',
98
+ token_type: 'Bearer',
99
+ };
100
+
101
+ const MOCK_PORTAL_RESPONSE = {
102
+ portalId: 42,
103
+ uiDomain: 'app.hubspot.com',
104
+ accountType: 'STANDARD',
105
+ timeZone: 'UTC',
106
+ companyCurrency: 'USD',
107
+ dataHostingLocation: 'US',
108
+ utcOffset: '+00:00',
109
+ utcOffsetMilliseconds: 0,
110
+ additionalCurrencies: [],
111
+ };
112
+
113
+ const MOCK_USER_RESPONSE = {
114
+ id: '99',
115
+ email: 'user@example.com',
116
+ firstName: 'Pat',
117
+ lastName: 'Test',
118
+ roleIds: [],
119
+ superAdmin: false,
120
+ };
121
+
122
+ function mockFetchSequence(responses: unknown[]): ReturnType<typeof vi.spyOn> {
123
+ let spy = vi.spyOn(globalThis, 'fetch');
124
+ for (const body of responses) {
125
+ spy = spy.mockResolvedValueOnce(
126
+ new Response(JSON.stringify(body), { status: 200 })
127
+ );
128
+ }
129
+ return spy;
130
+ }
131
+
81
132
  describe('handleAuthComplete', () => {
82
133
  afterEach(() => {
83
134
  vi.restoreAllMocks();
@@ -192,16 +243,12 @@ describe('handleAuthComplete', () => {
192
243
  });
193
244
 
194
245
  it('sends the OAuth token endpoint the same redirect_uri it advertised in init-session', async () => {
195
- const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
196
- new Response(
197
- JSON.stringify({
198
- access_token: 'at',
199
- refresh_token: 'rt',
200
- expires_in: 1800,
201
- }),
202
- { status: 200 }
203
- )
204
- );
246
+ const fetchSpy = mockFetchSequence([
247
+ { access_token: 'at', refresh_token: 'rt', expires_in: 1800 },
248
+ MOCK_INTROSPECT_RESPONSE,
249
+ MOCK_PORTAL_RESPONSE,
250
+ MOCK_USER_RESPONSE,
251
+ ]);
205
252
  const app = new Hono();
206
253
  app.post(`${BASE_PATH}/auth/complete`, (c) =>
207
254
  handleAuthComplete(c, buildOAuthRouteOptions())
@@ -223,16 +270,12 @@ describe('handleAuthComplete', () => {
223
270
 
224
271
  it('returns 200 with expires_at + return_path and sets durable cookies on success', async () => {
225
272
  const beforeMs = Date.now();
226
- vi.spyOn(globalThis, 'fetch').mockResolvedValue(
227
- new Response(
228
- JSON.stringify({
229
- access_token: 'new-access-token',
230
- refresh_token: 'new-refresh-token',
231
- expires_in: 1800,
232
- }),
233
- { status: 200 }
234
- )
235
- );
273
+ mockFetchSequence([
274
+ MOCK_TOKEN_RESPONSE,
275
+ MOCK_INTROSPECT_RESPONSE,
276
+ MOCK_PORTAL_RESPONSE,
277
+ MOCK_USER_RESPONSE,
278
+ ]);
236
279
  const app = new Hono();
237
280
  app.post(`${BASE_PATH}/auth/complete`, (c) =>
238
281
  handleAuthComplete(c, buildOAuthRouteOptions())
@@ -250,9 +293,19 @@ describe('handleAuthComplete', () => {
250
293
  const body = (await res.json()) as {
251
294
  expires_at: number;
252
295
  return_path: string;
296
+ whoami: { user?: { id: string; email: string }; hub?: { id: number } };
253
297
  };
254
298
  expect(body.return_path).toBe('/dashboard');
255
299
  expect(body.expires_at).toBeGreaterThanOrEqual(beforeMs + 1800 * 1000 - 50);
300
+ expect(body.whoami.user).toMatchObject({
301
+ id: '99',
302
+ email: 'user@example.com',
303
+ firstName: 'Pat',
304
+ });
305
+ expect(body.whoami.hub).toMatchObject({
306
+ id: 42,
307
+ uiDomain: 'app.hubspot.com',
308
+ });
256
309
 
257
310
  const setCookies = res.headers.getSetCookie();
258
311
 
@@ -282,4 +335,60 @@ describe('handleAuthComplete', () => {
282
335
  );
283
336
  expect(stateCleared).toContain('Max-Age=0');
284
337
  });
338
+
339
+ it('returns 200 with whoami.hub only when introspect fails', async () => {
340
+ vi.spyOn(globalThis, 'fetch')
341
+ .mockResolvedValueOnce(
342
+ new Response(JSON.stringify(MOCK_TOKEN_RESPONSE), { status: 200 })
343
+ )
344
+ .mockResolvedValueOnce(
345
+ new Response(JSON.stringify({ error: 'unauthorized_client' }), {
346
+ status: 401,
347
+ })
348
+ )
349
+ .mockResolvedValueOnce(
350
+ new Response(JSON.stringify(MOCK_PORTAL_RESPONSE), { status: 200 })
351
+ );
352
+ const app = new Hono();
353
+ app.post(`${BASE_PATH}/auth/complete`, (c) =>
354
+ handleAuthComplete(c, buildOAuthRouteOptions())
355
+ );
356
+ const { stateValue, cookieHeader } = buildCompleteFixture();
357
+ const res = await app.request(buildCompleteUrl(stateValue), {
358
+ method: 'POST',
359
+ headers: { Cookie: cookieHeader },
360
+ });
361
+
362
+ expect(res.status).toBe(200);
363
+ const body = (await res.json()) as {
364
+ whoami: { user: unknown; hub?: { id: number } };
365
+ };
366
+ expect(body.whoami.user).toEqual({});
367
+ expect(body.whoami.hub).toMatchObject({ id: 42 });
368
+ });
369
+
370
+ it('returns 200 with empty whoami when all whoami calls fail', async () => {
371
+ vi.spyOn(globalThis, 'fetch')
372
+ .mockResolvedValueOnce(
373
+ new Response(JSON.stringify(MOCK_TOKEN_RESPONSE), { status: 200 })
374
+ )
375
+ .mockResolvedValueOnce(new Response('', { status: 500 }))
376
+ .mockResolvedValueOnce(new Response('', { status: 500 }));
377
+ const app = new Hono();
378
+ app.post(`${BASE_PATH}/auth/complete`, (c) =>
379
+ handleAuthComplete(c, buildOAuthRouteOptions())
380
+ );
381
+ const { stateValue, cookieHeader } = buildCompleteFixture();
382
+ const res = await app.request(buildCompleteUrl(stateValue), {
383
+ method: 'POST',
384
+ headers: { Cookie: cookieHeader },
385
+ });
386
+
387
+ expect(res.status).toBe(200);
388
+ const body = (await res.json()) as {
389
+ whoami: { user: unknown; hub: unknown };
390
+ };
391
+ expect(body.whoami.user).toEqual({});
392
+ expect(body.whoami.hub).toEqual({});
393
+ });
285
394
  });
@@ -4,6 +4,7 @@ import {
4
4
  AUTH_COMPLETE_CODE_PARAM,
5
5
  AUTH_COMPLETE_STATE_PARAM,
6
6
  } from '../../../shared/constants.ts';
7
+ import type { AuthCompleteResponse } from '../../../shared/wire-types.ts';
7
8
  import {
8
9
  HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,
9
10
  HUBSPOT_APP_ORIGIN_COOKIE_NAME,
@@ -31,6 +32,7 @@ import {
31
32
  isSafeReturnPath,
32
33
  parseAppOriginHeader,
33
34
  } from './utils.ts';
35
+ import { fetchWhoami } from './whoami.ts';
34
36
 
35
37
  interface OAuthStatePayload {
36
38
  return_path?: string;
@@ -238,5 +240,10 @@ export async function handleAuthComplete(
238
240
  setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_PKCE_VERIFIER) });
239
241
  setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_OAUTH_STATE) });
240
242
 
241
- return c.json({ expires_at: expiresAt, return_path: returnPath });
243
+ const whoami = await fetchWhoami(accessToken, hubspotConnectEnv);
244
+ return c.json({
245
+ expires_at: expiresAt,
246
+ return_path: returnPath,
247
+ whoami,
248
+ } satisfies AuthCompleteResponse);
242
249
  }
@@ -0,0 +1,74 @@
1
+ import type { AuthCompleteWhoami } from '../../../shared/wire-types.ts';
2
+ import {
3
+ account,
4
+ authOauth,
5
+ createHubSpotClient,
6
+ settingsUsers,
7
+ } from '../../api-client-core/index.ts';
8
+ import { fetchTransportPlugin } from '../../api-client-core/plugins/fetch-transport.ts';
9
+ import { getHubSpotApiOrigin } from '../../utils/env-utils.ts';
10
+ import type { HubSpotConnectRoutesEnv } from './load-hubspot-connect-routes-env.ts';
11
+
12
+ export async function fetchWhoami(
13
+ accessToken: string,
14
+ hubspotConnectEnv: HubSpotConnectRoutesEnv
15
+ ): Promise<AuthCompleteWhoami> {
16
+ const apiClient = createHubSpotClient({
17
+ plugins: [
18
+ fetchTransportPlugin({
19
+ getEndpoint: getHubSpotApiOrigin,
20
+ getAccessToken: () => accessToken,
21
+ }),
22
+ ],
23
+ });
24
+
25
+ const introspectInput = hubspotConnectEnv.isCimdEnabled
26
+ ? { token: accessToken }
27
+ : {
28
+ client_id: hubspotConnectEnv.hubspotClientId,
29
+ client_secret: hubspotConnectEnv.hubspotClientSecret,
30
+ token: accessToken,
31
+ };
32
+
33
+ // Introspect and account.get are independent — run in parallel.
34
+ // settingsUsers.get requires user_id from introspect, so it runs after.
35
+ const [introspectResult, hubResult] = await Promise.allSettled([
36
+ apiClient.send(authOauth.introspectToken(introspectInput)),
37
+ apiClient.send(account.get()),
38
+ ]);
39
+
40
+ const whoami: AuthCompleteWhoami = {
41
+ hub: {},
42
+ user: {},
43
+ };
44
+
45
+ if (hubResult.status === 'fulfilled') {
46
+ const portal = hubResult.value;
47
+ whoami.hub.id = portal.portalId;
48
+ whoami.hub.uiDomain = portal.uiDomain;
49
+ }
50
+
51
+ if (
52
+ introspectResult.status === 'fulfilled' &&
53
+ introspectResult.value.token_use === 'access_token'
54
+ ) {
55
+ whoami.hub.domain = introspectResult.value.hub_domain;
56
+
57
+ const userId = String(introspectResult.value.user_id);
58
+ const userResult = await apiClient
59
+ .send(settingsUsers.get({ userId, idProperty: 'USER_ID' }))
60
+ .then(
61
+ (u) => ({ ok: true as const, value: u }),
62
+ () => ({ ok: false as const })
63
+ );
64
+ if (userResult.ok) {
65
+ const u = userResult.value;
66
+ whoami.user.id = u.id;
67
+ whoami.user.email = u.email;
68
+ whoami.user.firstName = u.firstName;
69
+ whoami.user.lastName = u.lastName;
70
+ }
71
+ }
72
+
73
+ return whoami;
74
+ }
@@ -15,6 +15,30 @@ export interface InitSessionResponse {
15
15
  authorization_url: string;
16
16
  }
17
17
 
18
+ export interface AuthCompleteWhoamiUser {
19
+ id?: string | undefined;
20
+ email?: string | undefined;
21
+ firstName?: string | undefined;
22
+ lastName?: string | undefined;
23
+ }
24
+
25
+ export interface AuthCompleteWhoamiHub {
26
+ id?: number | undefined;
27
+ domain?: string | undefined;
28
+ uiDomain?: string | undefined;
29
+ }
30
+
31
+ /**
32
+ * Identity information for the authenticated user and their HubSpot
33
+ * account. Both fields are optional because the access token may not
34
+ * carry the scopes needed to fetch each piece (`public-users-read` for
35
+ * `user`, `external-settings-access` for `hub`).
36
+ */
37
+ export interface AuthCompleteWhoami {
38
+ user: AuthCompleteWhoamiUser;
39
+ hub: AuthCompleteWhoamiHub;
40
+ }
41
+
18
42
  /**
19
43
  * Body returned by `POST /auth/complete` once the SDK has exchanged
20
44
  * the OAuth authorization code for an access + refresh token. The
@@ -32,6 +56,12 @@ export interface AuthCompleteResponse {
32
56
  * `/` and is restricted to safe internal paths.
33
57
  */
34
58
  return_path: string;
59
+ /**
60
+ * Identity of the authenticated user and their HubSpot account.
61
+ * Populated on a best-effort basis — fields are absent when the
62
+ * access token lacks the required scopes.
63
+ */
64
+ whoami: AuthCompleteWhoami;
35
65
  }
36
66
 
37
67
  /**