From 90f4921fae098dd866bc188d620b50f1f9ff033a Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Fri, 13 Mar 2026 19:10:19 +0900 Subject: [PATCH 1/3] fix: improve 401 error messages and document required scopes for scoped API tokens When using scoped API tokens with api.atlassian.com, users receive unhelpful 401 errors with no guidance on what went wrong. This adds an axios response interceptor that detects 401 errors and provides context-specific hints (scoped token vs basic auth vs bearer), and documents the required classic scopes in README and SKILL.md. Closes #76 --- .claude/skills/confluence/SKILL.md | 5 +++ README.md | 16 ++++++++ lib/confluence-client.js | 65 ++++++++++++++++++++++++++---- tests/confluence-client.test.js | 60 +++++++++++++++++++++++++-- 4 files changed, 136 insertions(+), 10 deletions(-) diff --git a/.claude/skills/confluence/SKILL.md b/.claude/skills/confluence/SKILL.md index f3a6002..d414bbd 100644 --- a/.claude/skills/confluence/SKILL.md +++ b/.claude/skills/confluence/SKILL.md @@ -64,6 +64,11 @@ export CONFLUENCE_EMAIL="user@company.com" export CONFLUENCE_API_TOKEN="your-scoped-token" ``` +Required classic scopes for scoped tokens: +- Read-only: `read:confluence-content.all`, `read:confluence-space.summary`, `search:confluence` +- Write: add `write:confluence-content`, `write:confluence-file`, `write:confluence-space` +- Attachments: `readonly:content.attachment:confluence` (download), `write:confluence-file` (upload) + **Read-only mode (recommended for AI agents):** Prevents all write operations (create, update, delete, move, etc.) at the profile level. Useful when giving an AI agent access to Confluence for reading only. diff --git a/README.md b/README.md index b558182..279558e 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,22 @@ Scoped tokens restrict access to specific Atlassian products and permissions, fo - **API path:** `/ex/confluence//wiki/rest/api` - **Auth type:** `basic` (email + scoped token) +**Required scopes for scoped API tokens:** + +When creating a scoped token, select the following [classic scopes](https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/) based on your needs: + +| Scope | Required for | +|-------|-------------| +| `read:confluence-content.all` | Reading pages and blog posts (`read`, `info`) | +| `read:confluence-space.summary` | Listing spaces (`spaces`) | +| `search:confluence` | Searching content (`search`) | +| `readonly:content.attachment:confluence` | Downloading attachments (`attachments --download`) | +| `write:confluence-content` | Creating and updating pages (`create`, `update`) | +| `write:confluence-file` | Uploading attachments (`attachments --upload`) | +| `write:confluence-space` | Managing spaces | + +For **read-only** usage, select at minimum: `read:confluence-content.all`, `read:confluence-space.summary`, and `search:confluence`. + **On-premise / Data Center:** Use your Confluence username and password for basic authentication. ## Usage diff --git a/lib/confluence-client.js b/lib/confluence-client.js index b3465ae..86c8dee 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -48,6 +48,44 @@ class ConfluenceClient { baseURL: this.baseURL, headers }); + + this.client.interceptors.response.use( + response => response, + error => { + if (error.response?.status === 401) { + const hints = ['Authentication failed (401 Unauthorized).']; + if (this.isScopedToken()) { + hints.push( + 'You are using a scoped API token (api.atlassian.com). Please verify:', + ' - Your token has the required scopes (e.g., read:confluence-content.all, read:confluence-space.summary)', + ' - Your Cloud ID in the API path is correct', + ' - Your email matches the account that created the token', + 'See: https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/' + ); + } else if (this.authType === 'basic') { + hints.push( + 'Please verify your email and API token are correct.', + 'Generate a token at: https://id.atlassian.com/manage-profile/security/api-tokens' + ); + } else { + hints.push( + 'Please verify your personal access token is valid and not expired.' + ); + } + error.message = hints.join('\n'); + } + return Promise.reject(error); + } + ); + } + + isCloud() { + return this.domain && this.domain.trim().toLowerCase().endsWith('.atlassian.net'); + } + + isScopedToken() { + const d = (this.domain || '').trim().toLowerCase(); + return d === 'api.atlassian.com' || this.apiPath?.includes('/ex/confluence/'); } sanitizeApiPath(rawPath) { @@ -1026,10 +1064,15 @@ class ConfluenceClient { // Convert code blocks to Confluence code macro storage = storage.replace(/
(.*?)<\/code><\/pre>/gs, (_, lang, code) => {
       const language = lang || 'text';
-      return `
-        ${language}
-        
-      `;
+      // Trim trailing newline added by markdown-it during HTML rendering,
+      // and decode HTML entities that markdown-it encodes inside  blocks
+      // so they appear as literal characters in the CDATA output
+      const decodedCode = code.replace(/\n$/, '')
+        .replace(/"/g, '"')
+        .replace(/&/g, '&')
+        .replace(/</g, '<')
+        .replace(/>/g, '>');
+      return `${language}`;
     });
     
     // Convert inline code
@@ -1070,13 +1113,21 @@ class ConfluenceClient {
     storage = storage.replace(/(.*?)<\/td>/g, '

$1

'); // Convert links - storage = storage.replace(/(.*?)<\/a>/g, ''); + // Confluence Cloud does not render ac:link + ri:url; use smart links instead. + // Server/Data Center instances continue to use the ac:link storage format. + if (this.isCloud()) { + storage = storage.replace(/(.*?)<\/a>/g, '$2'); + } else { + storage = storage.replace(/(.*?)<\/a>/g, ''); + } // Convert horizontal rules storage = storage.replace(//g, '
'); - // Clean up any remaining HTML entities and normalize whitespace - storage = storage.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + // Note: Do NOT globally decode < > & here. These represent literal + // characters in user content (e.g. in inline text) and + // Confluence storage format renders them correctly as-is. Code block + // entities are decoded separately above before CDATA insertion. return storage; } diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index dfc671d..ae65d0a 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -159,6 +159,47 @@ describe('ConfluenceClient', () => { }); }); + describe('401 error handling', () => { + test('provides scoped token hints when using api.atlassian.com', async () => { + const scopedClient = new ConfluenceClient({ + domain: 'api.atlassian.com', + token: 'scoped-token', + authType: 'basic', + email: 'user@example.com', + apiPath: '/ex/confluence/cloud-id/wiki/rest/api' + }); + const mock = new MockAdapter(scopedClient.client); + mock.onGet(/\/content\/123/).reply(401); + + await expect(scopedClient.readPage('123')).rejects.toThrow(/scoped API token/); + await expect(scopedClient.readPage('123')).rejects.toThrow(/read:confluence-content\.all/); + mock.restore(); + }); + + test('provides basic auth hints for regular cloud tokens', async () => { + const mock = new MockAdapter(client.client); + mock.onGet(/\/content\/123/).reply(401); + + await expect(client.readPage('123')).rejects.toThrow(/Authentication failed/); + await expect(client.readPage('123')).rejects.toThrow(/verify your personal access token/); + mock.restore(); + }); + + test('provides basic auth hints when using basic auth on cloud', async () => { + const basicClient = new ConfluenceClient({ + domain: 'test.atlassian.net', + token: 'api-token', + authType: 'basic', + email: 'user@example.com' + }); + const mock = new MockAdapter(basicClient.client); + mock.onGet(/\/content\/123/).reply(401); + + await expect(basicClient.readPage('123')).rejects.toThrow(/verify your email and API token/); + mock.restore(); + }); + }); + describe('extractPageId', () => { test('should return numeric page ID as is', async () => { expect(await client.extractPageId('123456789')).toBe('123456789'); @@ -242,7 +283,7 @@ describe('ConfluenceClient', () => { expect(result).toContain(''); expect(result).toContain('javascript'); - expect(result).toContain('console.log("Hello World");'); + expect(result).toContain('console.log("Hello World");'); }); test('should convert lists to native Confluence format', () => { @@ -272,13 +313,26 @@ describe('ConfluenceClient', () => { expect(result).toContain('

Cell 1

'); }); - test('should convert links to Confluence link format', () => { + test('should convert links to smart link format on Cloud instances', () => { const markdown = '[Example Link](https://example.com)'; const result = client.markdownToStorage(markdown); - + + expect(result).toContain('
Example Link'); + expect(result).not.toContain(''); + }); + + test('should convert links to ac:link format on Server/Data Center instances', () => { + const serverClient = new ConfluenceClient({ + domain: 'confluence.example.com', + token: 'test-token' + }); + const markdown = '[Example Link](https://example.com)'; + const result = serverClient.markdownToStorage(markdown); + expect(result).toContain(''); expect(result).toContain('ri:value="https://example.com"'); expect(result).toContain('Example Link'); + expect(result).not.toContain('data-card-appearance'); }); }); From bf61fa94dbb7399419cfb132ee71919f3a3e41b2 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Fri, 13 Mar 2026 19:18:17 +0900 Subject: [PATCH 2/3] fix: treat scoped token (api.atlassian.com) as Cloud in isCloud() and rename misleading test - isCloud() now returns true for scoped tokens so Cloud-specific behavior (e.g. smart link rendering) applies correctly - Rename test to accurately reflect bearer/PAT auth scenario --- lib/confluence-client.js | 2 +- tests/confluence-client.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 86c8dee..97df989 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -80,7 +80,7 @@ class ConfluenceClient { } isCloud() { - return this.domain && this.domain.trim().toLowerCase().endsWith('.atlassian.net'); + return this.isScopedToken() || (this.domain && this.domain.trim().toLowerCase().endsWith('.atlassian.net')); } isScopedToken() { diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index ae65d0a..99507f5 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -176,7 +176,7 @@ describe('ConfluenceClient', () => { mock.restore(); }); - test('provides basic auth hints for regular cloud tokens', async () => { + test('provides bearer/PAT hints for bearer token auth', async () => { const mock = new MockAdapter(client.client); mock.onGet(/\/content\/123/).reply(401); From 3cb45ea3830925024b30a0e7a747a060b2232cd3 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Fri, 13 Mar 2026 19:20:03 +0900 Subject: [PATCH 3/3] fix: distinguish Cloud vs Server/DC in 401 basic-auth error hints Server/DC uses username/password for basic auth, not email/API token. Gate the Cloud-specific hint behind isCloud() and add a separate message for on-premise users. --- lib/confluence-client.js | 6 +++++- tests/confluence-client.test.js | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 97df989..fb76728 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -62,11 +62,15 @@ class ConfluenceClient { ' - Your email matches the account that created the token', 'See: https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/' ); - } else if (this.authType === 'basic') { + } else if (this.authType === 'basic' && this.isCloud()) { hints.push( 'Please verify your email and API token are correct.', 'Generate a token at: https://id.atlassian.com/manage-profile/security/api-tokens' ); + } else if (this.authType === 'basic') { + hints.push( + 'Please verify your username and password are correct.' + ); } else { hints.push( 'Please verify your personal access token is valid and not expired.' diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 99507f5..5f40248 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -198,6 +198,20 @@ describe('ConfluenceClient', () => { await expect(basicClient.readPage('123')).rejects.toThrow(/verify your email and API token/); mock.restore(); }); + + test('provides server/DC hints when using basic auth on non-cloud', async () => { + const dcClient = new ConfluenceClient({ + domain: 'confluence.mycompany.com', + token: 'password', + authType: 'basic', + email: 'admin' + }); + const mock = new MockAdapter(dcClient.client); + mock.onGet(/\/content\/123/).reply(401); + + await expect(dcClient.readPage('123')).rejects.toThrow(/verify your username and password/); + mock.restore(); + }); }); describe('extractPageId', () => {