@tenxyte/core 0.1.5 → 0.9.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 +184 -0
- package/dist/index.cjs +951 -496
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1709 -1265
- package/dist/index.d.ts +1709 -1265
- package/dist/index.js +919 -464
- package/dist/index.js.map +1 -1
- package/package.json +70 -66
- package/src/client.ts +50 -21
- package/src/http/client.ts +162 -162
- package/src/http/index.ts +1 -1
- package/src/http/interceptors.ts +117 -117
- package/src/index.ts +7 -7
- package/src/modules/ai.ts +178 -0
- package/src/modules/auth.ts +116 -95
- package/src/modules/b2b.ts +177 -0
- package/src/modules/rbac.ts +207 -160
- package/src/modules/security.ts +313 -122
- package/src/modules/user.ts +95 -80
- package/src/storage/cookie.ts +39 -39
- package/src/storage/index.ts +29 -29
- package/src/storage/localStorage.ts +75 -75
- package/src/storage/memory.ts +30 -30
- package/src/types/index.ts +152 -150
- package/src/utils/base64url.ts +25 -0
- package/src/utils/device_info.ts +94 -94
- package/src/utils/events.ts +71 -71
- package/src/utils/jwt.ts +51 -51
- package/tests/http.test.ts +144 -144
- package/tests/modules/auth.test.ts +93 -93
- package/tests/modules/rbac.test.ts +95 -95
- package/tests/modules/security.test.ts +85 -75
- package/tests/modules/user.test.ts +76 -76
- package/tests/storage.test.ts +96 -96
- package/tests/utils.test.ts +71 -71
- package/tsup.config.ts +10 -10
- package/vitest.config.ts +7 -7
package/src/http/client.ts
CHANGED
|
@@ -1,162 +1,162 @@
|
|
|
1
|
-
import type { TenxyteError } from '../types';
|
|
2
|
-
|
|
3
|
-
export interface HttpClientOptions {
|
|
4
|
-
baseUrl: string;
|
|
5
|
-
timeoutMs?: number;
|
|
6
|
-
headers?: Record<string, string>;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export type RequestConfig = Omit<RequestInit, 'body' | 'headers'> & {
|
|
10
|
-
body?: unknown;
|
|
11
|
-
headers?: Record<string, string>;
|
|
12
|
-
params?: Record<string, string | number | boolean>;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Core HTTP Client underlying the SDK.
|
|
17
|
-
* Handles JSON parsing, standard headers, simple request processing,
|
|
18
|
-
* and normalizing errors into TenxyteError format.
|
|
19
|
-
*/
|
|
20
|
-
export class TenxyteHttpClient {
|
|
21
|
-
private baseUrl: string;
|
|
22
|
-
private defaultHeaders: Record<string, string>;
|
|
23
|
-
|
|
24
|
-
// Interceptors
|
|
25
|
-
private requestInterceptors: Array<(config: RequestConfig & { url: string }) => Promise<RequestConfig & { url: string }> | (RequestConfig & { url: string })> = [];
|
|
26
|
-
private responseInterceptors: Array<(response: Response, request: { url: string; config: RequestConfig }) => Promise<Response> | Response> = [];
|
|
27
|
-
|
|
28
|
-
constructor(options: HttpClientOptions) {
|
|
29
|
-
this.baseUrl = options.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
30
|
-
this.defaultHeaders = {
|
|
31
|
-
'Content-Type': 'application/json',
|
|
32
|
-
Accept: 'application/json',
|
|
33
|
-
...options.headers,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Interceptor Registration
|
|
38
|
-
addRequestInterceptor(interceptor: typeof this.requestInterceptors[0]) {
|
|
39
|
-
this.requestInterceptors.push(interceptor);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
addResponseInterceptor(interceptor: typeof this.responseInterceptors[0]) {
|
|
43
|
-
this.responseInterceptors.push(interceptor);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Main request method wrapping fetch
|
|
48
|
-
*/
|
|
49
|
-
async request<T>(endpoint: string, config: RequestConfig = {}): Promise<T> {
|
|
50
|
-
const urlStr = endpoint.startsWith('http')
|
|
51
|
-
? endpoint
|
|
52
|
-
: `${this.baseUrl}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`;
|
|
53
|
-
|
|
54
|
-
let urlObj = new URL(urlStr);
|
|
55
|
-
|
|
56
|
-
if (config.params) {
|
|
57
|
-
Object.entries(config.params).forEach(([key, value]) => {
|
|
58
|
-
if (value !== undefined && value !== null) {
|
|
59
|
-
urlObj.searchParams.append(key, String(value));
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
let requestContext: any = {
|
|
65
|
-
url: urlObj.toString(),
|
|
66
|
-
...config,
|
|
67
|
-
headers: { ...this.defaultHeaders, ...(config.headers || {}) } as Record<string, string>,
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// Handle FormData implicitly for multipart requests
|
|
71
|
-
if (typeof FormData !== 'undefined' && requestContext.body instanceof FormData) {
|
|
72
|
-
const headers = requestContext.headers as Record<string, string>;
|
|
73
|
-
// Explicitly remove Content-Type so fetch can auto-assign the multipart boundary
|
|
74
|
-
delete headers['Content-Type'];
|
|
75
|
-
delete headers['content-type'];
|
|
76
|
-
} else if (requestContext.body && typeof requestContext.body === 'object') {
|
|
77
|
-
const contentType = (requestContext.headers as Record<string, string>)['Content-Type'] || '';
|
|
78
|
-
if (contentType.toLowerCase().includes('application/json')) {
|
|
79
|
-
requestContext.body = JSON.stringify(requestContext.body);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Run Request Interceptors
|
|
84
|
-
for (const interceptor of this.requestInterceptors) {
|
|
85
|
-
requestContext = await interceptor(requestContext);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const { url, ...fetchConfig } = requestContext as any;
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
let response = await fetch(url, fetchConfig as RequestInit);
|
|
92
|
-
|
|
93
|
-
// Run Response Interceptors (e.g., token refresh logic)
|
|
94
|
-
for (const interceptor of this.responseInterceptors) {
|
|
95
|
-
response = await interceptor(response, { url, config: fetchConfig as RequestConfig });
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (!response.ok) {
|
|
99
|
-
throw await this.normalizeError(response);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Handle NoContent
|
|
103
|
-
if (response.status === 204) {
|
|
104
|
-
return {} as T;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const contentType = response.headers.get('content-type');
|
|
108
|
-
if (contentType && contentType.includes('application/json')) {
|
|
109
|
-
return (await response.json()) as T;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return (await response.text()) as unknown as T;
|
|
113
|
-
} catch (error: any) {
|
|
114
|
-
if (error && error.code) {
|
|
115
|
-
throw error; // Already normalized
|
|
116
|
-
}
|
|
117
|
-
throw {
|
|
118
|
-
error: error.message || 'Network request failed',
|
|
119
|
-
code: 'NETWORK_ERROR' as unknown as import('../types').TenxyteErrorCode,
|
|
120
|
-
details: String(error)
|
|
121
|
-
} as TenxyteError;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
private async normalizeError(response: Response): Promise<TenxyteError> {
|
|
126
|
-
try {
|
|
127
|
-
const body = await response.json();
|
|
128
|
-
return {
|
|
129
|
-
error: body.error || body.detail || 'API request failed',
|
|
130
|
-
code: body.code || `HTTP_${response.status}`,
|
|
131
|
-
details: body.details || body,
|
|
132
|
-
retry_after: response.headers.has('Retry-After') ? parseInt(response.headers.get('Retry-After')!, 10) : undefined,
|
|
133
|
-
} as TenxyteError;
|
|
134
|
-
} catch (e) {
|
|
135
|
-
return {
|
|
136
|
-
error: `HTTP Error ${response.status}: ${response.statusText}`,
|
|
137
|
-
code: `HTTP_${response.status}` as unknown as import('../types').TenxyteErrorCode,
|
|
138
|
-
} as TenxyteError;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Convenience methods
|
|
143
|
-
get<T>(endpoint: string, config?: Omit<RequestConfig, 'method' | 'body'>) {
|
|
144
|
-
return this.request<T>(endpoint, { ...config, method: 'GET' });
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
post<T>(endpoint: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'body'>) {
|
|
148
|
-
return this.request<T>(endpoint, { ...config, method: 'POST', body: data });
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
put<T>(endpoint: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'body'>) {
|
|
152
|
-
return this.request<T>(endpoint, { ...config, method: 'PUT', body: data });
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
patch<T>(endpoint: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'body'>) {
|
|
156
|
-
return this.request<T>(endpoint, { ...config, method: 'PATCH', body: data });
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
delete<T>(endpoint: string, config?: Omit<RequestConfig, 'method' | 'body'>) {
|
|
160
|
-
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
|
|
161
|
-
}
|
|
162
|
-
}
|
|
1
|
+
import type { TenxyteError } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface HttpClientOptions {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type RequestConfig = Omit<RequestInit, 'body' | 'headers'> & {
|
|
10
|
+
body?: unknown;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
params?: Record<string, string | number | boolean>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Core HTTP Client underlying the SDK.
|
|
17
|
+
* Handles JSON parsing, standard headers, simple request processing,
|
|
18
|
+
* and normalizing errors into TenxyteError format.
|
|
19
|
+
*/
|
|
20
|
+
export class TenxyteHttpClient {
|
|
21
|
+
private baseUrl: string;
|
|
22
|
+
private defaultHeaders: Record<string, string>;
|
|
23
|
+
|
|
24
|
+
// Interceptors
|
|
25
|
+
private requestInterceptors: Array<(config: RequestConfig & { url: string }) => Promise<RequestConfig & { url: string }> | (RequestConfig & { url: string })> = [];
|
|
26
|
+
private responseInterceptors: Array<(response: Response, request: { url: string; config: RequestConfig }) => Promise<Response> | Response> = [];
|
|
27
|
+
|
|
28
|
+
constructor(options: HttpClientOptions) {
|
|
29
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
30
|
+
this.defaultHeaders = {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
Accept: 'application/json',
|
|
33
|
+
...options.headers,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Interceptor Registration
|
|
38
|
+
addRequestInterceptor(interceptor: typeof this.requestInterceptors[0]) {
|
|
39
|
+
this.requestInterceptors.push(interceptor);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
addResponseInterceptor(interceptor: typeof this.responseInterceptors[0]) {
|
|
43
|
+
this.responseInterceptors.push(interceptor);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Main request method wrapping fetch
|
|
48
|
+
*/
|
|
49
|
+
async request<T>(endpoint: string, config: RequestConfig = {}): Promise<T> {
|
|
50
|
+
const urlStr = endpoint.startsWith('http')
|
|
51
|
+
? endpoint
|
|
52
|
+
: `${this.baseUrl}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`;
|
|
53
|
+
|
|
54
|
+
let urlObj = new URL(urlStr);
|
|
55
|
+
|
|
56
|
+
if (config.params) {
|
|
57
|
+
Object.entries(config.params).forEach(([key, value]) => {
|
|
58
|
+
if (value !== undefined && value !== null) {
|
|
59
|
+
urlObj.searchParams.append(key, String(value));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let requestContext: any = {
|
|
65
|
+
url: urlObj.toString(),
|
|
66
|
+
...config,
|
|
67
|
+
headers: { ...this.defaultHeaders, ...(config.headers || {}) } as Record<string, string>,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Handle FormData implicitly for multipart requests
|
|
71
|
+
if (typeof FormData !== 'undefined' && requestContext.body instanceof FormData) {
|
|
72
|
+
const headers = requestContext.headers as Record<string, string>;
|
|
73
|
+
// Explicitly remove Content-Type so fetch can auto-assign the multipart boundary
|
|
74
|
+
delete headers['Content-Type'];
|
|
75
|
+
delete headers['content-type'];
|
|
76
|
+
} else if (requestContext.body && typeof requestContext.body === 'object') {
|
|
77
|
+
const contentType = (requestContext.headers as Record<string, string>)['Content-Type'] || '';
|
|
78
|
+
if (contentType.toLowerCase().includes('application/json')) {
|
|
79
|
+
requestContext.body = JSON.stringify(requestContext.body);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Run Request Interceptors
|
|
84
|
+
for (const interceptor of this.requestInterceptors) {
|
|
85
|
+
requestContext = await interceptor(requestContext);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { url, ...fetchConfig } = requestContext as any;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
let response = await fetch(url, fetchConfig as RequestInit);
|
|
92
|
+
|
|
93
|
+
// Run Response Interceptors (e.g., token refresh logic)
|
|
94
|
+
for (const interceptor of this.responseInterceptors) {
|
|
95
|
+
response = await interceptor(response, { url, config: fetchConfig as RequestConfig });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
throw await this.normalizeError(response);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Handle NoContent
|
|
103
|
+
if (response.status === 204) {
|
|
104
|
+
return {} as T;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const contentType = response.headers.get('content-type');
|
|
108
|
+
if (contentType && contentType.includes('application/json')) {
|
|
109
|
+
return (await response.json()) as T;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (await response.text()) as unknown as T;
|
|
113
|
+
} catch (error: any) {
|
|
114
|
+
if (error && error.code) {
|
|
115
|
+
throw error; // Already normalized
|
|
116
|
+
}
|
|
117
|
+
throw {
|
|
118
|
+
error: error.message || 'Network request failed',
|
|
119
|
+
code: 'NETWORK_ERROR' as unknown as import('../types').TenxyteErrorCode,
|
|
120
|
+
details: String(error)
|
|
121
|
+
} as TenxyteError;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async normalizeError(response: Response): Promise<TenxyteError> {
|
|
126
|
+
try {
|
|
127
|
+
const body = await response.json();
|
|
128
|
+
return {
|
|
129
|
+
error: body.error || body.detail || 'API request failed',
|
|
130
|
+
code: body.code || `HTTP_${response.status}`,
|
|
131
|
+
details: body.details || body,
|
|
132
|
+
retry_after: response.headers.has('Retry-After') ? parseInt(response.headers.get('Retry-After')!, 10) : undefined,
|
|
133
|
+
} as TenxyteError;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return {
|
|
136
|
+
error: `HTTP Error ${response.status}: ${response.statusText}`,
|
|
137
|
+
code: `HTTP_${response.status}` as unknown as import('../types').TenxyteErrorCode,
|
|
138
|
+
} as TenxyteError;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Convenience methods
|
|
143
|
+
get<T>(endpoint: string, config?: Omit<RequestConfig, 'method' | 'body'>) {
|
|
144
|
+
return this.request<T>(endpoint, { ...config, method: 'GET' });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
post<T>(endpoint: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'body'>) {
|
|
148
|
+
return this.request<T>(endpoint, { ...config, method: 'POST', body: data });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
put<T>(endpoint: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'body'>) {
|
|
152
|
+
return this.request<T>(endpoint, { ...config, method: 'PUT', body: data });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
patch<T>(endpoint: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'body'>) {
|
|
156
|
+
return this.request<T>(endpoint, { ...config, method: 'PATCH', body: data });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
delete<T>(endpoint: string, config?: Omit<RequestConfig, 'method' | 'body'>) {
|
|
160
|
+
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/http/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from './client';
|
|
1
|
+
export * from './client';
|
package/src/http/interceptors.ts
CHANGED
|
@@ -1,117 +1,117 @@
|
|
|
1
|
-
import type { TenxyteStorage } from '../storage';
|
|
2
|
-
import type { RequestConfig, TenxyteHttpClient } from './client';
|
|
3
|
-
|
|
4
|
-
export interface TenxyteContext {
|
|
5
|
-
activeOrgSlug: string | null;
|
|
6
|
-
agentTraceId: string | null;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function createAuthInterceptor(storage: TenxyteStorage, context: TenxyteContext) {
|
|
10
|
-
return async (request: RequestConfig & { url: string }) => {
|
|
11
|
-
// Inject Authorization if present
|
|
12
|
-
const token = await storage.getItem('tx_access');
|
|
13
|
-
const headers = { ...(request.headers as Record<string, string>) || {} };
|
|
14
|
-
|
|
15
|
-
if (token && !headers['Authorization']) {
|
|
16
|
-
headers['Authorization'] = `Bearer ${token}`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// Inject Contextual Headers based on SDK state
|
|
20
|
-
if (context.activeOrgSlug && !headers['X-Org-Slug']) {
|
|
21
|
-
headers['X-Org-Slug'] = context.activeOrgSlug;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (context.agentTraceId && !headers['X-Prompt-Trace-ID']) {
|
|
25
|
-
headers['X-Prompt-Trace-ID'] = context.agentTraceId;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return { ...request, headers };
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function createRefreshInterceptor(
|
|
33
|
-
client: TenxyteHttpClient,
|
|
34
|
-
storage: TenxyteStorage,
|
|
35
|
-
onSessionExpired: () => void
|
|
36
|
-
) {
|
|
37
|
-
let isRefreshing = false;
|
|
38
|
-
let refreshQueue: Array<(token: string | null) => void> = [];
|
|
39
|
-
|
|
40
|
-
const processQueue = (error: Error | null, token: string | null = null) => {
|
|
41
|
-
refreshQueue.forEach(prom => prom(token));
|
|
42
|
-
refreshQueue = [];
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
return async (response: Response, request: { url: string; config: RequestConfig }): Promise<Response> => {
|
|
46
|
-
// Only intercept 401s when not attempting to login/refresh itself
|
|
47
|
-
if (response.status === 401 && !request.url.includes('/auth/refresh') && !request.url.includes('/auth/login')) {
|
|
48
|
-
const refreshToken = await storage.getItem('tx_refresh');
|
|
49
|
-
|
|
50
|
-
if (!refreshToken) {
|
|
51
|
-
onSessionExpired();
|
|
52
|
-
return response; // Pass through 401 if we cannot refresh
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (isRefreshing) {
|
|
56
|
-
// Wait in queue for the refresh to complete
|
|
57
|
-
return new Promise<Response>((resolve) => {
|
|
58
|
-
refreshQueue.push((newToken: string | null) => {
|
|
59
|
-
if (newToken) {
|
|
60
|
-
const retryHeaders = { ...(request.config.headers as Record<string, string>), Authorization: `Bearer ${newToken}` };
|
|
61
|
-
resolve(fetch(request.url, { ...request.config, headers: retryHeaders } as RequestInit));
|
|
62
|
-
} else {
|
|
63
|
-
resolve(response);
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// We are the first one, initiate refresh
|
|
70
|
-
isRefreshing = true;
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
const refreshResponse = await fetch(`${client['baseUrl']}/auth/refresh/`, {
|
|
74
|
-
method: 'POST',
|
|
75
|
-
headers: { 'Content-Type': 'application/json' },
|
|
76
|
-
body: JSON.stringify({ refresh_token: refreshToken })
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
if (!refreshResponse.ok) {
|
|
80
|
-
throw new Error('Refresh failed');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const data = await refreshResponse.json();
|
|
84
|
-
|
|
85
|
-
await storage.setItem('tx_access', data.access);
|
|
86
|
-
if (data.refresh) {
|
|
87
|
-
await storage.setItem('tx_refresh', data.refresh);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
isRefreshing = false;
|
|
91
|
-
processQueue(null, data.access);
|
|
92
|
-
|
|
93
|
-
// Retry original request seamlessly for the caller that initiated this
|
|
94
|
-
const retryHeaders = { ...(request.config.headers as Record<string, string>), Authorization: `Bearer ${data.access}` };
|
|
95
|
-
// We use fetch directly to return a true Response object back to the chain,
|
|
96
|
-
// rather than using client.request which resolves the JSON.
|
|
97
|
-
// Wait, the interceptor must return a Promise<Response>!
|
|
98
|
-
const r = await fetch(request.url, { ...request.config, headers: retryHeaders } as RequestInit);
|
|
99
|
-
return r;
|
|
100
|
-
|
|
101
|
-
} catch (err) {
|
|
102
|
-
// Refresh failed (invalid token, expired, network error)
|
|
103
|
-
isRefreshing = false;
|
|
104
|
-
await storage.removeItem('tx_access');
|
|
105
|
-
await storage.removeItem('tx_refresh');
|
|
106
|
-
|
|
107
|
-
processQueue(err as Error, null);
|
|
108
|
-
onSessionExpired();
|
|
109
|
-
|
|
110
|
-
// Pass original 401 back
|
|
111
|
-
return response;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return response;
|
|
116
|
-
};
|
|
117
|
-
}
|
|
1
|
+
import type { TenxyteStorage } from '../storage';
|
|
2
|
+
import type { RequestConfig, TenxyteHttpClient } from './client';
|
|
3
|
+
|
|
4
|
+
export interface TenxyteContext {
|
|
5
|
+
activeOrgSlug: string | null;
|
|
6
|
+
agentTraceId: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createAuthInterceptor(storage: TenxyteStorage, context: TenxyteContext) {
|
|
10
|
+
return async (request: RequestConfig & { url: string }) => {
|
|
11
|
+
// Inject Authorization if present
|
|
12
|
+
const token = await storage.getItem('tx_access');
|
|
13
|
+
const headers = { ...(request.headers as Record<string, string>) || {} };
|
|
14
|
+
|
|
15
|
+
if (token && !headers['Authorization']) {
|
|
16
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Inject Contextual Headers based on SDK state
|
|
20
|
+
if (context.activeOrgSlug && !headers['X-Org-Slug']) {
|
|
21
|
+
headers['X-Org-Slug'] = context.activeOrgSlug;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (context.agentTraceId && !headers['X-Prompt-Trace-ID']) {
|
|
25
|
+
headers['X-Prompt-Trace-ID'] = context.agentTraceId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { ...request, headers };
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createRefreshInterceptor(
|
|
33
|
+
client: TenxyteHttpClient,
|
|
34
|
+
storage: TenxyteStorage,
|
|
35
|
+
onSessionExpired: () => void
|
|
36
|
+
) {
|
|
37
|
+
let isRefreshing = false;
|
|
38
|
+
let refreshQueue: Array<(token: string | null) => void> = [];
|
|
39
|
+
|
|
40
|
+
const processQueue = (error: Error | null, token: string | null = null) => {
|
|
41
|
+
refreshQueue.forEach(prom => prom(token));
|
|
42
|
+
refreshQueue = [];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return async (response: Response, request: { url: string; config: RequestConfig }): Promise<Response> => {
|
|
46
|
+
// Only intercept 401s when not attempting to login/refresh itself
|
|
47
|
+
if (response.status === 401 && !request.url.includes('/auth/refresh') && !request.url.includes('/auth/login')) {
|
|
48
|
+
const refreshToken = await storage.getItem('tx_refresh');
|
|
49
|
+
|
|
50
|
+
if (!refreshToken) {
|
|
51
|
+
onSessionExpired();
|
|
52
|
+
return response; // Pass through 401 if we cannot refresh
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isRefreshing) {
|
|
56
|
+
// Wait in queue for the refresh to complete
|
|
57
|
+
return new Promise<Response>((resolve) => {
|
|
58
|
+
refreshQueue.push((newToken: string | null) => {
|
|
59
|
+
if (newToken) {
|
|
60
|
+
const retryHeaders = { ...(request.config.headers as Record<string, string>), Authorization: `Bearer ${newToken}` };
|
|
61
|
+
resolve(fetch(request.url, { ...request.config, headers: retryHeaders } as RequestInit));
|
|
62
|
+
} else {
|
|
63
|
+
resolve(response);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// We are the first one, initiate refresh
|
|
70
|
+
isRefreshing = true;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const refreshResponse = await fetch(`${client['baseUrl']}/auth/refresh/`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({ refresh_token: refreshToken })
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!refreshResponse.ok) {
|
|
80
|
+
throw new Error('Refresh failed');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const data = await refreshResponse.json();
|
|
84
|
+
|
|
85
|
+
await storage.setItem('tx_access', data.access);
|
|
86
|
+
if (data.refresh) {
|
|
87
|
+
await storage.setItem('tx_refresh', data.refresh);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
isRefreshing = false;
|
|
91
|
+
processQueue(null, data.access);
|
|
92
|
+
|
|
93
|
+
// Retry original request seamlessly for the caller that initiated this
|
|
94
|
+
const retryHeaders = { ...(request.config.headers as Record<string, string>), Authorization: `Bearer ${data.access}` };
|
|
95
|
+
// We use fetch directly to return a true Response object back to the chain,
|
|
96
|
+
// rather than using client.request which resolves the JSON.
|
|
97
|
+
// Wait, the interceptor must return a Promise<Response>!
|
|
98
|
+
const r = await fetch(request.url, { ...request.config, headers: retryHeaders } as RequestInit);
|
|
99
|
+
return r;
|
|
100
|
+
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// Refresh failed (invalid token, expired, network error)
|
|
103
|
+
isRefreshing = false;
|
|
104
|
+
await storage.removeItem('tx_access');
|
|
105
|
+
await storage.removeItem('tx_refresh');
|
|
106
|
+
|
|
107
|
+
processQueue(err as Error, null);
|
|
108
|
+
onSessionExpired();
|
|
109
|
+
|
|
110
|
+
// Pass original 401 back
|
|
111
|
+
return response;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return response;
|
|
116
|
+
};
|
|
117
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export * from './client';
|
|
2
|
-
export * from './http/client';
|
|
3
|
-
export * from './modules/auth';
|
|
4
|
-
export * from './modules/security';
|
|
5
|
-
export * from './modules/rbac';
|
|
6
|
-
export * from './modules/user';
|
|
7
|
-
export * from './types';
|
|
1
|
+
export * from './client';
|
|
2
|
+
export * from './http/client';
|
|
3
|
+
export * from './modules/auth';
|
|
4
|
+
export * from './modules/security';
|
|
5
|
+
export * from './modules/rbac';
|
|
6
|
+
export * from './modules/user';
|
|
7
|
+
export * from './types';
|