Skip to content
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,19 @@ SEO_CLIENT_SECRET=
# silently point a fresh local stack at the live upstream.
# SEMRUSH_PROJECTS_BASE_URL=https://adobe-hackathon.semrush.com

# ── Semrush User Manager provisioning (POST /serenity/members) ────────────────
# Dedicated Semrush IMS technical account whose client_credentials token authorizes
# the workspace member-add call (NOT the calling user's token — this is what lets the
# flow provision a user who is not yet a workspace member). Token endpoint host is
# derived from IMS_HOST; scope defaults to
# openid,AdobeID,user_management_sdk,additional_info.projectedProductContext.
# SEMRUSH_IMS_TECH_ID=
# SEMRUSH_IMS_TECH_SECRET=
# SEMRUSH_IMS_TECH_SCOPE=
# When 'true', a brand-scoped Semrush READ that 401/403s because the caller is not yet a
# workspace member auto-provisions them (viewer) via the token above, then retries once.
# Default off — enabling is a config flip once the dedicated IMS account is wired.
# SERENITY_MEMBER_AUTOPROVISION=false

# ── Server port (optional, default 3002) ──────────────────────────────────────
# PORT=3001
123 changes: 103 additions & 20 deletions src/controllers/serenity.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
resolveBrandWorkspace,
clearBrandWorkspaceCache,
} from '../support/serenity/workspace-resolver.js';
import { mintSemrushImsToken } from '../support/serenity/semrush-ims-token.js';
import { withMemberAutoProvision } from '../support/serenity/member-autoprovision.js';
import {
handleListPrompts,
handleCreatePrompts,
Expand Down Expand Up @@ -80,7 +82,7 @@ import { resolveBrandUuid } from '../support/prompts-storage.js';
import {
getBrandAliases, getBrandUrlSources, getBrandCompetitors, updateBrand, getBrandBaseSiteId,
} from '../support/brands-storage.js';
import { ErrorWithStatusCode, resolveSemrushImsToken as resolveImsTokenViaPromise } from '../support/utils.js';
import { ErrorWithStatusCode, resolveSemrushImsToken as resolveImsTokenViaPromise, resolveCallerEmail } from '../support/utils.js';
import { hostnameFromUrlString } from '../support/url-utils.js';
import { ensureMarketSite, resolveSiteDomain, unlinkMarketSiteIfOrphaned } from '../support/serenity/site-linkage.js';
import { X_PROMISE_TOKEN_HEADER, PROMISE_TOKEN_REQUIRED_ERROR_CODE } from '../utils/constants.js';
Expand Down Expand Up @@ -200,6 +202,17 @@ function mapError(e, log) {
err.status,
);
}
if (err.status === 422) {
// An upstream unprocessable-entity refusal (e.g. member add rejected with
// "corporate account does not have enough user units" / limit_exceeded) is the
// caller's to act on, not an outage. Surface 422 with a specific token instead of
// flattening to a generic 502; the body (limit flag + emails) stays server-side only
// (logged above), consistent with the 401/403 redaction.
return createResponse(
{ error: 'unprocessableEntity', message: 'Upstream rejected the request as unprocessable (quota or validation)' },
422,
);
}
return createResponse({
error: 'serenityUpstreamError',
message: 'Upstream request failed',
Expand Down Expand Up @@ -449,6 +462,28 @@ function SerenityController(context, log, env) {
return createSerenityTransport({ env: ctx.env || env, imsToken });
}

// Auto-provision-on-401/403 flag (env/Vault boolean, default OFF). When a brand-scoped
// Semrush READ fails because the caller is not yet a member of the workspace, provision
// them on the fly (dedicated IMS token → add member, viewer) and retry the read once.
// Only meaningful when Semrush is integrated for the org (serenity active + a workspace
// resolves) — which `authorize` already guarantees before any read handler runs.
const memberAutoProvisionEnabled = (ctx) => (ctx?.env || env)?.SERENITY_MEMBER_AUTOPROVISION === 'true';

/**
* Wraps a Semrush read so a "caller not yet a member" 401/403 self-heals: on that
* upstream denial (and only when the flag is on and we have a workspace + caller email),
* the calling user is granted viewer access and the read is retried once. Best-effort —
* see member-autoprovision.js. `run` must be idempotent (it may execute twice).
*/
const readWithProvision = (ctx, auth, run) => withMemberAutoProvision({
run,
env: ctx.env || env,
log,
enabled: memberAutoProvisionEnabled(ctx),
workspaceId: auth.workspaceId,
memberEmail: resolveCallerEmail(ctx),
});

// Global dynamic-allocation kill-switch for this request (env/Vault boolean, default OFF). Read
// per request off ctx.env, mirroring buildTransport's env resolution. When OFF the metered
// handlers front through a no-op guard (byte-for-byte pre-PR behavior).
Expand Down Expand Up @@ -510,15 +545,15 @@ function SerenityController(context, log, env) {
return auth.error;
}
const transport = buildTransport(ctx, imsToken);
const result = auth.mode === 'subworkspace'
? await handleListPromptsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
: await handleListPrompts(
const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace'
? handleListPromptsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
: handleListPrompts(
transport,
ctx.dataAccess,
auth.brandUuid,
auth.workspaceId,
parsedQuery(ctx),
);
)));
return createResponse(result, 200);
} catch (e) {
return mapError(e, log);
Expand Down Expand Up @@ -705,8 +740,8 @@ function SerenityController(context, log, env) {
return auth.error;
}
const transport = buildTransport(ctx, imsToken);
const result = auth.mode === 'subworkspace'
? await handleListMarketsSubworkspace(
const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace'
? handleListMarketsSubworkspace(
transport,
/** @type {string} */ (auth.brandUuid),
/** @type {string} */ (auth.workspaceId),
Expand All @@ -715,12 +750,12 @@ function SerenityController(context, log, env) {
ctx.dataAccess,
log,
)
: await handleListMarkets(
: handleListMarkets(
transport,
ctx.dataAccess,
auth.brandUuid,
auth.workspaceId,
);
)));
return createResponse(result, 200);
} catch (e) {
return mapError(e, log);
Expand All @@ -742,8 +777,8 @@ function SerenityController(context, log, env) {
// coerce '2840abc' → 2840 and silently resolve a different slice.
const geoTargetId = /^\d+$/.test(String(pGeo || '')) ? Number(pGeo) : null;
const languageCode = pLang ? String(pLang).toLowerCase() : null;
const result = auth.mode === 'subworkspace'
? await handleGetMarketSubworkspace(
const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace'
? handleGetMarketSubworkspace(
buildTransport(ctx, imsToken),
auth.brandUuid,
auth.workspaceId,
Expand All @@ -753,7 +788,7 @@ function SerenityController(context, log, env) {
// Enrich the resolved slice with its siteId (LLMO-6405 Phase 2).
ctx.dataAccess,
)
: await handleGetMarket(ctx.dataAccess, auth.brandUuid, geoTargetId, languageCode);
: handleGetMarket(ctx.dataAccess, auth.brandUuid, geoTargetId, languageCode)));
return createResponse(result, 200);
} catch (e) {
return mapError(e, log);
Expand Down Expand Up @@ -996,16 +1031,16 @@ function SerenityController(context, log, env) {
return auth.error;
}
const transport = buildTransport(ctx, imsToken);
const result = auth.mode === 'subworkspace'
? await handleListTagsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
: await handleListTags(
const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace'
? handleListTagsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
: handleListTags(
transport,
ctx.dataAccess,
auth.brandUuid,
auth.workspaceId,
parsedQuery(ctx),
log,
);
)));
return createResponse(result, 200);
} catch (e) {
return mapError(e, log);
Expand Down Expand Up @@ -1105,15 +1140,15 @@ function SerenityController(context, log, env) {
return auth.error;
}
const transport = buildTransport(ctx, imsToken);
const result = auth.mode === 'subworkspace'
? await handleListModelsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
: await handleListModels(
const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace'
? handleListModelsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
: handleListModels(
transport,
ctx.dataAccess,
auth.brandUuid,
auth.workspaceId,
parsedQuery(ctx),
);
)));
return createResponse(result, 200);
} catch (e) {
return mapError(e, log);
Expand Down Expand Up @@ -1231,6 +1266,53 @@ function SerenityController(context, log, env) {
}
};

/**
* POST /serenity/members — grant one or more users a Semrush workspace role
* (RBAC write slice; ADR-draft-2/3). Resolves the brand's workspace via
* `authorize` (the brand's sub-workspace in subworkspace mode, else the org's
* flat parent workspace) and calls Semrush's User Manager
* `POST /v1/workspaces/{ws}/members`. Body: `{ members: string[], role?: string }`;
* `role` defaults to `role/workspace/viewer`.
*
* Auth: the Adobe caller is authorized here at the ORG level (`authorize` →
* AccessControlUtil), but the OUTBOUND Semrush call is authenticated with a token
* minted for the DEDICATED Semrush IMS technical account (SEMRUSH_IMS_TECH_*),
* NOT the caller's own token. This is what lets the grant provision a user who is
* not yet a member of the workspace — the mint identity holds the member-management
* rights, so the "user hits 401/403 → provision them" flow can succeed.
*/
const addMembers = async (ctx) => {
try {
const auth = await authorize(ctx);
if (auth.error) {
return auth.error;
}
const body = ctx.data || {};
const members = Array.isArray(body.members)
? body.members.filter((m) => hasText(m))
: [];
if (members.length === 0) {
throw new ErrorWithStatusCode('members must be a non-empty array of user identifiers', 400);
}
const role = hasText(body.role) ? body.role : 'role/workspace/viewer';
// Mint the dedicated Semrush IMS technical-account token (NOT the caller's) so a
// not-yet-a-member user can still be provisioned. Minted only after the org-level
// authorize gate + input validation pass, so a bad request never mints a token.
const imsToken = await mintSemrushImsToken(ctx.env || env, log);
const transport = buildTransport(ctx, imsToken);
const result = await transport.addWorkspaceMembers(
/** @type {string} */ (auth.workspaceId),
members,
role,
);
// Semrush may answer 2xx with an empty body; echo the grant so the spike
// caller sees what landed.
return createResponse(isNonEmptyObject(result) ? result : { members, role }, 200);
} catch (e) {
return mapError(e, log);
}
};

/**
* POST /serenity/activate — flips a brand into subworkspace mode (design flow 5):
* ensure the subworkspace, then per caller-supplied market create a draft,
Expand Down Expand Up @@ -1795,6 +1877,7 @@ function SerenityController(context, log, env) {
listOrgModels,
listOrgLanguages,
updateModels,
addMembers,
activate,
deactivate,
};
Expand Down
4 changes: 4 additions & 0 deletions src/routes/facs-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,10 @@ const routeFacsCapabilities = {
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/tags': 'llmo/can_configure',
'PATCH /v2/orgs/:spaceCatId/brands/:brandId/serenity/tags/:tagId': 'llmo/can_configure',
'PUT /v2/orgs/:spaceCatId/brands/:brandId/serenity/models': 'llmo/can_configure',
// SPIKE (ADR-draft-2): RBAC member grant. Mirrors sibling write ops as
// can_configure; the correct level (can_configure vs can_manage_users) is
// an OPEN design question tied to the add-member auth decision.
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/members': 'llmo/can_configure',
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate': 'llmo/can_configure',
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/deactivate': 'llmo/can_configure',
// Prompt suitability check — body-based mutation against the brand
Expand Down
1 change: 1 addition & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export default function getRouteHandlers(
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/source-visibility-headline': elementsController.getSourceVisibilityHeadline,
// Brand-independent Semrush language catalog (add-brand wizard language picker).
'GET /v2/orgs/:spaceCatId/serenity/languages': serenityController.listOrgLanguages,
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/members': serenityController.addMembers,
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate': serenityController.activate,
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/deactivate': serenityController.deactivate,
'POST /v2/orgs/:spaceCatId/semrush-onboarding': onboardingController.triggerOnboarding,
Expand Down
1 change: 1 addition & 0 deletions src/routes/required-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ const routeRequiredCapabilities = {
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/kpi-headlines': 'brand:read',
// eslint-disable-next-line max-len
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/source-visibility-headline': 'brand:read',
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/members': 'organization:write',
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate': 'organization:write',
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/deactivate': 'organization:write',
'GET /v2/orgs/:spaceCatId/sites/:siteId/brand': 'organization:read',
Expand Down
101 changes: 101 additions & 0 deletions src/support/serenity/member-autoprovision.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* 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 { hasText } from '@adobe/spacecat-shared-utils';
import { isSemrushTransportError, unwrapTransportCause } from './errors.js';
import { mintSemrushImsToken } from './semrush-ims-token.js';
import { createSerenityTransport } from './rest-transport.js';

const DEFAULT_ROLE = 'role/workspace/viewer';

/**
* True when the error is a Semrush auth denial (HTTP 401/403) on an outbound call.
* 403 is the fixable "authenticated but not yet a workspace member" case; 401 (an
* invalid/expired token) is included for completeness, but the single non-looping
* retry below makes an unfixable 401 harmless.
*
* @param {unknown} e
* @returns {boolean}
*/
export function isSemrushMembershipDenied(e) {
const err = unwrapTransportCause(e);
return isSemrushTransportError(err) && (err.status === 401 || err.status === 403);
}

/**
* Runs a Semrush data read and, if it fails because the caller is not yet a member of
* the workspace (upstream 401/403), provisions the caller onto the workspace (viewer
* role) using the DEDICATED Semrush IMS technical-account token, then runs the read
* exactly once more.
*
* Guarantees:
* - SINGLE retry — never loops, even if the retry also 401/403s.
* - Best-effort provisioning: if the grant itself fails (e.g. 422 "no user units", or a
* token-mint config error), the ORIGINAL read error is surfaced — the grant error
* never masks why the read failed.
* - Transparent no-op (runs the read once, unwrapped) when disabled, when
* workspaceId/memberEmail is missing, or when the error is not a Semrush 401/403.
*
* The read keeps using the caller's own transport (passed in via `run`); provisioning
* uses a SEPARATE admin transport built from the minted service token.
*
* @template T
* @param {object} params
* @param {() => Promise<T>} params.run - executes the Semrush read; must be safe to call twice.
* @param {object} params.env
* @param {{ info: Function, error: (m: string, meta?: object) => void }} params.log
* @param {boolean} params.enabled - the SERENITY_MEMBER_AUTOPROVISION flag.
* @param {string | null | undefined} params.workspaceId - the resolved brand workspace.
* @param {string | null | undefined} params.memberEmail - the calling user's email.
* @param {string} [params.role] - Semrush role to grant (default `role/workspace/viewer`).
* @returns {Promise<T>}
*/
export async function withMemberAutoProvision({
run, env, log, enabled, workspaceId, memberEmail, role = DEFAULT_ROLE,
}) {
if (!enabled) {
return run();
}
try {
return await run();
} catch (readError) {
// The truthiness checks also narrow the `string | null | undefined` params to
// `string` for TS (hasText is not a type guard — see this dir's CLAUDE.md).
if (!isSemrushMembershipDenied(readError)
|| !workspaceId || !hasText(workspaceId)
|| !memberEmail || !hasText(memberEmail)) {
throw readError;
}
try {
log.info('[serenity] auto-provisioning workspace member after upstream 401/403', {
workspaceId,
});
const imsToken = await mintSemrushImsToken(env, log);
const adminTransport = createSerenityTransport({ env, imsToken });
await adminTransport.addWorkspaceMembers(workspaceId, [memberEmail], role);
} catch (provisionError) {
// Provisioning is best-effort: surface the ORIGINAL read error (the 401/403), not
// the grant error, so a "no seats" (422) or mint-config failure never masks the
// reason the read failed. The grant error is logged for diagnosis only.
log.error('[serenity] member auto-provision failed; surfacing original read error', {
workspaceId,
provisionError: provisionError?.message,
});
throw readError;
}
// Provisioned — retry the read exactly once. If it still fails, that error propagates
// (no further provisioning attempt).
return run();
}
}
Loading
Loading