@tenxyte/core 0.9.0 → 0.9.3
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/LICENSE +21 -0
- package/README.md +362 -102
- package/dist/index.cjs +966 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1073 -15
- package/dist/index.d.ts +1073 -15
- package/dist/index.js +946 -35
- package/dist/index.js.map +1 -1
- package/package.json +15 -3
- package/patched-schema.json +0 -11388
- package/src/client.ts +0 -50
- package/src/config.ts +0 -0
- package/src/http/client.ts +0 -162
- package/src/http/index.ts +0 -1
- package/src/http/interceptors.ts +0 -117
- package/src/index.ts +0 -7
- package/src/modules/ai.ts +0 -178
- package/src/modules/auth.ts +0 -116
- package/src/modules/b2b.ts +0 -177
- package/src/modules/rbac.ts +0 -207
- package/src/modules/security.ts +0 -313
- package/src/modules/user.ts +0 -95
- package/src/storage/cookie.ts +0 -39
- package/src/storage/index.ts +0 -29
- package/src/storage/localStorage.ts +0 -75
- package/src/storage/memory.ts +0 -30
- package/src/types/api-schema.d.ts +0 -6590
- package/src/types/index.ts +0 -152
- package/src/utils/base64url.ts +0 -25
- package/src/utils/device_info.ts +0 -94
- package/src/utils/events.ts +0 -71
- package/src/utils/jwt.ts +0 -51
- package/tests/http.test.ts +0 -144
- package/tests/modules/auth.test.ts +0 -93
- package/tests/modules/rbac.test.ts +0 -95
- package/tests/modules/security.test.ts +0 -85
- package/tests/modules/user.test.ts +0 -76
- package/tests/storage.test.ts +0 -96
- package/tests/utils.test.ts +0 -71
- package/tsconfig.json +0 -26
- package/tsup.config.ts +0 -10
- package/vitest.config.ts +0 -7
package/src/types/index.ts
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import type { components, paths } from './api-schema';
|
|
2
|
-
|
|
3
|
-
export type GeneratedSchema = components['schemas'];
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Core User Interface exposed by the SDK.
|
|
7
|
-
* Represents the authenticated entity bound to the active session.
|
|
8
|
-
*/
|
|
9
|
-
export interface TenxyteUser {
|
|
10
|
-
id: string; // UUID
|
|
11
|
-
email: string | null;
|
|
12
|
-
phone_country_code: string | null;
|
|
13
|
-
phone_number: string | null;
|
|
14
|
-
first_name: string;
|
|
15
|
-
last_name: string;
|
|
16
|
-
is_email_verified: boolean;
|
|
17
|
-
is_phone_verified: boolean;
|
|
18
|
-
is_2fa_enabled: boolean;
|
|
19
|
-
roles: string[]; // Role codes e.g., ['admin', 'viewer']
|
|
20
|
-
permissions: string[]; // Permission codes (direct + inherited)
|
|
21
|
-
created_at: string; // ISO 8601
|
|
22
|
-
last_login: string | null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Standard SDK Token Pair (internal structure normalized by interceptors).
|
|
27
|
-
* These are managed automatically if auto-refresh is enabled.
|
|
28
|
-
*/
|
|
29
|
-
export interface TokenPair {
|
|
30
|
-
access_token: string; // JWT Bearer
|
|
31
|
-
refresh_token: string;
|
|
32
|
-
token_type: 'Bearer';
|
|
33
|
-
expires_in: number; // Current access_token lifetime in seconds
|
|
34
|
-
device_summary: string | null; // e.g., "desktop — windows 11 — chrome 122" (null if device_info absent)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Standardized API Error Response wrapper thrown by network interceptors.
|
|
39
|
-
*/
|
|
40
|
-
export interface TenxyteError {
|
|
41
|
-
error: string; // Human message
|
|
42
|
-
code: TenxyteErrorCode; // Machine identifier
|
|
43
|
-
details?: Record<string, string[]> | string; // Per-field errors or free message
|
|
44
|
-
retry_after?: number; // Present on HTTP 429 and 423
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export type TenxyteErrorCode =
|
|
48
|
-
// Auth
|
|
49
|
-
| 'LOGIN_FAILED'
|
|
50
|
-
| 'INVALID_CREDENTIALS'
|
|
51
|
-
| 'ACCOUNT_LOCKED'
|
|
52
|
-
| 'ACCOUNT_BANNED'
|
|
53
|
-
| '2FA_REQUIRED'
|
|
54
|
-
| 'ADMIN_2FA_SETUP_REQUIRED'
|
|
55
|
-
| 'TOKEN_EXPIRED'
|
|
56
|
-
| 'TOKEN_BLACKLISTED'
|
|
57
|
-
| 'REFRESH_FAILED'
|
|
58
|
-
| 'PERMISSION_DENIED'
|
|
59
|
-
| 'SESSION_LIMIT_EXCEEDED'
|
|
60
|
-
| 'DEVICE_LIMIT_EXCEEDED'
|
|
61
|
-
| 'RATE_LIMITED'
|
|
62
|
-
| 'INVALID_OTP'
|
|
63
|
-
| 'OTP_EXPIRED'
|
|
64
|
-
| 'INVALID_PROVIDER'
|
|
65
|
-
| 'SOCIAL_AUTH_FAILED'
|
|
66
|
-
| 'VALIDATION_URL_REQUIRED'
|
|
67
|
-
| 'INVALID_TOKEN'
|
|
68
|
-
// User / Account
|
|
69
|
-
| 'CONFIRMATION_REQUIRED'
|
|
70
|
-
| 'PASSWORD_REQUIRED'
|
|
71
|
-
| 'INVALID_PASSWORD'
|
|
72
|
-
| 'INVALID_DEVICE_INFO'
|
|
73
|
-
// B2B / Organizations
|
|
74
|
-
| 'ORG_NOT_FOUND'
|
|
75
|
-
| 'NOT_ORG_MEMBER'
|
|
76
|
-
| 'NOT_OWNER'
|
|
77
|
-
| 'ALREADY_MEMBER'
|
|
78
|
-
| 'MEMBER_LIMIT_EXCEEDED'
|
|
79
|
-
| 'HAS_CHILDREN'
|
|
80
|
-
| 'CIRCULAR_HIERARCHY'
|
|
81
|
-
| 'LAST_OWNER_REQUIRED'
|
|
82
|
-
| 'INVITATION_EXISTS'
|
|
83
|
-
| 'INVALID_ROLE'
|
|
84
|
-
// AIRS — Agent errors
|
|
85
|
-
| 'AGENT_NOT_FOUND'
|
|
86
|
-
| 'AGENT_SUSPENDED'
|
|
87
|
-
| 'AGENT_REVOKED'
|
|
88
|
-
| 'AGENT_EXPIRED'
|
|
89
|
-
| 'BUDGET_EXCEEDED'
|
|
90
|
-
| 'RATE_LIMIT_EXCEEDED'
|
|
91
|
-
| 'HEARTBEAT_MISSING'
|
|
92
|
-
| 'AIRS_DISABLED';
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Organization Structure defining a B2B tenant or hierarchical unit.
|
|
96
|
-
*/
|
|
97
|
-
export interface Organization {
|
|
98
|
-
id: number;
|
|
99
|
-
name: string;
|
|
100
|
-
slug: string;
|
|
101
|
-
description: string | null;
|
|
102
|
-
metadata: Record<string, unknown> | null;
|
|
103
|
-
is_active: boolean;
|
|
104
|
-
max_members: number; // 0 = unlimited
|
|
105
|
-
member_count: number;
|
|
106
|
-
created_at: string;
|
|
107
|
-
updated_at: string;
|
|
108
|
-
parent: { id: number; name: string; slug: string } | null;
|
|
109
|
-
children: Array<{ id: number; name: string; slug: string }>;
|
|
110
|
-
user_role: string | null; // Current user's contextual role inside this exact org
|
|
111
|
-
user_permissions: string[]; // Effective permissions resolving downward in this org
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Base Pagination Response wrapper
|
|
116
|
-
*/
|
|
117
|
-
export interface PaginatedResponse<T> {
|
|
118
|
-
count: number;
|
|
119
|
-
page: number;
|
|
120
|
-
page_size: number;
|
|
121
|
-
total_pages: number;
|
|
122
|
-
next: string | null;
|
|
123
|
-
previous: string | null;
|
|
124
|
-
results: T[];
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* AIRS Agent Token metadata
|
|
129
|
-
*/
|
|
130
|
-
export interface AgentTokenSummary {
|
|
131
|
-
id: number;
|
|
132
|
-
agent_id: string;
|
|
133
|
-
status: 'ACTIVE' | 'SUSPENDED' | 'REVOKED' | 'EXPIRED';
|
|
134
|
-
expires_at: string;
|
|
135
|
-
created_at: string;
|
|
136
|
-
organization: string | null; // Org slug, or null
|
|
137
|
-
current_request_count: number;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Request awaiting Human-In-The-Loop approval
|
|
142
|
-
*/
|
|
143
|
-
export interface AgentPendingAction {
|
|
144
|
-
id: number;
|
|
145
|
-
agent_id: string;
|
|
146
|
-
permission: string; // e.g., "users.delete"
|
|
147
|
-
endpoint: string;
|
|
148
|
-
payload: unknown;
|
|
149
|
-
confirmation_token: string;
|
|
150
|
-
expires_at: string;
|
|
151
|
-
created_at: string;
|
|
152
|
-
}
|
package/src/utils/base64url.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
export function bufferToBase64url(buffer: ArrayBuffer | Uint8Array): string {
|
|
2
|
-
const bytes = new Uint8Array(buffer);
|
|
3
|
-
let binary = '';
|
|
4
|
-
for (let i = 0; i < bytes.byteLength; i++) {
|
|
5
|
-
binary += String.fromCharCode(bytes[i]);
|
|
6
|
-
}
|
|
7
|
-
return btoa(binary)
|
|
8
|
-
.replace(/\+/g, '-')
|
|
9
|
-
.replace(/\//g, '_')
|
|
10
|
-
.replace(/=/g, '');
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function base64urlToBuffer(base64url: string): Uint8Array {
|
|
14
|
-
const base64 = base64url
|
|
15
|
-
.replace(/-/g, '+')
|
|
16
|
-
.replace(/_/g, '/');
|
|
17
|
-
const padLen = (4 - (base64.length % 4)) % 4;
|
|
18
|
-
const padded = base64 + '='.repeat(padLen);
|
|
19
|
-
const binary = atob(padded);
|
|
20
|
-
const bytes = new Uint8Array(binary.length);
|
|
21
|
-
for (let i = 0; i < binary.length; i++) {
|
|
22
|
-
bytes[i] = binary.charCodeAt(i);
|
|
23
|
-
}
|
|
24
|
-
return bytes;
|
|
25
|
-
}
|
package/src/utils/device_info.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Helper utility to build the device fingerprint required by Tenxyte security features.
|
|
3
|
-
* Format: `v=1|os=windows;osv=11|device=desktop|arch=x64|app=tenxyte;appv=1.0.0|runtime=chrome;rtv=122|tz=Europe/Paris`
|
|
4
|
-
*/
|
|
5
|
-
export interface CustomDeviceInfo {
|
|
6
|
-
os?: string;
|
|
7
|
-
osVersion?: string;
|
|
8
|
-
device?: string;
|
|
9
|
-
arch?: string;
|
|
10
|
-
app?: string;
|
|
11
|
-
appVersion?: string;
|
|
12
|
-
runtime?: string;
|
|
13
|
-
runtimeVersion?: string;
|
|
14
|
-
timezone?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function buildDeviceInfo(customInfo: CustomDeviceInfo = {}): string {
|
|
18
|
-
// Try to determine automatically from navigator
|
|
19
|
-
const autoInfo = getAutoInfo();
|
|
20
|
-
|
|
21
|
-
const v = '1';
|
|
22
|
-
const os = customInfo.os || autoInfo.os;
|
|
23
|
-
const osv = customInfo.osVersion || autoInfo.osVersion;
|
|
24
|
-
const device = customInfo.device || autoInfo.device;
|
|
25
|
-
const arch = customInfo.arch || autoInfo.arch;
|
|
26
|
-
const app = customInfo.app || autoInfo.app;
|
|
27
|
-
const appv = customInfo.appVersion || autoInfo.appVersion;
|
|
28
|
-
const runtime = customInfo.runtime || autoInfo.runtime;
|
|
29
|
-
const rtv = customInfo.runtimeVersion || autoInfo.runtimeVersion;
|
|
30
|
-
const tz = customInfo.timezone || autoInfo.timezone;
|
|
31
|
-
|
|
32
|
-
const parts = [
|
|
33
|
-
`v=${v}`,
|
|
34
|
-
`os=${os}` + (osv ? `;osv=${osv}` : ''),
|
|
35
|
-
`device=${device}`,
|
|
36
|
-
arch ? `arch=${arch}` : '',
|
|
37
|
-
app ? `app=${app}${appv ? `;appv=${appv}` : ''}` : '',
|
|
38
|
-
`runtime=${runtime}` + (rtv ? `;rtv=${rtv}` : ''),
|
|
39
|
-
tz ? `tz=${tz}` : ''
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
return parts.filter(Boolean).join('|');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function getAutoInfo() {
|
|
46
|
-
const info = {
|
|
47
|
-
os: 'unknown',
|
|
48
|
-
osVersion: '',
|
|
49
|
-
device: 'desktop', // default
|
|
50
|
-
arch: '',
|
|
51
|
-
app: 'sdk',
|
|
52
|
-
appVersion: '0.1.0',
|
|
53
|
-
runtime: 'unknown',
|
|
54
|
-
runtimeVersion: '',
|
|
55
|
-
timezone: ''
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
if (typeof Intl !== 'undefined') {
|
|
60
|
-
info.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (typeof process !== 'undefined' && process.version) {
|
|
64
|
-
info.runtime = 'node';
|
|
65
|
-
info.runtimeVersion = process.version;
|
|
66
|
-
info.os = process.platform;
|
|
67
|
-
info.arch = process.arch;
|
|
68
|
-
info.device = 'server';
|
|
69
|
-
} else if (typeof window !== 'undefined' && window.navigator) {
|
|
70
|
-
const ua = window.navigator.userAgent.toLowerCase();
|
|
71
|
-
|
|
72
|
-
// Basic OS detection
|
|
73
|
-
if (ua.includes('windows')) info.os = 'windows';
|
|
74
|
-
else if (ua.includes('mac')) info.os = 'macos';
|
|
75
|
-
else if (ua.includes('linux')) info.os = 'linux';
|
|
76
|
-
else if (ua.includes('android')) info.os = 'android';
|
|
77
|
-
else if (ua.includes('ios') || ua.includes('iphone') || ua.includes('ipad')) info.os = 'ios';
|
|
78
|
-
|
|
79
|
-
// Basic Device Type
|
|
80
|
-
if (/mobi|android|touch|mini/i.test(ua)) info.device = 'mobile';
|
|
81
|
-
if (/tablet|ipad/i.test(ua)) info.device = 'tablet';
|
|
82
|
-
|
|
83
|
-
// Basic Runtime (Browser)
|
|
84
|
-
if (ua.includes('firefox')) info.runtime = 'firefox';
|
|
85
|
-
else if (ua.includes('edg/')) info.runtime = 'edge';
|
|
86
|
-
else if (ua.includes('chrome')) info.runtime = 'chrome';
|
|
87
|
-
else if (ua.includes('safari')) info.runtime = 'safari';
|
|
88
|
-
}
|
|
89
|
-
} catch (e) {
|
|
90
|
-
// Ignore context extraction errors
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return info;
|
|
94
|
-
}
|
package/src/utils/events.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight EventEmitter for TenxyteClient.
|
|
3
|
-
* Provides `.on`, `.once`, `.off`, and `.emit`.
|
|
4
|
-
*/
|
|
5
|
-
export class EventEmitter<Events extends Record<string, any>> {
|
|
6
|
-
private events: Map<keyof Events, Array<Function>>;
|
|
7
|
-
|
|
8
|
-
constructor() {
|
|
9
|
-
this.events = new Map();
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Subscribe to an event.
|
|
14
|
-
* @param event The event name
|
|
15
|
-
* @param callback The callback function
|
|
16
|
-
* @returns Unsubscribe function
|
|
17
|
-
*/
|
|
18
|
-
on<K extends keyof Events>(event: K, callback: (payload: Events[K]) => void): () => void {
|
|
19
|
-
if (!this.events.has(event)) {
|
|
20
|
-
this.events.set(event, []);
|
|
21
|
-
}
|
|
22
|
-
this.events.get(event)!.push(callback);
|
|
23
|
-
return () => this.off(event, callback);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Unsubscribe from an event.
|
|
28
|
-
* @param event The event name
|
|
29
|
-
* @param callback The exact callback function that was passed to .on()
|
|
30
|
-
*/
|
|
31
|
-
off<K extends keyof Events>(event: K, callback: (payload: Events[K]) => void): void {
|
|
32
|
-
const callbacks = this.events.get(event);
|
|
33
|
-
if (!callbacks) return;
|
|
34
|
-
const index = callbacks.indexOf(callback);
|
|
35
|
-
if (index !== -1) {
|
|
36
|
-
callbacks.splice(index, 1);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Subscribe to an event exactly once.
|
|
42
|
-
*/
|
|
43
|
-
once<K extends keyof Events>(event: K, callback: (payload: Events[K]) => void): () => void {
|
|
44
|
-
const wrapped = (payload: Events[K]) => {
|
|
45
|
-
this.off(event, wrapped);
|
|
46
|
-
callback(payload);
|
|
47
|
-
};
|
|
48
|
-
return this.on(event, wrapped);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Emit an event internally.
|
|
53
|
-
*/
|
|
54
|
-
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
|
|
55
|
-
const callbacks = this.events.get(event);
|
|
56
|
-
if (!callbacks) return;
|
|
57
|
-
// Copy array to prevent mutation issues during emission
|
|
58
|
-
const copy = [...callbacks];
|
|
59
|
-
for (const callback of copy) {
|
|
60
|
-
try {
|
|
61
|
-
callback(payload);
|
|
62
|
-
} catch (err) {
|
|
63
|
-
console.error(`[Tenxyte EventEmitter] Error executing callback for event ${String(event)}`, err);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
removeAllListeners(): void {
|
|
69
|
-
this.events.clear();
|
|
70
|
-
}
|
|
71
|
-
}
|
package/src/utils/jwt.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
export interface DecodedTenxyteToken {
|
|
2
|
-
exp?: number;
|
|
3
|
-
iat?: number;
|
|
4
|
-
sub?: string;
|
|
5
|
-
roles?: string[];
|
|
6
|
-
permissions?: string[];
|
|
7
|
-
[key: string]: any;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Decodes the payload of a JWT without verifying the signature.
|
|
12
|
-
* Suitable for client-side routing and UI state.
|
|
13
|
-
*/
|
|
14
|
-
export function decodeJwt(token: string): DecodedTenxyteToken | null {
|
|
15
|
-
try {
|
|
16
|
-
const parts = token.split('.');
|
|
17
|
-
if (parts.length !== 3) {
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
let base64Url = parts[1];
|
|
22
|
-
if (!base64Url) return null;
|
|
23
|
-
|
|
24
|
-
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
25
|
-
|
|
26
|
-
// Pad with standard base64 padding
|
|
27
|
-
while (base64.length % 4) {
|
|
28
|
-
base64 += '=';
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const isBrowser = typeof window !== 'undefined' && typeof window.atob === 'function';
|
|
32
|
-
let jsonPayload: string;
|
|
33
|
-
|
|
34
|
-
if (isBrowser) {
|
|
35
|
-
// Browser decode
|
|
36
|
-
jsonPayload = decodeURIComponent(
|
|
37
|
-
window.atob(base64)
|
|
38
|
-
.split('')
|
|
39
|
-
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
|
40
|
-
.join('')
|
|
41
|
-
);
|
|
42
|
-
} else {
|
|
43
|
-
// Node.js decode
|
|
44
|
-
jsonPayload = Buffer.from(base64, 'base64').toString('utf8');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return JSON.parse(jsonPayload);
|
|
48
|
-
} catch (e) {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}
|
package/tests/http.test.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { TenxyteHttpClient } from '../src/http/client';
|
|
3
|
-
import { MemoryStorage } from '../src/storage';
|
|
4
|
-
import { createAuthInterceptor, createRefreshInterceptor } from '../src/http/interceptors';
|
|
5
|
-
|
|
6
|
-
// Mock global fetch
|
|
7
|
-
const mockFetch = vi.fn();
|
|
8
|
-
global.fetch = mockFetch;
|
|
9
|
-
|
|
10
|
-
describe('TenxyteHttpClient', () => {
|
|
11
|
-
let client: TenxyteHttpClient;
|
|
12
|
-
let storage: MemoryStorage;
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
mockFetch.mockReset();
|
|
16
|
-
storage = new MemoryStorage();
|
|
17
|
-
client = new TenxyteHttpClient({ baseUrl: 'https://api.tenxyte.com/v1' });
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
describe('Core HTTP', () => {
|
|
21
|
-
it('should format URL correctly and apply default headers', async () => {
|
|
22
|
-
mockFetch.mockResolvedValueOnce({
|
|
23
|
-
ok: true,
|
|
24
|
-
status: 200,
|
|
25
|
-
headers: new Headers({ 'content-type': 'application/json' }),
|
|
26
|
-
json: () => Promise.resolve({ data: 'ok' })
|
|
27
|
-
} as any);
|
|
28
|
-
|
|
29
|
-
const res = await client.get<{ data: string }>('/users', { params: { limit: 10 } });
|
|
30
|
-
|
|
31
|
-
expect(res.data).toBe('ok');
|
|
32
|
-
expect(mockFetch).toHaveBeenCalledWith('https://api.tenxyte.com/v1/users?limit=10', expect.objectContaining({
|
|
33
|
-
method: 'GET',
|
|
34
|
-
headers: expect.objectContaining({
|
|
35
|
-
'Content-Type': 'application/json',
|
|
36
|
-
'Accept': 'application/json'
|
|
37
|
-
})
|
|
38
|
-
}));
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('should throw normalized TenxyteError on failure', async () => {
|
|
42
|
-
mockFetch.mockResolvedValueOnce({
|
|
43
|
-
ok: false,
|
|
44
|
-
status: 400,
|
|
45
|
-
headers: new Headers({ 'content-type': 'application/json' }),
|
|
46
|
-
json: () => Promise.resolve({ error: 'Bad data', code: 'INVALID_CREDENTIALS', details: { email: ['invalid'] } })
|
|
47
|
-
} as any);
|
|
48
|
-
|
|
49
|
-
await expect(client.post('/auth/login', { bad: true })).rejects.toMatchObject({
|
|
50
|
-
error: 'Bad data',
|
|
51
|
-
code: 'INVALID_CREDENTIALS',
|
|
52
|
-
details: { email: ['invalid'] }
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('should handle 204 No Content correctly without parsing JSON', async () => {
|
|
57
|
-
mockFetch.mockResolvedValueOnce({
|
|
58
|
-
ok: true,
|
|
59
|
-
status: 204,
|
|
60
|
-
headers: new Headers()
|
|
61
|
-
} as any);
|
|
62
|
-
|
|
63
|
-
const res = await client.delete('/users/1');
|
|
64
|
-
expect(res).toEqual({});
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe('Interceptors', () => {
|
|
69
|
-
it('should inject Authorization and Context headers', async () => {
|
|
70
|
-
storage.setItem('tx_access', 'jwt.token.123');
|
|
71
|
-
const authInterceptor = createAuthInterceptor(storage, { activeOrgSlug: 'tenxyte-labs', agentTraceId: 'trc_999' });
|
|
72
|
-
client.addRequestInterceptor(authInterceptor);
|
|
73
|
-
|
|
74
|
-
mockFetch.mockResolvedValueOnce({
|
|
75
|
-
ok: true,
|
|
76
|
-
status: 200,
|
|
77
|
-
headers: new Headers({ 'content-type': 'application/json' }),
|
|
78
|
-
json: () => Promise.resolve({ ok: true })
|
|
79
|
-
} as any);
|
|
80
|
-
|
|
81
|
-
await client.get('/me');
|
|
82
|
-
expect(mockFetch).toHaveBeenCalledWith('https://api.tenxyte.com/v1/me', expect.objectContaining({
|
|
83
|
-
headers: expect.objectContaining({
|
|
84
|
-
Authorization: 'Bearer jwt.token.123',
|
|
85
|
-
'X-Org-Slug': 'tenxyte-labs',
|
|
86
|
-
'X-Prompt-Trace-ID': 'trc_999'
|
|
87
|
-
})
|
|
88
|
-
}));
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('should seamlessly refresh token on 401', async () => {
|
|
92
|
-
storage.setItem('tx_access', 'expired.token');
|
|
93
|
-
storage.setItem('tx_refresh', 'valid.refresh.token');
|
|
94
|
-
|
|
95
|
-
const onSessionExpired = vi.fn();
|
|
96
|
-
const authInterceptor = createAuthInterceptor(storage, { activeOrgSlug: null, agentTraceId: null });
|
|
97
|
-
const refreshInterceptor = createRefreshInterceptor(client, storage, onSessionExpired);
|
|
98
|
-
|
|
99
|
-
client.addRequestInterceptor(authInterceptor);
|
|
100
|
-
client.addResponseInterceptor(refreshInterceptor);
|
|
101
|
-
|
|
102
|
-
// 1. Initial request gets 401
|
|
103
|
-
// 2. Interceptor triggers refresh (/auth/refresh/) returning 200 with new token
|
|
104
|
-
// 3. Interceptor retries initial request with new token, returning 200
|
|
105
|
-
|
|
106
|
-
mockFetch
|
|
107
|
-
// First fail call '/me' with 401
|
|
108
|
-
.mockResolvedValueOnce({
|
|
109
|
-
ok: false,
|
|
110
|
-
status: 401,
|
|
111
|
-
headers: new Headers(),
|
|
112
|
-
json: () => Promise.resolve({ error: 'Token expired', code: 'TOKEN_EXPIRED' })
|
|
113
|
-
} as any)
|
|
114
|
-
// Refresh call '/auth/refresh/' succeeds
|
|
115
|
-
.mockResolvedValueOnce({
|
|
116
|
-
ok: true,
|
|
117
|
-
status: 200,
|
|
118
|
-
headers: new Headers({ 'content-type': 'application/json' }),
|
|
119
|
-
json: () => Promise.resolve({ access: 'new.access', refresh: 'new.refresh' })
|
|
120
|
-
} as any)
|
|
121
|
-
// Retry call '/me' succeeds
|
|
122
|
-
.mockResolvedValueOnce({
|
|
123
|
-
ok: true,
|
|
124
|
-
status: 200,
|
|
125
|
-
headers: new Headers({ 'content-type': 'application/json' }),
|
|
126
|
-
json: () => Promise.resolve({ id: 1, name: 'Bob' })
|
|
127
|
-
} as any);
|
|
128
|
-
|
|
129
|
-
const res = await client.get('/me');
|
|
130
|
-
|
|
131
|
-
expect(res).toEqual({ id: 1, name: 'Bob' });
|
|
132
|
-
expect(storage.getItem('tx_access')).toBe('new.access');
|
|
133
|
-
expect(storage.getItem('tx_refresh')).toBe('new.refresh');
|
|
134
|
-
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
135
|
-
|
|
136
|
-
// Verify the retry request had the NEW token!
|
|
137
|
-
expect(mockFetch).toHaveBeenNthCalledWith(3, 'https://api.tenxyte.com/v1/me', expect.objectContaining({
|
|
138
|
-
headers: expect.objectContaining({
|
|
139
|
-
Authorization: 'Bearer new.access'
|
|
140
|
-
})
|
|
141
|
-
}));
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
});
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { AuthModule } from '../../src/modules/auth';
|
|
3
|
-
import { TenxyteHttpClient } from '../../src/http/client';
|
|
4
|
-
|
|
5
|
-
describe('AuthModule', () => {
|
|
6
|
-
let client: TenxyteHttpClient;
|
|
7
|
-
let auth: AuthModule;
|
|
8
|
-
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
client = new TenxyteHttpClient({ baseUrl: 'http://localhost:8000' });
|
|
11
|
-
auth = new AuthModule(client);
|
|
12
|
-
|
|
13
|
-
// Mock the underlying request method
|
|
14
|
-
vi.spyOn(client, 'request').mockImplementation(async () => {
|
|
15
|
-
return {};
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('loginWithEmail should POST to /api/v1/auth/login/email/', async () => {
|
|
20
|
-
const mockResponse = { access_token: 'acc', refresh_token: 'ref', token_type: 'Bearer', expires_in: 3600, device_summary: null };
|
|
21
|
-
vi.mocked(client.request).mockResolvedValueOnce(mockResponse);
|
|
22
|
-
|
|
23
|
-
const data = { email: 'test@example.com', password: 'password123' };
|
|
24
|
-
const result = await auth.loginWithEmail(data);
|
|
25
|
-
|
|
26
|
-
expect(client.request).toHaveBeenCalledWith('/api/v1/auth/login/email/', {
|
|
27
|
-
method: 'POST',
|
|
28
|
-
body: data,
|
|
29
|
-
});
|
|
30
|
-
expect(result).toEqual(mockResponse);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('logout should POST to /api/v1/auth/logout/ with refresh_token', async () => {
|
|
34
|
-
vi.mocked(client.request).mockResolvedValueOnce(undefined);
|
|
35
|
-
|
|
36
|
-
await auth.logout('some_refresh_token');
|
|
37
|
-
|
|
38
|
-
expect(client.request).toHaveBeenCalledWith('/api/v1/auth/logout/', {
|
|
39
|
-
method: 'POST',
|
|
40
|
-
body: { refresh_token: 'some_refresh_token' },
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('requestMagicLink should POST to /api/v1/auth/magic-link/request/', async () => {
|
|
45
|
-
vi.mocked(client.request).mockResolvedValueOnce(undefined);
|
|
46
|
-
|
|
47
|
-
const data = { email: 'magic@example.com' };
|
|
48
|
-
await auth.requestMagicLink(data);
|
|
49
|
-
|
|
50
|
-
expect(client.request).toHaveBeenCalledWith('/api/v1/auth/magic-link/request/', {
|
|
51
|
-
method: 'POST',
|
|
52
|
-
body: data,
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('verifyMagicLink should GET from /api/v1/auth/magic-link/verify/ with token in query', async () => {
|
|
57
|
-
const mockResponse = { access_token: 'acc' };
|
|
58
|
-
vi.mocked(client.request).mockResolvedValueOnce(mockResponse);
|
|
59
|
-
|
|
60
|
-
const result = await auth.verifyMagicLink('magic_token_123');
|
|
61
|
-
|
|
62
|
-
expect(client.request).toHaveBeenCalledWith('/api/v1/auth/magic-link/verify/', {
|
|
63
|
-
method: 'GET',
|
|
64
|
-
params: { token: 'magic_token_123' },
|
|
65
|
-
});
|
|
66
|
-
expect(result).toEqual(mockResponse);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('loginWithSocial should POST to /api/v1/auth/social/:provider/', async () => {
|
|
70
|
-
const mockResponse = { access_token: 'acc' };
|
|
71
|
-
vi.mocked(client.request).mockResolvedValueOnce(mockResponse);
|
|
72
|
-
|
|
73
|
-
const data = { access_token: 'google_token' };
|
|
74
|
-
const result = await auth.loginWithSocial('google', data);
|
|
75
|
-
|
|
76
|
-
expect(client.request).toHaveBeenCalledWith('/api/v1/auth/social/google/', {
|
|
77
|
-
method: 'POST',
|
|
78
|
-
body: data,
|
|
79
|
-
});
|
|
80
|
-
expect(result).toEqual(mockResponse);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('handleSocialCallback should GET from callback endpoint with correct params', async () => {
|
|
84
|
-
vi.mocked(client.request).mockResolvedValueOnce({ access_token: 'acc' });
|
|
85
|
-
|
|
86
|
-
await auth.handleSocialCallback('github', 'auth_code', 'http://localhost/callback');
|
|
87
|
-
|
|
88
|
-
expect(client.request).toHaveBeenCalledWith('/api/v1/auth/social/github/callback/', {
|
|
89
|
-
method: 'GET',
|
|
90
|
-
params: { code: 'auth_code', redirect_uri: 'http://localhost/callback' },
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
});
|