diff --git a/src/utils/index.ts b/src/utils/index.ts index 7c14ac9..e1c1d23 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -31,3 +31,5 @@ export { isLocalVariablesResponse, isPublishedVariablesResponse, } from 'utils/typeGuards' +export { redactToken } from 'utils/redactToken' +export type { RedactTokenOptions } from 'utils/redactToken' diff --git a/src/utils/redactToken.ts b/src/utils/redactToken.ts new file mode 100644 index 0000000..ee7aea7 --- /dev/null +++ b/src/utils/redactToken.ts @@ -0,0 +1,71 @@ +/** + * Redact a Figma Personal Access Token for safe logging. + * + * @remarks + * This utility masks the middle portion of a token while preserving + * the first and last few characters for identification. Useful for + * debugging and logging without exposing sensitive credentials. + * + * @param token - The token to redact (can be null/undefined) + * @param options - Optional configuration for redaction behavior + * @returns The redacted token string, or a placeholder for empty/short tokens + * + * @example + * ```ts + * import { redactToken } from '@figma-vars/hooks/utils'; + * + * console.log(redactToken('figd_abc123xyz789def456')); + * // Output: 'figd_***...***456' + * + * console.log(redactToken(null)); + * // Output: '[no token]' + * ``` + * + * @public + */ +export interface RedactTokenOptions { + /** + * Number of characters to show at the start of the token. + * @defaultValue 5 + */ + visibleStart?: number + /** + * Number of characters to show at the end of the token. + * @defaultValue 3 + */ + visibleEnd?: number + /** + * Placeholder text for null/undefined tokens. + * @defaultValue '[no token]' + */ + emptyPlaceholder?: string +} + +export function redactToken( + token: string | null | undefined, + options?: RedactTokenOptions +): string { + const { + visibleStart = 5, + visibleEnd = 3, + emptyPlaceholder = '[no token]', + } = options ?? {} + + // Handle null/undefined/empty tokens + if (!token) { + return emptyPlaceholder + } + + // Minimum length to apply redaction (start + end + at least 1 char to hide) + const minLength = visibleStart + visibleEnd + 1 + + // If token is too short to redact meaningfully, mask entirely + if (token.length < minLength) { + return '*'.repeat(token.length) + } + + const start = token.slice(0, visibleStart) + const end = token.slice(-visibleEnd) + + return `${start}***...***${end}` +} diff --git a/tests/utils/redactToken.test.ts b/tests/utils/redactToken.test.ts new file mode 100644 index 0000000..3b6f7b0 --- /dev/null +++ b/tests/utils/redactToken.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import { redactToken } from '../../src/utils/redactToken' + +describe('redactToken', () => { + describe('basic redaction', () => { + it('should redact middle portion of a normal token', () => { + const token = 'figd_abc123xyz789def456' + const result = redactToken(token) + expect(result).toBe('figd_***...***456') + }) + + it('should show first 5 and last 3 characters by default', () => { + const token = 'abcdefghijklmnopqrstuvwxyz' + const result = redactToken(token) + expect(result).toBe('abcde***...***xyz') + }) + }) + + describe('null/undefined/empty handling', () => { + it('should return placeholder for null token', () => { + expect(redactToken(null)).toBe('[no token]') + }) + + it('should return placeholder for undefined token', () => { + expect(redactToken(undefined)).toBe('[no token]') + }) + + it('should return placeholder for empty string', () => { + expect(redactToken('')).toBe('[no token]') + }) + }) + + describe('short token handling', () => { + it('should mask entirely if token is too short', () => { + // minLength = 5 + 3 + 1 = 9, so 8 chars should be fully masked + expect(redactToken('12345678')).toBe('********') + }) + + it('should mask single character token', () => { + expect(redactToken('x')).toBe('*') + }) + + it('should mask token at exactly minimum length', () => { + // 9 chars is exactly minLength, should be redacted normally + expect(redactToken('123456789')).toBe('12345***...***789') + }) + }) + + describe('custom options', () => { + it('should use custom visibleStart', () => { + const result = redactToken('abcdefghijklmnop', { visibleStart: 3 }) + expect(result).toBe('abc***...***nop') + }) + + it('should use custom visibleEnd', () => { + const result = redactToken('abcdefghijklmnop', { visibleEnd: 5 }) + expect(result).toBe('abcde***...***lmnop') + }) + + it('should use custom emptyPlaceholder', () => { + const result = redactToken(null, { emptyPlaceholder: 'N/A' }) + expect(result).toBe('N/A') + }) + + it('should use all custom options together', () => { + const result = redactToken('abcdefghijklmnop', { + visibleStart: 2, + visibleEnd: 2, + emptyPlaceholder: 'custom', + }) + expect(result).toBe('ab***...***op') + }) + }) + + describe('edge cases', () => { + it('should handle token with special characters', () => { + const token = 'figd_!@#$%^&*()_+-=[]' + const result = redactToken(token) + // Last 3 chars of 'figd_!@#$%^&*()_+-=[]' are '=[]' + expect(result).toBe('figd_***...***=[]') + }) + + it('should handle very long token', () => { + const token = 'a'.repeat(1000) + const result = redactToken(token) + expect(result).toBe('aaaaa***...***aaa') + }) + }) +})