Skip to content

Commit 3cf3cd6

Browse files
ravvermaclaude
andcommitted
feat(serenity): auto-provision workspace member on read 401/403 (flag-gated)
Productizes the member-add mechanism as the real flow: when a brand-scoped Semrush data read fails because the caller is not yet a workspace member (401/403), provision them (viewer) using the dedicated Semrush IMS token and retry the read once. - add src/support/serenity/member-autoprovision.js: withMemberAutoProvision (single retry, best-effort — a failed grant surfaces the original read error; no-op when disabled / missing ids / non-401-403) + isSemrushMembershipDenied. - controllers/serenity.js: wrap the 5 brand-scoped reads (prompts, markets, getMarket, tags, models) via readWithProvision; gate on SERENITY_MEMBER_AUTOPROVISION (default off) + the existing serenity-active/workspace gate; resolve caller email. - mapError: surface upstream 422 as unprocessableEntity (was flattened to 502); the limit/emails body stays server-side only. - utils.js: resolveCallerEmail. .env.example documents the flag. - tests: member-autoprovision (9), addMembers 422 passthrough, read-provision wiring (3). The standalone POST /serenity/members endpoint is kept for testing and must be removed before merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d95e2c6 commit 3cf3cd6

6 files changed

Lines changed: 404 additions & 20 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ SEO_CLIENT_SECRET=
135135
# SEMRUSH_IMS_TECH_ID=
136136
# SEMRUSH_IMS_TECH_SECRET=
137137
# SEMRUSH_IMS_TECH_SCOPE=
138+
# When 'true', a brand-scoped Semrush READ that 401/403s because the caller is not yet a
139+
# workspace member auto-provisions them (viewer) via the token above, then retries once.
140+
# Default off — enabling is a config flip once the dedicated IMS account is wired.
141+
# SERENITY_MEMBER_AUTOPROVISION=false
138142

139143
# ── Server port (optional, default 3002) ──────────────────────────────────────
140144
# PORT=3001

src/controllers/serenity.js

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
clearBrandWorkspaceCache,
2626
} from '../support/serenity/workspace-resolver.js';
2727
import { mintSemrushImsToken } from '../support/serenity/semrush-ims-token.js';
28+
import { withMemberAutoProvision } from '../support/serenity/member-autoprovision.js';
2829
import {
2930
handleListPrompts,
3031
handleCreatePrompts,
@@ -81,7 +82,7 @@ import { resolveBrandUuid } from '../support/prompts-storage.js';
8182
import {
8283
getBrandAliases, getBrandUrlSources, getBrandCompetitors, updateBrand, getBrandBaseSiteId,
8384
} from '../support/brands-storage.js';
84-
import { ErrorWithStatusCode, resolveSemrushImsToken as resolveImsTokenViaPromise } from '../support/utils.js';
85+
import { ErrorWithStatusCode, resolveSemrushImsToken as resolveImsTokenViaPromise, resolveCallerEmail } from '../support/utils.js';
8586
import { hostnameFromUrlString } from '../support/url-utils.js';
8687
import { ensureMarketSite, resolveSiteDomain, unlinkMarketSiteIfOrphaned } from '../support/serenity/site-linkage.js';
8788
import { X_PROMISE_TOKEN_HEADER, PROMISE_TOKEN_REQUIRED_ERROR_CODE } from '../utils/constants.js';
@@ -201,6 +202,17 @@ function mapError(e, log) {
201202
err.status,
202203
);
203204
}
205+
if (err.status === 422) {
206+
// An upstream unprocessable-entity refusal (e.g. member add rejected with
207+
// "corporate account does not have enough user units" / limit_exceeded) is the
208+
// caller's to act on, not an outage. Surface 422 with a specific token instead of
209+
// flattening to a generic 502; the body (limit flag + emails) stays server-side only
210+
// (logged above), consistent with the 401/403 redaction.
211+
return createResponse(
212+
{ error: 'unprocessableEntity', message: 'Upstream rejected the request as unprocessable (quota or validation)' },
213+
422,
214+
);
215+
}
204216
return createResponse({
205217
error: 'serenityUpstreamError',
206218
message: 'Upstream request failed',
@@ -450,6 +462,28 @@ function SerenityController(context, log, env) {
450462
return createSerenityTransport({ env: ctx.env || env, imsToken });
451463
}
452464

465+
// Auto-provision-on-401/403 flag (env/Vault boolean, default OFF). When a brand-scoped
466+
// Semrush READ fails because the caller is not yet a member of the workspace, provision
467+
// them on the fly (dedicated IMS token → add member, viewer) and retry the read once.
468+
// Only meaningful when Semrush is integrated for the org (serenity active + a workspace
469+
// resolves) — which `authorize` already guarantees before any read handler runs.
470+
const memberAutoProvisionEnabled = (ctx) => (ctx?.env || env)?.SERENITY_MEMBER_AUTOPROVISION === 'true';
471+
472+
/**
473+
* Wraps a Semrush read so a "caller not yet a member" 401/403 self-heals: on that
474+
* upstream denial (and only when the flag is on and we have a workspace + caller email),
475+
* the calling user is granted viewer access and the read is retried once. Best-effort —
476+
* see member-autoprovision.js. `run` must be idempotent (it may execute twice).
477+
*/
478+
const readWithProvision = (ctx, auth, run) => withMemberAutoProvision({
479+
run,
480+
env: ctx.env || env,
481+
log,
482+
enabled: memberAutoProvisionEnabled(ctx),
483+
workspaceId: auth.workspaceId,
484+
memberEmail: resolveCallerEmail(ctx),
485+
});
486+
453487
// Global dynamic-allocation kill-switch for this request (env/Vault boolean, default OFF). Read
454488
// per request off ctx.env, mirroring buildTransport's env resolution. When OFF the metered
455489
// handlers front through a no-op guard (byte-for-byte pre-PR behavior).
@@ -511,15 +545,15 @@ function SerenityController(context, log, env) {
511545
return auth.error;
512546
}
513547
const transport = buildTransport(ctx, imsToken);
514-
const result = auth.mode === 'subworkspace'
515-
? await handleListPromptsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
516-
: await handleListPrompts(
548+
const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace'
549+
? handleListPromptsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
550+
: handleListPrompts(
517551
transport,
518552
ctx.dataAccess,
519553
auth.brandUuid,
520554
auth.workspaceId,
521555
parsedQuery(ctx),
522-
);
556+
)));
523557
return createResponse(result, 200);
524558
} catch (e) {
525559
return mapError(e, log);
@@ -706,8 +740,8 @@ function SerenityController(context, log, env) {
706740
return auth.error;
707741
}
708742
const transport = buildTransport(ctx, imsToken);
709-
const result = auth.mode === 'subworkspace'
710-
? await handleListMarketsSubworkspace(
743+
const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace'
744+
? handleListMarketsSubworkspace(
711745
transport,
712746
/** @type {string} */ (auth.brandUuid),
713747
/** @type {string} */ (auth.workspaceId),
@@ -716,12 +750,12 @@ function SerenityController(context, log, env) {
716750
ctx.dataAccess,
717751
log,
718752
)
719-
: await handleListMarkets(
753+
: handleListMarkets(
720754
transport,
721755
ctx.dataAccess,
722756
auth.brandUuid,
723757
auth.workspaceId,
724-
);
758+
)));
725759
return createResponse(result, 200);
726760
} catch (e) {
727761
return mapError(e, log);
@@ -743,8 +777,8 @@ function SerenityController(context, log, env) {
743777
// coerce '2840abc' → 2840 and silently resolve a different slice.
744778
const geoTargetId = /^\d+$/.test(String(pGeo || '')) ? Number(pGeo) : null;
745779
const languageCode = pLang ? String(pLang).toLowerCase() : null;
746-
const result = auth.mode === 'subworkspace'
747-
? await handleGetMarketSubworkspace(
780+
const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace'
781+
? handleGetMarketSubworkspace(
748782
buildTransport(ctx, imsToken),
749783
auth.brandUuid,
750784
auth.workspaceId,
@@ -754,7 +788,7 @@ function SerenityController(context, log, env) {
754788
// Enrich the resolved slice with its siteId (LLMO-6405 Phase 2).
755789
ctx.dataAccess,
756790
)
757-
: await handleGetMarket(ctx.dataAccess, auth.brandUuid, geoTargetId, languageCode);
791+
: handleGetMarket(ctx.dataAccess, auth.brandUuid, geoTargetId, languageCode)));
758792
return createResponse(result, 200);
759793
} catch (e) {
760794
return mapError(e, log);
@@ -997,16 +1031,16 @@ function SerenityController(context, log, env) {
9971031
return auth.error;
9981032
}
9991033
const transport = buildTransport(ctx, imsToken);
1000-
const result = auth.mode === 'subworkspace'
1001-
? await handleListTagsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
1002-
: await handleListTags(
1034+
const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace'
1035+
? handleListTagsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
1036+
: handleListTags(
10031037
transport,
10041038
ctx.dataAccess,
10051039
auth.brandUuid,
10061040
auth.workspaceId,
10071041
parsedQuery(ctx),
10081042
log,
1009-
);
1043+
)));
10101044
return createResponse(result, 200);
10111045
} catch (e) {
10121046
return mapError(e, log);
@@ -1106,15 +1140,15 @@ function SerenityController(context, log, env) {
11061140
return auth.error;
11071141
}
11081142
const transport = buildTransport(ctx, imsToken);
1109-
const result = auth.mode === 'subworkspace'
1110-
? await handleListModelsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
1111-
: await handleListModels(
1143+
const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace'
1144+
? handleListModelsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log)
1145+
: handleListModels(
11121146
transport,
11131147
ctx.dataAccess,
11141148
auth.brandUuid,
11151149
auth.workspaceId,
11161150
parsedQuery(ctx),
1117-
);
1151+
)));
11181152
return createResponse(result, 200);
11191153
} catch (e) {
11201154
return mapError(e, log);
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
// @ts-check
14+
15+
import { hasText } from '@adobe/spacecat-shared-utils';
16+
import { isSemrushTransportError, unwrapTransportCause } from './errors.js';
17+
import { mintSemrushImsToken } from './semrush-ims-token.js';
18+
import { createSerenityTransport } from './rest-transport.js';
19+
20+
const DEFAULT_ROLE = 'role/workspace/viewer';
21+
22+
/**
23+
* True when the error is a Semrush auth denial (HTTP 401/403) on an outbound call.
24+
* 403 is the fixable "authenticated but not yet a workspace member" case; 401 (an
25+
* invalid/expired token) is included for completeness, but the single non-looping
26+
* retry below makes an unfixable 401 harmless.
27+
*
28+
* @param {unknown} e
29+
* @returns {boolean}
30+
*/
31+
export function isSemrushMembershipDenied(e) {
32+
const err = unwrapTransportCause(e);
33+
return isSemrushTransportError(err) && (err.status === 401 || err.status === 403);
34+
}
35+
36+
/**
37+
* Runs a Semrush data read and, if it fails because the caller is not yet a member of
38+
* the workspace (upstream 401/403), provisions the caller onto the workspace (viewer
39+
* role) using the DEDICATED Semrush IMS technical-account token, then runs the read
40+
* exactly once more.
41+
*
42+
* Guarantees:
43+
* - SINGLE retry — never loops, even if the retry also 401/403s.
44+
* - Best-effort provisioning: if the grant itself fails (e.g. 422 "no user units", or a
45+
* token-mint config error), the ORIGINAL read error is surfaced — the grant error
46+
* never masks why the read failed.
47+
* - Transparent no-op (runs the read once, unwrapped) when disabled, when
48+
* workspaceId/memberEmail is missing, or when the error is not a Semrush 401/403.
49+
*
50+
* The read keeps using the caller's own transport (passed in via `run`); provisioning
51+
* uses a SEPARATE admin transport built from the minted service token.
52+
*
53+
* @template T
54+
* @param {object} params
55+
* @param {() => Promise<T>} params.run - executes the Semrush read; must be safe to call twice.
56+
* @param {object} params.env
57+
* @param {{ info: Function, error: (m: string, meta?: object) => void }} params.log
58+
* @param {boolean} params.enabled - the SERENITY_MEMBER_AUTOPROVISION flag.
59+
* @param {string | null | undefined} params.workspaceId - the resolved brand workspace.
60+
* @param {string | null | undefined} params.memberEmail - the calling user's email.
61+
* @param {string} [params.role] - Semrush role to grant (default `role/workspace/viewer`).
62+
* @returns {Promise<T>}
63+
*/
64+
export async function withMemberAutoProvision({
65+
run, env, log, enabled, workspaceId, memberEmail, role = DEFAULT_ROLE,
66+
}) {
67+
if (!enabled) {
68+
return run();
69+
}
70+
try {
71+
return await run();
72+
} catch (readError) {
73+
// The truthiness checks also narrow the `string | null | undefined` params to
74+
// `string` for TS (hasText is not a type guard — see this dir's CLAUDE.md).
75+
if (!isSemrushMembershipDenied(readError)
76+
|| !workspaceId || !hasText(workspaceId)
77+
|| !memberEmail || !hasText(memberEmail)) {
78+
throw readError;
79+
}
80+
try {
81+
log.info('[serenity] auto-provisioning workspace member after upstream 401/403', {
82+
workspaceId,
83+
});
84+
const imsToken = await mintSemrushImsToken(env, log);
85+
const adminTransport = createSerenityTransport({ env, imsToken });
86+
await adminTransport.addWorkspaceMembers(workspaceId, [memberEmail], role);
87+
} catch (provisionError) {
88+
// Provisioning is best-effort: surface the ORIGINAL read error (the 401/403), not
89+
// the grant error, so a "no seats" (422) or mint-config failure never masks the
90+
// reason the read failed. The grant error is logged for diagnosis only.
91+
log.error('[serenity] member auto-provision failed; surfacing original read error', {
92+
workspaceId,
93+
provisionError: provisionError?.message,
94+
});
95+
throw readError;
96+
}
97+
// Provisioned — retry the read exactly once. If it still fails, that error propagates
98+
// (no further provisioning attempt).
99+
return run();
100+
}
101+
}

src/support/utils.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,22 @@ export function resolveCallerImsUserId(context) {
874874
return hasText(raw) ? String(raw) : null;
875875
}
876876

877+
/**
878+
* Resolves the caller's human email address (NOT the opaque IMS user id) from the
879+
* authenticated profile — the address to add as a Semrush workspace member. Prefers
880+
* `email`, then `preferred_username` (IMS typically sets this to the email), then
881+
* `trial_email`. Returns null when none resolve, in which case callers must skip any
882+
* member-provisioning rather than guess.
883+
*
884+
* @param {object} context - The request context.
885+
* @returns {string|null} The caller's email, or null when unresolvable.
886+
*/
887+
export function resolveCallerEmail(context) {
888+
const profile = context?.attributes?.authInfo?.getProfile?.();
889+
const raw = profile?.email ?? profile?.preferred_username ?? profile?.trial_email;
890+
return hasText(raw) ? String(raw) : null;
891+
}
892+
877893
/**
878894
* Resolves the IMS access token to forward to the Semrush gateway for a request.
879895
*

0 commit comments

Comments
 (0)