Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"build": "hedy -v --test-bundle",
"deploy": "hedy -v --deploy --aws-deploy-bucket=spacecat-prod-deploy --pkgVersion=latest",
"deploy-stage": "hedy -v --deploy --aws-deploy-bucket=spacecat-stage-deploy --pkgVersion=latest",
"deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l latest --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h",
"deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l sandsinh --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h",
"deploy-secrets": "hedy --aws-update-secrets --params-file=secrets/secrets.env",
"docs": "npm run docs:lint && npm run docs:build",
"docs:build": "npx @redocly/cli build-docs -o ./docs/index.html --config docs/openapi/redocly-config.yaml",
Expand Down
65 changes: 65 additions & 0 deletions src/controllers/tools.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright 2025 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 {
badRequest,
internalServerError,
ok,
} from '@adobe/spacecat-shared-http-utils';
import { hasText } from '@adobe/spacecat-shared-utils';
import { validateRepoUrl } from '../utils/validations.js';
import { resolveHlxConfigFromGitHubURL } from '../support/hlx-config.js';

/**
* Tools Controller. Provides generic utility endpoints not tied to a specific site.
* @param {object} ctx - Context of the request.
* @param {object} log - Logger instance.
* @param {object} env - Environment variables.
* @returns {object} Tools controller.
*/
function ToolsController(ctx, log, env) {
/**
* Resolves hlxConfig and code attributes from a GitHub URL by querying the
* admin.hlx.page API and falling back to fstab.yaml. Read-only — does not
* persist anything.
* @param {object} context - Context of the request.
* @returns {Promise<Response>} Resolved hlxConfig and code.
*/
const resolveConfig = async (context) => {
const { gitHubURL } = context.data;
if (!hasText(gitHubURL)) {
return badRequest('gitHubURL is required');
}

if (!validateRepoUrl(gitHubURL)) {
return badRequest('Invalid GitHub repository URL');
}

const hlxAdminToken = env.HLX_ADMIN_TOKEN;
if (!hasText(hlxAdminToken)) {
log.error('HLX_ADMIN_TOKEN is not configured');
return internalServerError('HLX admin token not configured');
}

try {
const result = await resolveHlxConfigFromGitHubURL(gitHubURL, hlxAdminToken, log);
return ok(result);
} catch (e) {
log.error(`Error resolving config from ${gitHubURL}: ${e.message}`);
return internalServerError('Failed to resolve config');
}
};

return { resolveConfig };
}

export default ToolsController;
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import AutofixChecksController from './controllers/autofix-checks.js';
import routeRequiredCapabilities from './routes/required-capabilities.js';
import ContactSalesLeadsController from './controllers/contact-sales-leads.js';
import PageRelationshipsController from './controllers/page-relationships.js';
import ToolsController from './controllers/tools.js';

const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

Expand Down Expand Up @@ -233,6 +234,7 @@ async function run(request, context) {
const featureFlagsController = FeatureFlagsController(context);
const autofixChecksController = AutofixChecksController(context);
const pageRelationshipsController = PageRelationshipsController(context);
const toolsController = ToolsController(context, log, context.env);

const routeHandlers = getRouteHandlers(
auditsController,
Expand Down Expand Up @@ -282,6 +284,7 @@ async function run(request, context) {
contactSalesLeadsController,
featureFlagsController,
pageRelationshipsController,
toolsController,
ephemeralRunController,
autofixChecksController,
);
Expand Down
3 changes: 3 additions & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ function isStaticRoute(routePattern) {
* @param {Object} contactSalesLeadsController - The contact sales leads controller.
* @param {Object} featureFlagsController - Organization feature flags (mysticat) controller.
* @param {Object} pageRelationshipsController - The page relationships controller.
* @param {Object} toolsController - The tools controller.
* @param {Object} ephemeralRunController - The ephemeral run batch controller.
* @param {Object} autofixChecksController - Autofix checks controller for autofix deploy.
* @return {{staticRoutes: {}, dynamicRoutes: {}}} - An object with static and dynamic routes.
Expand Down Expand Up @@ -145,6 +146,7 @@ export default function getRouteHandlers(
contactSalesLeadsController,
featureFlagsController,
pageRelationshipsController,
toolsController,
ephemeralRunController,
autofixChecksController,
) {
Expand Down Expand Up @@ -349,6 +351,7 @@ export default function getRouteHandlers(
'POST /slack/events': slackController.handleEvent,
'POST /slack/channels/invite-by-user-id': slackController.inviteUserToChannel,
'GET /trigger': triggerHandler,
'POST /tools/resolve-config': toolsController.resolveConfig,
'POST /tools/api-keys': apiKeyController.createApiKey,
'DELETE /tools/api-keys/:id': apiKeyController.deleteApiKey,
'GET /tools/api-keys': apiKeyController.getApiKeys,
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 @@ -76,6 +76,9 @@ export const INTERNAL_ROUTES = [
'POST /sites/:siteId/llmo/edge-optimize-routing',
'PUT /sites/:siteId/llmo/opportunities-reviewed',

// Resolve config - UI-only, resolves hlxConfig/code from GitHub URL
'POST /tools/resolve-config',

// PLG onboarding - IMS token auth, self-service flow, not S2S
'POST /plg/onboard',
'GET /plg/sites',
Expand Down
177 changes: 177 additions & 0 deletions src/support/hlx-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/*
* Copyright 2025 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 {
isObject,
isValidUrl,
tracingFetch as fetch,
} from '@adobe/spacecat-shared-utils';
import yaml from 'js-yaml';

/**
* Parses a domain string for the HLX RSO pattern: ref--site--owner.(hlx.live|aem.live)
* @param {string} domain - The domain to parse.
* @returns {object|null} - Parsed RSO object or null if no match.
*/
export function parseHlxRSO(domain) {
const regex = /^([\w-]+)--([\w-]+)--([\w-]+)\.(hlx\.live|aem\.live)$/;
const match = domain.match(regex);

if (!match) {
return null;
}

return {
ref: match[1],
site: match[2],
owner: match[3],
tld: match[4],
};
}

/**
* Fetches the aggregated HLX config from admin.hlx.page for the given owner/site.
* Returns the config object containing cdn, code, and content, or null if not found.
* @param {object} hlxConfig - The hlx config object with rso.owner and rso.site.
* @param {string} hlxAdminToken - The HLX admin API token.
* @param {object} log - The logger object.
* @returns {Promise<object|null>} - The config from admin API, or null.
*/
export async function fetchHlxConfig(hlxConfig, hlxAdminToken, log) {
const { hlxVersion, rso } = hlxConfig;

if (hlxVersion < 5) {
log.info(`HLX version is ${hlxVersion}. Skipping fetching hlx config`);
return null;
}

const { owner, site } = rso;
const url = `https://admin.hlx.page/config/${owner}/aggregated/${site}.json`;

try {
const response = await fetch(url, {
headers: { Authorization: `token ${hlxAdminToken}` },
});

if (response.status === 200) {
return response.json();
}

if (response.status === 404) {
log.debug(`No hlx config found for ${owner}/${site}`);
return null;
}

log.error(`Error fetching hlx config for ${owner}/${site}. Status: ${response.status}. Error: ${response.headers.get('x-error')}`);
} catch (e) {
log.error(`Error fetching hlx config for ${owner}/${site}`, e);
}

return null;
}

/**
* Fetches the content source from fstab.yaml in the GitHub repository.
* @param {object} hlxConfig - The hlx config object with rso.ref, rso.site, rso.owner.
* @param {object} log - The logger object.
* @returns {Promise<object|null>} - Content source object or null.
*/
export async function getContentSource(hlxConfig, log) {
const { ref, site: repo, owner } = hlxConfig.rso;

const fstabResponse = await fetch(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/fstab.yaml`);

if (fstabResponse.status !== 200) {
log.error(`Error fetching fstab.yaml for ${owner}/${repo}. Status: ${fstabResponse.status}`);
return null;
}

const fstabContent = await fstabResponse.text();
const parsedContent = yaml.load(fstabContent);

const url = parsedContent?.mountpoints
? Object.entries(parsedContent.mountpoints)?.[0]?.[1]
: null;

if (!isValidUrl(url)) {
log.debug(`No content source found for ${owner}/${repo} in fstab.yaml`);
return null;
}

const type = url.includes('drive.google') ? 'drive.google' : 'onedrive';
return { source: { type, url } };
}

/**
* Resolves the full HLX config and code object from a GitHub repository URL.
*
* Calls admin.hlx.page to fetch cdn, code, and content config. Falls back to
* reading fstab.yaml from GitHub for content source if the admin API returns nothing.
*
* @param {string} gitHubURL - GitHub repository URL (e.g. https://github.com/owner/repo).
* @param {string} hlxAdminToken - The HLX admin API token.
* @param {object} log - The logger object.
* @returns {Promise<{hlxConfig: object, code: object}>} - Resolved hlxConfig and code.
*/
export async function resolveHlxConfigFromGitHubURL(gitHubURL, hlxAdminToken, log) {
const parsedUrl = new URL(gitHubURL);
const pathParts = parsedUrl.pathname.split('/').filter(Boolean);
const owner = pathParts[0];
const repo = pathParts[1];

const hlxConfig = {
hlxVersion: 5,
rso: { owner, site: repo, ref: 'main' },
};

// Fetch full config from admin.hlx.page
const adminConfig = await fetchHlxConfig(hlxConfig, hlxAdminToken, log);
if (isObject(adminConfig)) {
const { cdn, code, content } = adminConfig;
if (isObject(cdn)) {
hlxConfig.cdn = cdn;
// If CDN prod host is available, derive full rso with tld
if (cdn.prod?.host) {
const rso = parseHlxRSO(cdn.prod.host);
if (rso) {
hlxConfig.rso = rso;
}
}
}
if (isObject(code)) {
hlxConfig.code = code;
}
if (isObject(content)) {
hlxConfig.content = content;
}
} else {
// Fallback: read fstab.yaml for content source
try {
const content = await getContentSource(hlxConfig, log);
if (isObject(content)) {
hlxConfig.content = content;
}
} catch (e) {
log.error(`Error fetching fstab.yaml for ${owner}/${repo}: ${e.message}`);
}
}

const code = {
type: 'github',
owner,
repo,
ref: 'main',
url: gitHubURL,
};

return { hlxConfig, code, adminConfig };
}
Loading
Loading