-
Notifications
You must be signed in to change notification settings - Fork 630
skill: HubSpot Private App creation + rotation #175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
axelclark
wants to merge
1
commit into
browser-use:main
Choose a base branch
from
axelclark:skill/hubspot-private-app
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| # HubSpot — creating a Private App | ||
|
|
||
| For generating a long-lived access token (`pat-<region>-<uuid>`) against `api.hubapi.com`. Takes ~60 seconds of clicks if you drive it via DOM. | ||
|
|
||
| ## URL map | ||
|
|
||
| - `https://app-<region>.hubspot.com/legacy-apps/{portalId}` — **start here**. Private Apps live under "Legacy Apps" now. | ||
| - `https://app-<region>.hubspot.com/private-apps/{portalId}` — **dead route** for dev portals; it renders "Your private apps have moved" with a redirect button. Don't start here. | ||
| - `https://app-<region>.hubspot.com/settings/{portalId}/integrations/private-apps` — **404**. HubSpot's docs still reference the Settings path, but it's gone from the UI in the dev-portal/standard-portal case. | ||
| - After creation, HubSpot redirects to `https://app-<region>.hubspot.com/private-apps/{portalId}/{appId}` — that IS a valid URL post-creation (it's the app-detail page). | ||
|
|
||
| `<region>` is `na1`, `na2`, `eu1`, etc. — read it off the current `location.hostname` or from the portal's own settings. | ||
|
|
||
| ## Flow | ||
|
|
||
| 1. Land on `/legacy-apps/{portalId}`. | ||
| 2. Click the orange "Create legacy app" button (top-right). | ||
| 3. A modal appears: "Public" (for many accounts) vs "Private" (for one account). Clicking "Private" enters the creation form. | ||
| 4. Fill **Basic Info** tab: name + description. Logo is optional. | ||
| 5. Click **Scopes** tab → **Add new scope** → scope picker drawer opens from the right. | ||
| 6. Check the scope checkboxes you want → **Update** at the bottom of the drawer. | ||
| 7. Click **Create app** (top-right) → confirmation modal warns about token sharing. | ||
| 8. Click **Continue creating** → redirect to app detail page. | ||
| 9. **Auth** tab has the access token. Click **Show token** to unmask. | ||
|
|
||
| The app detail page is also where you rotate the token or delete the app later. | ||
|
|
||
| ## Gotchas | ||
|
|
||
| ### Text content is in `<I18N-STRING>` custom elements, not regular text | ||
|
|
||
| Standard `[...document.querySelectorAll('button')].find(b => b.innerText === 'Scopes')` often **fails** because the button's innerText is composed from a custom `<I18N-STRING>` child element whose text walkers/selectors may not traverse the same way as `<span>`. Work around it with a `TreeWalker`: | ||
|
|
||
| ```js | ||
| const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); | ||
| let node; | ||
| while ((node = walker.nextNode())) { | ||
| if (node.nodeValue.trim() === 'Scopes') { | ||
| let cur = node.parentElement; | ||
| for (let i = 0; i < 6; i++) { | ||
| if (!cur) break; | ||
| if (cur.tagName === 'A' || cur.tagName === 'BUTTON' || cur.getAttribute('role') === 'tab') { | ||
| cur.click(); | ||
| break; | ||
| } | ||
| cur = cur.parentElement; | ||
| } | ||
| break; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| This pattern (text-node → walk up to clickable ancestor) works for basically every tab, button, and card in the HubSpot developer UI. | ||
|
|
||
| ### Form inputs are React-controlled | ||
|
|
||
| Setting `input.value = 'x'` doesn't register. Use the native setter + dispatch `input` event: | ||
|
|
||
| ```js | ||
| const desc = Object.getOwnPropertyDescriptor(el.constructor.prototype, 'value'); | ||
| desc.set.call(el, 'AO MCP'); | ||
| el.dispatchEvent(new Event('input', { bubbles: true })); | ||
| el.dispatchEvent(new Event('change', { bubbles: true })); | ||
| ``` | ||
|
|
||
| ### Scope rows have direct checkboxes — click them, not the row | ||
|
|
||
| The scope picker renders each scope as a row. The checkbox is a real `<input type="checkbox">`. Walk up from the scope's code-name text node to find the checkbox in the same row: | ||
|
|
||
| ```js | ||
| let cur = scopeCodeTextNode.parentElement; | ||
| for (let i = 0; i < 8; i++) { | ||
| if (!cur) break; | ||
| const cb = cur.querySelector('input[type="checkbox"]'); | ||
| if (cb) { if (!cb.checked) cb.click(); break; } | ||
| cur = cur.parentElement; | ||
| } | ||
| ``` | ||
|
|
||
| ### Two-step confirmation before token issuance | ||
|
|
||
| After clicking "Create app," a "Create a new private app" warning modal appears with a "Continue creating" button. Don't stop after the first click — look for the modal. | ||
|
|
||
| ### Show token ≠ one-time reveal | ||
|
|
||
| Unlike some credential systems, the HubSpot Private App token is re-revealable indefinitely from the Auth tab → Show token. Not capturing it on first click is fine. | ||
|
|
||
| ### Token format | ||
|
|
||
| `pat-{region}-{uuid-v4}` — 44 characters total. Regex: `^pat-na[0-9]+-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`. The 5-char hex prefix shown in the masked view (e.g. `pat-na2-abcde**-...`) is the start of the UUID's first group — useful for confirming a revealed token matches what you're looking at without printing the full value. | ||
|
|
||
| ## Verification | ||
|
|
||
| After capturing the token, a 200 from `/account-info/v3/details` confirms both validity and the `portalId` the token is bound to: | ||
|
|
||
| ```bash | ||
| curl -sS -H "Authorization: Bearer $TOKEN" https://api.hubapi.com/account-info/v3/details | ||
| # => {"portalId":..., "uiDomain":"app-na2.hubspot.com", "dataHostingLocation":"na2", ...} | ||
| ``` | ||
|
|
||
| The `uiDomain` / `dataHostingLocation` fields tell you which `<region>` to use for future Auth-tab URLs. | ||
|
|
||
| ## What doesn't apply to Private Apps | ||
|
|
||
| - No OAuth flow, no client_id/client_secret exchange for getting a token | ||
| - No "verify your app" / "unverified-app banner" (those are Public App concerns) | ||
| - No redirect URI configuration | ||
| - No user consent screen — token is account-scoped, not user-scoped | ||
| - No marketplace review | ||
| - Client secret shown on the Auth page is for **webhook signature validation**, not API auth | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| # HubSpot — rotating a Private App access token | ||
|
|
||
| For killing a compromised token and capturing its replacement without the value ever touching a shell, chat, or log. See `private-app-creation.md` for the DOM gotchas that apply generally (I18N-STRING text nodes, React-controlled inputs, TreeWalker patterns) — this file covers only the rotation-specific flow. | ||
|
|
||
| ## When to rotate | ||
|
|
||
| - Token value was exposed (leaked in chat, committed to git, pasted in a screenshot). | ||
| - Routine rotation policy. | ||
| - You're handing off ownership and want to invalidate any cached copies. | ||
|
|
||
| HubSpot Private App tokens are re-revealable indefinitely from the UI, so "I lost the value" is not a reason to rotate — just click **Show token** again. | ||
|
|
||
| ## URL | ||
|
|
||
| ``` | ||
| https://app-<region>.hubspot.com/private-apps/{portalId}/{appId}/auth | ||
| ``` | ||
|
|
||
| The Auth tab is where Rotate lives. If you land on the app-detail page, click the "Auth" tab first — it's an `<a role="button">` with innerText "Auth" (no href; client-side routed). | ||
|
|
||
| ## Flow | ||
|
|
||
| 1. Navigate to the Auth tab (URL above, or click the Auth tab anchor). | ||
| 2. Click the **Rotate** button (a `<button>` with innerText "Rotate", not to be confused with "Rotate now" which is inner-modal). | ||
| 3. First modal asks *when* to expire the previous token: | ||
| - **Rotate and expire this token later** — creates a new token; the old one stays valid during a grace window (exact duration visible in the modal copy). Use for zero-downtime rotation where you control both deploy timing and the rotation. | ||
| - **Rotate and expire this token now** — immediate invalidation. Use when the old token is compromised. | ||
| 4. A second confirmation modal appears: **Rotate now** vs **Cancel**. Click **Rotate now**. | ||
| 5. After ~2–4 seconds, the Auth tab re-renders with a new token and the **Hide token** button replacing **Show token** (HubSpot auto-reveals the freshly-minted token once). | ||
| 6. The token is a text node inside a `<code>` element, matching `^pat-na[0-9]+-<uuid>$` (44 chars). | ||
|
|
||
| ## Extracting without printing | ||
|
|
||
| The safe pattern: run a TreeWalker filtered by the exact token regex (so it only matches unmasked tokens, not the `********-****` display). Read the value into a Python variable, pipe straight into `subprocess.run(["fly", "secrets", "set", ..., f"KEY={token}"])`, and do not echo to stdout. | ||
|
|
||
| ```python | ||
| from browser_harness import * | ||
| import subprocess, re, sys | ||
|
|
||
| # Switch to the open HubSpot tab rather than creating one — the existing session is authenticated. | ||
| for t in list_tabs(include_chrome=False): | ||
| if "private-apps/" in t.get("url", "") and "/auth" in t.get("url", ""): | ||
| switch_tab(t["targetId"]) | ||
| break | ||
|
|
||
| token = js(""" | ||
| (() => { | ||
| const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); | ||
| let n; | ||
| while ((n = walker.nextNode())) { | ||
| const t = (n.nodeValue || "").trim(); | ||
| if (/^pat-na[0-9]+-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(t)) return t; | ||
| } | ||
| return null; | ||
| })() | ||
| """) | ||
|
|
||
| if not token or not re.fullmatch(r"pat-na[0-9]+-[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}", token): | ||
| sys.exit("no token extracted") | ||
|
|
||
| # Metadata only — never print the value. | ||
| print(f"len={len(token)} prefix={token[:8]}") | ||
|
|
||
| r = subprocess.run( | ||
| ["fly", "secrets", "set", "--app", "<app>", f"HUBSPOT_PRIVATE_APP_TOKEN={token}"], | ||
| capture_output=True, text=True, | ||
| ) | ||
| # Scrub in case fly echoes it on error paths. | ||
| def scrub(s, secret=token): | ||
| return s.replace(secret, "<TOKEN>") if s else s | ||
| print("rc", r.returncode, scrub(r.stderr)) | ||
| ``` | ||
|
|
||
| ### Why subprocess.run with a list, not `fly …` via a shell | ||
|
|
||
| `subprocess.run([...])` passes the token directly as `argv[2]` to fly. No shell parses it, no history captures it, and it's visible only in `/proc/<fly-pid>/cmdline` for the ~2-second lifetime of the process. A shell invocation (`bash -c 'fly secrets set ... KEY=$TOKEN'`) would put the expanded value into the shell's argv and expose it to `ps aux` across two processes. | ||
|
|
||
| Do NOT use the prefix-env pattern: | ||
| ```bash | ||
| # WRONG — arg-side $TOKEN expands in the outer shell, not the prefix env. | ||
| TOKEN="$(cat /tmp/ao_token)" fly secrets set --app X KEY="$TOKEN" | ||
| ``` | ||
| The prefix `TOKEN=...` only applies to `fly`'s child environment; the `$TOKEN` in the argument is expanded by the calling shell, where `TOKEN` is usually unset, silently producing `KEY=`. Fly will happily store an empty string and the secret looks set until you SSH in and check its length. You want either the subprocess-list form above, or direct inline substitution: | ||
| ```bash | ||
| fly secrets set --app X KEY="$(cat /tmp/ao_token)" | ||
| ``` | ||
|
|
||
| ## Verification | ||
|
|
||
| After `fly secrets set` (no `--stage`, so it auto-deploys), wait for the rolling restart and then check from inside the VM — the token never has to leave the prod environment: | ||
|
|
||
| ```bash | ||
| fly ssh console --app <app> -C 'node -e " | ||
| const t = process.env.HUBSPOT_PRIVATE_APP_TOKEN; | ||
| console.log(\"LEN=\" + (t||\"\").length + \" prefix=\" + (t||\"\").slice(0,8)); | ||
| fetch(\"https://api.hubapi.com/account-info/v3/details\", { | ||
| headers: { Authorization: \"Bearer \" + t } | ||
| }).then(async r => { | ||
| const j = await r.json().catch(() => ({})); | ||
| console.log(\"HTTP \" + r.status + \" portalId=\" + j.portalId + \" uiDomain=\" + j.uiDomain); | ||
| }).catch(e => console.log(\"ERR \" + e.message)); | ||
| "' | ||
| ``` | ||
|
|
||
| Expected: `LEN=44 prefix=pat-na2- … HTTP 200 portalId=<your portal> uiDomain=app-na2.hubspot.com`. | ||
|
|
||
| Slim container lacks `curl` — use `node -e` for inline fetches. | ||
|
|
||
| ## Gotchas | ||
|
|
||
| ### The prior token is re-revealable even after rotation if you chose "later" | ||
|
|
||
| "Rotate and expire this token later" means the old token stays valid for a grace period shown in the modal. During that window, both tokens authenticate. Don't conflate "I rotated" with "the old value is dead" unless you picked "expire now." | ||
|
|
||
| ### The masked display is also a text node | ||
|
|
||
| An earlier token's masked value (`********-****-…`) coexists on the page with the new token's unmasked value. Don't scan loosely for "pat-" or take the first `<code>` — gate by the full UUID regex so the masked display doesn't match. (It's `*` characters, not hex.) | ||
|
|
||
| ### Rotation invalidates in-flight refresh tokens too | ||
|
|
||
| Irrelevant for Private Apps (which have no OAuth flow — see `private-app-creation.md`) but worth noting if you later switch this app type: Public App rotations invalidate stored HubSpot refresh tokens, forcing a full reauth cycle for every user. |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Token format regex is incorrectly hardcoded to
naregion, contradicting the documented multi-region token format and rejecting valid non-NA tokens.Prompt for AI agents