Skip to content
Open
90 changes: 72 additions & 18 deletions docs/index.html

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,12 @@ SiteUpdate:
name:
description: The name of the site
type: string
baseURL:
description: >-
The base URL of the site. Changing the URL of a site attached to a
Semrush-managed brand's own primary site propagates the change to
Semrush; a market-mirror site stays immutable — see PATCH /sites/{siteId}.
$ref: '#/URL'
deliveryType:
description: The type of the delivery this site is using
$ref: '#/DeliveryType'
Expand Down
129 changes: 103 additions & 26 deletions src/controllers/sites.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
isArray,
getStoredMetrics,
isValidUUID,
isValidUrl,
deepEqual,
isNonEmptyObject,
canonicalizeUrl,
Expand All @@ -48,7 +49,7 @@ import { validateRepoUrl } from '../utils/validations.js';
import { applyFieldProjection } from '../utils/field-projection.js';
import {
wwwUrlResolver, resolveWwwUrl, getAsoEntitlement, getAsoTier, CUSTOMER_VISIBLE_TIERS,
isInternalOrg,
isInternalOrg, resolveSemrushImsToken,
} from '../support/utils.js';
import AccessControlUtil from '../support/access-control-util.js';
import { CAP_SITE_READ_ALL, CAP_SITE_CREATE } from '../routes/capability-constants.js';
Expand All @@ -62,6 +63,10 @@ import {
resolveProductCode,
} from '../support/tier-provisioning.js';
import { getBrandBySite, isSemrushMarketMirrorSite } from '../support/brands-storage.js';
import { normalizeHostCase } from '../support/url-utils.js';
import { createSerenityTransport } from '../support/serenity/rest-transport.js';
import { propagateSiteUrlToSemrush } from '../support/serenity/site-url-propagation.js';
import { isSemrushTransportError, unwrapTransportCause } from '../support/serenity/errors.js';
import { listViewableResourceIds } from '../support/state-access-mapping-utils.js';
import { requirePostgrestForFacsMappings } from '../support/postgrest-availability.js';
import { isFacsRebacResource } from '../routes/facs-capabilities.js';
Expand Down Expand Up @@ -1194,57 +1199,129 @@ function SitesController(ctx, log, env) {
return badRequest('Request body required');
}

// A site's URL is immutable once it backs a Semrush-managed brand. That brand's
// tracked domain lives on its Semrush projects/markets, which have no
// domain-update path (the domain is set only at project-create time), so
// letting the SpaceCat site URL drift would desync the site from its Semrush
// projects. Only the URL is gated here — every other site field stays editable.
// The brand lookup runs only when a URL change is actually requested, so the
// common patch path (no baseURL) pays no extra query.
//
// TODO (~2026-07-07): Semrush is expected to support changing a project's
// primary URL — which is its main (own-brand, `main_brand: true`) benchmark
// domain, see ensureOwnBrandBenchmark in support/serenity/brand-urls.js — in
// ~2 weeks (heads-up received 2026-06-23). Once available, relax this guard to
// propagate the new URL to each market's main benchmark (and republish) instead
// of blocking the edit.
if (hasText(requestBody.baseURL) && requestBody.baseURL !== site.getBaseURL()) {
// Normalize a trailing slash ONCE, up front — so `https://x.com/` and `https://x.com`
// (or a subpath with/without one) compare, collide-check, propagate, and persist
// identically, matching how `createSite` normalizes its input before ever touching
// `Site.findByBaseURL`. Deliberately NOT reusing `composeBaseURL`/`canonicalizeUrl`
// here — both also strip `www.`, which this feature must NOT collapse (a Semrush
// project's identity treats `www.x.com` and `x.com` as distinct sites; see
// `siteIdentityFromUrlString`'s own "do NOT collapse www vs apex" contract). The host's
// case is normalized here too — hosts are case-insensitive (RFC 3986 / WHATWG URL), so
// `Site1.com` and `site1.com` must compare, collide-check, propagate, and persist as the
// same value — but the path is left untouched, since paths ARE case-sensitive.
const nextBaseURL = hasText(requestBody.baseURL)
? normalizeHostCase(requestBody.baseURL.replace(/\/$/, ''))
: requestBody.baseURL;

// A site's URL backing a Semrush-managed brand can be changed, but only for the
// brand's OWN primary site — see serenity-docs#349. A registrable-domain change is
// allowed too: live-verified against adobe-hackathon.semrush.com (2026-08-18) that
// Semrush's project PATCH accepts and persists a changed `domain` (not just
// `primary_url`), and that a subsequent publish settles cleanly with no project
// recreation — the "would this lose history?" concern that originally motivated
// refusing cross-domain edits does not hold. propagateSiteUrlToSemrush keeps the
// project's `domain` in step with the new registrable domain in this case (see that
// module). A market-mirror site (linked via brand_sites, type='serenity') stays
// immutable: whether/how a mirror should follow a rename is a separate, open decision
// (issue #349 workstream 4) this change does not make. The brand lookup runs only when
// a URL change is actually requested, so the common patch path (no baseURL) pays no
// extra query.
if (hasText(nextBaseURL) && nextBaseURL !== site.getBaseURL()) {
if (!isValidUrl(nextBaseURL)) {
return badRequest('baseURL must be a valid URL');
}

const collidingSite = await Site.findByBaseURL(nextBaseURL);
if (collidingSite && collidingSite.getId() !== site.getId()) {
return createResponse({
message: 'A site with this baseURL already exists',
code: 'siteUrlTaken',
}, 409);
}

const postgrestClient = context.dataAccess?.services?.postgrestClient;
if (postgrestClient?.from) {
// Two ways a site can back a Semrush-managed brand, both immutable:
// Two ways a site can back a Semrush-managed brand:
// - the brand's OWN primary site (brands.site_id) — getBrandBySite, or
// - a Semrush market mirror linked via brand_sites (type='serenity'),
// which a serenity brand shell (no brands.site_id) reaches ONLY here.
// The lookups can throw on a transient PostgREST error; map that to a 5xx
// rather than letting it escape this catch-less handler as an opaque 500.
let attachedToSemrushBrand = false;
let attachedBrand = null;
let isMirror = false;
try {
const attachedBrand = await getBrandBySite(
attachedBrand = await getBrandBySite(
site.getOrganizationId(),
site.getId(),
postgrestClient,
log,
);
attachedToSemrushBrand = hasText(attachedBrand?.semrushSubWorkspaceId)
|| await isSemrushMarketMirrorSite(
site.getOrganizationId(),
site.getId(),
postgrestClient,
);
isMirror = !attachedBrand && await isSemrushMarketMirrorSite(
site.getOrganizationId(),
site.getId(),
postgrestClient,
);
} catch (lookupError) {
log.error('updateSite: failed to resolve Semrush-brand attachment for URL-immutability guard', {
siteId: site.getId(),
error: lookupError?.message,
});
return internalServerError('Could not verify whether this site URL is editable; please retry');
}
if (attachedToSemrushBrand) {

// v1 scope: market-mirror sites stay immutable (see comment above).
if (isMirror) {
return forbidden('Updating the URL of a site attached to a Semrush-managed brand is not allowed');
}

if (hasText(attachedBrand?.semrushSubWorkspaceId)) {
try {
await propagateSiteUrlToSemrush({
dataAccess: context.dataAccess,
transport: createSerenityTransport({
env: context.env,
imsToken: await resolveSemrushImsToken(context, log, 'sites'),
}),
workspaceId: attachedBrand.semrushSubWorkspaceId,
brandId: attachedBrand.id,
siteId: site.getId(),
brandIdentity: { name: attachedBrand.name, aliases: attachedBrand.brandAliases },
newBaseURL: nextBaseURL,
log,
});
} catch (propagationError) {
const err = unwrapTransportCause(propagationError);
log.error('updateSite: Semrush URL propagation failed', {
siteId: site.getId(),
brandId: attachedBrand.id,
status: err?.status,
error: err?.message,
});
if (isSemrushTransportError(err)) {
// Never echo an upstream message (it embeds the gateway host + workspace/
// project UUIDs) — mirrors the brands.js re-sync's error hygiene.
return createResponse({ message: 'Failed to update the tracked URL in Semrush' }, 502);
}
const status = err?.status || 500;
return createResponse({
message: status === 500 ? 'Failed to update the tracked URL in Semrush' : err.message,
...(err?.code ? { code: err.code } : {}),
}, status);
// toQuotaExceededError() sets status=409, code='quotaExceeded' — passes through above.
}
// NOT persisted on failure: the Semrush call runs BEFORE site.save() below, so a
// thrown error here returns before `updates` is ever set for baseURL.
}
}
}

let updates = false;

if (hasText(nextBaseURL) && nextBaseURL !== site.getBaseURL()) {
site.setBaseURL(nextBaseURL);
updates = true;
}

if (isBoolean(requestBody.isLive) && requestBody.isLive !== site.getIsLive()) {
site.toggleLive();
updates = true;
Expand Down
22 changes: 22 additions & 0 deletions src/support/serenity/mapping-rows.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,28 @@ export async function linkSiteToLiveRows(dataAccess, brandId, siteId, log) {
}
}

/**
* Live `BrandSemrushProject` rows for a brand whose `siteId` matches. A brand's markets
* don't all share one Site — each distinct domain gets its own mirror Site (see
* `ensureMarketSite`/`linkSiteToLiveRows` above) — so this is the reference-counting
* query (same shape as the tombstone/link helpers' `allByBrandId`-then-filter) that
* scopes a site URL-change edit to only the project(s) actually tracking this site.
*
* @param {any} dataAccess
* @param {string|null|undefined} brandId
* @param {string|null|undefined} siteId
* @returns {Promise<Array<any>>} live rows linked to `siteId` (empty on missing input).
*/
export async function projectsForSite(dataAccess, brandId, siteId) {
const BrandSemrushProject = dataAccess?.BrandSemrushProject;
if (!BrandSemrushProject || !brandId || !hasText(brandId) || !siteId || !hasText(siteId)) {
return [];
}
const rows = await BrandSemrushProject.allByBrandId(brandId);
return (Array.isArray(rows) ? rows : [])
.filter((row) => !row.getDeletedAt() && row.getSiteId() === siteId);
}

/**
* Links `siteId` onto the ONE live mapping row named by `semrushProjectId`.
*
Expand Down
145 changes: 145 additions & 0 deletions src/support/serenity/site-url-propagation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* 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.
*/

// @ts-check

import { siteIdentityFromUrlString } from '@adobe/spacecat-shared-utils';

import { hostnameFromUrlString } from '../url-utils.js';
import { ensureOwnBrandBenchmark, republish } from './brand-urls.js';
import { projectsForSite } from './mapping-rows.js';

/** @typedef {import('./rest-transport.js').SerenityTransport} SerenityTransport */

/**
* Propagates a brand's own primary site's `baseURL` change onto Semrush: for every live
* market project mapped to this site, re-points the project's tracked `primary_url` AND its
* `domain` (the registrable-domain grouping key — Semrush normalizes whatever is sent to the
* eTLD+1 via the Public Suffix List, so a same-domain edit is a no-op on this field and a
* cross-domain edit moves it too; live-verified against adobe-hackathon.semrush.com
* 2026-08-18 that a changed `domain` is accepted, persists, and a subsequent publish settles
* cleanly with no project recreation), the own-brand benchmark's `domain` (full-body PUT — a
* field omitted from that PUT is CLEARED upstream, not preserved, see `rest-transport.js`'s
* `updateBenchmark` JSDoc — so this reads the benchmark first to carry its
* `brand_name`/`brand_aliases` forward unchanged), and republishes.
*
* Scope: a brand's sub-workspace can hold multiple market projects, and a Site can be
* shared by more than one of them (two locale variants of the same market both on one
* domain) — see `projectsForSite`. This updates every live project mapped to `siteId`,
* never the brand's other markets on a different domain.
*
* Errors (a transport failure, or `republish` throwing `toQuotaExceededError()` under the
* SITES-49206 convention) propagate to the caller rather than being swallowed — the
* caller (sites.js `updateSite`) persists the SpaceCat-side URL only after this resolves,
* so a thrown error here must fail the whole edit, not leave a partial update silent.
*
* @param {object} params
* @param {any} params.dataAccess - `ctx.dataAccess`, for `projectsForSite`.
* @param {SerenityTransport} params.transport
* @param {string} params.workspaceId - the brand's Semrush sub-workspace id.
* @param {string} params.brandId
* @param {string} params.siteId - the Site being edited.
* @param {{name: string, aliases?: Array<{name: string, regions?: string[]}>}} params.brandIdentity
* - the brand's own identity, for `ensureOwnBrandBenchmark`'s `brand` param.
* @param {string} params.newBaseURL - the site's new `baseURL`, as submitted.
* @param {object} [params.log]
* @returns {Promise<{projectsUpdated: number}>}
*/
export async function propagateSiteUrlToSemrush({
dataAccess, transport, workspaceId, brandId, siteId, brandIdentity, newBaseURL, log,
}) {
const newIdentity = siteIdentityFromUrlString(newBaseURL);
const newDomain = hostnameFromUrlString(newBaseURL);
if (newIdentity === null || newDomain === null) {
// The caller validates newBaseURL as a well-formed URL before calling this — a
// null identity/hostname here means the derivations disagree on parseability,
// which is a bug worth failing loudly on rather than sending a nullish
// `primary_url`/`domain` to Semrush (domain is required in practice, see
// updateBenchmark's JSDoc).
throw new Error(`site-url-propagation: could not derive a URL identity from "${newBaseURL}"`);
}

const rows = await projectsForSite(dataAccess, brandId, siteId);
if (rows.length === 0) {
// No live project mapped to this site yet (e.g. the mapping row's siteId link is
// still pending a best-effort write). Not an error — the caller still persists the
// SpaceCat-side baseURL.
log?.warn?.('site-url-propagation: no live projects mapped to this site; nothing to propagate', {
brandId, siteId,
});
return { projectsUpdated: 0 };
}

let projectsUpdated = 0;
for (const row of rows) {
const projectId = row.getSemrushProjectId();

try {
// eslint-disable-next-line no-await-in-loop
await transport.updateProject(workspaceId, projectId, {
type: 'ai', primary_url: newIdentity, domain: newDomain,
});

// eslint-disable-next-line no-await-in-loop
const benchmarkId = await ensureOwnBrandBenchmark(
transport,
workspaceId,
projectId,
{ name: brandIdentity.name, domain: newDomain, aliases: brandIdentity.aliases },
log,
);
if (benchmarkId) {
// Read the DRAFT view — the PUT below acts on the draft (see updateBenchmark's
// JSDoc / syncBrandAliasesAcrossMarkets), so a diff against the published view
// would be stale on a project with pending changes.
// eslint-disable-next-line no-await-in-loop
const resp = await transport.listBenchmarks(workspaceId, projectId, { draft: true });
const benchmarks = Array.isArray(resp?.aio_benchmarks) ? resp.aio_benchmarks : [];
const own = benchmarks.find((b) => String(b?.id) === benchmarkId);
// eslint-disable-next-line no-await-in-loop
await transport.updateBenchmark(workspaceId, projectId, benchmarkId, {
brand_name: own?.brand_name || brandIdentity.name,
domain: newDomain,
brand_aliases: Array.isArray(own?.brand_aliases) ? own.brand_aliases : [],
});
}

// NEW convention (SITES-49206): a real quota 405 now throws toQuotaExceededError()
// instead of silently leaving the project draft. Let it propagate.
// eslint-disable-next-line no-await-in-loop
await republish(transport, workspaceId, projectId, log);
} catch (e) {
// Name WHICH project failed and how many in THIS call already succeeded before it —
// mirrors brand-urls.js/brand-aliases.js's per-market fan-out logging — so an operator
// reading this line can tell "2 of 3 projects already re-pointed, the 3rd failed" rather
// than an opaque top-level error with no indication of partial progress. Log-then-rethrow:
// the caller (sites.js) still needs this to propagate so the SpaceCat-side URL isn't
// persisted on a failure (see the module JSDoc's "propagate before persist" ordering).
log?.error?.('site-url-propagation: failed re-pointing a project mid fan-out', {
brandId,
siteId,
projectId,
status: e?.status,
projectsUpdatedBeforeFailure: projectsUpdated,
totalProjects: rows.length,
});
throw e;
}

projectsUpdated += 1;
}

log?.info?.('site-url-propagation: re-pointed Semrush project(s) for a site URL change', {
brandId, siteId, projectsUpdated,
});
return { projectsUpdated };
}
Loading
Loading