import { isValidProviderId } from '@hookfish/provider' import { BrokerError } from './errors.js' export const MAX_RESOURCE_PATH_LENGTH = 768 export type ConnectionPath = { path: string namespace: string providerId: string } export function normalizeProviderId(providerId: string): string { if (!isValidProviderId(providerId)) { throw new BrokerError( 400, 'invalid_provider_id', 'Provider ids must be lower-camel JavaScript identifiers up to 128 characters and cannot be reserved words.', ) } return providerId } export function parseConnectionPath(path: string): ConnectionPath { const normalized = normalizeResourcePath(path, 'connection') const separator = normalized.lastIndexOf('/') return { path: normalized, namespace: separator === -1 ? '' : normalized.slice(0, separator), providerId: normalizeProviderId( separator === -1 ? normalized : normalized.slice(separator + 1), ), } } export function formatConnectionPath( namespace: string, providerId: string, ): string { const normalizedProviderId = normalizeProviderId(providerId) if (!namespace) return normalizedProviderId return parseConnectionPath( `${normalizeResourcePath(namespace, 'namespace')}/${normalizedProviderId}`, ).path } function hasUnsafePathCharacters(value: string): boolean { for (const character of value) { const codePoint = character.codePointAt(0) if (codePoint === undefined) continue if ( codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) || codePoint === 0x200e || codePoint === 0x200f || (codePoint >= 0x202a && codePoint <= 0x202e) || (codePoint >= 0x2066 && codePoint <= 0x2069) ) { return true } } return false } function decodesToPathStructure(segment: string): boolean { let decoded: string try { decoded = decodeURIComponent(segment) } catch { // A literal percent sign is safe when the value is subsequently encoded as // a URL component. Only decoded structural values are ambiguous. return false } if (decoded === segment) return false return ( decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || hasUnsafePathCharacters(decoded) ) } /** Validate a URL-shaped resource identifier shared by connections and providers. */ export function normalizeResourcePath(path: string, resource: string): string { const segments = path.split('/') const structurallyInvalid = path.length === 0 || path.length > MAX_RESOURCE_PATH_LENGTH || path !== path.normalize('NFC') || path.includes('\\') || hasUnsafePathCharacters(path) || segments.some( (segment) => segment.length === 0 || segment === '.' || segment === '..' || decodesToPathStructure(segment), ) if (structurallyInvalid) { throw new BrokerError( 400, `invalid_${resource}_path`, `${resource[0]?.toUpperCase()}${resource.slice(1)} paths must be canonical slash-delimited identifiers without empty, dot, encoded structural, control, or backslash segments.`, ) } return path } export function encodeResourcePath(path: string): string { return path.split('/').map(encodeURIComponent).join('/') }