import type { LambdaConnectionErrorCode, LambdaConnectionPhase, LambdaConnectionState, } from '../types/types'; import { buildLambdaHttpErrorMessage, buildLambdaJsonHeaders, LambdaAuthSecretError, } from './lambdaAuth'; import { createTimeoutController, isAbortError, truncateResponseSnippet, } from './lambdaHttp'; const HEALTH_CHECK_TIMEOUT_MS = 8000; export const HEALTH_ENDPOINT_PATH = '/api/datocms/plugin-health'; const EXPECTED_PLUGIN_NAME = 'datocms-plugin-automatic-environment-backups'; const EXPECTED_MPI_MESSAGE = 'DATOCMS_AUTOMATIC_BACKUPS_PLUGIN_PING'; const EXPECTED_MPI_VERSION = '2026-02-26'; const EXPECTED_PONG_MESSAGE = 'DATOCMS_AUTOMATIC_BACKUPS_LAMBDA_PONG'; const EXPECTED_SERVICE_NAME = 'datocms-backups-scheduled-function'; const EXPECTED_STATUS = 'ready'; const SNIPPET_MAX_LENGTH = 280; const PROTOCOL_PREFIX_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//; type LambdaHealthResponsePayload = { ok?: boolean; mpi?: { message?: string; version?: string; }; service?: string; status?: string; }; type LambdaHealthCheckErrorConstructorProps = { code: LambdaConnectionErrorCode; message: string; phase: LambdaConnectionPhase; endpoint: string; httpStatus?: number; responseSnippet?: string; }; export type VerifyLambdaHealthInput = { baseUrl: string; environment: string; phase: LambdaConnectionPhase; lambdaAuthSecret: string; }; export type VerifyLambdaHealthResult = { endpoint: string; checkedAt: string; normalizedBaseUrl: string; }; export class LambdaHealthCheckError extends Error { readonly code: LambdaConnectionErrorCode; readonly phase: LambdaConnectionPhase; readonly endpoint: string; readonly httpStatus?: number; readonly responseSnippet?: string; constructor({ code, message, phase, endpoint, httpStatus, responseSnippet, }: LambdaHealthCheckErrorConstructorProps) { super(message); this.name = 'LambdaHealthCheckError'; this.code = code; this.phase = phase; this.endpoint = endpoint; this.httpStatus = httpStatus; this.responseSnippet = responseSnippet; } } const normalizeCandidateUrl = (baseUrl: string): string => { const trimmedBaseUrl = baseUrl.trim(); if (!trimmedBaseUrl) { return ''; } if (PROTOCOL_PREFIX_PATTERN.test(trimmedBaseUrl)) { return trimmedBaseUrl; } return `https://${trimmedBaseUrl}`; }; const getFallbackEndpoint = (baseUrl: string): string => { const candidate = normalizeCandidateUrl(baseUrl); try { const parsed = new URL(candidate); return new URL(HEALTH_ENDPOINT_PATH, `${parsed.origin}/`).toString(); } catch { return `${candidate || '(empty url)'}${HEALTH_ENDPOINT_PATH}`; } }; const normalizeBaseUrl = ( baseUrl: string, phase: LambdaConnectionPhase, ): string => { const candidate = normalizeCandidateUrl(baseUrl); if (!candidate) { throw new LambdaHealthCheckError({ code: 'INVALID_URL', message: 'No URL was provided for the deployment.', phase, endpoint: '(missing endpoint)', }); } let parsed: URL; try { parsed = new URL(candidate); } catch { throw new LambdaHealthCheckError({ code: 'INVALID_URL', message: 'The deployed URL is not valid. Use a full URL like https://backups.example.com, or paste only the hostname and https will be added automatically.', phase, endpoint: getFallbackEndpoint(candidate), }); } if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { throw new LambdaHealthCheckError({ code: 'INVALID_URL', message: 'The deployed URL must use http or https. Example: https://backups.example.com', phase, endpoint: getFallbackEndpoint(candidate), }); } const hostname = parsed.hostname.toLowerCase(); const isLocalhost = hostname === 'localhost'; const hasDomainDot = hostname.includes('.'); if (!isLocalhost && !hasDomainDot) { throw new LambdaHealthCheckError({ code: 'INVALID_URL', message: 'The deployed URL hostname looks incomplete. Use a full domain like https://backups.example.com, or localhost for local testing.', phase, endpoint: getFallbackEndpoint(candidate), }); } return parsed.origin; }; const parseJsonPayload = (payload: string): unknown => { if (!payload.trim()) { throw new Error('empty'); } return JSON.parse(payload); }; const extractHttpErrorMessage = (payload: string, status: number): string => buildLambdaHttpErrorMessage( status, payload, 'health endpoint returned an error status', ); const isExpectedResponse = (payload: LambdaHealthResponsePayload): boolean => { return ( payload.ok === true && payload.mpi?.message === EXPECTED_PONG_MESSAGE && payload.mpi?.version === EXPECTED_MPI_VERSION && payload.service === EXPECTED_SERVICE_NAME && payload.status === EXPECTED_STATUS ); }; const assertExpectedResponse = ( payload: LambdaHealthResponsePayload, endpoint: string, phase: LambdaConnectionPhase, rawPayload: string, ) => { if (!isExpectedResponse(payload)) { throw new LambdaHealthCheckError({ code: 'UNEXPECTED_RESPONSE', message: 'Health endpoint response did not match the expected Automatic Backups MPI PONG contract.', phase, endpoint, responseSnippet: truncateResponseSnippet(rawPayload, SNIPPET_MAX_LENGTH), }); } }; const buildUnexpectedResponseMessage = (): string => `Expected HTTP 200 JSON with ok=true, mpi.message=${EXPECTED_PONG_MESSAGE}, mpi.version=${EXPECTED_MPI_VERSION}, service=${EXPECTED_SERVICE_NAME}, status=${EXPECTED_STATUS}.`; export const verifyLambdaHealth = async ({ baseUrl, environment, phase, lambdaAuthSecret, }: VerifyLambdaHealthInput): Promise => { const normalizedBaseUrl = normalizeBaseUrl(baseUrl, phase); const endpoint = new URL( HEALTH_ENDPOINT_PATH, `${normalizedBaseUrl}/`, ).toString(); const checkedAt = new Date().toISOString(); const requestBody = JSON.stringify({ event_type: 'plugin_health_ping', mpi: { message: EXPECTED_MPI_MESSAGE, version: EXPECTED_MPI_VERSION, phase, }, plugin: { name: EXPECTED_PLUGIN_NAME, environment, }, }); const timeoutController = createTimeoutController(HEALTH_CHECK_TIMEOUT_MS); let requestHeaders: Record; try { requestHeaders = buildLambdaJsonHeaders(lambdaAuthSecret); } catch (error) { if (error instanceof LambdaAuthSecretError) { throw new LambdaHealthCheckError({ code: 'MISSING_AUTH_SECRET', message: error.message, phase, endpoint, }); } throw error; } let response: Response; try { response = await fetch(endpoint, { method: 'POST', body: requestBody, headers: requestHeaders, signal: timeoutController.controller.signal, }); } catch (error) { if (isAbortError(error)) { throw new LambdaHealthCheckError({ code: 'TIMEOUT', message: `Health check timed out after ${HEALTH_CHECK_TIMEOUT_MS}ms.`, phase, endpoint, }); } throw new LambdaHealthCheckError({ code: 'NETWORK', message: 'Could not reach the health endpoint.', phase, endpoint, }); } finally { timeoutController.clear(); } const responsePayload = await response.text(); if (!response.ok) { throw new LambdaHealthCheckError({ code: 'HTTP', message: extractHttpErrorMessage(responsePayload, response.status), phase, endpoint, httpStatus: response.status, responseSnippet: truncateResponseSnippet( responsePayload, SNIPPET_MAX_LENGTH, ), }); } let parsedResponse: LambdaHealthResponsePayload; try { parsedResponse = parseJsonPayload( responsePayload, ) as LambdaHealthResponsePayload; } catch { throw new LambdaHealthCheckError({ code: 'INVALID_JSON', message: 'Health endpoint returned HTTP 200 with an invalid JSON payload.', phase, endpoint, responseSnippet: truncateResponseSnippet( responsePayload, SNIPPET_MAX_LENGTH, ), }); } assertExpectedResponse(parsedResponse, endpoint, phase, responsePayload); return { endpoint, checkedAt, normalizedBaseUrl, }; }; export const buildDisconnectedLambdaConnectionState = ( error: unknown, baseUrl: string, phase: LambdaConnectionPhase, ): LambdaConnectionState => { const fallbackEndpoint = getFallbackEndpoint(baseUrl); const checkedAt = new Date().toISOString(); if (error instanceof LambdaHealthCheckError) { return { status: 'disconnected', endpoint: error.endpoint || fallbackEndpoint, lastCheckedAt: checkedAt, lastCheckPhase: phase, errorCode: error.code, errorMessage: error.message, httpStatus: error.httpStatus, responseSnippet: error.responseSnippet, }; } return { status: 'disconnected', endpoint: fallbackEndpoint, lastCheckedAt: checkedAt, lastCheckPhase: phase, errorCode: 'NETWORK', errorMessage: 'Unexpected error while checking health.', }; }; export const buildConnectedLambdaConnectionState = ( endpoint: string, checkedAt: string, phase: LambdaConnectionPhase, ): LambdaConnectionState => ({ status: 'connected', endpoint, lastCheckedAt: checkedAt, lastCheckPhase: phase, }); export const getLambdaConnectionErrorDetails = ( connection: LambdaConnectionState, ): string[] => { return [ 'Could not validate the Automatic Backups deployment.', `Health check phase: ${connection.lastCheckPhase}.`, `Endpoint called: ${connection.endpoint}.`, connection.errorCode ? `Failure code: ${connection.errorCode}.` : '', connection.errorMessage ? `Failure details: ${connection.errorMessage}` : '', connection.httpStatus ? `HTTP status: ${connection.httpStatus}.` : '', connection.responseSnippet ? `Response snippet: ${connection.responseSnippet}` : '', buildUnexpectedResponseMessage(), 'If this worked before, your deployment may be outdated or unhealthy.', 'Confirm /api/datocms/plugin-health exists, the lambda auth secret matches, and DATOCMS_BACKUPS_SHARED_SECRET is configured server-side.', ].filter(Boolean); }; export const normalizeLambdaBaseUrl = (baseUrl: string): string => { return normalizeBaseUrl(baseUrl, 'config_connect'); };