@florentleveque/mural-mcp-serveur 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +1472 -0
- package/build/index.js.map +1 -0
- package/build/mural-client.d.ts +66 -0
- package/build/mural-client.d.ts.map +1 -0
- package/build/mural-client.js +706 -0
- package/build/mural-client.js.map +1 -0
- package/build/oauth.d.ts +22 -0
- package/build/oauth.d.ts.map +1 -0
- package/build/oauth.js +278 -0
- package/build/oauth.js.map +1 -0
- package/build/rate-limiter.d.ts +23 -0
- package/build/rate-limiter.d.ts.map +1 -0
- package/build/rate-limiter.js +163 -0
- package/build/rate-limiter.js.map +1 -0
- package/build/types.d.ts +245 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { MuralOAuth } from './oauth.js';
|
|
2
|
+
import { MuralRateLimiter } from './rate-limiter.js';
|
|
3
|
+
const MURAL_API_BASE = 'https://app.mural.co/api/public/v1';
|
|
4
|
+
// Global authentication promise to prevent multiple concurrent auth flows
|
|
5
|
+
let globalAuthPromise = null;
|
|
6
|
+
export class MuralClient {
|
|
7
|
+
oauth;
|
|
8
|
+
baseUrl;
|
|
9
|
+
rateLimiter;
|
|
10
|
+
constructor(clientId, clientSecret, redirectUri, rateLimitConfig) {
|
|
11
|
+
this.oauth = new MuralOAuth(clientId, clientSecret, redirectUri);
|
|
12
|
+
this.baseUrl = MURAL_API_BASE;
|
|
13
|
+
this.rateLimiter = new MuralRateLimiter(rateLimitConfig);
|
|
14
|
+
}
|
|
15
|
+
async getAccessToken() {
|
|
16
|
+
// If authentication is already in progress globally, wait for it
|
|
17
|
+
if (globalAuthPromise) {
|
|
18
|
+
return globalAuthPromise;
|
|
19
|
+
}
|
|
20
|
+
// Start new authentication and store globally
|
|
21
|
+
globalAuthPromise = this.oauth.getValidAccessToken();
|
|
22
|
+
try {
|
|
23
|
+
const token = await globalAuthPromise;
|
|
24
|
+
return token;
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
// Clear the global promise when done (success or failure)
|
|
28
|
+
globalAuthPromise = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async makeAuthenticatedRequest(endpoint, options = {}, maxRetries = 3) {
|
|
32
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
33
|
+
// Check rate limits before making request
|
|
34
|
+
const rateLimitCheck = await this.rateLimiter.canMakeRequest();
|
|
35
|
+
if (!rateLimitCheck.allowed) {
|
|
36
|
+
if (rateLimitCheck.waitTimeMs && rateLimitCheck.waitTimeMs <= 5000) {
|
|
37
|
+
// If wait time is reasonable (≤5s), wait and retry
|
|
38
|
+
console.warn(`Rate limit hit: ${rateLimitCheck.reason}. Waiting ${rateLimitCheck.waitTimeMs}ms...`);
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, rateLimitCheck.waitTimeMs));
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
// If wait time is too long or not available, throw error
|
|
44
|
+
throw new Error(`Rate limit exceeded: ${rateLimitCheck.reason}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Consume rate limit token
|
|
48
|
+
const consumed = await this.rateLimiter.consumeRequest();
|
|
49
|
+
if (!consumed) {
|
|
50
|
+
throw new Error('Failed to consume rate limit token');
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const accessToken = await this.getAccessToken();
|
|
54
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
55
|
+
const headers = {
|
|
56
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
57
|
+
'Accept': 'application/json',
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
...options.headers
|
|
60
|
+
};
|
|
61
|
+
const response = await fetch(url, {
|
|
62
|
+
...options,
|
|
63
|
+
headers
|
|
64
|
+
});
|
|
65
|
+
// Handle rate limit responses from the API
|
|
66
|
+
if (response.status === 429) {
|
|
67
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
68
|
+
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000;
|
|
69
|
+
if (attempt < maxRetries && waitTime <= 30000) {
|
|
70
|
+
console.warn(`API rate limit hit (HTTP 429). Retrying after ${waitTime}ms... (attempt ${attempt + 1}/${maxRetries + 1})`);
|
|
71
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
throw new Error(`API rate limit exceeded (HTTP 429). Max retries reached or wait time too long.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
|
80
|
+
try {
|
|
81
|
+
const errorData = await response.json();
|
|
82
|
+
if (errorData.message) {
|
|
83
|
+
errorMessage += ` - ${errorData.message}`;
|
|
84
|
+
}
|
|
85
|
+
if (errorData.errors && Array.isArray(errorData.errors)) {
|
|
86
|
+
errorMessage += ` - ${errorData.errors.join(', ')}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// If error response isn't JSON, use status text
|
|
91
|
+
}
|
|
92
|
+
// Don't retry on client errors (4xx except 429) or auth errors
|
|
93
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
|
94
|
+
throw new Error(`Mural API request failed: ${errorMessage}`);
|
|
95
|
+
}
|
|
96
|
+
// Retry on server errors (5xx) with exponential backoff
|
|
97
|
+
if (attempt < maxRetries && response.status >= 500) {
|
|
98
|
+
const backoffTime = Math.pow(2, attempt) * 1000;
|
|
99
|
+
console.warn(`Server error (${response.status}). Retrying after ${backoffTime}ms... (attempt ${attempt + 1}/${maxRetries + 1})`);
|
|
100
|
+
await new Promise(resolve => setTimeout(resolve, backoffTime));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`Mural API request failed: ${errorMessage}`);
|
|
104
|
+
}
|
|
105
|
+
// Some endpoints (e.g. DELETE) return 204 No Content / an empty body;
|
|
106
|
+
// calling response.json() on those would throw, so handle empty bodies.
|
|
107
|
+
if (response.status === 204) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const text = await response.text();
|
|
111
|
+
return (text ? JSON.parse(text) : undefined);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
// If it's our last attempt or a non-retryable error, throw
|
|
115
|
+
if (attempt === maxRetries || error instanceof Error && (error.message.includes('Rate limit exceeded') ||
|
|
116
|
+
error.message.includes('authentication') ||
|
|
117
|
+
error.message.includes('authorization'))) {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
// Otherwise, wait and retry with exponential backoff
|
|
121
|
+
const backoffTime = Math.pow(2, attempt) * 1000;
|
|
122
|
+
console.warn(`Request failed: ${error}. Retrying after ${backoffTime}ms... (attempt ${attempt + 1}/${maxRetries + 1})`);
|
|
123
|
+
await new Promise(resolve => setTimeout(resolve, backoffTime));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
throw new Error('Max retries exceeded');
|
|
127
|
+
}
|
|
128
|
+
async getWorkspaces(limit, offset) {
|
|
129
|
+
const params = new URLSearchParams();
|
|
130
|
+
if (limit !== undefined) {
|
|
131
|
+
params.append('limit', limit.toString());
|
|
132
|
+
}
|
|
133
|
+
if (offset !== undefined) {
|
|
134
|
+
params.append('offset', offset.toString());
|
|
135
|
+
}
|
|
136
|
+
const queryString = params.toString();
|
|
137
|
+
const endpoint = `/workspaces${queryString ? `?${queryString}` : ''}`;
|
|
138
|
+
try {
|
|
139
|
+
const response = await this.makeAuthenticatedRequest(endpoint);
|
|
140
|
+
// The API returns workspaces in a "value" property
|
|
141
|
+
return response.value && Array.isArray(response.value) ? response.value : [];
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
console.error('Failed to fetch workspaces:', error);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async getWorkspace(workspaceId) {
|
|
149
|
+
try {
|
|
150
|
+
const workspace = await this.makeAuthenticatedRequest(`/workspaces/${workspaceId}`);
|
|
151
|
+
return workspace;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
console.error(`Failed to fetch workspace ${workspaceId}:`, error);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async testConnection() {
|
|
159
|
+
try {
|
|
160
|
+
await this.getWorkspaces(1);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
console.error('Connection test failed:', error);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async clearAuthentication() {
|
|
169
|
+
// Clear the global auth promise
|
|
170
|
+
globalAuthPromise = null;
|
|
171
|
+
await this.oauth.clearTokens();
|
|
172
|
+
}
|
|
173
|
+
async getRateLimitStatus() {
|
|
174
|
+
return await this.rateLimiter.getRateLimitStatus();
|
|
175
|
+
}
|
|
176
|
+
async resetRateLimits() {
|
|
177
|
+
await this.rateLimiter.reset();
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Fetch every page of a cursor-paginated endpoint and return a flat array.
|
|
181
|
+
* The Mural API paginates list endpoints with `limit` + a `next` cursor.
|
|
182
|
+
* Checks the OAuth scope once, then follows `next` until exhausted or the
|
|
183
|
+
* safety cap (`maxPages`) is reached. Existing query params are preserved.
|
|
184
|
+
*/
|
|
185
|
+
async fetchAllPages(basePath, scope, maxPages = 100) {
|
|
186
|
+
const scopeCheck = await this.checkScope(scope);
|
|
187
|
+
if (!scopeCheck.hasScope) {
|
|
188
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has '${scope}' scope and re-authenticate.`);
|
|
189
|
+
}
|
|
190
|
+
const items = [];
|
|
191
|
+
const [path, existingQuery = ''] = basePath.split('?');
|
|
192
|
+
let next;
|
|
193
|
+
let page = 0;
|
|
194
|
+
do {
|
|
195
|
+
const params = new URLSearchParams(existingQuery);
|
|
196
|
+
if (next)
|
|
197
|
+
params.set('next', next);
|
|
198
|
+
const queryString = params.toString();
|
|
199
|
+
const endpoint = `${path}${queryString ? `?${queryString}` : ''}`;
|
|
200
|
+
const response = await this.makeAuthenticatedRequest(endpoint);
|
|
201
|
+
const pageItems = response.value ?? response.widgets ?? response;
|
|
202
|
+
if (Array.isArray(pageItems)) {
|
|
203
|
+
items.push(...pageItems);
|
|
204
|
+
}
|
|
205
|
+
next = response.next;
|
|
206
|
+
page++;
|
|
207
|
+
} while (next && page < maxPages);
|
|
208
|
+
if (next && page >= maxPages) {
|
|
209
|
+
console.error(`fetchAllPages: reached the ${maxPages}-page cap for ${path}; results may be truncated.`);
|
|
210
|
+
}
|
|
211
|
+
return items;
|
|
212
|
+
}
|
|
213
|
+
async getWorkspaceRooms(workspaceId, openOnly = false) {
|
|
214
|
+
try {
|
|
215
|
+
const endpoint = openOnly
|
|
216
|
+
? `/workspaces/${workspaceId}/rooms/open`
|
|
217
|
+
: `/workspaces/${workspaceId}/rooms`;
|
|
218
|
+
return await this.fetchAllPages(endpoint, 'rooms:read');
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (error instanceof Error && (error.message.includes('403') || error.message.includes('scope'))) {
|
|
222
|
+
const scopeCheck = await this.checkScope('rooms:read');
|
|
223
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'rooms:read' scope and re-authenticate.`);
|
|
224
|
+
}
|
|
225
|
+
console.error(`Failed to fetch rooms for workspace ${workspaceId}:`, error);
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async getWorkspaceTemplates(workspaceId, searchQuery, withoutDefault = false) {
|
|
230
|
+
try {
|
|
231
|
+
let endpoint;
|
|
232
|
+
if (searchQuery && searchQuery.trim()) {
|
|
233
|
+
endpoint = `/search/${workspaceId}/templates?q=${encodeURIComponent(searchQuery.trim())}`;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
endpoint = `/workspaces/${workspaceId}/templates`;
|
|
237
|
+
if (withoutDefault) {
|
|
238
|
+
endpoint += '?withoutDefault=true';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return await this.fetchAllPages(endpoint, 'templates:read');
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (error instanceof Error && (error.message.includes('403') || error.message.includes('scope'))) {
|
|
245
|
+
const scopeCheck = await this.checkScope('templates:read');
|
|
246
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'templates:read' scope and re-authenticate.`);
|
|
247
|
+
}
|
|
248
|
+
console.error(`Failed to fetch templates for workspace ${workspaceId}:`, error);
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async createMuralFromTemplate(templateId, title, roomId, folderId) {
|
|
253
|
+
try {
|
|
254
|
+
const scopeCheck = await this.checkScope('murals:write');
|
|
255
|
+
if (!scopeCheck.hasScope) {
|
|
256
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:write' scope and re-authenticate.`);
|
|
257
|
+
}
|
|
258
|
+
const body = { title, roomId };
|
|
259
|
+
if (folderId) {
|
|
260
|
+
body.folderId = folderId;
|
|
261
|
+
}
|
|
262
|
+
const response = await this.makeAuthenticatedRequest(`/templates/${encodeURIComponent(templateId)}/murals`, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
body: JSON.stringify(body)
|
|
265
|
+
});
|
|
266
|
+
return response.value || response;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
console.error(`Failed to create mural from template ${templateId}:`, error);
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async createRoom(workspaceId, name, type, description, confidential) {
|
|
274
|
+
try {
|
|
275
|
+
const scopeCheck = await this.checkScope('rooms:write');
|
|
276
|
+
if (!scopeCheck.hasScope) {
|
|
277
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'rooms:write' scope and re-authenticate.`);
|
|
278
|
+
}
|
|
279
|
+
const body = { name, type, workspaceId };
|
|
280
|
+
if (description !== undefined) {
|
|
281
|
+
body.description = description;
|
|
282
|
+
}
|
|
283
|
+
if (confidential !== undefined) {
|
|
284
|
+
body.confidential = confidential;
|
|
285
|
+
}
|
|
286
|
+
const response = await this.makeAuthenticatedRequest('/rooms', {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
body: JSON.stringify(body)
|
|
289
|
+
});
|
|
290
|
+
return response.value || response;
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
console.error(`Failed to create room "${name}" in workspace ${workspaceId}:`, error);
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async createMural(roomId, options = {}) {
|
|
298
|
+
try {
|
|
299
|
+
const scopeCheck = await this.checkScope('murals:write');
|
|
300
|
+
if (!scopeCheck.hasScope) {
|
|
301
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:write' scope and re-authenticate.`);
|
|
302
|
+
}
|
|
303
|
+
const body = { roomId, ...options };
|
|
304
|
+
const response = await this.makeAuthenticatedRequest('/murals', {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
body: JSON.stringify(body)
|
|
307
|
+
});
|
|
308
|
+
return response.value || response;
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
console.error(`Failed to create mural in room ${roomId}:`, error);
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
async updateMural(muralId, updates) {
|
|
316
|
+
try {
|
|
317
|
+
const scopeCheck = await this.checkScope('murals:write');
|
|
318
|
+
if (!scopeCheck.hasScope) {
|
|
319
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:write' scope and re-authenticate.`);
|
|
320
|
+
}
|
|
321
|
+
const response = await this.makeAuthenticatedRequest(`/murals/${encodeURIComponent(muralId)}`, {
|
|
322
|
+
method: 'PATCH',
|
|
323
|
+
body: JSON.stringify(updates)
|
|
324
|
+
});
|
|
325
|
+
return response.value || response;
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
console.error(`Failed to update mural ${muralId}:`, error);
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async deleteMural(muralId) {
|
|
333
|
+
try {
|
|
334
|
+
const scopeCheck = await this.checkScope('murals:write');
|
|
335
|
+
if (!scopeCheck.hasScope) {
|
|
336
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:write' scope and re-authenticate.`);
|
|
337
|
+
}
|
|
338
|
+
await this.makeAuthenticatedRequest(`/murals/${encodeURIComponent(muralId)}`, { method: 'DELETE' });
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
console.error(`Failed to delete mural ${muralId}:`, error);
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async duplicateMural(muralId, roomId, title, options = {}) {
|
|
346
|
+
try {
|
|
347
|
+
const scopeCheck = await this.checkScope('murals:write');
|
|
348
|
+
if (!scopeCheck.hasScope) {
|
|
349
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:write' scope and re-authenticate.`);
|
|
350
|
+
}
|
|
351
|
+
const body = { roomId, title, ...options };
|
|
352
|
+
const response = await this.makeAuthenticatedRequest(`/murals/${encodeURIComponent(muralId)}/duplicate`, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
body: JSON.stringify(body)
|
|
355
|
+
});
|
|
356
|
+
return response.value || response;
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
console.error(`Failed to duplicate mural ${muralId}:`, error);
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async exportMural(muralId, downloadFormat) {
|
|
364
|
+
try {
|
|
365
|
+
const scopeCheck = await this.checkScope('murals:read');
|
|
366
|
+
if (!scopeCheck.hasScope) {
|
|
367
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:read' scope and re-authenticate.`);
|
|
368
|
+
}
|
|
369
|
+
const response = await this.makeAuthenticatedRequest(`/murals/${encodeURIComponent(muralId)}/export`, {
|
|
370
|
+
method: 'POST',
|
|
371
|
+
body: JSON.stringify({ downloadFormat })
|
|
372
|
+
});
|
|
373
|
+
return response.value || response;
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
console.error(`Failed to export mural ${muralId}:`, error);
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async getWorkspaceMurals(workspaceId) {
|
|
381
|
+
try {
|
|
382
|
+
// Check if user has required scope first
|
|
383
|
+
const scopeCheck = await this.checkScope('murals:read');
|
|
384
|
+
if (!scopeCheck.hasScope) {
|
|
385
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:read' scope and re-authenticate.`);
|
|
386
|
+
}
|
|
387
|
+
// Try RESTful endpoint (legacy endpoints appear to be deprecated/non-existent)
|
|
388
|
+
const response = await this.makeAuthenticatedRequest(`/workspaces/${workspaceId}/murals`);
|
|
389
|
+
// The API response structure may vary, handle both direct array and wrapped response
|
|
390
|
+
const murals = response.value || response.murals || response;
|
|
391
|
+
return Array.isArray(murals) ? murals : [];
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
// Check if error is scope-related and provide helpful message
|
|
395
|
+
if (error instanceof Error) {
|
|
396
|
+
if (error.message.includes('403') || error.message.includes('scope')) {
|
|
397
|
+
const scopeCheck = await this.checkScope('murals:read');
|
|
398
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:read' scope and re-authenticate.`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
console.error(`Failed to fetch murals for workspace ${workspaceId}:`, error);
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async getRoomMurals(roomId) {
|
|
406
|
+
try {
|
|
407
|
+
// Check if user has required scope first
|
|
408
|
+
const scopeCheck = await this.checkScope('murals:read');
|
|
409
|
+
if (!scopeCheck.hasScope) {
|
|
410
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:read' scope and re-authenticate.`);
|
|
411
|
+
}
|
|
412
|
+
// Try RESTful endpoint (legacy endpoints appear to be deprecated/non-existent)
|
|
413
|
+
const response = await this.makeAuthenticatedRequest(`/rooms/${roomId}/murals`);
|
|
414
|
+
// The API response structure may vary, handle both direct array and wrapped response
|
|
415
|
+
const murals = response.value || response.murals || response;
|
|
416
|
+
return Array.isArray(murals) ? murals : [];
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
// Check if error is scope-related and provide helpful message
|
|
420
|
+
if (error instanceof Error) {
|
|
421
|
+
if (error.message.includes('403') || error.message.includes('scope')) {
|
|
422
|
+
const scopeCheck = await this.checkScope('murals:read');
|
|
423
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:read' scope and re-authenticate.`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
console.error(`Failed to fetch murals for room ${roomId}:`, error);
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
async getMural(muralId) {
|
|
431
|
+
try {
|
|
432
|
+
// Check if user has required scope first
|
|
433
|
+
const scopeCheck = await this.checkScope('murals:read');
|
|
434
|
+
if (!scopeCheck.hasScope) {
|
|
435
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:read' scope and re-authenticate.`);
|
|
436
|
+
}
|
|
437
|
+
const mural = await this.makeAuthenticatedRequest(`/murals/${muralId}`);
|
|
438
|
+
return mural;
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
// Check if error is scope-related and provide helpful message
|
|
442
|
+
if (error instanceof Error) {
|
|
443
|
+
if (error.message.includes('403') || error.message.includes('scope')) {
|
|
444
|
+
const scopeCheck = await this.checkScope('murals:read');
|
|
445
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:read' scope and re-authenticate.`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
console.error(`Failed to fetch mural ${muralId}:`, error);
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async getCurrentUser() {
|
|
453
|
+
try {
|
|
454
|
+
const user = await this.makeAuthenticatedRequest(`/users/me`);
|
|
455
|
+
return user;
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
console.error('Failed to fetch current user:', error);
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async getUserScopes() {
|
|
463
|
+
try {
|
|
464
|
+
// Extract scopes from the stored OAuth token (primary method)
|
|
465
|
+
const tokens = await this.oauth.getStoredTokens();
|
|
466
|
+
if (!tokens) {
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
// First check if scopes are in the top-level scope field
|
|
470
|
+
if (tokens.scope) {
|
|
471
|
+
return tokens.scope.split(' ').filter(scope => scope.trim() !== '');
|
|
472
|
+
}
|
|
473
|
+
// If no top-level scope field, try to decode JWT access token
|
|
474
|
+
if (tokens.access_token) {
|
|
475
|
+
try {
|
|
476
|
+
// Decode JWT payload (without verification - just for scope extraction)
|
|
477
|
+
const payloadPart = tokens.access_token.split('.')[1];
|
|
478
|
+
if (payloadPart) {
|
|
479
|
+
const payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString());
|
|
480
|
+
if (payload.scopes && Array.isArray(payload.scopes)) {
|
|
481
|
+
return payload.scopes;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch (jwtError) {
|
|
486
|
+
console.warn('Failed to decode JWT for scope extraction:', jwtError);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// If no stored tokens or scope information, return empty array
|
|
490
|
+
// Don't try to fetch from API as that might require scopes we don't have
|
|
491
|
+
return [];
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
console.error('Failed to get user scopes:', error);
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async checkScope(requiredScope) {
|
|
499
|
+
try {
|
|
500
|
+
const availableScopes = await this.getUserScopes();
|
|
501
|
+
const hasScope = availableScopes.includes(requiredScope);
|
|
502
|
+
return {
|
|
503
|
+
hasScope,
|
|
504
|
+
requiredScope,
|
|
505
|
+
availableScopes,
|
|
506
|
+
message: hasScope
|
|
507
|
+
? `User has required scope: ${requiredScope}`
|
|
508
|
+
: `User missing required scope: ${requiredScope}. Available scopes: ${availableScopes.join(', ') || 'none'}`
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
return {
|
|
513
|
+
hasScope: false,
|
|
514
|
+
requiredScope,
|
|
515
|
+
availableScopes: [],
|
|
516
|
+
message: `Failed to check scopes: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
async debugWorkspacesAPI() {
|
|
521
|
+
const accessToken = await this.oauth.getValidAccessToken();
|
|
522
|
+
const url = `${this.baseUrl}/workspaces`;
|
|
523
|
+
const headers = {
|
|
524
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
525
|
+
'Accept': 'application/json',
|
|
526
|
+
'Content-Type': 'application/json'
|
|
527
|
+
};
|
|
528
|
+
try {
|
|
529
|
+
const response = await fetch(url, { headers });
|
|
530
|
+
const debugInfo = {
|
|
531
|
+
url,
|
|
532
|
+
status: response.status,
|
|
533
|
+
statusText: response.statusText,
|
|
534
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
535
|
+
timestamp: new Date().toISOString()
|
|
536
|
+
};
|
|
537
|
+
let responseData;
|
|
538
|
+
try {
|
|
539
|
+
responseData = await response.json();
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
responseData = await response.text();
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
request: debugInfo,
|
|
546
|
+
response: {
|
|
547
|
+
value: responseData,
|
|
548
|
+
raw: responseData
|
|
549
|
+
},
|
|
550
|
+
success: response.ok
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
return {
|
|
555
|
+
request: { url, headers: { ...headers, Authorization: '[REDACTED]' } },
|
|
556
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
557
|
+
success: false
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// ============================================================================
|
|
562
|
+
// CONTENT API METHODS
|
|
563
|
+
// ============================================================================
|
|
564
|
+
// Widget operations
|
|
565
|
+
async getMuralWidgets(muralId) {
|
|
566
|
+
try {
|
|
567
|
+
// Paginated: follows the API's `next` cursor so all widgets are returned
|
|
568
|
+
// (the endpoint pages at ~100 widgets).
|
|
569
|
+
return await this.fetchAllPages(`/murals/${encodeURIComponent(muralId)}/widgets`, 'murals:read');
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
if (error instanceof Error && (error.message.includes('403') || error.message.includes('scope'))) {
|
|
573
|
+
const scopeCheck = await this.checkScope('murals:read');
|
|
574
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:read' scope and re-authenticate.`);
|
|
575
|
+
}
|
|
576
|
+
console.error(`Failed to fetch widgets for mural ${muralId}:`, error);
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
async getMuralWidget(muralId, widgetId) {
|
|
581
|
+
try {
|
|
582
|
+
const scopeCheck = await this.checkScope('murals:read');
|
|
583
|
+
if (!scopeCheck.hasScope) {
|
|
584
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:read' scope and re-authenticate.`);
|
|
585
|
+
}
|
|
586
|
+
const response = await this.makeAuthenticatedRequest(`/murals/${encodeURIComponent(muralId)}/widgets/${encodeURIComponent(widgetId)}`);
|
|
587
|
+
return response;
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
console.error(`Failed to fetch widget ${widgetId} from mural ${muralId}:`, error);
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
async deleteWidget(muralId, widgetId) {
|
|
595
|
+
try {
|
|
596
|
+
const scopeCheck = await this.checkScope('murals:write');
|
|
597
|
+
if (!scopeCheck.hasScope) {
|
|
598
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:write' scope and re-authenticate.`);
|
|
599
|
+
}
|
|
600
|
+
await this.makeAuthenticatedRequest(`/murals/${encodeURIComponent(muralId)}/widgets/${encodeURIComponent(widgetId)}`, {
|
|
601
|
+
method: 'DELETE'
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
console.error(`Failed to delete widget ${widgetId} from mural ${muralId}:`, error);
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Widget creation methods
|
|
610
|
+
async createStickyNotes(muralId, stickyNotes) {
|
|
611
|
+
try {
|
|
612
|
+
const scopeCheck = await this.checkScope('murals:write');
|
|
613
|
+
if (!scopeCheck.hasScope) {
|
|
614
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:write' scope and re-authenticate.`);
|
|
615
|
+
}
|
|
616
|
+
if (stickyNotes.length > 1000) {
|
|
617
|
+
throw new Error('Maximum 1000 sticky notes per request');
|
|
618
|
+
}
|
|
619
|
+
const response = await this.makeAuthenticatedRequest(`/murals/${encodeURIComponent(muralId)}/widgets/sticky-note`, {
|
|
620
|
+
method: 'POST',
|
|
621
|
+
body: JSON.stringify(stickyNotes)
|
|
622
|
+
});
|
|
623
|
+
return response.value || response || [];
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
console.error(`Failed to create sticky notes for mural ${muralId}:`, error);
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// ============================================================================
|
|
631
|
+
// WIDGET UPDATE METHODS (PATCH OPERATIONS)
|
|
632
|
+
// ============================================================================
|
|
633
|
+
async updateStickyNote(muralId, widgetId, updates) {
|
|
634
|
+
try {
|
|
635
|
+
const scopeCheck = await this.checkScope('murals:write');
|
|
636
|
+
if (!scopeCheck.hasScope) {
|
|
637
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:write' scope and re-authenticate.`);
|
|
638
|
+
}
|
|
639
|
+
const response = await this.makeAuthenticatedRequest(`/murals/${encodeURIComponent(muralId)}/widgets/sticky-note/${encodeURIComponent(widgetId)}`, {
|
|
640
|
+
method: 'PATCH',
|
|
641
|
+
body: JSON.stringify(updates)
|
|
642
|
+
});
|
|
643
|
+
return response.value || response;
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
console.error(`Failed to update sticky note ${widgetId} in mural ${muralId}:`, error);
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// ============================================================================
|
|
651
|
+
// GENERIC WIDGET CREATION / UPDATE (used by shape, arrow, text-box, title, area)
|
|
652
|
+
// ============================================================================
|
|
653
|
+
async createWidgetsOfKind(muralId, kind, widgets) {
|
|
654
|
+
const scopeCheck = await this.checkScope('murals:write');
|
|
655
|
+
if (!scopeCheck.hasScope) {
|
|
656
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:write' scope and re-authenticate.`);
|
|
657
|
+
}
|
|
658
|
+
const response = await this.makeAuthenticatedRequest(`/murals/${encodeURIComponent(muralId)}/widgets/${kind}`, {
|
|
659
|
+
method: 'POST',
|
|
660
|
+
body: JSON.stringify(widgets),
|
|
661
|
+
});
|
|
662
|
+
return response.value || response || [];
|
|
663
|
+
}
|
|
664
|
+
async updateWidgetOfKind(muralId, kind, widgetId, updates) {
|
|
665
|
+
const scopeCheck = await this.checkScope('murals:write');
|
|
666
|
+
if (!scopeCheck.hasScope) {
|
|
667
|
+
throw new Error(`Permission denied: ${scopeCheck.message}. Please ensure your Mural OAuth app has 'murals:write' scope and re-authenticate.`);
|
|
668
|
+
}
|
|
669
|
+
const response = await this.makeAuthenticatedRequest(`/murals/${encodeURIComponent(muralId)}/widgets/${kind}/${encodeURIComponent(widgetId)}`, {
|
|
670
|
+
method: 'PATCH',
|
|
671
|
+
body: JSON.stringify(updates),
|
|
672
|
+
});
|
|
673
|
+
return response.value || response;
|
|
674
|
+
}
|
|
675
|
+
async createShapes(muralId, shapes) {
|
|
676
|
+
return this.createWidgetsOfKind(muralId, 'shape', shapes);
|
|
677
|
+
}
|
|
678
|
+
async createArrows(muralId, arrows) {
|
|
679
|
+
return this.createWidgetsOfKind(muralId, 'arrow', arrows);
|
|
680
|
+
}
|
|
681
|
+
async createTextBoxes(muralId, textBoxes) {
|
|
682
|
+
return this.createWidgetsOfKind(muralId, 'text-box', textBoxes);
|
|
683
|
+
}
|
|
684
|
+
async createTitles(muralId, titles) {
|
|
685
|
+
return this.createWidgetsOfKind(muralId, 'title', titles);
|
|
686
|
+
}
|
|
687
|
+
async createAreas(muralId, areas) {
|
|
688
|
+
return this.createWidgetsOfKind(muralId, 'area', areas);
|
|
689
|
+
}
|
|
690
|
+
async updateShape(muralId, widgetId, updates) {
|
|
691
|
+
return this.updateWidgetOfKind(muralId, 'shape', widgetId, updates);
|
|
692
|
+
}
|
|
693
|
+
async updateArrow(muralId, widgetId, updates) {
|
|
694
|
+
return this.updateWidgetOfKind(muralId, 'arrow', widgetId, updates);
|
|
695
|
+
}
|
|
696
|
+
async updateTextBox(muralId, widgetId, updates) {
|
|
697
|
+
return this.updateWidgetOfKind(muralId, 'text-box', widgetId, updates);
|
|
698
|
+
}
|
|
699
|
+
async updateTitle(muralId, widgetId, updates) {
|
|
700
|
+
return this.updateWidgetOfKind(muralId, 'title', widgetId, updates);
|
|
701
|
+
}
|
|
702
|
+
async updateArea(muralId, widgetId, updates) {
|
|
703
|
+
return this.updateWidgetOfKind(muralId, 'area', widgetId, updates);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
//# sourceMappingURL=mural-client.js.map
|