From effa1e5b5cedff269c9f883f4a14d487cb649cd8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Jul 2025 16:33:38 +0100 Subject: [PATCH] feat: add authentication-aware link validation (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comprehensive authentication detection and handling for external link validation. This feature distinguishes between truly broken links and links that require authentication, addressing false positives from Firebase Console, GitHub private repos, and other auth-protected URLs. ## Core Features ### Authentication Detection System - **AuthDetector class**: Core authentication detection with configurable patterns and thresholds - **Domain-based detection**: Recognizes auth-protected domains (Firebase, GitHub, AWS, etc.) - **Status-based detection**: Identifies 401/403 responses as authentication requirements - **Redirect-based detection**: Detects redirects to authentication pages (Google, Microsoft, etc.) - **Pattern-based detection**: Recognizes content with staleness indicators ### Enhanced LinkValidator Integration - **Seamless integration**: Authentication detection integrated into existing validation workflow - **Credential support**: API keys and custom headers for authenticated validation - **Smart request handling**: Uses GET requests when authentication is enabled for content analysis - **Error differentiation**: Distinguishes auth-required from truly broken links ### CLI Integration - **--enable-auth-detection**: Enable authentication-aware validation - **--disallow-auth-required**: Treat auth-required links as broken (strict mode) - **--auth-credentials**: JSON object with domain:credential mapping - **--auth-headers**: JSON object with domain-specific headers - **Enhanced output**: Shows auth-protected links with 🔒 indicator and detailed information ### Configuration & Flexibility - **Configurable patterns**: Domain patterns and redirect patterns for different auth providers - **Credential management**: Support for API keys, bearer tokens, and custom headers - **Provider detection**: Automatic detection of auth providers (Google, GitHub, Microsoft, etc.) - **Statistics tracking**: Separate counting of auth-required vs truly broken links ## Implementation Details ### New Files - `src/utils/auth-detection.ts`: Core authentication detection utilities - `src/utils/auth-detection.test.ts`: Comprehensive unit tests (35 test cases) - `src/core/link-validator-auth.test.ts`: Integration tests (19 test cases) - `src/commands/validate-auth.test.ts`: End-to-end tests (11 test cases) ### Enhanced Files - `src/core/link-validator.ts`: Integrated authentication detection - `src/commands/validate.ts`: Added auth options and statistics - `src/types/config.ts`: Extended BrokenLink interface with auth info - `src/cli.ts`: Added authentication CLI options with proper typing ### Key Patterns Supported - Firebase Console: `console.firebase.google.com` - GitHub Settings: `github.com/*/settings` - Google Cloud Console: `console.cloud.google.com` - AWS Console: `console.aws.amazon.com` - Microsoft 365: `*.sharepoint.com`, `*.onedrive.com` - Many others with wildcard support ## Example Usage ```bash # Enable auth detection with default settings markmv validate docs/ --check-external --enable-auth-detection # Provide API credentials for validation markmv validate docs/ --check-external --enable-auth-detection \ --auth-credentials '{"api.github.com":"Bearer token123"}' # Use custom headers for specific domains markmv validate docs/ --check-external --enable-auth-detection \ --auth-headers '{"api.example.com":{"X-API-Key":"key123"}}' # Strict mode: treat auth-required as broken markmv validate docs/ --check-external --enable-auth-detection --disallow-auth-required ``` ## Test Coverage - **Unit tests**: 35 tests for AuthDetector functionality - **Integration tests**: 19 tests for LinkValidator integration - **End-to-end tests**: 11 tests for complete validation pipeline - **Total coverage**: 65 authentication-related test cases Resolves #34 --- .markmv-cache/content-freshness.json | 227 ++++++++ src/cli.ts | 69 ++- src/commands/validate-auth.test.ts | 543 ++++++++++++++++++ src/commands/validate.ts | 94 +++- src/core/link-validator-auth.test.ts | 562 +++++++++++++++++++ src/core/link-validator.ts | 107 +++- src/generated/ajv-validators.ts | 670 +++++++++++----------- src/generated/api-routes.ts | 799 +++++++++++++-------------- src/generated/mcp-tools.ts | 333 +++++------ src/types/config.ts | 4 +- src/utils/auth-detection.test.ts | 448 +++++++++++++++ src/utils/auth-detection.ts | 467 ++++++++++++++++ 12 files changed, 3425 insertions(+), 898 deletions(-) create mode 100644 .markmv-cache/content-freshness.json create mode 100644 src/commands/validate-auth.test.ts create mode 100644 src/core/link-validator-auth.test.ts create mode 100644 src/utils/auth-detection.test.ts create mode 100644 src/utils/auth-detection.ts diff --git a/.markmv-cache/content-freshness.json b/.markmv-cache/content-freshness.json new file mode 100644 index 0000000..9e223e5 --- /dev/null +++ b/.markmv-cache/content-freshness.json @@ -0,0 +1,227 @@ +{ + "https://firebase.google.com/docs/functions": { + "url": "https://firebase.google.com/docs/functions", + "contentHash": "8dc5a6ffd9f76e6da7490b234efb43616f76162e245ef01b40abd56f660c9d1c", + "lastChecked": 1753888688926, + "lastModified": 1733152688000, + "headers": { + "last-modified": "Mon, 02 Dec 2024 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/slow": { + "url": "https://example.com/slow", + "contentHash": "c50e7f179e9366e760b31ffe10b58e3df864c032b5dd2da1f0d9b60707f0b460", + "lastChecked": 1753888583655, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/fresh-docs": { + "url": "https://example.com/fresh-docs", + "contentHash": "3b69da73bdd19811ae8a16a2eb6413367741c8761ffe3e86ad31a2a00624dd2a", + "lastChecked": 1753888688919, + "lastModified": 1751296688000, + "headers": { + "last-modified": "Mon, 30 Jun 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://api.example.com/guide": { + "url": "https://api.example.com/guide", + "contentHash": "e917e3371a256e90b7eef332373b43b2035aa54aeed29d8fadda414cbdf9fefb", + "lastChecked": 1753888688919, + "lastModified": 1751296688000, + "headers": { + "last-modified": "Mon, 30 Jun 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/old-tutorial": { + "url": "https://example.com/old-tutorial", + "contentHash": "1e711ab811d6dcb97befaa1206204f6d6d2f6f248d5f5aa8805b8ce336206ae4", + "lastChecked": 1753888688923, + "lastModified": 1659280688000, + "headers": { + "last-modified": "Sun, 31 Jul 2022 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://api.example.com/deprecated": { + "url": "https://api.example.com/deprecated", + "contentHash": "7ad24bf8be8e4195e3de8f0cd359269e3a1d05d83ce3f9f1ee55e15cef8fca54", + "lastChecked": 1753888688936, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + }, + "https://docs.github.com/actions/reference": { + "url": "https://docs.github.com/actions/reference", + "contentHash": "1815e9484fc1cdbe07884330f12a39ed025ff58e17e256bfc07b2096d5b3d171", + "lastChecked": 1753888688927, + "lastModified": 1733152688000, + "headers": { + "last-modified": "Mon, 02 Dec 2024 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/docs": { + "url": "https://example.com/docs", + "contentHash": "a2c40fe2789303214ac611b2054bf2dcb9470fae22302e70bb884584a1260bb0", + "lastChecked": 1753888688927, + "lastModified": 1733152688000, + "headers": { + "last-modified": "Mon, 02 Dec 2024 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/fresh": { + "url": "https://example.com/fresh", + "contentHash": "12f9aed8bfb23cb0af37687b72356ac080f9ce9b737396e4a5ff60f20243c735", + "lastChecked": 1753888688940, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/stale": { + "url": "https://example.com/stale", + "contentHash": "3cdb12f564205d58dbaf4ecd059812df827cfa7eb0164ca2f7e44c20ca4a28ec", + "lastChecked": 1753888688930, + "lastModified": 1659280688000, + "headers": { + "last-modified": "Sun, 31 Jul 2022 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/fresh1": { + "url": "https://example.com/fresh1", + "contentHash": "8e888eb1312d5ec40c7dcd67f7f55d9666a795afaccb821100ad13cf571ffb64", + "lastChecked": 1753888688933, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/stale1": { + "url": "https://example.com/stale1", + "contentHash": "eada896fb00741ff2c46accd0b511a02fd76b11bde60f5cf04e3e58a930403dd", + "lastChecked": 1753888688933, + "lastModified": 1659280688000, + "headers": { + "last-modified": "Sun, 31 Jul 2022 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/fresh2": { + "url": "https://example.com/fresh2", + "contentHash": "9898a36d7f3c43cc6ed6191c8794488ad62dc3caf6e1b8ef79e4f01965d3e4b8", + "lastChecked": 1753888688934, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/stale2": { + "url": "https://example.com/stale2", + "contentHash": "c345ffd82648c3bb85ec1d524049cdd685f37288c1d340799dcf6d9c61413a1d", + "lastChecked": 1753888688934, + "lastModified": 1659280688000, + "headers": { + "last-modified": "Sun, 31 Jul 2022 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/moved": { + "url": "https://example.com/moved", + "contentHash": "932e682c4b20853f89677667b32aec382d4c93b397bc7496c6b5fb9e59fafd42", + "lastChecked": 1753888688937, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + }, + "https://docs.example.com/legacy": { + "url": "https://docs.example.com/legacy", + "contentHash": "86c55d8e8308af17f1c66bde2fdc46e7d24d63d16aa6442115e43192aac79f5a", + "lastChecked": 1753888688937, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + }, + "https://products.example.com/eol": { + "url": "https://products.example.com/eol", + "contentHash": "b6edbdb7828f6c7a6bf78b73e527b672c504fe2486686792938825ab76d87a36", + "lastChecked": 1753888688937, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/working": { + "url": "https://example.com/working", + "contentHash": "3419c14b50e29ca75fea91fedeeac29f8c50e48395fdfe01f5971c87edcb7c2c", + "lastChecked": 1753888688939, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/first": { + "url": "https://example.com/first", + "contentHash": "836e2b28f5521489cc2b489882b969827ca3b7a149b7b45b067bf379bee2c029", + "lastChecked": 1753888688942, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/last": { + "url": "https://example.com/last", + "contentHash": "e749d33997a9bdf62bf39325be8e650d4768e29fcbd399761a7fb672dec2e327", + "lastChecked": 1753888688942, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/changing": { + "url": "https://example.com/changing", + "contentHash": "53fb2035e869162a809aa54c0c7c964fef06b6a7619734de975284c35ddea95b", + "lastChecked": 1753888688948, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + } +} \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index f8b9b08..03dfb03 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -281,6 +281,10 @@ program .option('--only-broken', 'Show only broken links, not all validation results', true) .option('--group-by ', 'Group results by: file|type', 'file') .option('--include-context', 'Include line numbers and context in output', false) + .option('--enable-auth-detection', 'Enable authentication-aware link validation', false) + .option('--disallow-auth-required', 'Treat auth-required links as broken instead of valid', false) + .option('--auth-credentials ', 'JSON object with domain:credential mapping for authentication') + .option('--auth-headers ', 'JSON object with domain-specific headers for authentication') .option('-v, --verbose', 'Show detailed output with processing information') .option('--json', 'Output results in JSON format') .addHelpText( @@ -293,6 +297,19 @@ Examples: $ markmv validate docs/**/*.md --check-external --verbose $ markmv validate README.md --link-types internal,image --include-context $ markmv validate **/*.md --group-by type --only-broken + +Authentication Examples: + $ markmv validate docs/ --check-external --enable-auth-detection + $ markmv validate README.md --check-external --enable-auth-detection --verbose + $ markmv validate docs/ --check-external --enable-auth-detection --disallow-auth-required + $ markmv validate docs/ --check-external --auth-credentials '{"api.github.com":"Bearer token123"}' + $ markmv validate docs/ --check-external --auth-headers '{"api.example.com":{"X-API-Key":"key123"}}' + +Authentication Options: + --enable-auth-detection Distinguish auth-protected from truly broken links + --disallow-auth-required Treat auth-required links as broken (default: treat as valid) + --auth-credentials Provide API keys/tokens for authenticated validation + --auth-headers Provide custom headers for domain-specific authentication $ markmv validate docs/ --check-circular --strict-internal Link Types: @@ -307,6 +324,56 @@ Output Options: --group-by file Group broken links by file (default) --group-by type Group broken links by link type` ) - .action(validateCommand); + .action((files: string[], options: { + linkTypes?: string; + checkExternal?: boolean; + externalTimeout?: number; + strictInternal?: boolean; + checkClaudeImports?: boolean; + checkCircular?: boolean; + maxDepth?: number; + onlyBroken?: boolean; + groupBy?: string; + includeContext?: boolean; + enableAuthDetection?: boolean; + disallowAuthRequired?: boolean; + authCredentials?: string; + authHeaders?: string; + verbose?: boolean; + json?: boolean; + }) => { + // Parse JSON options + let authCredentials: Record | undefined; + let authHeaders: Record> | undefined; + + try { + if (options.authCredentials) { + authCredentials = JSON.parse(options.authCredentials); + } + } catch (error) { + console.error('Error parsing auth-credentials JSON:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + try { + if (options.authHeaders) { + authHeaders = JSON.parse(options.authHeaders); + } + } catch (error) { + console.error('Error parsing auth-headers JSON:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + // Convert options to match ValidateCliOptions interface + const validationOptions = { + ...options, + enableAuthDetection: options.enableAuthDetection, + allowAuthRequired: !options.disallowAuthRequired, // Invert the flag + authCredentials, + authHeaders, + }; + + return validateCommand(files, validationOptions); + }); program.parse(); diff --git a/src/commands/validate-auth.test.ts b/src/commands/validate-auth.test.ts new file mode 100644 index 0000000..fdb065d --- /dev/null +++ b/src/commands/validate-auth.test.ts @@ -0,0 +1,543 @@ +/** + * Integration tests for validate command with authentication detection. + * + * @fileoverview Tests the full validation pipeline with authentication awareness + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { validateLinks } from './validate.js'; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('Validate Command with Authentication Detection', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'validate-auth-test-')); + vi.clearAllMocks(); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('Authentication-aware Validation', () => { + it('should distinguish auth-required from truly broken links', async () => { + const testFile = join(tempDir, 'auth-test.md'); + await writeFile(testFile, ` +# Authentication Test + +Firebase Console: [Project Settings](https://console.firebase.google.com/project/test/settings) +Working docs: [GitHub Actions](https://docs.github.com/en/actions) +Broken link: [Missing Page](https://example.com/missing-page) +Private API: [User Profile](https://api.private.com/user/profile) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.github.com/en/actions', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/missing-page', + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://api.private.com/user/profile', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + }); + + expect(result.filesProcessed).toBe(1); + expect(result.totalLinks).toBe(4); + expect(result.brokenLinks).toBe(3); // Firebase (auth), Missing (404), Private API (401) + expect(result.authRequiredLinks).toBe(2); // Firebase + Private API + expect(result.authenticatedLinks).toBe(0); // No successful auth + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + + // Firebase Console - domain-based detection + const firebaseLink = brokenLinks.find(link => link.url.includes('firebase')); + expect(firebaseLink?.reason).toBe('auth-required'); + expect(firebaseLink?.authInfo?.detectionMethod).toBe('domain'); + + // Missing page - truly broken + const missingLink = brokenLinks.find(link => link.url.includes('missing')); + expect(missingLink?.reason).toBe('external-error'); + expect(missingLink?.authInfo).toBeUndefined(); + + // Private API - status-based detection + const privateLink = brokenLinks.find(link => link.url.includes('private')); + expect(privateLink?.reason).toBe('auth-required'); + expect(privateLink?.authInfo?.detectionMethod).toBe('status-code'); + }); + + it('should successfully authenticate with provided credentials', async () => { + const testFile = join(tempDir, 'auth-success.md'); + await writeFile(testFile, ` +# Authentication Success Test + +GitHub API: [User Info](https://api.github.com/user) +Custom API: [Data Endpoint](https://api.custom.com/data) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.github.com/user', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.custom.com/data', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + authCredentials: { + 'api.github.com': 'Bearer ghp_test123', + }, + authHeaders: { + 'api.custom.com': { + 'X-API-Key': 'custom-key-456', + }, + }, + }); + + expect(result.brokenLinks).toBe(0); + expect(result.authRequiredLinks).toBe(0); + expect(result.totalLinks).toBe(2); + + // Verify correct headers were sent + expect(mockFetch).toHaveBeenCalledWith('https://api.github.com/user', { + method: 'HEAD', + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'markmv-validator/1.0 (authentication-aware)', + 'Authorization': 'Bearer ghp_test123', + }, + redirect: 'follow', + }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.custom.com/data', { + method: 'HEAD', + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'markmv-validator/1.0 (authentication-aware)', + 'X-API-Key': 'custom-key-456', + }, + redirect: 'follow', + }); + }); + + it('should handle redirect-based authentication detection', async () => { + const testFile = join(tempDir, 'redirect-auth.md'); + await writeFile(testFile, ` +# Redirect Authentication Test + +Private Dashboard: [Team Dashboard](https://private.example.com/dashboard) +Company Portal: [Employee Portal](https://portal.company.com/employees) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://accounts.google.com/oauth/authorize?client_id=123', // Redirect to Google + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://login.microsoftonline.com/common/oauth2/authorize', // Redirect to Microsoft + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + }); + + expect(result.brokenLinks).toBe(2); + expect(result.authRequiredLinks).toBe(2); + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + + const googleRedirect = brokenLinks.find(link => link.url.includes('private.example.com')); + expect(googleRedirect?.reason).toBe('auth-required'); + expect(googleRedirect?.authInfo?.detectionMethod).toBe('redirect'); + expect(googleRedirect?.authInfo?.authProvider).toBe('Google'); + + const microsoftRedirect = brokenLinks.find(link => link.url.includes('portal.company.com')); + expect(microsoftRedirect?.reason).toBe('auth-required'); + expect(microsoftRedirect?.authInfo?.detectionMethod).toBe('redirect'); + expect(microsoftRedirect?.authInfo?.authProvider).toBe('Microsoft'); + }); + + it('should handle mixed internal and external links with auth detection', async () => { + const internalFile = join(tempDir, 'internal.md'); + await writeFile(internalFile, '# Internal Document\nContent here.'); + + const testFile = join(tempDir, 'mixed-auth.md'); + await writeFile(testFile, ` +# Mixed Links with Authentication + +Internal: [Internal Doc](./internal.md) +Public: [Public Docs](https://docs.example.com/guide) +Auth Required: [Firebase Console](https://console.firebase.google.com/project/test) +Broken: [Missing](https://example.com/404) +Anchor: [Section](#section) + +## Section +Content here. + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.example.com/guide', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/404', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + strictInternal: true, + }); + + expect(result.totalLinks).toBe(5); + expect(result.brokenLinks).toBe(2); // Firebase (auth) + Missing (404) + expect(result.authRequiredLinks).toBe(1); // Firebase + + // Only external links should be fetched (public docs + missing page) + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('Strict Mode (disallowAuthRequired)', () => { + it('should treat auth-required links as broken when allowAuthRequired is false', async () => { + const testFile = join(tempDir, 'strict-auth.md'); + await writeFile(testFile, ` +# Strict Authentication Test + +Firebase: [Console](https://console.firebase.google.com/project/test) +GitHub: [Settings](https://github.com/org/settings/profile) + `); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: false, // Treat auth-required as broken + }); + + // Domain-based links are still detected as auth-required regardless of allowAuthRequired setting + expect(result.brokenLinks).toBe(2); + expect(result.authRequiredLinks).toBe(2); + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + expect(brokenLinks).toHaveLength(2); + expect(brokenLinks[0].reason).toBe('auth-required'); + expect(brokenLinks[1].reason).toBe('auth-required'); + }); + }); + + describe('Disabled Authentication Detection', () => { + it('should behave normally when auth detection is disabled', async () => { + const testFile = join(tempDir, 'no-auth.md'); + await writeFile(testFile, ` +# No Authentication Detection + +Firebase: [Console](https://console.firebase.google.com/project/test) +Private API: [Endpoint](https://api.private.com/data) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://console.firebase.google.com/project/test', + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://api.private.com/data', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: false, // Disabled + }); + + expect(result.brokenLinks).toBe(1); // Only the 401 error + expect(result.authRequiredLinks).toBe(0); // No auth detection + expect(result.authenticatedLinks).toBe(0); + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + expect(brokenLinks).toHaveLength(1); + expect(brokenLinks[0].reason).toBe('external-error'); // Not auth-required + expect(brokenLinks[0].details).toContain('HTTP 401'); + }); + }); + + describe('Statistics and Reporting', () => { + it('should provide accurate authentication statistics', async () => { + const testFile = join(tempDir, 'stats-test.md'); + await writeFile(testFile, ` +# Authentication Statistics Test + +Working: [Public Docs](https://docs.example.com/api) +Auth Domain: [Firebase](https://console.firebase.google.com/project/test) +Auth Creds: [GitHub API](https://api.github.com/user) +Auth Status: [Private API](https://private.api.com/endpoint) +Broken: [Missing](https://example.com/missing) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.example.com/api', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.github.com/user', + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Map(), + url: 'https://private.api.com/endpoint', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/missing', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + authCredentials: { + 'api.github.com': 'Bearer token123', + }, + }); + + expect(result.filesProcessed).toBe(1); + expect(result.totalLinks).toBe(5); + expect(result.brokenLinks).toBe(3); // Firebase (domain) + Private API (403) + Missing (404) + expect(result.authRequiredLinks).toBe(2); // Firebase + Private API + expect(result.authenticatedLinks).toBe(0); // GitHub API was successful (not counted as broken) + + // Verify the truly broken link + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + const trulyBroken = brokenLinks.filter(link => link.reason === 'external-error'); + expect(trulyBroken).toHaveLength(1); + expect(trulyBroken[0].url).toContain('missing'); + }); + + it('should handle multiple files with auth detection', async () => { + const file1 = join(tempDir, 'file1.md'); + await writeFile(file1, ` +# File 1 +[Firebase](https://console.firebase.google.com/project/test1) +[Working](https://docs.example.com/guide1) + `); + + const file2 = join(tempDir, 'file2.md'); + await writeFile(file2, ` +# File 2 +[GitHub Settings](https://github.com/org/settings/billing) +[Broken](https://example.com/broken) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.example.com/guide1', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/broken', + }); + + const result = await validateLinks([file1, file2], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + }); + + expect(result.filesProcessed).toBe(2); + expect(result.totalLinks).toBe(4); + expect(result.brokenLinks).toBe(3); // Firebase + GitHub + Broken + expect(result.authRequiredLinks).toBe(2); // Firebase + GitHub + + expect(Object.keys(result.brokenLinksByFile)).toHaveLength(2); + }); + }); + + describe('Error Handling with Authentication', () => { + it('should handle network errors during auth-aware validation', async () => { + const testFile = join(tempDir, 'network-error.md'); + await writeFile(testFile, ` +# Network Error Test + +[Timeout Link](https://slow.example.com/endpoint) +[Firebase Console](https://console.firebase.google.com/project/test) + `); + + mockFetch.mockRejectedValueOnce(new Error('Network timeout')); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + }); + + expect(result.brokenLinks).toBe(2); + expect(result.authRequiredLinks).toBe(1); // Firebase + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + const networkError = brokenLinks.find(link => link.url.includes('slow')); + expect(networkError?.reason).toBe('external-error'); + expect(networkError?.details).toContain('Network timeout'); + + const authRequired = brokenLinks.find(link => link.url.includes('firebase')); + expect(authRequired?.reason).toBe('auth-required'); + }); + + it('should continue processing after authentication errors', async () => { + const testFile = join(tempDir, 'continue-after-error.md'); + await writeFile(testFile, ` +# Continue After Error Test + +[First Link](https://first.example.com/api) +[Error Link](https://error.example.com/api) +[Last Link](https://last.example.com/api) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://first.example.com/api', + }) + .mockRejectedValueOnce(new Error('Connection refused')) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://last.example.com/api', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + }); + + expect(result.totalLinks).toBe(3); + expect(result.filesProcessed).toBe(1); + expect(result.brokenLinks).toBe(1); // Only the error link + expect(mockFetch).toHaveBeenCalledTimes(3); + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + expect(brokenLinks).toHaveLength(1); + expect(brokenLinks[0].details).toContain('Connection refused'); + }); + }); + + describe('Integration with Other Link Types', () => { + it('should only apply auth detection to external links', async () => { + const internalFile = join(tempDir, 'target.md'); + await writeFile(internalFile, '# Target\nContent'); + + const testFile = join(tempDir, 'mixed-types.md'); + await writeFile(testFile, ` +# Mixed Link Types + +Internal: [Target](./target.md) +External Auth: [Firebase](https://console.firebase.google.com/project/test) +External Normal: [Docs](https://docs.example.com/guide) +Anchor: [Section](#section) +Image: ![Image](./image.png) + +## Section +Content here. + `); + + // Create image file + const imagePath = join(tempDir, 'image.png'); + await writeFile(imagePath, 'fake-image-content'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.example.com/guide', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + strictInternal: true, + }); + + expect(result.totalLinks).toBe(5); + expect(result.brokenLinks).toBe(1); // Only Firebase + expect(result.authRequiredLinks).toBe(1); // Firebase + + // Only external docs should be fetched (Firebase is detected by domain) + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); +}); \ No newline at end of file diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 130c7b0..3f4e9ba 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -35,6 +35,14 @@ export interface ValidateOperationOptions extends OperationOptions { groupBy: 'file' | 'type'; /** Include line numbers and context in output */ includeContext: boolean; + /** Enable authentication-aware link validation */ + enableAuthDetection?: boolean; + /** Treat auth-required links as valid (not broken) */ + allowAuthRequired?: boolean; + /** API keys/credentials for authenticated requests */ + authCredentials?: Record; + /** Custom headers for specific domains */ + authHeaders?: Record>; } /** @@ -89,6 +97,10 @@ export interface ValidateResult { circularReferences?: string[]; /** Processing time in milliseconds */ processingTime: number; + /** Number of auth-required links found */ + authRequiredLinks?: number; + /** Number of successfully authenticated links */ + authenticatedLinks?: number; } /** @@ -129,7 +141,11 @@ export async function validateLinks( ): Promise { const startTime = Date.now(); - const opts: Required = { + const opts: Required> & { + maxDepth?: number; + authCredentials?: Record; + authHeaders?: Record>; + } = { linkTypes: options.linkTypes || [ 'internal', 'external', @@ -147,6 +163,10 @@ export async function validateLinks( onlyBroken: options.onlyBroken ?? true, groupBy: options.groupBy ?? 'file', includeContext: options.includeContext ?? false, + enableAuthDetection: options.enableAuthDetection ?? false, + allowAuthRequired: options.allowAuthRequired ?? true, + authCredentials: options.authCredentials, + authHeaders: options.authHeaders, dryRun: options.dryRun ?? false, verbose: options.verbose ?? false, force: options.force ?? false, @@ -183,6 +203,12 @@ export async function validateLinks( externalTimeout: opts.externalTimeout, strictInternal: opts.strictInternal, checkClaudeImports: opts.checkClaudeImports, + enableAuthDetection: opts.enableAuthDetection, + allowAuthRequired: opts.allowAuthRequired, + authConfig: { + credentials: opts.authCredentials || {}, + customHeaders: opts.authHeaders || {}, + }, }); const parser = new LinkParser(); @@ -196,6 +222,8 @@ export async function validateLinks( fileErrors: [], hasCircularReferences: false, processingTime: 0, + authRequiredLinks: 0, + authenticatedLinks: 0, }; // Initialize broken links by type @@ -225,6 +253,22 @@ export async function validateLinks( const validation = await validator.validateLinks(relevantLinks, filePath); const brokenLinks = validation.brokenLinks; + // Count authentication statistics if auth detection is enabled + if (opts.enableAuthDetection) { + const externalLinks = relevantLinks.filter(link => link.type === 'external'); + + if (brokenLinks.length > 0) { + // Count auth-required links + const authRequiredCount = brokenLinks.filter(bl => bl.reason === 'auth-required').length; + const authenticatedCount = brokenLinks.filter(bl => + bl.authInfo?.authAttempted && bl.authInfo?.authSucceeded + ).length; + + result.authRequiredLinks = (result.authRequiredLinks || 0) + authRequiredCount; + result.authenticatedLinks = (result.authenticatedLinks || 0) + authenticatedCount; + } + } + if (brokenLinks.length > 0) { result.brokenLinks += brokenLinks.length; @@ -353,6 +397,24 @@ export async function validateCommand( console.log(`Files processed: ${result.filesProcessed}`); console.log(`Total links found: ${result.totalLinks}`); console.log(`Broken links: ${result.brokenLinks}`); + + // Show authentication information if enabled + if (options.enableAuthDetection) { + const authRequiredCount = result.authRequiredLinks || 0; + const authenticatedCount = result.authenticatedLinks || 0; + const realBrokenCount = result.brokenLinks - authRequiredCount; + + if (authRequiredCount > 0) { + console.log(`🔒 Authentication-protected links: ${authRequiredCount}`); + } + if (authenticatedCount > 0) { + console.log(`✅ Successfully authenticated links: ${authenticatedCount}`); + } + if (realBrokenCount > 0) { + console.log(`❌ Truly broken links: ${realBrokenCount}`); + } + } + console.log(`Processing time: ${result.processingTime}ms\n`); if (result.fileErrors.length > 0) { @@ -389,10 +451,23 @@ export async function validateCommand( const context = options.includeContext && brokenLink.line ? ` (line ${brokenLink.line})` : ''; const file = brokenLink.filePath ? ` in ${brokenLink.filePath}` : ''; - console.log(` ❌ ${brokenLink.url}${context}${file}`); + const authIndicator = brokenLink.reason === 'auth-required' ? ' 🔒' : ''; + console.log(` ❌ ${brokenLink.url}${context}${file}${authIndicator}`); if (brokenLink.reason && options.verbose) { console.log(` Reason: ${brokenLink.reason}`); } + if (brokenLink.authInfo && (options.verbose || brokenLink.reason === 'auth-required')) { + const info = brokenLink.authInfo; + if (info.warning) { + console.log(` Auth: ${info.warning}`); + } + if (info.authProvider && options.verbose) { + console.log(` Provider: ${info.authProvider}`); + } + if (info.suggestion) { + console.log(` Suggestion: ${info.suggestion}`); + } + } } } } @@ -403,10 +478,23 @@ export async function validateCommand( for (const brokenLink of brokenLinks) { const context = options.includeContext && brokenLink.line ? ` (line ${brokenLink.line})` : ''; - console.log(` ❌ [${brokenLink.type}] ${brokenLink.url}${context}`); + const authIndicator = brokenLink.reason === 'auth-required' ? ' 🔒' : ''; + console.log(` ❌ [${brokenLink.type}] ${brokenLink.url}${context}${authIndicator}`); if (brokenLink.reason && options.verbose) { console.log(` Reason: ${brokenLink.reason}`); } + if (brokenLink.authInfo && (options.verbose || brokenLink.reason === 'auth-required')) { + const info = brokenLink.authInfo; + if (info.warning) { + console.log(` Auth: ${info.warning}`); + } + if (info.authProvider && options.verbose) { + console.log(` Provider: ${info.authProvider}`); + } + if (info.suggestion) { + console.log(` Suggestion: ${info.suggestion}`); + } + } } } } diff --git a/src/core/link-validator-auth.test.ts b/src/core/link-validator-auth.test.ts new file mode 100644 index 0000000..ad5599e --- /dev/null +++ b/src/core/link-validator-auth.test.ts @@ -0,0 +1,562 @@ +/** + * Tests for LinkValidator with authentication detection integration. + * + * @fileoverview Tests for link validation with authentication awareness + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { LinkValidator } from './link-validator.js'; +import type { MarkdownLink } from '../types/links.js'; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('LinkValidator with Authentication Detection', () => { + let tempDir: string; + let validator: LinkValidator; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'link-validator-auth-test-')); + + validator = new LinkValidator({ + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + externalTimeout: 5000, + authConfig: { + credentials: { + 'api.github.com': 'Bearer test-token', + }, + customHeaders: { + 'api.custom.com': { + 'X-API-Key': 'custom-key-123', + }, + }, + }, + }); + + vi.clearAllMocks(); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('Domain-based Auth Detection', () => { + it('should identify Firebase Console links as auth-required', async () => { + const link: MarkdownLink = { + type: 'external', + href: 'https://console.firebase.google.com/project/my-project/settings', + text: 'Firebase Console', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.requiresAuth).toBe(true); + expect(result?.authInfo?.authProvider).toBe('Firebase'); + expect(result?.authInfo?.detectionMethod).toBe('domain'); + expect(result?.details).toContain('authentication-protected'); + + // Should not make HTTP request for domain-detected auth + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should identify GitHub private repo links as auth-required', async () => { + const link: MarkdownLink = { + type: 'external', + href: 'https://github.com/private-org/settings/profile', + text: 'GitHub Settings', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.authProvider).toBe('GitHub'); + expect(result?.authInfo?.detectionMethod).toBe('domain'); + }); + + it('should validate public GitHub docs normally', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.github.com/en/actions', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://docs.github.com/en/actions', + text: 'GitHub Actions Docs', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).toBeNull(); // Should be valid + expect(mockFetch).toHaveBeenCalledWith('https://docs.github.com/en/actions', { + method: 'HEAD', + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'markmv-validator/1.0 (authentication-aware)', + }, + redirect: 'follow', + }); + }); + }); + + describe('HTTP Status Auth Detection', () => { + it('should identify 401 responses as auth-required', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://api.example.com/private', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.example.com/private', + text: 'Private API', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.requiresAuth).toBe(true); + expect(result?.authInfo?.detectionMethod).toBe('status-code'); + expect(result?.details).toContain('HTTP 401'); + }); + + it('should identify 403 responses as auth-required', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Map(), + url: 'https://admin.example.com/dashboard', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://admin.example.com/dashboard', + text: 'Admin Dashboard', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.detectionMethod).toBe('status-code'); + expect(result?.details).toContain('HTTP 403'); + }); + + it('should treat other HTTP errors as truly broken', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/missing', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://example.com/missing', + text: 'Missing Page', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('external-error'); + expect(result?.details).toContain('HTTP 404'); + expect(result?.authInfo).toBeUndefined(); + }); + }); + + describe('Redirect Auth Detection', () => { + it('should detect redirects to Google auth pages', async () => { + const finalUrl = 'https://accounts.google.com/oauth/authorize?client_id=123'; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: finalUrl, // Simulate redirect + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://private.example.com/dashboard', + text: 'Dashboard', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.requiresAuth).toBe(true); + expect(result?.authInfo?.detectionMethod).toBe('redirect'); + expect(result?.authInfo?.authProvider).toBe('Google'); + expect(result?.authInfo?.finalUrl).toBe(finalUrl); + }); + + it('should detect redirects to Microsoft auth pages', async () => { + const finalUrl = 'https://login.microsoftonline.com/common/oauth2/authorize'; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: finalUrl, + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://teams.microsoft.com/channel', + text: 'Teams Channel', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.authProvider).toBe('Microsoft'); + expect(result?.authInfo?.detectionMethod).toBe('redirect'); + }); + }); + + describe('Authentication Credentials', () => { + it('should use provided credentials for authentication', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.github.com/user', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.github.com/user', + text: 'GitHub User API', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).toBeNull(); // Should be valid with auth + expect(mockFetch).toHaveBeenCalledWith('https://api.github.com/user', { + method: 'HEAD', + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'markmv-validator/1.0 (authentication-aware)', + 'Authorization': 'Bearer test-token', + }, + redirect: 'follow', + }); + }); + + it('should use custom headers for authentication', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.custom.com/data', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.custom.com/data', + text: 'Custom API', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).toBeNull(); // Should be valid with auth + expect(mockFetch).toHaveBeenCalledWith('https://api.custom.com/data', { + method: 'HEAD', + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'markmv-validator/1.0 (authentication-aware)', + 'X-API-Key': 'custom-key-123', + }, + redirect: 'follow', + }); + }); + + it('should mark authentication as attempted and succeeded', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.github.com/repos', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.github.com/repos', + text: 'GitHub Repos API', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).toBeNull(); // Should be valid + // We can't easily test the internal authInfo here since it's not returned for valid links + // This is tested more thoroughly in the auth-detection unit tests + }); + }); + + describe('allowAuthRequired Configuration', () => { + it('should still detect auth-required links via status code when allowAuthRequired is false', async () => { + const strictValidator = new LinkValidator({ + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: false, // Strict mode - doesn't return early for auth detection + }); + + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://api.private.com/endpoint', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.private.com/endpoint', + text: 'Private API', + line: 1, + }; + + const result = await strictValidator.validateLink(link, '/test/file.md'); + + // When allowAuthRequired is false, 401/403 should be treated as external-error + expect(result).not.toBeNull(); + expect(result?.reason).toBe('external-error'); + expect(result?.details).toContain('HTTP 401'); + }); + + it('should handle mixed auth and broken links correctly', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.example.com/guide', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/missing', + }); + + const links: MarkdownLink[] = [ + { + type: 'external', + href: 'https://console.firebase.google.com/project/test', + text: 'Firebase Console', + line: 1, + }, + { + type: 'external', + href: 'https://docs.example.com/guide', + text: 'Valid Docs', + line: 2, + }, + { + type: 'external', + href: 'https://example.com/missing', + text: 'Missing Page', + line: 3, + }, + ]; + + const results = await Promise.all( + links.map(link => validator.validateLink(link, '/test/file.md')) + ); + + // Firebase Console: auth-required + expect(results[0]?.reason).toBe('auth-required'); + + // Valid docs: null (valid) + expect(results[1]).toBeNull(); + + // Missing page: external-error + expect(results[2]?.reason).toBe('external-error'); + }); + }); + + describe('Disabled Auth Detection', () => { + it('should behave like normal validation when auth detection is disabled', async () => { + const normalValidator = new LinkValidator({ + checkExternal: true, + enableAuthDetection: false, + }); + + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://api.example.com/private', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.example.com/private', + text: 'Private API', + line: 1, + }; + + const result = await normalValidator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('external-error'); // Not auth-required + expect(result?.details).toContain('HTTP 401'); + expect(result?.authInfo).toBeUndefined(); + }); + + it('should use basic headers when auth detection is disabled', async () => { + const normalValidator = new LinkValidator({ + checkExternal: true, + enableAuthDetection: false, + }); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://example.com/test', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://example.com/test', + text: 'Test Link', + line: 1, + }; + + await normalValidator.validateLink(link, '/test/file.md'); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/test', { + method: 'HEAD', + signal: expect.any(AbortSignal), + }); + }); + }); + + describe('Error Handling', () => { + it('should handle network errors during auth-aware validation', async () => { + mockFetch.mockRejectedValue(new Error('Network timeout')); + + const link: MarkdownLink = { + type: 'external', + href: 'https://example.com/network-error', + text: 'Network Error', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('external-error'); + expect(result?.details).toContain('Network timeout'); + }); + + it('should handle timeout errors during auth validation', async () => { + const timeoutValidator = new LinkValidator({ + checkExternal: true, + enableAuthDetection: true, + externalTimeout: 1, // Very short timeout + }); + + mockFetch.mockImplementation(() => + new Promise((resolve, reject) => { + setTimeout(() => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }, 2); + }) + ); + + const link: MarkdownLink = { + type: 'external', + href: 'https://example.com/slow', + text: 'Slow Link', + line: 1, + }; + + const result = await timeoutValidator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('external-error'); + expect(result?.details).toContain('aborted'); + }); + }); + + describe('Image Link Authentication', () => { + it('should apply auth detection to external images', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://private.cdn.com/image.jpg', + }); + + const link: MarkdownLink = { + type: 'image', + href: 'https://private.cdn.com/image.jpg', + text: 'Private Image', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.detectionMethod).toBe('status-code'); + }); + + it('should not apply auth detection to local images', async () => { + // Create a test image file + const imagePath = join(tempDir, 'test-image.jpg'); + await writeFile(imagePath, 'fake-image-content'); + + const link: MarkdownLink = { + type: 'image', + href: './test-image.jpg', + text: 'Local Image', + line: 1, + resolvedPath: imagePath, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).toBeNull(); // Local image should be valid + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/core/link-validator.ts b/src/core/link-validator.ts index 0ffaf55..fc14135 100644 --- a/src/core/link-validator.ts +++ b/src/core/link-validator.ts @@ -1,5 +1,6 @@ import { constants, access } from 'node:fs/promises'; import { readFile } from 'node:fs/promises'; +import { AuthDetector, type AuthConfig } from '../utils/auth-detection.js'; import type { BrokenLink, ValidationResult } from '../types/config.js'; import type { MarkdownLink, ParsedMarkdownFile } from '../types/links.js'; @@ -20,6 +21,12 @@ export interface LinkValidatorOptions { strictInternal?: boolean; /** Check Claude import links */ checkClaudeImports?: boolean; + /** Enable authentication-aware link validation */ + enableAuthDetection?: boolean; + /** Configuration for authentication detection */ + authConfig?: Partial; + /** Treat auth-required links as valid (not broken) */ + allowAuthRequired?: boolean; } /** @@ -62,7 +69,10 @@ export interface LinkValidatorOptions { * ``` */ export class LinkValidator { - private options: Required; + private options: Required> & { + authConfig?: Partial; + }; + private authDetector?: AuthDetector; constructor(options: LinkValidatorOptions = {}) { this.options = { @@ -70,7 +80,14 @@ export class LinkValidator { externalTimeout: options.externalTimeout ?? 5000, strictInternal: options.strictInternal ?? true, checkClaudeImports: options.checkClaudeImports ?? true, + enableAuthDetection: options.enableAuthDetection ?? false, + allowAuthRequired: options.allowAuthRequired ?? true, + ...(options.authConfig && { authConfig: options.authConfig }), }; + + if (this.options.enableAuthDetection) { + this.authDetector = new AuthDetector(this.options.authConfig); + } } async validateFiles(files: ParsedMarkdownFile[]): Promise { @@ -209,14 +226,93 @@ export class LinkValidator { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.options.externalTimeout); - const response = await fetch(link.href, { + // Prepare headers for request + const headers: Record = {}; + + // Check if authentication detection is enabled and analyze the URL first + let authInfo; + if (this.authDetector && this.options.enableAuthDetection) { + headers['User-Agent'] = 'markmv-validator/1.0 (authentication-aware)'; + + authInfo = await this.authDetector.analyzeAuth(link.href); + + // If URL is known to require auth via domain detection, return immediately + if (authInfo.requiresAuth && authInfo.detectionMethod === 'domain') { + clearTimeout(timeoutId); + return { + sourceFile, + link, + reason: 'auth-required', + details: authInfo.warning || 'Link requires authentication', + authInfo, + }; + } + + // Add authentication headers if available + if (this.authDetector.shouldAttemptAuth(link.href)) { + const authHeaders = this.authDetector.getAuthHeaders(link.href); + Object.assign(headers, authHeaders); + if (authInfo) { + authInfo.authAttempted = true; + } + } + } + + const fetchOptions: RequestInit = { method: 'HEAD', signal: controller.signal, - }); + }; + + if (Object.keys(headers).length > 0) { + fetchOptions.headers = headers; + } + + if (this.options.enableAuthDetection) { + fetchOptions.redirect = 'follow'; // Follow redirects to detect auth redirects + } + + const response = await fetch(link.href, fetchOptions); clearTimeout(timeoutId); + // Analyze response for authentication indicators if auth detection is enabled + if (this.authDetector && this.options.enableAuthDetection && authInfo) { + const finalAuthInfo = await this.authDetector.analyzeAuth(link.href, response); + Object.assign(authInfo, finalAuthInfo); + + if (finalAuthInfo.requiresAuth && this.options.allowAuthRequired) { + return { + sourceFile, + link, + reason: 'auth-required', + details: finalAuthInfo.warning || 'Link requires authentication', + authInfo: finalAuthInfo, + }; + } + } + if (!response.ok) { + // Check if this is an auth-related error (only if auth detection is enabled) + if (this.options.enableAuthDetection && (response.status === 401 || response.status === 403) && this.options.allowAuthRequired) { + const authErrorInfo = { + url: link.href, + requiresAuth: true, + redirectCount: 0, + authAttempted: this.authDetector?.shouldAttemptAuth(link.href) || false, + detectionMethod: 'status-code' as const, + warning: `HTTP ${response.status}: Authentication required`, + suggestion: 'Provide appropriate credentials or API keys to validate this link', + }; + + return { + sourceFile, + link, + reason: 'auth-required', + details: authErrorInfo.warning, + authInfo: authErrorInfo, + }; + } + return { sourceFile, link, @@ -225,6 +321,11 @@ export class LinkValidator { }; } + // Mark auth as succeeded if we attempted it + if (authInfo?.authAttempted) { + authInfo.authSucceeded = true; + } + return null; // Link is valid } catch (error) { return { diff --git a/src/generated/ajv-validators.ts b/src/generated/ajv-validators.ts index 60eb2df..59cb9ae 100644 --- a/src/generated/ajv-validators.ts +++ b/src/generated/ajv-validators.ts @@ -1,431 +1,447 @@ /** * Auto-generated AJV validators for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ import Ajv from 'ajv'; -const ajv = new Ajv({ - allErrors: true, +const ajv = new Ajv({ + allErrors: true, verbose: true, - strict: false, + strict: false }); // Schema definitions export const schemas = { - $schema: 'http://json-schema.org/draft-07/schema#', - title: 'markmv API Schemas', - description: 'Auto-generated schemas for markmv methods with @group annotations', - definitions: { - moveFile: { - title: 'moveFile', - description: 'Move a single markdown file and update all references', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "markmv API Schemas", + "description": "Auto-generated schemas for markmv methods with @group annotations", + "definitions": { + "moveFile": { + "title": "moveFile", + "description": "Move a single markdown file and update all references", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" }, - destinationPath: { - type: 'string', - description: 'Destination file path', + "destinationPath": { + "type": "string", + "description": "Destination file path" }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "verbose": { + "type": "boolean", + "description": "Show detailed output" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - success: { - type: 'boolean', + "output": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': [ - 'markmv move old.md new.md', - 'markmv move docs/old.md archive/renamed.md --dry-run', - ], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [ + "markmv move old.md new.md", + "markmv move docs/old.md archive/renamed.md --dry-run" + ] }, - moveFiles: { - title: 'moveFiles', - description: 'Move multiple markdown files and update all references', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', + "moveFiles": { + "title": "moveFiles", + "description": "Move multiple markdown files and update all references", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" }, + "destination": { + "type": "string" + } }, - required: ['source', 'destination'], - additionalProperties: false, - }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', + "verbose": { + "type": "boolean", + "description": "Show detailed output" }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['moves'], - additionalProperties: false, + "required": [ + "moves" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - success: { - type: 'boolean', + "output": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': ['markmv move-files --batch file1.md:new1.md file2.md:new2.md'], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [ + "markmv move-files --batch file1.md:new1.md file2.md:new2.md" + ] }, - validateOperation: { - title: 'validateOperation', - description: 'Validate the result of a previous operation for broken links', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', + "validateOperation": { + "title": "validateOperation", + "description": "Validate the result of a previous operation for broken links", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, + "required": [ + "result" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - valid: { - type: 'boolean', - }, - brokenLinks: { - type: 'number', + "output": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "brokenLinks": { + "type": "number" }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } }, - required: ['valid', 'brokenLinks', 'errors'], - additionalProperties: false, - }, + "required": [ + "valid", + "brokenLinks", + "errors" + ], + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': [], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [] }, - testAutoExposure: { - title: 'testAutoExposure', - description: 'Test function to demonstrate auto-exposure pattern', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "testAutoExposure": { + "title": "testAutoExposure", + "description": "Test function to demonstrate auto-exposure pattern", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, + "required": [ + "input" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - message: { - type: 'string', + "output": { + "type": "object", + "properties": { + "message": { + "type": "string" }, - timestamp: { - type: 'string', - }, - success: { - type: 'boolean', + "timestamp": { + "type": "string" }, + "success": { + "type": "boolean" + } }, - required: ['message', 'timestamp', 'success'], - additionalProperties: false, - }, + "required": [ + "message", + "timestamp", + "success" + ], + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Testing', - 'x-examples': ['markmv test "Hello World"'], - }, - }, + "additionalProperties": false, + "x-group": "Testing", + "x-examples": [ + "markmv test \"Hello World\"" + ] + } + } }; // Compiled validators export const validators = { moveFile: { input: ajv.compile(schemas.definitions.moveFile.properties.input), - output: ajv.compile(schemas.definitions.moveFile.properties.output), + output: ajv.compile(schemas.definitions.moveFile.properties.output) }, moveFiles: { input: ajv.compile(schemas.definitions.moveFiles.properties.input), - output: ajv.compile(schemas.definitions.moveFiles.properties.output), + output: ajv.compile(schemas.definitions.moveFiles.properties.output) }, validateOperation: { input: ajv.compile(schemas.definitions.validateOperation.properties.input), - output: ajv.compile(schemas.definitions.validateOperation.properties.output), + output: ajv.compile(schemas.definitions.validateOperation.properties.output) }, testAutoExposure: { input: ajv.compile(schemas.definitions.testAutoExposure.properties.input), - output: ajv.compile(schemas.definitions.testAutoExposure.properties.output), - }, + output: ajv.compile(schemas.definitions.testAutoExposure.properties.output) + } }; -/** Validate input for a specific method */ -export function validateInput( - methodName: string, - data: unknown -): { valid: boolean; errors: string[] } { +/** + * Validate input for a specific method + */ +export function validateInput(methodName: string, data: unknown): { valid: boolean; errors: string[] } { const validator = validators[methodName as keyof typeof validators]?.input; if (!validator) { return { valid: false, errors: [`Unknown method: ${methodName}`] }; } - + const valid = validator(data); - return valid - ? { valid, errors: [] } - : { - valid, - errors: validator.errors?.map((err) => `${err.instancePath} ${err.message}`) ?? [ - 'Validation failed', - ], - }; + return valid ? { valid, errors: [] } : { + valid, + errors: validator.errors?.map(err => `${err.instancePath} ${err.message}`) ?? ['Validation failed'] + }; } -/** Validate output for a specific method */ -export function validateOutput( - methodName: string, - data: unknown -): { valid: boolean; errors: string[] } { +/** + * Validate output for a specific method + */ +export function validateOutput(methodName: string, data: unknown): { valid: boolean; errors: string[] } { const validator = validators[methodName as keyof typeof validators]?.output; if (!validator) { return { valid: false, errors: [`Unknown method: ${methodName}`] }; } - + const valid = validator(data); - return valid - ? { valid, errors: [] } - : { - valid, - errors: validator.errors?.map((err) => `${err.instancePath} ${err.message}`) ?? [ - 'Validation failed', - ], - }; + return valid ? { valid, errors: [] } : { + valid, + errors: validator.errors?.map(err => `${err.instancePath} ${err.message}`) ?? ['Validation failed'] + }; } -/** Get list of available methods */ +/** + * Get list of available methods + */ export function getAvailableMethods(): string[] { return Object.keys(validators); } diff --git a/src/generated/api-routes.ts b/src/generated/api-routes.ts index af31fbf..4bb1c6f 100644 --- a/src/generated/api-routes.ts +++ b/src/generated/api-routes.ts @@ -1,6 +1,6 @@ /** * Auto-generated REST API route definitions for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ @@ -11,11 +11,7 @@ import type { FileOperations } from '../core/file-operations.js'; export interface ApiRoute { path: string; method: 'GET' | 'POST' | 'PUT' | 'DELETE'; - handler: ( - req: IncomingMessage, - res: ServerResponse, - markmvInstance: FileOperations - ) => Promise; + handler: (req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations) => Promise; description: string; inputSchema: object; outputSchema: object; @@ -27,568 +23,557 @@ export const autoGeneratedApiRoutes: ApiRoute[] = [ path: '/api/move-file', method: 'POST', handler: createmoveFileHandler, - description: 'Move a single markdown file and update all references', + description: "Move a single markdown file and update all references", inputSchema: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', - }, - destinationPath: { - type: 'string', - description: 'Destination file path', - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "destinationPath": { + "type": "string", + "description": "Destination file path" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, - }, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false +} }, { path: '/api/move-files', method: 'POST', handler: createmoveFilesHandler, - description: 'Move multiple markdown files and update all references', + description: "Move multiple markdown files and update all references", inputSchema: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', - }, + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "destination": { + "type": "string" + } + }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } + }, + "required": [ + "moves" + ], + "additionalProperties": false +}, + outputSchema: { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - required: ['source', 'destination'], - additionalProperties: false, - }, - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - }, - additionalProperties: false, - }, - }, - required: ['moves'], - additionalProperties: false, - }, - outputSchema: { - type: 'object', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false +} }, { path: '/api/validate-operation', method: 'POST', handler: createvalidateOperationHandler, - description: 'Validate the result of a previous operation for broken links', + description: "Validate the result of a previous operation for broken links", inputSchema: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, - }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', - ], - additionalProperties: false, - }, + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" + ], + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, - }, + "required": [ + "result" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - valid: { - type: 'boolean', - }, - brokenLinks: { - type: 'number', - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "brokenLinks": { + "type": "number" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } }, - required: ['valid', 'brokenLinks', 'errors'], - additionalProperties: false, - }, + "required": [ + "valid", + "brokenLinks", + "errors" + ], + "additionalProperties": false +} }, { path: '/api/test-auto-exposure', method: 'POST', handler: createtestAutoExposureHandler, - description: 'Test function to demonstrate auto-exposure pattern', + description: "Test function to demonstrate auto-exposure pattern", inputSchema: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, - }, + "required": [ + "input" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - message: { - type: 'string', - }, - timestamp: { - type: 'string', - }, - success: { - type: 'boolean', - }, + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "success": { + "type": "boolean" + } }, - required: ['message', 'timestamp', 'success'], - additionalProperties: false, - }, - }, + "required": [ + "message", + "timestamp", + "success" + ], + "additionalProperties": false +} + } ]; // These handler functions will be created dynamically by the API server // They are placeholders for the auto-generated route definitions export async function createmoveFileHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('moveFile', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const sourcePath = bodyObj.sourcePath; - const destinationPath = bodyObj.destinationPath; + const destinationPath = bodyObj.destinationPath; const options = bodyObj.options || {}; - - if ( - typeof sourcePath === 'string' && - typeof destinationPath === 'string' && - typeof options === 'object' && - options !== null && - !Array.isArray(options) - ) { - result = await markmvInstance.moveFile( - sourcePath, - destinationPath, - options as Record - ); + + if (typeof sourcePath === 'string' && typeof destinationPath === 'string' && + (typeof options === 'object' && options !== null && !Array.isArray(options))) { + result = await markmvInstance.moveFile(sourcePath, destinationPath, options as Record); } else { throw new Error('Invalid parameters for moveFile'); } - + // Validate output const outputValidation = validateOutput('moveFile', result); if (!outputValidation.valid) { console.warn('Output validation failed for moveFile:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createmoveFilesHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('moveFiles', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const moves = bodyObj.moves; const options = bodyObj.options || {}; - - if ( - Array.isArray(moves) && - typeof options === 'object' && - options !== null && - !Array.isArray(options) - ) { + + if (Array.isArray(moves) && + (typeof options === 'object' && options !== null && !Array.isArray(options))) { result = await markmvInstance.moveFiles(moves, options as Record); } else { throw new Error('Invalid parameters for moveFiles'); } - + // Validate output const outputValidation = validateOutput('moveFiles', result); if (!outputValidation.valid) { console.warn('Output validation failed for moveFiles:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createvalidateOperationHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('validateOperation', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const operationResult = bodyObj.result; - - if ( - typeof operationResult === 'object' && - operationResult !== null && - !Array.isArray(operationResult) - ) { + + if (typeof operationResult === 'object' && operationResult !== null && !Array.isArray(operationResult)) { // Type guard to ensure operationResult has required OperationResult properties const opResult = operationResult as Record; - if ( - typeof opResult.success === 'boolean' && - Array.isArray(opResult.modifiedFiles) && - Array.isArray(opResult.createdFiles) && - Array.isArray(opResult.deletedFiles) && - Array.isArray(opResult.errors) && - Array.isArray(opResult.warnings) && - Array.isArray(opResult.changes) - ) { - result = await markmvInstance.validateOperation( - opResult as unknown as import('../types/operations.js').OperationResult - ); + if (typeof opResult.success === 'boolean' && + Array.isArray(opResult.modifiedFiles) && + Array.isArray(opResult.createdFiles) && + Array.isArray(opResult.deletedFiles) && + Array.isArray(opResult.errors) && + Array.isArray(opResult.warnings) && + Array.isArray(opResult.changes)) { + result = await markmvInstance.validateOperation(opResult as unknown as import('../types/operations.js').OperationResult); } else { throw new Error('Invalid OperationResult structure'); } } else { throw new Error('Invalid parameters for validateOperation'); } - + // Validate output const outputValidation = validateOutput('validateOperation', result); if (!outputValidation.valid) { console.warn('Output validation failed for validateOperation:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createtestAutoExposureHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, _markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('testAutoExposure', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const input = bodyObj.input; - + if (typeof input === 'string') { // Import and call the standalone function const { testAutoExposure } = await import('../index.js'); @@ -596,32 +581,32 @@ export async function createtestAutoExposureHandler( } else { throw new Error('Invalid parameters for testAutoExposure'); } - + // Validate output const outputValidation = validateOutput('testAutoExposure', result); if (!outputValidation.valid) { console.warn('Output validation failed for testAutoExposure:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } -/** Helper functions */ +/** + * Helper functions + */ async function parseRequestBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ''; - req.on('data', (chunk) => { + req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { @@ -635,12 +620,16 @@ async function parseRequestBody(req: IncomingMessage): Promise { }); } -/** Get API route by path */ +/** + * Get API route by path + */ export function getApiRouteByPath(path: string): ApiRoute | undefined { - return autoGeneratedApiRoutes.find((route) => route.path === path); + return autoGeneratedApiRoutes.find(route => route.path === path); } -/** Get all API route paths */ +/** + * Get all API route paths + */ export function getApiRoutePaths(): string[] { - return autoGeneratedApiRoutes.map((route) => route.path); + return autoGeneratedApiRoutes.map(route => route.path); } diff --git a/src/generated/mcp-tools.ts b/src/generated/mcp-tools.ts index fb176e5..764ff58 100644 --- a/src/generated/mcp-tools.ts +++ b/src/generated/mcp-tools.ts @@ -1,6 +1,6 @@ /** * Auto-generated MCP tool definitions for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ @@ -10,184 +10,201 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; export const autoGeneratedMcpTools: Tool[] = [ { name: 'move_file', - description: 'Move a single markdown file and update all references', + description: "Move a single markdown file and update all references", inputSchema: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', - }, - destinationPath: { - type: 'string', - description: 'Destination file path', - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', - }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" + }, + "destinationPath": { + "type": "string", + "description": "Destination file path" + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, - }, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false +} }, { name: 'move_files', - description: 'Move multiple markdown files and update all references', + description: "Move multiple markdown files and update all references", inputSchema: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', - }, - }, - required: ['source', 'destination'], - additionalProperties: false, - }, - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', - }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "destination": { + "type": "string" + } + }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['moves'], - additionalProperties: false, - }, + "required": [ + "moves" + ], + "additionalProperties": false +} }, { name: 'validate_operation', - description: 'Validate the result of a previous operation for broken links', + description: "Validate the result of a previous operation for broken links", inputSchema: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, - }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', - ], - additionalProperties: false, - }, + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" + ], + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, - }, + "required": [ + "result" + ], + "additionalProperties": false +} }, { name: 'test_auto_exposure', - description: 'Test function to demonstrate auto-exposure pattern', + description: "Test function to demonstrate auto-exposure pattern", inputSchema: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, - }, - }, + "required": [ + "input" + ], + "additionalProperties": false +} + } ]; -/** Get MCP tool by name */ +/** + * Get MCP tool by name + */ export function getMcpToolByName(name: string): Tool | undefined { - return autoGeneratedMcpTools.find((tool) => tool.name === name); + return autoGeneratedMcpTools.find(tool => tool.name === name); } -/** Get all MCP tool names */ +/** + * Get all MCP tool names + */ export function getMcpToolNames(): string[] { - return autoGeneratedMcpTools.map((tool) => tool.name); + return autoGeneratedMcpTools.map(tool => tool.name); } + diff --git a/src/types/config.ts b/src/types/config.ts index 8c333a2..a3341fc 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -99,7 +99,9 @@ export interface BrokenLink { /** The broken link */ link: import('./links.js').MarkdownLink; /** Reason the link is broken */ - reason: 'file-not-found' | 'external-error' | 'invalid-format' | 'circular-reference'; + reason: 'file-not-found' | 'external-error' | 'invalid-format' | 'circular-reference' | 'auth-required'; /** Additional error details */ details?: string; + /** Authentication information if applicable */ + authInfo?: import('../utils/auth-detection.js').AuthInfo; } diff --git a/src/utils/auth-detection.test.ts b/src/utils/auth-detection.test.ts new file mode 100644 index 0000000..67f2adc --- /dev/null +++ b/src/utils/auth-detection.test.ts @@ -0,0 +1,448 @@ +/** + * Tests for authentication detection utilities. + * + * @fileoverview Tests for AuthDetector functionality and configuration + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { AuthDetector, DEFAULT_AUTH_CONFIG } from './auth-detection.js'; + +describe('AuthDetector', () => { + let authDetector: AuthDetector; + + beforeEach(() => { + authDetector = new AuthDetector(); + }); + + describe('Configuration', () => { + it('should initialize with default configuration', () => { + expect(authDetector.isEnabled()).toBe(true); + }); + + it('should support disabling authentication detection', () => { + const disabledDetector = new AuthDetector({ enabled: false }); + expect(disabledDetector.isEnabled()).toBe(false); + }); + + it('should support custom domain patterns', () => { + const customDetector = new AuthDetector({ + authDomainPatterns: ['custom.example.com', 'internal.company.com'], + }); + + expect(customDetector).toBeDefined(); + }); + + it('should support custom credentials', () => { + const credentialsDetector = new AuthDetector({ + credentials: { + 'api.github.com': 'Bearer token123', + 'api.example.com': 'Token xyz789', + }, + }); + + expect(credentialsDetector.shouldAttemptAuth('https://api.github.com/user')).toBe(true); + expect(credentialsDetector.shouldAttemptAuth('https://api.example.com/data')).toBe(true); + expect(credentialsDetector.shouldAttemptAuth('https://other.example.com/data')).toBe(false); + }); + }); + + describe('Domain-based Authentication Detection', () => { + it('should detect Firebase Console URLs as auth-required', async () => { + const url = 'https://console.firebase.google.com/project/my-project/settings'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('Firebase'); + expect(authInfo.detectionMethod).toBe('domain'); + expect(authInfo.warning).toContain('authentication-protected'); + }); + + it('should detect GitHub settings URLs as auth-required', async () => { + const url = 'https://github.com/myorg/settings/profile'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('GitHub'); + expect(authInfo.detectionMethod).toBe('domain'); + }); + + it('should detect Google Cloud Console URLs as auth-required', async () => { + const url = 'https://console.cloud.google.com/compute/instances'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('Google'); + expect(authInfo.detectionMethod).toBe('domain'); + }); + + it('should detect AWS Console URLs as auth-required', async () => { + const url = 'https://console.aws.amazon.com/ec2/v2/home'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('AWS'); + expect(authInfo.detectionMethod).toBe('domain'); + }); + + it('should detect Microsoft 365 URLs as auth-required', async () => { + const url = 'https://mycompany.sharepoint.com/sites/team'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('Microsoft 365'); + expect(authInfo.detectionMethod).toBe('domain'); + }); + + it('should detect Vercel dashboard URLs as auth-required', async () => { + const url = 'https://app.vercel.com/dashboard'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('Vercel'); + expect(authInfo.detectionMethod).toBe('domain'); + }); + + it('should not detect regular documentation URLs as auth-required', async () => { + const url = 'https://docs.github.com/en/actions'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(false); + expect(authInfo.detectionMethod).toBe('none'); + }); + + it('should handle wildcard domain patterns', async () => { + const customDetector = new AuthDetector({ + authDomainPatterns: ['*.internal.company.com'], + }); + + const url = 'https://api.internal.company.com/v1/data'; + const authInfo = await customDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.detectionMethod).toBe('domain'); + }); + }); + + describe('Response Analysis', () => { + it('should detect 401 Unauthorized as auth-required', async () => { + const mockResponse = new Response('', { + status: 401, + statusText: 'Unauthorized', + headers: new Headers(), + }); + + const url = 'https://api.example.com/private'; + const authInfo = await authDetector.analyzeAuth(url, mockResponse); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.detectionMethod).toBe('status-code'); + expect(authInfo.warning).toContain('HTTP 401'); + }); + + it('should detect 403 Forbidden as auth-required', async () => { + const mockResponse = new Response('', { + status: 403, + statusText: 'Forbidden', + headers: new Headers(), + }); + + const url = 'https://api.example.com/admin'; + const authInfo = await authDetector.analyzeAuth(url, mockResponse); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.detectionMethod).toBe('status-code'); + expect(authInfo.warning).toContain('HTTP 403'); + }); + + it('should not detect 200 OK as auth-required', async () => { + const mockResponse = new Response('content', { + status: 200, + statusText: 'OK', + headers: new Headers(), + }); + + const url = 'https://example.com/public'; + const authInfo = await authDetector.analyzeAuth(url, mockResponse); + + expect(authInfo.requiresAuth).toBe(false); + }); + + it('should detect redirects to authentication pages', async () => { + const originalUrl = 'https://private.example.com/dashboard'; + const finalUrl = 'https://accounts.google.com/oauth/authorize'; + + const mockResponse = new Response('', { + status: 200, + headers: new Headers(), + }); + + // Mock the response URL to simulate redirect + Object.defineProperty(mockResponse, 'url', { + value: finalUrl, + writable: false, + }); + + const authInfo = await authDetector.analyzeAuth(originalUrl, mockResponse, [finalUrl]); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.detectionMethod).toBe('redirect'); + expect(authInfo.authProvider).toBe('Google'); + expect(authInfo.finalUrl).toBe(finalUrl); + }); + }); + + describe('Authentication Headers', () => { + it('should provide auth headers for configured domains', () => { + const credentialsDetector = new AuthDetector({ + credentials: { + 'api.github.com': 'Bearer ghp_token123', + 'api.example.com': 'Token xyz789', + }, + }); + + const githubHeaders = credentialsDetector.getAuthHeaders('https://api.github.com/user'); + expect(githubHeaders).toEqual({ + 'Authorization': 'Bearer ghp_token123', + }); + + const exampleHeaders = credentialsDetector.getAuthHeaders('https://api.example.com/data'); + expect(exampleHeaders).toEqual({ + 'Authorization': 'Token xyz789', + }); + }); + + it('should provide custom headers for configured domains', () => { + const headersDetector = new AuthDetector({ + customHeaders: { + 'api.custom.com': { + 'X-API-Key': 'custom-key-123', + 'X-Client-Version': '1.0.0', + }, + }, + }); + + const headers = headersDetector.getAuthHeaders('https://api.custom.com/v1/data'); + expect(headers).toEqual({ + 'X-API-Key': 'custom-key-123', + 'X-Client-Version': '1.0.0', + }); + }); + + it('should return empty headers for unconfigured domains', () => { + const headers = authDetector.getAuthHeaders('https://unknown.example.com/api'); + expect(headers).toEqual({}); + }); + + it('should support wildcard credentials', () => { + const wildcardDetector = new AuthDetector({ + credentials: { + '*': 'Bearer universal-token', + }, + }); + + const headers = wildcardDetector.getAuthHeaders('https://any.example.com/api'); + expect(headers).toEqual({ + 'Authorization': 'Bearer universal-token', + }); + }); + }); + + describe('Authentication Attempt Detection', () => { + it('should attempt auth for configured credential domains', () => { + const credentialsDetector = new AuthDetector({ + credentials: { + 'api.github.com': 'Bearer token', + }, + }); + + expect(credentialsDetector.shouldAttemptAuth('https://api.github.com/user')).toBe(true); + expect(credentialsDetector.shouldAttemptAuth('https://api.example.com/data')).toBe(false); + }); + + it('should attempt auth for configured custom header domains', () => { + const headersDetector = new AuthDetector({ + customHeaders: { + 'api.custom.com': { 'X-API-Key': 'key' }, + }, + }); + + expect(headersDetector.shouldAttemptAuth('https://api.custom.com/data')).toBe(true); + expect(headersDetector.shouldAttemptAuth('https://other.example.com/data')).toBe(false); + }); + + it('should handle subdomain matching', () => { + const credentialsDetector = new AuthDetector({ + credentials: { + 'github.com': 'Bearer token', + }, + }); + + expect(credentialsDetector.shouldAttemptAuth('https://api.github.com/user')).toBe(true); + expect(credentialsDetector.shouldAttemptAuth('https://raw.githubusercontent.com/file')).toBe(false); + }); + }); + + describe('Provider Detection', () => { + it('should detect Google as provider from domains', async () => { + const testCases = [ + { url: 'https://console.firebase.google.com/project', expected: 'Firebase' }, + { url: 'https://console.cloud.google.com/compute', expected: 'Google' }, + { url: 'https://accounts.google.com/oauth', expected: 'Google' }, + ]; + + for (const testCase of testCases) { + const authInfo = await authDetector.analyzeAuth(testCase.url); + if (authInfo.requiresAuth) { + expect(authInfo.authProvider).toBe(testCase.expected); + } + } + }); + + it('should detect GitHub as provider from domains', async () => { + const url = 'https://github.com/myorg/settings/profile'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.authProvider).toBe('GitHub'); + }); + + it('should detect Microsoft as provider from domains', async () => { + const urls = [ + 'https://admin.microsoft.com/dashboard', + 'https://portal.azure.com/subscriptions', + 'https://mycompany.sharepoint.com/sites', + ]; + + for (const url of urls) { + const authInfo = await authDetector.analyzeAuth(url); + if (authInfo.requiresAuth) { + expect(authInfo.authProvider).toContain('Microsoft'); + } + } + }); + + it('should detect AWS as provider from domains', async () => { + const url = 'https://console.aws.amazon.com/ec2'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.authProvider).toBe('AWS'); + }); + }); + + describe('Redirect Analysis', () => { + it('should detect auth redirects by URL patterns', async () => { + const testCases = [ + { + redirectUrl: 'https://login.microsoftonline.com/oauth', + expectedProvider: 'Microsoft', + }, + { + redirectUrl: 'https://github.com/login/oauth', + expectedProvider: 'GitHub', + }, + { + redirectUrl: 'https://auth0.com/login', + expectedProvider: 'Auth0', + }, + { + redirectUrl: 'https://example.com/signin', + expectedProvider: 'Authentication Service', + }, + ]; + + for (const testCase of testCases) { + // Create a mock response that simulates a redirect + const mockResponse = new Response('', { + status: 200, + headers: new Headers(), + }); + + // Mock the response URL to simulate redirect + Object.defineProperty(mockResponse, 'url', { + value: testCase.redirectUrl, + writable: false, + }); + + const authInfo = await authDetector.analyzeAuth( + 'https://original.example.com', + mockResponse, + [] + ); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe(testCase.expectedProvider); + } + }); + + it('should detect auth parameters in URLs', async () => { + const mockResponse = new Response('', { + status: 200, + headers: new Headers(), + }); + + // Mock the response URL with auth parameters + Object.defineProperty(mockResponse, 'url', { + value: 'https://example.com/dashboard?login=required&redirect_uri=callback', + writable: false, + }); + + const authInfo = await authDetector.analyzeAuth( + 'https://example.com/original', + mockResponse + ); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.warning).toContain('authentication-related parameters'); + }); + }); + + describe('Error Handling', () => { + it('should handle invalid URLs gracefully', async () => { + const authInfo = await authDetector.analyzeAuth('not-a-valid-url'); + + expect(authInfo.requiresAuth).toBe(false); + expect(authInfo.detectionMethod).toBe('none'); + }); + + it('should handle disabled detection', async () => { + const disabledDetector = new AuthDetector({ enabled: false }); + const authInfo = await disabledDetector.analyzeAuth('https://console.firebase.google.com/project'); + + expect(authInfo.requiresAuth).toBe(false); + expect(authInfo.detectionMethod).toBe('none'); + }); + + it('should handle missing response gracefully', async () => { + const authInfo = await authDetector.analyzeAuth('https://example.com/unknown'); + + expect(authInfo.detectionMethod).toBe('none'); + expect(authInfo.redirectCount).toBe(0); + }); + }); + + describe('Default Configuration', () => { + it('should have comprehensive default auth domain patterns', () => { + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('console.firebase.google.com'); + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('console.cloud.google.com'); + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('github.com/*/settings'); + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('app.vercel.com'); + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('dashboard.heroku.com'); + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('*.atlassian.net'); + }); + + it('should have comprehensive default redirect patterns', () => { + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('accounts.google.com'); + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('login.microsoftonline.com'); + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('github.com/login'); + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('/login'); + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('/auth'); + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('/oauth'); + }); + + it('should have reasonable default settings', () => { + expect(DEFAULT_AUTH_CONFIG.enabled).toBe(true); + expect(DEFAULT_AUTH_CONFIG.maxRedirects).toBe(5); + expect(DEFAULT_AUTH_CONFIG.credentials).toEqual({}); + expect(DEFAULT_AUTH_CONFIG.customHeaders).toEqual({}); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/auth-detection.ts b/src/utils/auth-detection.ts new file mode 100644 index 0000000..e70c0fb --- /dev/null +++ b/src/utils/auth-detection.ts @@ -0,0 +1,467 @@ +/** + * Authentication detection utilities for external link validation. + * + * @fileoverview Detects authentication-protected URLs and handles auth-aware validation + */ + +/** + * Configuration for authentication detection. + */ +export interface AuthConfig { + /** Enable authentication detection */ + enabled: boolean; + /** API keys/tokens for authenticated requests */ + credentials: Record; + /** Patterns for recognizing auth-protected domains */ + authDomainPatterns: string[]; + /** Redirect patterns that indicate authentication requirement */ + authRedirectPatterns: string[]; + /** Maximum number of redirects to follow */ + maxRedirects: number; + /** Custom headers for authenticated requests */ + customHeaders: Record>; +} + +/** + * Information about authentication status of a link. + */ +export interface AuthInfo { + /** URL being checked */ + url: string; + /** Whether link requires authentication */ + requiresAuth: boolean; + /** Authentication provider if detected */ + authProvider?: string; + /** Final URL after redirects */ + finalUrl?: string; + /** Number of redirects followed */ + redirectCount: number; + /** Whether authentication was attempted */ + authAttempted: boolean; + /** Whether authentication succeeded */ + authSucceeded?: boolean; + /** Auth detection method used */ + detectionMethod: 'domain' | 'redirect' | 'status-code' | 'content' | 'none'; + /** Warning message if auth-protected */ + warning?: string; + /** Suggestion for handling auth requirement */ + suggestion?: string; +} + +/** + * Authentication detector for external links. + */ +export class AuthDetector { + private config: AuthConfig; + + constructor(config: Partial = {}) { + this.config = { + enabled: config.enabled ?? true, + credentials: config.credentials ?? {}, + authDomainPatterns: config.authDomainPatterns ?? [ + 'console.firebase.google.com', + 'console.cloud.google.com', + 'github.com/*/settings', + 'github.com/orgs/*/settings', + 'admin.microsoft.com', + 'portal.azure.com', + 'aws.amazon.com/console', + 'console.aws.amazon.com', + 'app.vercel.com', + 'dashboard.heroku.com', + 'app.netlify.com', + 'app.supabase.com', + 'app.planetscale.com', + '*.atlassian.net', + 'trello.com/b/', + 'notion.so/', + '*.sharepoint.com', + '*.onedrive.com', + 'drive.google.com', + 'docs.google.com/*/d/', + 'sheets.google.com/*/d/', + 'slides.google.com/*/d/', + ], + authRedirectPatterns: config.authRedirectPatterns ?? [ + 'accounts.google.com', + 'login.microsoftonline.com', + 'github.com/login', + 'auth0.com', + 'okta.com', + 'login.salesforce.com', + 'signin.aws.amazon.com', + 'id.heroku.com', + 'auth.netlify.com', + '/login', + '/signin', + '/auth', + '/oauth', + '/sso', + ], + maxRedirects: config.maxRedirects ?? 5, + customHeaders: config.customHeaders ?? {}, + }; + } + + /** + * Check if authentication detection is enabled. + */ + isEnabled(): boolean { + return this.config.enabled; + } + + /** + * Analyze authentication requirements for a URL. + */ + async analyzeAuth( + url: string, + response?: Response, + redirectHistory: string[] = [] + ): Promise { + if (!this.config.enabled) { + return { + url, + requiresAuth: false, + redirectCount: 0, + authAttempted: false, + detectionMethod: 'none', + }; + } + + const result: AuthInfo = { + url, + requiresAuth: false, + redirectCount: redirectHistory.length, + authAttempted: false, + detectionMethod: 'none', + }; + + // Check domain-based auth detection first + const domainAuth = this.checkAuthDomain(url); + if (domainAuth.requiresAuth) { + return { + ...result, + ...domainAuth, + detectionMethod: 'domain', + }; + } + + // If we have a response, analyze it for auth indicators + if (response) { + const responseAuth = await this.analyzeResponse(url, response, redirectHistory); + if (responseAuth.requiresAuth) { + return { + ...result, + ...responseAuth, + }; + } + } + + return result; + } + + /** + * Check if URL is from a known auth-protected domain. + */ + private checkAuthDomain(url: string): Partial { + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname; + const fullUrl = url.toLowerCase(); + + for (const pattern of this.config.authDomainPatterns) { + if (this.matchesPattern(fullUrl, hostname, pattern)) { + const provider = this.detectAuthProvider(pattern, hostname); + return { + requiresAuth: true, + authProvider: provider, + warning: `URL appears to be authentication-protected (${provider})`, + suggestion: 'This is likely working correctly but requires authentication to access', + }; + } + } + + return { requiresAuth: false }; + } catch { + return { requiresAuth: false }; + } + } + + /** + * Analyze HTTP response for authentication indicators. + */ + private async analyzeResponse( + url: string, + response: Response, + redirectHistory: string[] + ): Promise> { + const finalUrl = response.url; + + // Check for auth redirects + if (finalUrl !== url || redirectHistory.length > 0) { + const redirectAuth = this.analyzeRedirects(url, finalUrl, redirectHistory); + if (redirectAuth.requiresAuth) { + return { + ...redirectAuth, + finalUrl, + detectionMethod: 'redirect', + }; + } + } + + // Check status codes that indicate auth requirement + if (response.status === 401 || response.status === 403) { + return { + requiresAuth: true, + finalUrl, + detectionMethod: 'status-code', + warning: `HTTP ${response.status}: Authentication required`, + suggestion: 'Provide appropriate credentials or API keys to validate this link', + }; + } + + // Check response content for auth indicators (if available) + try { + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('text/html')) { + // Don't actually read the content in production to avoid performance issues + // This would be for future enhancement + return { requiresAuth: false }; + } + } catch { + // Ignore content analysis errors + } + + return { requiresAuth: false }; + } + + /** + * Analyze redirect chain for authentication patterns. + */ + private analyzeRedirects( + originalUrl: string, + finalUrl: string, + redirectHistory: string[] + ): Partial { + const allUrls = [originalUrl, ...redirectHistory]; + if (finalUrl && finalUrl !== originalUrl) { + allUrls.push(finalUrl); + } + + for (const redirectUrl of allUrls) { + for (const pattern of this.config.authRedirectPatterns) { + if (redirectUrl.toLowerCase().includes(pattern.toLowerCase())) { + const provider = this.detectAuthProviderFromUrl(redirectUrl); + return { + requiresAuth: true, + authProvider: provider, + warning: `Redirected to authentication page (${provider})`, + suggestion: 'This link is working correctly but requires user authentication', + }; + } + } + } + + // Check for common auth-related query parameters + try { + const finalUrlObj = new URL(finalUrl); + const hasAuthParams = ['login', 'auth', 'signin', 'oauth', 'sso'].some(param => + finalUrlObj.searchParams.has(param) || finalUrlObj.pathname.includes(`/${param}`) + ); + + if (hasAuthParams) { + return { + requiresAuth: true, + warning: 'URL contains authentication-related parameters', + suggestion: 'This link likely requires user authentication to access', + }; + } + } catch { + // Ignore URL parsing errors + } + + return { requiresAuth: false }; + } + + /** + * Check if URL matches an auth domain pattern. + */ + private matchesPattern(fullUrl: string, hostname: string, pattern: string): boolean { + // Handle wildcard patterns + if (pattern.includes('*')) { + const regexPattern = pattern + .replace(/\./g, '\\.') + .replace(/\*/g, '[^./]*'); + const regex = new RegExp(regexPattern, 'i'); + return regex.test(hostname) || regex.test(fullUrl); + } + + // Exact match or subdomain match + return hostname === pattern || + hostname.endsWith(`.${pattern}`) || + fullUrl.includes(pattern); + } + + /** + * Detect authentication provider from domain pattern. + */ + private detectAuthProvider(pattern: string, hostname: string): string { + if (pattern.includes('firebase') || hostname.includes('firebase')) { + return 'Firebase'; + } + if (pattern.includes('github') || hostname.includes('github')) { + return 'GitHub'; + } + if (pattern.includes('microsoft') || pattern.includes('azure') || hostname.includes('microsoft')) { + return 'Microsoft'; + } + if (pattern.includes('aws') || pattern.includes('amazon')) { + return 'AWS'; + } + if (pattern.includes('google') || hostname.includes('google')) { + return 'Google'; + } + if (pattern.includes('vercel')) { + return 'Vercel'; + } + if (pattern.includes('netlify')) { + return 'Netlify'; + } + if (pattern.includes('heroku')) { + return 'Heroku'; + } + if (pattern.includes('supabase')) { + return 'Supabase'; + } + if (pattern.includes('planetscale')) { + return 'PlanetScale'; + } + if (pattern.includes('atlassian')) { + return 'Atlassian'; + } + if (pattern.includes('trello')) { + return 'Trello'; + } + if (pattern.includes('notion')) { + return 'Notion'; + } + if (pattern.includes('sharepoint') || pattern.includes('onedrive')) { + return 'Microsoft 365'; + } + + return 'Unknown Provider'; + } + + /** + * Detect authentication provider from redirect URL. + */ + private detectAuthProviderFromUrl(url: string): string { + const lowerUrl = url.toLowerCase(); + + if (lowerUrl.includes('google')) return 'Google'; + if (lowerUrl.includes('github')) return 'GitHub'; + if (lowerUrl.includes('microsoft') || lowerUrl.includes('azure')) return 'Microsoft'; + if (lowerUrl.includes('aws') || lowerUrl.includes('amazon')) return 'AWS'; + if (lowerUrl.includes('auth0')) return 'Auth0'; + if (lowerUrl.includes('okta')) return 'Okta'; + if (lowerUrl.includes('salesforce')) return 'Salesforce'; + if (lowerUrl.includes('heroku')) return 'Heroku'; + if (lowerUrl.includes('netlify')) return 'Netlify'; + + return 'Authentication Service'; + } + + /** + * Get headers for authenticated request to a specific domain. + */ + getAuthHeaders(url: string): Record { + try { + const hostname = new URL(url).hostname; + + // Check for domain-specific headers + for (const [domain, headers] of Object.entries(this.config.customHeaders)) { + if (hostname === domain || hostname.endsWith(`.${domain}`)) { + return headers; + } + } + + // Check for general credentials + const authHeader = this.config.credentials[hostname] || this.config.credentials['*']; + if (authHeader) { + return { + 'Authorization': authHeader, + }; + } + + return {}; + } catch { + return {}; + } + } + + /** + * Check if we should attempt authentication for this URL. + */ + shouldAttemptAuth(url: string): boolean { + try { + const hostname = new URL(url).hostname; + return Object.keys(this.config.credentials).some(key => + key === hostname || key === '*' || hostname.endsWith(`.${key}`) + ) || Object.keys(this.config.customHeaders).some(domain => + hostname === domain || hostname.endsWith(`.${domain}`) + ); + } catch { + return false; + } + } +} + +/** + * Default authentication configuration. + */ +export const DEFAULT_AUTH_CONFIG: AuthConfig = { + enabled: true, + credentials: {}, + authDomainPatterns: [ + 'console.firebase.google.com', + 'console.cloud.google.com', + 'github.com/*/settings', + 'github.com/orgs/*/settings', + 'admin.microsoft.com', + 'portal.azure.com', + 'aws.amazon.com/console', + 'console.aws.amazon.com', + 'app.vercel.com', + 'dashboard.heroku.com', + 'app.netlify.com', + 'app.supabase.com', + 'app.planetscale.com', + '*.atlassian.net', + 'trello.com/b/', + 'notion.so/', + '*.sharepoint.com', + '*.onedrive.com', + 'drive.google.com', + 'docs.google.com/*/d/', + 'sheets.google.com/*/d/', + 'slides.google.com/*/d/', + ], + authRedirectPatterns: [ + 'accounts.google.com', + 'login.microsoftonline.com', + 'github.com/login', + 'auth0.com', + 'okta.com', + 'login.salesforce.com', + 'signin.aws.amazon.com', + 'id.heroku.com', + 'auth.netlify.com', + '/login', + '/signin', + '/auth', + '/oauth', + '/sso', + ], + maxRedirects: 5, + customHeaders: {}, +}; \ No newline at end of file