From 95b83c835eac13caf888a3667bff37c6e7657341 Mon Sep 17 00:00:00 2001 From: bradensui Date: Wed, 7 Jan 2026 22:12:18 -0600 Subject: [PATCH 1/6] feat: add vibe-utils shared utilities module Add foundational utility module for vibe functionality: - HTTP fetch with timeout and retry logic - Exponential backoff for transient failures - URL validation and security checks - Verbose logging utilities - Error handling with error-causes This is the first module in the vibe command implementation, providing shared functionality for authentication, file handling, code generation, and publishing modules. Co-Authored-By: Claude Opus 4.5 --- lib/vibe-utils.d.ts | 59 +++ lib/vibe-utils.js | 952 +++++++++++++++++++++++++++++++++++ lib/vibe-utils.test.js | 1080 ++++++++++++++++++++++++++++++++++++++++ package.json | 4 + 4 files changed, 2095 insertions(+) create mode 100644 lib/vibe-utils.d.ts create mode 100644 lib/vibe-utils.js create mode 100644 lib/vibe-utils.test.js diff --git a/lib/vibe-utils.d.ts b/lib/vibe-utils.d.ts new file mode 100644 index 0000000..5e09761 --- /dev/null +++ b/lib/vibe-utils.d.ts @@ -0,0 +1,59 @@ +export function normalizeOrigin(origin: string): string; + +export function validateApiBase( + apiBase: string, + options?: { _testOnlyOrigins?: string[] }, +): { valid: boolean; reason?: string; origin?: string }; + +export function validatePlayerBase( + playerBase: string, + options?: { _testOnlyOrigins?: string[] }, +): { valid: boolean; reason?: string; origin?: string }; + +export function createVerboseLogger( + moduleName: string, +): (message: string, verbose: boolean) => void; + +export function verboseLog(prefix: string, message: string, verbose: boolean): void; + +export function fetchWithTimeout( + url: string, + init?: unknown, + timeoutMs?: number, +): Promise; + +export function fetchWithRetry( + url: string, + init?: unknown, + options?: { + maxRetries?: number; + baseDelayMs?: number; + timeoutMs?: number; + verbose?: boolean; + }, +): Promise; + +export function fetchJson( + url: string, + init?: unknown, + options?: { + timeoutMs?: number; + maxRetries?: number; + baseDelayMs?: number; + verbose?: boolean; + }, +): Promise; + +export function isAuthError(err: unknown): boolean; +export const isLikelyClerkTokenProblem: typeof isAuthError; + +export function isPathSafe(filePath: string): { safe: boolean; reason?: string }; + +export function deepClone(obj: T): T; + +export function mergeConfig( + target: T, + source: U, +): T & U; + +export const allowedOrigins: Record; diff --git a/lib/vibe-utils.js b/lib/vibe-utils.js new file mode 100644 index 0000000..25a66d1 --- /dev/null +++ b/lib/vibe-utils.js @@ -0,0 +1,952 @@ +/** + * vibe-utils.js + * + * Shared utility functions for vibe modules. + * Centralizes common operations like HTTP requests, URL handling, + * logging, and security validation. + * + * @module vibe-utils + */ + +import { errorCauses, createError } from "error-causes"; + +// ============================================================================= +// Error Definitions +// ============================================================================= + +const [networkErrors] = errorCauses({ + FetchError: { + code: "FETCH_FAILED", + message: "Fetch operation failed", + }, + FetchTimeout: { + code: "FETCH_TIMEOUT", + message: "Request timed out", + }, + FetchRetryExhausted: { + code: "FETCH_RETRY_EXHAUSTED", + message: "All retry attempts failed", + }, + TestOnlyOriginsNotAllowed: { + code: "TEST_ONLY_ORIGINS_NOT_ALLOWED", + message: "Test-only origins are not allowed in production", + }, + FetchJsonParseError: { + code: "FETCH_JSON_PARSE_ERROR", + message: "Failed to parse JSON response", + }, + FetchJsonHttpError: { + code: "FETCH_JSON_HTTP_ERROR", + message: "HTTP error response", + }, + FetchJsonEmptyResponse: { + code: "FETCH_JSON_EMPTY_RESPONSE", + message: "Empty response body", + }, +}); + +const { + FetchError, + FetchTimeout, + FetchRetryExhausted, + TestOnlyOriginsNotAllowed, + FetchJsonParseError, + FetchJsonHttpError, + FetchJsonEmptyResponse, +} = networkErrors; + +// ============================================================================= +// Network Resilience Constants +// ============================================================================= + +/** + * Default timeout for fetch requests in milliseconds. + * 30 seconds is generous but prevents indefinite hangs. + */ +const defaultTimeoutMs = 30000; + +/** + * Default maximum retry attempts for transient failures. + */ +const defaultMaxRetries = 3; + +/** + * Default base delay for exponential backoff in milliseconds. + */ +const defaultBaseDelayMs = 1000; + +/** + * HTTP status codes that indicate transient server errors worth retrying. + */ +const retryableStatusCodes = [429, 502, 503, 504]; + +/** + * Network error codes that indicate transient failures worth retrying. + */ +const retryableErrorCodes = [ + "ECONNRESET", + "ETIMEDOUT", + "ECONNREFUSED", + "ENOTFOUND", + "FETCH_TIMEOUT", +]; + +// ============================================================================= +// URL Allowlist Configuration +// ============================================================================= + +/** + * Allowed API base URLs for Vibecodr operations. + * This prevents token theft via malicious apiBase values. + * + * SECURITY: Only send tokens to these trusted origins. + */ +const allowedApiOrigins = [ + "https://api.vibecodr.space", + "https://api.staging.vibecodr.space", + "http://localhost:8787", // Local development + "http://127.0.0.1:8787", // Local development +]; + +/** + * Allowed player base URLs for building vibe URLs. + */ +const allowedPlayerOrigins = [ + "https://vibecodr.space", + "https://staging.vibecodr.space", + "http://localhost:3000", // Local development + "http://127.0.0.1:3000", // Local development +]; + +// ============================================================================= +// URL Utilities +// ============================================================================= + +/** + * Normalize origin URL by removing trailing slashes. + * + * @param {string} origin - URL origin to normalize + * @returns {string} Normalized origin without trailing slashes + * + * @example + * normalizeOrigin("https://api.vibecodr.space///") // "https://api.vibecodr.space" + */ +export const normalizeOrigin = (origin) => { + if (!origin || typeof origin !== "string") { + return ""; + } + return origin.replace(/\/+$/, ""); +}; + +/** + * Extract origin from a URL string with security validation. + * + * SECURITY: Rejects URLs with embedded credentials (username/password) + * to prevent origin confusion attacks like: + * https://api.vibecodr.space@evil.com + * where the actual host is evil.com, not api.vibecodr.space. + * + * Also rejects backslashes which can cause parsing inconsistencies. + * + * @param {string} urlString - Full URL string + * @returns {{origin: string|null, error?: string}} Origin or error + */ +const extractOrigin = (urlString) => { + try { + // SECURITY: Reject URLs containing backslashes (parsing inconsistencies) + if (urlString.includes("\\")) { + return { origin: null, error: "URL contains backslash" }; + } + + const url = new URL(urlString); + + // SECURITY: Reject URLs with embedded credentials + // These can be used for origin confusion attacks + if (url.username || url.password) { + return { + origin: null, + error: "URL contains embedded credentials (username/password)", + }; + } + + return { origin: url.origin }; + } catch { + return { origin: null, error: "Invalid URL format" }; + } +}; + +/** + * Validate that apiBase URL is in the allowlist. + * Prevents token theft via malicious apiBase values. + * + * SECURITY: Always call this before sending tokens to any URL. + * + * @param {string} apiBase - API base URL to validate + * @param {Object} [options] - Validation options + * @param {string[]} [options._testOnlyOrigins] - Extra allowed origins (for unit tests ONLY) + * @returns {{valid: boolean, reason?: string, origin?: string}} + * + * @example + * validateApiBase("https://api.vibecodr.space") // { valid: true, origin: "https://api.vibecodr.space" } + * validateApiBase("https://evil.com") // { valid: false, reason: "..." } + */ +export const validateApiBase = (apiBase, { _testOnlyOrigins = [] } = {}) => { + // SECURITY: _testOnlyOrigins is for unit tests only + // In production builds, this should never have values + if (_testOnlyOrigins.length > 0 && process.env.NODE_ENV === "production") { + throw createError({ + ...TestOnlyOriginsNotAllowed, + message: "_testOnlyOrigins cannot be used in production", + }); + } + + if (!apiBase || typeof apiBase !== "string") { + return { valid: false, reason: "apiBase must be a non-empty string" }; + } + + const normalized = normalizeOrigin(apiBase); + const { origin, error } = extractOrigin(normalized); + + if (!origin) { + const reason = + typeof error === "string" && error.length > 0 + ? error + : `Invalid URL: ${apiBase}`; + return { valid: false, reason }; + } + + const allAllowed = [...allowedApiOrigins, ..._testOnlyOrigins]; + + if (!allAllowed.includes(origin)) { + return { + valid: false, + reason: + `API origin "${origin}" is not in the allowed list. ` + + `Allowed: ${allowedApiOrigins.join(", ")}`, + origin, + }; + } + + return { valid: true, origin }; +}; + +/** + * Validate that playerBase URL is in the allowlist. + * + * @param {string} playerBase - Player base URL to validate + * @param {Object} [options] - Validation options + * @param {string[]} [options._testOnlyOrigins] - Extra allowed origins (for unit tests ONLY) + * @returns {{valid: boolean, reason?: string, origin?: string}} + */ +export const validatePlayerBase = ( + playerBase, + { _testOnlyOrigins = [] } = {}, +) => { + // SECURITY: _testOnlyOrigins is for unit tests only + // In production builds, this should never have values + if (_testOnlyOrigins.length > 0 && process.env.NODE_ENV === "production") { + throw createError({ + ...TestOnlyOriginsNotAllowed, + message: "_testOnlyOrigins cannot be used in production", + }); + } + + if (!playerBase || typeof playerBase !== "string") { + return { valid: false, reason: "playerBase must be a non-empty string" }; + } + + const normalized = normalizeOrigin(playerBase); + const { origin, error } = extractOrigin(normalized); + + if (!origin) { + const reason = + typeof error === "string" && error.length > 0 + ? error + : `Invalid URL: ${playerBase}`; + return { valid: false, reason }; + } + + const allAllowed = [...allowedPlayerOrigins, ..._testOnlyOrigins]; + + if (!allAllowed.includes(origin)) { + return { + valid: false, + reason: + `Player origin "${origin}" is not in the allowed list. ` + + `Allowed: ${allowedPlayerOrigins.join(", ")}`, + origin, + }; + } + + return { valid: true, origin }; +}; + +// ============================================================================= +// Logging Utilities +// ============================================================================= + +/** + * Create a verbose logger for a specific module. + * Logs to stderr to avoid polluting stdout. + * + * @param {string} moduleName - Name of the module for log prefix + * @returns {Function} Logger function that respects verbose flag + * + * @example + * const log = createVerboseLogger("vibe-auth"); + * log("Checking credentials...", true); // outputs: [vibe-auth] Checking credentials... + * log("Secret stuff", false); // no output + */ +export const createVerboseLogger = (moduleName) => { + return (message, verbose) => { + if (verbose) { + process.stderr.write(`[${moduleName}] ${message}\n`); + } + }; +}; + +/** + * Simple verbose log function for backward compatibility. + * Prefer createVerboseLogger for new code. + * + * @param {string} prefix - Log prefix (module name) + * @param {string} message - Message to log + * @param {boolean} verbose - Whether to actually log + */ +export const verboseLog = (prefix, message, verbose) => { + if (verbose) { + process.stderr.write(`[${prefix}] ${message}\n`); + } +}; + +// ============================================================================= +// HTTP Utilities +// ============================================================================= + +/** + * Fetch with timeout support using AbortController. + * Prevents fetch() from hanging indefinitely when server doesn't respond. + * + * @param {string} url - URL to fetch from + * @param {RequestInit} [init] - Fetch options (signal will be merged) + * @param {number} [timeoutMs=30000] - Timeout in milliseconds + * @returns {Promise} Fetch Response object + * @throws {Error} FETCH_TIMEOUT if request exceeds timeout + * + * @example + * try { + * const response = await fetchWithTimeout("https://api.example.com/slow", {}, 5000); + * } catch (err) { + * if (err.code === 'FETCH_TIMEOUT') { + * console.log('Request timed out'); + * } + * } + */ +export const fetchWithTimeout = async ( + url, + init = {}, + timeoutMs = defaultTimeoutMs, +) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }); + return response; + } catch (err) { + // AbortError is thrown when AbortController.abort() is called + if (err.name === "AbortError") { + throw createError({ + ...FetchTimeout, + message: `Request timed out after ${timeoutMs}ms`, + url, + timeoutMs, + }); + } + const originalCode = err && typeof err.code === "string" ? err.code : null; + const retryable = + !!originalCode && + Array.isArray(retryableErrorCodes) && + retryableErrorCodes.includes(originalCode); + + throw createError({ + ...FetchError, + message: `Fetch failed for ${url}: ${err.message}`, + cause: err, + url, + originalCode, + retryable, + }); + } finally { + // Always clean up the timeout to prevent memory leaks + clearTimeout(timeoutId); + } +}; + +/** + * Collect error codes (and original error codes) from an error cause chain. + * @param {Error|null} err - Error object + * @returns {string[]} Codes found (outer-to-inner) + */ +const getErrorCodes = (err) => { + const collect = (current) => { + if (!current) { + return []; + } + + const ownCodes = [ + ...(typeof current.originalCode === "string" + ? [current.originalCode] + : []), + ...(typeof current.code === "string" ? [current.code] : []), + ]; + + return [...ownCodes, ...collect(current.cause)]; + }; + + return collect(err); +}; + +/** + * Check if an error or HTTP status is retryable. + * + * @param {Error|null} err - Error object (may be null) + * @param {Response|null} response - Fetch Response (may be null) + * @returns {boolean} Whether the request should be retried + */ +const isRetryable = (err, response) => { + // Check for retryable error codes (network-level failures) + if (err) { + const codes = getErrorCodes(err); + if (codes.some((code) => retryableErrorCodes.includes(code))) { + return true; + } + } + + // Check for retryable HTTP status codes (server-level failures) + if (response && retryableStatusCodes.includes(response.status)) { + return true; + } + + return false; +}; + +/** + * Parse Retry-After header value to milliseconds. + * Handles both delta-seconds format (e.g., "120") and HTTP-date format. + * + * @param {string|null} retryAfter - Retry-After header value + * @param {number} defaultMs - Default delay if header is missing/invalid + * @returns {number} Delay in milliseconds + */ +const parseRetryAfter = (retryAfter, defaultMs) => { + if (!retryAfter) { + return defaultMs; + } + + // Try parsing as number (delta-seconds) + const seconds = parseInt(retryAfter, 10); + if (!isNaN(seconds) && seconds >= 0) { + // Cap at 5 minutes to prevent excessively long waits + return Math.min(seconds * 1000, 300000); + } + + // Try parsing as HTTP-date (e.g., "Wed, 21 Oct 2015 07:28:00 GMT") + const date = Date.parse(retryAfter); + if (!isNaN(date)) { + const delayMs = date - Date.now(); + // If date is in the past or too far in the future, use default + if (delayMs > 0 && delayMs < 300000) { + return delayMs; + } + } + + return defaultMs; +}; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const getExponentialBackoffMs = ({ baseDelayMs, attempt }) => + baseDelayMs * 2 ** (attempt - 1); + +/** + * Fetch with retry logic for transient failures. + * Handles 502, 503, 504, 429 (rate limit), and network errors with exponential backoff. + * + * @param {string} url - URL to fetch from + * @param {RequestInit} [init] - Fetch options + * @param {Object} [options] - Retry options + * @param {number} [options.maxRetries=3] - Maximum retry attempts + * @param {number} [options.baseDelayMs=1000] - Base delay for exponential backoff + * @param {number} [options.timeoutMs=30000] - Timeout per request in milliseconds + * @param {boolean} [options.verbose=false] - Enable verbose logging + * @returns {Promise} Fetch Response object + * @throws {Error} FETCH_RETRY_EXHAUSTED if all retries fail + * + * @example + * try { + * const response = await fetchWithRetry("https://api.example.com/data", { + * method: "POST", + * body: JSON.stringify({ data: "value" }) + * }, { maxRetries: 5, verbose: true }); + * } catch (err) { + * if (err.code === 'FETCH_RETRY_EXHAUSTED') { + * console.log('All retry attempts failed'); + * } + * } + */ +export const fetchWithRetry = async (url, init = {}, options = {}) => { + const { + maxRetries = defaultMaxRetries, + baseDelayMs = defaultBaseDelayMs, + timeoutMs = defaultTimeoutMs, + verbose = false, + } = options; + + const throwRetryExhausted = ({ lastError, lastResponse }) => { + throw createError({ + ...FetchRetryExhausted, + message: `All ${maxRetries} retry attempts failed for ${url}`, + url, + attempts: maxRetries, + lastStatus: lastResponse?.status, + cause: lastError, + }); + }; + + const attemptFetch = async ({ attempt, lastError, lastResponse }) => { + try { + const response = await fetchWithTimeout(url, init, timeoutMs); + + // Handle rate limiting (429) + if (response.status === 429) { + const retryAfterHeader = response.headers.get("Retry-After"); + const delayMs = parseRetryAfter( + retryAfterHeader, + getExponentialBackoffMs({ baseDelayMs, attempt }), + ); + + if (verbose) { + verboseLog( + "vibe-utils", + `Rate limited (429). Retry-After: ${retryAfterHeader ?? "not set"}. ` + + `Waiting ${delayMs}ms before retry ${attempt}/${maxRetries}`, + true, + ); + } + + if (attempt < maxRetries) { + await sleep(delayMs); + return attemptFetch({ + attempt: attempt + 1, + lastError, + lastResponse: response, + }); + } + + return throwRetryExhausted({ lastError, lastResponse: response }); + } + + // Handle retryable server errors (502, 503, 504) + if (isRetryable(null, response)) { + if (verbose) { + verboseLog( + "vibe-utils", + `Server error (${response.status}). Retry ${attempt}/${maxRetries}`, + true, + ); + } + + if (attempt < maxRetries) { + const delayMs = getExponentialBackoffMs({ baseDelayMs, attempt }); + await sleep(delayMs); + return attemptFetch({ + attempt: attempt + 1, + lastError, + lastResponse: response, + }); + } + + return throwRetryExhausted({ lastError, lastResponse: response }); + } + + // Success or non-retryable HTTP status - return as-is + return response; + } catch (err) { + if (isRetryable(err, null)) { + if (verbose) { + const codes = getErrorCodes(err); + verboseLog( + "vibe-utils", + `Network error (${codes[0] ?? err.name}). Retry ${attempt}/${maxRetries}`, + true, + ); + } + + if (attempt < maxRetries) { + const delayMs = getExponentialBackoffMs({ baseDelayMs, attempt }); + await sleep(delayMs); + return attemptFetch({ + attempt: attempt + 1, + lastError: err, + lastResponse, + }); + } + + return throwRetryExhausted({ lastError: err, lastResponse }); + } + + // Non-retryable error - throw immediately + throw err; + } + }; + + return attemptFetch({ attempt: 1, lastError: null, lastResponse: null }); +}; + +/** + * Fetch JSON from URL with enhanced error handling, timeout, and retry support. + * Provides consistent error structure across all vibe modules. + * + * RESILIENCE FEATURES: + * - Configurable timeout to prevent indefinite hangs (default: 30s) + * - Automatic retry with exponential backoff for transient failures + * - Rate limit handling with Retry-After header support + * + * @param {string} url - URL to fetch from + * @param {RequestInit} [init] - Fetch options + * @param {Object} [options] - Network resilience options + * @param {number} [options.timeoutMs=30000] - Timeout in milliseconds + * @param {number} [options.maxRetries=3] - Maximum retry attempts (set to 1 to disable retry) + * @param {number} [options.baseDelayMs=1000] - Base delay for exponential backoff + * @param {boolean} [options.verbose=false] - Enable verbose logging for retries + * @returns {Promise} Parsed JSON response + * @throws {Error} Enhanced error with status, body, and url properties + * @throws {Error} FETCH_TIMEOUT if request times out + * @throws {Error} FETCH_RETRY_EXHAUSTED if all retries fail + * + * @example + * try { + * const data = await fetchJson("https://api.example.com/data", { + * method: "POST", + * headers: { "Content-Type": "application/json" }, + * body: JSON.stringify({ foo: "bar" }) + * }, { timeoutMs: 10000, maxRetries: 5, verbose: true }); + * } catch (err) { + * console.error(err.status, err.body); + * } + */ +export const fetchJson = async (url, init, options = {}) => { + const { + timeoutMs = defaultTimeoutMs, + maxRetries = defaultMaxRetries, + baseDelayMs = defaultBaseDelayMs, + verbose = false, + } = options; + + // Use retry wrapper which internally uses timeout wrapper + const res = await fetchWithRetry(url, init, { + timeoutMs, + maxRetries, + baseDelayMs, + verbose, + }); + + const text = await res.text(); + + const parseJsonText = () => { + if (!text) { + return null; + } + + try { + return JSON.parse(text); + } catch { + const err = createError({ + ...FetchJsonParseError, + message: `Expected JSON from ${url} (status=${res.status})`, + status: res.status, + url, + }); + // Preserve commonly-consumed error properties for callers/tests + err.status = res.status; + err.url = url; + err.responseText = text; + throw err; + } + }; + + const json = parseJsonText(); + + if (!res.ok) { + const err = createError({ + ...FetchJsonHttpError, + message: `HTTP ${res.status} from ${url}`, + status: res.status, + url, + body: json, + }); + // Preserve commonly-consumed error properties for callers/tests + err.status = res.status; + err.url = url; + err.body = json; + throw err; + } + + // Handle empty response body - throw explicit error rather than returning null + // to prevent null dereference when caller accesses properties like .success + if (json === null) { + const err = createError({ + ...FetchJsonEmptyResponse, + message: `Empty response body from ${url} (status=${res.status})`, + status: res.status, + url, + }); + // Backward-compatible code used by tests/callers + err.code = "EMPTY_RESPONSE"; + err.status = res.status; + err.url = url; + throw err; + } + + return json; +}; + +// ============================================================================= +// Auth Error Detection +// ============================================================================= + +/** + * Check if an error indicates the token needs refreshing. + * Used for auth retry logic across modules. + * + * @param {Error} err - Error to check + * @returns {boolean} Whether error suggests auth retry is needed + * + * @example + * try { + * await apiCall(); + * } catch (err) { + * if (isAuthError(err)) { + * await refreshToken(); + * await apiCall(); // retry + * } + * } + */ +export const isAuthError = (err) => { + if (!err) return false; + + // Direct 401 means auth failed + if (err.status === 401) return true; + + // Check error body for expiration hints from the API + const body = err.body ?? err.cause; + if (body && typeof body === "object") { + const hint = + typeof body.hint === "string" && body.hint.length > 0 + ? body.hint + : typeof body.message === "string" + ? body.message + : ""; + if ( + typeof hint === "string" && + hint.toLowerCase().includes("expiring soon") + ) { + return true; + } + const code = + typeof body.errorCode === "string" && body.errorCode.length > 0 + ? body.errorCode + : typeof body.code === "string" && body.code.length > 0 + ? body.code + : typeof body.error === "string" + ? body.error + : undefined; + if (typeof code === "string" && code.includes("auth.")) { + return true; + } + } + + return false; +}; + +/** + * Check if error indicates token problem (for Clerk token refresh). + * Alias for isAuthError with same semantics. + * + * @param {Error} err - Error to check + * @returns {boolean} Whether error suggests Clerk token refresh is needed + */ +export const isLikelyClerkTokenProblem = isAuthError; + +// ============================================================================= +// Path Safety Validation +// ============================================================================= + +/** + * Unicode characters that can normalize to ASCII dots. + * Used to detect Unicode-based path traversal bypass attempts. + * + * SECURITY: Attackers may use these lookalike characters to bypass + * simple ".." checks. For example: + * - \u2024 (ONE DOT LEADER) looks like "." + * - \uFF0E (FULLWIDTH FULL STOP) looks like "." + * - \u0307 (COMBINING DOT ABOVE) combined with chars + * + * We detect these by normalizing to NFKD and checking for resulting dots, + * and also explicitly checking for known lookalike codepoints. + */ +const unicodeDotLookalikes = [ + "\u2024", // ONE DOT LEADER + "\uFF0E", // FULLWIDTH FULL STOP + "\u0701", // SYRIAC SUPRALINEAR FULL STOP + "\u0702", // SYRIAC SUBLINEAR FULL STOP + "\uFE52", // SMALL FULL STOP + "\u2E3C", // STENOGRAPHIC FULL STOP +]; + +/** + * Normalize a path for security comparison. + * Applies Unicode NFKD normalization to detect bypass attempts. + * + * @param {string} path - Path to normalize + * @returns {string} Normalized path + */ +const normalizePathForSecurity = (path) => { + // Apply NFKD normalization which converts lookalike chars to their base form + // For example, \uFF0E (FULLWIDTH FULL STOP) becomes "." + return path.normalize("NFKD"); +}; + +/** + * Check if a file path is safe (no path traversal attempts). + * Rejects paths that try to escape the working directory. + * + * SECURITY: Always validate user-provided paths before file operations. + * Handles Unicode normalization attacks by checking both original + * and NFKD-normalized paths for traversal patterns. + * + * @param {string} filePath - File path to validate + * @returns {{safe: boolean, reason?: string}} Validation result + * + * @example + * isPathSafe("src/App.tsx") // { safe: true } + * isPathSafe("../../../etc/passwd") // { safe: false, reason: "..." } + * isPathSafe("src/\uFF0E\uFF0E/etc/passwd") // { safe: false } - Unicode bypass attempt + */ +export const isPathSafe = (filePath) => { + if (!filePath || typeof filePath !== "string") { + return { safe: false, reason: "Path must be a non-empty string" }; + } + + // SECURITY: Check for Unicode dot lookalikes BEFORE normalization + // These are explicit bypass attempts + const hasDotLookalikes = unicodeDotLookalikes.some((lookalike) => + filePath.includes(lookalike), + ); + if (hasDotLookalikes) { + return { + safe: false, + reason: "Path contains Unicode lookalike characters", + }; + } + + // SECURITY: Normalize Unicode before checking for traversal patterns + // This catches bypass attempts using characters that normalize to ".." + const normalizedPath = normalizePathForSecurity(filePath); + + // Reject absolute paths (check both original and normalized) + if ( + filePath.startsWith("/") || + normalizedPath.startsWith("/") || + /^[A-Za-z]:/.test(filePath) || + /^[A-Za-z]:/.test(normalizedPath) + ) { + return { safe: false, reason: "Absolute paths are not allowed" }; + } + + // Reject path traversal sequences in normalized path + const pathParts = normalizedPath.split(/[/\\]/); + const hasTraversal = pathParts.some((part) => part === ".."); + if (hasTraversal) { + return { safe: false, reason: "Path traversal (..) is not allowed" }; + } + const hasNullBytes = pathParts.some((part) => part.includes("\0")); + if (hasNullBytes) { + return { safe: false, reason: "Null bytes in path are not allowed" }; + } + + // Reject paths starting with .. (normalized) + if (normalizedPath.startsWith("..")) { + return { safe: false, reason: "Path cannot start with .." }; + } + + // Reject dangerous patterns in BOTH original and normalized paths + const dangerousPatterns = [ + /\.\./, // Any double dots + /%2e%2e/i, // URL-encoded .. + /%252e/i, // Double URL-encoded . + ]; + + const hasDangerousPatterns = dangerousPatterns.some( + (pattern) => pattern.test(filePath) || pattern.test(normalizedPath), + ); + if (hasDangerousPatterns) { + return { safe: false, reason: "Path contains forbidden pattern" }; + } + + // SECURITY: Final check - if normalized path differs significantly from original, + // and normalized version contains dots where original didn't, reject it + const originalDotCount = (filePath.match(/\./g) ?? []).length; + const normalizedDotCount = (normalizedPath.match(/\./g) ?? []).length; + if (normalizedDotCount > originalDotCount) { + return { + safe: false, + reason: "Path contains characters that normalize to dots", + }; + } + + return { safe: true }; +}; + +// ============================================================================= +// Config Utilities +// ============================================================================= + +/** + * Deep clone an object to avoid mutation. + * Simple implementation for config objects (no circular refs, no functions). + * + * @param {object} obj - Object to clone + * @returns {object} Cloned object + */ +export const deepClone = (obj) => { + if (obj === null || typeof obj !== "object") { + return obj; + } + return JSON.parse(JSON.stringify(obj)); +}; + +/** + * Merge objects immutably (shallow merge with spread pattern). + * + * @param {object} target - Base object + * @param {object} source - Object to merge in + * @returns {object} New merged object + */ +export const mergeConfig = (target, source) => ({ + ...target, + ...source, +}); + +// ============================================================================= +// Exports - Constants for Testing +// ============================================================================= + +export const allowedOrigins = { + api: allowedApiOrigins, + player: allowedPlayerOrigins, +}; diff --git a/lib/vibe-utils.test.js b/lib/vibe-utils.test.js new file mode 100644 index 0000000..6768d09 --- /dev/null +++ b/lib/vibe-utils.test.js @@ -0,0 +1,1080 @@ +/** + * vibe-utils.test.js + * + * Unit tests for vibe-utils module + * Uses Riteway format with Vitest + */ +import { assert } from "riteway/vitest"; +import { describe, test, vi, beforeEach, afterEach } from "vitest"; + +import { + normalizeOrigin, + validateApiBase, + validatePlayerBase, + createVerboseLogger, + verboseLog, + fetchJson, + fetchWithTimeout, + fetchWithRetry, + isAuthError, + isLikelyClerkTokenProblem, + isPathSafe, + deepClone, + mergeConfig, + allowedOrigins, +} from "./vibe-utils.js"; + +// ============================================================================= +// Mock fetch for HTTP tests +// ============================================================================= + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +afterEach(() => { + mockFetch.mockReset(); +}); + +// ============================================================================= +// normalizeOrigin tests +// ============================================================================= + +describe("normalizeOrigin", () => { + test("removes trailing slashes", () => { + assert({ + given: "a URL with trailing slashes", + should: "remove all trailing slashes", + actual: normalizeOrigin("https://api.vibecodr.space///"), + expected: "https://api.vibecodr.space", + }); + }); + + test("leaves clean URLs unchanged", () => { + assert({ + given: "a URL without trailing slashes", + should: "return the URL unchanged", + actual: normalizeOrigin("https://api.vibecodr.space"), + expected: "https://api.vibecodr.space", + }); + }); + + test("handles empty string", () => { + assert({ + given: "an empty string", + should: "return empty string", + actual: normalizeOrigin(""), + expected: "", + }); + }); + + test("handles null/undefined", () => { + assert({ + given: "null input", + should: "return empty string", + actual: normalizeOrigin(null), + expected: "", + }); + }); +}); + +// ============================================================================= +// validateApiBase tests +// ============================================================================= + +describe("validateApiBase", () => { + test("accepts production API URL", () => { + const result = validateApiBase("https://api.vibecodr.space"); + + assert({ + given: "production API URL", + should: "return valid true", + actual: result.valid, + expected: true, + }); + }); + + test("accepts staging API URL", () => { + const result = validateApiBase("https://api.staging.vibecodr.space"); + + assert({ + given: "staging API URL", + should: "return valid true", + actual: result.valid, + expected: true, + }); + }); + + test("accepts localhost for development", () => { + const result = validateApiBase("http://localhost:8787"); + + assert({ + given: "localhost URL", + should: "return valid true", + actual: result.valid, + expected: true, + }); + }); + + test("rejects unknown origins", () => { + const result = validateApiBase("https://evil-api.com"); + + assert({ + given: "unknown origin", + should: "return valid false", + actual: result.valid, + expected: false, + }); + + assert({ + given: "unknown origin", + should: "include reason", + actual: result.reason.includes("not in the allowed list"), + expected: true, + }); + }); + + test("rejects empty input", () => { + const result = validateApiBase(""); + + assert({ + given: "empty string", + should: "return valid false", + actual: result.valid, + expected: false, + }); + }); + + test("accepts _testOnlyOrigins for testing", () => { + const result = validateApiBase("https://test-api.example.com", { + _testOnlyOrigins: ["https://test-api.example.com"], + }); + + assert({ + given: "URL in _testOnlyOrigins", + should: "return valid true", + actual: result.valid, + expected: true, + }); + }); + + test("handles URLs with paths", () => { + const result = validateApiBase("https://api.vibecodr.space/v1/endpoint"); + + assert({ + given: "API URL with path", + should: "validate based on origin", + actual: result.valid, + expected: true, + }); + }); +}); + +// ============================================================================= +// validatePlayerBase tests +// ============================================================================= + +describe("validatePlayerBase", () => { + test("accepts production player URL", () => { + const result = validatePlayerBase("https://vibecodr.space"); + + assert({ + given: "production player URL", + should: "return valid true", + actual: result.valid, + expected: true, + }); + }); + + test("rejects unknown origins", () => { + const result = validatePlayerBase("https://fake-player.com"); + + assert({ + given: "unknown origin", + should: "return valid false", + actual: result.valid, + expected: false, + }); + }); +}); + +// ============================================================================= +// createVerboseLogger tests +// ============================================================================= + +describe("createVerboseLogger", () => { + test("creates logger that outputs when verbose is true", () => { + const output = []; + const originalWrite = process.stderr.write; + process.stderr.write = (msg) => output.push(msg); + + const log = createVerboseLogger("test-module"); + log("Test message", true); + + process.stderr.write = originalWrite; + + assert({ + given: "verbose true", + should: "output message with prefix", + actual: output[0], + expected: "[test-module] Test message\n", + }); + }); + + test("creates logger that is silent when verbose is false", () => { + const output = []; + const originalWrite = process.stderr.write; + process.stderr.write = (msg) => output.push(msg); + + const log = createVerboseLogger("test-module"); + log("Test message", false); + + process.stderr.write = originalWrite; + + assert({ + given: "verbose false", + should: "not output anything", + actual: output.length, + expected: 0, + }); + }); +}); + +describe("verboseLog", () => { + test("outputs with prefix when verbose is true", () => { + const output = []; + const originalWrite = process.stderr.write; + process.stderr.write = (msg) => output.push(msg); + + verboseLog("my-module", "Test message", true); + + process.stderr.write = originalWrite; + + assert({ + given: "verbose true", + should: "output message with prefix", + actual: output[0], + expected: "[my-module] Test message\n", + }); + }); + + test("is silent when verbose is false", () => { + const output = []; + const originalWrite = process.stderr.write; + process.stderr.write = (msg) => output.push(msg); + + verboseLog("my-module", "Test message", false); + + process.stderr.write = originalWrite; + + assert({ + given: "verbose false", + should: "not output anything", + actual: output.length, + expected: 0, + }); + }); +}); + +// ============================================================================= +// fetchWithTimeout tests +// ============================================================================= + +describe("fetchWithTimeout", () => { + test("returns response on success within timeout", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => "OK", + }); + + const response = await fetchWithTimeout( + "https://api.test.com/data", + {}, + 5000, + ); + + assert({ + given: "successful fetch within timeout", + should: "return response", + actual: response.status, + expected: 200, + }); + }); + + test("throws FETCH_TIMEOUT on abort", async () => { + // Simulate a request that gets aborted due to timeout + mockFetch.mockImplementationOnce( + () => + new Promise((_, reject) => { + // Immediately simulate abort behavior + const error = new Error("Aborted"); + error.name = "AbortError"; + reject(error); + }), + ); + + let error; + try { + await fetchWithTimeout("https://api.test.com/slow", {}, 50); + } catch (e) { + error = e; + } + + // error-causes stores code in error.cause.code + assert({ + given: "request that times out", + should: "throw FETCH_TIMEOUT error", + actual: error?.cause?.code, + expected: "FETCH_TIMEOUT", + }); + + assert({ + given: "timeout error", + should: "include URL in error", + actual: error?.cause?.url, + expected: "https://api.test.com/slow", + }); + }); + + test("passes through non-timeout errors with wrapping", async () => { + const networkError = new Error("Network failure"); + networkError.code = "ECONNREFUSED"; + mockFetch.mockRejectedValueOnce(networkError); + + let error; + try { + await fetchWithTimeout("https://api.test.com/data", {}, 5000); + } catch (e) { + error = e; + } + + // Error is wrapped with FetchError + assert({ + given: "non-timeout network error", + should: "wrap with FetchError", + actual: error?.cause?.code, + expected: "FETCH_FAILED", + }); + + assert({ + given: "wrapped network error", + should: "preserve original error code", + actual: error?.cause?.originalCode, + expected: "ECONNREFUSED", + }); + + assert({ + given: "wrapped network error", + should: "preserve original error in cause", + actual: error?.cause?.cause?.message, + expected: "Network failure", + }); + + assert({ + given: "wrapped network error", + should: "include URL in error context", + actual: error?.cause?.url, + expected: "https://api.test.com/data", + }); + }); +}); + +// ============================================================================= +// fetchWithRetry tests +// ============================================================================= + +describe("fetchWithRetry", () => { + test("returns response on first success", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => "OK", + }); + + const response = await fetchWithRetry( + "https://api.test.com/data", + {}, + { maxRetries: 3 }, + ); + + assert({ + given: "successful first attempt", + should: "return response", + actual: response.status, + expected: 200, + }); + + assert({ + given: "successful first attempt", + should: "only call fetch once", + actual: mockFetch.mock.calls.length, + expected: 1, + }); + }); + + test("retries on 502 and succeeds", async () => { + // First call returns 502, second succeeds + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 502, + headers: new Map(), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => "OK", + }); + + const response = await fetchWithRetry( + "https://api.test.com/data", + {}, + { maxRetries: 3, baseDelayMs: 1 }, // Use 1ms delay for fast tests + ); + + assert({ + given: "502 then 200", + should: "return success response", + actual: response.status, + expected: 200, + }); + + assert({ + given: "retry scenario", + should: "call fetch twice", + actual: mockFetch.mock.calls.length, + expected: 2, + }); + }); + + test("retries on 429 with Retry-After header", async () => { + // First call returns 429, second succeeds + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 429, + headers: new Map([["Retry-After", "1"]]), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => "OK", + }); + + const response = await fetchWithRetry( + "https://api.test.com/data", + {}, + { maxRetries: 3, baseDelayMs: 1 }, + ); + + assert({ + given: "rate limited then success", + should: "return success response", + actual: response.status, + expected: 200, + }); + }); + + test("retries on ECONNRESET", async () => { + const networkError = new Error("Connection reset"); + networkError.code = "ECONNRESET"; + + mockFetch.mockRejectedValueOnce(networkError).mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => "OK", + }); + + const response = await fetchWithRetry( + "https://api.test.com/data", + {}, + { maxRetries: 3, baseDelayMs: 1 }, + ); + + assert({ + given: "ECONNRESET then success", + should: "return success response", + actual: response.status, + expected: 200, + }); + }); + + test("throws FETCH_RETRY_EXHAUSTED after max retries", async () => { + // All attempts return 503 + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + headers: new Map(), + }); + + let error; + try { + await fetchWithRetry( + "https://api.test.com/data", + {}, + { maxRetries: 2, baseDelayMs: 1 }, + ); + } catch (e) { + error = e; + } + + // error-causes stores code in error.cause.code + assert({ + given: "all retries exhausted", + should: "throw FETCH_RETRY_EXHAUSTED", + actual: error?.cause?.code, + expected: "FETCH_RETRY_EXHAUSTED", + }); + + assert({ + given: "exhausted retries", + should: "include attempt count", + actual: error?.cause?.attempts, + expected: 2, + }); + }); + + test("does not retry on 400 errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Map(), + text: async () => JSON.stringify({ error: "Bad request" }), + }); + + const response = await fetchWithRetry( + "https://api.test.com/data", + {}, + { maxRetries: 3, baseDelayMs: 1 }, + ); + + assert({ + given: "400 error", + should: "return response without retry", + actual: response.status, + expected: 400, + }); + + assert({ + given: "non-retryable error", + should: "only call fetch once", + actual: mockFetch.mock.calls.length, + expected: 1, + }); + }); + + test("does not retry on non-retryable network errors", async () => { + const error = new Error("Unknown error"); + error.code = "SOME_UNKNOWN_CODE"; + mockFetch.mockRejectedValueOnce(error); + + let caught; + try { + await fetchWithRetry( + "https://api.test.com/data", + {}, + { maxRetries: 3, baseDelayMs: 1 }, + ); + } catch (e) { + caught = e; + } + + // Error is wrapped by fetchWithTimeout as FetchError + assert({ + given: "non-retryable network error", + should: "wrap with FetchError", + actual: caught?.cause?.code, + expected: "FETCH_FAILED", + }); + + assert({ + given: "wrapped non-retryable error", + should: "preserve original error code", + actual: caught?.cause?.originalCode, + expected: "SOME_UNKNOWN_CODE", + }); + + assert({ + given: "wrapped non-retryable error", + should: "include retryable flag", + actual: caught?.cause?.retryable, + expected: false, + }); + + assert({ + given: "non-retryable error", + should: "only call fetch once", + actual: mockFetch.mock.calls.length, + expected: 1, + }); + }); +}); + +// ============================================================================= +// fetchJson tests +// ============================================================================= + +describe("fetchJson", () => { + test("parses JSON response on success", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + text: async () => JSON.stringify({ success: true, data: "test" }), + }); + + const result = await fetchJson("https://api.test.com/data"); + + assert({ + given: "successful JSON response", + should: "return parsed JSON", + actual: result.success, + expected: true, + }); + }); + + test("throws on non-OK response with body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Map(), + text: async () => JSON.stringify({ error: "Bad request" }), + }); + + let error; + try { + await fetchJson("https://api.test.com/data"); + } catch (e) { + error = e; + } + + assert({ + given: "400 response", + should: "throw error with status", + actual: error?.status, + expected: 400, + }); + + assert({ + given: "400 response", + should: "include body in error", + actual: error?.body?.error, + expected: "Bad request", + }); + }); + + test("throws on invalid JSON", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + text: async () => "not json", + }); + + let error; + try { + await fetchJson("https://api.test.com/data"); + } catch (e) { + error = e; + } + + assert({ + given: "invalid JSON response", + should: "throw error", + actual: error?.message.includes("Expected JSON"), + expected: true, + }); + }); + + test("throws EMPTY_RESPONSE on empty body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + text: async () => "", + }); + + let error; + try { + await fetchJson("https://api.test.com/data"); + } catch (e) { + error = e; + } + + assert({ + given: "empty response body", + should: "throw error with EMPTY_RESPONSE code", + actual: error?.code, + expected: "EMPTY_RESPONSE", + }); + + assert({ + given: "empty response body", + should: "include status in error", + actual: error?.status, + expected: 200, + }); + + assert({ + given: "empty response body", + should: "include URL in error", + actual: error?.url, + expected: "https://api.test.com/data", + }); + }); +}); + +// ============================================================================= +// isAuthError tests +// ============================================================================= + +describe("isAuthError", () => { + test("returns true for 401 status", () => { + const err = new Error("Unauthorized"); + err.status = 401; + + assert({ + given: "error with status 401", + should: "return true", + actual: isAuthError(err), + expected: true, + }); + }); + + test("returns true for expiring soon hint", () => { + const err = new Error("Token error"); + err.body = { hint: "Token expiring soon" }; + + assert({ + given: "error with expiring soon hint", + should: "return true", + actual: isAuthError(err), + expected: true, + }); + }); + + test("returns true for auth code in body", () => { + const err = new Error("Auth error"); + err.body = { code: "auth.token_expired" }; + + assert({ + given: "error with auth code", + should: "return true", + actual: isAuthError(err), + expected: true, + }); + }); + + test("returns false for non-auth errors", () => { + const err = new Error("Server error"); + err.status = 500; + + assert({ + given: "500 error", + should: "return false", + actual: isAuthError(err), + expected: false, + }); + }); + + test("returns false for null/undefined", () => { + assert({ + given: "null input", + should: "return false", + actual: isAuthError(null), + expected: false, + }); + }); +}); + +describe("isLikelyClerkTokenProblem", () => { + test("is alias for isAuthError", () => { + assert({ + given: "the function", + should: "be same as isAuthError", + actual: isLikelyClerkTokenProblem === isAuthError, + expected: true, + }); + }); +}); + +// ============================================================================= +// isPathSafe tests +// ============================================================================= + +describe("isPathSafe", () => { + test("accepts valid relative paths", () => { + assert({ + given: "valid relative path", + should: "return safe true", + actual: isPathSafe("src/App.tsx").safe, + expected: true, + }); + }); + + test("rejects path traversal", () => { + const result = isPathSafe("../../../etc/passwd"); + + assert({ + given: "path with traversal", + should: "return safe false", + actual: result.safe, + expected: false, + }); + }); + + test("rejects absolute paths", () => { + const result = isPathSafe("/etc/passwd"); + + assert({ + given: "absolute path", + should: "return safe false", + actual: result.safe, + expected: false, + }); + }); + + test("rejects Windows absolute paths", () => { + const result = isPathSafe("C:\\Windows\\System32"); + + assert({ + given: "Windows absolute path", + should: "return safe false", + actual: result.safe, + expected: false, + }); + }); + + test("rejects URL-encoded traversal", () => { + const result = isPathSafe("src/%2e%2e/etc/passwd"); + + assert({ + given: "URL-encoded traversal", + should: "return safe false", + actual: result.safe, + expected: false, + }); + }); + + test("rejects null bytes", () => { + const result = isPathSafe("src/file\0.txt"); + + assert({ + given: "path with null byte", + should: "return safe false", + actual: result.safe, + expected: false, + }); + }); + + test("accepts dotfiles", () => { + assert({ + given: "dotfile path", + should: "return safe true", + actual: isPathSafe(".gitignore").safe, + expected: true, + }); + }); +}); + +// ============================================================================= +// deepClone tests +// ============================================================================= + +describe("deepClone", () => { + test("creates independent copy", () => { + const original = { a: 1, b: { c: 2 } }; + const clone = deepClone(original); + clone.b.c = 999; + + assert({ + given: "cloned object modified", + should: "not affect original", + actual: original.b.c, + expected: 2, + }); + }); + + test("handles null", () => { + assert({ + given: "null input", + should: "return null", + actual: deepClone(null), + expected: null, + }); + }); + + test("handles primitives", () => { + assert({ + given: "string input", + should: "return same string", + actual: deepClone("test"), + expected: "test", + }); + }); +}); + +// ============================================================================= +// mergeConfig tests +// ============================================================================= + +describe("mergeConfig", () => { + test("merges objects immutably", () => { + const target = { a: 1, b: 2 }; + const source = { b: 3, c: 4 }; + const result = mergeConfig(target, source); + + assert({ + given: "two objects", + should: "merge with source overwriting", + actual: result, + expected: { a: 1, b: 3, c: 4 }, + }); + + assert({ + given: "merge operation", + should: "not modify original target", + actual: target.b, + expected: 2, + }); + }); +}); + +// ============================================================================= +// allowedOrigins export tests +// ============================================================================= + +describe("allowedOrigins", () => { + test("exports API origins", () => { + assert({ + given: "allowedOrigins.api", + should: "include production URL", + actual: allowedOrigins.api.includes("https://api.vibecodr.space"), + expected: true, + }); + }); + + test("exports player origins", () => { + assert({ + given: "allowedOrigins.player", + should: "include production URL", + actual: allowedOrigins.player.includes("https://vibecodr.space"), + expected: true, + }); + }); +}); + +// ============================================================================= +// SECURITY: URL Origin Confusion Attack Tests (CRITICAL-2) +// ============================================================================= + +describe("validateApiBase - origin confusion attacks", () => { + test("rejects URLs with embedded credentials", () => { + const result = validateApiBase("https://api.vibecodr.space@evil.com/api"); + + assert({ + given: "URL with embedded credentials (origin confusion attack)", + should: "return valid false", + actual: result.valid, + expected: false, + }); + + assert({ + given: "URL with embedded credentials", + should: "include credentials in reason", + actual: result.reason.includes("credentials"), + expected: true, + }); + }); + + test("rejects URLs with username only", () => { + const result = validateApiBase("https://user@api.vibecodr.space"); + + assert({ + given: "URL with username only", + should: "return valid false", + actual: result.valid, + expected: false, + }); + }); + + test("rejects URLs with backslashes", () => { + const result = validateApiBase("https://api.vibecodr.space\\@evil.com"); + + assert({ + given: "URL with backslash", + should: "return valid false", + actual: result.valid, + expected: false, + }); + }); +}); + +// ============================================================================= +// SECURITY: Unicode Path Traversal Bypass Tests (CRITICAL-1) +// ============================================================================= + +describe("isPathSafe - Unicode bypass attempts", () => { + test("rejects fullwidth dot traversal (\\uFF0E)", () => { + // \uFF0E is FULLWIDTH FULL STOP which looks like "." + const result = isPathSafe("src/\uFF0E\uFF0E/etc/passwd"); + + assert({ + given: "path with Unicode fullwidth dots (bypass attempt)", + should: "return safe false", + actual: result.safe, + expected: false, + }); + }); + + test("rejects one dot leader traversal (\\u2024)", () => { + // \u2024 is ONE DOT LEADER which looks like "." + const result = isPathSafe("src/\u2024\u2024/etc/passwd"); + + assert({ + given: "path with Unicode one dot leader (bypass attempt)", + should: "return safe false", + actual: result.safe, + expected: false, + }); + }); + + test("rejects mixed Unicode and ASCII dots", () => { + // Mix of \uFF0E and ASCII "." + const result = isPathSafe("src/\uFF0E./etc/passwd"); + + assert({ + given: "path with mixed Unicode and ASCII dots", + should: "return safe false", + actual: result.safe, + expected: false, + }); + }); + + test("accepts legitimate Unicode in filenames", () => { + // Legitimate international characters should be allowed + const result = isPathSafe("src/日本語ファイル.tsx"); + + assert({ + given: "path with legitimate Japanese characters", + should: "return safe true", + actual: result.safe, + expected: true, + }); + }); + + test("accepts legitimate emoji in filenames", () => { + const result = isPathSafe("src/components/Button🎉.tsx"); + + assert({ + given: "path with emoji", + should: "return safe true", + actual: result.safe, + expected: true, + }); + }); +}); diff --git a/package.json b/package.json index 9c7447b..807065d 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,10 @@ "./server": { "types": "./src/server/index.d.ts", "default": "./src/server/index.js" + }, + "./vibe-utils": { + "types": "./lib/vibe-utils.d.ts", + "default": "./lib/vibe-utils.js" } }, "files": [ From 8d03887d2e1a76d3ec5157065b1968f6c7150b2b Mon Sep 17 00:00:00 2001 From: bradensui Date: Wed, 7 Jan 2026 22:14:07 -0600 Subject: [PATCH 2/6] feat: add vibe-auth authentication module Add authentication module for Vibecodr API access: - PKCE (Proof Key for Code Exchange) flow support - Token storage with secure file permissions - Automatic token refresh with configurable buffer - Windows permission remediation for credential files - Session state management Depends on: vibe-utils Co-Authored-By: Claude Opus 4.5 --- lib/vibe-auth.d.ts | 40 + lib/vibe-auth.js | 1019 +++++++++++++++++++ lib/vibe-auth.test.js | 2250 +++++++++++++++++++++++++++++++++++++++++ package.json | 4 + 4 files changed, 3313 insertions(+) create mode 100644 lib/vibe-auth.d.ts create mode 100644 lib/vibe-auth.js create mode 100644 lib/vibe-auth.test.js diff --git a/lib/vibe-auth.d.ts b/lib/vibe-auth.d.ts new file mode 100644 index 0000000..ad9e550 --- /dev/null +++ b/lib/vibe-auth.d.ts @@ -0,0 +1,40 @@ +export interface EnsureVibecodrAuthParams { + apiBase?: string; + configPath?: string; + verbose?: boolean; + minValidSeconds?: number; +} + +export function ensureVibecodrAuth( + params?: EnsureVibecodrAuthParams, +): Promise<{ token: string; expiresAt: number }>; + +export interface RefreshVibecodrTokenParams { + configPath?: string; + apiBase?: string; + verbose?: boolean; +} + +export function refreshVibecodrToken( + params?: RefreshVibecodrTokenParams, +): Promise<{ token: string; expiresAt: number }>; + +export function defaultConfigPath(): string; + +export function normalizeOrigin(origin: string): string; + +export function isLikelyClerkTokenProblem(err: unknown): boolean; + +export function getStoredCredentials(params?: { + configPath?: string; +}): { + hasCredentials: boolean; + expiresAt?: number; + isExpired?: boolean; + configPath: string; +}; + +export function fixWindowsPermissions( + filePath: string, + options?: { verbose?: boolean }, +): { success: boolean; warning?: string; commandRun?: string }; diff --git a/lib/vibe-auth.js b/lib/vibe-auth.js new file mode 100644 index 0000000..51894a3 --- /dev/null +++ b/lib/vibe-auth.js @@ -0,0 +1,1019 @@ +/** + * vibe-auth.js + * + * Authentication integration module for Vibecodr within aidd context. + * Handles credential validation, token refresh, and auth error handling. + * + * Reference: vibecodr-auth.js in project root for auth patterns + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execSync } from "node:child_process"; +import { errorCauses, createError } from "error-causes"; +import { + normalizeOrigin, + verboseLog, + fetchJson, + isLikelyClerkTokenProblem, +} from "./vibe-utils.js"; + +// ============================================================================= +// Error Definitions +// ============================================================================= + +const [vibeAuthErrors] = errorCauses({ + AuthRequired: { + code: "AUTH_REQUIRED", + message: "Authentication required", + }, + AuthExpired: { + code: "AUTH_EXPIRED", + message: "Token expired and refresh failed", + }, + ConfigReadError: { + code: "CONFIG_READ_ERROR", + message: "Failed to read configuration file", + }, + ConfigWriteError: { + code: "CONFIG_WRITE_ERROR", + message: "Failed to write configuration file", + }, + TokenExchangeError: { + code: "TOKEN_EXCHANGE_ERROR", + message: "Failed to exchange token with Vibecodr", + }, + RefreshError: { + code: "REFRESH_ERROR", + message: "Failed to refresh authentication token", + }, +}); + +const { + AuthRequired, + AuthExpired, + ConfigReadError, + ConfigWriteError, + TokenExchangeError, + RefreshError, +} = vibeAuthErrors; + +// ============================================================================= +// Security Constants & Helpers +// ============================================================================= + +/** + * SECURITY: Get expected config directory path by platform. + * Config files outside this directory trigger a warning. + * @returns {string} Expected config directory path + */ +const getExpectedConfigDir = () => { + if (process.platform === "win32") { + return path.join(process.env.APPDATA ?? "", "vibecodr"); + } + return path.join(os.homedir(), ".config", "vibecodr"); +}; + +/** + * SECURITY: Validate that config path is within expected directory. + * Prevents arbitrary file access via configPath parameter. + * + * @param {string} configPath - Config path to validate + * @returns {{valid: boolean, warning?: string, reason?: string}} + */ +const validateConfigPath = (configPath) => { + const resolved = path.resolve(configPath); + const expectedDir = path.resolve(getExpectedConfigDir()); + + // Allow custom paths but warn if outside expected directory + if ( + !resolved.startsWith(expectedDir + path.sep) && + resolved !== expectedDir + ) { + // Don't block, but this is unusual - could indicate misconfiguration or attack + return { + valid: true, + warning: `Config path outside standard location`, + }; + } + + // Must end in .json for config files + if (!resolved.endsWith(".json")) { + return { valid: false, reason: "Config path must end in .json" }; + } + + return { valid: true }; +}; + +/** + * SECURITY: Verify file permissions on both Unix and Windows systems. + * - Unix: Config files should only be readable by owner (mode 0600). + * - Windows: Config files should NOT be readable by Everyone/Users groups. + * + * @param {string} filePath - File path to check + * @throws {Error} CONFIG_READ_ERROR if permissions are insecure + */ +const verifyFilePermissions = (filePath) => { + try { + // SECURITY: Windows - check for world-readable ACLs + if (process.platform === "win32") { + const permCheck = checkWindowsPermissions(filePath); + + // If check returned a warning (couldn't verify), log it but don't block + if (permCheck.warning) { + // Don't block on verification failure - assume secure + return; + } + + // If permissions are insecure, throw error with fix instructions + if (!permCheck.secure) { + throw createError({ + ...ConfigReadError, + message: + `Config file has insecure Windows permissions.\n` + + `${permCheck.details}\n\n` + + `To fix, run one of:\n` + + ` 1. Automated: Call fixWindowsPermissions() from code\n` + + ` 2. Manual: icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:(F)"`, + configPath: filePath, + permissionDetails: permCheck.details, + }); + } + + return; + } + + // SECURITY: Unix - check file mode bits + const stats = fs.statSync(filePath); + const mode = stats.mode & 0o777; + + // Check that group and others have no access (mode should be 0600 or stricter) + if ((mode & 0o077) !== 0) { + throw createError({ + ...ConfigReadError, + message: `Config file has insecure permissions (${mode.toString(8)}). Expected 600. Run: chmod 600 "${filePath}"`, + configPath: filePath, + actualMode: mode.toString(8), + expectedMode: "600", + }); + } + } catch (err) { + // If statSync fails with ENOENT, that's fine - file doesn't exist yet + if (err?.code === "ENOENT") { + return; + } + // Re-throw CONFIG_READ_ERROR from permission check + if (err?.cause?.code === "CONFIG_READ_ERROR") { + throw err; + } + // Other errors (permission denied to stat, etc.) - throw wrapped error + throw createError({ + ...ConfigReadError, + message: `Failed to verify config permissions: ${err.message}`, + configPath: filePath, + cause: err, + }); + } +}; + +// ============================================================================= +// Windows File Permission Helpers +// ============================================================================= + +/** + * SECURITY: Check if a Windows file has insecure permissions. + * Detects if "Everyone" or "BUILTIN\Users" have read access to the file. + * + * Uses icacls to query file ACLs and parses output for world-readable entries. + * This is the Windows equivalent of checking for group/world bits on Unix. + * + * @param {string} filePath - Path to the file to check + * @returns {{secure: boolean, details?: string, warning?: string}} Check result + */ +const checkWindowsPermissions = (filePath) => { + try { + // Query file ACLs using icacls + // Output format: filename DOMAIN\User:(permissions) + const output = execSync(`icacls "${filePath}"`, { + encoding: "utf8", + windowsHide: true, + }); + + // SECURITY: Check for world-readable entries + // "Everyone" = all users on the system + // "BUILTIN\Users" = all interactive users (also insecure for credentials) + // "NT AUTHORITY\Authenticated Users" = all authenticated users (also insecure) + const insecurePatterns = [ + /Everyone:/i, + /BUILTIN\\Users:/i, + /NT AUTHORITY\\Authenticated Users:/i, + ]; + + const insecureEntries = output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .filter((line) => insecurePatterns.some((pattern) => pattern.test(line))); + + if (insecureEntries.length > 0) { + return { + secure: false, + details: `File has world-readable permissions:\n${insecureEntries.join("\n")}`, + }; + } + + return { secure: true }; + } catch (err) { + // If icacls fails, we can't verify security - return warning but don't block + return { + secure: true, // Assume secure to avoid blocking legitimate usage + warning: `Could not verify Windows permissions: ${err.message}`, + }; + } +}; + +/** + * SECURITY: Fix insecure Windows file permissions by removing world-readable access. + * This is an OPT-IN remediation function that should only be called with user consent. + * + * Removes inherited permissions and grants only the current user full control. + * Logs the icacls command being executed for transparency. + * + * @param {string} filePath - Path to the file to fix + * @param {object} [options] - Fix options + * @param {boolean} [options.verbose=false] - Log icacls commands + * @returns {{success: boolean, warning?: string, commandRun?: string}} Fix result + */ +const fixWindowsPermissions = (filePath, { verbose = false } = {}) => { + const command = `icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:(F)"`; + + if (verbose) { + console.log(`[vibe-auth] Running: ${command}`); + } + + try { + execSync(command, { + stdio: verbose ? "inherit" : "ignore", + windowsHide: true, + }); + + if (verbose) { + console.log(`[vibe-auth] Successfully secured file: ${filePath}`); + } + + return { success: true, commandRun: command }; + } catch (err) { + // Don't crash on failure - return error details for user + const warning = + `Failed to fix permissions on "${filePath}": ${err.message}\n` + + `For security, manually run: ${command}`; + + return { + success: false, + warning, + commandRun: command, + }; + } +}; + +/** + * SECURITY: Set restrictive file permissions on Windows using icacls. + * Removes inherited ACLs and grants only the current user full control. + * + * This is the Windows equivalent of chmod 600 on Unix: + * - /inheritance:r - Remove all inherited permissions + * - /grant:r %USERNAME%:(F) - Grant ONLY current user full control (replace mode) + * + * @param {string} filePath - Path to the file to secure + * @returns {{success: boolean, warning?: string}} Result of permission setting + */ +const setWindowsFilePermissions = (filePath) => { + try { + // icacls has been built into Windows since Vista (2006) + // /inheritance:r removes inherited permissions + // /grant:r replaces (not adds) permissions for the user + // %USERNAME%:(F) gives full control to current user only + execSync(`icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:(F)"`, { + stdio: "ignore", + windowsHide: true, + }); + return { success: true }; + } catch (err) { + // Don't crash on failure - warn the user instead + // Common reasons: file locked, permission denied, icacls not found (shouldn't happen) + return { + success: false, + warning: + `Could not set secure permissions on "${filePath}". ` + + `For security, manually run: icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:(F)"`, + }; + } +}; + +/** + * SECURITY: Set restrictive directory permissions on Windows using icacls. + * Removes inherited ACLs and grants only the current user full control, + * with inheritance flags for child objects. + * + * @param {string} dirPath - Path to the directory to secure + * @returns {{success: boolean, warning?: string}} Result of permission setting + */ +const setWindowsDirectoryPermissions = (dirPath) => { + try { + // (OI) = Object Inherit - files in this folder inherit these permissions + // (CI) = Container Inherit - subfolders inherit these permissions + // (F) = Full control + execSync( + `icacls "${dirPath}" /inheritance:r /grant:r "%USERNAME%:(OI)(CI)(F)"`, + { stdio: "ignore", windowsHide: true }, + ); + return { success: true }; + } catch (err) { + // Don't crash on failure - this is a secondary security measure + return { success: false }; + } +}; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Parse an expiry value that may be number or string. + * Handles type coercion for expires_at values from JSON config. + * + * @param {number|string|null|undefined} value - Expiry value to parse + * @returns {number|null} Parsed numeric expiry or null if invalid + */ +const parseExpiry = (value) => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +/** + * Minimum buffer time in seconds for token validity checks. + * Even if user passes minValidSeconds=0, we enforce at least 10 seconds + * to prevent race conditions with nearly-expired tokens. + */ +const minBufferSeconds = 10; + +/** + * Get the default config path based on platform. + * On Windows, tries APPDATA first, then falls back to USERPROFILE\AppData\Roaming, + * and finally to homedir-based path if neither exists. + * + * @returns {string} Default configuration file path + */ +const defaultConfigPath = () => { + if (process.platform === "win32") { + // Primary: APPDATA environment variable + const appData = process.env.APPDATA; + if (appData) return path.join(appData, "vibecodr", "cli.json"); + + // Fallback: Construct path from USERPROFILE + const userProfile = process.env.USERPROFILE; + if (userProfile) { + return path.join( + userProfile, + "AppData", + "Roaming", + "vibecodr", + "cli.json", + ); + } + // Final fallback: use homedir (may still work on Windows) + } + return path.join(os.homedir(), ".config", "vibecodr", "cli.json"); +}; + +/** + * Module-specific verbose logger using shared verboseLog + * @param {string} message - Message to log + * @param {boolean} verbose - Whether to actually log + */ +const log = (message, verbose) => verboseLog("vibe-auth", message, verbose); + +/** + * Read and parse config file with security validation. + * + * SECURITY: On Unix systems, verifies file permissions are 0600 before reading + * to prevent reading config files that could have been tampered with. + * + * @param {string} filePath - Path to config file + * @param {object} [options] - Read options + * @param {boolean} [options.skipPermissionCheck=false] - Skip permission verification (testing only) + * @returns {object|null} Parsed config or null if not found + * @throws {Error} CONFIG_READ_ERROR if permissions are insecure or read fails + */ +const readConfig = (filePath, { skipPermissionCheck = false } = {}) => { + try { + // SECURITY: Verify file permissions before reading (Unix only) + if (!skipPermissionCheck) { + verifyFilePermissions(filePath); + } + + const text = fs.readFileSync(filePath, "utf8"); + return JSON.parse(text); + } catch (err) { + if (err && typeof err === "object" && err.code === "ENOENT") { + return null; + } + // Re-throw structured errors from verifyFilePermissions + if (err?.cause?.code === "CONFIG_READ_ERROR") { + throw err; + } + // SECURITY: Use generic message, put path in structured field only + throw createError({ + ...ConfigReadError, + message: `Failed to read config file: ${err.message}`, + cause: err, + configPath: filePath, + }); + } +}; + +/** + * Ensure directory exists for file and secure it on Windows. + * + * SECURITY: On Windows, attempts to secure the directory with restrictive + * ACLs so that only the current user can access credential files. + * + * @param {string} filePath - File path to ensure directory for + * @returns {{dirCreated: boolean, dirPath: string}} Info about the directory + */ +const ensureDirForFile = (filePath) => { + const dir = path.dirname(filePath); + const existed = fs.existsSync(dir); + fs.mkdirSync(dir, { recursive: true }); + + // On Windows, secure newly created directories + // Only attempt if directory was just created to avoid repeated icacls calls + if (process.platform === "win32" && !existed) { + setWindowsDirectoryPermissions(dir); + } + + return { dirCreated: !existed, dirPath: dir }; +}; + +/** + * Write config to file with proper permissions using atomic write pattern. + * + * ATOMIC WRITE: Writes to a temp file first, then renames. This prevents + * config corruption if the process is killed mid-write, since rename is + * atomic on most filesystems. + * + * SECURITY: + * - On Unix: Sets file mode to 0o600 (owner read/write only) + * - On Windows: Uses icacls to remove inherited permissions and grant + * only the current user full control. If icacls fails, a warning is + * emitted to stderr but the write operation continues. + * + * @param {string} filePath - Path to write config to + * @param {object} data - Config data to write + * @throws {Error} CONFIG_WRITE_ERROR if write fails + */ +const writeConfig = (filePath, data) => { + ensureDirForFile(filePath); + const text = JSON.stringify(data, null, 2) + "\n"; + + // Use temp file + rename for atomic write + const tempPath = `${filePath}.tmp.${process.pid}`; + + try { + if (process.platform === "win32") { + // Windows: write file, then secure with icacls + fs.writeFileSync(tempPath, text, "utf8"); + fs.renameSync(tempPath, filePath); + + // SECURITY: Attempt to secure the file with restrictive ACLs + // This removes inherited permissions and grants only current user access + const permResult = setWindowsFilePermissions(filePath); + if (!permResult.success && permResult.warning) { + // Warn user but don't fail - file is written, just not secured + process.stderr.write(`Warning: ${permResult.warning}\n`); + } + return; + } + + // Unix: restrict to owner-only read/write (mode 0o600) + fs.writeFileSync(tempPath, text, { encoding: "utf8", mode: 0o600 }); + + // Atomic rename - if this fails, config file is unchanged + fs.renameSync(tempPath, filePath); + } catch (err) { + // Clean up temp file on any failure + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors - temp file may not exist + } + throw createError({ + ...ConfigWriteError, + message: `Failed to write config atomically: ${err.message}`, + cause: err, + configPath: filePath, + }); + } +}; + +// ============================================================================= +// Concurrent Refresh Protection +// ============================================================================= + +/** + * Module-level promise for in-progress refresh operations. + * Prevents race conditions when multiple parallel operations detect expired token. + * @type {Promise<{token: string, expiresAt: number}>|null} + */ +const refreshInProgress = { current: null }; + +/** + * Execute a refresh operation with mutex protection. + * If a refresh is already in progress, returns the existing promise. + * This prevents multiple concurrent refresh attempts from racing. + * + * @param {Function} refreshFn - Async function that performs the refresh + * @returns {Promise<{token: string, expiresAt: number}>} Refresh result + */ +const refreshWithLock = async (refreshFn) => { + // If refresh already in progress, wait for it instead of starting another + if (refreshInProgress.current) { + return refreshInProgress.current; + } + + // Start new refresh with cleanup on completion + refreshInProgress.current = refreshFn().finally(() => { + refreshInProgress.current = null; + }); + + return refreshInProgress.current; +}; + +const getResolvedConfigPath = (configPath) => configPath ?? defaultConfigPath(); + +const requireConfigForRefresh = ({ config, resolvedConfigPath }) => { + if (config) { + return config; + } + + throw createError({ + ...AuthExpired, + message: + "No config found. Cannot refresh token. Run 'aidd --vibe-login' first.", + configPath: resolvedConfigPath, + }); +}; + +const requireClerkAccessToken = ({ clerkAccessToken, resolvedConfigPath }) => { + if (clerkAccessToken) { + return clerkAccessToken; + } + + throw createError({ + ...AuthExpired, + message: + "Missing access token. Run 'aidd --vibe-login' or 'vibecodr-auth.js login' first.", + configPath: resolvedConfigPath, + }); +}; + +const exchangeAndStoreVibecodrToken = async ({ + config, + apiBase, + clerkAccessToken, + resolvedConfigPath, + verbose, +}) => { + log("Exchanging Auth token for Vibecodr token...", verbose); + + const vibecodr = await exchangeForVibecodrToken({ + apiBase, + clerkAccessToken, + }); + + if ( + !vibecodr || + typeof vibecodr.access_token !== "string" || + typeof vibecodr.expires_at !== "number" + ) { + throw createError({ + ...TokenExchangeError, + message: "Unexpected /auth/cli/exchange response shape", + hasAccessToken: typeof vibecodr?.access_token === "string", + hasExpiresAt: typeof vibecodr?.expires_at === "number", + }); + } + + const updatedConfig = { + ...config, + vibecodr, + api_base: apiBase, + updated_at: new Date().toISOString(), + }; + writeConfig(resolvedConfigPath, updatedConfig); + + log("Token refresh successful", verbose); + + return { token: vibecodr.access_token, expiresAt: vibecodr.expires_at }; +}; + +const shouldAttemptClerkRefresh = ({ clerkExpiresAt, now, exchangeErr }) => { + const clerkExpiringSoon = + typeof clerkExpiresAt === "number" ? clerkExpiresAt - now < 120 : false; + const errorSuggestsRefresh = isLikelyClerkTokenProblem(exchangeErr); + return clerkExpiringSoon || errorSuggestsRefresh; +}; + +const requireClerkRefreshConfig = ({ + clerkIssuer, + clerkClientId, + resolvedConfigPath, +}) => { + const issuerOk = typeof clerkIssuer === "string" && clerkIssuer.length > 0; + const clientIdOk = + typeof clerkClientId === "string" && clerkClientId.length > 0; + + if (issuerOk && clientIdOk) { + return; + } + + throw createError({ + ...AuthExpired, + message: + "Authentication refresh not configured. Run 'aidd --vibe-login' or 'vibecodr-auth.js login' again.", + configPath: resolvedConfigPath, + }); +}; + +const refreshClerkAndStore = async ({ + config, + now, + clerkIssuer, + clerkClientId, + clerkRefreshToken, + clerkExpiresAt, + resolvedConfigPath, + verbose, +}) => { + log("Refreshing Vibecodr OAuth token...", verbose); + + const refreshed = await refreshClerkAccessToken({ + issuer: clerkIssuer, + clientId: clerkClientId, + refreshToken: clerkRefreshToken, + }); + + const newClerkAccessToken = refreshed?.access_token; + if (!newClerkAccessToken) { + throw createError({ + ...RefreshError, + message: "Token endpoint did not return access_token", + }); + } + + const newClerkExpiresAt = + typeof refreshed.expires_in === "number" + ? now + refreshed.expires_in + : clerkExpiresAt; + + const updatedClerkConfig = { + ...config, + clerk: { + ...(config.clerk ?? {}), + access_token: newClerkAccessToken, + refresh_token: refreshed.refresh_token ?? clerkRefreshToken, + expires_at: newClerkExpiresAt, + }, + updated_at: new Date().toISOString(), + }; + writeConfig(resolvedConfigPath, updatedClerkConfig); + + return { updatedClerkConfig, newClerkAccessToken }; +}; + +/** + * Discover OIDC configuration from issuer + * @param {string} issuer - OAuth issuer URL + * @returns {Promise} OIDC configuration + */ +const discoverOidc = async (issuer) => { + const url = `${normalizeOrigin(issuer)}/.well-known/openid-configuration`; + return fetchJson(url, { headers: { Accept: "application/json" } }); +}; + +/** + * Exchange Clerk access token for Vibecodr token + * @param {object} params - Exchange parameters + * @param {string} params.apiBase - Vibecodr API base URL + * @param {string} params.clerkAccessToken - Clerk access token + * @returns {Promise} Vibecodr token response + */ +const exchangeForVibecodrToken = async ({ apiBase, clerkAccessToken }) => { + const url = `${normalizeOrigin(apiBase)}/auth/cli/exchange`; + return fetchJson(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ access_token: clerkAccessToken }), + }); +}; + +/** + * Refresh Clerk access token using refresh token + * @param {object} params - Refresh parameters + * @param {string} params.issuer - OAuth issuer URL + * @param {string} params.clientId - OAuth client ID + * @param {string} params.refreshToken - Refresh token + * @returns {Promise} New token response + */ +const refreshClerkAccessToken = async ({ issuer, clientId, refreshToken }) => { + const oidc = await discoverOidc(issuer); + const tokenEndpoint = oidc && oidc.token_endpoint; + if (!tokenEndpoint) { + throw createError({ + ...RefreshError, + message: "Issuer is missing token_endpoint in openid-configuration", + issuer, + }); + } + + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: refreshToken, + }); + + return fetchJson(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body, + }); +}; + +// ============================================================================= +// Main Exported Functions +// ============================================================================= + +/** + * Ensure valid Vibecodr authentication is available. + * Checks for stored credentials and validates token expiry. + * + * @param {object} params - Authentication parameters + * @param {string} [params.apiBase='https://api.vibecodr.space'] - Vibecodr API base URL + * @param {string} [params.configPath] - Path to config file (defaults to platform default) + * @param {boolean} [params.verbose=false] - Enable verbose logging + * @param {number} [params.minValidSeconds=120] - Minimum seconds token must be valid + * @returns {Promise<{token: string, expiresAt: number}>} Valid token and expiry + * @throws {Error} AUTH_REQUIRED if no credentials found + * @throws {Error} AUTH_EXPIRED if token expired and cannot refresh + */ +export const ensureVibecodrAuth = async ({ + apiBase = "https://api.vibecodr.space", + configPath, + verbose = false, + minValidSeconds = 120, +} = {}) => { + const resolvedConfigPath = getResolvedConfigPath(configPath); + + // SECURITY: Validate config path before use + const pathValidation = validateConfigPath(resolvedConfigPath); + if (!pathValidation.valid) { + throw createError({ + ...ConfigReadError, + message: `Invalid config path: ${pathValidation.reason}`, + configPath: resolvedConfigPath, + }); + } + if (pathValidation.warning) { + log(`WARNING: ${pathValidation.warning}`, verbose); + } + + // Enforce minimum buffer time to prevent race conditions with nearly-expired tokens + const effectiveMinValid = Math.max(minValidSeconds, minBufferSeconds); + + log(`Reading config...`, verbose); + + const config = readConfig(resolvedConfigPath); + + if (!config) { + // SECURITY: Generic message - path is in error.configPath for debugging + throw createError({ + ...AuthRequired, + message: + "No CLI config found. Run 'aidd --vibe-login' or 'vibecodr-auth.js login' first.", + configPath: resolvedConfigPath, + }); + } + + const vibecodrToken = config?.vibecodr?.access_token; + // Handle string expiry values (e.g., "1234567890" from JSON) + const vibecodrExpiresAt = parseExpiry(config?.vibecodr?.expires_at); + + if (!vibecodrToken) { + // SECURITY: Generic message - path is in error.configPath for debugging + throw createError({ + ...AuthRequired, + message: + "Missing vibecodr access token. Run 'aidd --vibe-login' or 'vibecodr-auth.js login' first.", + configPath: resolvedConfigPath, + }); + } + + const now = Math.floor(Date.now() / 1000); + + // Check if token is valid for minimum required time + // Use > instead of >= to ensure we have at least effectiveMinValid remaining + if ( + typeof vibecodrExpiresAt === "number" && + vibecodrExpiresAt - now > effectiveMinValid + ) { + // SECURITY: Log relative time instead of exact expiry to prevent timing attacks + const minutesRemaining = Math.floor((vibecodrExpiresAt - now) / 60); + log(`Token valid for ~${minutesRemaining} minutes`, verbose); + return { token: vibecodrToken, expiresAt: vibecodrExpiresAt }; + } + + log("Token expired or expiring soon, attempting refresh...", verbose); + + // RACE CONDITION PROTECTION: Use mutex to prevent concurrent refresh attempts + // If multiple parallel operations detect expired token, only one will refresh + const refreshedAuth = await refreshWithLock(() => + refreshVibecodrToken({ + configPath: resolvedConfigPath, + apiBase, + verbose, + }), + ); + + return refreshedAuth; +}; + +/** + * Attempt to refresh Vibecodr token using stored refresh token. + * + * @param {object} params - Refresh parameters + * @param {string} [params.configPath] - Path to config file + * @param {string} [params.apiBase='https://api.vibecodr.space'] - Vibecodr API base URL + * @param {boolean} [params.verbose=false] - Enable verbose logging + * @returns {Promise<{token: string, expiresAt: number}>} New token and expiry + * @throws {Error} AUTH_EXPIRED if refresh fails + */ +export const refreshVibecodrToken = async ({ + configPath, + apiBase = "https://api.vibecodr.space", + verbose = false, +} = {}) => { + const resolvedConfigPath = getResolvedConfigPath(configPath); + + log(`Attempting token refresh...`, verbose); + + const config = requireConfigForRefresh({ + config: readConfig(resolvedConfigPath), + resolvedConfigPath, + }); + + const clerkAccessToken = requireClerkAccessToken({ + clerkAccessToken: config?.clerk?.access_token, + resolvedConfigPath, + }); + + const clerkRefreshToken = config?.clerk?.refresh_token; + const clerkExpiresAt = parseExpiry(config?.clerk?.expires_at); + const clerkIssuer = config?.issuer; + const clerkClientId = config?.client_id; + + try { + return await exchangeAndStoreVibecodrToken({ + config, + apiBase, + clerkAccessToken, + resolvedConfigPath, + verbose, + }); + } catch (exchangeErr) { + const now = Math.floor(Date.now() / 1000); + + if (!clerkRefreshToken) { + throw createError({ + ...AuthExpired, + message: + "Token exchange failed and no refresh_token available. Run 'vibecodr-auth.js login' again.", + configPath: resolvedConfigPath, + cause: exchangeErr, + }); + } + + if ( + !shouldAttemptClerkRefresh({ + clerkExpiresAt, + now, + exchangeErr, + }) + ) { + throw createError({ + ...AuthExpired, + message: `Token exchange failed: ${exchangeErr.message}`, + configPath: resolvedConfigPath, + cause: exchangeErr, + }); + } + + requireClerkRefreshConfig({ + clerkIssuer, + clerkClientId, + resolvedConfigPath, + }); + + try { + const { updatedClerkConfig, newClerkAccessToken } = + await refreshClerkAndStore({ + config, + now, + clerkIssuer, + clerkClientId, + clerkRefreshToken, + clerkExpiresAt, + resolvedConfigPath, + verbose, + }); + + return await exchangeAndStoreVibecodrToken({ + config: updatedClerkConfig, + apiBase, + clerkAccessToken: newClerkAccessToken, + resolvedConfigPath, + verbose, + }); + } catch (refreshErr) { + throw createError({ + ...RefreshError, + message: `Failed to refresh authentication: ${refreshErr.message}`, + configPath: resolvedConfigPath, + cause: refreshErr, + }); + } + } +}; + +/** + * Get stored credentials status without exposing the actual token. + * Useful for checking if credentials exist and their expiry status. + * + * SECURITY: This function intentionally does NOT return the raw token + * to prevent accidental logging or leakage. Use ensureVibecodrAuth() + * when you need the actual token for API calls. + * + * @param {object} params - Parameters + * @param {string} [params.configPath] - Path to config file + * @returns {{hasCredentials: boolean, expiresAt?: number, isExpired?: boolean, configPath: string}} + */ +export const getStoredCredentials = ({ configPath } = {}) => { + const resolvedConfigPath = getResolvedConfigPath(configPath); + const config = readConfig(resolvedConfigPath); + + if (!config || !config.vibecodr?.access_token) { + return { hasCredentials: false, configPath: resolvedConfigPath }; + } + + const expiresAt = config.vibecodr.expires_at; + const now = Math.floor(Date.now() / 1000); + const isExpired = + typeof expiresAt === "number" ? expiresAt <= now : undefined; + + // NOTE: Intentionally NOT returning the token to prevent leakage + return { + hasCredentials: true, + expiresAt, + isExpired, + configPath: resolvedConfigPath, + }; +}; + +// Export helpers for testing and backward compatibility +// NOTE: normalizeOrigin and isLikelyClerkTokenProblem are now in vibe-utils.js +// Re-exported here for backward compatibility with existing imports +export { defaultConfigPath }; +export { normalizeOrigin, isLikelyClerkTokenProblem } from "./vibe-utils.js"; + +// SECURITY: Export Windows permission fix function for opt-in remediation +// This is a public API for users to fix insecure permissions when detected +export { fixWindowsPermissions }; + +// SECURITY: Export Windows permission helpers for testing only +// These should not be used directly by consumers +export const _testOnly = { + checkWindowsPermissions, + setWindowsFilePermissions, + setWindowsDirectoryPermissions, +}; diff --git a/lib/vibe-auth.test.js b/lib/vibe-auth.test.js new file mode 100644 index 0000000..b3bfffb --- /dev/null +++ b/lib/vibe-auth.test.js @@ -0,0 +1,2250 @@ +/** + * vibe-auth.test.js + * + * Unit tests for vibe-auth module + * Uses Riteway format with Vitest + */ +import { assert } from "riteway/vitest"; +import { describe, test, beforeEach, afterEach, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +// Mock child_process before importing the module +// This must be done at the top level for ESM +let mockExecSync = vi.fn(); +vi.mock("node:child_process", () => ({ + execSync: (...args) => mockExecSync(...args), +})); + +import { + ensureVibecodrAuth, + refreshVibecodrToken, + getStoredCredentials, + defaultConfigPath, + normalizeOrigin, + isLikelyClerkTokenProblem, + fixWindowsPermissions, + _testOnly, +} from "./vibe-auth.js"; + +// ============================================================================= +// Helper function tests +// ============================================================================= + +describe("normalizeOrigin", () => { + test("removes trailing slashes", () => { + assert({ + given: "a URL with trailing slashes", + should: "remove all trailing slashes", + actual: normalizeOrigin("https://api.vibecodr.space///"), + expected: "https://api.vibecodr.space", + }); + }); + + test("leaves clean URLs unchanged", () => { + assert({ + given: "a URL without trailing slashes", + should: "return the URL unchanged", + actual: normalizeOrigin("https://api.vibecodr.space"), + expected: "https://api.vibecodr.space", + }); + }); +}); + +describe("defaultConfigPath", () => { + test("returns platform-appropriate path", () => { + const configPath = defaultConfigPath(); + + assert({ + given: "no parameters", + should: "return a path ending with vibecodr/cli.json", + actual: configPath.endsWith(path.join("vibecodr", "cli.json")), + expected: true, + }); + }); + + test("uses APPDATA on Windows", () => { + const originalPlatform = process.platform; + const originalAppData = process.env.APPDATA; + + // Only run this test on Windows + if (originalPlatform === "win32" && originalAppData) { + const configPath = defaultConfigPath(); + assert({ + given: "Windows platform with APPDATA set", + should: "return path under APPDATA", + actual: configPath.startsWith(originalAppData), + expected: true, + }); + } else { + // Skip test on non-Windows + assert({ + given: "non-Windows platform", + should: "skip APPDATA test", + actual: true, + expected: true, + }); + } + }); +}); + +describe("isLikelyClerkTokenProblem", () => { + test("returns true for 401 status", () => { + const err = new Error("Unauthorized"); + err.status = 401; + + assert({ + given: "an error with status 401", + should: "return true", + actual: isLikelyClerkTokenProblem(err), + expected: true, + }); + }); + + test("returns true for expiring soon hint", () => { + const err = new Error("Token issue"); + err.body = { hint: "Token is expiring soon" }; + + assert({ + given: "an error with expiring soon hint in body", + should: "return true", + actual: isLikelyClerkTokenProblem(err), + expected: true, + }); + }); + + test("returns true for auth code in body", () => { + const err = new Error("Auth error"); + err.body = { code: "auth.token_expired" }; + + assert({ + given: "an error with auth.* code in body", + should: "return true", + actual: isLikelyClerkTokenProblem(err), + expected: true, + }); + }); + + test("returns false for non-auth errors", () => { + const err = new Error("Network error"); + err.status = 500; + + assert({ + given: "an error with non-auth status", + should: "return false", + actual: isLikelyClerkTokenProblem(err), + expected: false, + }); + }); + + test("returns false for null/undefined", () => { + assert({ + given: "null error", + should: "return false", + actual: isLikelyClerkTokenProblem(null), + expected: false, + }); + + assert({ + given: "undefined error", + should: "return false", + actual: isLikelyClerkTokenProblem(undefined), + expected: false, + }); + }); +}); + +// ============================================================================= +// getStoredCredentials tests +// ============================================================================= + +describe("getStoredCredentials", () => { + let tempDir; + let tempConfigPath; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vibe-auth-test-")); + tempConfigPath = path.join(tempDir, "vibecodr", "cli.json"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("returns hasCredentials false when no config exists", () => { + const result = getStoredCredentials({ configPath: tempConfigPath }); + + assert({ + given: "a non-existent config path", + should: "return hasCredentials false", + actual: result.hasCredentials, + expected: false, + }); + }); + + test("returns credentials when config exists", () => { + // Create config directory and file + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const expiresAt = Math.floor(Date.now() / 1000) + 3600; + const testConfig = { + vibecodr: { + access_token: "test-token-123", + expires_at: expiresAt, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + const result = getStoredCredentials({ configPath: tempConfigPath }); + + assert({ + given: "a config file with credentials", + should: "return hasCredentials true", + actual: result.hasCredentials, + expected: true, + }); + + // SECURITY: getStoredCredentials no longer returns token to prevent leakage + // It only returns metadata about credential status + assert({ + given: "a config file with credentials", + should: "NOT return the token (security measure)", + actual: result.token, + expected: undefined, + }); + + assert({ + given: "a config file with credentials", + should: "return the expiry time", + actual: result.expiresAt, + expected: expiresAt, + }); + + assert({ + given: "a config file with non-expired credentials", + should: "return isExpired false", + actual: result.isExpired, + expected: false, + }); + }); + + test("returns hasCredentials false when config exists but no token", () => { + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const testConfig = { vibecodr: {} }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + const result = getStoredCredentials({ configPath: tempConfigPath }); + + assert({ + given: "a config file without access_token", + should: "return hasCredentials false", + actual: result.hasCredentials, + expected: false, + }); + }); +}); + +// ============================================================================= +// ensureVibecodrAuth tests +// ============================================================================= + +describe("ensureVibecodrAuth", () => { + let tempDir; + let tempConfigPath; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vibe-auth-test-")); + tempConfigPath = path.join(tempDir, "vibecodr", "cli.json"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("throws AUTH_REQUIRED when no config exists", async () => { + let error; + try { + await ensureVibecodrAuth({ configPath: tempConfigPath }); + } catch (e) { + error = e; + } + + assert({ + given: "no config file", + should: "throw error with AUTH_REQUIRED code", + actual: error?.cause?.code, + expected: "AUTH_REQUIRED", + }); + }); + + test("throws AUTH_REQUIRED when no access_token in config", async () => { + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const testConfig = { vibecodr: {} }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + let error; + try { + await ensureVibecodrAuth({ configPath: tempConfigPath }); + } catch (e) { + error = e; + } + + assert({ + given: "config without access_token", + should: "throw error with AUTH_REQUIRED code", + actual: error?.cause?.code, + expected: "AUTH_REQUIRED", + }); + }); + + test("returns token when valid and not expiring", async () => { + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const futureExpiry = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + const testConfig = { + vibecodr: { + access_token: "valid-token-123", + expires_at: futureExpiry, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + const result = await ensureVibecodrAuth({ configPath: tempConfigPath }); + + assert({ + given: "valid non-expiring token in config", + should: "return the token", + actual: result.token, + expected: "valid-token-123", + }); + + assert({ + given: "valid non-expiring token in config", + should: "return the expiresAt", + actual: result.expiresAt, + expected: futureExpiry, + }); + }); +}); + +// ============================================================================= +// refreshVibecodrToken tests +// ============================================================================= + +describe("refreshVibecodrToken", () => { + let tempDir; + let tempConfigPath; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vibe-auth-test-")); + tempConfigPath = path.join(tempDir, "vibecodr", "cli.json"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("throws AUTH_EXPIRED when no config exists", async () => { + let error; + try { + await refreshVibecodrToken({ configPath: tempConfigPath }); + } catch (e) { + error = e; + } + + assert({ + given: "no config file", + should: "throw error with AUTH_EXPIRED code", + actual: error?.cause?.code, + expected: "AUTH_EXPIRED", + }); + }); + + test("throws AUTH_EXPIRED when no clerk access_token", async () => { + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const testConfig = { clerk: {} }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + let error; + try { + await refreshVibecodrToken({ configPath: tempConfigPath }); + } catch (e) { + error = e; + } + + assert({ + given: "config without clerk access_token", + should: "throw error with AUTH_EXPIRED code", + actual: error?.cause?.code, + expected: "AUTH_EXPIRED", + }); + }); +}); + +// ============================================================================= +// Additional Coverage Tests +// ============================================================================= + +describe("getStoredCredentials - string expires_at", () => { + let tempDir; + let tempConfigPath; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vibe-auth-test-")); + tempConfigPath = path.join(tempDir, "vibecodr", "cli.json"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("handles string expires_at value gracefully", () => { + // Some systems might store expires_at as string instead of number + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const expiresAtString = String(Math.floor(Date.now() / 1000) + 3600); + const testConfig = { + vibecodr: { + access_token: "test-token-123", + expires_at: expiresAtString, // String instead of number + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + const result = getStoredCredentials({ configPath: tempConfigPath }); + + assert({ + given: "a config file with string expires_at", + should: "return hasCredentials true", + actual: result.hasCredentials, + expected: true, + }); + + // isExpired should be undefined when expires_at is not a number + assert({ + given: "a config file with string expires_at", + should: "return undefined isExpired (type mismatch)", + actual: result.isExpired, + expected: undefined, + }); + }); +}); + +// ============================================================================= +// minValidSeconds edge case tests +// ============================================================================= + +describe("ensureVibecodrAuth - minValidSeconds edge cases", () => { + let tempDir; + let tempConfigPath; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vibe-auth-test-")); + tempConfigPath = path.join(tempDir, "vibecodr", "cli.json"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("enforces minimum buffer even when minValidSeconds=0", async () => { + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + // Token expires in 5 seconds - less than the enforced minimum buffer (10s) + const nearExpiry = Math.floor(Date.now() / 1000) + 5; + const testConfig = { + vibecodr: { + access_token: "nearly-expired-token", + expires_at: nearExpiry, + }, + clerk: { + access_token: "clerk-token", + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + // Even with minValidSeconds=0, the 10s minimum buffer should trigger refresh + let error; + try { + await ensureVibecodrAuth({ + configPath: tempConfigPath, + minValidSeconds: 0, + }); + } catch (e) { + error = e; + } + + // Should attempt refresh (and fail because no refresh mechanism is mocked) + // The key is that it doesn't return the near-expiry token + assert({ + given: "token expiring in 5s with minValidSeconds=0", + should: "attempt refresh due to enforced minimum buffer", + actual: error !== undefined, // Should error because refresh would be attempted + expected: true, + }); + }); + + test("accepts token valid for more than minimum buffer", async () => { + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + // Token expires in 200 seconds - more than default minValidSeconds (120) + const futureExpiry = Math.floor(Date.now() / 1000) + 200; + const testConfig = { + vibecodr: { + access_token: "valid-token", + expires_at: futureExpiry, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + const result = await ensureVibecodrAuth({ configPath: tempConfigPath }); + + assert({ + given: "token valid for 200 seconds", + should: "return the token without refresh", + actual: result.token, + expected: "valid-token", + }); + }); +}); + +// ============================================================================= +// Token expiry type coercion tests +// ============================================================================= + +describe("ensureVibecodrAuth - expires_at type coercion", () => { + let tempDir; + let tempConfigPath; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vibe-auth-test-")); + tempConfigPath = path.join(tempDir, "vibecodr", "cli.json"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("handles string expires_at correctly", async () => { + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const futureExpiry = Math.floor(Date.now() / 1000) + 3600; + const testConfig = { + vibecodr: { + access_token: "string-expiry-token", + expires_at: String(futureExpiry), // String instead of number + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + const result = await ensureVibecodrAuth({ configPath: tempConfigPath }); + + assert({ + given: "config with string expires_at", + should: "parse and return the token", + actual: result.token, + expected: "string-expiry-token", + }); + }); +}); + +// ============================================================================= +// File Permissions Verification Tests (Security) +// ============================================================================= + +describe("file permissions verification", () => { + // Save original platform for restoration + const originalPlatform = process.platform; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + // Restore platform after each test + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + test("allows reading config with secure permissions (0600)", async () => { + // Skip this test on Windows - permissions work differently + if (originalPlatform === "win32") { + assert({ + given: "Windows platform", + should: "skip Unix permission test", + actual: true, + expected: true, + }); + return; + } + + // Mock Unix platform + Object.defineProperty(process, "platform", { value: "linux" }); + + // Mock fs.statSync to return mode 0o100600 (regular file + 0600 permissions) + // 0o100000 = regular file type bit, 0o600 = owner read/write only + vi.spyOn(fs, "statSync").mockReturnValue({ mode: 0o100600 }); + + const futureExpiry = Math.floor(Date.now() / 1000) + 3600; + vi.spyOn(fs, "readFileSync").mockReturnValue( + JSON.stringify({ + vibecodr: { access_token: "secure-token", expires_at: futureExpiry }, + }), + ); + + const result = await ensureVibecodrAuth({ + configPath: "/home/user/.config/vibecodr/cli.json", + }); + + assert({ + given: "config file with 0600 permissions", + should: "read successfully and return token", + actual: result.token, + expected: "secure-token", + }); + }); + + test("rejects config with group-readable permissions (0640)", async () => { + // Skip this test on Windows - permissions work differently + if (originalPlatform === "win32") { + assert({ + given: "Windows platform", + should: "skip Unix permission test", + actual: true, + expected: true, + }); + return; + } + + // Mock Unix platform + Object.defineProperty(process, "platform", { value: "linux" }); + + // Mock fs.statSync to return insecure mode 0o100640 (group readable) + vi.spyOn(fs, "statSync").mockReturnValue({ mode: 0o100640 }); + + let error = null; + try { + await ensureVibecodrAuth({ + configPath: "/home/user/.config/vibecodr/cli.json", + }); + } catch (e) { + error = e; + } + + assert({ + given: "config file with 0640 permissions", + should: "throw CONFIG_READ_ERROR", + actual: error?.cause?.code, + expected: "CONFIG_READ_ERROR", + }); + + assert({ + given: "config file with insecure permissions", + should: "include actual mode in error message", + actual: error?.message?.includes("640"), + expected: true, + }); + }); + + test("rejects config with world-readable permissions (0644)", async () => { + // Skip this test on Windows - permissions work differently + if (originalPlatform === "win32") { + assert({ + given: "Windows platform", + should: "skip Unix permission test", + actual: true, + expected: true, + }); + return; + } + + // Mock Unix platform + Object.defineProperty(process, "platform", { value: "linux" }); + + // Mock fs.statSync to return insecure mode 0o100644 (world readable) + vi.spyOn(fs, "statSync").mockReturnValue({ mode: 0o100644 }); + + let error = null; + try { + await ensureVibecodrAuth({ + configPath: "/home/user/.config/vibecodr/cli.json", + }); + } catch (e) { + error = e; + } + + assert({ + given: "config file with 0644 permissions", + should: "throw CONFIG_READ_ERROR", + actual: error?.cause?.code, + expected: "CONFIG_READ_ERROR", + }); + + assert({ + given: "config file with world-readable permissions", + should: "include actual mode in error message", + actual: error?.message?.includes("644"), + expected: true, + }); + }); + + test("rejects config with world-writable permissions (0666)", async () => { + // Skip this test on Windows - permissions work differently + if (originalPlatform === "win32") { + assert({ + given: "Windows platform", + should: "skip Unix permission test", + actual: true, + expected: true, + }); + return; + } + + // Mock Unix platform + Object.defineProperty(process, "platform", { value: "linux" }); + + // Mock fs.statSync to return insecure mode 0o100666 (world readable/writable) + vi.spyOn(fs, "statSync").mockReturnValue({ mode: 0o100666 }); + + let error = null; + try { + await ensureVibecodrAuth({ + configPath: "/home/user/.config/vibecodr/cli.json", + }); + } catch (e) { + error = e; + } + + assert({ + given: "config file with 0666 permissions", + should: "throw CONFIG_READ_ERROR", + actual: error?.cause?.code, + expected: "CONFIG_READ_ERROR", + }); + + assert({ + given: "config file with world-writable permissions", + should: "include actual mode in error message", + actual: error?.message?.includes("666"), + expected: true, + }); + }); + + test("allows owner-execute permission (0700)", async () => { + // Skip this test on Windows - permissions work differently + if (originalPlatform === "win32") { + assert({ + given: "Windows platform", + should: "skip Unix permission test", + actual: true, + expected: true, + }); + return; + } + + // Mock Unix platform + Object.defineProperty(process, "platform", { value: "linux" }); + + // Mock fs.statSync to return mode 0o100700 (owner rwx, no group/world) + // This is unusual for a config file but should be allowed since + // group and others have no access + vi.spyOn(fs, "statSync").mockReturnValue({ mode: 0o100700 }); + + const futureExpiry = Math.floor(Date.now() / 1000) + 3600; + vi.spyOn(fs, "readFileSync").mockReturnValue( + JSON.stringify({ + vibecodr: { access_token: "exec-token", expires_at: futureExpiry }, + }), + ); + + const result = await ensureVibecodrAuth({ + configPath: "/home/user/.config/vibecodr/cli.json", + }); + + assert({ + given: "config file with 0700 permissions (owner execute)", + should: "read successfully since group/world have no access", + actual: result.token, + expected: "exec-token", + }); + }); + + test("skips permission check on Windows", async () => { + // Mock Windows platform + Object.defineProperty(process, "platform", { value: "win32" }); + + // Spy on statSync - it should NOT be called on Windows for permission check + const statSpy = vi.spyOn(fs, "statSync"); + + const futureExpiry = Math.floor(Date.now() / 1000) + 3600; + vi.spyOn(fs, "readFileSync").mockReturnValue( + JSON.stringify({ + vibecodr: { access_token: "windows-token", expires_at: futureExpiry }, + }), + ); + + const result = await ensureVibecodrAuth({ + configPath: "C:\\Users\\test\\AppData\\Roaming\\vibecodr\\cli.json", + }); + + assert({ + given: "Windows platform", + should: "not call statSync for permission check", + actual: statSpy.mock.calls.length, + expected: 0, + }); + + assert({ + given: "Windows platform", + should: "read config successfully without permission check", + actual: result.token, + expected: "windows-token", + }); + }); + + test("handles non-existent file gracefully (ENOENT)", async () => { + // Skip this test on Windows - permissions work differently + if (originalPlatform === "win32") { + assert({ + given: "Windows platform", + should: "skip Unix permission test", + actual: true, + expected: true, + }); + return; + } + + // Mock Unix platform + Object.defineProperty(process, "platform", { value: "linux" }); + + // Mock fs.statSync to throw ENOENT (file doesn't exist) + const enoentError = new Error("ENOENT: no such file or directory"); + enoentError.code = "ENOENT"; + vi.spyOn(fs, "statSync").mockImplementation(() => { + throw enoentError; + }); + + // readFileSync should also throw ENOENT + vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw enoentError; + }); + + let error = null; + try { + await ensureVibecodrAuth({ + configPath: "/home/user/.config/vibecodr/cli.json", + }); + } catch (e) { + error = e; + } + + // Should throw AUTH_REQUIRED (no config), not CONFIG_READ_ERROR + assert({ + given: "non-existent config file", + should: "throw AUTH_REQUIRED (not CONFIG_READ_ERROR)", + actual: error?.cause?.code, + expected: "AUTH_REQUIRED", + }); + }); + + test("rejects group-writable permissions (0620)", async () => { + // Skip this test on Windows - permissions work differently + if (originalPlatform === "win32") { + assert({ + given: "Windows platform", + should: "skip Unix permission test", + actual: true, + expected: true, + }); + return; + } + + // Mock Unix platform + Object.defineProperty(process, "platform", { value: "linux" }); + + // Mock fs.statSync to return mode 0o100620 (owner rw, group w) + vi.spyOn(fs, "statSync").mockReturnValue({ mode: 0o100620 }); + + let error = null; + try { + await ensureVibecodrAuth({ + configPath: "/home/user/.config/vibecodr/cli.json", + }); + } catch (e) { + error = e; + } + + assert({ + given: "config file with group-writable permissions (0620)", + should: "throw CONFIG_READ_ERROR", + actual: error?.cause?.code, + expected: "CONFIG_READ_ERROR", + }); + }); + + test("includes chmod instruction in error message", async () => { + // Skip this test on Windows - permissions work differently + if (originalPlatform === "win32") { + assert({ + given: "Windows platform", + should: "skip Unix permission test", + actual: true, + expected: true, + }); + return; + } + + // Mock Unix platform + Object.defineProperty(process, "platform", { value: "linux" }); + + // Mock fs.statSync to return insecure mode + vi.spyOn(fs, "statSync").mockReturnValue({ mode: 0o100644 }); + + let error = null; + try { + await ensureVibecodrAuth({ + configPath: "/home/user/.config/vibecodr/cli.json", + }); + } catch (e) { + error = e; + } + + assert({ + given: "insecure file permissions", + should: "include chmod 600 instruction in error", + actual: error?.message?.includes("chmod 600"), + expected: true, + }); + }); +}); + +// ============================================================================= +// Concurrent Token Refresh (Mutex) Tests +// ============================================================================= + +describe("refreshWithLock - concurrent token refresh protection", () => { + let tempDir; + let tempConfigPath; + let mockFetch; + let originalFetch; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vibe-auth-test-")); + tempConfigPath = path.join(tempDir, "vibecodr", "cli.json"); + + // Save original fetch and replace with mock + originalFetch = global.fetch; + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + // Restore original fetch + global.fetch = originalFetch; + mockFetch.mockReset(); + }); + + test("only performs one refresh when multiple operations detect expiry simultaneously", async () => { + // Setup: Create config with expired vibecodr token but valid clerk token + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const expiredAt = Math.floor(Date.now() / 1000) - 100; // Expired 100 seconds ago + const testConfig = { + vibecodr: { + access_token: "expired-vibecodr-token", + expires_at: expiredAt, + }, + clerk: { + access_token: "valid-clerk-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, // Valid for 1 hour + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + // Track how many times the exchange endpoint is called + let exchangeCallCount = 0; + const newExpiresAt = Math.floor(Date.now() / 1000) + 3600; + + // Mock fetch to handle the token exchange endpoint + // The exchange endpoint is called when refreshing vibecodr token + mockFetch.mockImplementation(async (url) => { + if (url.includes("/auth/cli/exchange")) { + exchangeCallCount++; + // Simulate network delay to ensure both calls overlap + await new Promise((resolve) => setTimeout(resolve, 50)); + return { + ok: true, + status: 200, + headers: new Map(), + text: async () => + JSON.stringify({ + access_token: "new-vibecodr-token", + expires_at: newExpiresAt, + }), + }; + } + // Unexpected endpoint + return { + ok: false, + status: 404, + headers: new Map(), + text: async () => JSON.stringify({ error: "Not found" }), + }; + }); + + // Trigger two concurrent operations that both detect expired token + const operation1 = ensureVibecodrAuth({ configPath: tempConfigPath }); + const operation2 = ensureVibecodrAuth({ configPath: tempConfigPath }); + + // Both should complete successfully + const [result1, result2] = await Promise.all([operation1, operation2]); + + // Assert: refresh (exchange) was only called ONCE due to mutex + assert({ + given: "two concurrent operations detecting expired token", + should: "only call token exchange once (mutex working)", + actual: exchangeCallCount, + expected: 1, + }); + + // Assert: both operations got the same refreshed token + assert({ + given: "two concurrent operations", + should: "both return the same refreshed token", + actual: result1.token, + expected: "new-vibecodr-token", + }); + + assert({ + given: "two concurrent operations", + should: "both return the same token (second caller)", + actual: result2.token, + expected: "new-vibecodr-token", + }); + + // Assert: both got the same expiry + assert({ + given: "two concurrent operations", + should: "both return the same expiresAt", + actual: result1.expiresAt === result2.expiresAt, + expected: true, + }); + }); + + test("releases lock after refresh completes allowing subsequent refreshes", async () => { + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + + // Track exchange calls + let exchangeCallCount = 0; + + // Mock fetch for token exchange + mockFetch.mockImplementation(async (url) => { + if (url.includes("/auth/cli/exchange")) { + exchangeCallCount++; + const newExpiresAt = Math.floor(Date.now() / 1000) + 3600; + return { + ok: true, + status: 200, + headers: new Map(), + text: async () => + JSON.stringify({ + access_token: `refreshed-token-${exchangeCallCount}`, + expires_at: newExpiresAt, + }), + }; + } + return { + ok: false, + status: 404, + headers: new Map(), + text: async () => JSON.stringify({ error: "Not found" }), + }; + }); + + // First refresh cycle: expired token triggers refresh + const expiredAt = Math.floor(Date.now() / 1000) - 100; + const config1 = { + vibecodr: { + access_token: "expired-token-1", + expires_at: expiredAt, + }, + clerk: { + access_token: "clerk-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(config1)); + + const result1 = await ensureVibecodrAuth({ configPath: tempConfigPath }); + + assert({ + given: "first expired token", + should: "trigger first refresh", + actual: exchangeCallCount, + expected: 1, + }); + + assert({ + given: "first refresh", + should: "return first refreshed token", + actual: result1.token, + expected: "refreshed-token-1", + }); + + // Simulate token expiring again by updating config with expired token + const config2 = { + vibecodr: { + access_token: "expired-token-2", + expires_at: expiredAt, + }, + clerk: { + access_token: "clerk-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(config2)); + + // Second refresh cycle should work (lock was released) + const result2 = await ensureVibecodrAuth({ configPath: tempConfigPath }); + + assert({ + given: "second expired token after first refresh completed", + should: "trigger second refresh (lock released)", + actual: exchangeCallCount, + expected: 2, + }); + + assert({ + given: "second refresh", + should: "return second refreshed token", + actual: result2.token, + expected: "refreshed-token-2", + }); + }); + + test("releases lock even if refresh fails", async () => { + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + + let exchangeCallCount = 0; + + // Mock fetch to fail first, succeed second + mockFetch.mockImplementation(async (url) => { + if (url.includes("/auth/cli/exchange")) { + exchangeCallCount++; + if (exchangeCallCount === 1) { + // First call fails + return { + ok: false, + status: 500, + headers: new Map(), + text: async () => JSON.stringify({ error: "Server error" }), + }; + } + // Second call succeeds + return { + ok: true, + status: 200, + headers: new Map(), + text: async () => + JSON.stringify({ + access_token: "success-after-failure", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + }; + } + return { + ok: false, + status: 404, + headers: new Map(), + text: async () => JSON.stringify({ error: "Not found" }), + }; + }); + + // Config with expired vibecodr token, valid clerk token, but no refresh token + // This ensures only the exchange is attempted (not clerk refresh) + const expiredAt = Math.floor(Date.now() / 1000) - 100; + const config = { + vibecodr: { + access_token: "expired-token", + expires_at: expiredAt, + }, + clerk: { + access_token: "clerk-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + // No refresh_token means it will fail without attempting clerk refresh + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(config)); + + // First call should fail + let error1; + try { + await ensureVibecodrAuth({ configPath: tempConfigPath }); + } catch (e) { + error1 = e; + } + + assert({ + given: "first refresh attempt that fails", + should: "throw an error", + actual: error1 !== undefined, + expected: true, + }); + + assert({ + given: "first refresh attempt", + should: "have called exchange once", + actual: exchangeCallCount, + expected: 1, + }); + + // Second call should be able to try again (lock released on failure) + const result2 = await ensureVibecodrAuth({ configPath: tempConfigPath }); + + assert({ + given: "second refresh attempt after first failure", + should: "be able to try again (lock released)", + actual: exchangeCallCount, + expected: 2, + }); + + assert({ + given: "second successful refresh", + should: "return the token", + actual: result2.token, + expected: "success-after-failure", + }); + }); + + test("all concurrent callers receive same error when refresh fails", async () => { + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + + let exchangeCallCount = 0; + + // Mock fetch to fail with delay + mockFetch.mockImplementation(async (url) => { + if (url.includes("/auth/cli/exchange")) { + exchangeCallCount++; + await new Promise((resolve) => setTimeout(resolve, 50)); + return { + ok: false, + status: 500, + headers: new Map(), + text: async () => JSON.stringify({ error: "Server error" }), + }; + } + return { + ok: false, + status: 404, + headers: new Map(), + text: async () => JSON.stringify({ error: "Not found" }), + }; + }); + + const expiredAt = Math.floor(Date.now() / 1000) - 100; + const config = { + vibecodr: { + access_token: "expired-token", + expires_at: expiredAt, + }, + clerk: { + access_token: "clerk-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(config)); + + // Trigger two concurrent operations + const operation1 = ensureVibecodrAuth({ configPath: tempConfigPath }).catch( + (e) => e, + ); + const operation2 = ensureVibecodrAuth({ configPath: tempConfigPath }).catch( + (e) => e, + ); + + const [error1, error2] = await Promise.all([operation1, operation2]); + + // Both should receive errors (same rejection) + assert({ + given: "two concurrent operations when refresh fails", + should: "only call exchange once (mutex working)", + actual: exchangeCallCount, + expected: 1, + }); + + assert({ + given: "two concurrent operations when refresh fails", + should: "both receive errors", + actual: error1 instanceof Error && error2 instanceof Error, + expected: true, + }); + + // Both should receive the same error (same promise rejection) + assert({ + given: "two concurrent operations receiving same rejection", + should: "have same error message", + actual: error1.message === error2.message, + expected: true, + }); + }); +}); + +// ============================================================================= +// Windows File Permission Tests (Security) +// ============================================================================= + +describe("Windows file permissions - setWindowsFilePermissions", () => { + const originalPlatform = process.platform; + + beforeEach(() => { + mockExecSync.mockReset(); + mockExecSync.mockImplementation(() => {}); + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + test("calls icacls with correct arguments for file permissions", () => { + const result = _testOnly.setWindowsFilePermissions( + "C:\\Users\\test\\vibecodr\\cli.json", + ); + + assert({ + given: "a file path", + should: "return success true", + actual: result.success, + expected: true, + }); + + assert({ + given: "a file path", + should: "call execSync with icacls command", + actual: mockExecSync.mock.calls.length, + expected: 1, + }); + + const command = mockExecSync.mock.calls[0][0]; + assert({ + given: "a file path", + should: "include /inheritance:r to remove inherited permissions", + actual: command.includes("/inheritance:r"), + expected: true, + }); + + assert({ + given: "a file path", + should: "include /grant:r to replace permissions", + actual: command.includes("/grant:r"), + expected: true, + }); + + assert({ + given: "a file path", + should: "include %USERNAME%:(F) for full control", + actual: command.includes('"%USERNAME%:(F)"'), + expected: true, + }); + + assert({ + given: "a file path", + should: "include the file path in the command", + actual: command.includes("C:\\Users\\test\\vibecodr\\cli.json"), + expected: true, + }); + }); + + test("returns warning when icacls fails", () => { + // Mock execSync to throw an error + mockExecSync.mockImplementation(() => { + throw new Error("Access denied"); + }); + + const result = _testOnly.setWindowsFilePermissions( + "C:\\Users\\test\\vibecodr\\cli.json", + ); + + assert({ + given: "icacls command that fails", + should: "return success false", + actual: result.success, + expected: false, + }); + + assert({ + given: "icacls command that fails", + should: "return a warning message", + actual: typeof result.warning === "string" && result.warning.length > 0, + expected: true, + }); + + assert({ + given: "icacls command that fails", + should: "include manual instructions in warning", + actual: result.warning.includes("icacls"), + expected: true, + }); + }); + + test("uses windowsHide option to suppress command window", () => { + _testOnly.setWindowsFilePermissions("C:\\test\\file.json"); + + const options = mockExecSync.mock.calls[0][1]; + assert({ + given: "icacls execution", + should: "use windowsHide: true to prevent command window popup", + actual: options.windowsHide, + expected: true, + }); + + assert({ + given: "icacls execution", + should: "use stdio: ignore to suppress output", + actual: options.stdio, + expected: "ignore", + }); + }); +}); + +describe("Windows directory permissions - setWindowsDirectoryPermissions", () => { + beforeEach(() => { + mockExecSync.mockReset(); + mockExecSync.mockImplementation(() => {}); + }); + + test("calls icacls with inheritance flags for directory", () => { + const result = _testOnly.setWindowsDirectoryPermissions( + "C:\\Users\\test\\vibecodr", + ); + + assert({ + given: "a directory path", + should: "return success true", + actual: result.success, + expected: true, + }); + + const command = mockExecSync.mock.calls[0][0]; + assert({ + given: "a directory path", + should: "include (OI) for object inherit", + actual: command.includes("(OI)"), + expected: true, + }); + + assert({ + given: "a directory path", + should: "include (CI) for container inherit", + actual: command.includes("(CI)"), + expected: true, + }); + + assert({ + given: "a directory path", + should: "include (F) for full control", + actual: command.includes("(F)"), + expected: true, + }); + }); + + test("returns success false when icacls fails", () => { + mockExecSync.mockImplementation(() => { + throw new Error("Permission denied"); + }); + + const result = _testOnly.setWindowsDirectoryPermissions( + "C:\\Users\\test\\vibecodr", + ); + + assert({ + given: "icacls command that fails on directory", + should: "return success false", + actual: result.success, + expected: false, + }); + + // Directory permissions don't return a warning (secondary security measure) + assert({ + given: "icacls command that fails on directory", + should: "not include a warning (fails silently)", + actual: result.warning, + expected: undefined, + }); + }); +}); + +describe("Windows permissions integration - writeConfig flow", () => { + let tempDir; + let tempConfigPath; + const originalPlatform = process.platform; + let mockFetch; + let originalFetch; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vibe-auth-win-test-")); + tempConfigPath = path.join(tempDir, "vibecodr", "cli.json"); + mockExecSync.mockReset(); + mockExecSync.mockImplementation(() => {}); + + originalFetch = global.fetch; + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + Object.defineProperty(process, "platform", { value: originalPlatform }); + global.fetch = originalFetch; + }); + + test("icacls is called when refreshVibecodrToken writes config on Windows", async () => { + // Mock Windows platform + Object.defineProperty(process, "platform", { value: "win32" }); + + // Setup config with valid clerk token + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const testConfig = { + clerk: { + access_token: "clerk-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + // Mock successful token exchange + const newExpiresAt = Math.floor(Date.now() / 1000) + 3600; + mockFetch.mockImplementation(async (url) => { + if (url.includes("/auth/cli/exchange")) { + return { + ok: true, + status: 200, + headers: new Map(), + text: async () => + JSON.stringify({ + access_token: "new-vibecodr-token", + expires_at: newExpiresAt, + }), + }; + } + return { + ok: false, + status: 404, + headers: new Map(), + text: async () => JSON.stringify({ error: "Not found" }), + }; + }); + + await refreshVibecodrToken({ configPath: tempConfigPath }); + + // Verify icacls was called for the config file + const icaclsCalls = mockExecSync.mock.calls.filter((call) => + call[0].includes("icacls"), + ); + + assert({ + given: "refreshVibecodrToken on Windows", + should: "call icacls to secure the config file", + actual: icaclsCalls.length >= 1, + expected: true, + }); + + // Verify the command targets the config file + const filePermissionCall = icaclsCalls.find( + (call) => call[0].includes("cli.json") && !call[0].includes("(OI)"), + ); + assert({ + given: "refreshVibecodrToken on Windows", + should: "call icacls for the config file specifically", + actual: filePermissionCall !== undefined, + expected: true, + }); + }); + + test("config is written successfully even if icacls fails", async () => { + // Mock Windows platform + Object.defineProperty(process, "platform", { value: "win32" }); + + // Mock execSync to fail + mockExecSync.mockImplementation(() => { + throw new Error("Access denied"); + }); + + // Capture stderr warnings + const stderrWrites = []; + const originalStderrWrite = process.stderr.write; + process.stderr.write = (msg) => { + stderrWrites.push(msg); + return true; + }; + + // Setup config + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const testConfig = { + clerk: { + access_token: "clerk-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + // Mock successful token exchange + mockFetch.mockImplementation(async (url) => { + if (url.includes("/auth/cli/exchange")) { + return { + ok: true, + status: 200, + headers: new Map(), + text: async () => + JSON.stringify({ + access_token: "new-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + }; + } + return { + ok: false, + status: 404, + headers: new Map(), + text: async () => JSON.stringify({ error: "Not found" }), + }; + }); + + try { + const result = await refreshVibecodrToken({ configPath: tempConfigPath }); + + // Should succeed despite icacls failure + assert({ + given: "icacls failure on Windows", + should: "still return the token (write succeeded)", + actual: result.token, + expected: "new-token", + }); + + // Config file should exist + assert({ + given: "icacls failure on Windows", + should: "still write the config file", + actual: fs.existsSync(tempConfigPath), + expected: true, + }); + + // Should emit warning + const hasWarning = stderrWrites.some( + (msg) => msg.includes("Warning") && msg.includes("icacls"), + ); + assert({ + given: "icacls failure on Windows", + should: "emit a warning to stderr", + actual: hasWarning, + expected: true, + }); + } finally { + process.stderr.write = originalStderrWrite; + } + }); + + test("icacls is NOT called on Unix platforms", async () => { + // Mock Unix platform + Object.defineProperty(process, "platform", { value: "linux" }); + + // Setup config + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const testConfig = { + clerk: { + access_token: "clerk-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + // Write config with proper permissions for Unix mode check + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig), { + mode: 0o600, + }); + + // Mock fs.statSync to return Unix-like mode for permission check + // This is needed because we're simulating Unix on Windows + const originalStatSync = fs.statSync; + vi.spyOn(fs, "statSync").mockImplementation((filePath) => { + // For our test config file, return Unix-style secure permissions + if (filePath === tempConfigPath) { + return { mode: 0o100600 }; // regular file + 0600 permissions + } + // For other files, use real implementation + return originalStatSync(filePath); + }); + + // Mock successful token exchange + mockFetch.mockImplementation(async (url) => { + if (url.includes("/auth/cli/exchange")) { + return { + ok: true, + status: 200, + headers: new Map(), + text: async () => + JSON.stringify({ + access_token: "new-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + }; + } + return { + ok: false, + status: 404, + headers: new Map(), + text: async () => JSON.stringify({ error: "Not found" }), + }; + }); + + await refreshVibecodrToken({ configPath: tempConfigPath }); + + // Verify icacls was NOT called + const icaclsCalls = mockExecSync.mock.calls.filter((call) => + call[0].includes("icacls"), + ); + + assert({ + given: "refreshVibecodrToken on Unix", + should: "NOT call icacls", + actual: icaclsCalls.length, + expected: 0, + }); + }); +}); + +// ============================================================================= +// Windows Permission Detection Tests (checkWindowsPermissions) +// ============================================================================= + +describe("Windows permission detection - checkWindowsPermissions", () => { + const originalPlatform = process.platform; + + beforeEach(() => { + mockExecSync.mockReset(); + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + test("detects Everyone group with read access", () => { + // Mock icacls output with Everyone having read permissions + mockExecSync.mockReturnValue( + "C:\\Users\\test\\vibecodr\\cli.json NT AUTHORITY\\SYSTEM:(F)\n" + + " BUILTIN\\Administrators:(F)\n" + + " Everyone:(R)\n" + + " TEST-PC\\testuser:(F)\n", + ); + + const result = _testOnly.checkWindowsPermissions( + "C:\\Users\\test\\vibecodr\\cli.json", + ); + + assert({ + given: "icacls output with Everyone read access", + should: "return secure false", + actual: result.secure, + expected: false, + }); + + assert({ + given: "icacls output with Everyone read access", + should: "include details about insecure entry", + actual: result.details?.includes("Everyone:"), + expected: true, + }); + }); + + test("detects BUILTIN\\Users group with read access", () => { + mockExecSync.mockReturnValue( + "C:\\Users\\test\\vibecodr\\cli.json NT AUTHORITY\\SYSTEM:(F)\n" + + " BUILTIN\\Administrators:(F)\n" + + " BUILTIN\\Users:(R)\n" + + " TEST-PC\\testuser:(F)\n", + ); + + const result = _testOnly.checkWindowsPermissions( + "C:\\Users\\test\\vibecodr\\cli.json", + ); + + assert({ + given: "icacls output with BUILTIN\\Users read access", + should: "return secure false", + actual: result.secure, + expected: false, + }); + + assert({ + given: "icacls output with BUILTIN\\Users", + should: "include details about insecure entry", + actual: result.details?.includes("BUILTIN\\Users:"), + expected: true, + }); + }); + + test("detects NT AUTHORITY\\Authenticated Users with access", () => { + mockExecSync.mockReturnValue( + "C:\\Users\\test\\vibecodr\\cli.json NT AUTHORITY\\SYSTEM:(F)\n" + + " NT AUTHORITY\\Authenticated Users:(R)\n" + + " TEST-PC\\testuser:(F)\n", + ); + + const result = _testOnly.checkWindowsPermissions( + "C:\\Users\\test\\vibecodr\\cli.json", + ); + + assert({ + given: "icacls output with Authenticated Users", + should: "return secure false", + actual: result.secure, + expected: false, + }); + + assert({ + given: "icacls output with Authenticated Users", + should: "include details about insecure entry", + actual: result.details?.includes("NT AUTHORITY\\Authenticated Users:"), + expected: true, + }); + }); + + test("returns secure true for properly restricted file", () => { + // Mock icacls output with only owner access + mockExecSync.mockReturnValue( + "C:\\Users\\test\\vibecodr\\cli.json NT AUTHORITY\\SYSTEM:(F)\n" + + " BUILTIN\\Administrators:(F)\n" + + " TEST-PC\\testuser:(F)\n", + ); + + const result = _testOnly.checkWindowsPermissions( + "C:\\Users\\test\\vibecodr\\cli.json", + ); + + assert({ + given: "icacls output with only owner/system access", + should: "return secure true", + actual: result.secure, + expected: true, + }); + + assert({ + given: "secure file", + should: "not include details field", + actual: result.details, + expected: undefined, + }); + }); + + test("handles icacls failure gracefully", () => { + // Mock execSync to throw error + mockExecSync.mockImplementation(() => { + throw new Error("Access denied"); + }); + + const result = _testOnly.checkWindowsPermissions( + "C:\\Users\\test\\vibecodr\\cli.json", + ); + + // Should assume secure to avoid blocking legitimate usage + assert({ + given: "icacls command that fails", + should: "return secure true (assume secure on verification failure)", + actual: result.secure, + expected: true, + }); + + assert({ + given: "icacls command that fails", + should: "return a warning message", + actual: typeof result.warning === "string" && result.warning.length > 0, + expected: true, + }); + }); + + test("is case-insensitive when detecting Everyone", () => { + mockExecSync.mockReturnValue( + "C:\\Users\\test\\vibecodr\\cli.json everyone:(R)\n" + + " TEST-PC\\testuser:(F)\n", + ); + + const result = _testOnly.checkWindowsPermissions( + "C:\\Users\\test\\vibecodr\\cli.json", + ); + + assert({ + given: "icacls output with lowercase 'everyone'", + should: "detect insecure permissions (case-insensitive)", + actual: result.secure, + expected: false, + }); + }); + + test("detects multiple insecure entries", () => { + mockExecSync.mockReturnValue( + "C:\\Users\\test\\vibecodr\\cli.json Everyone:(R)\n" + + " BUILTIN\\Users:(R)\n" + + " NT AUTHORITY\\Authenticated Users:(R)\n" + + " TEST-PC\\testuser:(F)\n", + ); + + const result = _testOnly.checkWindowsPermissions( + "C:\\Users\\test\\vibecodr\\cli.json", + ); + + assert({ + given: "icacls output with multiple insecure entries", + should: "return secure false", + actual: result.secure, + expected: false, + }); + + // Should include all three insecure entries + assert({ + given: "multiple insecure entries", + should: "include Everyone in details", + actual: result.details?.includes("Everyone:"), + expected: true, + }); + + assert({ + given: "multiple insecure entries", + should: "include BUILTIN\\Users in details", + actual: result.details?.includes("BUILTIN\\Users:"), + expected: true, + }); + + assert({ + given: "multiple insecure entries", + should: "include Authenticated Users in details", + actual: result.details?.includes("NT AUTHORITY\\Authenticated Users:"), + expected: true, + }); + }); +}); + +// ============================================================================= +// Windows Permission Remediation Tests (fixWindowsPermissions) +// ============================================================================= + +describe("Windows permission remediation - fixWindowsPermissions", () => { + beforeEach(() => { + mockExecSync.mockReset(); + mockExecSync.mockImplementation(() => {}); + }); + + test("calls icacls with correct command", () => { + const result = fixWindowsPermissions("C:\\Users\\test\\vibecodr\\cli.json"); + + assert({ + given: "a file path", + should: "return success true", + actual: result.success, + expected: true, + }); + + assert({ + given: "a file path", + should: "call execSync once", + actual: mockExecSync.mock.calls.length, + expected: 1, + }); + + const command = mockExecSync.mock.calls[0][0]; + assert({ + given: "fixWindowsPermissions call", + should: "include /inheritance:r to remove inherited permissions", + actual: command.includes("/inheritance:r"), + expected: true, + }); + + assert({ + given: "fixWindowsPermissions call", + should: "include /grant:r to replace permissions", + actual: command.includes("/grant:r"), + expected: true, + }); + + assert({ + given: "fixWindowsPermissions call", + should: "include %USERNAME%:(F) for full control", + actual: command.includes('"%USERNAME%:(F)"'), + expected: true, + }); + }); + + test("returns command that was run", () => { + const result = fixWindowsPermissions("C:\\test\\file.json"); + + assert({ + given: "successful fix", + should: "return the command that was run", + actual: result.commandRun?.includes("icacls"), + expected: true, + }); + + assert({ + given: "successful fix", + should: "include the file path in command", + actual: result.commandRun?.includes("C:\\test\\file.json"), + expected: true, + }); + }); + + test("handles icacls failure gracefully", () => { + mockExecSync.mockImplementation(() => { + throw new Error("Access denied"); + }); + + const result = fixWindowsPermissions("C:\\test\\file.json"); + + assert({ + given: "icacls command that fails", + should: "return success false", + actual: result.success, + expected: false, + }); + + assert({ + given: "icacls command that fails", + should: "return a warning message", + actual: typeof result.warning === "string" && result.warning.length > 0, + expected: true, + }); + + assert({ + given: "icacls failure", + should: "include manual fix instructions in warning", + actual: result.warning?.includes("icacls"), + expected: true, + }); + + assert({ + given: "icacls failure", + should: "still return the command that was attempted", + actual: result.commandRun?.includes("icacls"), + expected: true, + }); + }); + + test("logs command when verbose=true", () => { + const logs = []; + const originalLog = console.log; + console.log = (...args) => logs.push(args.join(" ")); + + try { + fixWindowsPermissions("C:\\test\\file.json", { verbose: true }); + + assert({ + given: "verbose=true option", + should: "log the icacls command being run", + actual: logs.some( + (log) => log.includes("Running:") && log.includes("icacls"), + ), + expected: true, + }); + + assert({ + given: "successful fix with verbose=true", + should: "log success message", + actual: logs.some((log) => log.includes("Successfully secured")), + expected: true, + }); + } finally { + console.log = originalLog; + } + }); + + test("does not log when verbose=false (default)", () => { + const logs = []; + const originalLog = console.log; + console.log = (...args) => logs.push(args.join(" ")); + + try { + fixWindowsPermissions("C:\\test\\file.json"); + + assert({ + given: "default verbose=false", + should: "not log any messages", + actual: logs.length, + expected: 0, + }); + } finally { + console.log = originalLog; + } + }); + + test("uses windowsHide option to suppress command window", () => { + fixWindowsPermissions("C:\\test\\file.json"); + + const options = mockExecSync.mock.calls[0][1]; + assert({ + given: "fixWindowsPermissions execution", + should: "use windowsHide: true to prevent command window popup", + actual: options.windowsHide, + expected: true, + }); + }); + + test("uses inherit stdio when verbose=true", () => { + fixWindowsPermissions("C:\\test\\file.json", { verbose: true }); + + const options = mockExecSync.mock.calls[0][1]; + assert({ + given: "verbose=true option", + should: "use stdio: inherit to show command output", + actual: options.stdio, + expected: "inherit", + }); + }); + + test("uses ignore stdio when verbose=false", () => { + fixWindowsPermissions("C:\\test\\file.json", { verbose: false }); + + const options = mockExecSync.mock.calls[0][1]; + assert({ + given: "verbose=false option", + should: "use stdio: ignore to suppress output", + actual: options.stdio, + expected: "ignore", + }); + }); +}); + +// ============================================================================= +// Windows Permission Detection Integration Tests +// ============================================================================= + +describe("Windows permission detection - integration with verifyFilePermissions", () => { + let tempDir; + let tempConfigPath; + const originalPlatform = process.platform; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vibe-win-perm-test-")); + tempConfigPath = path.join(tempDir, "vibecodr", "cli.json"); + mockExecSync.mockReset(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + test("throws CONFIG_READ_ERROR when Windows file has Everyone access", async () => { + // Mock Windows platform + Object.defineProperty(process, "platform", { value: "win32" }); + + // Create config file + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const futureExpiry = Math.floor(Date.now() / 1000) + 3600; + const testConfig = { + vibecodr: { + access_token: "test-token", + expires_at: futureExpiry, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + // Mock icacls to report Everyone has access + mockExecSync.mockReturnValue( + `${tempConfigPath} Everyone:(R)\n` + + ` TEST-PC\\testuser:(F)\n`, + ); + + let error; + try { + await ensureVibecodrAuth({ configPath: tempConfigPath }); + } catch (e) { + error = e; + } + + assert({ + given: "Windows config file with Everyone read access", + should: "throw CONFIG_READ_ERROR", + actual: error?.cause?.code, + expected: "CONFIG_READ_ERROR", + }); + + assert({ + given: "Windows config with insecure permissions", + should: "include 'insecure Windows permissions' in message", + actual: error?.message?.includes("insecure Windows permissions"), + expected: true, + }); + + assert({ + given: "Windows config with insecure permissions", + should: "include Everyone in error message", + actual: error?.message?.includes("Everyone:"), + expected: true, + }); + + assert({ + given: "Windows config with insecure permissions", + should: "include fix instructions in message", + actual: error?.message?.includes("To fix"), + expected: true, + }); + + assert({ + given: "Windows config with insecure permissions", + should: "mention fixWindowsPermissions in message", + actual: error?.message?.includes("fixWindowsPermissions"), + expected: true, + }); + }); + + test("allows reading config when Windows permissions are secure", async () => { + // Mock Windows platform + Object.defineProperty(process, "platform", { value: "win32" }); + + // Create config file + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const futureExpiry = Math.floor(Date.now() / 1000) + 3600; + const testConfig = { + vibecodr: { + access_token: "secure-token", + expires_at: futureExpiry, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + // Mock icacls to report secure permissions (no Everyone/Users) + mockExecSync.mockReturnValue( + `${tempConfigPath} NT AUTHORITY\\SYSTEM:(F)\n` + + ` TEST-PC\\testuser:(F)\n`, + ); + + const result = await ensureVibecodrAuth({ configPath: tempConfigPath }); + + assert({ + given: "Windows config with secure permissions", + should: "read successfully and return token", + actual: result.token, + expected: "secure-token", + }); + }); + + test("does not block when icacls verification fails", async () => { + // Mock Windows platform + Object.defineProperty(process, "platform", { value: "win32" }); + + // Create config file + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const futureExpiry = Math.floor(Date.now() / 1000) + 3600; + const testConfig = { + vibecodr: { + access_token: "test-token", + expires_at: futureExpiry, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + // Mock icacls to fail (e.g., permission denied to query ACLs) + mockExecSync.mockImplementation(() => { + throw new Error("Access denied to query ACLs"); + }); + + // Should not throw - assumes secure when verification fails + const result = await ensureVibecodrAuth({ configPath: tempConfigPath }); + + assert({ + given: "Windows with icacls verification failure", + should: "assume secure and return token (graceful degradation)", + actual: result.token, + expected: "test-token", + }); + }); +}); diff --git a/package.json b/package.json index 807065d..862617a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,10 @@ "./vibe-utils": { "types": "./lib/vibe-utils.d.ts", "default": "./lib/vibe-utils.js" + }, + "./vibe-auth": { + "types": "./lib/vibe-auth.d.ts", + "default": "./lib/vibe-auth.js" } }, "files": [ From 265732bed1a30e67e0797e878cae547a4a27eaa7 Mon Sep 17 00:00:00 2001 From: bradensui Date: Wed, 7 Jan 2026 22:16:11 -0600 Subject: [PATCH 3/6] feat: add vibe-files and vibe-prompt modules Add file handling and prompt construction modules: vibe-files: - File validation with size and type checks - Bundle collection with glob patterns - Security filtering for sensitive files - HTML/JavaScript entry point detection vibe-prompt: - AI prompt template construction - Context aggregation for code generation - Structured prompt formatting Depends on: vibe-utils (#73) Co-Authored-By: Claude Opus 4.5 --- lib/vibe-files.d.ts | 34 ++ lib/vibe-files.js | 557 ++++++++++++++++++++++++ lib/vibe-files.test.js | 933 ++++++++++++++++++++++++++++++++++++++++ lib/vibe-prompt.d.ts | 35 ++ lib/vibe-prompt.js | 386 +++++++++++++++++ lib/vibe-prompt.test.js | 637 +++++++++++++++++++++++++++ package.json | 8 + 7 files changed, 2590 insertions(+) create mode 100644 lib/vibe-files.d.ts create mode 100644 lib/vibe-files.js create mode 100644 lib/vibe-files.test.js create mode 100644 lib/vibe-prompt.d.ts create mode 100644 lib/vibe-prompt.js create mode 100644 lib/vibe-prompt.test.js diff --git a/lib/vibe-files.d.ts b/lib/vibe-files.d.ts new file mode 100644 index 0000000..7698872 --- /dev/null +++ b/lib/vibe-files.d.ts @@ -0,0 +1,34 @@ +export type VibeFileContent = string | Uint8Array; + +export type VibeFileEntry = { + path: string; + content: VibeFileContent; + [key: string]: unknown; +}; + +export const vibeFileErrors: Record; + +export function validateFileName(fileName: string): { + valid: boolean; + reason?: string; +}; + +export function validateBundle( + files: VibeFileEntry[], + options?: { maxSize?: number; maxFiles?: number }, +): { valid: true; totalSize: number; fileCount: number }; + +export function createFileEntry(params: { + path: string; + content: VibeFileContent; +}): VibeFileEntry; + +export function collectGeneratedFiles(generatedOutput: unknown): VibeFileEntry[]; + +export function calculateBundleSize(files: Array<{ path: string; content?: VibeFileContent; size?: number }> | null): { + totalSize: number; + fileCount: number; + breakdown: Record; +}; + +export const defaults: Record; diff --git a/lib/vibe-files.js b/lib/vibe-files.js new file mode 100644 index 0000000..70996ff --- /dev/null +++ b/lib/vibe-files.js @@ -0,0 +1,557 @@ +/** + * File handling module for generated vibes. + * Handles file validation, bundle size checks, and file collection + * for the Vibecodr publish pipeline. + * + * @module vibe-files + */ + +import { errorCauses, createError } from "error-causes"; +import { isPathSafe } from "./vibe-utils.js"; + +// ----------------------------------------------------------------------------- +// Error Definitions +// ----------------------------------------------------------------------------- + +/** + * Error causes for vibe file operations. + * Uses error-causes library for structured error handling. + */ +const [vibeFileErrors] = errorCauses({ + ForbiddenFileName: { + code: "FORBIDDEN_FILE_NAME", + message: "File name is not allowed", + }, + BundleTooLarge: { + code: "BUNDLE_TOO_LARGE", + message: "Total bundle size exceeds limit", + }, + TooManyFiles: { + code: "TOO_MANY_FILES", + message: "File count exceeds limit", + }, +}); + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/** + * Forbidden file patterns from Vibecodr constraints. + * Files matching these patterns cannot be uploaded to a vibe. + * + * Rationale: + * - entry.tsx: Reserved by Vibecodr runtime + * - _vibecodr__*: Internal Vibecodr namespace + * - __VCSHIM*: Internal shim files + * - node_modules/: Should never be uploaded + * - package.json/package-lock.json: Managed by Vibecodr + * - .env*: Security - prevent credential leaks + */ +const forbiddenPatterns = [ + /^entry\.tsx$/, + /^_vibecodr__/, + /^__VCSHIM/, + /^node_modules\//, + /^package\.json$/, + /^package-lock\.json$/, + /^\.env/, +]; + +/** + * Default bundle constraints. + * These can be overridden when calling validateBundle. + */ +const defaultMaxSize = 5 * 1024 * 1024; // 5MB +const defaultMaxFiles = 100; + +// ----------------------------------------------------------------------------- +// File Name Validation +// ----------------------------------------------------------------------------- + +/** + * Validate a file name against forbidden patterns. + * + * @param {string} fileName - The file name or path to validate + * @returns {{ valid: true } | { valid: false, reason: string }} Validation result + * + * @example + * validateFileName("App.tsx") // { valid: true } + * validateFileName("entry.tsx") // { valid: false, reason: "..." } + */ +export const validateFileName = (fileName) => { + if (typeof fileName !== "string" || fileName.length === 0) { + return { valid: false, reason: "File name must be a non-empty string" }; + } + + // Check each forbidden pattern + const matchedPattern = forbiddenPatterns.find((pattern) => + pattern.test(fileName), + ); + if (matchedPattern) { + return { + valid: false, + reason: `File "${fileName}" matches forbidden pattern: ${matchedPattern.toString()}`, + }; + } + + return { valid: true }; +}; + +// ----------------------------------------------------------------------------- +// Bundle Validation +// ----------------------------------------------------------------------------- + +/** + * Validate an entire bundle of files against size and count limits. + * Throws structured errors for validation failures. + * + * @param {Array<{ path: string, content: string | Buffer, size?: number }>} files - Array of file entries + * @param {Object} [options] - Validation options + * @param {number} [options.maxSize=5242880] - Maximum total bundle size in bytes (default 5MB) + * @param {number} [options.maxFiles=100] - Maximum number of files (default 100) + * @returns {{ valid: true, totalSize: number, fileCount: number }} Validation result with metrics + * @throws {Error} FORBIDDEN_FILE_NAME if any file path matches a forbidden pattern or files is not an array + * @throws {Error} BUNDLE_TOO_LARGE if total bundle size exceeds maxSize limit + * @throws {Error} TOO_MANY_FILES if file count exceeds maxFiles limit + * + * @example + * const files = [{ path: "App.tsx", content: "...", size: 1000 }]; + * validateBundle(files); // { valid: true, totalSize: 1000, fileCount: 1 } + */ +export const validateBundle = ( + files, + { maxSize = defaultMaxSize, maxFiles = defaultMaxFiles } = {}, +) => { + // Validate input + if (!Array.isArray(files)) { + throw createError({ + ...vibeFileErrors.ForbiddenFileName, + message: "Files must be an array", + }); + } + + // Check file count limit + if (files.length > maxFiles) { + throw createError({ + ...vibeFileErrors.TooManyFiles, + message: `Bundle contains ${files.length} files, exceeds limit of ${maxFiles}`, + }); + } + + const invalidFile = files + .map((file) => ({ + file, + validation: validateFileName(file.path), + })) + .find(({ validation }) => !validation.valid); + + if (invalidFile) { + throw createError({ + ...vibeFileErrors.ForbiddenFileName, + message: invalidFile.validation.reason, + }); + } + + const totalSize = files.reduce( + (sum, file) => sum + (file.size ?? getContentSize(file.content)), + 0, + ); + + // Check total size limit + if (totalSize > maxSize) { + const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(1); + const actualSizeMB = (totalSize / (1024 * 1024)).toFixed(2); + throw createError({ + ...vibeFileErrors.BundleTooLarge, + message: `Bundle size ${actualSizeMB}MB exceeds limit of ${maxSizeMB}MB`, + }); + } + + return { + valid: true, + totalSize, + fileCount: files.length, + }; +}; + +// ----------------------------------------------------------------------------- +// File Entry Creation +// ----------------------------------------------------------------------------- + +/** + * Get the byte size of content (string or Buffer). + * + * @param {string | Buffer} content - The file content + * @returns {number} Size in bytes + */ +const getContentSize = (content) => { + if (Buffer.isBuffer(content)) { + return content.length; + } + // For strings, use Buffer to get accurate byte length (handles UTF-8) + return Buffer.byteLength(content, "utf8"); +}; + +/** + * Threshold for non-printable character ratio to classify as binary. + * Files with more than 10% non-printable characters are considered binary. + */ +const binaryThreshold = 0.1; + +/** + * Sample size for binary detection (in bytes). + * Larger samples improve accuracy but cost more performance. + */ +const sampleSize = 4000; + +/** + * Check if a buffer sample contains binary content. + * Binary is detected by presence of null bytes or high concentration + * of non-printable characters (excluding common whitespace). + * + * @param {Buffer} sample - Buffer sample to analyze + * @returns {boolean} True if sample appears to be binary + */ +const isSampleBinary = (sample) => { + if (!sample || sample.length === 0) { + return false; + } + + // Null byte is a strong binary indicator + if (sample.includes(0)) { + return true; + } + + // Count non-printable chars (excluding tab, newline, carriage return) + const nonPrintable = sample.reduce( + (count, byte) => + count + (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13 ? 1 : 0), + 0, + ); + + // High ratio of non-printable chars indicates binary + return nonPrintable / sample.length > binaryThreshold; +}; + +/** + * Determine if content is binary based on type or content analysis. + * Samples from beginning, middle, and end to catch files with + * text headers but binary body (e.g., some archive formats). + * + * @param {string | Buffer} content - The file content + * @returns {"text" | "binary"} Content type + */ +const getContentType = (content) => { + // Strings are always text + if (typeof content === "string") { + return "text"; + } + + if (!Buffer.isBuffer(content)) { + return "text"; + } + + const len = content.length; + if (len === 0) { + return "text"; + } + + // Sample from beginning + const startSample = content.slice(0, Math.min(sampleSize, len)); + if (isSampleBinary(startSample)) { + return "binary"; + } + + // For small files, beginning sample is sufficient + if (len <= sampleSize) { + return "text"; + } + + // Sample from middle + const midStart = Math.floor(len / 2) - Math.floor(sampleSize / 2); + const midEnd = midStart + sampleSize; + const midSample = content.slice(Math.max(0, midStart), Math.min(len, midEnd)); + if (isSampleBinary(midSample)) { + return "binary"; + } + + // Sample from end + const endSample = content.slice(Math.max(0, len - sampleSize)); + if (isSampleBinary(endSample)) { + return "binary"; + } + + return "text"; +}; + +/** + * Create a standardized file entry with metadata. + * + * @param {Object} params - File parameters + * @param {string} params.path - Relative file path + * @param {string | Buffer} params.content - File content + * @returns {{ path: string, content: string | Buffer, size: number, type: "text" | "binary" }} File entry with metadata + * + * @example + * createFileEntry({ path: "App.tsx", content: "export const App = () =>
Hello
" }) + * // { path: "App.tsx", content: "...", size: 41, type: "text" } + */ +export const createFileEntry = ({ path, content }) => { + const size = getContentSize(content); + const type = getContentType(content); + + return { + path, + content, + size, + type, + }; +}; + +// ----------------------------------------------------------------------------- +// File Collection from AI Output +// ----------------------------------------------------------------------------- + +/** + * Common language specifiers used in markdown code blocks. + * These should be skipped when parsing for file names. + */ +const languageSpecifiers = new Set([ + // Common languages + "javascript", + "typescript", + "python", + "ruby", + "go", + "rust", + "java", + "csharp", + "cpp", + "c", + "php", + "swift", + "kotlin", + "scala", + "shell", + "bash", + "sh", + "zsh", + "powershell", + "sql", + "graphql", + // Short forms + "js", + "ts", + "tsx", + "jsx", + "py", + "rb", + "rs", + "cs", + // Web/markup + "html", + "css", + "scss", + "sass", + "less", + "xml", + "yaml", + "yml", + "json", + "toml", + "ini", + "markdown", + "md", + // Other + "text", + "plaintext", + "plain", + "diff", + "dockerfile", + "makefile", +]); + +/** + * Parse AI-generated output that may contain file markers. + * Supports format: ```filename.ext\n...content...\n``` + * + * SECURITY: Validates paths to prevent path traversal attacks from + * AI-generated output. Malicious paths are silently skipped. + * + * @param {string} output - Raw AI output text + * @returns {Array<{ path: string, content: string }>} Parsed files (with safe paths only) + */ +const parseMarkedFiles = (output) => { + // Pattern matches code blocks with optional filename + // Format: ```filename.ext\ncontent\n``` or ```lang:filename.ext\ncontent\n``` + const codeBlockRegex = /```(?:(\w+):)?([^\n`]+)?\n([\s\S]*?)```/g; + + return [...output.matchAll(codeBlockRegex)] + .map((match) => ({ + filename: match[2], + content: match[3], + })) + .filter( + ({ filename }) => + typeof filename === "string" && filename.trim().length > 0, + ) + .map(({ filename, content }) => ({ + cleanFilename: filename.trim(), + content, + })) + .filter(({ cleanFilename }) => { + const lowerFilename = cleanFilename.toLowerCase(); + const isLanguageSpecifier = + !cleanFilename.includes(".") && + !cleanFilename.includes("/") && + !cleanFilename.includes("\\") && + languageSpecifiers.has(lowerFilename); + return !isLanguageSpecifier; + }) + .filter(({ cleanFilename }) => isPathSafe(cleanFilename).safe) + .map(({ cleanFilename, content }) => ({ + path: cleanFilename, + content: String(content ?? "").trimEnd(), + })); +}; + +/** + * Convert AI generation output to standardized file entries. + * Handles both single file and multiple file output formats. + * + * @param {string | Object | Array} generatedOutput - AI generation output + * - string: Raw text, possibly with code block markers + * - Object: { files: Array } or single file { path, content } + * - Array: Array of file objects + * @returns {Array<{ path: string, content: string | Buffer, size: number, type: "text" | "binary" }>} File entries + * + * @example + * // From structured output + * collectGeneratedFiles({ files: [{ path: "App.tsx", content: "..." }] }) + * + * // From raw AI text with markers + * collectGeneratedFiles("```App.tsx\nexport const App = () => ...\n```") + */ +export const collectGeneratedFiles = (generatedOutput) => { + // Handle null/undefined + if (generatedOutput == null) { + return []; + } + + // Handle array directly + if (Array.isArray(generatedOutput)) { + return generatedOutput.map((file) => createFileEntry(file)); + } + + // Handle object with files property + if (typeof generatedOutput === "object" && generatedOutput !== null) { + // Check for files array property + if (Array.isArray(generatedOutput.files)) { + return generatedOutput.files.map((file) => createFileEntry(file)); + } + + // Check for single file object with path and content + if ( + typeof generatedOutput.path === "string" && + generatedOutput.content !== undefined + ) { + return [createFileEntry(generatedOutput)]; + } + + // Empty or unrecognized object + return []; + } + + // Handle string output - try to parse marked files + if (typeof generatedOutput === "string") { + const parsedFiles = parseMarkedFiles(generatedOutput); + + if (parsedFiles.length > 0) { + return parsedFiles.map((file) => createFileEntry(file)); + } + + // Single file without markers - return empty (caller should specify path) + return []; + } + + return []; +}; + +// ----------------------------------------------------------------------------- +// Bundle Size Calculation +// ----------------------------------------------------------------------------- + +/** + * Get file extension from path. + * + * @param {string} filePath - File path + * @returns {string} Extension including dot, or empty string + */ +const getExtension = (filePath) => { + const lastDot = filePath.lastIndexOf("."); + const lastSlash = Math.max( + filePath.lastIndexOf("/"), + filePath.lastIndexOf("\\"), + ); + + if (lastDot > lastSlash && lastDot > 0) { + return filePath.slice(lastDot).toLowerCase(); + } + return ""; +}; + +/** + * Calculate total bundle size with breakdown by file extension. + * + * @param {Array<{ path: string, content?: string | Buffer, size?: number }>} files - Array of file entries + * @returns {{ totalSize: number, fileCount: number, breakdown: Record }} Size metrics + * + * @example + * calculateBundleSize([ + * { path: "App.tsx", size: 1000 }, + * { path: "styles.css", size: 500 } + * ]) + * // { totalSize: 1500, fileCount: 2, breakdown: { ".tsx": 1000, ".css": 500 } } + */ +export const calculateBundleSize = (files) => { + if (!Array.isArray(files)) { + return { totalSize: 0, fileCount: 0, breakdown: {} }; + } + + const metrics = files.reduce( + (acc, file) => { + const size = file.size ?? getContentSize(file.content ?? ""); + const ext = getExtension(file.path); + const key = typeof ext === "string" && ext.length > 0 ? ext : "(no ext)"; + return { + totalSize: acc.totalSize + size, + breakdown: { + ...acc.breakdown, + [key]: (acc.breakdown[key] ?? 0) + size, + }, + }; + }, + { totalSize: 0, breakdown: {} }, + ); + + return { + totalSize: metrics.totalSize, + fileCount: files.length, + breakdown: metrics.breakdown, + }; +}; + +// ----------------------------------------------------------------------------- +// Exports +// ----------------------------------------------------------------------------- + +// Export error definitions for external use +export { vibeFileErrors }; + +// Export constants for testing/configuration +export const defaults = { + maxSize: defaultMaxSize, + maxFiles: defaultMaxFiles, + forbiddenPatterns: forbiddenPatterns, +}; diff --git a/lib/vibe-files.test.js b/lib/vibe-files.test.js new file mode 100644 index 0000000..b3cc695 --- /dev/null +++ b/lib/vibe-files.test.js @@ -0,0 +1,933 @@ +/** + * Tests for vibe-files module. + * Uses Riteway assertion format with Vitest. + */ + +import { assert } from "riteway/vitest"; +import { describe, test } from "vitest"; + +import { + validateFileName, + validateBundle, + createFileEntry, + collectGeneratedFiles, + calculateBundleSize, + vibeFileErrors, + defaults, +} from "./vibe-files.js"; + +// ----------------------------------------------------------------------------- +// validateFileName Tests +// ----------------------------------------------------------------------------- + +describe("validateFileName", () => { + test("rejects forbidden file names - entry.tsx", () => { + assert({ + given: "entry.tsx (reserved by Vibecodr runtime)", + should: "return invalid", + actual: validateFileName("entry.tsx").valid, + expected: false, + }); + }); + + test("rejects forbidden file names - _vibecodr__ prefix", () => { + assert({ + given: "a file starting with _vibecodr__", + should: "return invalid", + actual: validateFileName("_vibecodr__config.js").valid, + expected: false, + }); + }); + + test("rejects forbidden file names - __VCSHIM prefix", () => { + assert({ + given: "a file starting with __VCSHIM", + should: "return invalid", + actual: validateFileName("__VCSHIM_runtime.js").valid, + expected: false, + }); + }); + + test("rejects forbidden file names - node_modules path", () => { + assert({ + given: "a path inside node_modules", + should: "return invalid", + actual: validateFileName("node_modules/react/index.js").valid, + expected: false, + }); + }); + + test("rejects forbidden file names - package.json", () => { + assert({ + given: "package.json", + should: "return invalid", + actual: validateFileName("package.json").valid, + expected: false, + }); + }); + + test("rejects forbidden file names - package-lock.json", () => { + assert({ + given: "package-lock.json", + should: "return invalid", + actual: validateFileName("package-lock.json").valid, + expected: false, + }); + }); + + test("rejects forbidden file names - .env files", () => { + assert({ + given: ".env", + should: "return invalid", + actual: validateFileName(".env").valid, + expected: false, + }); + + assert({ + given: ".env.local", + should: "return invalid", + actual: validateFileName(".env.local").valid, + expected: false, + }); + + assert({ + given: ".env.production", + should: "return invalid", + actual: validateFileName(".env.production").valid, + expected: false, + }); + }); + + test("accepts valid file names", () => { + assert({ + given: "App.tsx", + should: "return valid", + actual: validateFileName("App.tsx").valid, + expected: true, + }); + }); + + test("accepts valid file names - various extensions", () => { + const validFiles = [ + "index.html", + "styles.css", + "main.js", + "utils.ts", + "components/Button.tsx", + "assets/logo.png", + "README.md", + ]; + + for (const file of validFiles) { + assert({ + given: `valid file "${file}"`, + should: "return valid", + actual: validateFileName(file).valid, + expected: true, + }); + } + }); + + test("accepts entry.ts (not entry.tsx)", () => { + assert({ + given: "entry.ts (not the reserved entry.tsx)", + should: "return valid", + actual: validateFileName("entry.ts").valid, + expected: true, + }); + }); + + test("rejects empty file names", () => { + assert({ + given: "empty string", + should: "return invalid", + actual: validateFileName("").valid, + expected: false, + }); + }); + + test("provides reason for invalid files", () => { + const result = validateFileName("entry.tsx"); + + assert({ + given: "invalid file name", + should: "include reason in result", + actual: typeof result.reason === "string" && result.reason.length > 0, + expected: true, + }); + }); +}); + +// ----------------------------------------------------------------------------- +// validateBundle Tests +// ----------------------------------------------------------------------------- + +describe("validateBundle", () => { + test("validates a valid bundle", () => { + const files = [ + { path: "App.tsx", content: "export const App = () =>
" }, + { path: "index.html", content: "" }, + ]; + + const result = validateBundle(files); + + assert({ + given: "a bundle with valid files under limits", + should: "return valid true", + actual: result.valid, + expected: true, + }); + }); + + test("calculates total size correctly", () => { + const files = [ + { path: "a.txt", content: "hello" }, // 5 bytes + { path: "b.txt", content: "world" }, // 5 bytes + ]; + + const result = validateBundle(files); + + assert({ + given: "files with known content sizes", + should: "return correct total size", + actual: result.totalSize, + expected: 10, + }); + }); + + test("returns file count", () => { + const files = [ + { path: "a.txt", content: "a" }, + { path: "b.txt", content: "b" }, + { path: "c.txt", content: "c" }, + ]; + + const result = validateBundle(files); + + assert({ + given: "3 files", + should: "return fileCount of 3", + actual: result.fileCount, + expected: 3, + }); + }); + + test("rejects bundles over size limit", () => { + // Create files totaling > 5MB + const largeContent = "x".repeat(3 * 1024 * 1024); // 3MB each + const files = [ + { path: "big1.txt", content: largeContent }, + { path: "big2.txt", content: largeContent }, + ]; + + let thrown = false; + let errorCode = null; + + try { + validateBundle(files); + } catch (error) { + thrown = true; + // error-causes stores code in error.cause.code + errorCode = error.cause?.code; + } + + assert({ + given: "files totaling > 5MB", + should: "throw an error", + actual: thrown, + expected: true, + }); + + assert({ + given: "files totaling > 5MB", + should: "throw BUNDLE_TOO_LARGE error", + actual: errorCode, + expected: "BUNDLE_TOO_LARGE", + }); + }); + + test("rejects bundles with custom size limit", () => { + const files = [{ path: "test.txt", content: "x".repeat(1000) }]; + + let thrown = false; + + try { + validateBundle(files, { maxSize: 500 }); + } catch { + thrown = true; + } + + assert({ + given: "files exceeding custom maxSize of 500 bytes", + should: "throw an error", + actual: thrown, + expected: true, + }); + }); + + test("rejects bundles with too many files", () => { + // Create > 100 files + const files = Array.from({ length: 101 }, (_, i) => ({ + path: `file${i}.txt`, + content: "x", + })); + + let thrown = false; + let errorCode = null; + + try { + validateBundle(files); + } catch (error) { + thrown = true; + // error-causes stores code in error.cause.code + errorCode = error.cause?.code; + } + + assert({ + given: "> 100 files", + should: "throw an error", + actual: thrown, + expected: true, + }); + + assert({ + given: "> 100 files", + should: "throw TOO_MANY_FILES error", + actual: errorCode, + expected: "TOO_MANY_FILES", + }); + }); + + test("rejects bundles with custom file limit", () => { + const files = Array.from({ length: 11 }, (_, i) => ({ + path: `file${i}.txt`, + content: "x", + })); + + let thrown = false; + + try { + validateBundle(files, { maxFiles: 10 }); + } catch { + thrown = true; + } + + assert({ + given: "11 files with maxFiles of 10", + should: "throw an error", + actual: thrown, + expected: true, + }); + }); + + test("rejects bundles containing forbidden file names", () => { + const files = [ + { path: "App.tsx", content: "valid" }, + { path: "entry.tsx", content: "forbidden" }, + ]; + + let thrown = false; + let errorCode = null; + + try { + validateBundle(files); + } catch (error) { + thrown = true; + // error-causes stores code in error.cause.code + errorCode = error.cause?.code; + } + + assert({ + given: "bundle containing entry.tsx", + should: "throw an error", + actual: thrown, + expected: true, + }); + + assert({ + given: "bundle containing entry.tsx", + should: "throw FORBIDDEN_FILE_NAME error", + actual: errorCode, + expected: "FORBIDDEN_FILE_NAME", + }); + }); + + test("accepts empty bundle", () => { + const result = validateBundle([]); + + assert({ + given: "empty array of files", + should: "return valid with zero counts", + actual: result, + expected: { valid: true, totalSize: 0, fileCount: 0 }, + }); + }); + + test("uses size property if provided", () => { + const files = [{ path: "test.txt", content: "short", size: 1000 }]; + + const result = validateBundle(files); + + assert({ + given: "file with explicit size property", + should: "use provided size instead of content length", + actual: result.totalSize, + expected: 1000, + }); + }); +}); + +// ----------------------------------------------------------------------------- +// createFileEntry Tests +// ----------------------------------------------------------------------------- + +describe("createFileEntry", () => { + test("creates file entry with correct path", () => { + const entry = createFileEntry({ path: "App.tsx", content: "content" }); + + assert({ + given: "path and content", + should: "preserve the path", + actual: entry.path, + expected: "App.tsx", + }); + }); + + test("calculates size from string content", () => { + const entry = createFileEntry({ path: "test.txt", content: "hello" }); + + assert({ + given: "string content 'hello'", + should: "calculate size as 5", + actual: entry.size, + expected: 5, + }); + }); + + test("calculates size from Buffer content", () => { + const buffer = Buffer.from("hello"); + const entry = createFileEntry({ path: "test.bin", content: buffer }); + + assert({ + given: "Buffer content", + should: "calculate size from buffer length", + actual: entry.size, + expected: 5, + }); + }); + + test("handles UTF-8 characters correctly", () => { + const entry = createFileEntry({ path: "test.txt", content: "héllo" }); + + assert({ + given: "UTF-8 content with accented character", + should: "calculate byte size correctly (6 bytes for héllo)", + actual: entry.size, + expected: 6, // 'é' is 2 bytes in UTF-8 + }); + }); + + test("detects text type for string content", () => { + const entry = createFileEntry({ + path: "test.txt", + content: "text content", + }); + + assert({ + given: "string content", + should: "set type to text", + actual: entry.type, + expected: "text", + }); + }); + + test("detects text type for text Buffer", () => { + const buffer = Buffer.from("text content"); + const entry = createFileEntry({ path: "test.txt", content: buffer }); + + assert({ + given: "Buffer without null bytes", + should: "set type to text", + actual: entry.type, + expected: "text", + }); + }); + + test("detects binary type for Buffer with null bytes", () => { + const buffer = Buffer.from([0x00, 0x01, 0x02, 0x00]); + const entry = createFileEntry({ path: "test.bin", content: buffer }); + + assert({ + given: "Buffer with null bytes", + should: "set type to binary", + actual: entry.type, + expected: "binary", + }); + }); + + test("detects binary in middle of file (not just header)", () => { + // Create a buffer with text header, null bytes in middle + const textHeader = Buffer.from("This is a text header\n".repeat(500)); + const binaryMiddle = Buffer.alloc(1000, 0); // null bytes + const textFooter = Buffer.from("This is a text footer\n".repeat(100)); + const buffer = Buffer.concat([textHeader, binaryMiddle, textFooter]); + + const entry = createFileEntry({ path: "test.bin", content: buffer }); + + assert({ + given: "Buffer with binary content in middle (text header)", + should: "detect as binary by sampling middle section", + actual: entry.type, + expected: "binary", + }); + }); + + test("detects binary at end of file", () => { + // Create a buffer with text at start, null bytes at end + const textStart = Buffer.from("Normal text content\n".repeat(1000)); + const binaryEnd = Buffer.alloc(5000, 0); // null bytes at end + const buffer = Buffer.concat([textStart, binaryEnd]); + + const entry = createFileEntry({ path: "test.bin", content: buffer }); + + assert({ + given: "Buffer with binary content at end only", + should: "detect as binary by sampling end section", + actual: entry.type, + expected: "binary", + }); + }); + + test("detects text for pure text content", () => { + // Large text-only file + const textContent = "Hello world! This is pure text.\n".repeat(1000); + const buffer = Buffer.from(textContent); + + const entry = createFileEntry({ path: "test.txt", content: buffer }); + + assert({ + given: "Large text-only buffer", + should: "detect as text", + actual: entry.type, + expected: "text", + }); + }); + + test("preserves content in entry", () => { + const content = "const x = 1;"; + const entry = createFileEntry({ path: "test.js", content }); + + assert({ + given: "content string", + should: "preserve content in entry", + actual: entry.content, + expected: content, + }); + }); +}); + +// ----------------------------------------------------------------------------- +// collectGeneratedFiles Tests +// ----------------------------------------------------------------------------- + +describe("collectGeneratedFiles", () => { + test("handles null input", () => { + assert({ + given: "null input", + should: "return empty array", + actual: collectGeneratedFiles(null), + expected: [], + }); + }); + + test("handles undefined input", () => { + assert({ + given: "undefined input", + should: "return empty array", + actual: collectGeneratedFiles(undefined), + expected: [], + }); + }); + + test("handles array of file objects", () => { + const input = [ + { path: "App.tsx", content: "code1" }, + { path: "index.html", content: "code2" }, + ]; + + const result = collectGeneratedFiles(input); + + assert({ + given: "array of file objects", + should: "return same number of entries", + actual: result.length, + expected: 2, + }); + + assert({ + given: "array of file objects", + should: "create proper file entries with size", + actual: result[0].size, + expected: 5, + }); + }); + + test("handles object with files array", () => { + const input = { + files: [ + { path: "App.tsx", content: "code" }, + { path: "style.css", content: "css" }, + ], + }; + + const result = collectGeneratedFiles(input); + + assert({ + given: "object with files array", + should: "extract files from the array", + actual: result.length, + expected: 2, + }); + + assert({ + given: "object with files array", + should: "preserve file paths", + actual: result.map((f) => f.path), + expected: ["App.tsx", "style.css"], + }); + }); + + test("handles single file object", () => { + const input = { path: "App.tsx", content: "single file content" }; + + const result = collectGeneratedFiles(input); + + assert({ + given: "single file object with path and content", + should: "return array with one entry", + actual: result.length, + expected: 1, + }); + + assert({ + given: "single file object", + should: "preserve path", + actual: result[0].path, + expected: "App.tsx", + }); + }); + + test("handles string with code block markers", () => { + const input = `Here is the code: +\`\`\`App.tsx +export const App = () =>
Hello
+\`\`\` +`; + + const result = collectGeneratedFiles(input); + + assert({ + given: "string with code block and filename", + should: "extract the file", + actual: result.length, + expected: 1, + }); + + assert({ + given: "string with code block", + should: "use filename from code block", + actual: result[0].path, + expected: "App.tsx", + }); + + assert({ + given: "string with code block", + should: "extract content correctly", + actual: result[0].content, + expected: "export const App = () =>
Hello
", + }); + }); + + test("handles multiple code blocks", () => { + const input = ` +\`\`\`App.tsx +component code +\`\`\` + +\`\`\`styles.css +css code +\`\`\` +`; + + const result = collectGeneratedFiles(input); + + assert({ + given: "string with multiple code blocks", + should: "extract all files", + actual: result.length, + expected: 2, + }); + + assert({ + given: "string with multiple code blocks", + should: "preserve paths for all files", + actual: result.map((f) => f.path), + expected: ["App.tsx", "styles.css"], + }); + }); + + test("ignores generic code blocks without filenames", () => { + const input = ` +\`\`\`javascript +// This is just example code +\`\`\` + +\`\`\`App.tsx +real file content +\`\`\` +`; + + const result = collectGeneratedFiles(input); + + assert({ + given: "string with generic code block and named code block", + should: "only extract named file", + actual: result.length, + expected: 1, + }); + + assert({ + given: "string with mixed code blocks", + should: "extract the named file", + actual: result[0].path, + expected: "App.tsx", + }); + }); + + test("handles empty object", () => { + assert({ + given: "empty object", + should: "return empty array", + actual: collectGeneratedFiles({}), + expected: [], + }); + }); + + test("handles string without code blocks", () => { + assert({ + given: "plain string without markers", + should: "return empty array", + actual: collectGeneratedFiles("just plain text"), + expected: [], + }); + }); +}); + +// ----------------------------------------------------------------------------- +// calculateBundleSize Tests +// ----------------------------------------------------------------------------- + +describe("calculateBundleSize", () => { + test("calculates total size", () => { + const files = [ + { path: "a.txt", content: "hello" }, // 5 bytes + { path: "b.txt", content: "world!" }, // 6 bytes + ]; + + const result = calculateBundleSize(files); + + assert({ + given: "files with content", + should: "sum up total size", + actual: result.totalSize, + expected: 11, + }); + }); + + test("uses size property if available", () => { + const files = [ + { path: "a.txt", size: 100 }, + { path: "b.txt", size: 200 }, + ]; + + const result = calculateBundleSize(files); + + assert({ + given: "files with size property", + should: "use provided sizes", + actual: result.totalSize, + expected: 300, + }); + }); + + test("returns file count", () => { + const files = [ + { path: "a.txt", size: 1 }, + { path: "b.txt", size: 1 }, + { path: "c.txt", size: 1 }, + ]; + + const result = calculateBundleSize(files); + + assert({ + given: "3 files", + should: "return fileCount of 3", + actual: result.fileCount, + expected: 3, + }); + }); + + test("provides breakdown by extension", () => { + const files = [ + { path: "app.tsx", size: 100 }, + { path: "utils.tsx", size: 50 }, + { path: "styles.css", size: 200 }, + ]; + + const result = calculateBundleSize(files); + + assert({ + given: "files with different extensions", + should: "group sizes by extension", + actual: result.breakdown[".tsx"], + expected: 150, + }); + + assert({ + given: "files with different extensions", + should: "include all extensions", + actual: result.breakdown[".css"], + expected: 200, + }); + }); + + test("handles files without extension", () => { + const files = [ + { path: "Makefile", size: 100 }, + { path: "Dockerfile", size: 50 }, + ]; + + const result = calculateBundleSize(files); + + assert({ + given: "files without extension", + should: "group under (no ext)", + actual: result.breakdown["(no ext)"], + expected: 150, + }); + }); + + test("handles empty array", () => { + const result = calculateBundleSize([]); + + assert({ + given: "empty array", + should: "return zero totals", + actual: result, + expected: { totalSize: 0, fileCount: 0, breakdown: {} }, + }); + }); + + test("handles non-array input", () => { + const result = calculateBundleSize(null); + + assert({ + given: "null input", + should: "return zero totals", + actual: result, + expected: { totalSize: 0, fileCount: 0, breakdown: {} }, + }); + }); + + test("handles nested paths", () => { + const files = [ + { path: "src/components/Button.tsx", size: 100 }, + { path: "src/utils/helpers.ts", size: 50 }, + ]; + + const result = calculateBundleSize(files); + + assert({ + given: "nested paths", + should: "extract extension correctly", + actual: result.breakdown[".tsx"], + expected: 100, + }); + + assert({ + given: "nested paths", + should: "handle multiple extensions", + actual: result.breakdown[".ts"], + expected: 50, + }); + }); +}); + +// ----------------------------------------------------------------------------- +// Error Types and Constants Tests +// ----------------------------------------------------------------------------- + +describe("vibeFileErrors", () => { + test("exports ForbiddenFileName error definition", () => { + assert({ + given: "vibeFileErrors export", + should: "contain ForbiddenFileName with correct code", + actual: vibeFileErrors.ForbiddenFileName.code, + expected: "FORBIDDEN_FILE_NAME", + }); + }); + + test("exports BundleTooLarge error definition", () => { + assert({ + given: "vibeFileErrors export", + should: "contain BundleTooLarge with correct code", + actual: vibeFileErrors.BundleTooLarge.code, + expected: "BUNDLE_TOO_LARGE", + }); + }); + + test("exports TooManyFiles error definition", () => { + assert({ + given: "vibeFileErrors export", + should: "contain TooManyFiles with correct code", + actual: vibeFileErrors.TooManyFiles.code, + expected: "TOO_MANY_FILES", + }); + }); +}); + +describe("defaults", () => { + test("exports maxSize constant", () => { + assert({ + given: "defaults export", + should: "contain maxSize", + actual: defaults.maxSize, + expected: 5 * 1024 * 1024, + }); + }); + + test("exports maxFiles constant", () => { + assert({ + given: "defaults export", + should: "contain maxFiles", + actual: defaults.maxFiles, + expected: 100, + }); + }); + + test("exports forbiddenPatterns array", () => { + assert({ + given: "defaults export", + should: "contain forbiddenPatterns as array", + actual: Array.isArray(defaults.forbiddenPatterns), + expected: true, + }); + + assert({ + given: "defaults.forbiddenPatterns", + should: "contain entry.tsx pattern", + actual: defaults.forbiddenPatterns.some((p) => p.test("entry.tsx")), + expected: true, + }); + }); +}); diff --git a/lib/vibe-prompt.d.ts b/lib/vibe-prompt.d.ts new file mode 100644 index 0000000..53e17c8 --- /dev/null +++ b/lib/vibe-prompt.d.ts @@ -0,0 +1,35 @@ +export const platformLimits: Record; + +export const forbiddenPatterns: string[]; + +export const validEntryExtensions: string[]; + +export const vibeSystemPrompt: string; +export const vibeSystemPromptCompact: string; + +export interface BuildVibePromptConstraints { + tier?: "free" | "creator" | "pro"; + compact?: boolean; + maxTokens?: number; + requiredFeatures?: string[]; + style?: string | null; +} + +export type BuiltVibePrompt = { + success: true; + fullPrompt: string; + [key: string]: unknown; +}; + +export function buildVibePrompt(params: { + userPrompt: string; + constraints?: BuildVibePromptConstraints; +}): BuiltVibePrompt; + +export const promptTemplates: Record; + +export function buildFromTemplate( + templateName: string, + userPrompt: string, + constraints?: BuildVibePromptConstraints, +): BuiltVibePrompt; diff --git a/lib/vibe-prompt.js b/lib/vibe-prompt.js new file mode 100644 index 0000000..62d4daf --- /dev/null +++ b/lib/vibe-prompt.js @@ -0,0 +1,386 @@ +/** + * Vibe Prompt Construction Module + * + * Builds optimized prompts for AI code generation targeting + * the Vibecodr platform. Includes platform constraints, code + * structure templates, and quality requirements. + * + * @module vibe-prompt + */ + +import { errorCauses, createError } from "error-causes"; + +// ----------------------------------------------------------------------------- +// Error Definitions +// ----------------------------------------------------------------------------- + +const [promptErrors] = errorCauses({ + InvalidPrompt: { + code: "INVALID_PROMPT", + message: "Prompt is required and must be a non-empty string", + }, + PromptTooLong: { + code: "PROMPT_TOO_LONG", + message: "Prompt exceeds maximum token limit", + }, +}); + +const { InvalidPrompt, PromptTooLong } = promptErrors; + +// ----------------------------------------------------------------------------- +// Platform Constraints +// ----------------------------------------------------------------------------- + +/** + * File and bundle size limits by tier. + * Used to inform AI about platform constraints. + */ +export const platformLimits = { + free: { + maxBundleSize: 10 * 1024 * 1024, // 10MB + maxFiles: 100, + targetBundleSize: 5 * 1024 * 1024, // 5MB recommended + }, + creator: { + maxBundleSize: 50 * 1024 * 1024, // 50MB + maxFiles: 500, + targetBundleSize: 10 * 1024 * 1024, // 10MB recommended + }, + pro: { + maxBundleSize: 100 * 1024 * 1024, // 100MB + maxFiles: 2000, + targetBundleSize: 25 * 1024 * 1024, // 25MB recommended + }, +}; + +/** + * Forbidden file patterns that should never be generated. + */ +export const forbiddenPatterns = [ + "entry.tsx", // Reserved by platform + "_vibecodr_*", // Reserved prefix + "__VCSHIM*", // Reserved prefix + "node_modules/", // Not allowed in bundle + "package.json", // Not allowed in bundle + ".env", // Security risk + ".env.*", // Security risk +]; + +/** + * Valid entry point extensions for vibes. + */ +export const validEntryExtensions = [ + ".html", + ".htm", + ".js", + ".jsx", + ".ts", + ".tsx", +]; + +// ----------------------------------------------------------------------------- +// System Prompt (Full Version) +// ----------------------------------------------------------------------------- + +/** + * Complete system prompt for AI code generation. + * Derived from vibe-generation-prompt.md with platform constraints. + */ +export const vibeSystemPrompt = `You are an expert React/TypeScript developer creating interactive apps called "vibes" for the Vibecodr platform. Your output will be compiled and run in a sandboxed browser iframe. + +## Platform Constraints (MUST FOLLOW) + +### Runner & Entry Point +- Use \`client-static\` runner (default) - DO NOT use webcontainer unless Node.js APIs are required +- Valid entry extensions: .html, .htm, .js, .jsx, .ts, .tsx +- Recommended entry: \`index.tsx\` or \`App.tsx\` for React, \`index.html\` for static + +### File Limits +- Max bundle size: 10MB (free), 50MB (creator), 100MB (pro) +- Max files: 100 (free), 500 (creator), 2000 (pro) +- Keep bundles under 5MB for optimal load times + +### Code Structure (React) +\`\`\`tsx +// Simple pattern - auto-rendered by runtime +export default function App() { + return
Your vibe content
; +} + +// With bridge access for host communication +export default function App({ bridge }) { + React.useEffect(() => { + bridge.ready({ capabilities: {} }); + }, []); + return
Ready!
; +} +\`\`\` + +### Available Globals +- \`React\` and \`ReactDOM\` (v18) are pre-loaded +- Standard Web APIs: fetch, Canvas, Web Audio, WebSocket, etc. +- \`window.vibecodrBridge\` for host communication + +### Storage (Available) +NOTE: Storage APIs exist, but this app MUST NOT access \`localStorage\` or \`sessionStorage\` directly. +If the app needs persistence, prefer in-memory state or use \`IndexedDB\` via a small wrapper module. + +### NPM Imports +Any npm package can be imported directly - the platform auto-resolves to esm.sh: +\`\`\`tsx +import confetti from "canvas-confetti"; +import * as THREE from "three"; +import { motion } from "framer-motion"; +import { format } from "date-fns"; +\`\`\` + +### Forbidden Patterns +- NO direct API key embedding (use pulses for secrets) +- NO server-side code in vibes (client-only execution) +- NO direct \`localStorage\` / \`sessionStorage\` access +- NO files named: entry.tsx, _vibecodr_*, __VCSHIM* +- NO node_modules or package.json in bundle + +### Performance Requirements +- Signal ready within 60 seconds (boot timeout) +- Minimize bundle size for faster loading +- Avoid blocking the main thread on load + +## Output Format + +Provide complete, production-ready code. Include: +1. Main component file (e.g., App.tsx or index.tsx) +2. Any additional component files +3. CSS/styles (inline or separate file) +4. Brief manifest suggestion if non-default settings needed + +## Quality Standards +- Clean, readable TypeScript/TSX code +- Proper error handling +- Responsive design (works on mobile) +- Accessible (keyboard navigation, ARIA labels) +- No console errors or warnings`; + +// ----------------------------------------------------------------------------- +// Compact System Prompt (Token-Limited Contexts) +// ----------------------------------------------------------------------------- + +/** + * Compact version of system prompt for token-limited contexts. + * Use when context window is constrained. + */ +export const vibeSystemPromptCompact = `You create interactive React apps called "vibes" for Vibecodr. Output runs in a sandboxed browser iframe. + +RULES: +- Entry: index.tsx or App.tsx (export default function) +- React/ReactDOM pre-loaded globally +- NPM imports work directly: \`import x from 'package-name'\` +- Max 5MB bundle, 100 files +- NO API keys, NO server code +- NO localStorage/sessionStorage access +- NO entry.tsx, NO _vibecodr_*, NO node_modules + +TEMPLATE: +\`\`\`tsx +export default function App() { + return
Your vibe
; +} +\`\`\` + +Output clean, complete, production-ready TSX code.`; + +// ----------------------------------------------------------------------------- +// Prompt Builder +// ----------------------------------------------------------------------------- + +/** + * Estimates token count for a prompt with better Unicode handling. + * Uses byte length for more accurate estimation with multi-byte characters. + * + * Rationale: Character length underestimates tokens for Unicode text + * (e.g., emoji, CJK characters) because tokenizers often split multi-byte + * characters. Using byte length with ~3 bytes per token is more conservative. + * + * @param {string} text - Text to estimate + * @returns {number} - Estimated token count + */ +const estimateTokens = (text) => { + if (!text || typeof text !== "string") { + return 0; + } + // Use byte length for better accuracy with Unicode + // ~3-4 bytes per token on average, use conservative 3 for safety margin + const byteLength = Buffer.byteLength(text, "utf8"); + return Math.ceil(byteLength / 3); +}; + +/** + * Builds a complete prompt for vibe generation. + * Combines system prompt with user prompt and optional constraints. + * + * @param {Object} options - Build options + * @param {string} options.userPrompt - User's description of desired vibe + * @param {Object} [options.constraints] - Additional constraints + * @param {string} [options.constraints.tier='free'] - User tier (free/creator/pro) + * @param {boolean} [options.constraints.compact=false] - Use compact prompt + * @param {number} [options.constraints.maxTokens] - Max tokens for prompt + * @param {string[]} [options.constraints.requiredFeatures] - Features to emphasize + * @param {string} [options.constraints.style] - Visual style preference + * @returns {Object} - Built prompt object + * @throws {Error} - If prompt exceeds token limit + */ +export const buildVibePrompt = ({ userPrompt, constraints = {} }) => { + const { + tier = "free", + compact = false, + maxTokens = 8000, + requiredFeatures = [], + style = null, + } = constraints; + + // Validate user prompt - must be non-empty string after trimming + // This catches whitespace-only prompts like " " which would pass a simple truthy check + const trimmedPrompt = typeof userPrompt === "string" ? userPrompt.trim() : ""; + if (!trimmedPrompt) { + throw createError({ + ...InvalidPrompt, + message: + "userPrompt is required and must be a non-empty string (not just whitespace)", + }); + } + + // Select system prompt based on compact flag + const systemPrompt = compact ? vibeSystemPromptCompact : vibeSystemPrompt; + + // Get tier-specific limits + const limits = platformLimits[tier] || platformLimits.free; + + // Build tier context section + const tierContext = compact + ? "" + : `\n\n## Your Tier Limits +- Max bundle: ${Math.round(limits.maxBundleSize / 1024 / 1024)}MB +- Max files: ${limits.maxFiles} +- Target bundle: <${Math.round(limits.targetBundleSize / 1024 / 1024)}MB for optimal performance`; + + // Build features section if specified + const featuresSection = + requiredFeatures.length > 0 + ? `\n\n## Required Features\n${requiredFeatures.map((f) => `- ${f}`).join("\n")}` + : ""; + + // Build style section if specified + const styleSection = style + ? `\n\n## Visual Style\nFollow this style guidance: ${style}` + : ""; + + // Construct user message + const userMessage = `Create a vibe that: ${userPrompt}${featuresSection}${styleSection}`; + + // Combine full prompt + const fullPrompt = `${systemPrompt}${tierContext}\n\n---\n\n${userMessage}`; + + // Check token limit + const estimatedTokens = estimateTokens(fullPrompt); + if (estimatedTokens > maxTokens) { + throw createError({ + ...PromptTooLong, + message: `Prompt exceeds token limit: ${estimatedTokens} estimated tokens (max: ${maxTokens})`, + }); + } + + return { + success: true, + systemPrompt, + userMessage, + fullPrompt, + metadata: { + tier, + compact, + estimatedTokens, + limits, + hasFeatures: requiredFeatures.length > 0, + hasStyle: !!style, + }, + }; +}; + +// ----------------------------------------------------------------------------- +// Prompt Templates +// ----------------------------------------------------------------------------- + +/** + * Pre-built prompt templates for common vibe types. + * Use these as starting points for specific app categories. + */ +export const promptTemplates = { + game: { + prefix: + "Create an interactive game vibe. Include score tracking, clear win/lose conditions, and intuitive controls.", + requiredFeatures: [ + "Score display", + "Reset/restart functionality", + "Keyboard or touch controls", + ], + }, + visualization: { + prefix: + "Create a data visualization vibe. Focus on clarity, smooth animations, and responsive layout.", + requiredFeatures: [ + "Clear data representation", + "Smooth transitions", + "Legend or labels where appropriate", + ], + }, + tool: { + prefix: + "Create a utility tool vibe. Prioritize usability, clear feedback, and efficient workflows.", + requiredFeatures: [ + "Clear UI feedback", + "Input validation", + "Error handling with user-friendly messages", + ], + }, + creative: { + prefix: + "Create an artistic/creative vibe. Emphasize visual appeal, interactivity, and expressive possibilities.", + requiredFeatures: [ + "Interactive elements", + "Visually engaging design", + "Smooth animations", + ], + }, +}; + +/** + * Builds a prompt using a pre-defined template. + * + * @param {string} templateName - Template key from PROMPT_TEMPLATES + * @param {string} userPrompt - User's specific requirements + * @param {Object} [constraints] - Additional constraints + * @returns {Object} - Built prompt object + */ +export const buildFromTemplate = ( + templateName, + userPrompt, + constraints = {}, +) => { + const template = promptTemplates[templateName]; + + if (!template) { + return buildVibePrompt({ userPrompt, constraints }); + } + + const enhancedPrompt = `${template.prefix}\n\nSpecific requirements: ${userPrompt}`; + + return buildVibePrompt({ + userPrompt: enhancedPrompt, + constraints: { + ...constraints, + requiredFeatures: [ + ...(constraints.requiredFeatures || []), + ...template.requiredFeatures, + ], + }, + }); +}; diff --git a/lib/vibe-prompt.test.js b/lib/vibe-prompt.test.js new file mode 100644 index 0000000..ecfb7dd --- /dev/null +++ b/lib/vibe-prompt.test.js @@ -0,0 +1,637 @@ +import { assert } from "riteway/vitest"; +import { describe, test } from "vitest"; + +import { + buildVibePrompt, + buildFromTemplate, + vibeSystemPrompt, + vibeSystemPromptCompact, + platformLimits, + forbiddenPatterns, + validEntryExtensions, + promptTemplates, +} from "./vibe-prompt.js"; + +// ----------------------------------------------------------------------------- +// buildVibePrompt Tests +// ----------------------------------------------------------------------------- + +describe("buildVibePrompt", () => { + test("returns success with valid user prompt", () => { + const result = buildVibePrompt({ + userPrompt: "Create a bouncing ball animation", + }); + + assert({ + given: "valid user prompt", + should: "return success true", + actual: result.success, + expected: true, + }); + + assert({ + given: "valid user prompt", + should: "include user message", + actual: result.userMessage.includes("bouncing ball"), + expected: true, + }); + }); + + test("includes system prompt in full prompt", () => { + const result = buildVibePrompt({ + userPrompt: "Create a counter app", + }); + + assert({ + given: "valid user prompt", + should: "include system prompt content", + actual: result.fullPrompt.includes("React/TypeScript developer"), + expected: true, + }); + }); + + test("throws on empty user prompt", () => { + let error = null; + try { + buildVibePrompt({ + userPrompt: "", + }); + } catch (e) { + error = e; + } + + assert({ + given: "empty user prompt", + should: "throw error", + actual: error !== null, + expected: true, + }); + + assert({ + given: "empty user prompt", + should: "include INVALID_PROMPT error code", + actual: error?.cause?.code, + expected: "INVALID_PROMPT", + }); + }); + + test("throws on null user prompt", () => { + let error = null; + try { + buildVibePrompt({ + userPrompt: null, + }); + } catch (e) { + error = e; + } + + assert({ + given: "null user prompt", + should: "throw error", + actual: error !== null, + expected: true, + }); + + assert({ + given: "null user prompt", + should: "include INVALID_PROMPT error code", + actual: error?.cause?.code, + expected: "INVALID_PROMPT", + }); + }); + + test("throws on whitespace-only user prompt", () => { + let error = null; + try { + buildVibePrompt({ + userPrompt: " \t\n ", + }); + } catch (e) { + error = e; + } + + assert({ + given: "whitespace-only user prompt", + should: "throw error", + actual: error !== null, + expected: true, + }); + + assert({ + given: "whitespace-only user prompt", + should: "include INVALID_PROMPT error code", + actual: error?.cause?.code, + expected: "INVALID_PROMPT", + }); + }); + + test("estimates more tokens for Unicode text", () => { + // ASCII text: 12 characters + const asciiResult = buildVibePrompt({ + userPrompt: "Hello world!", + constraints: { compact: true }, + }); + + // Unicode text with emoji: same visible characters but more bytes + const unicodeResult = buildVibePrompt({ + userPrompt: "Hello 世界! 🌍", + constraints: { compact: true }, + }); + + // Unicode should have higher token estimate because it uses byte length + assert({ + given: "Unicode text vs ASCII text", + should: "estimate more tokens for Unicode (uses byte length)", + actual: + unicodeResult.metadata.estimatedTokens >= + asciiResult.metadata.estimatedTokens, + expected: true, + }); + }); + + test("estimates more tokens for emoji-heavy text than ASCII of same char length", () => { + // Emoji are 4 bytes each in UTF-8 + // 5 emoji = 5 chars but 20 bytes -> ~7 tokens (20/3) + const emojiResult = buildVibePrompt({ + userPrompt: "🎮🎯🎨🎭🎪", + constraints: { compact: true }, + }); + + // 5 ASCII chars = 5 bytes -> ~2 tokens (5/3) + const asciiResult = buildVibePrompt({ + userPrompt: "games", + constraints: { compact: true }, + }); + + // Both have 5 characters, but emoji should estimate MORE tokens + // because byte-based estimation counts emoji as 4 bytes each + assert({ + given: "emoji prompt vs ASCII prompt of same character count", + should: "estimate more tokens for emoji (byte-based calculation)", + actual: + emojiResult.metadata.estimatedTokens > + asciiResult.metadata.estimatedTokens, + expected: true, + }); + }); + + test("estimates more tokens for CJK characters than ASCII of same char length", () => { + // CJK characters are 3 bytes each in UTF-8 + // 7 CJK chars = 7 chars but 21 bytes -> ~7 tokens (21/3) + const cjkResult = buildVibePrompt({ + userPrompt: "创建一个计数器", + constraints: { compact: true }, + }); + + // 7 ASCII chars = 7 bytes -> ~3 tokens (7/3) + const asciiResult = buildVibePrompt({ + userPrompt: "counter", + constraints: { compact: true }, + }); + + // Both have 7 characters, but CJK should estimate MORE tokens + // because byte-based estimation counts CJK as 3 bytes each + assert({ + given: "CJK prompt vs ASCII prompt of same character count", + should: "estimate more tokens for CJK (byte-based calculation)", + actual: + cjkResult.metadata.estimatedTokens > + asciiResult.metadata.estimatedTokens, + expected: true, + }); + }); + + test("estimates tokens proportionally to byte length", () => { + // Test that token estimation scales with bytes, not characters + // Single emoji: 4 bytes -> ceil(4/3) = 2 tokens + // Two emoji: 8 bytes -> ceil(8/3) = 3 tokens + // Four emoji: 16 bytes -> ceil(16/3) = 6 tokens + const oneEmoji = buildVibePrompt({ + userPrompt: "🎮", + constraints: { compact: true }, + }); + + const fourEmoji = buildVibePrompt({ + userPrompt: "🎮🎮🎮🎮", + constraints: { compact: true }, + }); + + // Four emoji should estimate roughly 4x more tokens than one emoji + // (for the user prompt portion - note system prompt adds constant overhead) + // We just verify that more emoji = more tokens, confirming byte-based scaling + assert({ + given: "4x emoji count", + should: "estimate proportionally more tokens", + actual: + fourEmoji.metadata.estimatedTokens > oneEmoji.metadata.estimatedTokens, + expected: true, + }); + }); + + test("uses compact prompt when requested", () => { + const result = buildVibePrompt({ + userPrompt: "Create a simple app", + constraints: { compact: true }, + }); + + assert({ + given: "compact constraint", + should: "use compact system prompt", + actual: result.systemPrompt === vibeSystemPromptCompact, + expected: true, + }); + + assert({ + given: "compact constraint", + should: "set compact in metadata", + actual: result.metadata?.compact, + expected: true, + }); + }); + + test("includes tier limits in prompt", () => { + const result = buildVibePrompt({ + userPrompt: "Create an app", + constraints: { tier: "creator" }, + }); + + assert({ + given: "creator tier", + should: "include 50MB limit in prompt", + actual: result.fullPrompt.includes("50MB"), + expected: true, + }); + }); + + test("includes required features when specified", () => { + const result = buildVibePrompt({ + userPrompt: "Create a game", + constraints: { + requiredFeatures: ["Score tracking", "Sound effects"], + }, + }); + + assert({ + given: "required features", + should: "include features in prompt", + actual: + result.fullPrompt.includes("Score tracking") && + result.fullPrompt.includes("Sound effects"), + expected: true, + }); + + assert({ + given: "required features", + should: "set hasFeatures in metadata", + actual: result.metadata?.hasFeatures, + expected: true, + }); + }); + + test("includes style guidance when specified", () => { + const result = buildVibePrompt({ + userPrompt: "Create a dashboard", + constraints: { style: "Dark theme with neon accents" }, + }); + + assert({ + given: "style constraint", + should: "include style in prompt", + actual: result.fullPrompt.includes("Dark theme with neon accents"), + expected: true, + }); + + assert({ + given: "style constraint", + should: "set hasStyle in metadata", + actual: result.metadata?.hasStyle, + expected: true, + }); + }); + + test("estimates token count", () => { + const result = buildVibePrompt({ + userPrompt: "Create a simple counter", + }); + + assert({ + given: "valid prompt", + should: "include estimated token count", + actual: result.metadata?.estimatedTokens > 0, + expected: true, + }); + }); + + test("throws on token limit exceeded", () => { + const longPrompt = "Create ".repeat(10000); + + let error = null; + try { + buildVibePrompt({ + userPrompt: longPrompt, + constraints: { maxTokens: 100 }, + }); + } catch (e) { + error = e; + } + + assert({ + given: "prompt exceeding token limit", + should: "throw PROMPT_TOO_LONG error", + actual: error?.cause?.code, + expected: "PROMPT_TOO_LONG", + }); + }); +}); + +// ----------------------------------------------------------------------------- +// buildFromTemplate Tests +// ----------------------------------------------------------------------------- + +describe("buildFromTemplate", () => { + test("applies game template", () => { + const result = buildFromTemplate("game", "a space shooter"); + + assert({ + given: "game template", + should: "include game prefix in prompt", + actual: result.userMessage.includes("interactive game"), + expected: true, + }); + + assert({ + given: "game template", + should: "include score requirement", + actual: result.fullPrompt.includes("Score"), + expected: true, + }); + }); + + test("applies visualization template", () => { + const result = buildFromTemplate("visualization", "stock price chart"); + + assert({ + given: "visualization template", + should: "include visualization prefix", + actual: result.userMessage.includes("data visualization"), + expected: true, + }); + }); + + test("applies tool template", () => { + const result = buildFromTemplate("tool", "color picker"); + + assert({ + given: "tool template", + should: "include tool prefix", + actual: result.userMessage.includes("utility tool"), + expected: true, + }); + }); + + test("applies creative template", () => { + const result = buildFromTemplate("creative", "generative art piece"); + + assert({ + given: "creative template", + should: "include creative prefix", + actual: result.userMessage.includes("artistic/creative"), + expected: true, + }); + }); + + test("falls back to basic prompt for unknown template", () => { + const result = buildFromTemplate("unknown", "some app"); + + assert({ + given: "unknown template name", + should: "return valid result", + actual: result.success, + expected: true, + }); + + assert({ + given: "unknown template name", + should: "include original prompt", + actual: result.userMessage.includes("some app"), + expected: true, + }); + }); + + test("merges constraints with template features", () => { + const result = buildFromTemplate("game", "puzzle game", { + requiredFeatures: ["Timer"], + }); + + assert({ + given: "template with additional features", + should: "include both template and custom features", + actual: + result.fullPrompt.includes("Score") && + result.fullPrompt.includes("Timer"), + expected: true, + }); + }); +}); + +// ----------------------------------------------------------------------------- +// Constants Tests +// ----------------------------------------------------------------------------- + +describe("vibeSystemPrompt", () => { + test("includes essential platform info", () => { + assert({ + given: "system prompt", + should: "mention React/TypeScript", + actual: vibeSystemPrompt.includes("React/TypeScript"), + expected: true, + }); + + assert({ + given: "system prompt", + should: "mention Vibecodr", + actual: vibeSystemPrompt.includes("Vibecodr"), + expected: true, + }); + + assert({ + given: "system prompt", + should: "mention sandboxed iframe", + actual: vibeSystemPrompt.includes("sandboxed"), + expected: true, + }); + }); + + test("includes forbidden patterns", () => { + assert({ + given: "system prompt", + should: "warn about entry.tsx", + actual: vibeSystemPrompt.includes("entry.tsx"), + expected: true, + }); + + assert({ + given: "system prompt", + should: "warn about API keys", + actual: vibeSystemPrompt.includes("API key"), + expected: true, + }); + }); + + test("includes code examples", () => { + assert({ + given: "system prompt", + should: "include App component example", + actual: vibeSystemPrompt.includes("export default function App"), + expected: true, + }); + }); +}); + +describe("vibeSystemPromptCompact", () => { + test("is shorter than full prompt", () => { + assert({ + given: "compact prompt", + should: "be shorter than full prompt", + actual: vibeSystemPromptCompact.length < vibeSystemPrompt.length, + expected: true, + }); + }); + + test("includes essential rules", () => { + assert({ + given: "compact prompt", + should: "mention entry point", + actual: vibeSystemPromptCompact.includes("index.tsx"), + expected: true, + }); + + assert({ + given: "compact prompt", + should: "mention React globals", + actual: vibeSystemPromptCompact.includes("React/ReactDOM"), + expected: true, + }); + }); +}); + +describe("platformLimits", () => { + test("defines free tier limits", () => { + assert({ + given: "free tier", + should: "have 10MB max bundle", + actual: platformLimits.free.maxBundleSize, + expected: 10 * 1024 * 1024, + }); + + assert({ + given: "free tier", + should: "have 100 max files", + actual: platformLimits.free.maxFiles, + expected: 100, + }); + }); + + test("defines creator tier limits", () => { + assert({ + given: "creator tier", + should: "have 50MB max bundle", + actual: platformLimits.creator.maxBundleSize, + expected: 50 * 1024 * 1024, + }); + }); + + test("defines pro tier limits", () => { + assert({ + given: "pro tier", + should: "have 100MB max bundle", + actual: platformLimits.pro.maxBundleSize, + expected: 100 * 1024 * 1024, + }); + }); +}); + +describe("forbiddenPatterns", () => { + test("includes reserved file names", () => { + assert({ + given: "forbidden patterns", + should: "include entry.tsx", + actual: forbiddenPatterns.includes("entry.tsx"), + expected: true, + }); + }); + + test("includes security risks", () => { + assert({ + given: "forbidden patterns", + should: "include .env", + actual: forbiddenPatterns.includes(".env"), + expected: true, + }); + }); +}); + +describe("validEntryExtensions", () => { + test("includes tsx extension", () => { + assert({ + given: "valid extensions", + should: "include .tsx", + actual: validEntryExtensions.includes(".tsx"), + expected: true, + }); + }); + + test("includes html extension", () => { + assert({ + given: "valid extensions", + should: "include .html", + actual: validEntryExtensions.includes(".html"), + expected: true, + }); + }); +}); + +describe("promptTemplates", () => { + test("defines game template", () => { + assert({ + given: "prompt templates", + should: "have game template", + actual: promptTemplates.game !== undefined, + expected: true, + }); + + assert({ + given: "game template", + should: "include required features", + actual: promptTemplates.game.requiredFeatures.length > 0, + expected: true, + }); + }); + + test("defines visualization template", () => { + assert({ + given: "prompt templates", + should: "have visualization template", + actual: promptTemplates.visualization !== undefined, + expected: true, + }); + }); + + test("defines tool template", () => { + assert({ + given: "prompt templates", + should: "have tool template", + actual: promptTemplates.tool !== undefined, + expected: true, + }); + }); + + test("defines creative template", () => { + assert({ + given: "prompt templates", + should: "have creative template", + actual: promptTemplates.creative !== undefined, + expected: true, + }); + }); +}); diff --git a/package.json b/package.json index 862617a..e261d2d 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,14 @@ "./vibe-auth": { "types": "./lib/vibe-auth.d.ts", "default": "./lib/vibe-auth.js" + }, + "./vibe-files": { + "types": "./lib/vibe-files.d.ts", + "default": "./lib/vibe-files.js" + }, + "./vibe-prompt": { + "types": "./lib/vibe-prompt.d.ts", + "default": "./lib/vibe-prompt.js" } }, "files": [ From e4419eed0710da11ede766322f7843b3eb1a3982 Mon Sep 17 00:00:00 2001 From: bradensui Date: Wed, 7 Jan 2026 22:36:06 -0600 Subject: [PATCH 4/6] fix(security): address AbortSignal overwrite and UNC path bypass Two security fixes for vibe-utils: 1. fetchWithTimeout - AbortSignal combination (P2) - Previously overwrote caller-provided signals with timeout signal - Now uses AbortSignal.any() to combine timeout and external signals - External cancellation (user abort) is now properly respected - Distinguishes timeout aborts from external aborts in error handling 2. isPathSafe - UNC path detection (P2) - Previously only checked for Unix and Windows drive letter absolute paths - UNC paths like \server\share bypassed validation - Now detects UNC paths (\\) as absolute paths and rejects them Both fixes include comprehensive test coverage. Co-Authored-By: Claude Opus 4.5 --- lib/vibe-utils.js | 64 ++++++++++++++++---- lib/vibe-utils.test.js | 132 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 178 insertions(+), 18 deletions(-) diff --git a/lib/vibe-utils.js b/lib/vibe-utils.js index 25a66d1..b29a555 100644 --- a/lib/vibe-utils.js +++ b/lib/vibe-utils.js @@ -327,11 +327,16 @@ export const verboseLog = (prefix, message, verbose) => { * Fetch with timeout support using AbortController. * Prevents fetch() from hanging indefinitely when server doesn't respond. * + * SECURITY: Properly combines timeout signal with caller-provided signals + * using AbortSignal.any(). This ensures external cancellation (e.g., user + * aborting an in-flight request) is respected alongside the timeout. + * * @param {string} url - URL to fetch from - * @param {RequestInit} [init] - Fetch options (signal will be merged) + * @param {RequestInit} [init] - Fetch options (signal will be merged, not overwritten) * @param {number} [timeoutMs=30000] - Timeout in milliseconds * @returns {Promise} Fetch Response object * @throws {Error} FETCH_TIMEOUT if request exceeds timeout + * @throws {Error} AbortError if caller's signal triggered abort (rethrown as-is) * * @example * try { @@ -341,30 +346,62 @@ export const verboseLog = (prefix, message, verbose) => { * console.log('Request timed out'); * } * } + * + * @example + * // With external abort signal + * const controller = new AbortController(); + * setTimeout(() => controller.abort(), 1000); // User cancels after 1s + * try { + * await fetchWithTimeout("https://api.example.com/slow", { signal: controller.signal }, 30000); + * } catch (err) { + * if (err.name === 'AbortError') { + * console.log('Request was cancelled by user'); + * } + * } */ export const fetchWithTimeout = async ( url, init = {}, timeoutMs = defaultTimeoutMs, ) => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + // Create timeout-specific controller to track if timeout triggered the abort + const timeoutController = new AbortController(); + const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs); + + // Combine timeout signal with any caller-provided signal + // This ensures external cancellation is respected alongside timeout + const signals = [timeoutController.signal]; + if (init.signal) { + signals.push(init.signal); + } + const combinedSignal = AbortSignal.any(signals); try { + // Check if external signal is already aborted before starting fetch + if (init.signal?.aborted) { + throw init.signal.reason ?? new DOMException("Aborted", "AbortError"); + } + const response = await fetch(url, { ...init, - signal: controller.signal, + signal: combinedSignal, }); return response; } catch (err) { - // AbortError is thrown when AbortController.abort() is called + // AbortError is thrown when any signal aborts if (err.name === "AbortError") { - throw createError({ - ...FetchTimeout, - message: `Request timed out after ${timeoutMs}ms`, - url, - timeoutMs, - }); + // Check if OUR timeout triggered the abort + if (timeoutController.signal.aborted) { + throw createError({ + ...FetchTimeout, + message: `Request timed out after ${timeoutMs}ms`, + url, + timeoutMs, + }); + } + // External signal triggered abort - rethrow as-is for caller to handle + // This preserves the original abort reason and allows proper cancellation flow + throw err; } const originalCode = err && typeof err.code === "string" ? err.code : null; const retryable = @@ -859,11 +896,14 @@ export const isPathSafe = (filePath) => { const normalizedPath = normalizePathForSecurity(filePath); // Reject absolute paths (check both original and normalized) + // SECURITY: Includes UNC paths (\\server\share) which are absolute on Windows if ( filePath.startsWith("/") || normalizedPath.startsWith("/") || /^[A-Za-z]:/.test(filePath) || - /^[A-Za-z]:/.test(normalizedPath) + /^[A-Za-z]:/.test(normalizedPath) || + filePath.startsWith("\\\\") || + normalizedPath.startsWith("\\\\") ) { return { safe: false, reason: "Absolute paths are not allowed" }; } diff --git a/lib/vibe-utils.test.js b/lib/vibe-utils.test.js index 6768d09..959d32f 100644 --- a/lib/vibe-utils.test.js +++ b/lib/vibe-utils.test.js @@ -306,19 +306,24 @@ describe("fetchWithTimeout", () => { }); test("throws FETCH_TIMEOUT on abort", async () => { - // Simulate a request that gets aborted due to timeout + // Simulate a slow request that will be aborted by our timeout + // The mock listens to the signal and rejects when aborted mockFetch.mockImplementationOnce( - () => + (url, options) => new Promise((_, reject) => { - // Immediately simulate abort behavior - const error = new Error("Aborted"); - error.name = "AbortError"; - reject(error); + // When the signal aborts (from our timeout), reject with AbortError + options.signal.addEventListener("abort", () => { + const error = new Error("Aborted"); + error.name = "AbortError"; + reject(error); + }); + // This promise would never resolve on its own - simulates slow server }), ); let error; try { + // Short timeout so test runs fast await fetchWithTimeout("https://api.test.com/slow", {}, 50); } catch (e) { error = e; @@ -340,6 +345,91 @@ describe("fetchWithTimeout", () => { }); }); + test("respects external AbortSignal for cancellation", async () => { + // Create an external controller that will abort + const externalController = new AbortController(); + + // Mock fetch to simulate a pending request that gets aborted externally + mockFetch.mockImplementationOnce( + (url, options) => + new Promise((_, reject) => { + // Listen for abort on the combined signal + options.signal.addEventListener("abort", () => { + const error = new Error("Aborted"); + error.name = "AbortError"; + reject(error); + }); + }), + ); + + // Start the fetch, then abort via external signal + const fetchPromise = fetchWithTimeout( + "https://api.test.com/slow", + { signal: externalController.signal }, + 30000, // Long timeout so it won't timeout first + ); + + // Abort via external signal + externalController.abort(); + + let error; + try { + await fetchPromise; + } catch (e) { + error = e; + } + + // External abort should NOT be wrapped as FETCH_TIMEOUT + // It should be rethrown as-is (AbortError) + assert({ + given: "external signal aborts request", + should: "throw AbortError (not FETCH_TIMEOUT)", + actual: error?.name, + expected: "AbortError", + }); + + // Should NOT have FETCH_TIMEOUT code + assert({ + given: "external signal aborts request", + should: "not be classified as timeout", + actual: error?.cause?.code !== "FETCH_TIMEOUT", + expected: true, + }); + }); + + test("rejects immediately if external signal already aborted", async () => { + // Create already-aborted signal + const abortedController = new AbortController(); + abortedController.abort(); + + let error; + try { + await fetchWithTimeout( + "https://api.test.com/data", + { signal: abortedController.signal }, + 5000, + ); + } catch (e) { + error = e; + } + + // Should throw immediately without calling fetch + assert({ + given: "already-aborted signal", + should: "throw AbortError immediately", + actual: error?.name, + expected: "AbortError", + }); + + // Fetch should not have been called + assert({ + given: "already-aborted signal", + should: "not call fetch", + actual: mockFetch.mock.calls.length, + expected: 0, + }); + }); + test("passes through non-timeout errors with wrapping", async () => { const networkError = new Error("Network failure"); networkError.code = "ECONNREFUSED"; @@ -879,6 +969,36 @@ describe("isPathSafe", () => { expected: true, }); }); + + test("rejects UNC paths", () => { + const result = isPathSafe("\\\\server\\share\\file.txt"); + + assert({ + given: "UNC path (Windows network path)", + should: "return safe false", + actual: result.safe, + expected: false, + }); + + assert({ + given: "UNC path rejection", + should: "give absolute path reason", + actual: result.reason, + expected: "Absolute paths are not allowed", + }); + }); + + test("rejects UNC paths with extended prefix", () => { + // \\?\C:\ is Windows extended-length path prefix + const result = isPathSafe("\\\\?\\C:\\Windows\\System32"); + + assert({ + given: "UNC extended-length path", + should: "return safe false", + actual: result.safe, + expected: false, + }); + }); }); // ============================================================================= From eb53fa536eb2099fce38b7a149443f236683b34b Mon Sep 17 00:00:00 2001 From: bradensui Date: Wed, 7 Jan 2026 22:59:43 -0600 Subject: [PATCH 5/6] fix(security): reject Windows root-relative paths in isPathSafe Extends absolute path detection to catch single-backslash root-relative paths like \Windows\System32 which resolve to the current drive's root on Windows. Previous check only caught: - Unix absolute: /etc/passwd - Drive letter: C:\Windows - UNC paths: \server\share Now also catches: - Root-relative: \Windows\System32 - Any path starting with backslash TDD approach: tests written first (RED), then implementation (GREEN). Co-Authored-By: Claude Opus 4.5 --- lib/vibe-utils.js | 7 ++++--- lib/vibe-utils.test.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/lib/vibe-utils.js b/lib/vibe-utils.js index b29a555..30154a8 100644 --- a/lib/vibe-utils.js +++ b/lib/vibe-utils.js @@ -896,14 +896,15 @@ export const isPathSafe = (filePath) => { const normalizedPath = normalizePathForSecurity(filePath); // Reject absolute paths (check both original and normalized) - // SECURITY: Includes UNC paths (\\server\share) which are absolute on Windows + // SECURITY: Includes Windows root-relative (\path) and UNC paths (\\server\share) + // Both are absolute paths that could escape the working directory if ( filePath.startsWith("/") || normalizedPath.startsWith("/") || /^[A-Za-z]:/.test(filePath) || /^[A-Za-z]:/.test(normalizedPath) || - filePath.startsWith("\\\\") || - normalizedPath.startsWith("\\\\") + filePath.startsWith("\\") || + normalizedPath.startsWith("\\") ) { return { safe: false, reason: "Absolute paths are not allowed" }; } diff --git a/lib/vibe-utils.test.js b/lib/vibe-utils.test.js index 959d32f..7c713bb 100644 --- a/lib/vibe-utils.test.js +++ b/lib/vibe-utils.test.js @@ -988,6 +988,45 @@ describe("isPathSafe", () => { }); }); + test("rejects Windows root-relative paths (single backslash)", () => { + // \Windows\System32 is root-relative on Windows (resolves to current drive root) + // This is an absolute path that should be rejected + const result = isPathSafe("\\Windows\\System32"); + + assert({ + given: "Windows root-relative path (single backslash)", + should: "return safe false", + actual: result.safe, + expected: false, + }); + + assert({ + given: "root-relative path rejection", + should: "give absolute path reason", + actual: result.reason, + expected: "Absolute paths are not allowed", + }); + }); + + test("rejects paths starting with single backslash", () => { + // Various single-backslash absolute path patterns + const testCases = [ + "\\temp\\file.txt", // \temp\file.txt + "\\Users\\data.json", // \Users\data.json + "\\", // Just root + ]; + + for (const testPath of testCases) { + const result = isPathSafe(testPath); + assert({ + given: `path starting with backslash: ${testPath}`, + should: "return safe false", + actual: result.safe, + expected: false, + }); + } + }); + test("rejects UNC paths with extended prefix", () => { // \\?\C:\ is Windows extended-length path prefix const result = isPathSafe("\\\\?\\C:\\Windows\\System32"); From fd60886561ed7f40d37da79fa04c07c633cbfb73 Mon Sep 17 00:00:00 2001 From: bradensui Date: Wed, 7 Jan 2026 23:45:51 -0600 Subject: [PATCH 6/6] fix(vibe-auth): validate apiBase before token exchange (security) SECURITY: Prevent credential exfiltration via malicious apiBase. The exchangeForVibecodrToken function now validates apiBase against trusted origins before sending the Clerk access token. A tampered apiBase (from CLI args, env vars, or config file) could have allowed an attacker to steal the access token. Changes: - Add validateApiBase call before token exchange - Add SecurityBlockError type for security validation failures - Ensure security errors propagate directly (not wrapped as AUTH_EXPIRED) - Add test for malicious apiBase rejection Co-Authored-By: Claude Opus 4.5 --- lib/vibe-auth.js | 23 +++++++++++++++++++++++ lib/vibe-auth.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/lib/vibe-auth.js b/lib/vibe-auth.js index 51894a3..155e1d9 100644 --- a/lib/vibe-auth.js +++ b/lib/vibe-auth.js @@ -16,6 +16,7 @@ import { verboseLog, fetchJson, isLikelyClerkTokenProblem, + validateApiBase, } from "./vibe-utils.js"; // ============================================================================= @@ -31,6 +32,10 @@ const [vibeAuthErrors] = errorCauses({ code: "AUTH_EXPIRED", message: "Token expired and refresh failed", }, + SecurityBlockError: { + code: "SECURITY_BLOCK", + message: "Security validation failed", + }, ConfigReadError: { code: "CONFIG_READ_ERROR", message: "Failed to read configuration file", @@ -56,6 +61,7 @@ const { ConfigWriteError, TokenExchangeError, RefreshError, + SecurityBlockError, } = vibeAuthErrors; // ============================================================================= @@ -715,6 +721,17 @@ const discoverOidc = async (issuer) => { * @returns {Promise} Vibecodr token response */ const exchangeForVibecodrToken = async ({ apiBase, clerkAccessToken }) => { + // SECURITY: Validate apiBase before sending tokens to prevent credential exfiltration + // A malicious or tampered apiBase could steal the Clerk access token + const apiCheck = validateApiBase(apiBase); + if (!apiCheck.valid) { + throw createError({ + ...SecurityBlockError, + message: `Refusing to send token to untrusted API: ${apiCheck.reason}`, + apiBase, + }); + } + const url = `${normalizeOrigin(apiBase)}/auth/cli/exchange`; return fetchJson(url, { method: "POST", @@ -902,6 +919,12 @@ export const refreshVibecodrToken = async ({ verbose, }); } catch (exchangeErr) { + // SECURITY: Security errors must propagate directly - never mask them + // This ensures token theft attempts are clearly reported, not hidden as auth issues + if (exchangeErr?.cause?.code === "SECURITY_BLOCK") { + throw exchangeErr; + } + const now = Math.floor(Date.now() / 1000); if (!clerkRefreshToken) { diff --git a/lib/vibe-auth.test.js b/lib/vibe-auth.test.js index b3bfffb..87ce862 100644 --- a/lib/vibe-auth.test.js +++ b/lib/vibe-auth.test.js @@ -378,6 +378,44 @@ describe("refreshVibecodrToken", () => { expected: "AUTH_EXPIRED", }); }); + + test("rejects malicious apiBase to prevent token exfiltration", async () => { + // SECURITY: Ensure tokens are never sent to untrusted URLs + fs.mkdirSync(path.dirname(tempConfigPath), { recursive: true }); + const testConfig = { + clerk: { + access_token: "clerk-token-to-protect", + refresh_token: "clerk-refresh-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + fs.writeFileSync(tempConfigPath, JSON.stringify(testConfig)); + + let error; + try { + await refreshVibecodrToken({ + configPath: tempConfigPath, + apiBase: "https://evil-attacker.com", // Malicious apiBase + }); + } catch (e) { + error = e; + } + + // Should reject BEFORE making any network request + assert({ + given: "malicious apiBase", + should: "throw SECURITY_BLOCK error to prevent token theft", + actual: error?.cause?.code, + expected: "SECURITY_BLOCK", + }); + + assert({ + given: "malicious apiBase", + should: "include reason in error message", + actual: error?.message?.includes("untrusted"), + expected: true, + }); + }); }); // =============================================================================