Skip to content

feat(realtime): refuse expired client tokens before dialling; add apiKeyProvider for fresh tokens on connect and reconnect - #216

Merged
AdirAmsalem merged 3 commits into
mainfrom
conductor/sdk-client-token-expiry-preflight
Oct 7, 2026
Merged

AdirAmsalem merged 3 commits into
mainfrom
conductor/sdk-client-token-expiry-preflight

Conversation

@AdirAmsalem

@AdirAmsalem AdirAmsalem commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Why

Client tokens expire 60 s after minting by default. Apps often mint on page load and connect only after the user grants the camera and presses start, or reconnect after a drop with the same token. The server refuses the expired token after a round trip, and the SDK surfaced that as a generic connection error.

What changed

  • Fail fast on expired tokens. Before every dial (connect, connect retry, reconnect, subscribe) the SDK decodes the client token's exp. An expired one (5 s clock-skew tolerance) rejects with TOKEN_EXPIRED without opening a socket, and the message says how late it is and what to do. Opaque keys pass through for the server to judge.
  • apiKeyProvider option. Pass a function instead of (or alongside) apiKey; the SDK calls it before every connect and reconnect, so each dial carries a token minted for it. apiKey is then optional for realtime; the HTTP APIs still use apiKey or proxy.
  • Static apiKey path unchanged, including timing.
  • README: documents the 60 s default TTL, the expiresIn knob, and both ways to stay fresh.
  • examples/nextjs-realtime shows the pattern: one createDecartClient({ apiKeyProvider }), a Start button, and the token minted inside connect() and again on every reconnect by the /api/realtime-token route.

Usage

const client = createDecartClient({
  apiKeyProvider: async () => {
    // Your server mints it with client.tokens.create({ expiresIn: ... }).
    const res = await fetch("/api/realtime-token", { method: "POST" });
    const { apiKey } = await res.json();
    return apiKey;
  },
});

const realtimeClient = await client.realtime.connect(stream, { model, onRemoteStream });

Without a provider, an expired static token now rejects connect() with:

TOKEN_EXPIRED: Client token expired 83 s ago (exp 2026-10-07T11:58:37.000Z). Mint a new one right before
connecting, or pass apiKeyProvider to createDecartClient so the SDK fetches a fresh token before every connect
and reconnect. Client tokens expire 60 s after minting by default (tokens.create({ expiresIn })).

Tests

26 new unit tests: expired token rejected before any socket, clock-skew tolerance, provider called on connect, connect retry and each reconnect, static token expiring mid-session stops the reconnect, provider failures (retried on redial unless the SDK refused the credential outright), subscribe, and createDecartClient option validation. Suite: 374 passed. Build, lint, format and typecheck clean.


Note

Medium Risk
Changes the realtime authentication and retry path for all dials; behavior is additive but static client tokens can now fail fast with TOKEN_EXPIRED, and reconnect semantics depend on provider correctness.

Overview
Adds apiKeyProvider to createDecartClient so realtime connect, connect retries, reconnects, and subscribe can mint a fresh client token per dial instead of reusing one from page load (default 60s TTL).

Before any socket opens, the SDK preflights JWT client tokens (exp, 5s skew) and fails with TOKEN_EXPIRED and actionable messaging; opaque keys still go to the server. redialUrl / dynamic signaling URLs refresh api_key on later dials; TOKEN_EXPIRED and INVALID_API_KEY stop retries, while transient provider/mint failures still retry.

The nextjs-realtime example documents the pattern (Start/Stop, provider-backed token route with expiresIn: 60). README and SDK docs describe TTL and both “mint right before connect” vs provider approaches.

Reviewed by Cursor Bugbot for commit f946417. Bugbot is set up for automated code reviews on this repo. Configure here.

@pkg-pr-new

pkg-pr-new Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@decartai/sdk@216

commit: f946417

…KeyProvider for fresh tokens on connect and reconnect

Client tokens expire 60 s after minting by default. A token minted on page
load is often already expired by the time the user grants the camera and
presses start, and a reconnect after a drop reuses the same token; the
server then refuses the connect after a round trip, and the SDK surfaced
that as a generic connection error.

The SDK now decodes the token's `exp` before every dial (connect, connect
retry, reconnect) and fails with TOKEN_EXPIRED client-side, with a 5 s
clock-skew tolerance and a message that says how late the token is and
what to do.

`createDecartClient({ apiKeyProvider })` accepts an async function returning
a fresh credential; it is called before every connect and reconnect (and
before `subscribe`). The static `apiKey` path is unchanged, including its
timing: the first socket still opens in the same tick.

README documents the 60 s TTL, `expiresIn`, minting right before connect,
and the provider.
@AdirAmsalem
AdirAmsalem force-pushed the conductor/sdk-client-token-expiry-preflight branch from fc963e2 to 32a7323 Compare October 7, 2026 08:34
…ia apiKeyProvider

The Next.js example now creates one client with `apiKeyProvider` and lets
the SDK fetch a token from /api/realtime-token right before every connect
and reconnect, instead of fetching one itself and connecting with it. A
Start button makes the point: the token is minted when the user acts, not
when the page loads. The route sets `expiresIn` explicitly and the README
walks through the flow.

@cursor cursor 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.

Stale Bugbot comment from a previous run.

Comment thread examples/nextjs-realtime/components/video-stream.tsx
Comment thread packages/sdk/src/realtime/subscribe-client.ts
…nce at the retry boundary

- credential.ts throws plain DecartSDKErrors like the rest of the SDK and
  lets provider rejections through unchanged; StreamSession wraps them
  into DecartSDKException once, where p-retry needs an Error.
- Retry permanence is a config list of SDK error codes
  (REALTIME_CONFIG.session.permanentErrorCodes) next to the existing
  substring list, so a provider's transient SDK error is retried as the
  docs say; only TOKEN_EXPIRED and INVALID_API_KEY end the attempts.
- subscribe() resolves the credential before its try block, so a provider
  rejection reaches the caller unchanged, as from connect().
- The per-dial URL builder captures only what it needs instead of the
  whole connect options; the provider round trip overlaps image encoding;
  telemetry reports with the credential of the dial that started the
  session; the dial resolver's stale guard also checks the attempt.
- Example: one release() for the teardown copies, and an attempt counter
  so a Stop or unmount during the camera prompt or connect() releases the
  camera and the session instead of leaking them.
- Tests: shared client-token JWT helper; two session-level tests for the
  retry boundary; subscribe provider-rejection test; pinned clock in the
  subscribe expiry test.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f946417. Configure here.

Comment thread examples/nextjs-realtime/components/video-stream.tsx
@AdirAmsalem
AdirAmsalem merged commit 2de7cb3 into main Oct 7, 2026
5 checks passed
@AdirAmsalem
AdirAmsalem deleted the conductor/sdk-client-token-expiry-preflight branch October 7, 2026 09:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant