Skip to content
Open
103 changes: 103 additions & 0 deletions src/controllers/preflight-checks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { isNonEmptyObject, isNonEmptyArray } from '@adobe/spacecat-shared-utils';
import {
badRequest, forbidden, internalServerError, notFound, ok,
} from '@adobe/spacecat-shared-http-utils';
import AccessControlUtil from '../support/access-control-util.js';
import checkHandlerRegistry from '../support/preflight-checks/registry.js';

/**
* Preflight Checks Controller — runs server-side permission and capability
* checks for a site before autofix deploy.
*
* POST /sites/:siteId/preflight-checks
*
* Request body:
* { "checks": [{ "type": "content-api-access" }] }
*
* Response:
* { "siteId": "...", "checks": [{ "type", "status", "message" }] }
*
* @param {Object} ctx - Application context (dataAccess, log, etc.)
* @returns {Object} Controller with runChecks method
*/
function PreflightChecksController(ctx) {
if (!isNonEmptyObject(ctx?.dataAccess)) {
throw new Error('Valid data access configuration required');
}

const { dataAccess, log } = ctx;
const { Site } = dataAccess;
const accessControlUtil = AccessControlUtil.fromContext(ctx);

/**
* Runs the requested preflight checks for a site.
* @param {Object} context - Request context with params.siteId and data.checks
* @returns {Promise<Object>} HTTP response
*/
const runChecks = async (context) => {
const { siteId } = context.params || {};
const { checks } = context.data || {};

if (!isNonEmptyArray(checks)) {
return badRequest('Request body must include a non-empty "checks" array');
}

// Validate all requested check types before doing any work
const unknownTypes = checks
.map((c) => c?.type)
.filter((type) => !checkHandlerRegistry[type]);

if (unknownTypes.length > 0) {
return badRequest(`Unknown check type(s): ${unknownTypes.join(', ')}`);
}

try {
const site = await Site.findById(siteId);

if (!site) {
return notFound(`Site with ID ${siteId} not found`);
}

if (!await accessControlUtil.hasAccess(site)) {
return forbidden('User does not have access to this site');
}

const results = await Promise.all(
checks.map(async (check) => {
const handler = checkHandlerRegistry[check.type];
try {
return await handler(site, context, log);
} catch (error) {
log.error(`Preflight check "${check.type}" threw unexpectedly: ${error.message}`);
return {
type: check.type,
status: 'ERROR',
message: 'Check failed unexpectedly',
};
}
}),
);

return ok({ siteId, checks: results });
} catch (error) {
log.error(`Preflight checks failed for site ${siteId}: ${error.message}`);
return internalServerError('Failed to run preflight checks');
}
};

return { runChecks };
}

export default PreflightChecksController;
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import ConsumersController from './controllers/consumers.js';
import TokensController from './controllers/tokens.js';
import ImsOrgAccessController from './controllers/ims-org-access.js';
import FeatureFlagsController from './controllers/feature-flags.js';
import PreflightChecksController from './controllers/preflight-checks.js';
import routeRequiredCapabilities from './routes/required-capabilities.js';
import ContactSalesLeadsController from './controllers/contact-sales-leads.js';

Expand Down Expand Up @@ -227,6 +228,7 @@ async function run(request, context) {
const imsOrgAccessController = ImsOrgAccessController(context);
const contactSalesLeadsController = ContactSalesLeadsController(context);
const featureFlagsController = FeatureFlagsController(context);
const preflightChecksController = PreflightChecksController(context);

const routeHandlers = getRouteHandlers(
auditsController,
Expand Down Expand Up @@ -275,6 +277,7 @@ async function run(request, context) {
imsOrgAccessController,
contactSalesLeadsController,
featureFlagsController,
preflightChecksController,
);

const routeMatch = matchPath(method, suffix, routeHandlers);
Expand Down
5 changes: 5 additions & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function isStaticRoute(routePattern) {
* @param {Object} imsOrgAccessController - The IMS org access controller.
* @param {Object} contactSalesLeadsController - The contact sales leads controller.
* @param {Object} featureFlagsController - Organization feature flags (mysticat) controller.
* @param {Object} preflightChecksController - Preflight checks controller for autofix deploy.
* @return {{staticRoutes: {}, dynamicRoutes: {}}} - An object with static and dynamic routes.
*/
export default function getRouteHandlers(
Expand Down Expand Up @@ -141,6 +142,7 @@ export default function getRouteHandlers(
imsOrgAccessController,
contactSalesLeadsController,
featureFlagsController,
preflightChecksController,
) {
const staticRoutes = {};
const dynamicRoutes = {};
Expand Down Expand Up @@ -528,6 +530,9 @@ export default function getRouteHandlers(
'GET /organizations/:organizationId/contact-sales-leads': contactSalesLeadsController.getByOrganizationId,
'GET /organizations/:organizationId/sites/:siteId/contact-sales-lead': contactSalesLeadsController.checkBySite,
'PATCH /contact-sales-leads/:contactSalesLeadId': contactSalesLeadsController.update,

// Preflight checks (autofix deploy permission/capability validation)
'POST /sites/:siteId/preflight-checks': preflightChecksController.runChecks,
};

// Initialization of static and dynamic routes
Expand Down
3 changes: 3 additions & 0 deletions src/routes/required-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export const INTERNAL_ROUTES = [
'GET /organizations/:organizationId/sites/:siteId/contact-sales-lead',
'PATCH /contact-sales-leads/:contactSalesLeadId',

// Preflight checks - proxies user's Bearer token to AEM Author; end-user UI only
'POST /sites/:siteId/preflight-checks',

// Consumer management - admin-only, requires is_s2s_admin; not for general S2S consumers
'GET /consumers',
'GET /consumers/by-client-id/:clientId',
Expand Down
118 changes: 118 additions & 0 deletions src/support/preflight-checks/handlers/content-api-access.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { tracingFetch as fetch } from '@adobe/spacecat-shared-utils';

const CHECK_TYPE = 'content-api-access';

/**
* Content API probe path — matches the UI-side probe
* (contentApiAccessCheck.ts in experience-success-studio-ui).
* Uses the experimental endpoint that AEM CS instances expose;
* returns 404 when Content API is not deployed (Rotary Release < 23963).
*/
const CONTENT_API_PROBE_PATH = '/adobe/experimental/expires-20251231/pages?limit=1';

/**
* Probes the AEM Author Content API to verify it is reachable and the caller
* has sufficient permissions.
*
* Endpoint: {authorURL}/adobe/experimental/expires-20251231/pages?limit=1
*
* Granular failure detection:
* - Edge Delivery site → skipped (different deploy mechanism)
* - Network error / timeout → author instance unreachable
* - 404 → Content API not deployed
* - 401 / 403 → insufficient permissions
* - 2xx → Content API is accessible
*
* @param {Object} site - Site entity
* @param {Object} context - Request context (pathInfo.headers.authorization)
* @param {Object} log - Logger
* @returns {Promise<{type: string, status: string, message: string}>}
*/
export default async function contentApiAccessHandler(site, context, log) {
// Edge Delivery sites use a different deploy mechanism — skip
if (site.getDeliveryType() === 'aem_edge') {
return {
type: CHECK_TYPE,
status: 'PASSED',
message: 'Edge Delivery site — Content API check not required',
};
}

const deliveryConfig = site.getDeliveryConfig();
const authorURL = deliveryConfig?.authorURL;

if (!authorURL) {
return {
type: CHECK_TYPE,
status: 'FAILED',
message: 'Site has no authorURL configured',
};
}

const authorization = context.pathInfo?.headers?.authorization;
if (!authorization) {
return {
type: CHECK_TYPE,
status: 'FAILED',
message: 'Missing authorization header',
};
}

const probeUrl = `${authorURL}${CONTENT_API_PROBE_PATH}`;

try {
const response = await fetch(probeUrl, {
method: 'GET',
headers: { Authorization: authorization },
});

if (response.ok) {
return {
type: CHECK_TYPE,
status: 'PASSED',
message: 'Content API is accessible',
};
}

if (response.status === 404) {
return {
type: CHECK_TYPE,
status: 'FAILED',
message: 'Content API is not available on this AEM instance',
};
}

if (response.status === 401 || response.status === 403) {
return {
type: CHECK_TYPE,
status: 'FAILED',
message: 'Insufficient permissions for Content API',
};
}

return {
type: CHECK_TYPE,
status: 'FAILED',
message: `Content API returned unexpected status ${response.status}`,
};
} catch (error) {
log.error(`Content API probe failed for ${authorURL}: ${error.message}`);
return {
type: CHECK_TYPE,
status: 'FAILED',
message: 'Author instance is not reachable',
};
}
}
31 changes: 31 additions & 0 deletions src/support/preflight-checks/registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import contentApiAccessHandler from './handlers/content-api-access.js';

/**
* Registry of preflight check handlers.
*
* Each handler is an async function with the signature:
* (site, context, log) => Promise<{ type: string, status: string, message: string }>
*
* To add a new handler:
* 1. Create a file in ./handlers/ following the content-api-access.js pattern
* 2. Import it here and add an entry to the registry map
*
* The key is the check type string that callers pass in the request body.
*/
const checkHandlerRegistry = {
'content-api-access': contentApiAccessHandler,
};

export default checkHandlerRegistry;
Loading
Loading