@tokenite/sdk 2.0.0 → 2.2.0

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.
package/README.md CHANGED
@@ -85,7 +85,7 @@ await fetch('/api/auth/exchange', {
85
85
 
86
86
  ### Managed agents (Anthropic)
87
87
 
88
- Tokenite proxies Anthropic's [Managed Agents](https://platform.claude.com/docs/en/managed-agents/overview) surface — the `/v1/agents`, `/v1/environments`, `/v1/sessions`, `/v1/vaults`, and `/v1/files` endpoints — under the same BYOK model as Messages. The app points the Anthropic SDK at `tk.proxyUrl('anthropic')`; the proxy injects the managed-agents beta header and forwards calls to the user's Anthropic org.
88
+ Tokenite proxies Anthropic's [Managed Agents](https://platform.claude.com/docs/en/managed-agents/overview) surface — the `/v1/agents`, `/v1/environments`, `/v1/sessions`, `/v1/vaults`, `/v1/files`, and `/v1/skills` endpoints — under the same BYOK model as Messages. The app points the Anthropic SDK at `tk.proxyUrl('anthropic')`; the proxy injects the right beta header per surface (`managed-agents-2026-04-01` for agents/environments/sessions/vaults, `files-api-2025-04-14` for files, `skills-2025-10-02` for skills) and forwards calls to the user's Anthropic org. Client-supplied `anthropic-beta` headers are passed through and merged with the auto-injected ones, so callers can opt into additional betas (e.g. multi-version skills) without losing the proxy-managed defaults.
89
89
 
90
90
  **Explicit consent required.** Because agent sessions run long-lived, billable server-side work on Anthropic (`$0.08/hr` session runtime on top of tokens), the proxy rejects agent-surface calls unless the app was created with `allowsManagedAgents: true`. The flag sits alongside the other app fields on creation:
91
91
 
@@ -93,7 +93,8 @@ Tokenite proxies Anthropic's [Managed Agents](https://platform.claude.com/docs/e
93
93
  await admin.apps.create({
94
94
  name: 'Life Coach',
95
95
  callbackUrl: 'https://lifecoach.ai/callback',
96
- requiredProviders: ['anthropic'],
96
+ modelStrategy: 'models',
97
+ allowedModels: ['claude-opus-4-7', 'claude-sonnet-4-6'],
97
98
  allowsManagedAgents: true, // ← opt in
98
99
  });
99
100
  ```
@@ -141,6 +142,57 @@ Tokenite tracks each session for attribution and auto-terminates running session
141
142
 
142
143
  Budget is checked pre-call but not enforced mid-session: a session that started under budget can run past it. Revoke is the user's hard kill switch.
143
144
 
145
+ ### Handling the OAuth callback
146
+
147
+ When Tokenite redirects back to your `redirect_uri`, the URL has one of:
148
+
149
+ - `?code=...&state=...` — user approved; exchange the code for an access token.
150
+ - `?error=access_denied&error_description=user_denied&state=...` — user clicked **Cancel** on the consent screen.
151
+ - `?error=...&error_description=...&state=...` — any other OAuth failure.
152
+ - nothing (or just `state`) — user landed on your callback path without completing a real OAuth round-trip.
153
+
154
+ `parseCallback(input, options?)` turns the URL into a typed result so you can switch on it instead of hand-parsing strings. Pure function; works in any runtime (Node, Bun, edge, browser).
155
+
156
+ ```typescript
157
+ import { parseCallback } from '@tokenite/sdk';
158
+
159
+ // In your /api/auth/callback handler:
160
+ const result = parseCallback(req.url, { expectedState: sessionStoredState });
161
+
162
+ if (result.ok) {
163
+ const { access_token } = await tk.exchangeCode(result.code);
164
+ // ... establish your app's session, redirect to /
165
+ return;
166
+ }
167
+
168
+ switch (result.reason) {
169
+ case 'user_denied':
170
+ // User clicked Cancel. Don't show a scary error — render a
171
+ // friendly "you cancelled" screen with a "Try again" button.
172
+ return renderCancelled();
173
+ case 'invalid_state':
174
+ // CSRF check failed (state mismatch). Force a fresh login flow.
175
+ return renderRetry('Your sign-in session expired. Please try again.');
176
+ case 'missing_code':
177
+ // User navigated to /callback directly (bookmark, browser back).
178
+ return redirectToHome();
179
+ case 'access_denied':
180
+ case 'oauth_error':
181
+ // Real OAuth failure. result.error + result.description have details.
182
+ return renderError(result);
183
+ }
184
+ ```
185
+
186
+ `parseCallback` accepts:
187
+
188
+ - A full URL string: `parseCallback('https://yourapp.com/cb?code=…&state=…')`
189
+ - A path + query: `parseCallback(req.url)` (Node's `req.url` works directly)
190
+ - A query string: `parseCallback('?code=…&state=…')` or `parseCallback('code=…&state=…')`
191
+ - A `URL` object: `parseCallback(new URL(...))`
192
+ - A `URLSearchParams`: `parseCallback(params)`
193
+
194
+ `expectedState` is optional. When you provide it, a mismatch returns `{ ok: false, reason: 'invalid_state' }` even if a `code` is present — treating a stolen code paired with a forged state as a CSRF attempt.
195
+
144
196
  ### Streaming responses
145
197
 
146
198
  `tk.call()` is for non-streaming requests. Streaming responses bypass the unified envelope and are forwarded as-is, so use any vendor SDK with `baseURL: tk.proxyUrl(...)`:
@@ -253,6 +305,44 @@ export type PopupResult = {
253
305
  readonly code: string;
254
306
  };
255
307
 
308
+ export type TopUpOptions = {
309
+ /**
310
+ * How to host the top-up screen.
311
+ *
312
+ * - `'popup'` (default) — open in a separate browser window via
313
+ * `window.open`. The user completes the form; the SDK resolves
314
+ * when Tokenite posts back the new limit.
315
+ * - `'redirect'` — full-page navigation. The builder's app loses
316
+ * in-memory state and must reload from the callback URL.
317
+ */
318
+ readonly mode?: 'popup' | 'redirect';
319
+ /** Pre-fill the new-limit field. Default: 2 × current limit. */
320
+ readonly suggestedAmount?: number;
321
+ /** Popup width in pixels. Default: 480 */
322
+ readonly width?: number;
323
+ /** Popup height in pixels. Default: 560 */
324
+ readonly height?: number;
325
+ };
326
+
327
+ export type TopUpResult =
328
+ | { readonly ok: true; readonly newLimit: number; readonly remaining: number }
329
+ | { readonly ok: false; readonly reason: 'cancelled' | 'popup-blocked' | 'closed' | 'redirected' };
330
+
331
+ export type CallWithRecoveryOptions = {
332
+ /**
333
+ * What to do when the proxy returns a recoverable funding error.
334
+ *
335
+ * - `'popup'` (default) — open Tokenite's top-up popup, then retry the
336
+ * call once on success.
337
+ * - `'redirect'` — full-page navigation; the call does not retry (the
338
+ * builder's app re-loads from the callback and re-invokes manually).
339
+ * - `'throw'` — disable recovery; surface the original error.
340
+ */
341
+ readonly onFundingNeeded?: 'popup' | 'redirect' | 'throw';
342
+ /** Override the suggested top-up amount. */
343
+ readonly suggestedAmount?: number;
344
+ };
345
+
256
346
  export type TokenResponse = {
257
347
  readonly access_token: string;
258
348
  readonly token_type: string;
@@ -405,12 +495,6 @@ export type AppInfo = {
405
495
  readonly websiteUrl: string | null;
406
496
  /** Absolute URL to the app's icon (PNG/SVG). null when the developer hasn't set one — render initials or a generic glyph. */
407
497
  readonly iconUrl: string | null;
408
- /** Providers the app declares it needs */
409
- readonly requiredProviders: readonly Provider[];
410
- /** Fallback order when `allowSubstitution` is true */
411
- readonly preferredProviders: readonly Provider[];
412
- /** Whether the app accepts substitute providers when a required one isn't available */
413
- readonly allowSubstitution: boolean;
414
498
  };
415
499
 
416
500
  /**
@@ -1,14 +1,11 @@
1
1
  import type { Provider } from '../types.js';
2
- export type ModelStrategy = 'any' | 'tier' | 'models';
2
+ export type ModelStrategy = 'any' | 'tier' | 'models' | 'none';
3
3
  export type RequiredTier = 'cheap' | 'fast' | 'smart' | 'reasoning';
4
4
  export type AppRecord = {
5
5
  readonly id: string;
6
6
  readonly builderId: string;
7
7
  readonly name: string;
8
8
  readonly callbackUrl: string;
9
- readonly requiredProviders: readonly Provider[];
10
- readonly preferredProviders: readonly Provider[];
11
- readonly allowSubstitution: boolean;
12
9
  readonly allowedModels?: readonly string[];
13
10
  readonly modelStrategy: ModelStrategy;
14
11
  readonly requiredTier?: RequiredTier;
@@ -24,9 +21,6 @@ export type CreatedApp = AppRecord & {
24
21
  export type CreateAppInput = {
25
22
  readonly name: string;
26
23
  readonly callbackUrl: string;
27
- readonly requiredProviders: readonly Provider[];
28
- readonly preferredProviders?: readonly Provider[];
29
- readonly allowSubstitution?: boolean;
30
24
  readonly allowedModels?: readonly string[];
31
25
  readonly modelStrategy?: ModelStrategy;
32
26
  readonly requiredTier?: RequiredTier;
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { TokeniteConfig, AuthorizeOptions, PopupOptions, PopupResult, TokenResponse, Provider, ProxyCallOptions, ProxyResponse, AccessContext } from './types.js';
1
+ import type { TokeniteConfig, AuthorizeOptions, PopupOptions, PopupResult, TokenResponse, Provider, ProxyCallOptions, ProxyResponse, AccessContext, TopUpOptions, TopUpResult, CallWithRecoveryOptions } from './types.js';
2
2
  /**
3
3
  * Tokenite client.
4
4
  *
@@ -117,6 +117,37 @@ export declare const Tokenite: (config: TokeniteConfig) => {
117
117
  * bypass the unified envelope.
118
118
  */
119
119
  proxyUrl: (provider: Provider) => string;
120
+ /**
121
+ * Open a Tokenite-hosted "raise budget" popup for this app.
122
+ *
123
+ * When `tk.call()` returns `BUDGET_EXCEEDED`, call this to let the
124
+ * user authorise a higher spending cap without leaving your app.
125
+ * The popup resolves once the new limit is committed. The access
126
+ * token does not change — only its budget ceiling.
127
+ *
128
+ * The user must be signed in to Tokenite in the same browser (the
129
+ * popup uses their session cookie). If not, the popup routes them
130
+ * through sign-in and returns afterwards.
131
+ *
132
+ * ```typescript
133
+ * const r = await tk.call({ accessToken, provider: 'anthropic', ... });
134
+ * if (isProxyError(r) && r.error.code === 'BUDGET_EXCEEDED') {
135
+ * const top = await tk.topUp();
136
+ * if (top.ok) return tk.call({ accessToken, ... });
137
+ * }
138
+ * ```
139
+ */
140
+ topUp: (options?: TopUpOptions) => Promise<TopUpResult>;
141
+ /**
142
+ * Wrap `tk.call()` with automatic recovery when the proxy returns a
143
+ * recoverable funding error (`BUDGET_EXCEEDED` by default). On a
144
+ * fundable error, opens the top-up popup and retries the call once
145
+ * if the user committed a new limit.
146
+ *
147
+ * The retry is bounded to one attempt — repeated funding failures
148
+ * surface back to the caller so misconfigured caps don't loop.
149
+ */
150
+ callWithRecovery: (options: ProxyCallOptions, recovery?: CallWithRecoveryOptions) => Promise<ProxyResponse>;
120
151
  /** The Tokenite dashboard base URL */
121
152
  baseUrl: string;
122
153
  /** The Tokenite proxy base URL */
package/dist/client.js CHANGED
@@ -1,8 +1,21 @@
1
+ import { AuthMessageType, TopupMessageType } from './protocol.js';
1
2
  import { extractErrorMessage } from './error.js';
2
- const DEFAULT_BASE_URL = 'https://tokenite.ai';
3
- const DEFAULT_PROXY_URL = 'https://api.tokenite.ai';
3
+ import { isProxyError } from './types.js';
4
+ import { isFundingError } from './recovery.js';
5
+ // `tokenite.ai` is the marketing site. The dashboard (which hosts the
6
+ // OAuth consent screen and the API the SDK calls — /oauth/authorize,
7
+ // /api/oauth/token) lives at `app.tokenite.ai`. Older versions of this
8
+ // SDK pointed at `tokenite.ai` and only worked because that hostname
9
+ // was aliased to the dashboard CloudFront; the marketing-site move on
10
+ // 2026-05-06 broke that. Marketing CF now redirects /oauth/* and
11
+ // /api/* to app.tokenite.ai, so older SDK installs continue to work,
12
+ // but new installs should hit the dashboard directly.
13
+ const DEFAULT_BASE_URL = 'https://app.tokenite.ai';
14
+ const DEFAULT_PROXY_URL = 'https://proxy.tokenite.ai';
4
15
  const IFRAME_WIDTH = 480;
5
16
  const IFRAME_HEIGHT = 620;
17
+ const TOPUP_WIDTH = 480;
18
+ const TOPUP_HEIGHT = 560;
6
19
  /**
7
20
  * Tokenite client.
8
21
  *
@@ -44,6 +57,29 @@ const IFRAME_HEIGHT = 620;
44
57
  export const Tokenite = (config) => {
45
58
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
46
59
  const proxyBase = config.proxyUrl ?? DEFAULT_PROXY_URL;
60
+ const proxyCall = async (options) => {
61
+ const path = options.path.startsWith('/') ? options.path : `/${options.path}`;
62
+ const response = await fetch(`${proxyBase}/${options.provider}${path}`, {
63
+ method: options.method ?? 'POST',
64
+ headers: {
65
+ 'authorization': `Bearer ${options.accessToken}`,
66
+ 'content-type': 'application/json',
67
+ },
68
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
69
+ });
70
+ return (await response.json());
71
+ };
72
+ const runTopUp = (options) => {
73
+ const mode = options?.mode ?? 'popup';
74
+ const width = options?.width ?? TOPUP_WIDTH;
75
+ const height = options?.height ?? TOPUP_HEIGHT;
76
+ const url = buildTopupUrl(baseUrl, config.clientId, mode, options?.suggestedAmount);
77
+ if (mode === 'redirect') {
78
+ window.location.href = url;
79
+ return Promise.resolve({ ok: false, reason: 'redirected' });
80
+ }
81
+ return openTopupPopup(url, width, height, baseUrl);
82
+ };
47
83
  const buildAuthorizeUrl = (options) => {
48
84
  const state = options?.state ?? generateState();
49
85
  const params = new URLSearchParams({
@@ -143,18 +179,7 @@ export const Tokenite = (config) => {
143
179
  * });
144
180
  * ```
145
181
  */
146
- call: async (options) => {
147
- const path = options.path.startsWith('/') ? options.path : `/${options.path}`;
148
- const response = await fetch(`${proxyBase}/${options.provider}${path}`, {
149
- method: options.method ?? 'POST',
150
- headers: {
151
- 'authorization': `Bearer ${options.accessToken}`,
152
- 'content-type': 'application/json',
153
- },
154
- body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
155
- });
156
- return (await response.json());
157
- },
182
+ call: proxyCall,
158
183
  /**
159
184
  * Fetch the full access context for an access token: which app it
160
185
  * belongs to, who holds the token, and which providers it can call.
@@ -198,6 +223,56 @@ export const Tokenite = (config) => {
198
223
  * bypass the unified envelope.
199
224
  */
200
225
  proxyUrl: (provider) => `${proxyBase}/${provider}`,
226
+ /**
227
+ * Open a Tokenite-hosted "raise budget" popup for this app.
228
+ *
229
+ * When `tk.call()` returns `BUDGET_EXCEEDED`, call this to let the
230
+ * user authorise a higher spending cap without leaving your app.
231
+ * The popup resolves once the new limit is committed. The access
232
+ * token does not change — only its budget ceiling.
233
+ *
234
+ * The user must be signed in to Tokenite in the same browser (the
235
+ * popup uses their session cookie). If not, the popup routes them
236
+ * through sign-in and returns afterwards.
237
+ *
238
+ * ```typescript
239
+ * const r = await tk.call({ accessToken, provider: 'anthropic', ... });
240
+ * if (isProxyError(r) && r.error.code === 'BUDGET_EXCEEDED') {
241
+ * const top = await tk.topUp();
242
+ * if (top.ok) return tk.call({ accessToken, ... });
243
+ * }
244
+ * ```
245
+ */
246
+ topUp: runTopUp,
247
+ /**
248
+ * Wrap `tk.call()` with automatic recovery when the proxy returns a
249
+ * recoverable funding error (`BUDGET_EXCEEDED` by default). On a
250
+ * fundable error, opens the top-up popup and retries the call once
251
+ * if the user committed a new limit.
252
+ *
253
+ * The retry is bounded to one attempt — repeated funding failures
254
+ * surface back to the caller so misconfigured caps don't loop.
255
+ */
256
+ callWithRecovery: async (options, recovery = {}) => {
257
+ const r = await proxyCall(options);
258
+ if (!isProxyError(r))
259
+ return r;
260
+ const klass = isFundingError(r.error);
261
+ if (!klass || klass.kind !== 'user-topup')
262
+ return r;
263
+ const onNeed = recovery.onFundingNeeded ?? 'popup';
264
+ if (onNeed === 'throw')
265
+ return r;
266
+ const top = await runTopUp({
267
+ mode: onNeed,
268
+ ...(recovery.suggestedAmount ?? klass.suggestedAmount
269
+ ? { suggestedAmount: (recovery.suggestedAmount ?? klass.suggestedAmount) }
270
+ : {}),
271
+ });
272
+ if (!top.ok)
273
+ return r;
274
+ return proxyCall(options);
275
+ },
201
276
  /** The Tokenite dashboard base URL */
202
277
  baseUrl,
203
278
  /** The Tokenite proxy base URL */
@@ -212,12 +287,12 @@ const handleAuthMessage = (event, baseUrl, resolve, reject, cleanup) => {
212
287
  if (event.origin !== baseUrl)
213
288
  return;
214
289
  const data = event.data;
215
- if (data.type === 'tokenite:auth-success' && data.code) {
290
+ if (data.type === AuthMessageType.Success && data.code) {
216
291
  cleanup();
217
292
  resolve({ code: data.code });
218
293
  return;
219
294
  }
220
- if (data.type === 'tokenite:auth-error') {
295
+ if (data.type === AuthMessageType.Error) {
221
296
  cleanup();
222
297
  reject(new Error(data.error ?? 'Authorization denied'));
223
298
  }
@@ -258,6 +333,50 @@ const openIframeModal = (url, width, height, baseUrl) => {
258
333
  window.addEventListener('message', onMessage);
259
334
  });
260
335
  };
336
+ const buildTopupUrl = (baseUrl, clientId, mode, suggestedAmount) => {
337
+ const params = new URLSearchParams({ client_id: clientId });
338
+ if (mode === 'popup')
339
+ params.set('mode', 'popup');
340
+ if (suggestedAmount !== undefined)
341
+ params.set('suggested_amount', String(suggestedAmount));
342
+ return `${baseUrl}/oauth/topup?${params}`;
343
+ };
344
+ const openTopupPopup = (url, width, height, baseUrl) => {
345
+ const left = Math.round(window.screenX + (window.innerWidth - width) / 2);
346
+ const top = Math.round(window.screenY + (window.innerHeight - height) / 2);
347
+ const popup = window.open(url, 'tokenite-topup', `width=${width},height=${height},left=${left},top=${top},popup=yes`);
348
+ if (!popup)
349
+ return Promise.resolve({ ok: false, reason: 'popup-blocked' });
350
+ return new Promise((resolve) => {
351
+ const cleanup = () => {
352
+ window.removeEventListener('message', onMessage);
353
+ clearInterval(poll);
354
+ if (!popup.closed)
355
+ popup.close();
356
+ };
357
+ const onMessage = (event) => {
358
+ if (event.origin !== baseUrl)
359
+ return;
360
+ const data = event.data;
361
+ if (data.type === TopupMessageType.Success && typeof data.newLimit === 'number') {
362
+ cleanup();
363
+ resolve({ ok: true, newLimit: data.newLimit, remaining: data.remaining ?? 0 });
364
+ return;
365
+ }
366
+ if (data.type === TopupMessageType.Cancelled) {
367
+ cleanup();
368
+ resolve({ ok: false, reason: 'cancelled' });
369
+ }
370
+ };
371
+ const poll = setInterval(() => {
372
+ if (popup.closed) {
373
+ cleanup();
374
+ resolve({ ok: false, reason: 'closed' });
375
+ }
376
+ }, 300);
377
+ window.addEventListener('message', onMessage);
378
+ });
379
+ };
261
380
  const openWindowPopup = (url, width, height, baseUrl, redirectUri) => {
262
381
  const left = Math.round(window.screenX + (window.innerWidth - width) / 2);
263
382
  const top = Math.round(window.screenY + (window.innerHeight - height) / 2);
package/dist/index.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  export { Tokenite } from './client.js';
2
- export type { TokeniteConfig, AuthorizeOptions, PopupOptions, PopupResult, TokenResponse, Provider, ProxyCallOptions, ProxyUsage, ProxySuccess, ProxyError, ProxyResponse, ErrorSource, ProviderInfo, AppInfo, UserInfo, AccessContext, } from './types.js';
2
+ export type { TokeniteConfig, AuthorizeOptions, PopupOptions, PopupResult, TokenResponse, Provider, ProxyCallOptions, ProxyUsage, ProxySuccess, ProxyError, ProxyResponse, ErrorSource, ProviderInfo, AppInfo, UserInfo, AccessContext, TopUpOptions, TopUpResult, CallWithRecoveryOptions, } from './types.js';
3
3
  export { isProxyError, isProxySuccess } from './types.js';
4
+ export { parseCallback } from './parse-callback.js';
5
+ export type { CallbackResult, CallbackSuccess, CallbackError, CallbackReason, ParseCallbackOptions, } from './parse-callback.js';
6
+ export { isFundingError } from './recovery.js';
7
+ export type { RecoveryClass, RecoveryKind } from './recovery.js';
8
+ export { AuthMessageType, TopupMessageType, parseFundingDetails } from './protocol.js';
9
+ export type { AuthMessage, TopupMessage, FundingErrorDetails } from './protocol.js';
4
10
  export { extractErrorMessage } from './error.js';
5
11
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export { Tokenite } from './client.js';
2
2
  export { isProxyError, isProxySuccess } from './types.js';
3
+ export { parseCallback } from './parse-callback.js';
4
+ export { isFundingError } from './recovery.js';
5
+ export { AuthMessageType, TopupMessageType, parseFundingDetails } from './protocol.js';
3
6
  export { extractErrorMessage } from './error.js';
4
7
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Parse the OAuth redirect callback that Tokenite sends to your app's
3
+ * `redirect_uri`. Returns a typed result the caller can switch on
4
+ * instead of hand-checking string params.
5
+ *
6
+ * ```typescript
7
+ * import { parseCallback } from '@tokenite/sdk';
8
+ *
9
+ * const result = parseCallback(req.url, { expectedState: storedState });
10
+ * if (result.ok) {
11
+ * const { code } = result;
12
+ * const { access_token } = await tk.exchangeCode(code);
13
+ * // ...
14
+ * } else {
15
+ * switch (result.reason) {
16
+ * case 'user_denied': return renderCancelled(); // user clicked Cancel
17
+ * case 'invalid_state': return renderRetry(); // CSRF check failed
18
+ * case 'missing_code': return renderRetry(); // user landed here directly
19
+ * default: return renderError(result); // other OAuth error
20
+ * }
21
+ * }
22
+ * ```
23
+ *
24
+ * Pure function — no side effects, no network, no SDK config needed.
25
+ * Works server-side (Node, edge runtimes) and in the browser.
26
+ */
27
+ export type CallbackSuccess = {
28
+ readonly ok: true;
29
+ readonly code: string;
30
+ readonly state: string | null;
31
+ };
32
+ /**
33
+ * Why an OAuth callback didn't yield a usable authorization code.
34
+ *
35
+ * - `user_denied` — the end user clicked "Cancel" on the Tokenite
36
+ * consent screen. The most common non-success case. Render a
37
+ * friendly "you cancelled" UI with a "Try again" CTA, NOT a
38
+ * technical error.
39
+ *
40
+ * - `access_denied` — the server returned `error=access_denied`
41
+ * without the `user_denied` description. Generic access refusal
42
+ * (e.g. the app's `redirect_uri` didn't match, scope was refused
43
+ * by policy). Still recoverable with a retry, but the cause is
44
+ * server-side rather than the user's choice.
45
+ *
46
+ * - `invalid_state` — `expectedState` was provided to `parseCallback`
47
+ * and didn't match the returned `state` value. Either a stale
48
+ * session or a CSRF attempt. Always reject and force a fresh
49
+ * flow; never auto-retry without user interaction.
50
+ *
51
+ * - `missing_code` — neither `code` nor `error` was present in the
52
+ * callback URL. Usually means the user navigated to the callback
53
+ * path directly (e.g. via bookmark or browser back) rather than
54
+ * completing a real OAuth round-trip.
55
+ *
56
+ * - `oauth_error` — any other OAuth 2.0 error code (`invalid_request`,
57
+ * `unauthorized_client`, `server_error`, etc.). Inspect
58
+ * `error` / `description` for specifics.
59
+ */
60
+ export type CallbackReason = 'user_denied' | 'access_denied' | 'invalid_state' | 'missing_code' | 'oauth_error';
61
+ export type CallbackError = {
62
+ readonly ok: false;
63
+ readonly reason: CallbackReason;
64
+ /** Raw OAuth error code from the URL, when present. */
65
+ readonly error?: string;
66
+ /** Raw `error_description` from the URL, when present. */
67
+ readonly description?: string;
68
+ /** The `state` value returned by Tokenite (may be null if the original flow didn't include one). */
69
+ readonly state: string | null;
70
+ };
71
+ export type CallbackResult = CallbackSuccess | CallbackError;
72
+ export type ParseCallbackOptions = {
73
+ /**
74
+ * If provided, compare against the returned `state` query param.
75
+ * Mismatch → `{ ok: false, reason: 'invalid_state' }`, even if a
76
+ * `code` is present (a code + bad state is treated as a CSRF
77
+ * attempt, not a success).
78
+ */
79
+ readonly expectedState?: string;
80
+ };
81
+ /**
82
+ * Parse an OAuth callback. Accepts any of: a full URL string, a path
83
+ * with query string (e.g. Node's `req.url`), a `URL` object, a
84
+ * `URLSearchParams`, or a plain query string (with or without leading
85
+ * `?`).
86
+ */
87
+ export declare const parseCallback: (input: string | URL | URLSearchParams, options?: ParseCallbackOptions) => CallbackResult;
88
+ //# sourceMappingURL=parse-callback.d.ts.map
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Parse the OAuth redirect callback that Tokenite sends to your app's
3
+ * `redirect_uri`. Returns a typed result the caller can switch on
4
+ * instead of hand-checking string params.
5
+ *
6
+ * ```typescript
7
+ * import { parseCallback } from '@tokenite/sdk';
8
+ *
9
+ * const result = parseCallback(req.url, { expectedState: storedState });
10
+ * if (result.ok) {
11
+ * const { code } = result;
12
+ * const { access_token } = await tk.exchangeCode(code);
13
+ * // ...
14
+ * } else {
15
+ * switch (result.reason) {
16
+ * case 'user_denied': return renderCancelled(); // user clicked Cancel
17
+ * case 'invalid_state': return renderRetry(); // CSRF check failed
18
+ * case 'missing_code': return renderRetry(); // user landed here directly
19
+ * default: return renderError(result); // other OAuth error
20
+ * }
21
+ * }
22
+ * ```
23
+ *
24
+ * Pure function — no side effects, no network, no SDK config needed.
25
+ * Works server-side (Node, edge runtimes) and in the browser.
26
+ */
27
+ /**
28
+ * Parse an OAuth callback. Accepts any of: a full URL string, a path
29
+ * with query string (e.g. Node's `req.url`), a `URL` object, a
30
+ * `URLSearchParams`, or a plain query string (with or without leading
31
+ * `?`).
32
+ */
33
+ export const parseCallback = (input, options = {}) => {
34
+ const params = toSearchParams(input);
35
+ const code = params.get('code');
36
+ const state = params.get('state');
37
+ const error = params.get('error');
38
+ const description = params.get('error_description');
39
+ // State validation runs first: a returned code paired with a bad
40
+ // state is more suspicious than no code at all (could be a planted
41
+ // code from an attacker hijacking the callback). Always reject.
42
+ if (options.expectedState !== undefined && state !== options.expectedState) {
43
+ return {
44
+ ok: false,
45
+ reason: 'invalid_state',
46
+ state,
47
+ ...(error !== null ? { error } : {}),
48
+ ...(description !== null ? { description } : {}),
49
+ };
50
+ }
51
+ if (error !== null) {
52
+ return {
53
+ ok: false,
54
+ reason: classifyError(error, description),
55
+ error,
56
+ state,
57
+ ...(description !== null ? { description } : {}),
58
+ };
59
+ }
60
+ if (code !== null && code !== '') {
61
+ return { ok: true, code, state };
62
+ }
63
+ return { ok: false, reason: 'missing_code', state };
64
+ };
65
+ const classifyError = (error, description) => {
66
+ if (error === 'access_denied' && description === 'user_denied')
67
+ return 'user_denied';
68
+ if (error === 'access_denied')
69
+ return 'access_denied';
70
+ return 'oauth_error';
71
+ };
72
+ const toSearchParams = (input) => {
73
+ if (input instanceof URLSearchParams)
74
+ return input;
75
+ if (input instanceof URL)
76
+ return input.searchParams;
77
+ const trimmed = input.trim();
78
+ // Bare query string ("?foo=bar" or "foo=bar")
79
+ if (trimmed.startsWith('?'))
80
+ return new URLSearchParams(trimmed.slice(1));
81
+ // Path + query ("/cb?foo=bar") or full URL ("https://x/cb?foo=bar")
82
+ const queryStart = trimmed.indexOf('?');
83
+ if (queryStart < 0)
84
+ return new URLSearchParams('');
85
+ return new URLSearchParams(trimmed.slice(queryStart + 1));
86
+ };
87
+ //# sourceMappingURL=parse-callback.js.map
@@ -0,0 +1,29 @@
1
+ export declare const AuthMessageType: {
2
+ readonly Success: "tokenite:auth-success";
3
+ readonly Error: "tokenite:auth-error";
4
+ };
5
+ export declare const TopupMessageType: {
6
+ readonly Success: "tokenite:topup-success";
7
+ readonly Cancelled: "tokenite:topup-cancelled";
8
+ };
9
+ export type AuthMessage = {
10
+ readonly type: typeof AuthMessageType.Success;
11
+ readonly code: string;
12
+ } | {
13
+ readonly type: typeof AuthMessageType.Error;
14
+ readonly error: string;
15
+ };
16
+ export type TopupMessage = {
17
+ readonly type: typeof TopupMessageType.Success;
18
+ readonly newLimit: number;
19
+ readonly remaining: number;
20
+ } | {
21
+ readonly type: typeof TopupMessageType.Cancelled;
22
+ };
23
+ export type FundingErrorDetails = {
24
+ readonly remaining?: number;
25
+ readonly currentLimit?: number;
26
+ readonly currentSpent?: number;
27
+ };
28
+ export declare const parseFundingDetails: (details: unknown) => FundingErrorDetails | null;
29
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1,23 @@
1
+ export const AuthMessageType = {
2
+ Success: 'tokenite:auth-success',
3
+ Error: 'tokenite:auth-error',
4
+ };
5
+ export const TopupMessageType = {
6
+ Success: 'tokenite:topup-success',
7
+ Cancelled: 'tokenite:topup-cancelled',
8
+ };
9
+ const isRecord = (v) => typeof v === 'object' && v !== null;
10
+ const numericField = (obj, key) => {
11
+ const v = obj[key];
12
+ return typeof v === 'number' ? v : undefined;
13
+ };
14
+ export const parseFundingDetails = (details) => {
15
+ if (!isRecord(details))
16
+ return null;
17
+ return {
18
+ ...(numericField(details, 'remaining') !== undefined ? { remaining: numericField(details, 'remaining') } : {}),
19
+ ...(numericField(details, 'currentLimit') !== undefined ? { currentLimit: numericField(details, 'currentLimit') } : {}),
20
+ ...(numericField(details, 'currentSpent') !== undefined ? { currentSpent: numericField(details, 'currentSpent') } : {}),
21
+ };
22
+ };
23
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1,13 @@
1
+ import type { ProxyError } from './types.js';
2
+ export type RecoveryKind = 'user-topup' | 'add-key' | 'team-action' | 'sponsored-out';
3
+ export type RecoveryClass = {
4
+ readonly kind: RecoveryKind;
5
+ readonly actor: 'user' | 'team-owner' | 'builder';
6
+ readonly suggestedAmount?: number;
7
+ readonly currentLimit?: number;
8
+ readonly currentSpent?: number;
9
+ readonly message: string;
10
+ readonly dashboardPath: string;
11
+ };
12
+ export declare const isFundingError: (error: ProxyError["error"]) => RecoveryClass | null;
13
+ //# sourceMappingURL=recovery.d.ts.map
@@ -0,0 +1,47 @@
1
+ import { parseFundingDetails } from './protocol.js';
2
+ const SUGGESTED_MULTIPLIER = 2;
3
+ export const isFundingError = (error) => {
4
+ switch (error.code) {
5
+ case 'BUDGET_EXCEEDED': {
6
+ const details = parseFundingDetails(error.details);
7
+ const currentLimit = details?.currentLimit;
8
+ const currentSpent = details?.currentSpent;
9
+ const suggestedAmount = currentLimit !== undefined
10
+ ? Math.max(currentLimit * SUGGESTED_MULTIPLIER, currentLimit + 1)
11
+ : undefined;
12
+ return {
13
+ kind: 'user-topup',
14
+ actor: 'user',
15
+ ...(currentLimit !== undefined ? { currentLimit } : {}),
16
+ ...(currentSpent !== undefined ? { currentSpent } : {}),
17
+ ...(suggestedAmount !== undefined ? { suggestedAmount } : {}),
18
+ message: 'Your per-app spending limit is exhausted. Raise it to continue.',
19
+ dashboardPath: '/oauth/topup',
20
+ };
21
+ }
22
+ case 'POOL_EXHAUSTED':
23
+ return {
24
+ kind: 'team-action',
25
+ actor: 'team-owner',
26
+ message: 'Your account pool budget is exhausted. The account owner must raise it.',
27
+ dashboardPath: '/a/_/team/pool',
28
+ };
29
+ case 'PROVIDER_KEY_MISSING':
30
+ return {
31
+ kind: 'add-key',
32
+ actor: 'user',
33
+ message: 'No provider key on file. Add one to continue.',
34
+ dashboardPath: '/a/_/consumer/keys',
35
+ };
36
+ case 'SPONSORED_POOL_EXHAUSTED':
37
+ return {
38
+ kind: 'sponsored-out',
39
+ actor: 'builder',
40
+ message: 'This app\'s sponsored credits are exhausted. The builder must refill.',
41
+ dashboardPath: '/builder/apps',
42
+ };
43
+ default:
44
+ return null;
45
+ }
46
+ };
47
+ //# sourceMappingURL=recovery.js.map
package/dist/types.d.ts CHANGED
@@ -45,6 +45,46 @@ export type PopupResult = {
45
45
  */
46
46
  readonly code: string;
47
47
  };
48
+ export type TopUpOptions = {
49
+ /**
50
+ * How to host the top-up screen.
51
+ *
52
+ * - `'popup'` (default) — open in a separate browser window via
53
+ * `window.open`. The user completes the form; the SDK resolves
54
+ * when Tokenite posts back the new limit.
55
+ * - `'redirect'` — full-page navigation. The builder's app loses
56
+ * in-memory state and must reload from the callback URL.
57
+ */
58
+ readonly mode?: 'popup' | 'redirect';
59
+ /** Pre-fill the new-limit field. Default: 2 × current limit. */
60
+ readonly suggestedAmount?: number;
61
+ /** Popup width in pixels. Default: 480 */
62
+ readonly width?: number;
63
+ /** Popup height in pixels. Default: 560 */
64
+ readonly height?: number;
65
+ };
66
+ export type TopUpResult = {
67
+ readonly ok: true;
68
+ readonly newLimit: number;
69
+ readonly remaining: number;
70
+ } | {
71
+ readonly ok: false;
72
+ readonly reason: 'cancelled' | 'popup-blocked' | 'closed' | 'redirected';
73
+ };
74
+ export type CallWithRecoveryOptions = {
75
+ /**
76
+ * What to do when the proxy returns a recoverable funding error.
77
+ *
78
+ * - `'popup'` (default) — open Tokenite's top-up popup, then retry the
79
+ * call once on success.
80
+ * - `'redirect'` — full-page navigation; the call does not retry (the
81
+ * builder's app re-loads from the callback and re-invokes manually).
82
+ * - `'throw'` — disable recovery; surface the original error.
83
+ */
84
+ readonly onFundingNeeded?: 'popup' | 'redirect' | 'throw';
85
+ /** Override the suggested top-up amount. */
86
+ readonly suggestedAmount?: number;
87
+ };
48
88
  export type TokenResponse = {
49
89
  readonly access_token: string;
50
90
  readonly token_type: string;
@@ -175,12 +215,6 @@ export type AppInfo = {
175
215
  readonly websiteUrl: string | null;
176
216
  /** Absolute URL to the app's icon (PNG/SVG). null when the developer hasn't set one — render initials or a generic glyph. */
177
217
  readonly iconUrl: string | null;
178
- /** Providers the app declares it needs */
179
- readonly requiredProviders: readonly Provider[];
180
- /** Fallback order when `allowSubstitution` is true */
181
- readonly preferredProviders: readonly Provider[];
182
- /** Whether the app accepts substitute providers when a required one isn't available */
183
- readonly allowSubstitution: boolean;
184
218
  };
185
219
  /**
186
220
  * Full access context for a single access token: the app it belongs to,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenite/sdk",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "SDK for integrating \"Login with Tokenite\" into your app. Your users bring their own AI tokens — you pay nothing.",
5
5
  "type": "module",
6
6
  "exports": {