@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,902 @@
|
|
|
1
|
+
import { jwtDecode } from 'jwt-decode';
|
|
2
|
+
import { paramsToUrlSearchParams } from './utils/urlParams.js';
|
|
3
|
+
import createAuthMethods from './routes/auth.js';
|
|
4
|
+
import createSubscriptionMethods from './routes/subscription.js';
|
|
5
|
+
import createUserMethods from './routes/user.js';
|
|
6
|
+
import createWorkspaceMethods from './routes/workspace.js';
|
|
7
|
+
import createProjectMethods from './routes/project.js';
|
|
8
|
+
import createAssetMethods from './routes/asset.js';
|
|
9
|
+
import createChatMethods from './routes/chat.js';
|
|
10
|
+
import createFolderMethods from './routes/folder.js';
|
|
11
|
+
import createInviteMethods from './routes/invite.js';
|
|
12
|
+
import createJoinLinkMethods from './routes/joinLink.js';
|
|
13
|
+
import createMembershipMethods from './routes/membership.js';
|
|
14
|
+
import createNotificationMethods from './routes/notification.js';
|
|
15
|
+
import createPaymentMethods from './routes/payment.js';
|
|
16
|
+
import createProductMethods from './routes/product.js';
|
|
17
|
+
import createSocketMethods from './routes/socket.js';
|
|
18
|
+
import createStorageMethods from './routes/storage.js';
|
|
19
|
+
import createTaskMethods from './routes/task.js';
|
|
20
|
+
import createVersionMethods from './routes/version.js';
|
|
21
|
+
import createConfigMethods from './routes/config.js';
|
|
22
|
+
import createTagMethods from './routes/tag.js';
|
|
23
|
+
import createPublicMethods from './routes/public.js';
|
|
24
|
+
import createSettingsMethods from './routes/settings.js';
|
|
25
|
+
import createDeviceMethods from './routes/device.js';
|
|
26
|
+
import createShortLinkMethods from './routes/shortlink.js';
|
|
27
|
+
import createConvoMethods from './routes/convo.js';
|
|
28
|
+
import createBoardMethods from './routes/board.js';
|
|
29
|
+
import createTaskRelationMethods from './routes/taskRelation.js';
|
|
30
|
+
import createBotMethods from './routes/bot.js';
|
|
31
|
+
import createAiMethods from './routes/ai.js';
|
|
32
|
+
import createAiChatMethods from './routes/chatAi.js';
|
|
33
|
+
import createSupportChatMethods from './routes/supportChat.js';
|
|
34
|
+
import createBlogPostsMethods from './routes/blogPosts.js';
|
|
35
|
+
import createCreditsMethods from './routes/credits.js';
|
|
36
|
+
import createScratchMethods from './routes/scratch.js';
|
|
37
|
+
import createTokenMethods from './routes/token.js';
|
|
38
|
+
import createOAuthGrantMethods from './routes/oauthGrant.js';
|
|
39
|
+
import createWebhookMethods from './routes/webhook.js';
|
|
40
|
+
import createSupportTicketMethods from './routes/supportTicket.js';
|
|
41
|
+
import { SDK_VERSION } from './version.js';
|
|
42
|
+
/** Narrow an unknown thrown value to an API error carrying an HTTP status. */
|
|
43
|
+
export const isApiError = (error) => error instanceof Error && typeof error.status === 'number';
|
|
44
|
+
/**
|
|
45
|
+
* Client interface for interacting with the Nurama REST API.
|
|
46
|
+
*/
|
|
47
|
+
class NuramaClient {
|
|
48
|
+
/**
|
|
49
|
+
* Creates an instance of NuramaClient.
|
|
50
|
+
* @param baseURL - The base URL for the Nurama API.
|
|
51
|
+
* @param options - Configuration options.
|
|
52
|
+
*/
|
|
53
|
+
constructor(baseURL, options = {}) {
|
|
54
|
+
this.browserMode = false;
|
|
55
|
+
this._accessToken = null;
|
|
56
|
+
this._refreshTokenValue = null;
|
|
57
|
+
this._accessTokenExpiry = null;
|
|
58
|
+
this._refreshTokenExpiry = null;
|
|
59
|
+
this._verifyMfaToken = null;
|
|
60
|
+
this._isRefreshing = false;
|
|
61
|
+
this._lastRefreshAttempt = 0;
|
|
62
|
+
this._cache = new Map();
|
|
63
|
+
// Bumped on any cache clear/invalidation so a GET already in flight won't
|
|
64
|
+
// re-cache a response body that predates the clear.
|
|
65
|
+
this._cacheGeneration = 0;
|
|
66
|
+
this._apiKey = null;
|
|
67
|
+
if (!baseURL) {
|
|
68
|
+
throw new Error('baseURL is required.');
|
|
69
|
+
}
|
|
70
|
+
// Use global fetch if available and no fetch provided
|
|
71
|
+
const defaultFetch = typeof fetch !== 'undefined' ? fetch : undefined;
|
|
72
|
+
this.baseURL = baseURL.replace(/\/$/, '');
|
|
73
|
+
// Determine if we're in browser mode
|
|
74
|
+
this.browserMode = options.browserMode || false;
|
|
75
|
+
// Handle fetch implementation with proper binding in browser mode
|
|
76
|
+
let effectiveFetch;
|
|
77
|
+
if (options.fetch) {
|
|
78
|
+
// User provided custom fetch implementation
|
|
79
|
+
effectiveFetch = options.fetch;
|
|
80
|
+
}
|
|
81
|
+
else if (this.browserMode && typeof window !== 'undefined' && window.fetch) {
|
|
82
|
+
// In browser mode, wrap fetch to ensure proper binding to window
|
|
83
|
+
effectiveFetch = (url, init) => window.fetch(url, init);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// Fallback to default fetch
|
|
87
|
+
effectiveFetch = defaultFetch;
|
|
88
|
+
}
|
|
89
|
+
// Validate fetch availability
|
|
90
|
+
if (!effectiveFetch) {
|
|
91
|
+
throw new Error('Fetch implementation not found. Please provide one in options or ensure global fetch is available.');
|
|
92
|
+
}
|
|
93
|
+
this.fetch = effectiveFetch;
|
|
94
|
+
this.tokenStorageKey = options.tokenStorageKey || 'nurama_access_token';
|
|
95
|
+
this.refreshTokenStorageKey = options.refreshTokenStorageKey || 'nurama_refresh_token';
|
|
96
|
+
this.tokenExpiryBufferSeconds = options.tokenExpiryBufferSeconds === undefined ? 60 : options.tokenExpiryBufferSeconds;
|
|
97
|
+
this.refreshLockTimeoutMs = options.refreshLockTimeoutMs === undefined ? 1000 : options.refreshLockTimeoutMs;
|
|
98
|
+
this.tokenRefreshRetryDelayMs = options.tokenRefreshRetryDelayMs === undefined ? 100 : options.tokenRefreshRetryDelayMs;
|
|
99
|
+
this.tokenRefreshMaxWaitMs = options.tokenRefreshMaxWaitMs === undefined ? 1000 : options.tokenRefreshMaxWaitMs;
|
|
100
|
+
this.debug = options.debug || false;
|
|
101
|
+
this.websocketURL = options.websocketURL;
|
|
102
|
+
this.enableCache = options.enableCache === undefined ? true : options.enableCache;
|
|
103
|
+
this.cacheDurationSeconds = options.cacheDurationSeconds === undefined ? 5 : options.cacheDurationSeconds;
|
|
104
|
+
this.invalidateCacheOnMutation = options.invalidateCacheOnMutation === undefined ? true : options.invalidateCacheOnMutation;
|
|
105
|
+
this._onUnauthorized = options.onUnauthorized;
|
|
106
|
+
this._onMaintenance = options.onMaintenance;
|
|
107
|
+
this._onTokensChanged = options.onTokensChanged;
|
|
108
|
+
this._apiKey = options.apiKey || null;
|
|
109
|
+
if (this.browserMode && !this._apiKey) {
|
|
110
|
+
this._loadTokens();
|
|
111
|
+
}
|
|
112
|
+
// --- Create API namespaces ---
|
|
113
|
+
this.auth = createAuthMethods(this);
|
|
114
|
+
this.subscription = createSubscriptionMethods(this);
|
|
115
|
+
this.user = createUserMethods(this);
|
|
116
|
+
this.workspace = createWorkspaceMethods(this);
|
|
117
|
+
this.project = createProjectMethods(this);
|
|
118
|
+
this.asset = createAssetMethods(this);
|
|
119
|
+
this.chat = createChatMethods(this);
|
|
120
|
+
this.folder = createFolderMethods(this);
|
|
121
|
+
this.invite = createInviteMethods(this);
|
|
122
|
+
this.joinLink = createJoinLinkMethods(this);
|
|
123
|
+
this.membership = createMembershipMethods(this);
|
|
124
|
+
this.notification = createNotificationMethods(this);
|
|
125
|
+
this.payment = createPaymentMethods(this);
|
|
126
|
+
this.product = createProductMethods(this);
|
|
127
|
+
this.socket = createSocketMethods(this);
|
|
128
|
+
this.storage = createStorageMethods(this);
|
|
129
|
+
this.task = createTaskMethods(this);
|
|
130
|
+
this.version = createVersionMethods(this);
|
|
131
|
+
this.config = createConfigMethods(this);
|
|
132
|
+
this.tag = createTagMethods(this);
|
|
133
|
+
this.public = createPublicMethods(this);
|
|
134
|
+
this.settings = createSettingsMethods(this);
|
|
135
|
+
this.device = createDeviceMethods(this);
|
|
136
|
+
this.shortlink = createShortLinkMethods(this);
|
|
137
|
+
this.convo = createConvoMethods(this);
|
|
138
|
+
this.board = createBoardMethods(this);
|
|
139
|
+
this.taskRelation = createTaskRelationMethods(this);
|
|
140
|
+
this.bot = createBotMethods(this);
|
|
141
|
+
this.ai = createAiMethods(this);
|
|
142
|
+
this.aiChat = createAiChatMethods(this);
|
|
143
|
+
this.supportChat = createSupportChatMethods(this);
|
|
144
|
+
this.blogPosts = createBlogPostsMethods(this);
|
|
145
|
+
this.credits = createCreditsMethods(this);
|
|
146
|
+
this.scratch = createScratchMethods(this);
|
|
147
|
+
this.token = createTokenMethods(this);
|
|
148
|
+
this.oauthGrant = createOAuthGrantMethods(this);
|
|
149
|
+
this.webhook = createWebhookMethods(this);
|
|
150
|
+
this.supportTicket = createSupportTicketMethods(this);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Internal helper for conditional logging. Underscore-prefixed to flag
|
|
154
|
+
* "internal contract" — accessible from sibling route modules but not
|
|
155
|
+
* part of the documented public API.
|
|
156
|
+
*
|
|
157
|
+
* @param args - Arguments to pass to console.log.
|
|
158
|
+
*/
|
|
159
|
+
_log(...args) {
|
|
160
|
+
if (this.debug) {
|
|
161
|
+
console.log(...args); // eslint-disable-line no-console
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Checks if a POST endpoint is safe to cache (read-only query operations).
|
|
166
|
+
* @param endpoint - The API endpoint to check.
|
|
167
|
+
* @returns True if the POST endpoint is cacheable, false otherwise.
|
|
168
|
+
* @private
|
|
169
|
+
*/
|
|
170
|
+
_isCacheablePostEndpoint(endpoint) {
|
|
171
|
+
// List of POST endpoints that are safe to cache (read-only query operations)
|
|
172
|
+
const cacheablePostEndpoints = [
|
|
173
|
+
'/v1/notifications/count',
|
|
174
|
+
'/v1/notifications/count/bulk',
|
|
175
|
+
'/v1/notifications',
|
|
176
|
+
'/v1/notifications/new',
|
|
177
|
+
'/v1/notifications/last-seen'
|
|
178
|
+
];
|
|
179
|
+
return cacheablePostEndpoints.some(cacheableEndpoint => endpoint === cacheableEndpoint);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Generates a cache key for a request.
|
|
183
|
+
* @param options - Request configuration.
|
|
184
|
+
* @returns A unique cache key string.
|
|
185
|
+
* @private
|
|
186
|
+
*/
|
|
187
|
+
_generateCacheKey(options) {
|
|
188
|
+
const { endpoint, method = 'GET', params = {}, body = null } = options;
|
|
189
|
+
const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
|
|
190
|
+
acc[key] = params[key];
|
|
191
|
+
return acc;
|
|
192
|
+
}, {});
|
|
193
|
+
return JSON.stringify({
|
|
194
|
+
endpoint,
|
|
195
|
+
method,
|
|
196
|
+
params: sortedParams,
|
|
197
|
+
body
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Checks if a cache entry is still valid.
|
|
202
|
+
* @param entry - The cache entry to check.
|
|
203
|
+
* @returns True if the entry is still valid, false otherwise.
|
|
204
|
+
* @private
|
|
205
|
+
*/
|
|
206
|
+
_isCacheEntryValid(entry) {
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
const maxAge = this.cacheDurationSeconds * 1000;
|
|
209
|
+
return (now - entry.timestamp) < maxAge;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Gets a cached response if available and valid.
|
|
213
|
+
* @param cacheKey - The cache key to look up.
|
|
214
|
+
* @returns The cached data or null if not found/expired.
|
|
215
|
+
* @private
|
|
216
|
+
*/
|
|
217
|
+
_getCachedResponse(cacheKey) {
|
|
218
|
+
const entry = this._cache.get(cacheKey);
|
|
219
|
+
if (!entry)
|
|
220
|
+
return null;
|
|
221
|
+
if (this._isCacheEntryValid(entry)) {
|
|
222
|
+
this._log('[DEBUG] Cache hit for key:', cacheKey);
|
|
223
|
+
return entry.data;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// Remove expired entry
|
|
227
|
+
this._cache.delete(cacheKey);
|
|
228
|
+
this._log('[DEBUG] Cache entry expired and removed for key:', cacheKey);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Stores a response in the cache.
|
|
234
|
+
* @param cacheKey - The cache key.
|
|
235
|
+
* @param data - The data to cache.
|
|
236
|
+
* @private
|
|
237
|
+
*/
|
|
238
|
+
_setCachedResponse(cacheKey, data) {
|
|
239
|
+
this._cache.set(cacheKey, {
|
|
240
|
+
data,
|
|
241
|
+
timestamp: Date.now()
|
|
242
|
+
});
|
|
243
|
+
this._log('[DEBUG] Cached response for key:', cacheKey);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Gets or sets a pending promise for concurrent requests.
|
|
247
|
+
* @param cacheKey - The cache key.
|
|
248
|
+
* @param promise - The promise to store (optional).
|
|
249
|
+
* @returns The existing or new promise.
|
|
250
|
+
* @private
|
|
251
|
+
*/
|
|
252
|
+
_getPendingPromise(cacheKey, promise) {
|
|
253
|
+
const entry = this._cache.get(cacheKey);
|
|
254
|
+
if (promise) {
|
|
255
|
+
// Store the promise
|
|
256
|
+
if (entry) {
|
|
257
|
+
entry.promise = promise;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
this._cache.set(cacheKey, {
|
|
261
|
+
data: null,
|
|
262
|
+
timestamp: Date.now(),
|
|
263
|
+
promise
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return promise;
|
|
267
|
+
}
|
|
268
|
+
// Return existing promise if valid
|
|
269
|
+
if (entry?.promise && this._isCacheEntryValid(entry)) {
|
|
270
|
+
this._log('[DEBUG] Returning pending promise for concurrent request:', cacheKey);
|
|
271
|
+
return entry.promise;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Clears the pending promise from a cache entry.
|
|
277
|
+
* @param cacheKey - The cache key.
|
|
278
|
+
* @private
|
|
279
|
+
*/
|
|
280
|
+
_clearPendingPromise(cacheKey) {
|
|
281
|
+
const entry = this._cache.get(cacheKey);
|
|
282
|
+
if (entry) {
|
|
283
|
+
delete entry.promise;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* True if a request is a write that should invalidate cached reads for its
|
|
288
|
+
* resource. Cacheable "read" POSTs (query endpoints) are excluded.
|
|
289
|
+
* @private
|
|
290
|
+
*/
|
|
291
|
+
_isMutation(method, endpoint) {
|
|
292
|
+
const m = (method || 'GET').toUpperCase();
|
|
293
|
+
if (m === 'PUT' || m === 'PATCH' || m === 'DELETE')
|
|
294
|
+
return true;
|
|
295
|
+
if (m === 'POST')
|
|
296
|
+
return !this._isCacheablePostEndpoint(endpoint);
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* True if a path segment looks like a resource id (UUID or numeric) rather
|
|
301
|
+
* than a collection name.
|
|
302
|
+
* @private
|
|
303
|
+
*/
|
|
304
|
+
_looksLikeSegmentId(segment) {
|
|
305
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)
|
|
306
|
+
|| /^\d+$/.test(segment);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The resource "collection" a mutation acts on — the deepest non-id,
|
|
310
|
+
* non-version path segment (e.g. `/v1/workspaces/:id/projects` -> `projects`,
|
|
311
|
+
* `DELETE /v1/projects/:id` -> `projects`).
|
|
312
|
+
* @private
|
|
313
|
+
*/
|
|
314
|
+
_resourceToken(endpoint) {
|
|
315
|
+
const path = (endpoint || '').split('?')[0];
|
|
316
|
+
const segments = path.split('/').filter(Boolean);
|
|
317
|
+
for (let i = segments.length - 1; i >= 0; i -= 1) {
|
|
318
|
+
const seg = segments[i];
|
|
319
|
+
if (seg === 'v1' || this._looksLikeSegmentId(seg))
|
|
320
|
+
continue;
|
|
321
|
+
return seg;
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Evict cached GET responses that belong to the same resource collection as a
|
|
327
|
+
* just-completed write, so the next read is fresh. Matches the parent-nested
|
|
328
|
+
* list form too — a `POST /v1/projects` also evicts a cached
|
|
329
|
+
* `/v1/workspaces/:id/projects` because both paths contain the `projects`
|
|
330
|
+
* segment. Cross-resource relationships that don't share a path segment
|
|
331
|
+
* (e.g. a payment write vs the subscription read) are NOT covered here and
|
|
332
|
+
* are handled at their call sites.
|
|
333
|
+
* @private
|
|
334
|
+
*/
|
|
335
|
+
_invalidateForMutation(endpoint) {
|
|
336
|
+
// Bump first so any GET already in flight won't re-cache a pre-write body.
|
|
337
|
+
this._cacheGeneration++;
|
|
338
|
+
const token = this._resourceToken(endpoint);
|
|
339
|
+
if (!token) {
|
|
340
|
+
// Couldn't identify the resource — clear everything to stay correct.
|
|
341
|
+
this._cache.clear();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
for (const key of Array.from(this._cache.keys())) {
|
|
345
|
+
let cachedEndpoint = '';
|
|
346
|
+
try {
|
|
347
|
+
cachedEndpoint = JSON.parse(key).endpoint || '';
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const segments = cachedEndpoint.split('?')[0].split('/').filter(Boolean);
|
|
353
|
+
if (segments.includes(token)) {
|
|
354
|
+
this._cache.delete(key);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
this._log('[DEBUG] Invalidated cached reads for resource:', token);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Sets the access and refresh tokens with optional expiration timestamps.
|
|
361
|
+
* @param accessToken - The JWT access token or null to clear.
|
|
362
|
+
* @param refreshToken - The refresh token or null to clear.
|
|
363
|
+
* @param accessTokenExpiry - ISO timestamp when access token expires (optional).
|
|
364
|
+
* @param refreshTokenExpiry - ISO timestamp when refresh token expires (optional).
|
|
365
|
+
* @private
|
|
366
|
+
*/
|
|
367
|
+
_setTokens(accessToken, refreshToken, accessTokenExpiry, refreshTokenExpiry) {
|
|
368
|
+
this._log('[DEBUG] Setting tokens. Access:', accessToken ? 'Yes' : 'No', 'Refresh:', refreshToken ? 'Yes' : 'No');
|
|
369
|
+
this._accessToken = accessToken;
|
|
370
|
+
this._refreshTokenValue = refreshToken;
|
|
371
|
+
this._accessTokenExpiry = accessTokenExpiry || null;
|
|
372
|
+
this._refreshTokenExpiry = refreshTokenExpiry || null;
|
|
373
|
+
// Clear verifyMfa token when setting regular tokens (or clearing all)
|
|
374
|
+
if (!accessToken && !refreshToken) {
|
|
375
|
+
this._verifyMfaToken = null;
|
|
376
|
+
}
|
|
377
|
+
if (this.browserMode) {
|
|
378
|
+
try {
|
|
379
|
+
if (accessToken) {
|
|
380
|
+
localStorage.setItem(this.tokenStorageKey, accessToken);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
localStorage.removeItem(this.tokenStorageKey);
|
|
384
|
+
}
|
|
385
|
+
if (refreshToken) {
|
|
386
|
+
localStorage.setItem(this.refreshTokenStorageKey, refreshToken);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
localStorage.removeItem(this.refreshTokenStorageKey);
|
|
390
|
+
}
|
|
391
|
+
// Store expiration timestamps
|
|
392
|
+
if (accessTokenExpiry) {
|
|
393
|
+
localStorage.setItem(`${this.tokenStorageKey}_expiry`, accessTokenExpiry);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
localStorage.removeItem(`${this.tokenStorageKey}_expiry`);
|
|
397
|
+
}
|
|
398
|
+
if (refreshTokenExpiry) {
|
|
399
|
+
localStorage.setItem(`${this.refreshTokenStorageKey}_expiry`, refreshTokenExpiry);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
localStorage.removeItem(`${this.refreshTokenStorageKey}_expiry`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
catch (e) {
|
|
406
|
+
console.error('Failed to save tokens to localStorage:', e);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (this._onTokensChanged) {
|
|
410
|
+
try {
|
|
411
|
+
this._onTokensChanged(accessToken || refreshToken ? this.getSession() : null);
|
|
412
|
+
}
|
|
413
|
+
catch (e) {
|
|
414
|
+
console.error('onTokensChanged handler threw:', e);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Returns the current session (tokens and expiries), or null when signed out.
|
|
420
|
+
* @public
|
|
421
|
+
*/
|
|
422
|
+
getSession() {
|
|
423
|
+
if (!this._accessToken && !this._refreshTokenValue)
|
|
424
|
+
return null;
|
|
425
|
+
return {
|
|
426
|
+
accessToken: this._accessToken,
|
|
427
|
+
refreshToken: this._refreshTokenValue,
|
|
428
|
+
accessTokenExpiry: this._accessTokenExpiry,
|
|
429
|
+
refreshTokenExpiry: this._refreshTokenExpiry,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Restores a session previously captured through `onTokensChanged` /
|
|
434
|
+
* `getSession()` — for hosts that persist tokens outside localStorage.
|
|
435
|
+
* Does not fire `onTokensChanged`. A missing or expired access token is
|
|
436
|
+
* refreshed on the next authenticated request.
|
|
437
|
+
* @public
|
|
438
|
+
*/
|
|
439
|
+
restoreSession(session) {
|
|
440
|
+
this._accessToken = session?.accessToken ?? null;
|
|
441
|
+
this._refreshTokenValue = session?.refreshToken ?? null;
|
|
442
|
+
this._accessTokenExpiry = session?.accessTokenExpiry ?? null;
|
|
443
|
+
this._refreshTokenExpiry = session?.refreshTokenExpiry ?? null;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Retrieves the current access token.
|
|
447
|
+
* @returns The access token or null.
|
|
448
|
+
* @private
|
|
449
|
+
*/
|
|
450
|
+
_getAccessToken() {
|
|
451
|
+
return this._accessToken;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Retrieves the current refresh token.
|
|
455
|
+
* @returns The refresh token or null.
|
|
456
|
+
* @private
|
|
457
|
+
*/
|
|
458
|
+
_getRefreshToken() {
|
|
459
|
+
return this._refreshTokenValue;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Sets the MFA verification token.
|
|
463
|
+
* @param verifyMfaToken - The verify MFA token or null to clear.
|
|
464
|
+
* @internal
|
|
465
|
+
*/
|
|
466
|
+
_setVerifyMfaToken(verifyMfaToken) {
|
|
467
|
+
this._log('[DEBUG] Setting verifyMfa token:', verifyMfaToken ? 'Yes' : 'No');
|
|
468
|
+
this._verifyMfaToken = verifyMfaToken;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Retrieves the current verify MFA token.
|
|
472
|
+
* @returns The verify MFA token or null.
|
|
473
|
+
* @internal
|
|
474
|
+
*/
|
|
475
|
+
_getVerifyMfaToken() {
|
|
476
|
+
return this._verifyMfaToken;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Loads tokens and their expiration timestamps from storage (browser mode only).
|
|
480
|
+
* @private
|
|
481
|
+
*/
|
|
482
|
+
_loadTokens() {
|
|
483
|
+
if (!this.browserMode)
|
|
484
|
+
return;
|
|
485
|
+
try {
|
|
486
|
+
this._accessToken = localStorage.getItem(this.tokenStorageKey);
|
|
487
|
+
this._refreshTokenValue = localStorage.getItem(this.refreshTokenStorageKey);
|
|
488
|
+
this._accessTokenExpiry = localStorage.getItem(`${this.tokenStorageKey}_expiry`);
|
|
489
|
+
this._refreshTokenExpiry = localStorage.getItem(`${this.refreshTokenStorageKey}_expiry`);
|
|
490
|
+
this._log('[DEBUG] Loaded tokens from localStorage. Access:', this._accessToken ? 'Yes' : 'No', 'Refresh:', this._refreshTokenValue ? 'Yes' : 'No');
|
|
491
|
+
this._log('[DEBUG] Loaded token expiries. Access expiry:', this._accessTokenExpiry || 'None', 'Refresh expiry:', this._refreshTokenExpiry || 'None');
|
|
492
|
+
}
|
|
493
|
+
catch (e) {
|
|
494
|
+
console.error('Failed to load tokens from localStorage:', e);
|
|
495
|
+
this._accessToken = null;
|
|
496
|
+
this._refreshTokenValue = null;
|
|
497
|
+
this._accessTokenExpiry = null;
|
|
498
|
+
this._refreshTokenExpiry = null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Checks if the current access token is expired or close to expiring.
|
|
503
|
+
* @returns True if the token is missing, expired, or needs refresh based on buffer.
|
|
504
|
+
* @private
|
|
505
|
+
*/
|
|
506
|
+
_isTokenExpired() {
|
|
507
|
+
// API keys never expire client-side; the server is the source of truth.
|
|
508
|
+
if (this._apiKey)
|
|
509
|
+
return false;
|
|
510
|
+
const token = this._getAccessToken();
|
|
511
|
+
if (!token)
|
|
512
|
+
return true;
|
|
513
|
+
try {
|
|
514
|
+
const decoded = jwtDecode(token);
|
|
515
|
+
if (!decoded || typeof decoded.exp !== 'number') {
|
|
516
|
+
console.error('Invalid token or missing exp claim.');
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
const expirationTimeMs = decoded.exp * 1000;
|
|
520
|
+
const currentTimeMs = Date.now();
|
|
521
|
+
const bufferMs = this.tokenExpiryBufferSeconds * 1000;
|
|
522
|
+
const needsRefresh = currentTimeMs >= expirationTimeMs - bufferMs;
|
|
523
|
+
if (needsRefresh && this.debug) {
|
|
524
|
+
this._log(`[DEBUG] Token needs refresh. Current: ${new Date(currentTimeMs).toISOString()}, Expires: ${new Date(expirationTimeMs).toISOString()}, Buffer: ${this.tokenExpiryBufferSeconds}s`);
|
|
525
|
+
}
|
|
526
|
+
return needsRefresh;
|
|
527
|
+
}
|
|
528
|
+
catch (e) {
|
|
529
|
+
console.error('Failed to decode or check token expiry:', e);
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Checks if the current JWT token exists and is valid.
|
|
535
|
+
* @returns {boolean} True if the token exists and is valid, false otherwise.
|
|
536
|
+
*/
|
|
537
|
+
tokenValid() {
|
|
538
|
+
return !this._isTokenExpired();
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Gets the token expiration timestamps.
|
|
542
|
+
* @returns Object containing access and refresh token expiry ISO timestamps.
|
|
543
|
+
* @public
|
|
544
|
+
*/
|
|
545
|
+
getTokenExpiry() {
|
|
546
|
+
return {
|
|
547
|
+
access: this._accessTokenExpiry,
|
|
548
|
+
refresh: this._refreshTokenExpiry
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Refreshes the access token using the stored refresh token.
|
|
553
|
+
* Includes a time lock to prevent rapid concurrent refresh attempts.
|
|
554
|
+
* @returns Promise resolving when refresh attempt is complete (or bypassed).
|
|
555
|
+
* @private
|
|
556
|
+
*/
|
|
557
|
+
async _refreshToken() {
|
|
558
|
+
const now = Date.now();
|
|
559
|
+
if (this._isRefreshing) {
|
|
560
|
+
this._log('[DEBUG] Token refresh already in progress, waiting...');
|
|
561
|
+
const waitStartTime = Date.now();
|
|
562
|
+
while (this._isRefreshing) {
|
|
563
|
+
if (Date.now() - waitStartTime > this.tokenRefreshMaxWaitMs) {
|
|
564
|
+
console.error('Timed out waiting for token refresh to complete.');
|
|
565
|
+
throw new Error('Timeout waiting for token refresh.');
|
|
566
|
+
}
|
|
567
|
+
await new Promise(resolve => setTimeout(resolve, this.tokenRefreshRetryDelayMs));
|
|
568
|
+
}
|
|
569
|
+
this._log('[DEBUG] Other refresh process finished.');
|
|
570
|
+
if (!this._isTokenExpired()) {
|
|
571
|
+
this._log('[DEBUG] Token is now valid after waiting.');
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (now - this._lastRefreshAttempt < this.refreshLockTimeoutMs) {
|
|
575
|
+
this._log(`[DEBUG] Refresh attempt too soon after waiting (last attempt: ${new Date(this._lastRefreshAttempt).toISOString()}, now: ${new Date(now).toISOString()})`);
|
|
576
|
+
throw new Error('Token still requires refresh after wait, but locked due to recent attempt.');
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
else if (now - this._lastRefreshAttempt < this.refreshLockTimeoutMs) {
|
|
580
|
+
this._log(`[DEBUG] Refresh attempt too soon (last attempt: ${new Date(this._lastRefreshAttempt).toISOString()}, now: ${new Date(now).toISOString()})`);
|
|
581
|
+
if (this._isTokenExpired()) {
|
|
582
|
+
throw new Error('Token requires refresh, but locked due to recent attempt.');
|
|
583
|
+
}
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const refreshToken = this._getRefreshToken();
|
|
587
|
+
if (!refreshToken) {
|
|
588
|
+
this._log('[DEBUG] No refresh token available for _refreshToken.');
|
|
589
|
+
this._setTokens(null, null);
|
|
590
|
+
throw new Error('Refresh token not found.');
|
|
591
|
+
}
|
|
592
|
+
this._isRefreshing = true;
|
|
593
|
+
this._lastRefreshAttempt = now;
|
|
594
|
+
try {
|
|
595
|
+
this._log('[DEBUG] Attempting token refresh...');
|
|
596
|
+
const response = await this._request({
|
|
597
|
+
endpoint: '/v1/auth/refresh-tokens',
|
|
598
|
+
method: 'POST',
|
|
599
|
+
body: { refreshToken },
|
|
600
|
+
sendJWT: false,
|
|
601
|
+
_isInternalRefresh: true
|
|
602
|
+
});
|
|
603
|
+
if (response && response.access && response.access.token && response.refresh && response.refresh.token) {
|
|
604
|
+
this._log('[DEBUG] Token refresh successful.');
|
|
605
|
+
this._setTokens(response.access.token, response.refresh.token, response.access.expires, response.refresh.expires);
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
console.error('Invalid token refresh response:', response);
|
|
609
|
+
// Clear both tokens since response structure is invalid
|
|
610
|
+
this._setTokens(null, null);
|
|
611
|
+
throw new Error('Invalid token refresh response structure.');
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
console.error('Token refresh API call failed:', error);
|
|
616
|
+
// Check if the error is due to invalid refresh token (401/403)
|
|
617
|
+
// In these cases, the refresh token itself is invalid and should be cleared
|
|
618
|
+
const isInvalidRefreshToken = error?.status === 401 || error?.status === 403;
|
|
619
|
+
if (isInvalidRefreshToken) {
|
|
620
|
+
this._log('[DEBUG] Refresh token is invalid (401/403), clearing all tokens');
|
|
621
|
+
// Clear both access and refresh tokens
|
|
622
|
+
this._setTokens(null, null);
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
this._log('[DEBUG] Refresh failed due to network/server error, keeping refresh token for retry');
|
|
626
|
+
// For other errors (network issues, 500 errors, etc.), only clear access token
|
|
627
|
+
// Keep refresh token so it can be retried later
|
|
628
|
+
this._setTokens(null, refreshToken, null, this._refreshTokenExpiry);
|
|
629
|
+
}
|
|
630
|
+
// Preserve the backend error type (e.g. `emailVerificationRequired`)
|
|
631
|
+
// so the request-level handler can pass it to onUnauthorized and the
|
|
632
|
+
// app can prompt verification rather than a generic logout.
|
|
633
|
+
const wrapped = new Error('Token refresh failed.');
|
|
634
|
+
wrapped.reasonType = error?.data?.type;
|
|
635
|
+
throw wrapped;
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
this._isRefreshing = false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Makes an HTTP request to the API.
|
|
643
|
+
* Handles token attachment and potential refresh.
|
|
644
|
+
* @param options - Request configuration.
|
|
645
|
+
* @returns The response data.
|
|
646
|
+
* @private
|
|
647
|
+
*/
|
|
648
|
+
async _request(options) {
|
|
649
|
+
const { endpoint, method = 'GET', params = {}, body = null, sendJWT = true, customHeaders = {}, _isInternalRefresh = false, bypassCache = false } = options;
|
|
650
|
+
// Generate cache key for this request
|
|
651
|
+
const cacheKey = this._generateCacheKey(options);
|
|
652
|
+
// Check if this is a cacheable POST request (read-only query operations)
|
|
653
|
+
const isCacheablePost = method === 'POST' && this._isCacheablePostEndpoint(endpoint);
|
|
654
|
+
const shouldUseCache = this.enableCache && !bypassCache && !_isInternalRefresh &&
|
|
655
|
+
(method === 'GET' || method === 'HEAD' || isCacheablePost);
|
|
656
|
+
if (isCacheablePost) {
|
|
657
|
+
this._log('[DEBUG] POST request eligible for caching:', endpoint);
|
|
658
|
+
}
|
|
659
|
+
// Check cache first (for GET/HEAD requests and cacheable POST requests when cache is enabled)
|
|
660
|
+
if (shouldUseCache) {
|
|
661
|
+
// Check for cached response
|
|
662
|
+
const cachedResponse = this._getCachedResponse(cacheKey);
|
|
663
|
+
if (cachedResponse !== null) {
|
|
664
|
+
return cachedResponse;
|
|
665
|
+
}
|
|
666
|
+
// Check for pending promise (concurrent request handling)
|
|
667
|
+
const pendingPromise = this._getPendingPromise(cacheKey);
|
|
668
|
+
if (pendingPromise) {
|
|
669
|
+
return pendingPromise;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
let token = this._apiKey || this._getAccessToken();
|
|
673
|
+
// Only check for token expiry and refresh if we need to send JWT (skip for API key auth)
|
|
674
|
+
if (!_isInternalRefresh && sendJWT === true && !this._apiKey) {
|
|
675
|
+
if (this._isTokenExpired()) {
|
|
676
|
+
try {
|
|
677
|
+
await this._refreshToken();
|
|
678
|
+
token = this._getAccessToken();
|
|
679
|
+
}
|
|
680
|
+
catch (refreshError) {
|
|
681
|
+
console.error('Token refresh failed during request:', refreshError);
|
|
682
|
+
this._setTokens(null, null);
|
|
683
|
+
if (this._onUnauthorized) {
|
|
684
|
+
this._onUnauthorized({ type: refreshError?.reasonType });
|
|
685
|
+
}
|
|
686
|
+
throw new Error('Authentication required due to refresh failure.');
|
|
687
|
+
}
|
|
688
|
+
// If token is still null after refresh attempt, something went wrong
|
|
689
|
+
if (!token) {
|
|
690
|
+
if (this._onUnauthorized) {
|
|
691
|
+
this._onUnauthorized();
|
|
692
|
+
}
|
|
693
|
+
throw new Error('Authentication required, token unavailable after refresh attempt.');
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
else if (!_isInternalRefresh && sendJWT === 'optional' && !this._apiKey) {
|
|
698
|
+
// Optional auth: send a valid token if we have one so the caller keeps
|
|
699
|
+
// their identity, but proceed anonymously (no token) rather than throwing
|
|
700
|
+
// when there's no session or a refresh fails. Used by public routes that
|
|
701
|
+
// accept both authenticated and anonymous callers.
|
|
702
|
+
if (token && this._isTokenExpired()) {
|
|
703
|
+
try {
|
|
704
|
+
await this._refreshToken();
|
|
705
|
+
token = this._getAccessToken();
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
token = null; // proceed without auth
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const headers = {
|
|
713
|
+
'Content-Type': 'application/json',
|
|
714
|
+
...customHeaders,
|
|
715
|
+
};
|
|
716
|
+
if (sendJWT && token) {
|
|
717
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
718
|
+
}
|
|
719
|
+
const config = {
|
|
720
|
+
method,
|
|
721
|
+
headers,
|
|
722
|
+
};
|
|
723
|
+
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) {
|
|
724
|
+
if (headers['Content-Type'] === 'application/json') {
|
|
725
|
+
config.body = JSON.stringify(body);
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
// For non-JSON, pass body directly (e.g., FormData)
|
|
729
|
+
config.body = body;
|
|
730
|
+
// Let fetch handle Content-Type for FormData, etc.
|
|
731
|
+
delete headers['Content-Type'];
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Ensure params is a proper object to prevent string iteration
|
|
735
|
+
const safeParams = (params && typeof params === 'object' && !Array.isArray(params)) ? params : {};
|
|
736
|
+
// Filter undefined/null params and convert to URL-safe format using enhanced parseUrlParams
|
|
737
|
+
const filteredParams = Object.entries(safeParams)
|
|
738
|
+
.filter(([, value]) => value !== undefined && value !== null)
|
|
739
|
+
.reduce((acc, [key, value]) => {
|
|
740
|
+
acc[key] = value;
|
|
741
|
+
return acc;
|
|
742
|
+
}, {});
|
|
743
|
+
// Use enhanced parseUrlParams utility with 'repeat' format for arrays
|
|
744
|
+
const searchParams = paramsToUrlSearchParams(filteredParams, { arrayFormat: 'repeat' });
|
|
745
|
+
const url = `${this.baseURL}${endpoint}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
|
746
|
+
this._log(`[DEBUG] API Request: ${method} ${url}`, body ? { Body: body } : '');
|
|
747
|
+
// Snapshot the cache generation so a clear/invalidation that lands while
|
|
748
|
+
// this request is in flight prevents it from caching a now-stale body.
|
|
749
|
+
const cacheGenerationAtStart = this._cacheGeneration;
|
|
750
|
+
// Create the actual request promise
|
|
751
|
+
const requestPromise = this._executeRequest(url, config);
|
|
752
|
+
// Store pending promise for concurrent request handling
|
|
753
|
+
if (shouldUseCache) {
|
|
754
|
+
this._getPendingPromise(cacheKey, requestPromise);
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
const result = await requestPromise;
|
|
758
|
+
// Cache the successful response (unless it was invalidated mid-flight).
|
|
759
|
+
if (shouldUseCache && result !== null && this._cacheGeneration === cacheGenerationAtStart) {
|
|
760
|
+
this._setCachedResponse(cacheKey, result);
|
|
761
|
+
}
|
|
762
|
+
// A successful write evicts cached reads for the same resource so the
|
|
763
|
+
// next GET returns fresh data (read-after-write consistency).
|
|
764
|
+
if (this.enableCache && this.invalidateCacheOnMutation && !_isInternalRefresh && this._isMutation(method, endpoint)) {
|
|
765
|
+
this._invalidateForMutation(endpoint);
|
|
766
|
+
}
|
|
767
|
+
return result;
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
// Clear pending promise
|
|
771
|
+
if (shouldUseCache) {
|
|
772
|
+
this._clearPendingPromise(cacheKey);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Executes the actual HTTP request.
|
|
778
|
+
* @param url - The complete URL to request.
|
|
779
|
+
* @param config - The fetch configuration.
|
|
780
|
+
* @returns The response data.
|
|
781
|
+
* @private
|
|
782
|
+
*/
|
|
783
|
+
async _executeRequest(url, config) {
|
|
784
|
+
try {
|
|
785
|
+
const response = await this.fetch(url, config);
|
|
786
|
+
if (!response.ok) {
|
|
787
|
+
let errorData;
|
|
788
|
+
try {
|
|
789
|
+
errorData = await response.json();
|
|
790
|
+
this._log('[DEBUG] API Error Response Body:', errorData);
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
errorData = { message: response.statusText };
|
|
794
|
+
this._log(`[DEBUG] API Error Response Text: ${response.statusText}`);
|
|
795
|
+
}
|
|
796
|
+
// A `{ error: 'maintenance' }` body on a 418/503 → the site is in a
|
|
797
|
+
// maintenance window (the edge rule returns exactly this to
|
|
798
|
+
// non-allowlisted clients). Notify the host so an already-loaded /
|
|
799
|
+
// cached SPA can show a maintenance message. Gating on the BODY — not
|
|
800
|
+
// just the status — avoids mislabeling a transient/real outage (which
|
|
801
|
+
// returns a different body) as planned maintenance.
|
|
802
|
+
//
|
|
803
|
+
// 418 and 503 are accepted: a Cloudflare WAF "Block" custom response can
|
|
804
|
+
// only be a 4xx, and 418 ("I'm a teapot") is a guaranteed-unused
|
|
805
|
+
// sentinel no real API path emits — so it can never collide. A Worker /
|
|
806
|
+
// real origin can instead return the semantically-correct 503.
|
|
807
|
+
if ((response.status === 418 || response.status === 503) &&
|
|
808
|
+
errorData?.error === 'maintenance' &&
|
|
809
|
+
this._onMaintenance) {
|
|
810
|
+
try {
|
|
811
|
+
this._onMaintenance();
|
|
812
|
+
}
|
|
813
|
+
catch { /* handler must never break the request path */ }
|
|
814
|
+
}
|
|
815
|
+
// Create a more informative error object
|
|
816
|
+
const error = new Error(errorData.message || errorData.type || `HTTP error! status: ${response.status}`);
|
|
817
|
+
error.status = response.status;
|
|
818
|
+
error.data = errorData;
|
|
819
|
+
throw error;
|
|
820
|
+
}
|
|
821
|
+
if (response.status === 204) {
|
|
822
|
+
this._log(`[DEBUG] API Response: ${response.status} No Content`);
|
|
823
|
+
return null; // Return null for 204 No Content
|
|
824
|
+
}
|
|
825
|
+
try {
|
|
826
|
+
const responseData = await response.json();
|
|
827
|
+
this._log(`[DEBUG] API Response: ${response.status}`, responseData);
|
|
828
|
+
return responseData;
|
|
829
|
+
}
|
|
830
|
+
catch (error) {
|
|
831
|
+
this._log(`[DEBUG] API Response OK but failed to parse as JSON: ${response.status}`, response);
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
const apiError = error; // Assert the error type
|
|
837
|
+
if (!(error instanceof Error && apiError.status)) {
|
|
838
|
+
// Only log generic network/fetch errors here
|
|
839
|
+
// Specific API errors (response.ok === false) are logged above
|
|
840
|
+
console.error(`[ERROR] API request failed: ${config.method} ${url}`, error);
|
|
841
|
+
}
|
|
842
|
+
else {
|
|
843
|
+
this._log(`[DEBUG] API Error Caught: ${config.method} ${url}`, error);
|
|
844
|
+
}
|
|
845
|
+
throw error; // Re-throw the error for handling by the caller
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Gets the current user's ID from the JWT token.
|
|
850
|
+
* @returns {string | null} The user ID if available, null otherwise.
|
|
851
|
+
*/
|
|
852
|
+
getUserId() {
|
|
853
|
+
const token = this._getAccessToken();
|
|
854
|
+
if (!token)
|
|
855
|
+
return null;
|
|
856
|
+
try {
|
|
857
|
+
const decoded = jwtDecode(token);
|
|
858
|
+
return decoded?.sub || null;
|
|
859
|
+
}
|
|
860
|
+
catch (e) {
|
|
861
|
+
console.error('Failed to decode token:', e);
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Gets the SDK version information including build timestamp and hash.
|
|
867
|
+
* Useful for debugging and verifying which SDK version is in use.
|
|
868
|
+
* @returns {SDKVersionInfo} SDK version, build timestamp, build hash, and git commit
|
|
869
|
+
*/
|
|
870
|
+
getVersion() {
|
|
871
|
+
return SDK_VERSION;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Clears the response cache.
|
|
875
|
+
* @public
|
|
876
|
+
*/
|
|
877
|
+
clearCache() {
|
|
878
|
+
this._cache.clear();
|
|
879
|
+
this._cacheGeneration++;
|
|
880
|
+
this._log('[DEBUG] Cache cleared');
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Gets cache statistics for debugging.
|
|
884
|
+
* @returns Object containing cache statistics.
|
|
885
|
+
* @public
|
|
886
|
+
*/
|
|
887
|
+
getCacheStats() {
|
|
888
|
+
const entries = Array.from(this._cache.entries()).map(([key, entry]) => ({
|
|
889
|
+
key,
|
|
890
|
+
timestamp: entry.timestamp,
|
|
891
|
+
hasData: entry.data !== null && entry.data !== undefined,
|
|
892
|
+
hasPendingPromise: !!entry.promise
|
|
893
|
+
}));
|
|
894
|
+
return {
|
|
895
|
+
size: this._cache.size,
|
|
896
|
+
entries
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
export default NuramaClient;
|
|
901
|
+
// Removed redundant export: export type { UserMethods, AssetMethods };
|
|
902
|
+
//# sourceMappingURL=NuramaClient.js.map
|