/**
* Authenticated Netlify REST client.
* Auth: personal access token secret `netlifyToken`.
* Host approval is enforced by Kody's fetch gateway.
*/
import {
API_BASE_URL,
API_KEY_SECRET,
assertNoRetiredAlias,
authSetupMessage,
} from './setup.js'
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(
'Netlify 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
}
/**
* Escape-hatch fetch for Netlify REST paths.
* `path` is relative to https://api.netlify.com/api/v1.
*/
export async function rawNetlifyRequest(path, options = {}) {
if (typeof path !== 'string' || path.trim() === '') {
throw new Error('Netlify request requires a non-empty path string.')
}
if (/^https?:\/\//i.test(path)) {
throw new Error('Netlify request path must be relative, for example /user or /sites.')
}
const normalized = path.startsWith('/') ? path : `/${path}`
const url = appendQuery(`${API_BASE_URL}${normalized}`, options.query)
const headers = mergeHeaders(
{
Accept: 'application/json',
'User-Agent': '@kody/netlify (https://kody.codes/@kody/netlify)',
...(options.headers ?? {}),
},
{ Authorization: `Bearer {{secret:${resolveSecretName(options)}}}` },
)
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 fetch(url, { method: options.method ?? 'GET', headers, body })
}