You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Say which TypeScript OAuth helpers need Node, and fail fast outside it - #976
TypeScript only. Three OAuth helpers use Node built-ins and can't run in React Native or a browser: performInteractiveLogin and startCallbackServer (a node:http callback server) and FileTokenStore (node:fs). This PR does three things:
Fail fast outside Node. Each of the three first checks for process.versions.node. If it's missing, the helper throws a usageBasecampError that names the helper and says it needs Node.js. The error's hint points at the new README section. The check is in a new src/oauth/node-runtime.ts, which isn't exported. performInteractiveLogin now throws before discovery runs. Before, it got as far as discovery and failed there with a network error.
README: the install note and the "Getting a token" table now say these helpers are Node-only, and so does the Interactive Login section (whose example imports open, also Node-only).
New README section, "React Native and browsers": Launchpad has no PKCE and requires the client secret for both the code exchange and refresh, so both run on your server. For a native app: register a redirect URI the app owns, sign in through the system auth session (an Expo example is included), and keep tokens in secure storage behind your own TokenStore. Launchpad's refresh returns no new refresh token, so the example keeps the original. For a browser app: an https callback on your server, which keeps the tokens (Launchpad sends no CORS headers). The section also says which Node built-ins each entry point loads, and that a bundler refusing them fails before the guard can run.
Docblocks on the three helpers say the same.
Why
A developer used performInteractiveLogin with FileTokenStore in a React Native app, following our README example. The first error was promisify is not a function, which comes from the top of the open package (v7) once a bundler stubs out util. Next was OAuth discovery failed: Load failed, WebKit's message for a fetch that was blocked or couldn't connect. In a web build that is expected, because Launchpad's /.well-known/oauth-authorization-server sends no Access-Control-Allow-Origin; the error alone doesn't prove that was the cause. Neither error names the real cause. If both had been worked around, the flow would still have failed at the callback server. Nothing in the OAuth docs said these helpers need Node. Support card: https://app.basecamp.com/2914079/buckets/27/card_tables/cards/10390587859
Decisions
No packaging change in this PR. The clean fix is to keep Node-only code out of what React Native and browser bundles load. Today both entry points import Node built-ins when they load: @37signals/basecamp loads node:crypto (webhook signing) and node:http (the callback server), and @37signals/basecamp/oauth loads node:http plus node:fs, node:path and node:os (the file token store). Getting them out means one of two things. Either move the Node-only exports (performInteractiveLogin, startCallbackServer, FileTokenStore, verifyWebhookSignature/signWebhookPayload, createNodeHandler) to a @37signals/basecamp/node subpath. Or add react-native/browser export conditions that resolve to a core entry without them. Either way index.ts has to be split, and those imports disappear from the root for some consumers. That's a breaking change, so it should go in its own PR with a MIGRATING note. Proposed follow-up.
The check is process.versions.node, not "does node:http exist." In the reported case the bundler had replaced built-ins with empty modules, so the import "works" and only calling it fails. Node, Bun, Deno's Node compatibility and Electron's main process all report versions.node. Browsers have no process, and React Native's process has no versions.node. Both cases are tested.
The guard can't catch the first reported error.promisify is not a function comes from the open package when it loads, before any SDK call, so the fix for that one is the README marking open as Node-only. The guard covers the later failures.
The recipe uses the standard grant_type parameters, not useLegacyFormat. Launchpad accepts both, and its API docs call the legacy ones not recommended.
FileTokenStore throws from its constructor. That's the first point where a caller touches it, and it fails before any token is in flight.
Testing
New tests/oauth/node-runtime.test.ts covers two runtimes: no process (browser) and a process polyfill without versions.node (React Native). In each, performInteractiveLogin rejects before fetch, openBrowser or store.save is called, startCallbackServer rejects, and the FileTokenStore constructor throws. All three report the exact usage message and the README hint. The global is swapped only around the synchronous call and restored before vitest or msw run again.
Non-vacuity: with the check disabled (if (false && …)), 6 of the 6 guard tests fail. Restored, 9 of 9 pass.
Node is unaffected: the existing interactive-login, callback-server and token-store suites pass unchanged.
npm test: 97 files, 2081 tests pass (rerun at e479a0e, along with typecheck, lint, build, make ts-check-drift and make doc-constants-check). npm run typecheck, npm run lint, npm run lint:test-timers and npm run build all pass.
make ts-check passes (Linux). Its ts-test-verb-refusal step needed a local shasum → sha1sum shim, because this box has no shasum; that's unrelated to this change.
Summary by cubic
Makes three Node-dependent OAuth helpers (performInteractiveLogin, startCallbackServer, FileTokenStore) fail fast with a clear usage BasecampError outside Node.js, instead of failing partway through with misleading errors (promisify is not a function, OAuth discovery failed: Load failed) that never named the cause.
Each helper checks for process.versions.node via the new unexported src/oauth/node-runtime.ts; FileTokenStore throws from its constructor. The guard only runs if the module loads — a bundler that refuses the static node: imports fails at import instead.
The README marks the helpers as Node-only and adds a "React Native and browsers" section: sign in through the platform's system auth session, exchange and refresh on a server (Launchpad requires the client secret for both), and keep tokens in secure storage — implemented behind a TokenStore in the app.
The manual refresh example now keeps the original refresh token, since Launchpad returns no new one on refresh. It also sends the client ID and secret, which Launchpad requires, and the native recipe prefers a universal link over a custom scheme.
New tests cover a missing process (browser) and a polyfill without versions.node (React Native); all guard tests fail when the check is disabled. Node is unaffected.
Written for commit 1b6011e. Summary will update on new commits.
performInteractiveLogin and startCallbackServer receive the redirect on a
node:http server, and FileTokenStore writes with node:fs. A React Native
developer reached for them from the README and got two misleading errors:
"promisify is not a function" from the open package the example imports, and
then "OAuth discovery failed: Load failed" when WebKit blocked the discovery
fetch (Launchpad sends no CORS headers). Neither named the cause, and the flow
would have failed at the callback server next anyway.
The three helpers now throw a usage BasecampError before doing anything when
process.versions.node is absent, with a hint pointing at the README. The README
marks them Node-only and adds a React Native and browsers section: system auth
session in the app, code exchange and refresh on a server (Launchpad requires
the client secret for both), tokens in the platform's secure storage.
Adds runtime guards and documentation for Node-only TypeScript OAuth helpers.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Changes:
Adds Node runtime detection and typed usage errors.
Guards interactive login, callback server, and file token storage.
Documents mobile/browser OAuth alternatives and adds guard tests.
File
Description
typescript/src/oauth/node-runtime.ts
Adds shared Node runtime guard.
typescript/src/oauth/interactive-login.ts
Guards interactive login.
typescript/src/oauth/callback-server.ts
Guards callback server startup.
typescript/src/oauth/token-store.ts
Guards file-store construction.
typescript/tests/oauth/node-runtime.test.ts
Tests browser and React Native behavior.
typescript/README.md
Documents runtime limitations and alternative OAuth flow.
- Keep the original refresh token: Launchpad's refresh returns a new
access token but no new refresh token, so saving the refreshed token
as-is lost the ability to refresh again.
- Drop useLegacyFormat from the recipe. Launchpad accepts the standard
grant_type parameters, and its API docs call the legacy ones not
recommended.
- Split native and browser guidance. The auth session and Keychain/
Keystore steps are native-only; a browser app uses an https callback
on its server and keeps tokens there. Same split in the error hint
and the docblocks.
- Say what each entry point actually loads (main: node:crypto,
node:http; /oauth: node:http, node:fs, node:path, node:os), and that
a bundler refusing those imports fails before the guard can run.
- Note in the token table that Launchpad has no PKCE.
- The callback-server, interactive-login and token-store docblocks now
say the usage error applies once the module has loaded; a bundler that
refuses the static node: imports fails at import instead.
- Manual Authorization Flow: the Launchpad refresh example now sends the
client ID and secret, which Launchpad requires, and the exchange
comment no longer says the legacy format is required.
- Native recipe: prefer a universal link / app link over a custom
scheme, since Launchpad has no PKCE to bind the code to the app.
- Error hint: "exchange the code and refresh tokens", not "refresh the
code".
Review round at 56e2f47. All 10 threads are answered and resolved.
Fixed (8):
The fail-fast claim is now qualified in the README and in all three docblocks: the usage error applies once the module has loaded.
The Manual Authorization Flow refresh now sends clientId and clientSecret.
The error hint now says "refresh tokens", not "refresh the code".
Declined (2):
PKCE in the native recipe. Launchpad doesn't support PKCE, so a challenge would be silently ignored. Instead, the recipe now says to prefer universal links / app links over a custom scheme. Reasoning is in the cubic and Copilot threads.
Spying on node:http in the startCallbackServer test. The guard runs synchronously while process is swapped, so a guard moved after the async listen already fails this test. Reasoning is in the thread.
Also in e479a0e, from verifying the support reply:
Launchpad's refresh returns no new refresh token, so the recipe now keeps the original.
The recipe drops useLegacyFormat.
Native and browser guidance are split.
The README now says which Node built-ins each entry point loads.
CI: the Go Vulnerability Check failure is unrelated to this TypeScript-only PR. govulncheck flags GO-2026-6612/6613/6617 in net/http from go1.26.8, fixed in 1.26.9. main last passed on Oct 7, before those advisories, so it needs a toolchain bump in its own PR.
Launchpad's refresh response has no refresh_token, so a sample that
replaces the stored token with the refresh result loses the only refresh
token. Same fix as the React Native recipe.
The function destructures options before checking the runtime, so an omitted/null argument outside Node throws a native TypeError rather than the promised usage error. Put the guard before destructuring to make it genuinely fail before doing anything else.
Move runtime check before options destructuring
typescript/src/oauth/interactive-login.ts:123
This guard is not the function's first operation: options is destructured first, so outside Node a missing/null argument produces a native TypeError (and any property getters run) instead of the documented usage error. Move the runtime check ahead of destructuring so the fail-fast contract holds for every call.
Update at 1b6011e: fixed cubic's finding on 56e2f47 (the Manual Authorization Flow refresh sample now keeps the original refresh token) and resolved that thread. Copilot re-reviewed 1b6011e with no new findings. cubic's check passed on 1b6011e, but it hadn't posted a review on that head after 20 minutes; its last review was on 56e2f47. No review threads are open. The only failing check is the unrelated Go Vulnerability Check (go1.26.8 net/http advisories, noted above).
🤖 @robzolkos this PR's security checks ran before main moved to Go 1.26.9 (#989) and cleared the kin-openapi CVEs (#994). The Go Vulnerability Check failure here is the old net/http advisories on 1.26.8, not this change. Rebasing onto main should clear it.
This branch has not been deployed
No deployments
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
typescriptPull requests that update TypeScript code
3 participants
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.
What
TypeScript only. Three OAuth helpers use Node built-ins and can't run in React Native or a browser:
performInteractiveLoginandstartCallbackServer(anode:httpcallback server) andFileTokenStore(node:fs). This PR does three things:process.versions.node. If it's missing, the helper throws ausageBasecampErrorthat names the helper and says it needs Node.js. The error's hint points at the new README section. The check is in a newsrc/oauth/node-runtime.ts, which isn't exported.performInteractiveLoginnow throws before discovery runs. Before, it got as far as discovery and failed there with a network error.open, also Node-only).TokenStore. Launchpad's refresh returns no new refresh token, so the example keeps the original. For a browser app: an https callback on your server, which keeps the tokens (Launchpad sends no CORS headers). The section also says which Node built-ins each entry point loads, and that a bundler refusing them fails before the guard can run.Why
A developer used
performInteractiveLoginwithFileTokenStorein a React Native app, following our README example. The first error waspromisify is not a function, which comes from the top of theopenpackage (v7) once a bundler stubs oututil. Next wasOAuth discovery failed: Load failed, WebKit's message for a fetch that was blocked or couldn't connect. In a web build that is expected, because Launchpad's/.well-known/oauth-authorization-serversends noAccess-Control-Allow-Origin; the error alone doesn't prove that was the cause. Neither error names the real cause. If both had been worked around, the flow would still have failed at the callback server. Nothing in the OAuth docs said these helpers need Node. Support card: https://app.basecamp.com/2914079/buckets/27/card_tables/cards/10390587859Decisions
@37signals/basecamploadsnode:crypto(webhook signing) andnode:http(the callback server), and@37signals/basecamp/oauthloadsnode:httpplusnode:fs,node:pathandnode:os(the file token store). Getting them out means one of two things. Either move the Node-only exports (performInteractiveLogin,startCallbackServer,FileTokenStore,verifyWebhookSignature/signWebhookPayload,createNodeHandler) to a@37signals/basecamp/nodesubpath. Or addreact-native/browserexport conditions that resolve to a core entry without them. Either wayindex.tshas to be split, and those imports disappear from the root for some consumers. That's a breaking change, so it should go in its own PR with a MIGRATING note. Proposed follow-up.process.versions.node, not "doesnode:httpexist." In the reported case the bundler had replaced built-ins with empty modules, so the import "works" and only calling it fails. Node, Bun, Deno's Node compatibility and Electron's main process all reportversions.node. Browsers have noprocess, and React Native'sprocesshas noversions.node. Both cases are tested.promisify is not a functioncomes from theopenpackage when it loads, before any SDK call, so the fix for that one is the README markingopenas Node-only. The guard covers the later failures.grant_typeparameters, notuseLegacyFormat. Launchpad accepts both, and its API docs call the legacy ones not recommended.FileTokenStorethrows from its constructor. That's the first point where a caller touches it, and it fails before any token is in flight.Testing
tests/oauth/node-runtime.test.tscovers two runtimes: noprocess(browser) and aprocesspolyfill withoutversions.node(React Native). In each,performInteractiveLoginrejects beforefetch,openBrowserorstore.saveis called,startCallbackServerrejects, and theFileTokenStoreconstructor throws. All three report the exactusagemessage and the README hint. The global is swapped only around the synchronous call and restored before vitest or msw run again.if (false && …)), 6 of the 6 guard tests fail. Restored, 9 of 9 pass.npm test: 97 files, 2081 tests pass (rerun at e479a0e, along with typecheck, lint, build,make ts-check-driftandmake doc-constants-check).npm run typecheck,npm run lint,npm run lint:test-timersandnpm run buildall pass.make ts-checkpasses (Linux). Itsts-test-verb-refusalstep needed a localshasum→sha1sumshim, because this box has noshasum; that's unrelated to this change.Summary by cubic
Makes three Node-dependent OAuth helpers (
performInteractiveLogin,startCallbackServer,FileTokenStore) fail fast with a clearusageBasecampError outside Node.js, instead of failing partway through with misleading errors (promisify is not a function,OAuth discovery failed: Load failed) that never named the cause.process.versions.nodevia the new unexportedsrc/oauth/node-runtime.ts;FileTokenStorethrows from its constructor. The guard only runs if the module loads — a bundler that refuses the staticnode:imports fails at import instead.TokenStorein the app.process(browser) and a polyfill withoutversions.node(React Native); all guard tests fail when the check is disabled. Node is unaffected.Written for commit 1b6011e. Summary will update on new commits.