@nurama/sdk 0.0.0-stage → 1.4.1
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 +202 -0
- package/NOTICE +5 -0
- package/README.md +1080 -2
- package/dist/BotClient.d.ts +66 -0
- package/dist/BotClient.d.ts.map +1 -0
- package/dist/BotClient.js +68 -0
- package/dist/BotClient.js.map +1 -0
- package/dist/NuramaClient.d.ts +480 -0
- package/dist/NuramaClient.d.ts.map +1 -0
- package/dist/NuramaClient.js +902 -0
- package/dist/NuramaClient.js.map +1 -0
- package/dist/browser/nurama-bot-sdk.js +12051 -0
- package/dist/browser/nurama-bot-sdk.min.js +1 -0
- package/dist/browser/nurama-sdk.js +12003 -0
- package/dist/browser/nurama-sdk.min.js +1 -0
- package/dist/routes/ai.d.ts +280 -0
- package/dist/routes/ai.d.ts.map +1 -0
- package/dist/routes/ai.js +173 -0
- package/dist/routes/ai.js.map +1 -0
- package/dist/routes/asset.d.ts +493 -0
- package/dist/routes/asset.d.ts.map +1 -0
- package/dist/routes/asset.js +848 -0
- package/dist/routes/asset.js.map +1 -0
- package/dist/routes/auth.d.ts +218 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +454 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/blogPosts.d.ts +17 -0
- package/dist/routes/blogPosts.d.ts.map +1 -0
- package/dist/routes/blogPosts.js +29 -0
- package/dist/routes/blogPosts.js.map +1 -0
- package/dist/routes/board.d.ts +187 -0
- package/dist/routes/board.d.ts.map +1 -0
- package/dist/routes/board.js +270 -0
- package/dist/routes/board.js.map +1 -0
- package/dist/routes/bot.d.ts +202 -0
- package/dist/routes/bot.d.ts.map +1 -0
- package/dist/routes/bot.js +229 -0
- package/dist/routes/bot.js.map +1 -0
- package/dist/routes/chat.d.ts +842 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +863 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/chatAi.d.ts +51 -0
- package/dist/routes/chatAi.d.ts.map +1 -0
- package/dist/routes/chatAi.js +109 -0
- package/dist/routes/chatAi.js.map +1 -0
- package/dist/routes/config.d.ts +11 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +24 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/convo.d.ts +169 -0
- package/dist/routes/convo.d.ts.map +1 -0
- package/dist/routes/convo.js +284 -0
- package/dist/routes/convo.js.map +1 -0
- package/dist/routes/credits.d.ts +82 -0
- package/dist/routes/credits.d.ts.map +1 -0
- package/dist/routes/credits.js +49 -0
- package/dist/routes/credits.js.map +1 -0
- package/dist/routes/device.d.ts +74 -0
- package/dist/routes/device.d.ts.map +1 -0
- package/dist/routes/device.js +122 -0
- package/dist/routes/device.js.map +1 -0
- package/dist/routes/folder.d.ts +75 -0
- package/dist/routes/folder.d.ts.map +1 -0
- package/dist/routes/folder.js +99 -0
- package/dist/routes/folder.js.map +1 -0
- package/dist/routes/invite.d.ts +61 -0
- package/dist/routes/invite.d.ts.map +1 -0
- package/dist/routes/invite.js +86 -0
- package/dist/routes/invite.js.map +1 -0
- package/dist/routes/joinLink.d.ts +88 -0
- package/dist/routes/joinLink.d.ts.map +1 -0
- package/dist/routes/joinLink.js +205 -0
- package/dist/routes/joinLink.js.map +1 -0
- package/dist/routes/membership.d.ts +116 -0
- package/dist/routes/membership.d.ts.map +1 -0
- package/dist/routes/membership.js +183 -0
- package/dist/routes/membership.js.map +1 -0
- package/dist/routes/notification.d.ts +103 -0
- package/dist/routes/notification.d.ts.map +1 -0
- package/dist/routes/notification.js +89 -0
- package/dist/routes/notification.js.map +1 -0
- package/dist/routes/oauthGrant.d.ts +45 -0
- package/dist/routes/oauthGrant.d.ts.map +1 -0
- package/dist/routes/oauthGrant.js +32 -0
- package/dist/routes/oauthGrant.js.map +1 -0
- package/dist/routes/payment.d.ts +56 -0
- package/dist/routes/payment.d.ts.map +1 -0
- package/dist/routes/payment.js +78 -0
- package/dist/routes/payment.js.map +1 -0
- package/dist/routes/product.d.ts +43 -0
- package/dist/routes/product.d.ts.map +1 -0
- package/dist/routes/product.js +53 -0
- package/dist/routes/product.js.map +1 -0
- package/dist/routes/project.d.ts +821 -0
- package/dist/routes/project.d.ts.map +1 -0
- package/dist/routes/project.js +1153 -0
- package/dist/routes/project.js.map +1 -0
- package/dist/routes/public.d.ts +269 -0
- package/dist/routes/public.d.ts.map +1 -0
- package/dist/routes/public.js +412 -0
- package/dist/routes/public.js.map +1 -0
- package/dist/routes/scratch.d.ts +70 -0
- package/dist/routes/scratch.d.ts.map +1 -0
- package/dist/routes/scratch.js +67 -0
- package/dist/routes/scratch.js.map +1 -0
- package/dist/routes/settings.d.ts +102 -0
- package/dist/routes/settings.d.ts.map +1 -0
- package/dist/routes/settings.js +94 -0
- package/dist/routes/settings.js.map +1 -0
- package/dist/routes/shortlink.d.ts +79 -0
- package/dist/routes/shortlink.d.ts.map +1 -0
- package/dist/routes/shortlink.js +25 -0
- package/dist/routes/shortlink.js.map +1 -0
- package/dist/routes/socket.d.ts +108 -0
- package/dist/routes/socket.d.ts.map +1 -0
- package/dist/routes/socket.js +573 -0
- package/dist/routes/socket.js.map +1 -0
- package/dist/routes/storage.d.ts +44 -0
- package/dist/routes/storage.d.ts.map +1 -0
- package/dist/routes/storage.js +49 -0
- package/dist/routes/storage.js.map +1 -0
- package/dist/routes/subscription.d.ts +184 -0
- package/dist/routes/subscription.d.ts.map +1 -0
- package/dist/routes/subscription.js +219 -0
- package/dist/routes/subscription.js.map +1 -0
- package/dist/routes/supportChat.d.ts +40 -0
- package/dist/routes/supportChat.d.ts.map +1 -0
- package/dist/routes/supportChat.js +53 -0
- package/dist/routes/supportChat.js.map +1 -0
- package/dist/routes/supportTicket.d.ts +89 -0
- package/dist/routes/supportTicket.d.ts.map +1 -0
- package/dist/routes/supportTicket.js +54 -0
- package/dist/routes/supportTicket.js.map +1 -0
- package/dist/routes/tag.d.ts +72 -0
- package/dist/routes/tag.d.ts.map +1 -0
- package/dist/routes/tag.js +81 -0
- package/dist/routes/tag.js.map +1 -0
- package/dist/routes/task.d.ts +252 -0
- package/dist/routes/task.d.ts.map +1 -0
- package/dist/routes/task.js +284 -0
- package/dist/routes/task.js.map +1 -0
- package/dist/routes/taskRelation.d.ts +80 -0
- package/dist/routes/taskRelation.d.ts.map +1 -0
- package/dist/routes/taskRelation.js +71 -0
- package/dist/routes/taskRelation.js.map +1 -0
- package/dist/routes/token.d.ts +97 -0
- package/dist/routes/token.d.ts.map +1 -0
- package/dist/routes/token.js +73 -0
- package/dist/routes/token.js.map +1 -0
- package/dist/routes/user.d.ts +112 -0
- package/dist/routes/user.d.ts.map +1 -0
- package/dist/routes/user.js +151 -0
- package/dist/routes/user.js.map +1 -0
- package/dist/routes/version.d.ts +42 -0
- package/dist/routes/version.d.ts.map +1 -0
- package/dist/routes/version.js +38 -0
- package/dist/routes/version.js.map +1 -0
- package/dist/routes/webhook.d.ts +170 -0
- package/dist/routes/webhook.d.ts.map +1 -0
- package/dist/routes/webhook.js +173 -0
- package/dist/routes/webhook.js.map +1 -0
- package/dist/routes/workspace.d.ts +120 -0
- package/dist/routes/workspace.d.ts.map +1 -0
- package/dist/routes/workspace.js +199 -0
- package/dist/routes/workspace.js.map +1 -0
- package/dist/utils/uploadSessionManager.d.ts +133 -0
- package/dist/utils/uploadSessionManager.d.ts.map +1 -0
- package/dist/utils/uploadSessionManager.js +321 -0
- package/dist/utils/uploadSessionManager.js.map +1 -0
- package/dist/utils/urlParams.d.ts +35 -0
- package/dist/utils/urlParams.d.ts.map +1 -0
- package/dist/utils/urlParams.js +146 -0
- package/dist/utils/urlParams.js.map +1 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +12 -0
- package/dist/version.js.map +1 -0
- package/package.json +87 -3
- package/src/BotClient.ts +113 -0
- package/src/NuramaClient.ts +1253 -0
- package/src/bot-browser-entry.js +15 -0
- package/src/browser-entry.js +20 -0
- package/src/routes/ai.ts +378 -0
- package/src/routes/asset.ts +1104 -0
- package/src/routes/auth.ts +587 -0
- package/src/routes/blogPosts.ts +29 -0
- package/src/routes/board.ts +403 -0
- package/src/routes/bot.ts +356 -0
- package/src/routes/chat.ts +1292 -0
- package/src/routes/chatAi.ts +125 -0
- package/src/routes/config.ts +31 -0
- package/src/routes/convo.ts +321 -0
- package/src/routes/credits.ts +112 -0
- package/src/routes/device.ts +133 -0
- package/src/routes/folder.ts +154 -0
- package/src/routes/invite.ts +133 -0
- package/src/routes/joinLink.ts +233 -0
- package/src/routes/membership.ts +237 -0
- package/src/routes/notification.ts +166 -0
- package/src/routes/oauthGrant.ts +64 -0
- package/src/routes/payment.ts +104 -0
- package/src/routes/product.ts +67 -0
- package/src/routes/project.ts +1528 -0
- package/src/routes/public.ts +496 -0
- package/src/routes/scratch.ts +94 -0
- package/src/routes/settings.ts +152 -0
- package/src/routes/shortlink.ts +90 -0
- package/src/routes/socket.ts +757 -0
- package/src/routes/storage.ts +83 -0
- package/src/routes/subscription.ts +307 -0
- package/src/routes/supportChat.ts +62 -0
- package/src/routes/supportTicket.ts +114 -0
- package/src/routes/tag.ts +131 -0
- package/src/routes/task.ts +431 -0
- package/src/routes/taskRelation.ts +125 -0
- package/src/routes/token.ts +152 -0
- package/src/routes/user.ts +214 -0
- package/src/routes/version.ts +62 -0
- package/src/routes/webhook.ts +295 -0
- package/src/routes/workspace.ts +223 -0
- package/src/utils/uploadSessionManager.ts +407 -0
- package/src/utils/urlParams.ts +181 -0
- package/src/version.ts +22 -0
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
import { jwtDecode } from 'jwt-decode';
|
|
2
|
+
|
|
3
|
+
import { paramsToUrlSearchParams } from './utils/urlParams.js';
|
|
4
|
+
import createAuthMethods from './routes/auth.js';
|
|
5
|
+
import createSubscriptionMethods from './routes/subscription.js';
|
|
6
|
+
import createUserMethods from './routes/user.js';
|
|
7
|
+
import createWorkspaceMethods from './routes/workspace.js';
|
|
8
|
+
import createProjectMethods from './routes/project.js';
|
|
9
|
+
import createAssetMethods from './routes/asset.js';
|
|
10
|
+
import createChatMethods from './routes/chat.js';
|
|
11
|
+
import createFolderMethods from './routes/folder.js';
|
|
12
|
+
import createInviteMethods from './routes/invite.js';
|
|
13
|
+
import createJoinLinkMethods from './routes/joinLink.js';
|
|
14
|
+
import createMembershipMethods from './routes/membership.js';
|
|
15
|
+
import createNotificationMethods from './routes/notification.js';
|
|
16
|
+
import createPaymentMethods from './routes/payment.js';
|
|
17
|
+
import createProductMethods from './routes/product.js';
|
|
18
|
+
import createSocketMethods from './routes/socket.js';
|
|
19
|
+
import createStorageMethods from './routes/storage.js';
|
|
20
|
+
import createTaskMethods from './routes/task.js';
|
|
21
|
+
import createVersionMethods from './routes/version.js';
|
|
22
|
+
import createConfigMethods from './routes/config.js';
|
|
23
|
+
import createTagMethods from './routes/tag.js';
|
|
24
|
+
import createPublicMethods from './routes/public.js';
|
|
25
|
+
import createSettingsMethods from './routes/settings.js';
|
|
26
|
+
import createDeviceMethods from './routes/device.js';
|
|
27
|
+
import createShortLinkMethods from './routes/shortlink.js';
|
|
28
|
+
import createConvoMethods from './routes/convo.js';
|
|
29
|
+
import createBoardMethods from './routes/board.js';
|
|
30
|
+
import createTaskRelationMethods from './routes/taskRelation.js';
|
|
31
|
+
import createBotMethods from './routes/bot.js';
|
|
32
|
+
import createAiMethods from './routes/ai.js';
|
|
33
|
+
import createAiChatMethods from './routes/chatAi.js';
|
|
34
|
+
import createSupportChatMethods from './routes/supportChat.js';
|
|
35
|
+
import createBlogPostsMethods from './routes/blogPosts.js';
|
|
36
|
+
import createCreditsMethods from './routes/credits.js';
|
|
37
|
+
import createScratchMethods from './routes/scratch.js';
|
|
38
|
+
import createTokenMethods from './routes/token.js';
|
|
39
|
+
import createOAuthGrantMethods from './routes/oauthGrant.js';
|
|
40
|
+
import createWebhookMethods from './routes/webhook.js';
|
|
41
|
+
import createSupportTicketMethods from './routes/supportTicket.js';
|
|
42
|
+
import { SDK_VERSION, type SDKVersionInfo } from './version.js';
|
|
43
|
+
|
|
44
|
+
// Type definitions
|
|
45
|
+
|
|
46
|
+
export interface NuramaClientOptions {
|
|
47
|
+
/** Custom fetch implementation. If not provided, will use global fetch */
|
|
48
|
+
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
49
|
+
|
|
50
|
+
/** Key used to store the access token in storage. Default: 'nurama_token' */
|
|
51
|
+
tokenStorageKey?: string;
|
|
52
|
+
|
|
53
|
+
/** Key used to store the refresh token in storage. Default: 'nurama_refresh_token' */
|
|
54
|
+
refreshTokenStorageKey?: string;
|
|
55
|
+
|
|
56
|
+
/** Whether to run in browser mode (uses localStorage for token storage). Default: false */
|
|
57
|
+
browserMode?: boolean;
|
|
58
|
+
|
|
59
|
+
/** Number of seconds before token expiry to trigger refresh. Default: 300 (5 minutes) */
|
|
60
|
+
tokenExpiryBufferSeconds?: number;
|
|
61
|
+
|
|
62
|
+
/** Delay in ms between token refresh retries. Default: 1000 */
|
|
63
|
+
tokenRefreshRetryDelayMs?: number;
|
|
64
|
+
|
|
65
|
+
/** Maximum time in ms to wait for token refresh. Default: 10000 */
|
|
66
|
+
tokenRefreshMaxWaitMs?: number;
|
|
67
|
+
|
|
68
|
+
/** Timeout in ms for the refresh lock to prevent multiple simultaneous refreshes. Default: 5000 */
|
|
69
|
+
refreshLockTimeoutMs?: number;
|
|
70
|
+
|
|
71
|
+
/** Enable debug logging. Default: false */
|
|
72
|
+
debug?: boolean;
|
|
73
|
+
|
|
74
|
+
/** WebSocket server URL. If not provided, will use the baseURL */
|
|
75
|
+
websocketURL?: string;
|
|
76
|
+
|
|
77
|
+
/** Enable response caching. Default: true */
|
|
78
|
+
enableCache?: boolean;
|
|
79
|
+
|
|
80
|
+
/** Cache duration in seconds. Default: 5 */
|
|
81
|
+
cacheDurationSeconds?: number;
|
|
82
|
+
|
|
83
|
+
/** After a successful write (POST/PUT/PATCH/DELETE) to a resource, evict
|
|
84
|
+
* cached GET responses for that resource so the next read returns fresh
|
|
85
|
+
* data (read-after-write consistency). Default: true. */
|
|
86
|
+
invalidateCacheOnMutation?: boolean;
|
|
87
|
+
|
|
88
|
+
/** Callback invoked when authentication fails and re-login is required
|
|
89
|
+
* (e.g., refresh token expired/missing). `reason.type` carries the
|
|
90
|
+
* backend error type when available — notably `emailVerificationRequired`
|
|
91
|
+
* when the refresh was rejected because the user's email-verification
|
|
92
|
+
* grace period has expired, so callers can prompt verification instead of
|
|
93
|
+
* a plain logout. */
|
|
94
|
+
onUnauthorized?: (reason?: { type?: string }) => void;
|
|
95
|
+
|
|
96
|
+
/** Invoked when the API returns a `{ error: 'maintenance' }` body on a 418 or
|
|
97
|
+
* 503 — i.e. the site is in a maintenance window (the edge rule returns this
|
|
98
|
+
* to non-allowlisted clients; a Cloudflare WAF block can only be a 4xx, so
|
|
99
|
+
* 418 — a guaranteed-unused sentinel — is used there, while a Worker/origin
|
|
100
|
+
* can return 503). Lets an already-loaded (cached) SPA surface a maintenance
|
|
101
|
+
* message instead of failing silently. */
|
|
102
|
+
onMaintenance?: () => void;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Invoked whenever the client's tokens change (login, MFA completion, refresh,
|
|
106
|
+
* logout, or a failed refresh clearing the session). Lets non-browser hosts
|
|
107
|
+
* (e.g. React Native) persist the session in their own secure storage; pair
|
|
108
|
+
* with `restoreSession()` on startup. Not called by `restoreSession()` itself.
|
|
109
|
+
*/
|
|
110
|
+
onTokensChanged?: (session: NuramaSession | null) => void;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Long-lived API key to use for every request (e.g., a bot API key starting with `nrm_bot_`).
|
|
114
|
+
* When set, the client skips JWT refresh logic and uses the key as the bearer token.
|
|
115
|
+
*/
|
|
116
|
+
apiKey?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A persisted session: the token pair plus their ISO expiry timestamps. */
|
|
120
|
+
export interface NuramaSession {
|
|
121
|
+
accessToken: string | null;
|
|
122
|
+
refreshToken: string | null;
|
|
123
|
+
accessTokenExpiry: string | null;
|
|
124
|
+
refreshTokenExpiry: string | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface RequestParams {
|
|
128
|
+
[key: string]: any; // Allows any query parameters
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface RequestBody {
|
|
132
|
+
[key: string]: any; // Allows any body structure, consider defining more specific types
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Base request options internal to the client
|
|
136
|
+
interface InternalRequestOptions {
|
|
137
|
+
endpoint: string;
|
|
138
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';
|
|
139
|
+
params?: RequestParams;
|
|
140
|
+
body?: RequestBody | null;
|
|
141
|
+
/**
|
|
142
|
+
* Authentication mode for the request:
|
|
143
|
+
* - true (default): JWT required — refresh a stale token and throw if unavailable.
|
|
144
|
+
* - false: no auth — never attach a token.
|
|
145
|
+
* - 'optional': attach a valid token if the client has one (best-effort
|
|
146
|
+
* refresh), otherwise proceed anonymously without throwing. Used by public
|
|
147
|
+
* routes that accept both authenticated and anonymous callers.
|
|
148
|
+
*/
|
|
149
|
+
sendJWT?: boolean | 'optional';
|
|
150
|
+
customHeaders?: Record<string, string>;
|
|
151
|
+
_isInternalRefresh?: boolean;
|
|
152
|
+
bypassCache?: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Cache entry structure
|
|
156
|
+
interface CacheEntry<T = any> {
|
|
157
|
+
data: T;
|
|
158
|
+
timestamp: number;
|
|
159
|
+
promise?: Promise<T>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// JWT payload structure (adjust based on your actual token)
|
|
163
|
+
interface JwtPayload {
|
|
164
|
+
exp: number;
|
|
165
|
+
sub: string; // Add user ID to JWT payload
|
|
166
|
+
// Add other relevant claims like sub, iat, etc.
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Error thrown by `_request` when the API responds with a non-2xx status.
|
|
171
|
+
* `status` is the HTTP status and `data` the parsed error body
|
|
172
|
+
* (`{ type, code, message, errorData? }`). Use `isApiError` to narrow.
|
|
173
|
+
*/
|
|
174
|
+
export interface ApiError extends Error {
|
|
175
|
+
status?: number;
|
|
176
|
+
data?: any;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Narrow an unknown thrown value to an API error carrying an HTTP status. */
|
|
180
|
+
export const isApiError = (error: unknown): error is ApiError =>
|
|
181
|
+
error instanceof Error && typeof (error as ApiError).status === 'number';
|
|
182
|
+
|
|
183
|
+
// Define types for the route method groups based on their return types
|
|
184
|
+
export interface AuthMethods extends ReturnType<typeof createAuthMethods> {}
|
|
185
|
+
export interface SubscriptionMethods extends ReturnType<typeof createSubscriptionMethods> {}
|
|
186
|
+
export interface UserMethods extends ReturnType<typeof createUserMethods> {}
|
|
187
|
+
export interface WorkspaceMethods extends ReturnType<typeof createWorkspaceMethods> {}
|
|
188
|
+
export interface ProjectMethods extends ReturnType<typeof createProjectMethods> {}
|
|
189
|
+
export interface AssetMethods extends ReturnType<typeof createAssetMethods> {}
|
|
190
|
+
type ChatMethods = ReturnType<typeof createChatMethods>;
|
|
191
|
+
type FolderMethods = ReturnType<typeof createFolderMethods>;
|
|
192
|
+
type InviteMethods = ReturnType<typeof createInviteMethods>;
|
|
193
|
+
type JoinLinkMethods = ReturnType<typeof createJoinLinkMethods>;
|
|
194
|
+
type MembershipMethods = ReturnType<typeof createMembershipMethods>;
|
|
195
|
+
type NotificationMethods = ReturnType<typeof createNotificationMethods>;
|
|
196
|
+
type PaymentMethods = ReturnType<typeof createPaymentMethods>;
|
|
197
|
+
type ProductMethods = ReturnType<typeof createProductMethods>;
|
|
198
|
+
type SocketMethods = ReturnType<typeof createSocketMethods>;
|
|
199
|
+
type StorageMethods = ReturnType<typeof createStorageMethods>;
|
|
200
|
+
type TaskMethods = ReturnType<typeof createTaskMethods>;
|
|
201
|
+
type VersionMethods = ReturnType<typeof createVersionMethods>;
|
|
202
|
+
type ConfigMethods = ReturnType<typeof createConfigMethods>;
|
|
203
|
+
type TagMethods = ReturnType<typeof createTagMethods>;
|
|
204
|
+
type PublicMethods = ReturnType<typeof createPublicMethods>;
|
|
205
|
+
type SettingsMethods = ReturnType<typeof createSettingsMethods>;
|
|
206
|
+
type DeviceMethods = ReturnType<typeof createDeviceMethods>;
|
|
207
|
+
type ShortLinkMethods = ReturnType<typeof createShortLinkMethods>;
|
|
208
|
+
type ConvoMethods = ReturnType<typeof createConvoMethods>;
|
|
209
|
+
type BoardMethods = ReturnType<typeof createBoardMethods>;
|
|
210
|
+
type TaskRelationMethods = ReturnType<typeof createTaskRelationMethods>;
|
|
211
|
+
type BotMethods = ReturnType<typeof createBotMethods>;
|
|
212
|
+
type AiMethods = ReturnType<typeof createAiMethods>;
|
|
213
|
+
type AiChatMethods = ReturnType<typeof createAiChatMethods>;
|
|
214
|
+
type SupportChatMethods = ReturnType<typeof createSupportChatMethods>;
|
|
215
|
+
type BlogPostsMethods = ReturnType<typeof createBlogPostsMethods>;
|
|
216
|
+
type CreditsMethods = ReturnType<typeof createCreditsMethods>;
|
|
217
|
+
type ScratchMethods = ReturnType<typeof createScratchMethods>;
|
|
218
|
+
type TokenMethods = ReturnType<typeof createTokenMethods>;
|
|
219
|
+
type OAuthGrantMethods = ReturnType<typeof createOAuthGrantMethods>;
|
|
220
|
+
type WebhookMethods = ReturnType<typeof createWebhookMethods>;
|
|
221
|
+
type SupportTicketMethods = ReturnType<typeof createSupportTicketMethods>;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Client interface for interacting with the Nurama REST API.
|
|
225
|
+
*/
|
|
226
|
+
class NuramaClient {
|
|
227
|
+
// --- Properties with types ---
|
|
228
|
+
readonly baseURL: string;
|
|
229
|
+
readonly fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
230
|
+
readonly tokenStorageKey: string;
|
|
231
|
+
readonly refreshTokenStorageKey: string;
|
|
232
|
+
readonly browserMode: boolean = false;
|
|
233
|
+
readonly tokenExpiryBufferSeconds: number;
|
|
234
|
+
readonly refreshLockTimeoutMs: number;
|
|
235
|
+
readonly tokenRefreshRetryDelayMs: number;
|
|
236
|
+
readonly tokenRefreshMaxWaitMs: number;
|
|
237
|
+
readonly debug: boolean;
|
|
238
|
+
readonly websocketURL?: string;
|
|
239
|
+
readonly enableCache: boolean;
|
|
240
|
+
readonly cacheDurationSeconds: number;
|
|
241
|
+
readonly invalidateCacheOnMutation: boolean;
|
|
242
|
+
private readonly _onUnauthorized?: (reason?: { type?: string }) => void;
|
|
243
|
+
private readonly _onMaintenance?: () => void;
|
|
244
|
+
private readonly _onTokensChanged?: (session: NuramaSession | null) => void;
|
|
245
|
+
|
|
246
|
+
private _accessToken: string | null = null;
|
|
247
|
+
private _refreshTokenValue: string | null = null;
|
|
248
|
+
private _accessTokenExpiry: string | null = null;
|
|
249
|
+
private _refreshTokenExpiry: string | null = null;
|
|
250
|
+
private _verifyMfaToken: string | null = null;
|
|
251
|
+
private _isRefreshing: boolean = false;
|
|
252
|
+
private _lastRefreshAttempt: number = 0;
|
|
253
|
+
private _cache: Map<string, CacheEntry> = new Map();
|
|
254
|
+
// Bumped on any cache clear/invalidation so a GET already in flight won't
|
|
255
|
+
// re-cache a response body that predates the clear.
|
|
256
|
+
private _cacheGeneration = 0;
|
|
257
|
+
private readonly _apiKey: string | null = null;
|
|
258
|
+
|
|
259
|
+
// Namespaced methods
|
|
260
|
+
readonly auth: AuthMethods;
|
|
261
|
+
readonly subscription: SubscriptionMethods;
|
|
262
|
+
readonly user: UserMethods;
|
|
263
|
+
readonly workspace: WorkspaceMethods;
|
|
264
|
+
readonly project: ProjectMethods;
|
|
265
|
+
readonly asset: AssetMethods;
|
|
266
|
+
public chat: ChatMethods;
|
|
267
|
+
public folder: FolderMethods;
|
|
268
|
+
public invite: InviteMethods;
|
|
269
|
+
public joinLink: JoinLinkMethods;
|
|
270
|
+
public membership: MembershipMethods;
|
|
271
|
+
public notification: NotificationMethods;
|
|
272
|
+
public payment: PaymentMethods;
|
|
273
|
+
public product: ProductMethods;
|
|
274
|
+
public socket: SocketMethods;
|
|
275
|
+
public storage: StorageMethods;
|
|
276
|
+
public task: TaskMethods;
|
|
277
|
+
public version: VersionMethods;
|
|
278
|
+
readonly config: ConfigMethods;
|
|
279
|
+
public tag: TagMethods;
|
|
280
|
+
public public: PublicMethods;
|
|
281
|
+
public settings: SettingsMethods;
|
|
282
|
+
public device: DeviceMethods;
|
|
283
|
+
public shortlink: ShortLinkMethods;
|
|
284
|
+
public convo: ConvoMethods;
|
|
285
|
+
public board: BoardMethods;
|
|
286
|
+
public taskRelation: TaskRelationMethods;
|
|
287
|
+
public bot: BotMethods;
|
|
288
|
+
public ai: AiMethods;
|
|
289
|
+
public aiChat: AiChatMethods;
|
|
290
|
+
public supportChat: SupportChatMethods;
|
|
291
|
+
public blogPosts: BlogPostsMethods;
|
|
292
|
+
public credits: CreditsMethods;
|
|
293
|
+
public scratch: ScratchMethods;
|
|
294
|
+
public token: TokenMethods;
|
|
295
|
+
|
|
296
|
+
/** Connected applications: what the user has authorised over OAuth. */
|
|
297
|
+
public oauthGrant: OAuthGrantMethods;
|
|
298
|
+
public webhook: WebhookMethods;
|
|
299
|
+
public supportTicket: SupportTicketMethods;
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Creates an instance of NuramaClient.
|
|
303
|
+
* @param baseURL - The base URL for the Nurama API.
|
|
304
|
+
* @param options - Configuration options.
|
|
305
|
+
*/
|
|
306
|
+
constructor(baseURL: string, options: NuramaClientOptions = {}) {
|
|
307
|
+
if (!baseURL) {
|
|
308
|
+
throw new Error('baseURL is required.');
|
|
309
|
+
}
|
|
310
|
+
// Use global fetch if available and no fetch provided
|
|
311
|
+
const defaultFetch = typeof fetch !== 'undefined' ? fetch : undefined;
|
|
312
|
+
|
|
313
|
+
this.baseURL = baseURL.replace(/\/$/, '');
|
|
314
|
+
|
|
315
|
+
// Determine if we're in browser mode
|
|
316
|
+
this.browserMode = options.browserMode || false;
|
|
317
|
+
|
|
318
|
+
// Handle fetch implementation with proper binding in browser mode
|
|
319
|
+
let effectiveFetch;
|
|
320
|
+
if (options.fetch) {
|
|
321
|
+
// User provided custom fetch implementation
|
|
322
|
+
effectiveFetch = options.fetch;
|
|
323
|
+
} else if (this.browserMode && typeof window !== 'undefined' && window.fetch) {
|
|
324
|
+
// In browser mode, wrap fetch to ensure proper binding to window
|
|
325
|
+
effectiveFetch = (url: RequestInfo | URL, init?: RequestInit) => window.fetch(url, init);
|
|
326
|
+
} else {
|
|
327
|
+
// Fallback to default fetch
|
|
328
|
+
effectiveFetch = defaultFetch;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Validate fetch availability
|
|
332
|
+
if (!effectiveFetch) {
|
|
333
|
+
throw new Error('Fetch implementation not found. Please provide one in options or ensure global fetch is available.');
|
|
334
|
+
}
|
|
335
|
+
this.fetch = effectiveFetch as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
336
|
+
|
|
337
|
+
this.tokenStorageKey = options.tokenStorageKey || 'nurama_access_token';
|
|
338
|
+
this.refreshTokenStorageKey = options.refreshTokenStorageKey || 'nurama_refresh_token';
|
|
339
|
+
this.tokenExpiryBufferSeconds = options.tokenExpiryBufferSeconds === undefined ? 60 : options.tokenExpiryBufferSeconds;
|
|
340
|
+
this.refreshLockTimeoutMs = options.refreshLockTimeoutMs === undefined ? 1000 : options.refreshLockTimeoutMs;
|
|
341
|
+
this.tokenRefreshRetryDelayMs = options.tokenRefreshRetryDelayMs === undefined ? 100 : options.tokenRefreshRetryDelayMs;
|
|
342
|
+
this.tokenRefreshMaxWaitMs = options.tokenRefreshMaxWaitMs === undefined ? 1000 : options.tokenRefreshMaxWaitMs;
|
|
343
|
+
this.debug = options.debug || false;
|
|
344
|
+
this.websocketURL = options.websocketURL;
|
|
345
|
+
this.enableCache = options.enableCache === undefined ? true : options.enableCache;
|
|
346
|
+
this.cacheDurationSeconds = options.cacheDurationSeconds === undefined ? 5 : options.cacheDurationSeconds;
|
|
347
|
+
this.invalidateCacheOnMutation = options.invalidateCacheOnMutation === undefined ? true : options.invalidateCacheOnMutation;
|
|
348
|
+
this._onUnauthorized = options.onUnauthorized;
|
|
349
|
+
this._onMaintenance = options.onMaintenance;
|
|
350
|
+
this._onTokensChanged = options.onTokensChanged;
|
|
351
|
+
this._apiKey = options.apiKey || null;
|
|
352
|
+
|
|
353
|
+
if (this.browserMode && !this._apiKey) {
|
|
354
|
+
this._loadTokens();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// --- Create API namespaces ---
|
|
358
|
+
this.auth = createAuthMethods(this);
|
|
359
|
+
this.subscription = createSubscriptionMethods(this);
|
|
360
|
+
this.user = createUserMethods(this);
|
|
361
|
+
this.workspace = createWorkspaceMethods(this);
|
|
362
|
+
this.project = createProjectMethods(this);
|
|
363
|
+
this.asset = createAssetMethods(this);
|
|
364
|
+
this.chat = createChatMethods(this);
|
|
365
|
+
this.folder = createFolderMethods(this);
|
|
366
|
+
this.invite = createInviteMethods(this);
|
|
367
|
+
this.joinLink = createJoinLinkMethods(this);
|
|
368
|
+
this.membership = createMembershipMethods(this);
|
|
369
|
+
this.notification = createNotificationMethods(this);
|
|
370
|
+
this.payment = createPaymentMethods(this);
|
|
371
|
+
this.product = createProductMethods(this);
|
|
372
|
+
this.socket = createSocketMethods(this);
|
|
373
|
+
this.storage = createStorageMethods(this);
|
|
374
|
+
this.task = createTaskMethods(this);
|
|
375
|
+
this.version = createVersionMethods(this);
|
|
376
|
+
this.config = createConfigMethods(this);
|
|
377
|
+
this.tag = createTagMethods(this);
|
|
378
|
+
this.public = createPublicMethods(this);
|
|
379
|
+
this.settings = createSettingsMethods(this);
|
|
380
|
+
this.device = createDeviceMethods(this);
|
|
381
|
+
this.shortlink = createShortLinkMethods(this);
|
|
382
|
+
this.convo = createConvoMethods(this);
|
|
383
|
+
this.board = createBoardMethods(this);
|
|
384
|
+
this.taskRelation = createTaskRelationMethods(this);
|
|
385
|
+
this.bot = createBotMethods(this);
|
|
386
|
+
this.ai = createAiMethods(this);
|
|
387
|
+
this.aiChat = createAiChatMethods(this);
|
|
388
|
+
this.supportChat = createSupportChatMethods(this);
|
|
389
|
+
this.blogPosts = createBlogPostsMethods(this);
|
|
390
|
+
this.credits = createCreditsMethods(this);
|
|
391
|
+
this.scratch = createScratchMethods(this);
|
|
392
|
+
this.token = createTokenMethods(this);
|
|
393
|
+
this.oauthGrant = createOAuthGrantMethods(this);
|
|
394
|
+
this.webhook = createWebhookMethods(this);
|
|
395
|
+
this.supportTicket = createSupportTicketMethods(this);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Internal helper for conditional logging. Underscore-prefixed to flag
|
|
400
|
+
* "internal contract" — accessible from sibling route modules but not
|
|
401
|
+
* part of the documented public API.
|
|
402
|
+
*
|
|
403
|
+
* @param args - Arguments to pass to console.log.
|
|
404
|
+
*/
|
|
405
|
+
public _log(...args: any[]): void {
|
|
406
|
+
if (this.debug) {
|
|
407
|
+
console.log(...args); // eslint-disable-line no-console
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Checks if a POST endpoint is safe to cache (read-only query operations).
|
|
413
|
+
* @param endpoint - The API endpoint to check.
|
|
414
|
+
* @returns True if the POST endpoint is cacheable, false otherwise.
|
|
415
|
+
* @private
|
|
416
|
+
*/
|
|
417
|
+
private _isCacheablePostEndpoint(endpoint: string): boolean {
|
|
418
|
+
// List of POST endpoints that are safe to cache (read-only query operations)
|
|
419
|
+
const cacheablePostEndpoints = [
|
|
420
|
+
'/v1/notifications/count',
|
|
421
|
+
'/v1/notifications/count/bulk',
|
|
422
|
+
'/v1/notifications',
|
|
423
|
+
'/v1/notifications/new',
|
|
424
|
+
'/v1/notifications/last-seen'
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
return cacheablePostEndpoints.some(cacheableEndpoint => endpoint === cacheableEndpoint);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Generates a cache key for a request.
|
|
432
|
+
* @param options - Request configuration.
|
|
433
|
+
* @returns A unique cache key string.
|
|
434
|
+
* @private
|
|
435
|
+
*/
|
|
436
|
+
private _generateCacheKey(options: InternalRequestOptions): string {
|
|
437
|
+
const { endpoint, method = 'GET', params = {}, body = null } = options;
|
|
438
|
+
const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
|
|
439
|
+
acc[key] = params[key];
|
|
440
|
+
return acc;
|
|
441
|
+
}, {} as RequestParams);
|
|
442
|
+
|
|
443
|
+
return JSON.stringify({
|
|
444
|
+
endpoint,
|
|
445
|
+
method,
|
|
446
|
+
params: sortedParams,
|
|
447
|
+
body
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Checks if a cache entry is still valid.
|
|
453
|
+
* @param entry - The cache entry to check.
|
|
454
|
+
* @returns True if the entry is still valid, false otherwise.
|
|
455
|
+
* @private
|
|
456
|
+
*/
|
|
457
|
+
private _isCacheEntryValid(entry: CacheEntry): boolean {
|
|
458
|
+
const now = Date.now();
|
|
459
|
+
const maxAge = this.cacheDurationSeconds * 1000;
|
|
460
|
+
return (now - entry.timestamp) < maxAge;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Gets a cached response if available and valid.
|
|
465
|
+
* @param cacheKey - The cache key to look up.
|
|
466
|
+
* @returns The cached data or null if not found/expired.
|
|
467
|
+
* @private
|
|
468
|
+
*/
|
|
469
|
+
private _getCachedResponse<T>(cacheKey: string): T | null {
|
|
470
|
+
const entry = this._cache.get(cacheKey);
|
|
471
|
+
if (!entry) return null;
|
|
472
|
+
|
|
473
|
+
if (this._isCacheEntryValid(entry)) {
|
|
474
|
+
this._log('[DEBUG] Cache hit for key:', cacheKey);
|
|
475
|
+
return entry.data as T;
|
|
476
|
+
} else {
|
|
477
|
+
// Remove expired entry
|
|
478
|
+
this._cache.delete(cacheKey);
|
|
479
|
+
this._log('[DEBUG] Cache entry expired and removed for key:', cacheKey);
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Stores a response in the cache.
|
|
486
|
+
* @param cacheKey - The cache key.
|
|
487
|
+
* @param data - The data to cache.
|
|
488
|
+
* @private
|
|
489
|
+
*/
|
|
490
|
+
private _setCachedResponse<T>(cacheKey: string, data: T): void {
|
|
491
|
+
this._cache.set(cacheKey, {
|
|
492
|
+
data,
|
|
493
|
+
timestamp: Date.now()
|
|
494
|
+
});
|
|
495
|
+
this._log('[DEBUG] Cached response for key:', cacheKey);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Gets or sets a pending promise for concurrent requests.
|
|
500
|
+
* @param cacheKey - The cache key.
|
|
501
|
+
* @param promise - The promise to store (optional).
|
|
502
|
+
* @returns The existing or new promise.
|
|
503
|
+
* @private
|
|
504
|
+
*/
|
|
505
|
+
private _getPendingPromise<T>(cacheKey: string, promise?: Promise<T>): Promise<T> | null {
|
|
506
|
+
const entry = this._cache.get(cacheKey);
|
|
507
|
+
|
|
508
|
+
if (promise) {
|
|
509
|
+
// Store the promise
|
|
510
|
+
if (entry) {
|
|
511
|
+
entry.promise = promise;
|
|
512
|
+
} else {
|
|
513
|
+
this._cache.set(cacheKey, {
|
|
514
|
+
data: null,
|
|
515
|
+
timestamp: Date.now(),
|
|
516
|
+
promise
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
return promise;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Return existing promise if valid
|
|
523
|
+
if (entry?.promise && this._isCacheEntryValid(entry)) {
|
|
524
|
+
this._log('[DEBUG] Returning pending promise for concurrent request:', cacheKey);
|
|
525
|
+
return entry.promise;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Clears the pending promise from a cache entry.
|
|
533
|
+
* @param cacheKey - The cache key.
|
|
534
|
+
* @private
|
|
535
|
+
*/
|
|
536
|
+
private _clearPendingPromise(cacheKey: string): void {
|
|
537
|
+
const entry = this._cache.get(cacheKey);
|
|
538
|
+
if (entry) {
|
|
539
|
+
delete entry.promise;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* True if a request is a write that should invalidate cached reads for its
|
|
545
|
+
* resource. Cacheable "read" POSTs (query endpoints) are excluded.
|
|
546
|
+
* @private
|
|
547
|
+
*/
|
|
548
|
+
private _isMutation(method: string, endpoint: string): boolean {
|
|
549
|
+
const m = (method || 'GET').toUpperCase();
|
|
550
|
+
if (m === 'PUT' || m === 'PATCH' || m === 'DELETE') return true;
|
|
551
|
+
if (m === 'POST') return !this._isCacheablePostEndpoint(endpoint);
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* True if a path segment looks like a resource id (UUID or numeric) rather
|
|
557
|
+
* than a collection name.
|
|
558
|
+
* @private
|
|
559
|
+
*/
|
|
560
|
+
private _looksLikeSegmentId(segment: string): boolean {
|
|
561
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)
|
|
562
|
+
|| /^\d+$/.test(segment);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The resource "collection" a mutation acts on — the deepest non-id,
|
|
567
|
+
* non-version path segment (e.g. `/v1/workspaces/:id/projects` -> `projects`,
|
|
568
|
+
* `DELETE /v1/projects/:id` -> `projects`).
|
|
569
|
+
* @private
|
|
570
|
+
*/
|
|
571
|
+
private _resourceToken(endpoint: string): string | null {
|
|
572
|
+
const path = (endpoint || '').split('?')[0];
|
|
573
|
+
const segments = path.split('/').filter(Boolean);
|
|
574
|
+
for (let i = segments.length - 1; i >= 0; i -= 1) {
|
|
575
|
+
const seg = segments[i];
|
|
576
|
+
if (seg === 'v1' || this._looksLikeSegmentId(seg)) continue;
|
|
577
|
+
return seg;
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Evict cached GET responses that belong to the same resource collection as a
|
|
584
|
+
* just-completed write, so the next read is fresh. Matches the parent-nested
|
|
585
|
+
* list form too — a `POST /v1/projects` also evicts a cached
|
|
586
|
+
* `/v1/workspaces/:id/projects` because both paths contain the `projects`
|
|
587
|
+
* segment. Cross-resource relationships that don't share a path segment
|
|
588
|
+
* (e.g. a payment write vs the subscription read) are NOT covered here and
|
|
589
|
+
* are handled at their call sites.
|
|
590
|
+
* @private
|
|
591
|
+
*/
|
|
592
|
+
private _invalidateForMutation(endpoint: string): void {
|
|
593
|
+
// Bump first so any GET already in flight won't re-cache a pre-write body.
|
|
594
|
+
this._cacheGeneration++;
|
|
595
|
+
const token = this._resourceToken(endpoint);
|
|
596
|
+
if (!token) {
|
|
597
|
+
// Couldn't identify the resource — clear everything to stay correct.
|
|
598
|
+
this._cache.clear();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
for (const key of Array.from(this._cache.keys())) {
|
|
602
|
+
let cachedEndpoint = '';
|
|
603
|
+
try {
|
|
604
|
+
cachedEndpoint = (JSON.parse(key) as { endpoint?: string }).endpoint || '';
|
|
605
|
+
} catch {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
const segments = cachedEndpoint.split('?')[0].split('/').filter(Boolean);
|
|
609
|
+
if (segments.includes(token)) {
|
|
610
|
+
this._cache.delete(key);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
this._log('[DEBUG] Invalidated cached reads for resource:', token);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Sets the access and refresh tokens with optional expiration timestamps.
|
|
618
|
+
* @param accessToken - The JWT access token or null to clear.
|
|
619
|
+
* @param refreshToken - The refresh token or null to clear.
|
|
620
|
+
* @param accessTokenExpiry - ISO timestamp when access token expires (optional).
|
|
621
|
+
* @param refreshTokenExpiry - ISO timestamp when refresh token expires (optional).
|
|
622
|
+
* @private
|
|
623
|
+
*/
|
|
624
|
+
private _setTokens(
|
|
625
|
+
accessToken: string | null,
|
|
626
|
+
refreshToken: string | null,
|
|
627
|
+
accessTokenExpiry?: string | null,
|
|
628
|
+
refreshTokenExpiry?: string | null
|
|
629
|
+
): void {
|
|
630
|
+
this._log('[DEBUG] Setting tokens. Access:', accessToken ? 'Yes' : 'No', 'Refresh:', refreshToken ? 'Yes' : 'No');
|
|
631
|
+
this._accessToken = accessToken;
|
|
632
|
+
this._refreshTokenValue = refreshToken;
|
|
633
|
+
this._accessTokenExpiry = accessTokenExpiry || null;
|
|
634
|
+
this._refreshTokenExpiry = refreshTokenExpiry || null;
|
|
635
|
+
|
|
636
|
+
// Clear verifyMfa token when setting regular tokens (or clearing all)
|
|
637
|
+
if (!accessToken && !refreshToken) {
|
|
638
|
+
this._verifyMfaToken = null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (this.browserMode) {
|
|
642
|
+
try {
|
|
643
|
+
if (accessToken) {
|
|
644
|
+
localStorage.setItem(this.tokenStorageKey, accessToken);
|
|
645
|
+
} else {
|
|
646
|
+
localStorage.removeItem(this.tokenStorageKey);
|
|
647
|
+
}
|
|
648
|
+
if (refreshToken) {
|
|
649
|
+
localStorage.setItem(this.refreshTokenStorageKey, refreshToken);
|
|
650
|
+
} else {
|
|
651
|
+
localStorage.removeItem(this.refreshTokenStorageKey);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Store expiration timestamps
|
|
655
|
+
if (accessTokenExpiry) {
|
|
656
|
+
localStorage.setItem(`${this.tokenStorageKey}_expiry`, accessTokenExpiry);
|
|
657
|
+
} else {
|
|
658
|
+
localStorage.removeItem(`${this.tokenStorageKey}_expiry`);
|
|
659
|
+
}
|
|
660
|
+
if (refreshTokenExpiry) {
|
|
661
|
+
localStorage.setItem(`${this.refreshTokenStorageKey}_expiry`, refreshTokenExpiry);
|
|
662
|
+
} else {
|
|
663
|
+
localStorage.removeItem(`${this.refreshTokenStorageKey}_expiry`);
|
|
664
|
+
}
|
|
665
|
+
} catch (e) {
|
|
666
|
+
console.error('Failed to save tokens to localStorage:', e);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (this._onTokensChanged) {
|
|
671
|
+
try {
|
|
672
|
+
this._onTokensChanged(accessToken || refreshToken ? this.getSession() : null);
|
|
673
|
+
} catch (e) {
|
|
674
|
+
console.error('onTokensChanged handler threw:', e);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Returns the current session (tokens and expiries), or null when signed out.
|
|
681
|
+
* @public
|
|
682
|
+
*/
|
|
683
|
+
public getSession(): NuramaSession | null {
|
|
684
|
+
if (!this._accessToken && !this._refreshTokenValue) return null;
|
|
685
|
+
return {
|
|
686
|
+
accessToken: this._accessToken,
|
|
687
|
+
refreshToken: this._refreshTokenValue,
|
|
688
|
+
accessTokenExpiry: this._accessTokenExpiry,
|
|
689
|
+
refreshTokenExpiry: this._refreshTokenExpiry,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Restores a session previously captured through `onTokensChanged` /
|
|
695
|
+
* `getSession()` — for hosts that persist tokens outside localStorage.
|
|
696
|
+
* Does not fire `onTokensChanged`. A missing or expired access token is
|
|
697
|
+
* refreshed on the next authenticated request.
|
|
698
|
+
* @public
|
|
699
|
+
*/
|
|
700
|
+
public restoreSession(session: NuramaSession | null): void {
|
|
701
|
+
this._accessToken = session?.accessToken ?? null;
|
|
702
|
+
this._refreshTokenValue = session?.refreshToken ?? null;
|
|
703
|
+
this._accessTokenExpiry = session?.accessTokenExpiry ?? null;
|
|
704
|
+
this._refreshTokenExpiry = session?.refreshTokenExpiry ?? null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Retrieves the current access token.
|
|
709
|
+
* @returns The access token or null.
|
|
710
|
+
* @private
|
|
711
|
+
*/
|
|
712
|
+
private _getAccessToken(): string | null {
|
|
713
|
+
return this._accessToken;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Retrieves the current refresh token.
|
|
718
|
+
* @returns The refresh token or null.
|
|
719
|
+
* @private
|
|
720
|
+
*/
|
|
721
|
+
private _getRefreshToken(): string | null {
|
|
722
|
+
return this._refreshTokenValue;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Sets the MFA verification token.
|
|
727
|
+
* @param verifyMfaToken - The verify MFA token or null to clear.
|
|
728
|
+
* @internal
|
|
729
|
+
*/
|
|
730
|
+
_setVerifyMfaToken(verifyMfaToken: string | null): void {
|
|
731
|
+
this._log('[DEBUG] Setting verifyMfa token:', verifyMfaToken ? 'Yes' : 'No');
|
|
732
|
+
this._verifyMfaToken = verifyMfaToken;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Retrieves the current verify MFA token.
|
|
737
|
+
* @returns The verify MFA token or null.
|
|
738
|
+
* @internal
|
|
739
|
+
*/
|
|
740
|
+
_getVerifyMfaToken(): string | null {
|
|
741
|
+
return this._verifyMfaToken;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Loads tokens and their expiration timestamps from storage (browser mode only).
|
|
746
|
+
* @private
|
|
747
|
+
*/
|
|
748
|
+
private _loadTokens(): void {
|
|
749
|
+
if (!this.browserMode) return;
|
|
750
|
+
try {
|
|
751
|
+
this._accessToken = localStorage.getItem(this.tokenStorageKey);
|
|
752
|
+
this._refreshTokenValue = localStorage.getItem(this.refreshTokenStorageKey);
|
|
753
|
+
this._accessTokenExpiry = localStorage.getItem(`${this.tokenStorageKey}_expiry`);
|
|
754
|
+
this._refreshTokenExpiry = localStorage.getItem(`${this.refreshTokenStorageKey}_expiry`);
|
|
755
|
+
this._log('[DEBUG] Loaded tokens from localStorage. Access:', this._accessToken ? 'Yes' : 'No', 'Refresh:', this._refreshTokenValue ? 'Yes' : 'No');
|
|
756
|
+
this._log('[DEBUG] Loaded token expiries. Access expiry:', this._accessTokenExpiry || 'None', 'Refresh expiry:', this._refreshTokenExpiry || 'None');
|
|
757
|
+
} catch (e) {
|
|
758
|
+
console.error('Failed to load tokens from localStorage:', e);
|
|
759
|
+
this._accessToken = null;
|
|
760
|
+
this._refreshTokenValue = null;
|
|
761
|
+
this._accessTokenExpiry = null;
|
|
762
|
+
this._refreshTokenExpiry = null;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Checks if the current access token is expired or close to expiring.
|
|
768
|
+
* @returns True if the token is missing, expired, or needs refresh based on buffer.
|
|
769
|
+
* @private
|
|
770
|
+
*/
|
|
771
|
+
private _isTokenExpired(): boolean {
|
|
772
|
+
// API keys never expire client-side; the server is the source of truth.
|
|
773
|
+
if (this._apiKey) return false;
|
|
774
|
+
const token = this._getAccessToken();
|
|
775
|
+
if (!token) return true;
|
|
776
|
+
|
|
777
|
+
try {
|
|
778
|
+
const decoded = jwtDecode<JwtPayload>(token);
|
|
779
|
+
if (!decoded || typeof decoded.exp !== 'number') {
|
|
780
|
+
console.error('Invalid token or missing exp claim.');
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const expirationTimeMs = decoded.exp * 1000;
|
|
785
|
+
const currentTimeMs = Date.now();
|
|
786
|
+
const bufferMs = this.tokenExpiryBufferSeconds * 1000;
|
|
787
|
+
|
|
788
|
+
const needsRefresh = currentTimeMs >= expirationTimeMs - bufferMs;
|
|
789
|
+
if (needsRefresh && this.debug) {
|
|
790
|
+
this._log(`[DEBUG] Token needs refresh. Current: ${new Date(currentTimeMs).toISOString()}, Expires: ${new Date(expirationTimeMs).toISOString()}, Buffer: ${this.tokenExpiryBufferSeconds}s`);
|
|
791
|
+
}
|
|
792
|
+
return needsRefresh;
|
|
793
|
+
|
|
794
|
+
} catch (e) {
|
|
795
|
+
console.error('Failed to decode or check token expiry:', e);
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Checks if the current JWT token exists and is valid.
|
|
802
|
+
* @returns {boolean} True if the token exists and is valid, false otherwise.
|
|
803
|
+
*/
|
|
804
|
+
public tokenValid(): boolean {
|
|
805
|
+
return !this._isTokenExpired();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Gets the token expiration timestamps.
|
|
810
|
+
* @returns Object containing access and refresh token expiry ISO timestamps.
|
|
811
|
+
* @public
|
|
812
|
+
*/
|
|
813
|
+
public getTokenExpiry(): { access: string | null; refresh: string | null } {
|
|
814
|
+
return {
|
|
815
|
+
access: this._accessTokenExpiry,
|
|
816
|
+
refresh: this._refreshTokenExpiry
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Refreshes the access token using the stored refresh token.
|
|
822
|
+
* Includes a time lock to prevent rapid concurrent refresh attempts.
|
|
823
|
+
* @returns Promise resolving when refresh attempt is complete (or bypassed).
|
|
824
|
+
* @private
|
|
825
|
+
*/
|
|
826
|
+
private async _refreshToken(): Promise<void> {
|
|
827
|
+
const now = Date.now();
|
|
828
|
+
if (this._isRefreshing) {
|
|
829
|
+
this._log('[DEBUG] Token refresh already in progress, waiting...');
|
|
830
|
+
const waitStartTime = Date.now();
|
|
831
|
+
while (this._isRefreshing) {
|
|
832
|
+
if (Date.now() - waitStartTime > this.tokenRefreshMaxWaitMs) {
|
|
833
|
+
console.error('Timed out waiting for token refresh to complete.');
|
|
834
|
+
throw new Error('Timeout waiting for token refresh.');
|
|
835
|
+
}
|
|
836
|
+
await new Promise(resolve => setTimeout(resolve, this.tokenRefreshRetryDelayMs));
|
|
837
|
+
}
|
|
838
|
+
this._log('[DEBUG] Other refresh process finished.');
|
|
839
|
+
if (!this._isTokenExpired()) {
|
|
840
|
+
this._log('[DEBUG] Token is now valid after waiting.');
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (now - this._lastRefreshAttempt < this.refreshLockTimeoutMs) {
|
|
844
|
+
this._log(`[DEBUG] Refresh attempt too soon after waiting (last attempt: ${new Date(this._lastRefreshAttempt).toISOString()}, now: ${new Date(now).toISOString()})`);
|
|
845
|
+
throw new Error('Token still requires refresh after wait, but locked due to recent attempt.');
|
|
846
|
+
}
|
|
847
|
+
} else if (now - this._lastRefreshAttempt < this.refreshLockTimeoutMs) {
|
|
848
|
+
this._log(`[DEBUG] Refresh attempt too soon (last attempt: ${new Date(this._lastRefreshAttempt).toISOString()}, now: ${new Date(now).toISOString()})`);
|
|
849
|
+
if (this._isTokenExpired()) {
|
|
850
|
+
throw new Error('Token requires refresh, but locked due to recent attempt.');
|
|
851
|
+
}
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const refreshToken = this._getRefreshToken();
|
|
856
|
+
if (!refreshToken) {
|
|
857
|
+
this._log('[DEBUG] No refresh token available for _refreshToken.');
|
|
858
|
+
this._setTokens(null, null);
|
|
859
|
+
throw new Error('Refresh token not found.');
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
this._isRefreshing = true;
|
|
863
|
+
this._lastRefreshAttempt = now;
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
this._log('[DEBUG] Attempting token refresh...');
|
|
867
|
+
// Make the actual request, skipping the standard refresh check
|
|
868
|
+
// Define expected response type for refresh endpoint
|
|
869
|
+
interface RefreshResponse {
|
|
870
|
+
access: { token: string; expires: string };
|
|
871
|
+
refresh: { token: string; expires: string };
|
|
872
|
+
}
|
|
873
|
+
const response = await this._request<RefreshResponse>({
|
|
874
|
+
endpoint: '/v1/auth/refresh-tokens',
|
|
875
|
+
method: 'POST',
|
|
876
|
+
body: { refreshToken },
|
|
877
|
+
sendJWT: false,
|
|
878
|
+
_isInternalRefresh: true
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
if (response && response.access && response.access.token && response.refresh && response.refresh.token) {
|
|
882
|
+
this._log('[DEBUG] Token refresh successful.');
|
|
883
|
+
this._setTokens(
|
|
884
|
+
response.access.token,
|
|
885
|
+
response.refresh.token,
|
|
886
|
+
response.access.expires,
|
|
887
|
+
response.refresh.expires
|
|
888
|
+
);
|
|
889
|
+
} else {
|
|
890
|
+
console.error('Invalid token refresh response:', response);
|
|
891
|
+
// Clear both tokens since response structure is invalid
|
|
892
|
+
this._setTokens(null, null);
|
|
893
|
+
throw new Error('Invalid token refresh response structure.');
|
|
894
|
+
}
|
|
895
|
+
} catch (error: any) {
|
|
896
|
+
console.error('Token refresh API call failed:', error);
|
|
897
|
+
|
|
898
|
+
// Check if the error is due to invalid refresh token (401/403)
|
|
899
|
+
// In these cases, the refresh token itself is invalid and should be cleared
|
|
900
|
+
const isInvalidRefreshToken = error?.status === 401 || error?.status === 403;
|
|
901
|
+
|
|
902
|
+
if (isInvalidRefreshToken) {
|
|
903
|
+
this._log('[DEBUG] Refresh token is invalid (401/403), clearing all tokens');
|
|
904
|
+
// Clear both access and refresh tokens
|
|
905
|
+
this._setTokens(null, null);
|
|
906
|
+
} else {
|
|
907
|
+
this._log('[DEBUG] Refresh failed due to network/server error, keeping refresh token for retry');
|
|
908
|
+
// For other errors (network issues, 500 errors, etc.), only clear access token
|
|
909
|
+
// Keep refresh token so it can be retried later
|
|
910
|
+
this._setTokens(null, refreshToken, null, this._refreshTokenExpiry);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Preserve the backend error type (e.g. `emailVerificationRequired`)
|
|
914
|
+
// so the request-level handler can pass it to onUnauthorized and the
|
|
915
|
+
// app can prompt verification rather than a generic logout.
|
|
916
|
+
const wrapped = new Error('Token refresh failed.') as any;
|
|
917
|
+
wrapped.reasonType = error?.data?.type;
|
|
918
|
+
throw wrapped;
|
|
919
|
+
} finally {
|
|
920
|
+
this._isRefreshing = false;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Makes an HTTP request to the API.
|
|
926
|
+
* Handles token attachment and potential refresh.
|
|
927
|
+
* @param options - Request configuration.
|
|
928
|
+
* @returns The response data.
|
|
929
|
+
* @private
|
|
930
|
+
*/
|
|
931
|
+
public async _request<T = any>(options: InternalRequestOptions): Promise<T> {
|
|
932
|
+
const { endpoint, method = 'GET', params = {}, body = null, sendJWT = true, customHeaders = {}, _isInternalRefresh = false, bypassCache = false } = options;
|
|
933
|
+
|
|
934
|
+
// Generate cache key for this request
|
|
935
|
+
const cacheKey = this._generateCacheKey(options);
|
|
936
|
+
|
|
937
|
+
// Check if this is a cacheable POST request (read-only query operations)
|
|
938
|
+
const isCacheablePost = method === 'POST' && this._isCacheablePostEndpoint(endpoint);
|
|
939
|
+
const shouldUseCache = this.enableCache && !bypassCache && !_isInternalRefresh &&
|
|
940
|
+
(method === 'GET' || method === 'HEAD' || isCacheablePost);
|
|
941
|
+
|
|
942
|
+
if (isCacheablePost) {
|
|
943
|
+
this._log('[DEBUG] POST request eligible for caching:', endpoint);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Check cache first (for GET/HEAD requests and cacheable POST requests when cache is enabled)
|
|
947
|
+
if (shouldUseCache) {
|
|
948
|
+
// Check for cached response
|
|
949
|
+
const cachedResponse = this._getCachedResponse<T>(cacheKey);
|
|
950
|
+
if (cachedResponse !== null) {
|
|
951
|
+
return cachedResponse;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Check for pending promise (concurrent request handling)
|
|
955
|
+
const pendingPromise = this._getPendingPromise<T>(cacheKey);
|
|
956
|
+
if (pendingPromise) {
|
|
957
|
+
return pendingPromise;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
let token: string | null = this._apiKey || this._getAccessToken();
|
|
962
|
+
|
|
963
|
+
// Only check for token expiry and refresh if we need to send JWT (skip for API key auth)
|
|
964
|
+
if (!_isInternalRefresh && sendJWT === true && !this._apiKey) {
|
|
965
|
+
if (this._isTokenExpired()) {
|
|
966
|
+
try {
|
|
967
|
+
await this._refreshToken();
|
|
968
|
+
token = this._getAccessToken();
|
|
969
|
+
} catch (refreshError) {
|
|
970
|
+
console.error('Token refresh failed during request:', refreshError);
|
|
971
|
+
this._setTokens(null, null);
|
|
972
|
+
if (this._onUnauthorized) {
|
|
973
|
+
this._onUnauthorized({ type: (refreshError as { reasonType?: string })?.reasonType });
|
|
974
|
+
}
|
|
975
|
+
throw new Error('Authentication required due to refresh failure.');
|
|
976
|
+
}
|
|
977
|
+
// If token is still null after refresh attempt, something went wrong
|
|
978
|
+
if (!token) {
|
|
979
|
+
if (this._onUnauthorized) {
|
|
980
|
+
this._onUnauthorized();
|
|
981
|
+
}
|
|
982
|
+
throw new Error('Authentication required, token unavailable after refresh attempt.');
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
} else if (!_isInternalRefresh && sendJWT === 'optional' && !this._apiKey) {
|
|
986
|
+
// Optional auth: send a valid token if we have one so the caller keeps
|
|
987
|
+
// their identity, but proceed anonymously (no token) rather than throwing
|
|
988
|
+
// when there's no session or a refresh fails. Used by public routes that
|
|
989
|
+
// accept both authenticated and anonymous callers.
|
|
990
|
+
if (token && this._isTokenExpired()) {
|
|
991
|
+
try {
|
|
992
|
+
await this._refreshToken();
|
|
993
|
+
token = this._getAccessToken();
|
|
994
|
+
} catch {
|
|
995
|
+
token = null; // proceed without auth
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const headers: Record<string, string> = {
|
|
1001
|
+
'Content-Type': 'application/json',
|
|
1002
|
+
...customHeaders,
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
if (sendJWT && token) {
|
|
1006
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const config: RequestInit = {
|
|
1010
|
+
method,
|
|
1011
|
+
headers,
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) {
|
|
1015
|
+
if (headers['Content-Type'] === 'application/json') {
|
|
1016
|
+
config.body = JSON.stringify(body);
|
|
1017
|
+
} else {
|
|
1018
|
+
// For non-JSON, pass body directly (e.g., FormData)
|
|
1019
|
+
config.body = body as BodyInit;
|
|
1020
|
+
// Let fetch handle Content-Type for FormData, etc.
|
|
1021
|
+
delete headers['Content-Type'];
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Ensure params is a proper object to prevent string iteration
|
|
1026
|
+
const safeParams = (params && typeof params === 'object' && !Array.isArray(params)) ? params : {};
|
|
1027
|
+
|
|
1028
|
+
// Filter undefined/null params and convert to URL-safe format using enhanced parseUrlParams
|
|
1029
|
+
const filteredParams = Object.entries(safeParams)
|
|
1030
|
+
.filter(([, value]) => value !== undefined && value !== null)
|
|
1031
|
+
.reduce((acc, [key, value]) => {
|
|
1032
|
+
acc[key] = value;
|
|
1033
|
+
return acc;
|
|
1034
|
+
}, {} as Record<string, any>);
|
|
1035
|
+
|
|
1036
|
+
// Use enhanced parseUrlParams utility with 'repeat' format for arrays
|
|
1037
|
+
const searchParams = paramsToUrlSearchParams(filteredParams, { arrayFormat: 'repeat' });
|
|
1038
|
+
|
|
1039
|
+
const url = `${this.baseURL}${endpoint}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
|
1040
|
+
|
|
1041
|
+
this._log(`[DEBUG] API Request: ${method} ${url}`, body ? { Body: body } : '');
|
|
1042
|
+
|
|
1043
|
+
// Snapshot the cache generation so a clear/invalidation that lands while
|
|
1044
|
+
// this request is in flight prevents it from caching a now-stale body.
|
|
1045
|
+
const cacheGenerationAtStart = this._cacheGeneration;
|
|
1046
|
+
|
|
1047
|
+
// Create the actual request promise
|
|
1048
|
+
const requestPromise = this._executeRequest<T>(url, config);
|
|
1049
|
+
|
|
1050
|
+
// Store pending promise for concurrent request handling
|
|
1051
|
+
if (shouldUseCache) {
|
|
1052
|
+
this._getPendingPromise<T>(cacheKey, requestPromise);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
try {
|
|
1056
|
+
const result = await requestPromise;
|
|
1057
|
+
|
|
1058
|
+
// Cache the successful response (unless it was invalidated mid-flight).
|
|
1059
|
+
if (shouldUseCache && result !== null && this._cacheGeneration === cacheGenerationAtStart) {
|
|
1060
|
+
this._setCachedResponse<T>(cacheKey, result);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// A successful write evicts cached reads for the same resource so the
|
|
1064
|
+
// next GET returns fresh data (read-after-write consistency).
|
|
1065
|
+
if (this.enableCache && this.invalidateCacheOnMutation && !_isInternalRefresh && this._isMutation(method, endpoint)) {
|
|
1066
|
+
this._invalidateForMutation(endpoint);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return result;
|
|
1070
|
+
} finally {
|
|
1071
|
+
// Clear pending promise
|
|
1072
|
+
if (shouldUseCache) {
|
|
1073
|
+
this._clearPendingPromise(cacheKey);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Executes the actual HTTP request.
|
|
1080
|
+
* @param url - The complete URL to request.
|
|
1081
|
+
* @param config - The fetch configuration.
|
|
1082
|
+
* @returns The response data.
|
|
1083
|
+
* @private
|
|
1084
|
+
*/
|
|
1085
|
+
private async _executeRequest<T>(url: string, config: RequestInit): Promise<T> {
|
|
1086
|
+
try {
|
|
1087
|
+
const response: Response = await this.fetch(url, config);
|
|
1088
|
+
|
|
1089
|
+
if (!response.ok) {
|
|
1090
|
+
let errorData: any;
|
|
1091
|
+
try {
|
|
1092
|
+
errorData = await response.json();
|
|
1093
|
+
this._log('[DEBUG] API Error Response Body:', errorData);
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
errorData = { message: response.statusText };
|
|
1096
|
+
this._log(`[DEBUG] API Error Response Text: ${response.statusText}`);
|
|
1097
|
+
}
|
|
1098
|
+
// A `{ error: 'maintenance' }` body on a 418/503 → the site is in a
|
|
1099
|
+
// maintenance window (the edge rule returns exactly this to
|
|
1100
|
+
// non-allowlisted clients). Notify the host so an already-loaded /
|
|
1101
|
+
// cached SPA can show a maintenance message. Gating on the BODY — not
|
|
1102
|
+
// just the status — avoids mislabeling a transient/real outage (which
|
|
1103
|
+
// returns a different body) as planned maintenance.
|
|
1104
|
+
//
|
|
1105
|
+
// 418 and 503 are accepted: a Cloudflare WAF "Block" custom response can
|
|
1106
|
+
// only be a 4xx, and 418 ("I'm a teapot") is a guaranteed-unused
|
|
1107
|
+
// sentinel no real API path emits — so it can never collide. A Worker /
|
|
1108
|
+
// real origin can instead return the semantically-correct 503.
|
|
1109
|
+
if (
|
|
1110
|
+
(response.status === 418 || response.status === 503) &&
|
|
1111
|
+
errorData?.error === 'maintenance' &&
|
|
1112
|
+
this._onMaintenance
|
|
1113
|
+
) {
|
|
1114
|
+
try { this._onMaintenance(); } catch { /* handler must never break the request path */ }
|
|
1115
|
+
}
|
|
1116
|
+
// Create a more informative error object
|
|
1117
|
+
const error = new Error(errorData.message || errorData.type || `HTTP error! status: ${response.status}`) as any;
|
|
1118
|
+
error.status = response.status;
|
|
1119
|
+
error.data = errorData;
|
|
1120
|
+
throw error;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (response.status === 204) {
|
|
1124
|
+
this._log(`[DEBUG] API Response: ${response.status} No Content`);
|
|
1125
|
+
return null as T; // Return null for 204 No Content
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
try {
|
|
1129
|
+
const responseData = await response.json();
|
|
1130
|
+
this._log(`[DEBUG] API Response: ${response.status}`, responseData);
|
|
1131
|
+
return responseData as T;
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
this._log(`[DEBUG] API Response OK but failed to parse as JSON: ${response.status}`, response);
|
|
1134
|
+
return null as T;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
} catch (error: any) {
|
|
1138
|
+
const apiError = error as ApiError; // Assert the error type
|
|
1139
|
+
if (!(error instanceof Error && apiError.status)) {
|
|
1140
|
+
// Only log generic network/fetch errors here
|
|
1141
|
+
// Specific API errors (response.ok === false) are logged above
|
|
1142
|
+
console.error(`[ERROR] API request failed: ${config.method} ${url}`, error);
|
|
1143
|
+
} else {
|
|
1144
|
+
this._log(`[DEBUG] API Error Caught: ${config.method} ${url}`, error);
|
|
1145
|
+
}
|
|
1146
|
+
throw error; // Re-throw the error for handling by the caller
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Gets the current user's ID from the JWT token.
|
|
1152
|
+
* @returns {string | null} The user ID if available, null otherwise.
|
|
1153
|
+
*/
|
|
1154
|
+
public getUserId(): string | null {
|
|
1155
|
+
const token = this._getAccessToken();
|
|
1156
|
+
if (!token) return null;
|
|
1157
|
+
|
|
1158
|
+
try {
|
|
1159
|
+
const decoded = jwtDecode<JwtPayload>(token);
|
|
1160
|
+
return decoded?.sub || null;
|
|
1161
|
+
} catch (e) {
|
|
1162
|
+
console.error('Failed to decode token:', e);
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Gets the SDK version information including build timestamp and hash.
|
|
1169
|
+
* Useful for debugging and verifying which SDK version is in use.
|
|
1170
|
+
* @returns {SDKVersionInfo} SDK version, build timestamp, build hash, and git commit
|
|
1171
|
+
*/
|
|
1172
|
+
public getVersion(): SDKVersionInfo {
|
|
1173
|
+
return SDK_VERSION;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Clears the response cache.
|
|
1178
|
+
* @public
|
|
1179
|
+
*/
|
|
1180
|
+
public clearCache(): void {
|
|
1181
|
+
this._cache.clear();
|
|
1182
|
+
this._cacheGeneration++;
|
|
1183
|
+
this._log('[DEBUG] Cache cleared');
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Gets cache statistics for debugging.
|
|
1188
|
+
* @returns Object containing cache statistics.
|
|
1189
|
+
* @public
|
|
1190
|
+
*/
|
|
1191
|
+
public getCacheStats(): { size: number; entries: Array<{ key: string; timestamp: number; hasData: boolean; hasPendingPromise: boolean }> } {
|
|
1192
|
+
const entries = Array.from(this._cache.entries()).map(([key, entry]) => ({
|
|
1193
|
+
key,
|
|
1194
|
+
timestamp: entry.timestamp,
|
|
1195
|
+
hasData: entry.data !== null && entry.data !== undefined,
|
|
1196
|
+
hasPendingPromise: !!entry.promise
|
|
1197
|
+
}));
|
|
1198
|
+
|
|
1199
|
+
return {
|
|
1200
|
+
size: this._cache.size,
|
|
1201
|
+
entries
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
export default NuramaClient;
|
|
1207
|
+
export type { SDKVersionInfo };
|
|
1208
|
+
export type { HealthStatus } from './routes/version.js';
|
|
1209
|
+
export type { UserTodo } from './routes/user.js';
|
|
1210
|
+
export type {
|
|
1211
|
+
SupportTicketScope,
|
|
1212
|
+
SupportTicketStatus,
|
|
1213
|
+
CreateSupportTicketRequest,
|
|
1214
|
+
SupportTicket,
|
|
1215
|
+
SupportTicketListParams,
|
|
1216
|
+
SupportTicketListResponse,
|
|
1217
|
+
SupportTicketScopeOptions,
|
|
1218
|
+
} from './routes/supportTicket.js';
|
|
1219
|
+
export type {
|
|
1220
|
+
BotApiKeySummary,
|
|
1221
|
+
Bot,
|
|
1222
|
+
CreateBotData,
|
|
1223
|
+
CreateBotResponse,
|
|
1224
|
+
UpdateBotData,
|
|
1225
|
+
RotateBotKeyResponse,
|
|
1226
|
+
UpdateBotAvatarFileData,
|
|
1227
|
+
UpdateBotAvatarResponse,
|
|
1228
|
+
BotProjectRole,
|
|
1229
|
+
BotProjectMembership,
|
|
1230
|
+
} from './routes/bot.js';
|
|
1231
|
+
export type {
|
|
1232
|
+
WebhookEvent,
|
|
1233
|
+
WebhookSubscriptionStatus,
|
|
1234
|
+
WebhookSubscription,
|
|
1235
|
+
CreateWebhookData,
|
|
1236
|
+
UpdateWebhookData,
|
|
1237
|
+
CreateWebhookResponse,
|
|
1238
|
+
RotateWebhookSecretResponse,
|
|
1239
|
+
WebhookAttemptStatus,
|
|
1240
|
+
WebhookAttempt,
|
|
1241
|
+
ListDeliveriesParams,
|
|
1242
|
+
ListDeliveriesResponse,
|
|
1243
|
+
TestWebhookResponse,
|
|
1244
|
+
} from './routes/webhook.js';
|
|
1245
|
+
export type {
|
|
1246
|
+
TokenKind,
|
|
1247
|
+
TokenScope,
|
|
1248
|
+
TokenSummary,
|
|
1249
|
+
CreateTokenData,
|
|
1250
|
+
CreateTokenResponse,
|
|
1251
|
+
} from './routes/token.js';
|
|
1252
|
+
|
|
1253
|
+
// Removed redundant export: export type { UserMethods, AssetMethods };
|