@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,3 +1,26 @@
1
+ //#region src/shared/wire-types.d.ts
2
+ interface AuthCompleteWhoamiUser {
3
+ id?: string | undefined;
4
+ email?: string | undefined;
5
+ firstName?: string | undefined;
6
+ lastName?: string | undefined;
7
+ }
8
+ interface AuthCompleteWhoamiHub {
9
+ id?: number | undefined;
10
+ domain?: string | undefined;
11
+ uiDomain?: string | undefined;
12
+ }
13
+ /**
14
+ * Identity information for the authenticated user and their HubSpot
15
+ * account. Both fields are optional because the access token may not
16
+ * carry the scopes needed to fetch each piece (`public-users-read` for
17
+ * `user`, `external-settings-access` for `hub`).
18
+ */
19
+ interface AuthCompleteWhoami {
20
+ user: AuthCompleteWhoamiUser;
21
+ hub: AuthCompleteWhoamiHub;
22
+ }
23
+ //#endregion
1
24
  //#region src/browser/types.d.ts
2
25
  /**
3
26
  * How the browser navigates to HubSpot's OAuth authorize endpoint when
@@ -48,6 +71,13 @@ interface AppConnectState {
48
71
  status: AppConnectStatus;
49
72
  /** Last error message that occurred, or `null` if no error. */
50
73
  error: string | null;
74
+ /**
75
+ * Identity of the connected HubSpot user and their account. Populated
76
+ * after a successful OAuth flow on a best-effort basis (fields may be
77
+ * absent if the access token lacked the required scopes). `null` before
78
+ * connect or after disconnect.
79
+ */
80
+ whoami: AuthCompleteWhoami | null;
51
81
  /**
52
82
  * Begins the OAuth connect flow. On success the browser either
53
83
  * redirects to HubSpot's authorize endpoint or opens it in a popup
@@ -91,4 +121,4 @@ interface AppConnectController {
91
121
  }
92
122
  //#endregion
93
123
  export { OAuthConnectMode as a, AppConnectStatus as i, AppConnectController as n, AppConnectState as r, AppConnectBrowserConfig as t };
94
- //# sourceMappingURL=types-DkAmHcZt.d.ts.map
124
+ //# sourceMappingURL=types-C3wed8dU.d.ts.map
@@ -6,6 +6,7 @@ import { serializeCookie, setResponseCookie } from "../utils/cookie-utils.js";
6
6
  import { REFRESH_COOKIE_MAX_AGE_SEC } from "./constants.js";
7
7
  import { buildClientAssertion, buildClientAssertionFormParams, buildClientSecretFormParams, buildTokenEndpointDpopProof, requestOAuthToken } from "./oauth-client.js";
8
8
  import { buildCimdClientIdUrlFromRequest, buildFrontendOAuthRedirectUri, clearTempCookie, isPositiveFiniteNumber, isSafeReturnPath, parseAppOriginHeader } from "./utils.js";
9
+ import { fetchWhoami } from "./whoami.js";
9
10
  //#region src/server/hono/hubspot-connect-routes/auth-complete.ts
10
11
  /**
11
12
  * Cross-origin OAuth completion endpoint.
@@ -144,9 +145,11 @@ async function handleAuthComplete(c, options) {
144
145
  c,
145
146
  value: clearTempCookie(TEMP_COOKIE_OAUTH_STATE)
146
147
  });
148
+ const whoami = await fetchWhoami(accessToken, hubspotConnectEnv);
147
149
  return c.json({
148
150
  expires_at: expiresAt,
149
- return_path: returnPath
151
+ return_path: returnPath,
152
+ whoami
150
153
  });
151
154
  }
152
155
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"auth-complete.js","names":[],"sources":["../../../../src/server/hono/hubspot-connect-routes/auth-complete.ts"],"sourcesContent":["import type { Context } from 'hono';\n\nimport {\n AUTH_COMPLETE_CODE_PARAM,\n AUTH_COMPLETE_STATE_PARAM,\n} from '../../../shared/constants.ts';\nimport {\n HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,\n HUBSPOT_APP_ORIGIN_COOKIE_NAME,\n HUBSPOT_REFRESH_COOKIE_PREFIX,\n TEMP_COOKIE_OAUTH_STATE,\n TEMP_COOKIE_PKCE_VERIFIER,\n} from '../../constants.ts';\nimport { base64urlDecode } from '../../utils/base64-utils.ts';\nimport { parseCookies } from '../../utils/cookie-utils.ts';\nimport { serializeCookie, setResponseCookie } from '../utils/cookie-utils.ts';\nimport { REFRESH_COOKIE_MAX_AGE_SEC } from './constants.ts';\nimport {\n buildClientAssertion,\n buildClientAssertionFormParams,\n buildClientSecretFormParams,\n buildTokenEndpointDpopProof,\n requestOAuthToken,\n} from './oauth-client.ts';\nimport type { HubSpotConnectOAuthRouteOptions } from './types.ts';\nimport {\n buildCimdClientIdUrlFromRequest,\n buildFrontendOAuthRedirectUri,\n clearTempCookie,\n isPositiveFiniteNumber,\n isSafeReturnPath,\n parseAppOriginHeader,\n} from './utils.ts';\n\ninterface OAuthStatePayload {\n return_path?: string;\n sid?: string;\n}\n\n/**\n * Cross-origin OAuth completion endpoint.\n *\n * Called from the React app on the frontend OAuth callback path\n * (`HUBSPOT_FRONTEND_CALLBACK_PATH`) once HubSpot has redirected the\n * browser back with `?code` + `?state`. The browser POSTs both\n * values here as a credentialed cross-site fetch — same partition as\n * `init-session`, so the temp PKCE/state cookies are visible — and\n * the SDK:\n *\n * 1. Validates `state` against the temp `__hs_oauth_state` cookie.\n * 2. Pulls the PKCE verifier from `__hs_pkce_verifier`.\n * 3. Rebuilds the same `redirect_uri` it sent to HubSpot during\n * `init-session` (frontend origin + the fixed callback path);\n * the OAuth token endpoint requires the two values to match.\n * 4. Exchanges `code` for an access + refresh token (with DPoP /\n * CIMD client-assertion when enabled).\n * 5. Sets the durable session cookies (access token, refresh) with\n * `SameSite=None; Secure; Partitioned` so they live in the\n * `(frontend, edge)` partition where subsequent API fetches will\n * read them.\n * 6. Clears the temp cookies.\n * 7. Returns `{ expires_at, return_path }` so the controller can\n * update its session-storage expiry tracking and navigate back to\n * the page the user started the connect flow from.\n */\nexport async function handleAuthComplete(\n c: Context,\n options: HubSpotConnectOAuthRouteOptions\n) {\n const { appKeys, refreshCookiePath, hubspotConnectEnv } = options;\n const xForwardedProto = c.req.header('x-forwarded-proto') ?? undefined;\n const xForwardedHost = c.req.header('x-forwarded-host') ?? undefined;\n const requestHostHeader = c.req.header('host') ?? undefined;\n const code = c.req.query(AUTH_COMPLETE_CODE_PARAM);\n const state = c.req.query(AUTH_COMPLETE_STATE_PARAM);\n\n if (!code || !state) {\n return c.json({ error: 'Missing code or state' }, 400);\n }\n\n if (hubspotConnectEnv.isAppPrivateKeyRequired && !appKeys) {\n return c.json(\n {\n error:\n 'Server misconfiguration: HUBSPOT_APP_PRIVATE_KEY is required when CIMD or DPoP is enabled',\n },\n 500\n );\n }\n\n const cookies = parseCookies(c.req.header('Cookie'));\n const expectedState = cookies[TEMP_COOKIE_OAUTH_STATE];\n const codeVerifier = cookies[TEMP_COOKIE_PKCE_VERIFIER];\n const appOriginCookie = cookies[HUBSPOT_APP_ORIGIN_COOKIE_NAME];\n\n if (!expectedState || state !== decodeURIComponent(expectedState)) {\n return c.json({ error: 'State mismatch' }, 403);\n }\n if (!codeVerifier) {\n return c.json({ error: 'Missing PKCE verifier' }, 400);\n }\n // The redirect_uri the OAuth token endpoint validates must equal\n // the one we sent during init-session. We rebuild it from the\n // pinned origin cookie so that value is anchored server-side, not\n // taken from the (caller-controlled) request `Origin` on this call.\n const appOrigin = parseAppOriginHeader(appOriginCookie);\n if (!appOrigin) {\n return c.json({ error: 'Missing app origin cookie' }, 400);\n }\n\n let statePayload: OAuthStatePayload;\n try {\n statePayload = JSON.parse(\n new TextDecoder().decode(base64urlDecode(decodeURIComponent(state)))\n ) as OAuthStatePayload;\n } catch {\n return c.json({ error: 'Malformed state value' }, 400);\n }\n const returnPath = statePayload.return_path;\n if (!returnPath || !isSafeReturnPath(returnPath)) {\n return c.json({ error: 'Invalid return path in state' }, 400);\n }\n\n const sessionId = statePayload.sid;\n if (!sessionId) {\n return c.json({ error: 'Missing app session cookie' }, 400);\n }\n\n const decodedCodeVerifier = decodeURIComponent(codeVerifier);\n\n const clientId = hubspotConnectEnv.isCimdEnabled\n ? buildCimdClientIdUrlFromRequest({\n requestUrl: c.req.url,\n basePath: options.basePath,\n xForwardedProto,\n xForwardedHost,\n requestHostHeader,\n appOrigin,\n })\n : hubspotConnectEnv.hubspotClientId;\n\n const redirectUri = buildFrontendOAuthRedirectUri(appOrigin);\n\n const tokenEndpointUrl = new URL(\n '/oauth/v1/token',\n hubspotConnectEnv.hubspotOAuthApiOrigin\n ).href;\n\n let dpopProof: string | undefined;\n if (hubspotConnectEnv.isDpopEnabled) {\n dpopProof = await buildTokenEndpointDpopProof({\n appKeys: appKeys!,\n tokenEndpointUrl,\n sessionIdHash: sessionId,\n });\n }\n\n let formParams: Record<string, string>;\n if (hubspotConnectEnv.isCimdEnabled) {\n const clientAssertion = await buildClientAssertion({\n appKeys: appKeys!,\n clientId,\n audience: tokenEndpointUrl,\n });\n formParams = {\n grant_type: 'authorization_code',\n code,\n code_verifier: decodedCodeVerifier,\n redirect_uri: redirectUri,\n ...buildClientAssertionFormParams({ clientId, clientAssertion }),\n };\n } else {\n formParams = {\n grant_type: 'authorization_code',\n code,\n code_verifier: decodedCodeVerifier,\n redirect_uri: redirectUri,\n ...buildClientSecretFormParams({\n clientId,\n clientSecret: hubspotConnectEnv.hubspotClientSecret,\n }),\n };\n }\n\n const tokenResult = await requestOAuthToken({\n tokenEndpointUrl,\n isDpopEnabled: hubspotConnectEnv.isDpopEnabled,\n ...(dpopProof !== undefined ? { dpopProof } : {}),\n formParams,\n });\n if (!tokenResult.ok) {\n return c.json(\n { error: `Token exchange failed: ${tokenResult.errorText}` },\n 502\n );\n }\n\n const {\n access_token: accessToken,\n refresh_token: refreshToken,\n expires_in,\n } = tokenResult.body;\n if (!refreshToken) {\n return c.json({ error: 'Token response missing refresh_token' }, 502);\n }\n if (!isPositiveFiniteNumber(expires_in)) {\n return c.json(\n { error: 'Token response missing or invalid expires_in' },\n 502\n );\n }\n\n const expiresAt = Date.now() + expires_in * 1000;\n const refreshCookieName = `${HUBSPOT_REFRESH_COOKIE_PREFIX}${sessionId}`;\n\n setResponseCookie({\n c,\n value: serializeCookie({\n name: HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,\n value: accessToken,\n path: '/',\n sameSite: 'None',\n partitioned: true,\n maxAge: expires_in,\n }),\n });\n setResponseCookie({\n c,\n value: serializeCookie({\n name: refreshCookieName,\n value: refreshToken,\n path: refreshCookiePath,\n sameSite: 'None',\n partitioned: true,\n maxAge: REFRESH_COOKIE_MAX_AGE_SEC,\n }),\n });\n setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_PKCE_VERIFIER) });\n setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_OAUTH_STATE) });\n\n return c.json({ expires_at: expiresAt, return_path: returnPath });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"auth-complete.js","names":[],"sources":["../../../../src/server/hono/hubspot-connect-routes/auth-complete.ts"],"sourcesContent":["import type { Context } from 'hono';\n\nimport {\n AUTH_COMPLETE_CODE_PARAM,\n AUTH_COMPLETE_STATE_PARAM,\n} from '../../../shared/constants.ts';\nimport type { AuthCompleteResponse } from '../../../shared/wire-types.ts';\nimport {\n HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,\n HUBSPOT_APP_ORIGIN_COOKIE_NAME,\n HUBSPOT_REFRESH_COOKIE_PREFIX,\n TEMP_COOKIE_OAUTH_STATE,\n TEMP_COOKIE_PKCE_VERIFIER,\n} from '../../constants.ts';\nimport { base64urlDecode } from '../../utils/base64-utils.ts';\nimport { parseCookies } from '../../utils/cookie-utils.ts';\nimport { serializeCookie, setResponseCookie } from '../utils/cookie-utils.ts';\nimport { REFRESH_COOKIE_MAX_AGE_SEC } from './constants.ts';\nimport {\n buildClientAssertion,\n buildClientAssertionFormParams,\n buildClientSecretFormParams,\n buildTokenEndpointDpopProof,\n requestOAuthToken,\n} from './oauth-client.ts';\nimport type { HubSpotConnectOAuthRouteOptions } from './types.ts';\nimport {\n buildCimdClientIdUrlFromRequest,\n buildFrontendOAuthRedirectUri,\n clearTempCookie,\n isPositiveFiniteNumber,\n isSafeReturnPath,\n parseAppOriginHeader,\n} from './utils.ts';\nimport { fetchWhoami } from './whoami.ts';\n\ninterface OAuthStatePayload {\n return_path?: string;\n sid?: string;\n}\n\n/**\n * Cross-origin OAuth completion endpoint.\n *\n * Called from the React app on the frontend OAuth callback path\n * (`HUBSPOT_FRONTEND_CALLBACK_PATH`) once HubSpot has redirected the\n * browser back with `?code` + `?state`. The browser POSTs both\n * values here as a credentialed cross-site fetch — same partition as\n * `init-session`, so the temp PKCE/state cookies are visible — and\n * the SDK:\n *\n * 1. Validates `state` against the temp `__hs_oauth_state` cookie.\n * 2. Pulls the PKCE verifier from `__hs_pkce_verifier`.\n * 3. Rebuilds the same `redirect_uri` it sent to HubSpot during\n * `init-session` (frontend origin + the fixed callback path);\n * the OAuth token endpoint requires the two values to match.\n * 4. Exchanges `code` for an access + refresh token (with DPoP /\n * CIMD client-assertion when enabled).\n * 5. Sets the durable session cookies (access token, refresh) with\n * `SameSite=None; Secure; Partitioned` so they live in the\n * `(frontend, edge)` partition where subsequent API fetches will\n * read them.\n * 6. Clears the temp cookies.\n * 7. Returns `{ expires_at, return_path }` so the controller can\n * update its session-storage expiry tracking and navigate back to\n * the page the user started the connect flow from.\n */\nexport async function handleAuthComplete(\n c: Context,\n options: HubSpotConnectOAuthRouteOptions\n) {\n const { appKeys, refreshCookiePath, hubspotConnectEnv } = options;\n const xForwardedProto = c.req.header('x-forwarded-proto') ?? undefined;\n const xForwardedHost = c.req.header('x-forwarded-host') ?? undefined;\n const requestHostHeader = c.req.header('host') ?? undefined;\n const code = c.req.query(AUTH_COMPLETE_CODE_PARAM);\n const state = c.req.query(AUTH_COMPLETE_STATE_PARAM);\n\n if (!code || !state) {\n return c.json({ error: 'Missing code or state' }, 400);\n }\n\n if (hubspotConnectEnv.isAppPrivateKeyRequired && !appKeys) {\n return c.json(\n {\n error:\n 'Server misconfiguration: HUBSPOT_APP_PRIVATE_KEY is required when CIMD or DPoP is enabled',\n },\n 500\n );\n }\n\n const cookies = parseCookies(c.req.header('Cookie'));\n const expectedState = cookies[TEMP_COOKIE_OAUTH_STATE];\n const codeVerifier = cookies[TEMP_COOKIE_PKCE_VERIFIER];\n const appOriginCookie = cookies[HUBSPOT_APP_ORIGIN_COOKIE_NAME];\n\n if (!expectedState || state !== decodeURIComponent(expectedState)) {\n return c.json({ error: 'State mismatch' }, 403);\n }\n if (!codeVerifier) {\n return c.json({ error: 'Missing PKCE verifier' }, 400);\n }\n // The redirect_uri the OAuth token endpoint validates must equal\n // the one we sent during init-session. We rebuild it from the\n // pinned origin cookie so that value is anchored server-side, not\n // taken from the (caller-controlled) request `Origin` on this call.\n const appOrigin = parseAppOriginHeader(appOriginCookie);\n if (!appOrigin) {\n return c.json({ error: 'Missing app origin cookie' }, 400);\n }\n\n let statePayload: OAuthStatePayload;\n try {\n statePayload = JSON.parse(\n new TextDecoder().decode(base64urlDecode(decodeURIComponent(state)))\n ) as OAuthStatePayload;\n } catch {\n return c.json({ error: 'Malformed state value' }, 400);\n }\n const returnPath = statePayload.return_path;\n if (!returnPath || !isSafeReturnPath(returnPath)) {\n return c.json({ error: 'Invalid return path in state' }, 400);\n }\n\n const sessionId = statePayload.sid;\n if (!sessionId) {\n return c.json({ error: 'Missing app session cookie' }, 400);\n }\n\n const decodedCodeVerifier = decodeURIComponent(codeVerifier);\n\n const clientId = hubspotConnectEnv.isCimdEnabled\n ? buildCimdClientIdUrlFromRequest({\n requestUrl: c.req.url,\n basePath: options.basePath,\n xForwardedProto,\n xForwardedHost,\n requestHostHeader,\n appOrigin,\n })\n : hubspotConnectEnv.hubspotClientId;\n\n const redirectUri = buildFrontendOAuthRedirectUri(appOrigin);\n\n const tokenEndpointUrl = new URL(\n '/oauth/v1/token',\n hubspotConnectEnv.hubspotOAuthApiOrigin\n ).href;\n\n let dpopProof: string | undefined;\n if (hubspotConnectEnv.isDpopEnabled) {\n dpopProof = await buildTokenEndpointDpopProof({\n appKeys: appKeys!,\n tokenEndpointUrl,\n sessionIdHash: sessionId,\n });\n }\n\n let formParams: Record<string, string>;\n if (hubspotConnectEnv.isCimdEnabled) {\n const clientAssertion = await buildClientAssertion({\n appKeys: appKeys!,\n clientId,\n audience: tokenEndpointUrl,\n });\n formParams = {\n grant_type: 'authorization_code',\n code,\n code_verifier: decodedCodeVerifier,\n redirect_uri: redirectUri,\n ...buildClientAssertionFormParams({ clientId, clientAssertion }),\n };\n } else {\n formParams = {\n grant_type: 'authorization_code',\n code,\n code_verifier: decodedCodeVerifier,\n redirect_uri: redirectUri,\n ...buildClientSecretFormParams({\n clientId,\n clientSecret: hubspotConnectEnv.hubspotClientSecret,\n }),\n };\n }\n\n const tokenResult = await requestOAuthToken({\n tokenEndpointUrl,\n isDpopEnabled: hubspotConnectEnv.isDpopEnabled,\n ...(dpopProof !== undefined ? { dpopProof } : {}),\n formParams,\n });\n if (!tokenResult.ok) {\n return c.json(\n { error: `Token exchange failed: ${tokenResult.errorText}` },\n 502\n );\n }\n\n const {\n access_token: accessToken,\n refresh_token: refreshToken,\n expires_in,\n } = tokenResult.body;\n if (!refreshToken) {\n return c.json({ error: 'Token response missing refresh_token' }, 502);\n }\n if (!isPositiveFiniteNumber(expires_in)) {\n return c.json(\n { error: 'Token response missing or invalid expires_in' },\n 502\n );\n }\n\n const expiresAt = Date.now() + expires_in * 1000;\n const refreshCookieName = `${HUBSPOT_REFRESH_COOKIE_PREFIX}${sessionId}`;\n\n setResponseCookie({\n c,\n value: serializeCookie({\n name: HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,\n value: accessToken,\n path: '/',\n sameSite: 'None',\n partitioned: true,\n maxAge: expires_in,\n }),\n });\n setResponseCookie({\n c,\n value: serializeCookie({\n name: refreshCookieName,\n value: refreshToken,\n path: refreshCookiePath,\n sameSite: 'None',\n partitioned: true,\n maxAge: REFRESH_COOKIE_MAX_AGE_SEC,\n }),\n });\n setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_PKCE_VERIFIER) });\n setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_OAUTH_STATE) });\n\n const whoami = await fetchWhoami(accessToken, hubspotConnectEnv);\n return c.json({\n expires_at: expiresAt,\n return_path: returnPath,\n whoami,\n } satisfies AuthCompleteResponse);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmEA,eAAsB,mBACpB,GACA,SACA;CACA,MAAM,EAAE,SAAS,mBAAmB,sBAAsB;CAC1D,MAAM,kBAAkB,EAAE,IAAI,OAAO,mBAAmB,KAAK,KAAA;CAC7D,MAAM,iBAAiB,EAAE,IAAI,OAAO,kBAAkB,KAAK,KAAA;CAC3D,MAAM,oBAAoB,EAAE,IAAI,OAAO,MAAM,KAAK,KAAA;CAClD,MAAM,OAAO,EAAE,IAAI,MAAM,wBAAwB;CACjD,MAAM,QAAQ,EAAE,IAAI,MAAM,yBAAyB;CAEnD,IAAI,CAAC,QAAQ,CAAC,OACZ,OAAO,EAAE,KAAK,EAAE,OAAO,wBAAwB,GAAG,GAAG;CAGvD,IAAI,kBAAkB,2BAA2B,CAAC,SAChD,OAAO,EAAE,KACP,EACE,OACE,4FACJ,GACA,GACF;CAGF,MAAM,UAAU,aAAa,EAAE,IAAI,OAAO,QAAQ,CAAC;CACnD,MAAM,gBAAgB,QAAQ;CAC9B,MAAM,eAAe,QAAQ;CAC7B,MAAM,kBAAkB,QAAQ;CAEhC,IAAI,CAAC,iBAAiB,UAAU,mBAAmB,aAAa,GAC9D,OAAO,EAAE,KAAK,EAAE,OAAO,iBAAiB,GAAG,GAAG;CAEhD,IAAI,CAAC,cACH,OAAO,EAAE,KAAK,EAAE,OAAO,wBAAwB,GAAG,GAAG;CAMvD,MAAM,YAAY,qBAAqB,eAAe;CACtD,IAAI,CAAC,WACH,OAAO,EAAE,KAAK,EAAE,OAAO,4BAA4B,GAAG,GAAG;CAG3D,IAAI;CACJ,IAAI;EACF,eAAe,KAAK,MAClB,IAAI,YAAY,EAAE,OAAO,gBAAgB,mBAAmB,KAAK,CAAC,CAAC,CACrE;CACF,QAAQ;EACN,OAAO,EAAE,KAAK,EAAE,OAAO,wBAAwB,GAAG,GAAG;CACvD;CACA,MAAM,aAAa,aAAa;CAChC,IAAI,CAAC,cAAc,CAAC,iBAAiB,UAAU,GAC7C,OAAO,EAAE,KAAK,EAAE,OAAO,+BAA+B,GAAG,GAAG;CAG9D,MAAM,YAAY,aAAa;CAC/B,IAAI,CAAC,WACH,OAAO,EAAE,KAAK,EAAE,OAAO,6BAA6B,GAAG,GAAG;CAG5D,MAAM,sBAAsB,mBAAmB,YAAY;CAE3D,MAAM,WAAW,kBAAkB,gBAC/B,gCAAgC;EAC9B,YAAY,EAAE,IAAI;EAClB,UAAU,QAAQ;EAClB;EACA;EACA;EACA;CACF,CAAC,IACD,kBAAkB;CAEtB,MAAM,cAAc,8BAA8B,SAAS;CAE3D,MAAM,mBAAmB,IAAI,IAC3B,mBACA,kBAAkB,qBACpB,EAAE;CAEF,IAAI;CACJ,IAAI,kBAAkB,eACpB,YAAY,MAAM,4BAA4B;EACnC;EACT;EACA,eAAe;CACjB,CAAC;CAGH,IAAI;CACJ,IAAI,kBAAkB,eAMpB,aAAa;EACX,YAAY;EACZ;EACA,eAAe;EACf,cAAc;EACd,GAAG,+BAA+B;GAAE;GAAU,iBAAA,MAVlB,qBAAqB;IACxC;IACT;IACA,UAAU;GACZ,CAAC;EAM+D,CAAC;CACjE;MAEA,aAAa;EACX,YAAY;EACZ;EACA,eAAe;EACf,cAAc;EACd,GAAG,4BAA4B;GAC7B;GACA,cAAc,kBAAkB;EAClC,CAAC;CACH;CAGF,MAAM,cAAc,MAAM,kBAAkB;EAC1C;EACA,eAAe,kBAAkB;EACjC,GAAI,cAAc,KAAA,IAAY,EAAE,UAAU,IAAI,CAAC;EAC/C;CACF,CAAC;CACD,IAAI,CAAC,YAAY,IACf,OAAO,EAAE,KACP,EAAE,OAAO,0BAA0B,YAAY,YAAY,GAC3D,GACF;CAGF,MAAM,EACJ,cAAc,aACd,eAAe,cACf,eACE,YAAY;CAChB,IAAI,CAAC,cACH,OAAO,EAAE,KAAK,EAAE,OAAO,uCAAuC,GAAG,GAAG;CAEtE,IAAI,CAAC,uBAAuB,UAAU,GACpC,OAAO,EAAE,KACP,EAAE,OAAO,+CAA+C,GACxD,GACF;CAGF,MAAM,YAAY,KAAK,IAAI,IAAI,aAAa;CAC5C,MAAM,oBAAoB,GAAG,gCAAgC;CAE7D,kBAAkB;EAChB;EACA,OAAO,gBAAgB;GACrB,MAAM;GACN,OAAO;GACP,MAAM;GACN,UAAU;GACV,aAAa;GACb,QAAQ;EACV,CAAC;CACH,CAAC;CACD,kBAAkB;EAChB;EACA,OAAO,gBAAgB;GACrB,MAAM;GACN,OAAO;GACP,MAAM;GACN,UAAU;GACV,aAAa;GACb,QAAQ;EACV,CAAC;CACH,CAAC;CACD,kBAAkB;EAAE;EAAG,OAAO,gBAAgB,yBAAyB;CAAE,CAAC;CAC1E,kBAAkB;EAAE;EAAG,OAAO,gBAAgB,uBAAuB;CAAE,CAAC;CAExE,MAAM,SAAS,MAAM,YAAY,aAAa,iBAAiB;CAC/D,OAAO,EAAE,KAAK;EACZ,YAAY;EACZ,aAAa;EACb;CACF,CAAgC;AAClC"}
@@ -0,0 +1,51 @@
1
+ import { createHubSpotClient } from "../../api-client-core/client.js";
2
+ import { account } from "../../api-client-core/apis/account/account-info.generated.js";
3
+ import { authOauth } from "../../api-client-core/apis/auth/oauth.generated.js";
4
+ import { settingsUsers } from "../../api-client-core/apis/settings/user-provisioning.generated.js";
5
+ import { getHubSpotApiOrigin } from "../../utils/env-utils.js";
6
+ import { fetchTransportPlugin } from "../../api-client-core/plugins/fetch-transport.js";
7
+ //#region src/server/hono/hubspot-connect-routes/whoami.ts
8
+ async function fetchWhoami(accessToken, hubspotConnectEnv) {
9
+ const apiClient = createHubSpotClient({ plugins: [fetchTransportPlugin({
10
+ getEndpoint: getHubSpotApiOrigin,
11
+ getAccessToken: () => accessToken
12
+ })] });
13
+ const introspectInput = hubspotConnectEnv.isCimdEnabled ? { token: accessToken } : {
14
+ client_id: hubspotConnectEnv.hubspotClientId,
15
+ client_secret: hubspotConnectEnv.hubspotClientSecret,
16
+ token: accessToken
17
+ };
18
+ const [introspectResult, hubResult] = await Promise.allSettled([apiClient.send(authOauth.introspectToken(introspectInput)), apiClient.send(account.get())]);
19
+ const whoami = {
20
+ hub: {},
21
+ user: {}
22
+ };
23
+ if (hubResult.status === "fulfilled") {
24
+ const portal = hubResult.value;
25
+ whoami.hub.id = portal.portalId;
26
+ whoami.hub.uiDomain = portal.uiDomain;
27
+ }
28
+ if (introspectResult.status === "fulfilled" && introspectResult.value.token_use === "access_token") {
29
+ whoami.hub.domain = introspectResult.value.hub_domain;
30
+ const userId = String(introspectResult.value.user_id);
31
+ const userResult = await apiClient.send(settingsUsers.get({
32
+ userId,
33
+ idProperty: "USER_ID"
34
+ })).then((u) => ({
35
+ ok: true,
36
+ value: u
37
+ }), () => ({ ok: false }));
38
+ if (userResult.ok) {
39
+ const u = userResult.value;
40
+ whoami.user.id = u.id;
41
+ whoami.user.email = u.email;
42
+ whoami.user.firstName = u.firstName;
43
+ whoami.user.lastName = u.lastName;
44
+ }
45
+ }
46
+ return whoami;
47
+ }
48
+ //#endregion
49
+ export { fetchWhoami };
50
+
51
+ //# sourceMappingURL=whoami.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"whoami.js","names":[],"sources":["../../../../src/server/hono/hubspot-connect-routes/whoami.ts"],"sourcesContent":["import type { AuthCompleteWhoami } from '../../../shared/wire-types.ts';\nimport {\n account,\n authOauth,\n createHubSpotClient,\n settingsUsers,\n} from '../../api-client-core/index.ts';\nimport { fetchTransportPlugin } from '../../api-client-core/plugins/fetch-transport.ts';\nimport { getHubSpotApiOrigin } from '../../utils/env-utils.ts';\nimport type { HubSpotConnectRoutesEnv } from './load-hubspot-connect-routes-env.ts';\n\nexport async function fetchWhoami(\n accessToken: string,\n hubspotConnectEnv: HubSpotConnectRoutesEnv\n): Promise<AuthCompleteWhoami> {\n const apiClient = createHubSpotClient({\n plugins: [\n fetchTransportPlugin({\n getEndpoint: getHubSpotApiOrigin,\n getAccessToken: () => accessToken,\n }),\n ],\n });\n\n const introspectInput = hubspotConnectEnv.isCimdEnabled\n ? { token: accessToken }\n : {\n client_id: hubspotConnectEnv.hubspotClientId,\n client_secret: hubspotConnectEnv.hubspotClientSecret,\n token: accessToken,\n };\n\n // Introspect and account.get are independent — run in parallel.\n // settingsUsers.get requires user_id from introspect, so it runs after.\n const [introspectResult, hubResult] = await Promise.allSettled([\n apiClient.send(authOauth.introspectToken(introspectInput)),\n apiClient.send(account.get()),\n ]);\n\n const whoami: AuthCompleteWhoami = {\n hub: {},\n user: {},\n };\n\n if (hubResult.status === 'fulfilled') {\n const portal = hubResult.value;\n whoami.hub.id = portal.portalId;\n whoami.hub.uiDomain = portal.uiDomain;\n }\n\n if (\n introspectResult.status === 'fulfilled' &&\n introspectResult.value.token_use === 'access_token'\n ) {\n whoami.hub.domain = introspectResult.value.hub_domain;\n\n const userId = String(introspectResult.value.user_id);\n const userResult = await apiClient\n .send(settingsUsers.get({ userId, idProperty: 'USER_ID' }))\n .then(\n (u) => ({ ok: true as const, value: u }),\n () => ({ ok: false as const })\n );\n if (userResult.ok) {\n const u = userResult.value;\n whoami.user.id = u.id;\n whoami.user.email = u.email;\n whoami.user.firstName = u.firstName;\n whoami.user.lastName = u.lastName;\n }\n }\n\n return whoami;\n}\n"],"mappings":";;;;;;;AAWA,eAAsB,YACpB,aACA,mBAC6B;CAC7B,MAAM,YAAY,oBAAoB,EACpC,SAAS,CACP,qBAAqB;EACnB,aAAa;EACb,sBAAsB;CACxB,CAAC,CACH,EACF,CAAC;CAED,MAAM,kBAAkB,kBAAkB,gBACtC,EAAE,OAAO,YAAY,IACrB;EACE,WAAW,kBAAkB;EAC7B,eAAe,kBAAkB;EACjC,OAAO;CACT;CAIJ,MAAM,CAAC,kBAAkB,aAAa,MAAM,QAAQ,WAAW,CAC7D,UAAU,KAAK,UAAU,gBAAgB,eAAe,CAAC,GACzD,UAAU,KAAK,QAAQ,IAAI,CAAC,CAC9B,CAAC;CAED,MAAM,SAA6B;EACjC,KAAK,CAAC;EACN,MAAM,CAAC;CACT;CAEA,IAAI,UAAU,WAAW,aAAa;EACpC,MAAM,SAAS,UAAU;EACzB,OAAO,IAAI,KAAK,OAAO;EACvB,OAAO,IAAI,WAAW,OAAO;CAC/B;CAEA,IACE,iBAAiB,WAAW,eAC5B,iBAAiB,MAAM,cAAc,gBACrC;EACA,OAAO,IAAI,SAAS,iBAAiB,MAAM;EAE3C,MAAM,SAAS,OAAO,iBAAiB,MAAM,OAAO;EACpD,MAAM,aAAa,MAAM,UACtB,KAAK,cAAc,IAAI;GAAE;GAAQ,YAAY;EAAU,CAAC,CAAC,EACzD,MACE,OAAO;GAAE,IAAI;GAAe,OAAO;EAAE,WAC/B,EAAE,IAAI,MAAe,EAC9B;EACF,IAAI,WAAW,IAAI;GACjB,MAAM,IAAI,WAAW;GACrB,OAAO,KAAK,KAAK,EAAE;GACnB,OAAO,KAAK,QAAQ,EAAE;GACtB,OAAO,KAAK,YAAY,EAAE;GAC1B,OAAO,KAAK,WAAW,EAAE;EAC3B;CACF;CAEA,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubspot/app-connect-sdk",
3
- "version": "1.0.0-alpha.20",
3
+ "version": "1.0.0-alpha.21",
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/prettier-config": "0.1.0",
49
- "@private/tsconfig": "0.1.0"
48
+ "@private/tsconfig": "0.1.0",
49
+ "@private/prettier-config": "0.1.0"
50
50
  },
51
51
  "scripts": {
52
52
  "clean": "rm -rf dist build *.tsbuildinfo node_modules .turbo",
@@ -35,6 +35,7 @@ function createTestContext(
35
35
  isDisconnectInFlight: false,
36
36
  error: null,
37
37
  expiresAt: null,
38
+ whoami: null,
38
39
  };
39
40
  return {
40
41
  config: {
@@ -1,11 +1,13 @@
1
1
  export { EXPIRES_AT_URL_PARAM } from '../../shared/constants.ts';
2
2
 
3
3
  /**
4
- * Key the controller persists the access-token `expiresAt` (Unix
5
- * epoch milliseconds) under in `sessionStorage`. Survives full-page
6
- * navigations within the same tab.
4
+ * Key under which the controller persists the entire session blob
5
+ * (expiresAt + whoami) as a single JSON string in `sessionStorage`.
6
+ * Using one key makes logout trivially correct — one `removeItem` call
7
+ * clears all session state. Survives full-page navigations within the
8
+ * same tab.
7
9
  */
8
- export const EXPIRES_AT_KEY = 'hubspot_token_expires_at';
10
+ export const SESSION_STORAGE_KEY = 'hubspot_connect_session';
9
11
 
10
12
  /**
11
13
  * Number of milliseconds before `expiresAt` that the refresh
@@ -16,7 +16,7 @@ import type {
16
16
  } from './types.ts';
17
17
  import { memoizeLast } from './utils/memoize-utils.ts';
18
18
  import {
19
- getExpiresAtFromSessionStorage,
19
+ getSessionFromSessionStorage,
20
20
  isClientSessionActive,
21
21
  } from './utils/session-utils.ts';
22
22
  import { createStore } from './utils/store-utils.ts';
@@ -54,6 +54,7 @@ export function createAppConnectController(
54
54
  isSessionConnected: false,
55
55
  error: null,
56
56
  expiresAt: null,
57
+ whoami: null,
57
58
  });
58
59
  const context: AppConnectContext = {
59
60
  config,
@@ -62,7 +63,11 @@ export function createAppConnectController(
62
63
  store,
63
64
  };
64
65
 
65
- store.setState({ expiresAt: getExpiresAtFromSessionStorage(context) });
66
+ const storedSession = getSessionFromSessionStorage(context);
67
+ store.setState({
68
+ expiresAt: storedSession?.expiresAt ?? null,
69
+ whoami: storedSession?.whoami ?? null,
70
+ });
66
71
 
67
72
  let hasStarted = false;
68
73
 
@@ -90,6 +95,7 @@ export function createAppConnectController(
90
95
  >((storeState) => ({
91
96
  status: getDerivedStatus(storeState),
92
97
  error: storeState.error,
98
+ whoami: storeState.whoami,
93
99
  connectToHubSpot,
94
100
  disconnectFromHubSpot,
95
101
  }));
@@ -35,6 +35,7 @@ export async function disconnectFromHubSpot(
35
35
 
36
36
  store.setState({
37
37
  expiresAt: null,
38
+ whoami: null,
38
39
  isSessionConnected: false,
39
40
  isDisconnectInFlight: false,
40
41
  });
@@ -5,7 +5,7 @@ import {
5
5
  OAUTH_POPUP_CALLBACK_MESSAGE_TYPE,
6
6
  } from '../../shared/constants.ts';
7
7
  import { noopLogger } from '../../shared/logger.ts';
8
- import { EXPIRES_AT_KEY } from './constants.ts';
8
+ import { SESSION_STORAGE_KEY } from './constants.ts';
9
9
  import { initAppConnect } from './init.ts';
10
10
  import { OAUTH_POPUP_WINDOW_NAME } from './oauth-popup.ts';
11
11
  import type {
@@ -39,6 +39,7 @@ function createTestContext(): AppConnectContext {
39
39
  isDisconnectInFlight: false,
40
40
  error: null,
41
41
  expiresAt: null,
42
+ whoami: null,
42
43
  };
43
44
  return {
44
45
  config: { hubSpotConnectBaseUrl: HUBSPOT_CONNECT_BASE_URL },
@@ -124,14 +125,16 @@ describe('initAppConnect', () => {
124
125
  installFakeWindow({
125
126
  href: `https://app.example.com${OAUTH_CALLBACK_PATH}?code=auth-code&state=auth-state`,
126
127
  });
127
- const fetchSpy = vi
128
- .spyOn(globalThis, 'fetch')
129
- .mockResolvedValue(
130
- new Response(
131
- JSON.stringify({ expires_at: expiresAt, return_path: '/dashboard' }),
132
- { status: 200 }
133
- )
134
- );
128
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
129
+ new Response(
130
+ JSON.stringify({
131
+ expires_at: expiresAt,
132
+ return_path: '/dashboard',
133
+ whoami: {},
134
+ }),
135
+ { status: 200 }
136
+ )
137
+ );
135
138
  const context = createTestContext();
136
139
 
137
140
  await initAppConnect(context);
@@ -148,9 +151,10 @@ describe('initAppConnect', () => {
148
151
  expect((calledInit as RequestInit).credentials).toBe('include');
149
152
 
150
153
  expect(context.store.getSnapshot().expiresAt).toBe(expiresAt);
151
- expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBe(
152
- String(expiresAt)
154
+ const stored = JSON.parse(
155
+ context.sessionStorage.getItem(SESSION_STORAGE_KEY)!
153
156
  );
157
+ expect(stored.expiresAt).toBe(expiresAt);
154
158
  expect(window.location.pathname).toBe('/dashboard');
155
159
  });
156
160
 
@@ -165,10 +169,13 @@ describe('initAppConnect', () => {
165
169
  })
166
170
  );
167
171
  const context = createTestContext();
168
- context.sessionStorage.setItem(EXPIRES_AT_KEY, '999');
172
+ context.sessionStorage.setItem(
173
+ SESSION_STORAGE_KEY,
174
+ '{"expiresAt":999,"whoami":null}'
175
+ );
169
176
 
170
177
  await expect(initAppConnect(context)).rejects.toThrow(/Failed to complete/);
171
- expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
178
+ expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
172
179
  });
173
180
 
174
181
  it('relays code and state to the opener without calling auth/complete when in an OAuth popup', async () => {
@@ -208,7 +215,11 @@ describe('initAppConnect', () => {
208
215
  });
209
216
  vi.spyOn(globalThis, 'fetch').mockResolvedValue(
210
217
  new Response(
211
- JSON.stringify({ expires_at: expiresAt, return_path: '/dashboard' }),
218
+ JSON.stringify({
219
+ expires_at: expiresAt,
220
+ return_path: '/dashboard',
221
+ whoami: {},
222
+ }),
212
223
  { status: 200 }
213
224
  )
214
225
  );
@@ -232,7 +243,11 @@ describe('initAppConnect', () => {
232
243
  });
233
244
  vi.spyOn(globalThis, 'fetch').mockResolvedValue(
234
245
  new Response(
235
- JSON.stringify({ expires_at: expiresAt, return_path: '/dashboard' }),
246
+ JSON.stringify({
247
+ expires_at: expiresAt,
248
+ return_path: '/dashboard',
249
+ whoami: {},
250
+ }),
236
251
  { status: 200 }
237
252
  )
238
253
  );
@@ -9,7 +9,7 @@ import {
9
9
  relayOAuthCallbackToOpener,
10
10
  } from './oauth-popup.ts';
11
11
  import type { AppConnectContext } from './types.ts';
12
- import { storeExpiresAt } from './utils/session-utils.ts';
12
+ import { storeSession } from './utils/session-utils.ts';
13
13
 
14
14
  /**
15
15
  * On `controller.start()`:
@@ -49,7 +49,7 @@ async function consumeOAuthCallback(context: AppConnectContext): Promise<void> {
49
49
  const body = await completeHubSpotOAuthSession(context, { code, state });
50
50
  const { expires_at: expiresAt, return_path: returnPath } = body;
51
51
 
52
- storeExpiresAt({ context, expiresAtMs: expiresAt });
52
+ storeSession({ context, expiresAtMs: expiresAt, whoami: body.whoami });
53
53
 
54
54
  const targetUrl = new URL(returnPath, window.location.origin);
55
55
  history.replaceState(
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { noopLogger } from '../../shared/logger.ts';
4
- import { EXPIRES_AT_KEY } from './constants.ts';
4
+ import { SESSION_STORAGE_KEY } from './constants.ts';
5
5
  import { completeHubSpotOAuthSession } from './oauth-complete.ts';
6
6
  import type {
7
7
  AppConnectContext,
@@ -34,6 +34,7 @@ function createTestContext(): AppConnectContext {
34
34
  isDisconnectInFlight: false,
35
35
  error: null,
36
36
  expiresAt: null,
37
+ whoami: null,
37
38
  };
38
39
  return {
39
40
  config: { hubSpotConnectBaseUrl: HUBSPOT_CONNECT_BASE_URL },
@@ -93,7 +94,10 @@ describe('completeHubSpotOAuthSession', () => {
93
94
  })
94
95
  );
95
96
  const context = createTestContext();
96
- context.sessionStorage.setItem(EXPIRES_AT_KEY, '999');
97
+ context.sessionStorage.setItem(
98
+ SESSION_STORAGE_KEY,
99
+ '{"expiresAt":999,"whoami":null}'
100
+ );
97
101
 
98
102
  await expect(
99
103
  completeHubSpotOAuthSession(context, {
@@ -101,6 +105,6 @@ describe('completeHubSpotOAuthSession', () => {
101
105
  state: 'bad-state',
102
106
  })
103
107
  ).rejects.toThrow(/Failed to complete/);
104
- expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
108
+ expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
105
109
  });
106
110
  });
@@ -34,6 +34,7 @@ function createTestContext(): AppConnectContext {
34
34
  isDisconnectInFlight: false,
35
35
  error: null,
36
36
  expiresAt: null,
37
+ whoami: null,
37
38
  };
38
39
  return {
39
40
  config: { hubSpotConnectBaseUrl: HUBSPOT_CONNECT_BASE_URL },
@@ -1,3 +1,4 @@
1
+ import type { AuthCompleteWhoami } from '../../shared/wire-types.ts';
1
2
  import type { Logger } from '../index.ts';
2
3
  import type { AppConnectBrowserConfig } from '../types.ts';
3
4
  import type { Store } from './utils/store-utils.ts';
@@ -30,6 +31,8 @@ export interface AppConnectInternalState {
30
31
  error: string | null;
31
32
  /** Access-token expiry (Unix epoch milliseconds), or `null` if unknown. */
32
33
  expiresAt: number | null;
34
+ /** Identity of the connected user and their HubSpot account, or `null`. */
35
+ whoami: AuthCompleteWhoami | null;
33
36
  }
34
37
 
35
38
  /** Convenience alias for the typed store used by the controller. */
@@ -1,7 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { noopLogger } from '../../../shared/logger.ts';
4
- import { EXPIRES_AT_KEY } from '../constants.ts';
4
+ import type { AuthCompleteWhoami } from '../../../shared/wire-types.ts';
5
+ import { SESSION_STORAGE_KEY } from '../constants.ts';
5
6
  import type {
6
7
  AppConnectContext,
7
8
  AppConnectInternalState,
@@ -9,9 +10,10 @@ import type {
9
10
  } from '../types.ts';
10
11
  import {
11
12
  clearSessionStorage,
12
- getExpiresAtFromSessionStorage,
13
+ getSessionFromSessionStorage,
13
14
  isClientSessionActive,
14
15
  storeExpiresAt,
16
+ storeSession,
15
17
  } from './session-utils.ts';
16
18
  import { createStore } from './store-utils.ts';
17
19
 
@@ -36,6 +38,7 @@ function createTestContext(): AppConnectContext {
36
38
  isDisconnectInFlight: false,
37
39
  error: null,
38
40
  expiresAt: null,
41
+ whoami: null,
39
42
  };
40
43
  return {
41
44
  config: { hubSpotConnectBaseUrl: '/functions/v1/hubspot-connect' },
@@ -45,6 +48,14 @@ function createTestContext(): AppConnectContext {
45
48
  };
46
49
  }
47
50
 
51
+ const MOCK_WHOAMI: AuthCompleteWhoami = {
52
+ user: { id: '99', email: 'demo@example.com', firstName: 'Demo' },
53
+ hub: {
54
+ id: 42,
55
+ uiDomain: 'app.hubspot.com',
56
+ },
57
+ };
58
+
48
59
  describe('session-utils', () => {
49
60
  const FIXED_NOW = 1_700_000_000_000;
50
61
 
@@ -57,52 +68,92 @@ describe('session-utils', () => {
57
68
  vi.useRealTimers();
58
69
  });
59
70
 
71
+ describe('storeSession', () => {
72
+ it('writes expiresAt and whoami to both store and session storage', () => {
73
+ const context = createTestContext();
74
+ storeSession({
75
+ context,
76
+ expiresAtMs: FIXED_NOW + 60_000,
77
+ whoami: MOCK_WHOAMI,
78
+ });
79
+
80
+ expect(context.store.getSnapshot().expiresAt).toBe(FIXED_NOW + 60_000);
81
+ expect(context.store.getSnapshot().whoami).toEqual(MOCK_WHOAMI);
82
+ const stored = JSON.parse(
83
+ context.sessionStorage.getItem(SESSION_STORAGE_KEY)!
84
+ );
85
+ expect(stored.expiresAt).toBe(FIXED_NOW + 60_000);
86
+ expect(stored.whoami).toEqual(MOCK_WHOAMI);
87
+ });
88
+ });
89
+
60
90
  describe('storeExpiresAt', () => {
61
- it('writes both the store and session storage', () => {
91
+ it('updates expiresAt in store and storage while preserving existing whoami', () => {
62
92
  const context = createTestContext();
93
+ context.store.setState({ whoami: MOCK_WHOAMI });
94
+
63
95
  storeExpiresAt({ context, expiresAtMs: FIXED_NOW + 60_000 });
64
96
 
65
97
  expect(context.store.getSnapshot().expiresAt).toBe(FIXED_NOW + 60_000);
66
- expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBe(
67
- String(FIXED_NOW + 60_000)
98
+ expect(context.store.getSnapshot().whoami).toEqual(MOCK_WHOAMI);
99
+ const stored = JSON.parse(
100
+ context.sessionStorage.getItem(SESSION_STORAGE_KEY)!
68
101
  );
102
+ expect(stored.expiresAt).toBe(FIXED_NOW + 60_000);
103
+ expect(stored.whoami).toEqual(MOCK_WHOAMI);
69
104
  });
70
105
  });
71
106
 
72
- describe('getExpiresAtFromSessionStorage', () => {
107
+ describe('getSessionFromSessionStorage', () => {
73
108
  it('returns null when nothing is stored', () => {
74
109
  const context = createTestContext();
75
- expect(getExpiresAtFromSessionStorage(context)).toBeNull();
110
+ expect(getSessionFromSessionStorage(context)).toBeNull();
76
111
  });
77
112
 
78
- it('returns the stored value when in the future', () => {
113
+ it('returns the stored session when expiresAt is in the future', () => {
79
114
  const context = createTestContext();
80
- const expiresAt = FIXED_NOW + 60_000;
81
- context.sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAt));
82
- expect(getExpiresAtFromSessionStorage(context)).toBe(expiresAt);
115
+ const session = { expiresAt: FIXED_NOW + 60_000, whoami: MOCK_WHOAMI };
116
+ context.sessionStorage.setItem(
117
+ SESSION_STORAGE_KEY,
118
+ JSON.stringify(session)
119
+ );
120
+ expect(getSessionFromSessionStorage(context)).toEqual(session);
83
121
  });
84
122
 
85
- it('clears and returns null on malformed input', () => {
123
+ it('clears and returns null on malformed JSON', () => {
86
124
  const context = createTestContext();
87
- context.sessionStorage.setItem(EXPIRES_AT_KEY, 'not-a-number');
88
- expect(getExpiresAtFromSessionStorage(context)).toBeNull();
89
- expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
125
+ context.sessionStorage.setItem(SESSION_STORAGE_KEY, 'not-json');
126
+ expect(getSessionFromSessionStorage(context)).toBeNull();
127
+ expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
90
128
  });
91
129
 
92
- it('clears and returns null on already-expired values', () => {
130
+ it('clears and returns null when expiresAt is not a number', () => {
93
131
  const context = createTestContext();
94
- context.sessionStorage.setItem(EXPIRES_AT_KEY, String(FIXED_NOW - 1));
95
- expect(getExpiresAtFromSessionStorage(context)).toBeNull();
96
- expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
132
+ context.sessionStorage.setItem(
133
+ SESSION_STORAGE_KEY,
134
+ JSON.stringify({ expiresAt: 'bad', whoami: null })
135
+ );
136
+ expect(getSessionFromSessionStorage(context)).toBeNull();
137
+ expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
138
+ });
139
+
140
+ it('clears and returns null when the session is already expired', () => {
141
+ const context = createTestContext();
142
+ context.sessionStorage.setItem(
143
+ SESSION_STORAGE_KEY,
144
+ JSON.stringify({ expiresAt: FIXED_NOW - 1, whoami: null })
145
+ );
146
+ expect(getSessionFromSessionStorage(context)).toBeNull();
147
+ expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
97
148
  });
98
149
  });
99
150
 
100
151
  describe('clearSessionStorage', () => {
101
- it('removes the persisted expiresAt key', () => {
152
+ it('removes the session storage key', () => {
102
153
  const context = createTestContext();
103
- context.sessionStorage.setItem(EXPIRES_AT_KEY, '123');
154
+ context.sessionStorage.setItem(SESSION_STORAGE_KEY, '{}');
104
155
  clearSessionStorage(context);
105
- expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
156
+ expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
106
157
  });
107
158
  });
108
159