@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.
Files changed (225) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/README.md +1080 -2
  4. package/dist/BotClient.d.ts +66 -0
  5. package/dist/BotClient.d.ts.map +1 -0
  6. package/dist/BotClient.js +68 -0
  7. package/dist/BotClient.js.map +1 -0
  8. package/dist/NuramaClient.d.ts +480 -0
  9. package/dist/NuramaClient.d.ts.map +1 -0
  10. package/dist/NuramaClient.js +902 -0
  11. package/dist/NuramaClient.js.map +1 -0
  12. package/dist/browser/nurama-bot-sdk.js +12051 -0
  13. package/dist/browser/nurama-bot-sdk.min.js +1 -0
  14. package/dist/browser/nurama-sdk.js +12003 -0
  15. package/dist/browser/nurama-sdk.min.js +1 -0
  16. package/dist/routes/ai.d.ts +280 -0
  17. package/dist/routes/ai.d.ts.map +1 -0
  18. package/dist/routes/ai.js +173 -0
  19. package/dist/routes/ai.js.map +1 -0
  20. package/dist/routes/asset.d.ts +493 -0
  21. package/dist/routes/asset.d.ts.map +1 -0
  22. package/dist/routes/asset.js +848 -0
  23. package/dist/routes/asset.js.map +1 -0
  24. package/dist/routes/auth.d.ts +218 -0
  25. package/dist/routes/auth.d.ts.map +1 -0
  26. package/dist/routes/auth.js +454 -0
  27. package/dist/routes/auth.js.map +1 -0
  28. package/dist/routes/blogPosts.d.ts +17 -0
  29. package/dist/routes/blogPosts.d.ts.map +1 -0
  30. package/dist/routes/blogPosts.js +29 -0
  31. package/dist/routes/blogPosts.js.map +1 -0
  32. package/dist/routes/board.d.ts +187 -0
  33. package/dist/routes/board.d.ts.map +1 -0
  34. package/dist/routes/board.js +270 -0
  35. package/dist/routes/board.js.map +1 -0
  36. package/dist/routes/bot.d.ts +202 -0
  37. package/dist/routes/bot.d.ts.map +1 -0
  38. package/dist/routes/bot.js +229 -0
  39. package/dist/routes/bot.js.map +1 -0
  40. package/dist/routes/chat.d.ts +842 -0
  41. package/dist/routes/chat.d.ts.map +1 -0
  42. package/dist/routes/chat.js +863 -0
  43. package/dist/routes/chat.js.map +1 -0
  44. package/dist/routes/chatAi.d.ts +51 -0
  45. package/dist/routes/chatAi.d.ts.map +1 -0
  46. package/dist/routes/chatAi.js +109 -0
  47. package/dist/routes/chatAi.js.map +1 -0
  48. package/dist/routes/config.d.ts +11 -0
  49. package/dist/routes/config.d.ts.map +1 -0
  50. package/dist/routes/config.js +24 -0
  51. package/dist/routes/config.js.map +1 -0
  52. package/dist/routes/convo.d.ts +169 -0
  53. package/dist/routes/convo.d.ts.map +1 -0
  54. package/dist/routes/convo.js +284 -0
  55. package/dist/routes/convo.js.map +1 -0
  56. package/dist/routes/credits.d.ts +82 -0
  57. package/dist/routes/credits.d.ts.map +1 -0
  58. package/dist/routes/credits.js +49 -0
  59. package/dist/routes/credits.js.map +1 -0
  60. package/dist/routes/device.d.ts +74 -0
  61. package/dist/routes/device.d.ts.map +1 -0
  62. package/dist/routes/device.js +122 -0
  63. package/dist/routes/device.js.map +1 -0
  64. package/dist/routes/folder.d.ts +75 -0
  65. package/dist/routes/folder.d.ts.map +1 -0
  66. package/dist/routes/folder.js +99 -0
  67. package/dist/routes/folder.js.map +1 -0
  68. package/dist/routes/invite.d.ts +61 -0
  69. package/dist/routes/invite.d.ts.map +1 -0
  70. package/dist/routes/invite.js +86 -0
  71. package/dist/routes/invite.js.map +1 -0
  72. package/dist/routes/joinLink.d.ts +88 -0
  73. package/dist/routes/joinLink.d.ts.map +1 -0
  74. package/dist/routes/joinLink.js +205 -0
  75. package/dist/routes/joinLink.js.map +1 -0
  76. package/dist/routes/membership.d.ts +116 -0
  77. package/dist/routes/membership.d.ts.map +1 -0
  78. package/dist/routes/membership.js +183 -0
  79. package/dist/routes/membership.js.map +1 -0
  80. package/dist/routes/notification.d.ts +103 -0
  81. package/dist/routes/notification.d.ts.map +1 -0
  82. package/dist/routes/notification.js +89 -0
  83. package/dist/routes/notification.js.map +1 -0
  84. package/dist/routes/oauthGrant.d.ts +45 -0
  85. package/dist/routes/oauthGrant.d.ts.map +1 -0
  86. package/dist/routes/oauthGrant.js +32 -0
  87. package/dist/routes/oauthGrant.js.map +1 -0
  88. package/dist/routes/payment.d.ts +56 -0
  89. package/dist/routes/payment.d.ts.map +1 -0
  90. package/dist/routes/payment.js +78 -0
  91. package/dist/routes/payment.js.map +1 -0
  92. package/dist/routes/product.d.ts +43 -0
  93. package/dist/routes/product.d.ts.map +1 -0
  94. package/dist/routes/product.js +53 -0
  95. package/dist/routes/product.js.map +1 -0
  96. package/dist/routes/project.d.ts +821 -0
  97. package/dist/routes/project.d.ts.map +1 -0
  98. package/dist/routes/project.js +1153 -0
  99. package/dist/routes/project.js.map +1 -0
  100. package/dist/routes/public.d.ts +269 -0
  101. package/dist/routes/public.d.ts.map +1 -0
  102. package/dist/routes/public.js +412 -0
  103. package/dist/routes/public.js.map +1 -0
  104. package/dist/routes/scratch.d.ts +70 -0
  105. package/dist/routes/scratch.d.ts.map +1 -0
  106. package/dist/routes/scratch.js +67 -0
  107. package/dist/routes/scratch.js.map +1 -0
  108. package/dist/routes/settings.d.ts +102 -0
  109. package/dist/routes/settings.d.ts.map +1 -0
  110. package/dist/routes/settings.js +94 -0
  111. package/dist/routes/settings.js.map +1 -0
  112. package/dist/routes/shortlink.d.ts +79 -0
  113. package/dist/routes/shortlink.d.ts.map +1 -0
  114. package/dist/routes/shortlink.js +25 -0
  115. package/dist/routes/shortlink.js.map +1 -0
  116. package/dist/routes/socket.d.ts +108 -0
  117. package/dist/routes/socket.d.ts.map +1 -0
  118. package/dist/routes/socket.js +573 -0
  119. package/dist/routes/socket.js.map +1 -0
  120. package/dist/routes/storage.d.ts +44 -0
  121. package/dist/routes/storage.d.ts.map +1 -0
  122. package/dist/routes/storage.js +49 -0
  123. package/dist/routes/storage.js.map +1 -0
  124. package/dist/routes/subscription.d.ts +184 -0
  125. package/dist/routes/subscription.d.ts.map +1 -0
  126. package/dist/routes/subscription.js +219 -0
  127. package/dist/routes/subscription.js.map +1 -0
  128. package/dist/routes/supportChat.d.ts +40 -0
  129. package/dist/routes/supportChat.d.ts.map +1 -0
  130. package/dist/routes/supportChat.js +53 -0
  131. package/dist/routes/supportChat.js.map +1 -0
  132. package/dist/routes/supportTicket.d.ts +89 -0
  133. package/dist/routes/supportTicket.d.ts.map +1 -0
  134. package/dist/routes/supportTicket.js +54 -0
  135. package/dist/routes/supportTicket.js.map +1 -0
  136. package/dist/routes/tag.d.ts +72 -0
  137. package/dist/routes/tag.d.ts.map +1 -0
  138. package/dist/routes/tag.js +81 -0
  139. package/dist/routes/tag.js.map +1 -0
  140. package/dist/routes/task.d.ts +252 -0
  141. package/dist/routes/task.d.ts.map +1 -0
  142. package/dist/routes/task.js +284 -0
  143. package/dist/routes/task.js.map +1 -0
  144. package/dist/routes/taskRelation.d.ts +80 -0
  145. package/dist/routes/taskRelation.d.ts.map +1 -0
  146. package/dist/routes/taskRelation.js +71 -0
  147. package/dist/routes/taskRelation.js.map +1 -0
  148. package/dist/routes/token.d.ts +97 -0
  149. package/dist/routes/token.d.ts.map +1 -0
  150. package/dist/routes/token.js +73 -0
  151. package/dist/routes/token.js.map +1 -0
  152. package/dist/routes/user.d.ts +112 -0
  153. package/dist/routes/user.d.ts.map +1 -0
  154. package/dist/routes/user.js +151 -0
  155. package/dist/routes/user.js.map +1 -0
  156. package/dist/routes/version.d.ts +42 -0
  157. package/dist/routes/version.d.ts.map +1 -0
  158. package/dist/routes/version.js +38 -0
  159. package/dist/routes/version.js.map +1 -0
  160. package/dist/routes/webhook.d.ts +170 -0
  161. package/dist/routes/webhook.d.ts.map +1 -0
  162. package/dist/routes/webhook.js +173 -0
  163. package/dist/routes/webhook.js.map +1 -0
  164. package/dist/routes/workspace.d.ts +120 -0
  165. package/dist/routes/workspace.d.ts.map +1 -0
  166. package/dist/routes/workspace.js +199 -0
  167. package/dist/routes/workspace.js.map +1 -0
  168. package/dist/utils/uploadSessionManager.d.ts +133 -0
  169. package/dist/utils/uploadSessionManager.d.ts.map +1 -0
  170. package/dist/utils/uploadSessionManager.js +321 -0
  171. package/dist/utils/uploadSessionManager.js.map +1 -0
  172. package/dist/utils/urlParams.d.ts +35 -0
  173. package/dist/utils/urlParams.d.ts.map +1 -0
  174. package/dist/utils/urlParams.js +146 -0
  175. package/dist/utils/urlParams.js.map +1 -0
  176. package/dist/version.d.ts +15 -0
  177. package/dist/version.d.ts.map +1 -0
  178. package/dist/version.js +12 -0
  179. package/dist/version.js.map +1 -0
  180. package/package.json +87 -3
  181. package/src/BotClient.ts +113 -0
  182. package/src/NuramaClient.ts +1253 -0
  183. package/src/bot-browser-entry.js +15 -0
  184. package/src/browser-entry.js +20 -0
  185. package/src/routes/ai.ts +378 -0
  186. package/src/routes/asset.ts +1104 -0
  187. package/src/routes/auth.ts +587 -0
  188. package/src/routes/blogPosts.ts +29 -0
  189. package/src/routes/board.ts +403 -0
  190. package/src/routes/bot.ts +356 -0
  191. package/src/routes/chat.ts +1292 -0
  192. package/src/routes/chatAi.ts +125 -0
  193. package/src/routes/config.ts +31 -0
  194. package/src/routes/convo.ts +321 -0
  195. package/src/routes/credits.ts +112 -0
  196. package/src/routes/device.ts +133 -0
  197. package/src/routes/folder.ts +154 -0
  198. package/src/routes/invite.ts +133 -0
  199. package/src/routes/joinLink.ts +233 -0
  200. package/src/routes/membership.ts +237 -0
  201. package/src/routes/notification.ts +166 -0
  202. package/src/routes/oauthGrant.ts +64 -0
  203. package/src/routes/payment.ts +104 -0
  204. package/src/routes/product.ts +67 -0
  205. package/src/routes/project.ts +1528 -0
  206. package/src/routes/public.ts +496 -0
  207. package/src/routes/scratch.ts +94 -0
  208. package/src/routes/settings.ts +152 -0
  209. package/src/routes/shortlink.ts +90 -0
  210. package/src/routes/socket.ts +757 -0
  211. package/src/routes/storage.ts +83 -0
  212. package/src/routes/subscription.ts +307 -0
  213. package/src/routes/supportChat.ts +62 -0
  214. package/src/routes/supportTicket.ts +114 -0
  215. package/src/routes/tag.ts +131 -0
  216. package/src/routes/task.ts +431 -0
  217. package/src/routes/taskRelation.ts +125 -0
  218. package/src/routes/token.ts +152 -0
  219. package/src/routes/user.ts +214 -0
  220. package/src/routes/version.ts +62 -0
  221. package/src/routes/webhook.ts +295 -0
  222. package/src/routes/workspace.ts +223 -0
  223. package/src/utils/uploadSessionManager.ts +407 -0
  224. package/src/utils/urlParams.ts +181 -0
  225. 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 };