Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/vercel

src/client.js

131 lines · 4.0 KB · JavaScript
/**
 * Authenticated Vercel REST client.
 * Auth: access-token secret `vercelToken`, or a saved OAuth integration.
 * Host approval is enforced by Kody's fetch gateway.
 */

import { createAuthenticatedFetch } from 'kody:runtime'
import {
	API_BASE_URL,
	API_KEY_SECRET,
	assertNoRetiredAlias,
	authSetupMessage,
} from './setup.js'

export function buildUrl(pathTemplate, params = {}) {
	return API_BASE_URL + pathTemplate.replace(/\{([^}]+)\}/g, (_match, name) => {
		const value = params[name]
		if (value === undefined || value === null || value === '') {
			throw new Error(`Missing required path parameter: ${name}`)
		}
		return encodeURIComponent(String(value))
	})
}

export function appendQuery(url, query = {}) {
	const search = new URLSearchParams()
	for (const [key, value] of Object.entries(query ?? {})) {
		if (value === undefined || value === null || value === '') continue
		if (Array.isArray(value)) {
			for (const item of value) {
				if (item === undefined || item === null || item === '') continue
				search.append(key, String(item))
			}
			continue
		}
		search.append(key, String(value))
	}
	const qs = search.toString()
	return qs ? `${url}?${qs}` : url
}

function mergeHeaders(userHeaders, authHeaders) {
	const merged = { ...(userHeaders ?? {}) }
	for (const [key, value] of Object.entries(authHeaders)) {
		const lower = key.toLowerCase()
		for (const existing of Object.keys(merged)) {
			if (existing.toLowerCase() === lower) delete merged[existing]
		}
		merged[key] = value
	}
	return merged
}

function hasHeader(headers, name) {
	const lower = name.toLowerCase()
	return Object.keys(headers).some((key) => key.toLowerCase() === lower)
}

function assertSecretName(name) {
	if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
		throw new Error(
			'Vercel secretName must be a safe secret identifier (letters, numbers, dot, underscore, hyphen).',
		)
	}
	return name
}

function resolveSecretName(input = {}) {
	assertNoRetiredAlias(input.secretName, 'secretName')
	assertNoRetiredAlias(input.account, 'account')
	if (typeof input.secretName === 'string' && input.secretName.trim()) {
		return assertSecretName(input.secretName.trim())
	}
	if (typeof input.account === 'string' && input.account.trim()) {
		return assertSecretName(`${API_KEY_SECRET}-${input.account.trim()}`)
	}
	return API_KEY_SECRET
}

async function resolveTransport(input = {}) {
	assertNoRetiredAlias(input.integrationName, 'integrationName')
	if (typeof input.integrationName === 'string' && input.integrationName.trim()) {
		try {
			return {
				fetchImpl: await createAuthenticatedFetch(input.integrationName.trim()),
				authHeaders: {},
			}
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error)
			throw new Error(
				authSetupMessage(
					`Vercel OAuth integration "${input.integrationName}" is not connected. ${message}`,
				),
			)
		}
	}

	return {
		fetchImpl: fetch,
		authHeaders: { Authorization: `Bearer {{secret:${resolveSecretName(input)}}}` },
	}
}

/**
 * Escape-hatch fetch for Vercel REST paths.
 * `path` is relative to https://api.vercel.com (include `/v9/projects`, etc.).
 */
export async function rawVercelRequest(path, options = {}) {
	if (typeof path !== 'string' || path.trim() === '') {
		throw new Error('Vercel request requires a non-empty path string.')
	}
	if (/^https?:\/\//i.test(path)) {
		throw new Error('Vercel request path must be relative, for example /v2/user.')
	}
	const normalized = path.startsWith('/') ? path : `/${path}`
	const url = appendQuery(`${API_BASE_URL}${normalized}`, options.query)
	const { fetchImpl, authHeaders } = await resolveTransport(options)
	const headers = mergeHeaders(
		{
			Accept: 'application/json',
			...(options.headers ?? {}),
		},
		authHeaders,
	)
	let body = options.body
	if (body !== undefined && typeof body !== 'string') {
		body = JSON.stringify(body)
		if (!hasHeader(headers, 'content-type')) headers['content-type'] = 'application/json'
	}
	return fetchImpl(url, { method: options.method ?? 'GET', headers, body })
}