@@ -25,6 +25,7 @@ import {
2525 clearBrandWorkspaceCache ,
2626} from '../support/serenity/workspace-resolver.js' ;
2727import { mintSemrushImsToken } from '../support/serenity/semrush-ims-token.js' ;
28+ import { withMemberAutoProvision } from '../support/serenity/member-autoprovision.js' ;
2829import {
2930 handleListPrompts ,
3031 handleCreatePrompts ,
@@ -81,7 +82,7 @@ import { resolveBrandUuid } from '../support/prompts-storage.js';
8182import {
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' ;
8586import { hostnameFromUrlString } from '../support/url-utils.js' ;
8687import { ensureMarketSite , resolveSiteDomain , unlinkMarketSiteIfOrphaned } from '../support/serenity/site-linkage.js' ;
8788import { 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 ) ;
0 commit comments