@ottocode/ai-sdk 0.1.8 → 0.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 +16 -2
- package/package.json +27 -27
- package/src/auth.ts +24 -11
- package/src/balance.ts +17 -4
- package/src/catalog.ts +48 -1
- package/src/fetch.ts +19 -7
- package/src/payment.ts +23 -9
- package/src/token.ts +155 -0
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ A drop-in SDK for accessing AI models (OpenAI, Anthropic, Google, Moonshot, Mini
|
|
|
4
4
|
|
|
5
5
|
All you need is a Solana wallet — the SDK handles authentication, payment negotiation, and provider routing automatically.
|
|
6
6
|
|
|
7
|
+
Normal API requests use bearer auth. The SDK signs a wallet nonce once to exchange for a short-lived Setu token, reuses that token across requests, and refreshes it automatically when needed.
|
|
8
|
+
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
11
|
```bash
|
|
@@ -32,6 +34,8 @@ console.log(text);
|
|
|
32
34
|
|
|
33
35
|
The SDK auto-resolves which provider to use based on the model name. It returns ai-sdk compatible model instances that work directly with `generateText()`, `streamText()`, etc.
|
|
34
36
|
|
|
37
|
+
Under the hood, the first protected request exchanges wallet auth headers for a bearer token via `POST /v1/auth/wallet-token`. Subsequent requests reuse `Authorization: Bearer <token>` until refresh is needed.
|
|
38
|
+
|
|
35
39
|
## Provider Auto-Resolution
|
|
36
40
|
|
|
37
41
|
Models are resolved to providers by prefix:
|
|
@@ -103,6 +107,8 @@ const setu = createSetu({
|
|
|
103
107
|
|
|
104
108
|
Monitor and control the payment lifecycle:
|
|
105
109
|
|
|
110
|
+
Request authentication and payment signing are separate: bearer auth is used for normal Setu HTTP requests, while your wallet still signs the x402 payment transaction during topups.
|
|
111
|
+
|
|
106
112
|
```ts
|
|
107
113
|
const setu = createSetu({
|
|
108
114
|
auth: { privateKey: '...' },
|
|
@@ -287,6 +293,8 @@ setu.registry.mapModel('some-model', 'openai');
|
|
|
287
293
|
|
|
288
294
|
Use the x402-aware fetch wrapper directly:
|
|
289
295
|
|
|
296
|
+
`setu.fetch()` uses bearer auth for normal requests and automatically refreshes the Setu access token on `401` once before retrying.
|
|
297
|
+
|
|
290
298
|
```ts
|
|
291
299
|
const customFetch = setu.fetch();
|
|
292
300
|
|
|
@@ -306,6 +314,7 @@ import {
|
|
|
306
314
|
getPublicKeyFromPrivate,
|
|
307
315
|
addAnthropicCacheControl,
|
|
308
316
|
createSetuFetch,
|
|
317
|
+
createWalletContext,
|
|
309
318
|
} from '@ottocode/ai-sdk';
|
|
310
319
|
|
|
311
320
|
// Get wallet address from private key
|
|
@@ -324,16 +333,21 @@ const setuFetch = createSetuFetch({
|
|
|
324
333
|
});
|
|
325
334
|
```
|
|
326
335
|
|
|
336
|
+
`createWalletContext()` remains available for advanced usage. Its wallet headers are now intended for token exchange only; regular API traffic should go through `createSetu()`, `setu.fetch()`, `createSetuFetch()`, or `fetchBalance()` so bearer auth refresh is handled automatically.
|
|
337
|
+
|
|
327
338
|
## How It Works
|
|
328
339
|
|
|
329
340
|
1. You call `setu.model('claude-sonnet-4-20250514')` — the SDK resolves this to Anthropic
|
|
330
341
|
2. It creates an ai-sdk provider (`@ai-sdk/anthropic`) pointed at the Setu proxy
|
|
331
342
|
3. A custom fetch wrapper intercepts all requests to:
|
|
332
|
-
-
|
|
343
|
+
- Exchange signed wallet headers for a short-lived bearer token when needed
|
|
344
|
+
- Inject `Authorization: Bearer <token>` into normal API requests
|
|
333
345
|
- Inject Anthropic cache control (if enabled)
|
|
346
|
+
- Handle `401` by refreshing the bearer token once and retrying
|
|
334
347
|
- Handle 402 responses by signing USDC payments via x402
|
|
335
348
|
- Sniff balance/cost info from SSE stream comments
|
|
336
|
-
4.
|
|
349
|
+
4. During topups, the wallet still signs the x402 transaction, but the `/v1/topup` HTTP request itself uses bearer auth
|
|
350
|
+
5. The Setu proxy verifies the wallet/token, checks balance, forwards to the real provider, and tracks usage
|
|
337
351
|
|
|
338
352
|
## Requirements
|
|
339
353
|
|
package/package.json
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
2
|
+
"name": "@ottocode/ai-sdk",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./providers": "./src/providers/index.ts",
|
|
8
|
+
"./types": "./src/types.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@ai-sdk/anthropic": "^3.0.0",
|
|
15
|
+
"@ai-sdk/google": "^3.0.0",
|
|
16
|
+
"@ai-sdk/openai": "^3.0.0",
|
|
17
|
+
"@ai-sdk/openai-compatible": "^2.0.0",
|
|
18
|
+
"@solana/web3.js": "^1.98.0",
|
|
19
|
+
"bs58": "^6.0.0",
|
|
20
|
+
"tweetnacl": "^1.0.3",
|
|
21
|
+
"x402": "^1.1.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"ai": ">=6.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "~5.9.3"
|
|
28
|
+
}
|
|
29
29
|
}
|
package/src/auth.ts
CHANGED
|
@@ -5,7 +5,13 @@ import type { SetuAuth } from './types.ts';
|
|
|
5
5
|
|
|
6
6
|
export interface WalletContext {
|
|
7
7
|
walletAddress: string;
|
|
8
|
-
|
|
8
|
+
buildWalletAuthHeaders:
|
|
9
|
+
| (() => Promise<Record<string, string>>)
|
|
10
|
+
| (() => Record<string, string>);
|
|
11
|
+
/** @deprecated Use buildWalletAuthHeaders() instead. */
|
|
12
|
+
buildHeaders:
|
|
13
|
+
| (() => Promise<Record<string, string>>)
|
|
14
|
+
| (() => Record<string, string>);
|
|
9
15
|
keypair?: Keypair;
|
|
10
16
|
privateKeyBytes?: Uint8Array;
|
|
11
17
|
signTransaction?: (transaction: Uint8Array) => Promise<Uint8Array>;
|
|
@@ -18,18 +24,20 @@ export function createWalletContext(auth: SetuAuth): WalletContext {
|
|
|
18
24
|
signNonce: customSignNonce,
|
|
19
25
|
signTransaction,
|
|
20
26
|
} = auth.signer;
|
|
27
|
+
const buildWalletAuthHeaders = async () => {
|
|
28
|
+
const nonce = Date.now().toString();
|
|
29
|
+
const signature = await customSignNonce(nonce);
|
|
30
|
+
return {
|
|
31
|
+
'x-wallet-address': walletAddress,
|
|
32
|
+
'x-wallet-nonce': nonce,
|
|
33
|
+
'x-wallet-signature': signature,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
21
36
|
return {
|
|
22
37
|
walletAddress,
|
|
23
38
|
signTransaction,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const signature = await customSignNonce(nonce);
|
|
27
|
-
return {
|
|
28
|
-
'x-wallet-address': walletAddress,
|
|
29
|
-
'x-wallet-nonce': nonce,
|
|
30
|
-
'x-wallet-signature': signature,
|
|
31
|
-
};
|
|
32
|
-
},
|
|
39
|
+
buildWalletAuthHeaders,
|
|
40
|
+
buildHeaders: buildWalletAuthHeaders,
|
|
33
41
|
};
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -40,11 +48,14 @@ export function createWalletContext(auth: SetuAuth): WalletContext {
|
|
|
40
48
|
const privateKeyBytes = bs58.decode(auth.privateKey);
|
|
41
49
|
const keypair = Keypair.fromSecretKey(privateKeyBytes);
|
|
42
50
|
const walletAddress = keypair.publicKey.toBase58();
|
|
51
|
+
const buildWalletAuthHeaders = () =>
|
|
52
|
+
buildWalletHeaders(walletAddress, privateKeyBytes);
|
|
43
53
|
return {
|
|
44
54
|
keypair,
|
|
45
55
|
walletAddress,
|
|
46
56
|
privateKeyBytes,
|
|
47
|
-
|
|
57
|
+
buildWalletAuthHeaders,
|
|
58
|
+
buildHeaders: buildWalletAuthHeaders,
|
|
48
59
|
};
|
|
49
60
|
}
|
|
50
61
|
|
|
@@ -67,6 +78,8 @@ export function buildWalletHeaders(
|
|
|
67
78
|
};
|
|
68
79
|
}
|
|
69
80
|
|
|
81
|
+
export const buildWalletAuthHeaders = buildWalletHeaders;
|
|
82
|
+
|
|
70
83
|
export function getPublicKeyFromPrivate(privateKey?: string): string | null {
|
|
71
84
|
if (!privateKey) return null;
|
|
72
85
|
try {
|
package/src/balance.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Keypair } from '@solana/web3.js';
|
|
|
3
3
|
import type { SetuAuth, BalanceResponse, WalletUsdcBalance } from './types.ts';
|
|
4
4
|
import type { WalletContext } from './auth.ts';
|
|
5
5
|
import { createWalletContext } from './auth.ts';
|
|
6
|
+
import { createAccessTokenManager } from './token.ts';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_BASE_URL = 'https://api.setu.ottocode.io';
|
|
8
9
|
const DEFAULT_RPC_URL = 'https://api.mainnet-beta.solana.com';
|
|
@@ -17,8 +18,10 @@ function isWalletContext(input: unknown): input is WalletContext {
|
|
|
17
18
|
return (
|
|
18
19
|
typeof input === 'object' &&
|
|
19
20
|
input !== null &&
|
|
20
|
-
'
|
|
21
|
-
|
|
21
|
+
(('buildWalletAuthHeaders' in input &&
|
|
22
|
+
typeof (input as WalletContext).buildWalletAuthHeaders === 'function') ||
|
|
23
|
+
('buildHeaders' in input &&
|
|
24
|
+
typeof (input as WalletContext).buildHeaders === 'function'))
|
|
22
25
|
);
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -31,9 +34,19 @@ export async function fetchBalance(
|
|
|
31
34
|
? walletOrAuth
|
|
32
35
|
: createWalletContext(walletOrAuth);
|
|
33
36
|
const url = trimTrailingSlash(baseURL ?? DEFAULT_BASE_URL);
|
|
34
|
-
const
|
|
37
|
+
const tokenManager = createAccessTokenManager({ wallet, baseURL: url });
|
|
38
|
+
const requestBalance = async (forceRefresh = false) => {
|
|
39
|
+
const accessToken = await tokenManager.getToken(forceRefresh);
|
|
40
|
+
return fetch(`${url}/v1/balance`, {
|
|
41
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
42
|
+
});
|
|
43
|
+
};
|
|
35
44
|
|
|
36
|
-
|
|
45
|
+
let response = await requestBalance();
|
|
46
|
+
if (response.status === 401) {
|
|
47
|
+
tokenManager.invalidate();
|
|
48
|
+
response = await requestBalance(true);
|
|
49
|
+
}
|
|
37
50
|
|
|
38
51
|
if (!response.ok) return null;
|
|
39
52
|
|
package/src/catalog.ts
CHANGED
|
@@ -1176,6 +1176,53 @@ export const setuCatalog: SetuCatalog = {
|
|
|
1176
1176
|
cache_read: 0.17587499999999998,
|
|
1177
1177
|
},
|
|
1178
1178
|
},
|
|
1179
|
+
{
|
|
1180
|
+
id: 'gpt-5.4',
|
|
1181
|
+
name: 'GPT-5.4',
|
|
1182
|
+
owned_by: 'openai',
|
|
1183
|
+
context_length: 1050000,
|
|
1184
|
+
max_output: 128000,
|
|
1185
|
+
reasoning: true,
|
|
1186
|
+
tool_call: true,
|
|
1187
|
+
attachment: true,
|
|
1188
|
+
temperature: false,
|
|
1189
|
+
knowledge: '2025-08-31',
|
|
1190
|
+
release_date: '2026-03-05',
|
|
1191
|
+
last_updated: '2026-03-05',
|
|
1192
|
+
open_weights: false,
|
|
1193
|
+
modalities: {
|
|
1194
|
+
input: ['text', 'image'],
|
|
1195
|
+
output: ['text'],
|
|
1196
|
+
},
|
|
1197
|
+
pricing: {
|
|
1198
|
+
input: 2.5124999999999997,
|
|
1199
|
+
output: 15.075,
|
|
1200
|
+
cache_read: 0.25125,
|
|
1201
|
+
},
|
|
1202
|
+
},
|
|
1203
|
+
{
|
|
1204
|
+
id: 'gpt-5.4-pro',
|
|
1205
|
+
name: 'GPT-5.4 Pro',
|
|
1206
|
+
owned_by: 'openai',
|
|
1207
|
+
context_length: 1050000,
|
|
1208
|
+
max_output: 128000,
|
|
1209
|
+
reasoning: true,
|
|
1210
|
+
tool_call: true,
|
|
1211
|
+
attachment: true,
|
|
1212
|
+
temperature: false,
|
|
1213
|
+
knowledge: '2025-08-31',
|
|
1214
|
+
release_date: '2026-03-05',
|
|
1215
|
+
last_updated: '2026-03-05',
|
|
1216
|
+
open_weights: false,
|
|
1217
|
+
modalities: {
|
|
1218
|
+
input: ['text', 'image'],
|
|
1219
|
+
output: ['text'],
|
|
1220
|
+
},
|
|
1221
|
+
pricing: {
|
|
1222
|
+
input: 30.15,
|
|
1223
|
+
output: 180.89999999999998,
|
|
1224
|
+
},
|
|
1225
|
+
},
|
|
1179
1226
|
{
|
|
1180
1227
|
id: 'glm-4.7',
|
|
1181
1228
|
name: 'GLM-4.7',
|
|
@@ -1248,5 +1295,5 @@ export const setuCatalog: SetuCatalog = {
|
|
|
1248
1295
|
},
|
|
1249
1296
|
],
|
|
1250
1297
|
providers: ['anthropic', 'google', 'minimax', 'moonshot', 'openai', 'zai'],
|
|
1251
|
-
lastUpdated: '2026-
|
|
1298
|
+
lastUpdated: '2026-03-06',
|
|
1252
1299
|
} as const satisfies SetuCatalog;
|
package/src/fetch.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from './types.ts';
|
|
9
9
|
import { pickPaymentRequirement, handlePayment } from './payment.ts';
|
|
10
10
|
import { addAnthropicCacheControl } from './cache.ts';
|
|
11
|
+
import { createAccessTokenManager } from './token.ts';
|
|
11
12
|
|
|
12
13
|
const DEFAULT_RPC_URL = 'https://api.mainnet-beta.solana.com';
|
|
13
14
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
@@ -186,6 +187,11 @@ export function createSetuFetch(options: CreateSetuFetchOptions) {
|
|
|
186
187
|
const topupApprovalMode = payment?.topupApprovalMode ?? 'auto';
|
|
187
188
|
const autoPayThresholdUsd = payment?.autoPayThresholdUsd ?? 0;
|
|
188
189
|
const baseFetch = customFetch ?? globalThis.fetch.bind(globalThis);
|
|
190
|
+
const tokenManager = createAccessTokenManager({
|
|
191
|
+
wallet,
|
|
192
|
+
baseURL,
|
|
193
|
+
fetch: baseFetch,
|
|
194
|
+
});
|
|
189
195
|
|
|
190
196
|
return async (
|
|
191
197
|
input: Parameters<typeof fetch>[0],
|
|
@@ -195,6 +201,13 @@ export function createSetuFetch(options: CreateSetuFetchOptions) {
|
|
|
195
201
|
|
|
196
202
|
while (attempt < maxAttempts) {
|
|
197
203
|
attempt++;
|
|
204
|
+
const performAuthenticatedRequest = async (forceRefresh = false) => {
|
|
205
|
+
const headers = new Headers(init?.headers);
|
|
206
|
+
const accessToken = await tokenManager.getToken(forceRefresh);
|
|
207
|
+
headers.set('authorization', `Bearer ${accessToken}`);
|
|
208
|
+
return baseFetch(input, { ...init, body, headers });
|
|
209
|
+
};
|
|
210
|
+
|
|
198
211
|
let body = init?.body;
|
|
199
212
|
if (body && typeof body === 'string') {
|
|
200
213
|
try {
|
|
@@ -213,13 +226,11 @@ export function createSetuFetch(options: CreateSetuFetchOptions) {
|
|
|
213
226
|
} catch {}
|
|
214
227
|
}
|
|
215
228
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const response = await baseFetch(input, { ...init, body, headers });
|
|
229
|
+
let response = await performAuthenticatedRequest();
|
|
230
|
+
if (response.status === 401) {
|
|
231
|
+
tokenManager.invalidate();
|
|
232
|
+
response = await performAuthenticatedRequest(true);
|
|
233
|
+
}
|
|
223
234
|
|
|
224
235
|
if (response.status !== 402) {
|
|
225
236
|
return wrapResponseWithBalanceSniffing(response, callbacks);
|
|
@@ -292,6 +303,7 @@ export function createSetuFetch(options: CreateSetuFetchOptions) {
|
|
|
292
303
|
rpcURL,
|
|
293
304
|
baseURL,
|
|
294
305
|
baseFetch,
|
|
306
|
+
tokenManager,
|
|
295
307
|
maxAttempts: remainingPayments,
|
|
296
308
|
callbacks,
|
|
297
309
|
});
|
package/src/payment.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
PaymentCallbacks,
|
|
10
10
|
FetchFunction,
|
|
11
11
|
} from './types.ts';
|
|
12
|
+
import type { AccessTokenManager } from './token.ts';
|
|
12
13
|
import {
|
|
13
14
|
address,
|
|
14
15
|
getTransactionEncoder,
|
|
@@ -138,6 +139,7 @@ export async function processSinglePayment(args: {
|
|
|
138
139
|
rpcURL: string;
|
|
139
140
|
baseURL: string;
|
|
140
141
|
baseFetch: FetchFunction;
|
|
142
|
+
tokenManager: AccessTokenManager;
|
|
141
143
|
callbacks: PaymentCallbacks;
|
|
142
144
|
}): Promise<{ attempts: number; balance?: number | string }> {
|
|
143
145
|
args.callbacks.onPaymentSigning?.();
|
|
@@ -156,15 +158,26 @@ export async function processSinglePayment(args: {
|
|
|
156
158
|
throw new Error(`Setu: ${userMsg}`);
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
161
|
+
const sendTopupRequest = async (forceRefresh = false) => {
|
|
162
|
+
const accessToken = await args.tokenManager.getToken(forceRefresh);
|
|
163
|
+
return args.baseFetch(`${args.baseURL}/v1/topup`, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: {
|
|
166
|
+
'Content-Type': 'application/json',
|
|
167
|
+
authorization: `Bearer ${accessToken}`,
|
|
168
|
+
},
|
|
169
|
+
body: JSON.stringify({
|
|
170
|
+
paymentPayload,
|
|
171
|
+
paymentRequirement: args.requirement,
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
let response = await sendTopupRequest();
|
|
177
|
+
if (response.status === 401) {
|
|
178
|
+
args.tokenManager.invalidate();
|
|
179
|
+
response = await sendTopupRequest(true);
|
|
180
|
+
}
|
|
168
181
|
|
|
169
182
|
const rawBody = await response.text().catch(() => '');
|
|
170
183
|
if (!response.ok) {
|
|
@@ -210,6 +223,7 @@ export async function handlePayment(args: {
|
|
|
210
223
|
rpcURL: string;
|
|
211
224
|
baseURL: string;
|
|
212
225
|
baseFetch: FetchFunction;
|
|
226
|
+
tokenManager: AccessTokenManager;
|
|
213
227
|
maxAttempts: number;
|
|
214
228
|
callbacks: PaymentCallbacks;
|
|
215
229
|
}): Promise<{ attemptsUsed: number }> {
|
package/src/token.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { WalletContext } from './auth.ts';
|
|
2
|
+
import type { FetchFunction } from './types.ts';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TOKEN_REFRESH_SKEW_MS = 60_000;
|
|
5
|
+
const DEFAULT_TOKEN_TTL_MS = 5 * 60_000;
|
|
6
|
+
|
|
7
|
+
interface AccessTokenState {
|
|
8
|
+
token: string;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AccessTokenManager {
|
|
13
|
+
getToken(forceRefresh?: boolean): Promise<string>;
|
|
14
|
+
invalidate(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CreateAccessTokenManagerOptions {
|
|
18
|
+
wallet: WalletContext;
|
|
19
|
+
baseURL: string;
|
|
20
|
+
fetch?: FetchFunction;
|
|
21
|
+
tokenRefreshSkewMs?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface WalletTokenResponse {
|
|
25
|
+
accessToken?: string;
|
|
26
|
+
access_token?: string;
|
|
27
|
+
token?: string;
|
|
28
|
+
expiresAt?: number | string;
|
|
29
|
+
expires_at?: number | string;
|
|
30
|
+
expiresIn?: number | string;
|
|
31
|
+
expires_in?: number | string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function trimTrailingSlash(url: string) {
|
|
35
|
+
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseNumber(value: unknown): number | null {
|
|
39
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
40
|
+
if (typeof value === 'string' && value.trim()) {
|
|
41
|
+
const parsed = Number(value);
|
|
42
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseJwtExpiry(token: string): number | null {
|
|
48
|
+
const parts = token.split('.');
|
|
49
|
+
if (parts.length < 2) return null;
|
|
50
|
+
try {
|
|
51
|
+
const base64 = parts[1]?.replace(/-/g, '+').replace(/_/g, '/');
|
|
52
|
+
if (!base64) return null;
|
|
53
|
+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
|
|
54
|
+
const json = JSON.parse(atob(padded)) as {
|
|
55
|
+
exp?: unknown;
|
|
56
|
+
};
|
|
57
|
+
const exp = parseNumber(json.exp);
|
|
58
|
+
return exp != null ? exp * 1000 : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveExpiresAt(payload: WalletTokenResponse, token: string): number {
|
|
65
|
+
const expiresAt = parseNumber(payload.expiresAt ?? payload.expires_at);
|
|
66
|
+
if (expiresAt != null) {
|
|
67
|
+
return expiresAt > 1_000_000_000_000 ? expiresAt : expiresAt * 1000;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const expiresIn = parseNumber(payload.expiresIn ?? payload.expires_in);
|
|
71
|
+
if (expiresIn != null) {
|
|
72
|
+
return Date.now() + expiresIn * 1000;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return parseJwtExpiry(token) ?? Date.now() + DEFAULT_TOKEN_TTL_MS;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function exchangeWalletToken(
|
|
79
|
+
wallet: WalletContext,
|
|
80
|
+
baseURL: string,
|
|
81
|
+
baseFetch: FetchFunction,
|
|
82
|
+
): Promise<AccessTokenState> {
|
|
83
|
+
const walletHeaders = await (
|
|
84
|
+
wallet.buildWalletAuthHeaders ?? wallet.buildHeaders
|
|
85
|
+
)();
|
|
86
|
+
const response = await baseFetch(
|
|
87
|
+
`${trimTrailingSlash(baseURL)}/v1/auth/wallet-token`,
|
|
88
|
+
{
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: walletHeaders,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const body = await response.text().catch(() => '');
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Setu: wallet token exchange failed (${response.status})${body ? `: ${body}` : ''}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const payload = (await response.json()) as WalletTokenResponse;
|
|
102
|
+
const token = payload.accessToken ?? payload.access_token ?? payload.token;
|
|
103
|
+
if (!token) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
'Setu: wallet token exchange response missing access token.',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
token,
|
|
111
|
+
expiresAt: resolveExpiresAt(payload, token),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createAccessTokenManager(
|
|
116
|
+
options: CreateAccessTokenManagerOptions,
|
|
117
|
+
): AccessTokenManager {
|
|
118
|
+
const {
|
|
119
|
+
wallet,
|
|
120
|
+
baseURL,
|
|
121
|
+
fetch: customFetch,
|
|
122
|
+
tokenRefreshSkewMs = DEFAULT_TOKEN_REFRESH_SKEW_MS,
|
|
123
|
+
} = options;
|
|
124
|
+
const baseFetch = customFetch ?? globalThis.fetch.bind(globalThis);
|
|
125
|
+
let state: AccessTokenState | null = null;
|
|
126
|
+
let inFlight: Promise<string> | null = null;
|
|
127
|
+
|
|
128
|
+
const hasValidToken = () =>
|
|
129
|
+
state != null && Date.now() + tokenRefreshSkewMs < state.expiresAt;
|
|
130
|
+
|
|
131
|
+
const refresh = async () => {
|
|
132
|
+
const next = await exchangeWalletToken(wallet, baseURL, baseFetch);
|
|
133
|
+
state = next;
|
|
134
|
+
return next.token;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
async getToken(forceRefresh = false) {
|
|
139
|
+
if (!forceRefresh && hasValidToken() && state) {
|
|
140
|
+
return state.token;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!inFlight) {
|
|
144
|
+
inFlight = refresh().finally(() => {
|
|
145
|
+
inFlight = null;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return inFlight;
|
|
150
|
+
},
|
|
151
|
+
invalidate() {
|
|
152
|
+
state = null;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|