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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ export {
isLocalVariablesResponse,
isPublishedVariablesResponse,
} from 'utils/typeGuards'
export { redactToken } from 'utils/redactToken'
export type { RedactTokenOptions } from 'utils/redactToken'
71 changes: 71 additions & 0 deletions src/utils/redactToken.ts
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +28 to +33
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interface documentation should specify that visibleStart and visibleEnd must be non-negative integers. Currently, there's no indication of valid ranges or constraints, which could lead to misuse. Consider adding a note in the JSDoc comments for both properties.

Suggested change
* 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.
* Number of characters to show at the start of the token.
* Must be a non-negative integer.
* @defaultValue 5
*/
visibleStart?: number
/**
* Number of characters to show at the end of the token.
* Must be a non-negative integer.

Copilot uses AI. Check for mistakes.
* @defaultValue 3
*/
visibleEnd?: number
/**
* Placeholder text for null/undefined tokens.
* @defaultValue '[no token]'
*/
emptyPlaceholder?: string
}
Comment on lines +1 to +42
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc comment on lines 1-25 appears to document the redactToken function, but it's placed above the RedactTokenOptions interface. This is unconventional and could confuse documentation generators.

The JSDoc comment should be moved to directly above the function declaration (line 44), and the interface should have its own separate JSDoc comment explaining what it represents. This follows the pattern seen in other files like src/utils/swrKeys.ts where interfaces have their own documentation.

Copilot uses AI. Check for mistakes.

export function redactToken(
token: string | null | undefined,
options?: RedactTokenOptions
): string {
const {
visibleStart = 5,
visibleEnd = 3,
emptyPlaceholder = '[no token]',
} = options ?? {}

Comment on lines +48 to +53
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function doesn't validate that visibleStart and visibleEnd are non-negative numbers. Negative values could lead to unexpected behavior with string slicing. For example, passing visibleStart: -5 would cause slice(0, -5) which removes the last 5 characters instead of showing the first -5 characters.

Consider adding validation to ensure both values are non-negative, or document the expected behavior when negative values are provided.

Suggested change
const {
visibleStart = 5,
visibleEnd = 3,
emptyPlaceholder = '[no token]',
} = options ?? {}
const emptyPlaceholder = options?.emptyPlaceholder ?? '[no token]'
let visibleStart = options?.visibleStart
if (typeof visibleStart !== 'number' || !Number.isFinite(visibleStart) || visibleStart < 0) {
visibleStart = 5
} else {
visibleStart = Math.floor(visibleStart)
}
let visibleEnd = options?.visibleEnd
if (typeof visibleEnd !== 'number' || !Number.isFinite(visibleEnd) || visibleEnd < 0) {
visibleEnd = 3
} else {
visibleEnd = Math.floor(visibleEnd)
}

Copilot uses AI. Check for mistakes.
// 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)
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a critical bug when visibleEnd is 0. The expression slice(-0) is equivalent to slice(0), which returns the entire string from the beginning, effectively exposing the full token instead of hiding it. This defeats the security purpose of the function.

To fix this, add a conditional check: if visibleEnd is 0, use an empty string for end. Similarly, ensure visibleStart works correctly when 0 (it already does, since slice(0, 0) returns an empty string).

Suggested change
const end = token.slice(-visibleEnd)
const end = visibleEnd === 0 ? '' : token.slice(-visibleEnd)

Copilot uses AI. Check for mistakes.

return `${start}***...***${end}`
}
89 changes: 89 additions & 0 deletions tests/utils/redactToken.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Comment on lines +49 to +73
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage is missing for the critical edge case where visibleEnd is set to 0. This would expose the entire token due to how slice(-0) behaves in JavaScript. Add a test case like:

expect(redactToken('abcdefghij', { visibleEnd: 0 })).toBe('abcde***...***')

Also add tests for:

  • visibleStart: 0 (should work correctly but needs verification)
  • Negative values for both options to document/verify expected behavior
  • Both options set to 0 simultaneously

Copilot uses AI. Check for mistakes.

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')
})
})
})