Skip to content

Say which TypeScript OAuth helpers need Node, and fail fast outside it - #976

Open
robzolkos wants to merge 4 commits into
mainfrom
ts-oauth-node-only-runtimes
Open

robzolkos wants to merge 4 commits into
mainfrom
ts-oauth-node-only-runtimes

Conversation

@robzolkos

@robzolkos robzolkos commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

What

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 usage BasecampError 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.

View guided diff Turn on auto-fix

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.
Copilot AI balanced review requested due to automatic review settings October 9, 2026 16:07
@github-actions github-actions Bot added the typescript Pull requests that update TypeScript code label Oct 9, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread typescript/README.md
Comment thread typescript/tests/oauth/node-runtime.test.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The mobile example omits PKCE, and several guarantees overlook failures from static Node imports.

6 open findings
What changed in this PR

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.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread typescript/README.md Outdated
Comment thread typescript/README.md
Comment thread typescript/src/oauth/callback-server.ts Outdated
Comment thread typescript/src/oauth/interactive-login.ts Outdated
Comment thread typescript/src/oauth/node-runtime.ts Outdated
Comment thread typescript/src/oauth/token-store.ts Outdated
- 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.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread typescript/src/oauth/node-runtime.ts Outdated
Comment thread typescript/README.md
- 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".
@robzolkos

robzolkos commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread typescript/README.md
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Two helpers destructure their arguments before the runtime guard, weakening the documented fail-fast guarantee.

0 open findings

6 resolved since last review
Previously missed (2)

In code that hasn't changed since last review

Medium severity Guard runtime before destructuring options

typescript/​src/​oauth/​callback-server.ts:86

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.

Medium severity 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.

🧠 Review effort: Balanced

@robzolkos

Copy link
Copy Markdown
Collaborator Author

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).

@jeremy

jeremy commented Oct 17, 2026

Copy link
Copy Markdown
Member

🤖 @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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

typescript Pull requests that update TypeScript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants