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..fb76728 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -48,6 +48,48 @@ 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' && 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.' + ); + } + error.message = hints.join('\n'); + } + return Promise.reject(error); + } + ); + } + + isCloud() { + return this.isScopedToken() || (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 +1068,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 +1117,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..5f40248 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -159,6 +159,61 @@ 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 bearer/PAT hints for bearer token auth', 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(); + }); + + 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', () => { test('should return numeric page ID as is', async () => { expect(await client.extractPageId('123456789')).toBe('123456789'); @@ -242,7 +297,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 +327,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'); }); });