Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/skills/confluence/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,22 @@ Scoped tokens restrict access to specific Atlassian products and permissions, fo
- **API path:** `/ex/confluence/<your-cloud-id>/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
Expand Down
69 changes: 62 additions & 7 deletions lib/confluence-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1026,10 +1068,15 @@ class ConfluenceClient {
// Convert code blocks to Confluence code macro
storage = storage.replace(/<pre><code(?:\s+class="language-(\w+)")?>(.*?)<\/code><\/pre>/gs, (_, lang, code) => {
const language = lang || 'text';
return `<ac:structured-macro ac:name="code">
<ac:parameter ac:name="language">${language}</ac:parameter>
<ac:plain-text-body><![CDATA[${code}]]></ac:plain-text-body>
</ac:structured-macro>`;
// Trim trailing newline added by markdown-it during HTML rendering,
// and decode HTML entities that markdown-it encodes inside <code> blocks
// so they appear as literal characters in the CDATA output
const decodedCode = code.replace(/\n$/, '')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
return `<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">${language}</ac:parameter><ac:plain-text-body><![CDATA[${decodedCode}]]></ac:plain-text-body></ac:structured-macro>`;
});

// Convert inline code
Expand Down Expand Up @@ -1070,13 +1117,21 @@ class ConfluenceClient {
storage = storage.replace(/<td>(.*?)<\/td>/g, '<td><p>$1</p></td>');

// Convert links
storage = storage.replace(/<a href="(.*?)">(.*?)<\/a>/g, '<ac:link><ri:url ri:value="$1" /><ac:plain-text-link-body><![CDATA[$2]]></ac:plain-text-link-body></ac:link>');
// 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 href="(.*?)">(.*?)<\/a>/g, '<a href="$1" data-card-appearance="inline">$2</a>');
} else {
storage = storage.replace(/<a href="(.*?)">(.*?)<\/a>/g, '<ac:link><ri:url ri:value="$1" /><ac:plain-text-link-body><![CDATA[$2]]></ac:plain-text-link-body></ac:link>');
}

// Convert horizontal rules
storage = storage.replace(/<hr\s*\/?>/g, '<hr />');

// Clean up any remaining HTML entities and normalize whitespace
storage = storage.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
// Note: Do NOT globally decode &lt; &gt; &amp; here. These represent literal
// characters in user content (e.g. <placeholder> in inline text) and
// Confluence storage format renders them correctly as-is. Code block
// entities are decoded separately above before CDATA insertion.

return storage;
}
Expand Down
74 changes: 71 additions & 3 deletions tests/confluence-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -242,7 +297,7 @@ describe('ConfluenceClient', () => {

expect(result).toContain('<ac:structured-macro ac:name="code">');
expect(result).toContain('<ac:parameter ac:name="language">javascript</ac:parameter>');
expect(result).toContain('console.log(&quot;Hello World&quot;);');
expect(result).toContain('console.log("Hello World");');
});

test('should convert lists to native Confluence format', () => {
Expand Down Expand Up @@ -272,13 +327,26 @@ describe('ConfluenceClient', () => {
expect(result).toContain('<td><p>Cell 1</p></td>');
});

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('<a href="https://example.com" data-card-appearance="inline">Example Link</a>');
expect(result).not.toContain('<ac:link>');
});

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('<ac:link>');
expect(result).toContain('ri:value="https://example.com"');
expect(result).toContain('Example Link');
expect(result).not.toContain('data-card-appearance');
});
});

Expand Down
Loading