@hubspot/app-connect-sdk 1.0.0-alpha.2 → 1.0.0-alpha.3

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 (70) hide show
  1. package/.turbo/turbo-format$colon$check.log +1 -1
  2. package/.turbo/turbo-test.log +60 -58
  3. package/.turbo/turbo-tsdown.log +55 -52
  4. package/build/tsconfig.browser.tsbuildinfo +1 -1
  5. package/build/tsconfig.server.tsbuildinfo +1 -1
  6. package/dist/browser/{HubSpotAppConnect-BW45gyDs.js → HubSpotAppConnect-COQgPrFn.js} +5 -3
  7. package/dist/browser/HubSpotAppConnect-COQgPrFn.js.map +1 -0
  8. package/dist/browser/{create-vctOhpX9.js → create-crdncXsh.js} +53 -24
  9. package/dist/browser/create-crdncXsh.js.map +1 -0
  10. package/dist/browser/index.js +1 -1
  11. package/dist/browser/react/lovable.js +2 -2
  12. package/dist/browser/react.js +1 -1
  13. package/dist/server/api-client-core/plugins/fetch-transport.js +5 -1
  14. package/dist/server/api-client-core/plugins/fetch-transport.js.map +1 -1
  15. package/dist/server/constants.js +33 -6
  16. package/dist/server/constants.js.map +1 -1
  17. package/dist/server/hono/hono-request-handler.js +18 -13
  18. package/dist/server/hono/hono-request-handler.js.map +1 -1
  19. package/dist/server/hono/hubspot-connect-routes/auth-complete.js +154 -0
  20. package/dist/server/hono/hubspot-connect-routes/auth-complete.js.map +1 -0
  21. package/dist/server/hono/hubspot-connect-routes/auth-init-session.js +22 -11
  22. package/dist/server/hono/hubspot-connect-routes/auth-init-session.js.map +1 -1
  23. package/dist/server/hono/hubspot-connect-routes/auth-logout.js +18 -1
  24. package/dist/server/hono/hubspot-connect-routes/auth-logout.js.map +1 -1
  25. package/dist/server/hono/hubspot-connect-routes/auth-refresh.js +6 -0
  26. package/dist/server/hono/hubspot-connect-routes/auth-refresh.js.map +1 -1
  27. package/dist/server/hono/hubspot-connect-routes/hubspot-connect-routes.js +4 -2
  28. package/dist/server/hono/hubspot-connect-routes/hubspot-connect-routes.js.map +1 -1
  29. package/dist/server/hono/hubspot-connect-routes/utils.js +50 -3
  30. package/dist/server/hono/hubspot-connect-routes/utils.js.map +1 -1
  31. package/dist/server/hono/types.d.ts +13 -9
  32. package/dist/server/hono/utils/cookie-utils.js +2 -1
  33. package/dist/server/hono/utils/cookie-utils.js.map +1 -1
  34. package/dist/server/hono/utils/cors-middleware.js +85 -0
  35. package/dist/server/hono/utils/cors-middleware.js.map +1 -0
  36. package/dist/server/sanitize-request.js +24 -10
  37. package/dist/server/sanitize-request.js.map +1 -1
  38. package/dist/server/shared/constants.js +22 -9
  39. package/dist/server/shared/constants.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/browser/app-connect-controller/init.test.ts +167 -0
  42. package/src/browser/app-connect-controller/init.ts +70 -19
  43. package/src/browser/react/components/AppConnectHeader/AppConnectHeader.tsx +3 -5
  44. package/src/browser/react/components/ConnectButton/ConnectButton.tsx +2 -1
  45. package/src/server/api-client-core/plugins/fetch-transport.ts +5 -1
  46. package/src/server/constants.ts +29 -4
  47. package/src/server/hono/hono-request-handler.ts +42 -15
  48. package/src/server/hono/hubspot-connect-routes/auth-complete.test.ts +285 -0
  49. package/src/server/hono/hubspot-connect-routes/{auth-callback.ts → auth-complete.ts} +73 -30
  50. package/src/server/hono/hubspot-connect-routes/auth-init-session.test.ts +114 -30
  51. package/src/server/hono/hubspot-connect-routes/auth-init-session.ts +33 -10
  52. package/src/server/hono/hubspot-connect-routes/auth-logout.test.ts +13 -0
  53. package/src/server/hono/hubspot-connect-routes/auth-logout.ts +18 -0
  54. package/src/server/hono/hubspot-connect-routes/auth-refresh.test.ts +6 -0
  55. package/src/server/hono/hubspot-connect-routes/auth-refresh.ts +6 -0
  56. package/src/server/hono/hubspot-connect-routes/hubspot-connect-routes.ts +9 -2
  57. package/src/server/hono/hubspot-connect-routes/utils.ts +57 -1
  58. package/src/server/hono/types.ts +15 -9
  59. package/src/server/hono/utils/cookie-utils.ts +27 -2
  60. package/src/server/hono/utils/cors-middleware.test.ts +79 -0
  61. package/src/server/hono/utils/cors-middleware.ts +95 -0
  62. package/src/server/sanitize-request.ts +25 -11
  63. package/src/server/types.ts +2 -2
  64. package/src/shared/constants.ts +31 -3
  65. package/src/shared/wire-types.ts +19 -0
  66. package/dist/browser/HubSpotAppConnect-BW45gyDs.js.map +0 -1
  67. package/dist/browser/create-vctOhpX9.js.map +0 -1
  68. package/dist/server/hono/hubspot-connect-routes/auth-callback.js +0 -125
  69. package/dist/server/hono/hubspot-connect-routes/auth-callback.js.map +0 -1
  70. package/src/server/hono/hubspot-connect-routes/auth-callback.test.ts +0 -225
@@ -14,13 +14,14 @@ function setResponseCookie(options) {
14
14
  * cookies share the same policy.
15
15
  */
16
16
  function serializeCookie(options) {
17
- const { name, value, path, sameSite = "Strict", maxAge, secure = true, httpOnly = true } = options;
17
+ const { name, value, path, sameSite = "Strict", maxAge, secure = true, httpOnly = true, partitioned = false } = options;
18
18
  const parts = [`${name}=${value}`];
19
19
  if (httpOnly) parts.push("HttpOnly");
20
20
  if (secure) parts.push("Secure");
21
21
  parts.push(`SameSite=${sameSite}`);
22
22
  parts.push(`Path=${path}`);
23
23
  parts.push(`Max-Age=${maxAge}`);
24
+ if (partitioned) parts.push("Partitioned");
24
25
  return parts.join("; ");
25
26
  }
26
27
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"cookie-utils.js","names":[],"sources":["../../../../src/server/hono/utils/cookie-utils.ts"],"sourcesContent":["import type { Context } from 'hono';\n\nexport interface SetResponseCookieOptions {\n c: Context;\n value: string;\n}\n\n/**\n * Appends a `Set-Cookie` header to the response. Hono replaces single\n * headers by default, so this uses `{ append: true }` to emit multiple\n * cookies on the same response.\n */\nexport function setResponseCookie(options: SetResponseCookieOptions): void {\n const { c, value } = options;\n c.header('Set-Cookie', value, { append: true });\n}\n\nexport interface SerializeCookieOptions {\n name: string;\n value: string;\n /** `__Host-` prefix requires `Path=/` and is recommended for session cookies. */\n path: string;\n /** Defaults to `Strict`. Use `Lax` for short-lived OAuth temp cookies. */\n sameSite?: 'Strict' | 'Lax';\n /** Lifetime in seconds. `0` deletes the cookie. */\n maxAge: number;\n /** Defaults to `true`; only set `false` for tests or non-HTTPS dev hosts. */\n secure?: boolean;\n /** Defaults to `true`. */\n httpOnly?: boolean;\n}\n\n/**\n * Builds a `Set-Cookie` header value with HubSpot's default attributes\n * (HttpOnly, Secure, SameSite). Centralizes the serialization so all\n * cookies share the same policy.\n */\nexport function serializeCookie(options: SerializeCookieOptions): string {\n const {\n name,\n value,\n path,\n sameSite = 'Strict',\n maxAge,\n secure = true,\n httpOnly = true,\n } = options;\n const parts: string[] = [`${name}=${value}`];\n if (httpOnly) parts.push('HttpOnly');\n if (secure) parts.push('Secure');\n parts.push(`SameSite=${sameSite}`);\n parts.push(`Path=${path}`);\n parts.push(`Max-Age=${maxAge}`);\n return parts.join('; ');\n}\n"],"mappings":";;;;;;AAYA,SAAgB,kBAAkB,SAAyC;CACzE,MAAM,EAAE,GAAG,UAAU;CACrB,EAAE,OAAO,cAAc,OAAO,EAAE,QAAQ,MAAM,CAAC;;;;;;;AAuBjD,SAAgB,gBAAgB,SAAyC;CACvE,MAAM,EACJ,MACA,OACA,MACA,WAAW,UACX,QACA,SAAS,MACT,WAAW,SACT;CACJ,MAAM,QAAkB,CAAC,GAAG,KAAK,GAAG,QAAQ;CAC5C,IAAI,UAAU,MAAM,KAAK,WAAW;CACpC,IAAI,QAAQ,MAAM,KAAK,SAAS;CAChC,MAAM,KAAK,YAAY,WAAW;CAClC,MAAM,KAAK,QAAQ,OAAO;CAC1B,MAAM,KAAK,WAAW,SAAS;CAC/B,OAAO,MAAM,KAAK,KAAK"}
1
+ {"version":3,"file":"cookie-utils.js","names":[],"sources":["../../../../src/server/hono/utils/cookie-utils.ts"],"sourcesContent":["import type { Context } from 'hono';\n\nexport interface SetResponseCookieOptions {\n c: Context;\n value: string;\n}\n\n/**\n * Appends a `Set-Cookie` header to the response. Hono replaces single\n * headers by default, so this uses `{ append: true }` to emit multiple\n * cookies on the same response.\n */\nexport function setResponseCookie(options: SetResponseCookieOptions): void {\n const { c, value } = options;\n c.header('Set-Cookie', value, { append: true });\n}\n\nexport interface SerializeCookieOptions {\n name: string;\n value: string;\n /** `__Host-` prefix requires `Path=/` and is recommended for session cookies. */\n path: string;\n /**\n * Defaults to `Strict`.\n *\n * - `Strict`: only sent on same-site requests. Default for self-hosted\n * same-origin deployments.\n * - `Lax`: also sent on top-level cross-site GET navigations. Use for\n * short-lived OAuth temp cookies that need to survive a redirect.\n * - `None`: sent on all cross-site requests; **requires `Secure=true`\n * and is typically combined with `Partitioned=true`** for the\n * cross-origin Lovable / Supabase deployment shape.\n */\n sameSite?: 'Strict' | 'Lax' | 'None';\n /** Lifetime in seconds. `0` deletes the cookie. */\n maxAge: number;\n /** Defaults to `true`; only set `false` for tests or non-HTTPS dev hosts. */\n secure?: boolean;\n /** Defaults to `true`. */\n httpOnly?: boolean;\n /**\n * When `true`, appends the `Partitioned` attribute (CHIPS — Cookies\n * Having Independent Partitioned State). The browser then keys the\n * cookie by `(top-level site, cookie host)` instead of by cookie\n * host alone, which is required for the cross-origin SDK shape\n * where the React app and the SDK's edge functions live on\n * different sites and third-party cookies are blocked.\n *\n * Defaults to `false`. Browsers ignore `Partitioned` on cookies\n * without `Secure=true` and reject it on cookies without\n * `SameSite=None`.\n */\n partitioned?: boolean;\n}\n\n/**\n * Builds a `Set-Cookie` header value with HubSpot's default attributes\n * (HttpOnly, Secure, SameSite). Centralizes the serialization so all\n * cookies share the same policy.\n */\nexport function serializeCookie(options: SerializeCookieOptions): string {\n const {\n name,\n value,\n path,\n sameSite = 'Strict',\n maxAge,\n secure = true,\n httpOnly = true,\n partitioned = false,\n } = options;\n const parts: string[] = [`${name}=${value}`];\n if (httpOnly) parts.push('HttpOnly');\n if (secure) parts.push('Secure');\n parts.push(`SameSite=${sameSite}`);\n parts.push(`Path=${path}`);\n parts.push(`Max-Age=${maxAge}`);\n if (partitioned) parts.push('Partitioned');\n return parts.join('; ');\n}\n"],"mappings":";;;;;;AAYA,SAAgB,kBAAkB,SAAyC;CACzE,MAAM,EAAE,GAAG,UAAU;CACrB,EAAE,OAAO,cAAc,OAAO,EAAE,QAAQ,MAAM,CAAC;;;;;;;AA8CjD,SAAgB,gBAAgB,SAAyC;CACvE,MAAM,EACJ,MACA,OACA,MACA,WAAW,UACX,QACA,SAAS,MACT,WAAW,MACX,cAAc,UACZ;CACJ,MAAM,QAAkB,CAAC,GAAG,KAAK,GAAG,QAAQ;CAC5C,IAAI,UAAU,MAAM,KAAK,WAAW;CACpC,IAAI,QAAQ,MAAM,KAAK,SAAS;CAChC,MAAM,KAAK,YAAY,WAAW;CAClC,MAAM,KAAK,QAAQ,OAAO;CAC1B,MAAM,KAAK,WAAW,SAAS;CAC/B,IAAI,aAAa,MAAM,KAAK,cAAc;CAC1C,OAAO,MAAM,KAAK,KAAK"}
@@ -0,0 +1,85 @@
1
+ import { HUBSPOT_APP_ORIGIN_COOKIE_NAME } from "../../constants.js";
2
+ import { parseCookies } from "../../utils/cookie-utils.js";
3
+ //#region src/server/hono/utils/cors-middleware.ts
4
+ /**
5
+ * Comma-separated list of request headers the SDK accepts on
6
+ * cross-site fetches. Mirrors the Supabase Edge Functions defaults
7
+ * the Lovable AI agent emits today, plus `content-type` for the
8
+ * `auth/complete` POST body and `accept` so JSON content negotiation
9
+ * works.
10
+ */
11
+ const ALLOWED_HEADERS = [
12
+ "authorization",
13
+ "x-client-info",
14
+ "apikey",
15
+ "content-type",
16
+ "accept",
17
+ "x-supabase-client-platform",
18
+ "x-supabase-client-platform-version",
19
+ "x-supabase-client-runtime",
20
+ "x-supabase-client-runtime-version"
21
+ ].join(", ");
22
+ const ALLOWED_METHODS = "GET, POST, OPTIONS";
23
+ const PREFLIGHT_MAX_AGE_SECONDS = "600";
24
+ /**
25
+ * Reads the persisted app-origin cookie from the request, falling
26
+ * back to the literal `Origin` request header. The cookie is the
27
+ * authoritative pin once `auth/init-session` has run; on the very
28
+ * first init-session call (no cookie yet) we just echo whatever
29
+ * `Origin` the caller sent — the actual access decision is enforced
30
+ * by cookie-based authentication on every other route, not by CORS.
31
+ */
32
+ function resolveAllowedOrigin(c) {
33
+ const pinned = parseCookies(c.req.header("Cookie"))[HUBSPOT_APP_ORIGIN_COOKIE_NAME];
34
+ if (pinned) return pinned;
35
+ return c.req.header("Origin") ?? null;
36
+ }
37
+ function setSharedCorsHeaders(c, allowOrigin) {
38
+ c.res.headers.set("Access-Control-Allow-Origin", allowOrigin);
39
+ c.res.headers.set("Access-Control-Allow-Credentials", "true");
40
+ c.res.headers.set("Vary", "Origin, Cookie");
41
+ }
42
+ /**
43
+ * Hono middleware that emits credentialed CORS response headers for
44
+ * the cross-origin Lovable / Supabase deployment shape.
45
+ *
46
+ * - On `OPTIONS` preflight: short-circuits with a 204 carrying
47
+ * `Access-Control-Allow-*` headers. The browser will then send the
48
+ * real request with cookies attached.
49
+ * - On every other method: echoes the pinned `__Host-hs_app_origin`
50
+ * cookie value (or, before init-session has run, the request
51
+ * `Origin` header) as `Access-Control-Allow-Origin`, with
52
+ * `Access-Control-Allow-Credentials: true`. The wildcard `*` is
53
+ * forbidden by browsers when credentials are included, so the
54
+ * middleware always echoes a concrete origin.
55
+ *
56
+ * Skips header emission entirely when the request has no `Origin`
57
+ * (server-to-server calls, curl, etc.) so non-browser callers are
58
+ * left untouched.
59
+ */
60
+ function corsMiddleware() {
61
+ return async (c, next) => {
62
+ const allowOrigin = resolveAllowedOrigin(c);
63
+ if (c.req.method === "OPTIONS") {
64
+ const headers = new Headers();
65
+ if (allowOrigin) {
66
+ headers.set("Access-Control-Allow-Origin", allowOrigin);
67
+ headers.set("Access-Control-Allow-Credentials", "true");
68
+ headers.set("Vary", "Origin, Cookie");
69
+ }
70
+ headers.set("Access-Control-Allow-Methods", ALLOWED_METHODS);
71
+ headers.set("Access-Control-Allow-Headers", ALLOWED_HEADERS);
72
+ headers.set("Access-Control-Max-Age", PREFLIGHT_MAX_AGE_SECONDS);
73
+ return new Response(null, {
74
+ status: 204,
75
+ headers
76
+ });
77
+ }
78
+ await next();
79
+ if (allowOrigin) setSharedCorsHeaders(c, allowOrigin);
80
+ };
81
+ }
82
+ //#endregion
83
+ export { corsMiddleware };
84
+
85
+ //# sourceMappingURL=cors-middleware.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cors-middleware.js","names":[],"sources":["../../../../src/server/hono/utils/cors-middleware.ts"],"sourcesContent":["import type { Context, MiddlewareHandler } from 'hono';\n\nimport { HUBSPOT_APP_ORIGIN_COOKIE_NAME } from '../../constants.ts';\nimport { parseCookies } from '../../utils/cookie-utils.ts';\n\n/**\n * Comma-separated list of request headers the SDK accepts on\n * cross-site fetches. Mirrors the Supabase Edge Functions defaults\n * the Lovable AI agent emits today, plus `content-type` for the\n * `auth/complete` POST body and `accept` so JSON content negotiation\n * works.\n */\nconst ALLOWED_HEADERS = [\n 'authorization',\n 'x-client-info',\n 'apikey',\n 'content-type',\n 'accept',\n 'x-supabase-client-platform',\n 'x-supabase-client-platform-version',\n 'x-supabase-client-runtime',\n 'x-supabase-client-runtime-version',\n].join(', ');\n\nconst ALLOWED_METHODS = 'GET, POST, OPTIONS';\n\nconst PREFLIGHT_MAX_AGE_SECONDS = '600';\n\n/**\n * Reads the persisted app-origin cookie from the request, falling\n * back to the literal `Origin` request header. The cookie is the\n * authoritative pin once `auth/init-session` has run; on the very\n * first init-session call (no cookie yet) we just echo whatever\n * `Origin` the caller sent — the actual access decision is enforced\n * by cookie-based authentication on every other route, not by CORS.\n */\nfunction resolveAllowedOrigin(c: Context): string | null {\n const cookies = parseCookies(c.req.header('Cookie'));\n const pinned = cookies[HUBSPOT_APP_ORIGIN_COOKIE_NAME];\n if (pinned) return pinned;\n return c.req.header('Origin') ?? null;\n}\n\nfunction setSharedCorsHeaders(c: Context, allowOrigin: string): void {\n c.res.headers.set('Access-Control-Allow-Origin', allowOrigin);\n c.res.headers.set('Access-Control-Allow-Credentials', 'true');\n // `Origin` so caches differentiate per-caller responses; `Cookie`\n // because the allowed origin is derived from the persisted\n // `__Host-hs_app_origin` cookie.\n c.res.headers.set('Vary', 'Origin, Cookie');\n}\n\n/**\n * Hono middleware that emits credentialed CORS response headers for\n * the cross-origin Lovable / Supabase deployment shape.\n *\n * - On `OPTIONS` preflight: short-circuits with a 204 carrying\n * `Access-Control-Allow-*` headers. The browser will then send the\n * real request with cookies attached.\n * - On every other method: echoes the pinned `__Host-hs_app_origin`\n * cookie value (or, before init-session has run, the request\n * `Origin` header) as `Access-Control-Allow-Origin`, with\n * `Access-Control-Allow-Credentials: true`. The wildcard `*` is\n * forbidden by browsers when credentials are included, so the\n * middleware always echoes a concrete origin.\n *\n * Skips header emission entirely when the request has no `Origin`\n * (server-to-server calls, curl, etc.) so non-browser callers are\n * left untouched.\n */\nexport function corsMiddleware(): MiddlewareHandler {\n return async (c, next) => {\n const allowOrigin = resolveAllowedOrigin(c);\n\n if (c.req.method === 'OPTIONS') {\n const headers = new Headers();\n if (allowOrigin) {\n headers.set('Access-Control-Allow-Origin', allowOrigin);\n headers.set('Access-Control-Allow-Credentials', 'true');\n headers.set('Vary', 'Origin, Cookie');\n }\n headers.set('Access-Control-Allow-Methods', ALLOWED_METHODS);\n headers.set('Access-Control-Allow-Headers', ALLOWED_HEADERS);\n headers.set('Access-Control-Max-Age', PREFLIGHT_MAX_AGE_SECONDS);\n return new Response(null, { status: 204, headers });\n }\n\n await next();\n\n if (allowOrigin) {\n setSharedCorsHeaders(c, allowOrigin);\n }\n return;\n };\n}\n"],"mappings":";;;;;;;;;;AAYA,MAAM,kBAAkB;CACtB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK;AAEZ,MAAM,kBAAkB;AAExB,MAAM,4BAA4B;;;;;;;;;AAUlC,SAAS,qBAAqB,GAA2B;CAEvD,MAAM,SADU,aAAa,EAAE,IAAI,OAAO,SAAS,CAC7B,CAAC;CACvB,IAAI,QAAQ,OAAO;CACnB,OAAO,EAAE,IAAI,OAAO,SAAS,IAAI;;AAGnC,SAAS,qBAAqB,GAAY,aAA2B;CACnE,EAAE,IAAI,QAAQ,IAAI,+BAA+B,YAAY;CAC7D,EAAE,IAAI,QAAQ,IAAI,oCAAoC,OAAO;CAI7D,EAAE,IAAI,QAAQ,IAAI,QAAQ,iBAAiB;;;;;;;;;;;;;;;;;;;;AAqB7C,SAAgB,iBAAoC;CAClD,OAAO,OAAO,GAAG,SAAS;EACxB,MAAM,cAAc,qBAAqB,EAAE;EAE3C,IAAI,EAAE,IAAI,WAAW,WAAW;GAC9B,MAAM,UAAU,IAAI,SAAS;GAC7B,IAAI,aAAa;IACf,QAAQ,IAAI,+BAA+B,YAAY;IACvD,QAAQ,IAAI,oCAAoC,OAAO;IACvD,QAAQ,IAAI,QAAQ,iBAAiB;;GAEvC,QAAQ,IAAI,gCAAgC,gBAAgB;GAC5D,QAAQ,IAAI,gCAAgC,gBAAgB;GAC5D,QAAQ,IAAI,0BAA0B,0BAA0B;GAChE,OAAO,IAAI,SAAS,MAAM;IAAE,QAAQ;IAAK;IAAS,CAAC;;EAGrD,MAAM,MAAM;EAEZ,IAAI,aACF,qBAAqB,GAAG,YAAY"}
@@ -7,22 +7,36 @@ function serializeCookies(cookies) {
7
7
  return parts.join("; ");
8
8
  }
9
9
  /**
10
- * Returns a clone of `original` whose `Cookie` header has every
11
- * protected cookie removed (see
12
- * {@link isProtectedCookieName}). When no other cookies
13
- * remain, the header is dropped entirely.
10
+ * Mutates `headers` in place: parses the `Cookie` header, drops every
11
+ * protected cookie (see {@link isProtectedCookieName}), and rewrites
12
+ * the header. Deletes the header entirely when nothing survives.
14
13
  *
15
- * Used by `createAppConnectRequestHandler` so the user's route
16
- * handlers never see and therefore cannot leak the access token,
17
- * session ID, or refresh-token cookies.
14
+ * Used by the SDK's auth middleware after it has read the access
15
+ * token / session ID, so user route handlers never see and
16
+ * therefore cannot leak — those cookies, while CORS / auth
17
+ * middleware that ran first still got the raw values.
18
18
  */
19
- function sanitizeRequest(original) {
20
- const cookies = parseCookies(original.headers.get("Cookie"));
19
+ function stripProtectedCookies(headers) {
20
+ const cookies = parseCookies(headers.get("Cookie"));
21
21
  const surviving = /* @__PURE__ */ new Map();
22
22
  for (const [name, value] of Object.entries(cookies)) if (!isProtectedCookieName(name)) surviving.set(name, value);
23
- const headers = new Headers(original.headers);
24
23
  if (surviving.size === 0) headers.delete("cookie");
25
24
  else headers.set("cookie", serializeCookies(surviving));
25
+ }
26
+ /**
27
+ * Returns a clone of `original` whose `Cookie` header has every
28
+ * protected cookie removed (see {@link isProtectedCookieName}). When
29
+ * no other cookies remain, the header is dropped entirely.
30
+ *
31
+ * Standalone helper retained for callers that want a new Request
32
+ * (e.g. tests). Inside the SDK's per-request handler, the auth
33
+ * middleware uses {@link stripProtectedCookies} directly on
34
+ * `c.req.raw.headers` so the auth check itself can still read the
35
+ * protected cookies before they are stripped.
36
+ */
37
+ function sanitizeRequest(original) {
38
+ const headers = new Headers(original.headers);
39
+ stripProtectedCookies(headers);
26
40
  const init = {
27
41
  method: original.method,
28
42
  headers,
@@ -1 +1 @@
1
- {"version":3,"file":"sanitize-request.js","names":[],"sources":["../../src/server/sanitize-request.ts"],"sourcesContent":["import { isProtectedCookieName } from './constants.ts';\nimport { parseCookies } from './utils/cookie-utils.ts';\n\nfunction serializeCookies(cookies: Map<string, string>): string {\n const parts: string[] = [];\n for (const [name, value] of cookies) {\n parts.push(`${name}=${value}`);\n }\n return parts.join('; ');\n}\n\n/**\n * Returns a clone of `original` whose `Cookie` header has every\n * protected cookie removed (see\n * {@link isProtectedCookieName}). When no other cookies\n * remain, the header is dropped entirely.\n *\n * Used by `createAppConnectRequestHandler` so the user's route\n * handlers never see — and therefore cannot leak — the access token,\n * session ID, or refresh-token cookies.\n */\nexport function sanitizeRequest(original: Request): Request {\n const cookies = parseCookies(original.headers.get('Cookie'));\n\n const surviving = new Map<string, string>();\n for (const [name, value] of Object.entries(cookies)) {\n if (!isProtectedCookieName(name)) {\n surviving.set(name, value);\n }\n }\n\n const headers = new Headers(original.headers);\n if (surviving.size === 0) {\n headers.delete('cookie');\n } else {\n headers.set('cookie', serializeCookies(surviving));\n }\n\n const init: RequestInit = {\n method: original.method,\n headers,\n redirect: original.redirect,\n signal: original.signal,\n };\n\n if (original.body !== null) {\n init.body = original.body;\n (init as RequestInit & { duplex: 'half' }).duplex = 'half';\n }\n\n return new Request(original.url, init);\n}\n"],"mappings":";;;AAGA,SAAS,iBAAiB,SAAsC;CAC9D,MAAM,QAAkB,EAAE;CAC1B,KAAK,MAAM,CAAC,MAAM,UAAU,SAC1B,MAAM,KAAK,GAAG,KAAK,GAAG,QAAQ;CAEhC,OAAO,MAAM,KAAK,KAAK;;;;;;;;;;;;AAazB,SAAgB,gBAAgB,UAA4B;CAC1D,MAAM,UAAU,aAAa,SAAS,QAAQ,IAAI,SAAS,CAAC;CAE5D,MAAM,4BAAY,IAAI,KAAqB;CAC3C,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,QAAQ,EACjD,IAAI,CAAC,sBAAsB,KAAK,EAC9B,UAAU,IAAI,MAAM,MAAM;CAI9B,MAAM,UAAU,IAAI,QAAQ,SAAS,QAAQ;CAC7C,IAAI,UAAU,SAAS,GACrB,QAAQ,OAAO,SAAS;MAExB,QAAQ,IAAI,UAAU,iBAAiB,UAAU,CAAC;CAGpD,MAAM,OAAoB;EACxB,QAAQ,SAAS;EACjB;EACA,UAAU,SAAS;EACnB,QAAQ,SAAS;EAClB;CAED,IAAI,SAAS,SAAS,MAAM;EAC1B,KAAK,OAAO,SAAS;EACrB,KAA2C,SAAS;;CAGtD,OAAO,IAAI,QAAQ,SAAS,KAAK,KAAK"}
1
+ {"version":3,"file":"sanitize-request.js","names":[],"sources":["../../src/server/sanitize-request.ts"],"sourcesContent":["import { isProtectedCookieName } from './constants.ts';\nimport { parseCookies } from './utils/cookie-utils.ts';\n\nfunction serializeCookies(cookies: Map<string, string>): string {\n const parts: string[] = [];\n for (const [name, value] of cookies) {\n parts.push(`${name}=${value}`);\n }\n return parts.join('; ');\n}\n\n/**\n * Mutates `headers` in place: parses the `Cookie` header, drops every\n * protected cookie (see {@link isProtectedCookieName}), and rewrites\n * the header. Deletes the header entirely when nothing survives.\n *\n * Used by the SDK's auth middleware after it has read the access\n * token / session ID, so user route handlers never see — and\n * therefore cannot leak — those cookies, while CORS / auth\n * middleware that ran first still got the raw values.\n */\nexport function stripProtectedCookies(headers: Headers): void {\n const cookies = parseCookies(headers.get('Cookie'));\n const surviving = new Map<string, string>();\n for (const [name, value] of Object.entries(cookies)) {\n if (!isProtectedCookieName(name)) {\n surviving.set(name, value);\n }\n }\n\n if (surviving.size === 0) {\n headers.delete('cookie');\n } else {\n headers.set('cookie', serializeCookies(surviving));\n }\n}\n\n/**\n * Returns a clone of `original` whose `Cookie` header has every\n * protected cookie removed (see {@link isProtectedCookieName}). When\n * no other cookies remain, the header is dropped entirely.\n *\n * Standalone helper retained for callers that want a new Request\n * (e.g. tests). Inside the SDK's per-request handler, the auth\n * middleware uses {@link stripProtectedCookies} directly on\n * `c.req.raw.headers` so the auth check itself can still read the\n * protected cookies before they are stripped.\n */\nexport function sanitizeRequest(original: Request): Request {\n const headers = new Headers(original.headers);\n stripProtectedCookies(headers);\n\n const init: RequestInit = {\n method: original.method,\n headers,\n redirect: original.redirect,\n signal: original.signal,\n };\n\n if (original.body !== null) {\n init.body = original.body;\n (init as RequestInit & { duplex: 'half' }).duplex = 'half';\n }\n\n return new Request(original.url, init);\n}\n"],"mappings":";;;AAGA,SAAS,iBAAiB,SAAsC;CAC9D,MAAM,QAAkB,EAAE;CAC1B,KAAK,MAAM,CAAC,MAAM,UAAU,SAC1B,MAAM,KAAK,GAAG,KAAK,GAAG,QAAQ;CAEhC,OAAO,MAAM,KAAK,KAAK;;;;;;;;;;;;AAazB,SAAgB,sBAAsB,SAAwB;CAC5D,MAAM,UAAU,aAAa,QAAQ,IAAI,SAAS,CAAC;CACnD,MAAM,4BAAY,IAAI,KAAqB;CAC3C,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,QAAQ,EACjD,IAAI,CAAC,sBAAsB,KAAK,EAC9B,UAAU,IAAI,MAAM,MAAM;CAI9B,IAAI,UAAU,SAAS,GACrB,QAAQ,OAAO,SAAS;MAExB,QAAQ,IAAI,UAAU,iBAAiB,UAAU,CAAC;;;;;;;;;;;;;AAetD,SAAgB,gBAAgB,UAA4B;CAC1D,MAAM,UAAU,IAAI,QAAQ,SAAS,QAAQ;CAC7C,sBAAsB,QAAQ;CAE9B,MAAM,OAAoB;EACxB,QAAQ,SAAS;EACjB;EACA,UAAU,SAAS;EACnB,QAAQ,SAAS;EAClB;CAED,IAAI,SAAS,SAAS,MAAM;EAC1B,KAAK,OAAO,SAAS;EACrB,KAA2C,SAAS;;CAGtD,OAAO,IAAI,QAAQ,SAAS,KAAK,KAAK"}
@@ -1,17 +1,30 @@
1
1
  //#region src/shared/constants.ts
2
2
  /**
3
- * Constants whose values are part of the contract between the browser
4
- * controller and the server-side hubspot-connect routes. Both halves
5
- * import from this module so the wire format stays in sync.
3
+ * Path the browser visits after HubSpot's authorize endpoint
4
+ * redirects back to the app. Mounted on the **frontend** origin (not
5
+ * the SDK's edge function host) so all OAuth-related cookies live in
6
+ * the `(frontend, edge)` CHIPS partition.
7
+ *
8
+ * The SDK's `auth/init-session` builds the OAuth `redirect_uri` as
9
+ * `${requestOrigin}${HUBSPOT_FRONTEND_CALLBACK_PATH}`. The browser
10
+ * controller, on `start()`, recognizes this path on `window.location`
11
+ * and forwards `?code` + `?state` to the SDK's `auth/complete`
12
+ * endpoint via a credentialed cross-site fetch. The host app must
13
+ * register `${app_origin}${HUBSPOT_FRONTEND_CALLBACK_PATH}` as a
14
+ * redirect URI in its HubSpot app settings.
6
15
  */
16
+ const HUBSPOT_FRONTEND_CALLBACK_PATH = "/__hubspot_oauth_callback";
7
17
  /**
8
- * Query parameter on the OAuth return URL that carries the new access
9
- * token's expiry (Unix epoch milliseconds). The server emits this on
10
- * the `auth/callback` redirect; the browser parses it during `initSdk`
11
- * and then strips it from the URL via `history.replaceState`.
18
+ * Query parameter on the `auth/complete` POST request carrying the
19
+ * authorization `code` HubSpot returned to the frontend callback.
12
20
  */
13
- const EXPIRES_AT_URL_PARAM = "__hs_expires_at";
21
+ const AUTH_COMPLETE_CODE_PARAM = "code";
22
+ /**
23
+ * Query parameter on the `auth/complete` POST request carrying the
24
+ * OAuth `state` HubSpot echoed back to the frontend callback.
25
+ */
26
+ const AUTH_COMPLETE_STATE_PARAM = "state";
14
27
  //#endregion
15
- export { EXPIRES_AT_URL_PARAM };
28
+ export { AUTH_COMPLETE_CODE_PARAM, AUTH_COMPLETE_STATE_PARAM, HUBSPOT_FRONTEND_CALLBACK_PATH };
16
29
 
17
30
  //# sourceMappingURL=constants.js.map
@@ -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 server emits this on\n * the `auth/callback` redirect; the browser parses it during `initSdk`\n * and then strips it from the URL via `history.replaceState`.\n */\nexport const EXPIRES_AT_URL_PARAM = '__hs_expires_at';\n"],"mappings":";;;;;;;;;;;;AAYA,MAAa,uBAAuB"}
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 HUBSPOT_FRONTEND_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,iCAAiC;;;;;AAM9C,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.2",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "HubSpot App Connect SDK (alpha release). Documentation and integration guidance forthcoming.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -50,9 +50,9 @@
50
50
  "tsdown": "0.22.0-beta.3",
51
51
  "typescript": "6.0.3",
52
52
  "vitest": "4.0.18",
53
+ "@private/prettier-config": "0.1.0",
53
54
  "@private/tsconfig": "0.1.0",
54
- "@private/eslint-config": "0.1.0",
55
- "@private/prettier-config": "0.1.0"
55
+ "@private/eslint-config": "0.1.0"
56
56
  },
57
57
  "scripts": {
58
58
  "clean": "rm -rf dist build *.tsbuildinfo node_modules .turbo",
@@ -0,0 +1,167 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { HUBSPOT_FRONTEND_CALLBACK_PATH } from '../../shared/constants.ts';
4
+ import { noopLogger } from '../../shared/logger.ts';
5
+ import { EXPIRES_AT_KEY } from './constants.ts';
6
+ import { initAppConnect } from './init.ts';
7
+ import type {
8
+ AppConnectContext,
9
+ AppConnectInternalState,
10
+ SessionStorage,
11
+ } from './types.ts';
12
+ import { createStore } from './utils/store-utils.ts';
13
+
14
+ const HUBSPOT_CONNECT_BASE_URL =
15
+ 'https://edge.example.com/functions/v1/hubspot-connect';
16
+
17
+ function createInMemorySessionStorage(): SessionStorage {
18
+ const map = new Map<string, string>();
19
+ return {
20
+ getItem: (key) => map.get(key) ?? null,
21
+ setItem: (key, value) => {
22
+ map.set(key, value);
23
+ },
24
+ removeItem: (key) => {
25
+ map.delete(key);
26
+ },
27
+ };
28
+ }
29
+
30
+ function createTestContext(): AppConnectContext {
31
+ const initialState: AppConnectInternalState = {
32
+ isInitComplete: false,
33
+ isConnectInFlight: false,
34
+ isSessionConnected: false,
35
+ isDisconnectInFlight: false,
36
+ error: null,
37
+ expiresAt: null,
38
+ };
39
+ return {
40
+ config: { hubSpotConnectBaseUrl: HUBSPOT_CONNECT_BASE_URL },
41
+ logger: noopLogger,
42
+ sessionStorage: createInMemorySessionStorage(),
43
+ store: createStore<AppConnectInternalState>(initialState),
44
+ };
45
+ }
46
+
47
+ interface FakeWindowOptions {
48
+ href: string;
49
+ }
50
+
51
+ interface FakeWindow {
52
+ history: { calls: Array<[unknown, string, string]> };
53
+ windowProxy: {
54
+ location: Pick<Location, 'pathname' | 'search' | 'origin'>;
55
+ };
56
+ }
57
+
58
+ function installFakeWindow(options: FakeWindowOptions): FakeWindow {
59
+ const url = new URL(options.href);
60
+ const calls: Array<[unknown, string, string]> = [];
61
+ const fakeWindow = {
62
+ location: {
63
+ pathname: url.pathname,
64
+ search: url.search,
65
+ origin: url.origin,
66
+ },
67
+ };
68
+ const fakeHistory = {
69
+ replaceState: (state: unknown, title: string, newUrl: string) => {
70
+ calls.push([state, title, newUrl]);
71
+ const replaced = new URL(newUrl, url.origin);
72
+ fakeWindow.location.pathname = replaced.pathname;
73
+ fakeWindow.location.search = replaced.search;
74
+ },
75
+ };
76
+ vi.stubGlobal('window', fakeWindow);
77
+ vi.stubGlobal('history', fakeHistory);
78
+ return { history: { calls }, windowProxy: fakeWindow };
79
+ }
80
+
81
+ describe('initAppConnect', () => {
82
+ beforeEach(() => {
83
+ vi.useFakeTimers();
84
+ vi.setSystemTime(new Date(1_700_000_000_000));
85
+ });
86
+
87
+ afterEach(() => {
88
+ vi.unstubAllGlobals();
89
+ vi.restoreAllMocks();
90
+ vi.useRealTimers();
91
+ });
92
+
93
+ it('is a no-op on a normal page (no callback path, no expires_at param)', async () => {
94
+ installFakeWindow({ href: 'https://app.example.com/dashboard' });
95
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
96
+ const context = createTestContext();
97
+
98
+ await initAppConnect(context);
99
+
100
+ expect(fetchSpy).not.toHaveBeenCalled();
101
+ expect(context.store.getSnapshot().expiresAt).toBeNull();
102
+ });
103
+
104
+ it('POSTs to /auth/complete on the OAuth callback path and persists expires_at + return_path', async () => {
105
+ const expiresAt = Date.now() + 1800 * 1000;
106
+ installFakeWindow({
107
+ href: `https://app.example.com${HUBSPOT_FRONTEND_CALLBACK_PATH}?code=auth-code&state=auth-state`,
108
+ });
109
+ const fetchSpy = vi
110
+ .spyOn(globalThis, 'fetch')
111
+ .mockResolvedValue(
112
+ new Response(
113
+ JSON.stringify({ expires_at: expiresAt, return_path: '/dashboard' }),
114
+ { status: 200 }
115
+ )
116
+ );
117
+ const context = createTestContext();
118
+
119
+ await initAppConnect(context);
120
+
121
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
122
+ const [calledUrl, calledInit] = fetchSpy.mock.calls[0]!;
123
+ const url = new URL(calledUrl as string);
124
+ expect(url.origin + url.pathname).toBe(
125
+ `${HUBSPOT_CONNECT_BASE_URL}/auth/complete`
126
+ );
127
+ expect(url.searchParams.get('code')).toBe('auth-code');
128
+ expect(url.searchParams.get('state')).toBe('auth-state');
129
+ expect((calledInit as RequestInit).method).toBe('POST');
130
+ expect((calledInit as RequestInit).credentials).toBe('include');
131
+
132
+ expect(context.store.getSnapshot().expiresAt).toBe(expiresAt);
133
+ expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBe(
134
+ String(expiresAt)
135
+ );
136
+ expect(window.location.pathname).toBe('/dashboard');
137
+ });
138
+
139
+ it('throws and clears session storage when /auth/complete returns a non-OK response', async () => {
140
+ installFakeWindow({
141
+ href: `https://app.example.com${HUBSPOT_FRONTEND_CALLBACK_PATH}?code=auth-code&state=bad-state`,
142
+ });
143
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
144
+ new Response('{"error":"State mismatch"}', {
145
+ status: 403,
146
+ statusText: 'Forbidden',
147
+ })
148
+ );
149
+ const context = createTestContext();
150
+ context.sessionStorage.setItem(EXPIRES_AT_KEY, '999');
151
+
152
+ await expect(initAppConnect(context)).rejects.toThrow(/Failed to complete/);
153
+ expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
154
+ });
155
+
156
+ it('skips the callback step on the callback path when code or state are missing', async () => {
157
+ installFakeWindow({
158
+ href: `https://app.example.com${HUBSPOT_FRONTEND_CALLBACK_PATH}`,
159
+ });
160
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
161
+ const context = createTestContext();
162
+
163
+ await initAppConnect(context);
164
+
165
+ expect(fetchSpy).not.toHaveBeenCalled();
166
+ });
167
+ });
@@ -1,35 +1,86 @@
1
- import { EXPIRES_AT_KEY, EXPIRES_AT_URL_PARAM } from './constants.ts';
1
+ import {
2
+ AUTH_COMPLETE_CODE_PARAM,
3
+ AUTH_COMPLETE_STATE_PARAM,
4
+ HUBSPOT_FRONTEND_CALLBACK_PATH,
5
+ } from '../../shared/constants.ts';
6
+ import type { AuthCompleteResponse } from '../../shared/wire-types.ts';
7
+ import { EXPIRES_AT_KEY } from './constants.ts';
2
8
  import type { AppConnectContext } from './types.ts';
9
+ import { clearSessionStorage } from './utils/session-utils.ts';
3
10
 
4
11
  /**
5
- * Picks up the `__hs_expires_at` parameter the OAuth callback adds to
6
- * the redirect URL, persists it to the controller, and strips it from
7
- * the address bar so it's not logged or bookmarked.
12
+ * On `controller.start()`:
8
13
  *
9
- * Called once by `controller.start()`. A no-op when the parameter
10
- * isn't present (e.g. on every page load other than the OAuth return
11
- * trip).
14
+ * 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.
29
+ *
30
+ * A no-op when neither set of parameters is present (every page
31
+ * load other than the OAuth return trip).
12
32
  */
13
33
  export async function initAppConnect(
14
34
  context: AppConnectContext
15
35
  ): Promise<void> {
36
+ await consumeOAuthCallback(context);
37
+ }
38
+
39
+ async function consumeOAuthCallback(context: AppConnectContext): Promise<void> {
40
+ if (window.location.pathname !== HUBSPOT_FRONTEND_CALLBACK_PATH) return;
41
+
16
42
  const params = new URLSearchParams(window.location.search);
17
- const expiresAtStr = params.get(EXPIRES_AT_URL_PARAM);
18
- if (!expiresAtStr) return;
43
+ const code = params.get(AUTH_COMPLETE_CODE_PARAM);
44
+ const state = params.get(AUTH_COMPLETE_STATE_PARAM);
45
+ if (!code || !state) return;
46
+
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
+ );
65
+ }
19
66
 
20
- const expiresAt = parseInt(expiresAtStr, 10);
21
- if (isNaN(expiresAt)) return;
67
+ if (!response.ok) {
68
+ clearSessionStorage(context);
69
+ throw new Error(
70
+ `Failed to complete HubSpot OAuth: ${response.status} ${response.statusText}`
71
+ );
72
+ }
22
73
 
23
- params.delete(EXPIRES_AT_URL_PARAM);
24
- const cleanSearch = params.toString();
74
+ const body = (await response.json()) as AuthCompleteResponse;
75
+
76
+ const { expires_at: expiresAt, return_path: returnPath } = body;
77
+ context.store.setState({ expiresAt });
78
+ context.sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAt));
79
+
80
+ const targetUrl = new URL(returnPath, window.location.origin);
25
81
  history.replaceState(
26
82
  null,
27
83
  '',
28
- cleanSearch
29
- ? `${window.location.pathname}?${cleanSearch}`
30
- : window.location.pathname
84
+ `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`
31
85
  );
32
-
33
- context.store.setState({ expiresAt });
34
- context.sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAt));
35
86
  }
@@ -37,6 +37,8 @@ interface AppConnectHeaderProps {
37
37
 
38
38
  export function AppConnectHeader({ title }: AppConnectHeaderProps) {
39
39
  const { status } = useHubSpotAppConnect();
40
+ const connectButton =
41
+ status === 'initializing' ? null : <ConnectButton variant="secondary" />;
40
42
  return (
41
43
  <header className={styles.header}>
42
44
  <div className={styles.titleRow}>
@@ -48,11 +50,7 @@ export function AppConnectHeader({ title }: AppConnectHeaderProps) {
48
50
  {status === 'connected' ? <ViewingHubSpotContextRow /> : null}
49
51
  </div>
50
52
  </div>
51
- {status === 'connected' ? (
52
- <UserMenu />
53
- ) : (
54
- <ConnectButton variant="secondary" />
55
- )}
53
+ {status === 'connected' ? <UserMenu /> : connectButton}
56
54
  </header>
57
55
  );
58
56
  }
@@ -20,7 +20,8 @@ export function ConnectButton({
20
20
  className,
21
21
  }: ConnectButtonProps) {
22
22
  const { status, connectToHubSpot } = useHubSpotAppConnect();
23
- const isConnecting = status === 'connecting';
23
+ console.log('status', status);
24
+ const isConnecting = status === 'connecting' || status === 'initializing';
24
25
  const composedClassName = [root, className].filter(Boolean).join(' ');
25
26
  const labelClassName = isConnecting ? `${label} ${labelMuted}` : label;
26
27
  return (
@@ -101,11 +101,15 @@ export function fetchTransportPlugin(
101
101
  }
102
102
  }
103
103
  const response = await fetch(url, init);
104
+ const responseHeaders: Record<string, string> = Object.create(null);
105
+ response.headers.forEach((value, key) => {
106
+ responseHeaders[key] = value;
107
+ });
104
108
 
105
109
  return {
106
110
  status: response.status,
107
111
  statusText: response.statusText,
108
- headers: Object.fromEntries(response.headers.entries()),
112
+ headers: responseHeaders,
109
113
  // 204 No Content responses have no body to parse
110
114
  bodyJson: response.status === 204 ? undefined : await response.json(),
111
115
  };
@@ -12,6 +12,25 @@ export const HUBSPOT_ACCESS_TOKEN_COOKIE_NAME = '__Host-hs_access_token';
12
12
  */
13
13
  export const HUBSPOT_APP_SID_COOKIE_NAME = '__Host-hs_app_sid';
14
14
 
15
+ /**
16
+ * Cookie pinning the browser-facing app origin (e.g.
17
+ * `https://app.example.com`) for the lifetime of an app session.
18
+ * Set by `auth/init-session` from the request's `Origin` header and
19
+ * read by:
20
+ *
21
+ * - The CORS middleware to emit a credentialed
22
+ * `Access-Control-Allow-Origin` value (`*` is forbidden when
23
+ * `Access-Control-Allow-Credentials: true`).
24
+ * - `auth/complete` to rebuild the OAuth `redirect_uri` it sent to
25
+ * HubSpot during `init-session` (the token endpoint validates that
26
+ * the two values match).
27
+ *
28
+ * `__Host-` prefixed (Path=/, Secure, no Domain), so the same
29
+ * function host can serve both `hubspot-connect` and `api` routes
30
+ * and read the cookie from either.
31
+ */
32
+ export const HUBSPOT_APP_ORIGIN_COOKIE_NAME = '__Host-hs_app_origin';
33
+
15
34
  /**
16
35
  * Prefix used for refresh-token cookies. Each session gets its own
17
36
  * cookie name (`hs_refresh_<sidHash>`) so multiple devices/tabs can
@@ -22,6 +41,7 @@ export const HUBSPOT_REFRESH_COOKIE_PREFIX = 'hs_refresh_';
22
41
  const PROTECTED_COOKIE_NAMES = new Set([
23
42
  HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,
24
43
  HUBSPOT_APP_SID_COOKIE_NAME,
44
+ HUBSPOT_APP_ORIGIN_COOKIE_NAME,
25
45
  ]);
26
46
 
27
47
  /**
@@ -40,14 +60,19 @@ export function isProtectedCookieName(cookieName: string): boolean {
40
60
 
41
61
  /**
42
62
  * Cookie carrying the PKCE code verifier between `init-session` and
43
- * `callback`. `Lax` SameSite so it accompanies the redirect back from
44
- * HubSpot.
63
+ * `auth/complete`. Set with `SameSite=None; Secure; Partitioned` so the
64
+ * frontend's credentialed cross-site `POST /auth/complete` (made from
65
+ * the OAuth callback page on the app origin to the SDK's edge function
66
+ * origin) carries it through. `Lax` would silently drop it on that
67
+ * fetch, breaking every successful HubSpot redirect.
45
68
  */
46
69
  export const TEMP_COOKIE_PKCE_VERIFIER = '__hs_pkce_verifier';
47
70
 
48
71
  /**
49
72
  * Cookie carrying the OAuth `state` value between `init-session` and
50
- * `callback`. Compared against the `state` query parameter as the
51
- * primary CSRF defense.
73
+ * `auth/complete`. Compared against the `state` query parameter as the
74
+ * primary CSRF defense. Same `SameSite=None; Secure; Partitioned`
75
+ * attributes as `TEMP_COOKIE_PKCE_VERIFIER` for the same cross-site
76
+ * `POST /auth/complete` reason.
52
77
  */
53
78
  export const TEMP_COOKIE_OAUTH_STATE = '__hs_oauth_state';