Compare commits

...

8 Commits

Author SHA1 Message Date
Oisin Coveney
ad17619102 testing 123 2024-10-18 17:48:15 +03:00
Oisin Coveney
13fdcd8b31 Merge branch 'change-errors-on-be-for-sdk' into testing-sdk-auth 2024-10-18 17:34:18 +03:00
Oisin Coveney
92a7858da0 Merge branch 'master' into change-errors-on-be-for-sdk 2024-10-18 17:34:16 +03:00
Oisin Coveney
e9ad3e5900 Testing things 2024-10-18 17:34:06 +03:00
Oisin Coveney
54b2621d39 Merge branch 'master' into change-errors-on-be-for-sdk 2024-10-18 16:55:57 +03:00
Oisin Coveney
4fa5b83bf2 Merge branch 'master' into change-errors-on-be-for-sdk 2024-10-18 16:48:08 +03:00
Oisin Coveney
fc5780a178 Hopefully fixed the tests 2024-10-18 16:46:18 +03:00
Oisin Coveney
81fcf84c77 Reformat files 2024-10-18 11:12:50 +03:00
20 changed files with 304 additions and 103 deletions

View File

@@ -35,8 +35,10 @@
sso-backend)
(defn- throw-not-configured-error []
(throw (ex-info (str (tru "SSO has not been enabled and/or configured"))
{:status-code 400})))
(throw
(ex-info (tru "SSO has not been enabled and/or configured")
{:status-code 400
:status "error-sso-disabled"})))
(defmethod sso-get :default
[_]

View File

@@ -7,7 +7,6 @@
[metabase-enterprise.sso.api.interface :as sso.i]
[metabase-enterprise.sso.integrations.sso-settings :as sso-settings]
[metabase-enterprise.sso.integrations.sso-utils :as sso-utils]
[metabase.api.common :as api]
[metabase.api.session :as api.session]
[metabase.embed.settings :as embed.settings]
[metabase.integrations.common :as integrations.common]
@@ -88,9 +87,10 @@
(jwt/unsign jwt (sso-settings/jwt-shared-secret)
{:max-age three-minutes-in-seconds})
(catch Throwable e
(throw (ex-info (ex-message e)
(assoc (ex-data e) :status-code 401)
e))))
(throw
(ex-info (ex-message e)
{:status "error-jwt-bad-unsigning"
:status-code 400}))))
login-attrs (jwt-data->login-attributes jwt-data)
email (get jwt-data (jwt-attribute-email))
first-name (get jwt-data (jwt-attribute-firstname))
@@ -101,16 +101,23 @@
{:session session, :redirect-url redirect-url, :jwt-data jwt-data})))
(defn- check-jwt-enabled []
(api/check (sso-settings/jwt-enabled)
[400 (tru "JWT SSO has not been enabled and/or configured")]))
(when-not (sso-settings/jwt-enabled)
(throw
(ex-info (tru "JWT SSO has not been enabled and/or configured")
{:status "error-sso-jwt-disabled" :status-code 405}))))
(defn ^:private generate-response-token
[session jwt-data]
(api/check (embed.settings/enable-embedding-sdk)
[402 (tru "SDK Embedding is not enabled.")])
(response/response {:id (:id session)
:exp (:exp jwt-data)
:iat (:iat jwt-data)}))
(if-not (embed.settings/enable-embedding-sdk)
(throw
(ex-info (tru "SDK Embedding is disabled. Enable it in the Embedding settings.")
{:status "error-embedding-sdk-disabled"
:status-code 405}))
(response/response
{:status :ok
:id (:id session)
:exp (:exp jwt-data)
:iat (:iat jwt-data)})))
(defn ^:private redirect-to-idp
[idp redirect]
@@ -134,4 +141,4 @@
(defmethod sso.i/sso-post :jwt
[_]
(throw (ex-info "POST not valid for JWT SSO requests" {:status-code 400})))
(throw (ex-info (tru "POST not valid for JWT SSO requests") {:status "error-post-jwt-not-valid" :status-code 501})))

View File

@@ -11,8 +11,7 @@
(testing "GET /api/ee/advanced-permissions/application/graph"
(mt/with-premium-features #{}
(testing "Should require a token with `:advanced-permissions`"
(is (= "Advanced Permissions is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
(mt/user-http-request :crowberto :get 402 "ee/advanced-permissions/application/graph")))))
(mt/assert-has-premium-feature-error "Advanced Permissions" (mt/user-http-request :crowberto :get 402 "ee/advanced-permissions/application/graph"))))
(mt/with-premium-features #{:advanced-permissions}
(testing "have to be a superuser"
@@ -42,8 +41,8 @@
(mt/with-premium-features #{}
(testing "Should require a token with `:advanced-permissions`"
(is (= "Advanced Permissions is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
(mt/user-http-request :crowberto :put 402 "ee/advanced-permissions/application/graph" new-graph)))))
(mt/assert-has-premium-feature-error "Advanced Permissions"
(mt/user-http-request :crowberto :put 402 "ee/advanced-permissions/application/graph" new-graph))))
(mt/with-premium-features #{:advanced-permissions}
(testing "have to be a superuser"

View File

@@ -49,8 +49,8 @@
[:data :status-code]))))))
(testing "When the `:llm-autodescription` feature is disabled, you get a 402 with message"
(mt/with-premium-features #{}
(is (= "LLM Auto-description is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
(mt/user-http-request :rasta :post 402 "ee/autodescribe/card/summarize" card))))))))))
(mt/assert-has-premium-feature-error "LLM Auto-description"
(mt/user-http-request :rasta :post 402 "ee/autodescribe/card/summarize" card)))))))))
(deftest summarize-dashboard-test
(testing "POST /api/ee/autodescribe/dashboard/summarize/:id"
@@ -95,5 +95,5 @@
[:data :status-code]))))))
(testing "When the `:llm-autodescription` feature is disabled, you get a 402 with message"
(mt/with-premium-features #{}
(is (= "LLM Auto-description is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
(mt/user-http-request :rasta :post 402 url))))))))))
(mt/assert-has-premium-feature-error "LLM Auto-description"
(mt/user-http-request :rasta :post 402 url)))))))))

View File

@@ -166,9 +166,8 @@
(deftest premium-feature-test
(testing "It requires the premium feature"
(mt/with-premium-features #{}
(is (= (str "Query Reference Validation is a paid feature not currently available to your instance. Please upgrade to"
" use it. Learn more at metabase.com/upgrade/")
(mt/user-http-request :crowberto :get 402 url))))))
(mt/assert-has-premium-feature-error "Query Reference Validation"
(mt/user-http-request :crowberto :get 402 url)))))
(defn- with-data-keys [{:keys [data] :as resp} ks]
(assoc resp :data (map (fn [d] (select-keys d ks)) data)))

View File

@@ -76,8 +76,8 @@
(let [known-files (set (.list (io/file api.serialization/parent-dir)))]
(testing "Should require a token with `:serialization`"
(mt/with-premium-features #{}
(is (= "Serialization is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
(mt/user-http-request :rasta :post 402 "ee/serialization/export")))))
(mt/assert-has-premium-feature-error "Serialization"
(mt/user-http-request :rasta :post 402 "ee/serialization/export"))))
(mt/with-premium-features #{:serialization}
(testing "POST /api/ee/serialization/export"
(mt/with-empty-h2-app-db

View File

@@ -51,8 +51,7 @@
(upload-test/with-uploads-enabled!
(testing "Behind a feature flag"
(mt/with-premium-features #{} ;; not :upload-management
(is (str/starts-with? (mt/user-http-request :crowberto :delete 402 (delete-url 1))
"Upload Management is a paid feature not currently available to your instance."))))
(mt/assert-has-premium-feature-error "Upload Management" (mt/user-http-request :crowberto :delete 402 (delete-url 1)))))
(mt/with-premium-features #{:upload-management}
(testing "Happy path\n"

View File

@@ -12,6 +12,7 @@ import { getSdkStore } from "embedding-sdk/store";
import {
setErrorComponent,
setEventHandlers,
setFetchRefreshTokenFn,
setLoaderComponent,
setMetabaseClientUrl,
setPlugins,
@@ -88,6 +89,10 @@ export const MetabaseProviderInternal = ({
store.dispatch(setMetabaseClientUrl(config.metabaseInstanceUrl));
}, [store, config.metabaseInstanceUrl]);
useEffect(() => {
store.dispatch(setFetchRefreshTokenFn(config.fetchRequestToken ?? null));
}, [store, config.fetchRequestToken]);
return (
<EmotionCacheProvider>
<Global styles={SCOPED_CSS_RESET} />

View File

@@ -22,8 +22,6 @@ const setupJwtAuth = (
api.sessionToken = (tokenState.payload as EmbeddingSessionToken | null)?.id;
};
dispatch(setLoginStatus({ status: "validated" }));
};
const setupLocalApiKey = (
@@ -31,7 +29,6 @@ const setupLocalApiKey = (
apiKey: SDKConfigWithApiKey["apiKey"],
) => {
api.apiKey = apiKey;
dispatch(setLoginStatus({ status: "validated" }));
};
export const setupSdkAuth = (config: SDKConfig, dispatch: Dispatch) =>

View File

@@ -6,6 +6,7 @@ import { setupSdkAuth } from "embedding-sdk/hooks";
import { COULD_NOT_AUTHENTICATE_MESSAGE } from "embedding-sdk/lib/user-warnings";
import { useSdkDispatch, useSdkSelector } from "embedding-sdk/store";
import {
getOrRefreshSession,
setFetchRefreshTokenFn,
setLoginStatus,
} from "embedding-sdk/store/reducer";
@@ -15,6 +16,12 @@ import api from "metabase/lib/api";
import { refreshSiteSettings } from "metabase/redux/settings";
import { refreshCurrentUser } from "metabase/redux/user";
import registerVisualizations from "metabase/visualizations/register";
import { useMount } from "react-use";
import { match } from "ts-pattern";
import {
handleServerError,
MetabaseSdkError,
} from "embedding-sdk/lib/error/error";
const registerVisualizationsOnce = _.once(registerVisualizations);
@@ -34,7 +41,7 @@ export const useInitData = ({ config }: InitDataLoaderParameters) => {
const dispatch = useSdkDispatch();
const loginStatus = useSdkSelector(getLoginStatus);
useEffect(() => {
useMount(() => {
registerVisualizationsOnce();
const EMBEDDING_SDK_VERSION = getEmbeddingSdkVersion();
@@ -50,56 +57,71 @@ export const useInitData = ({ config }: InitDataLoaderParameters) => {
`Using Metabase Embedding SDK, version ${EMBEDDING_SDK_VERSION}`,
);
}
}, [allowConsoleLog]);
useEffect(() => {
dispatch(setFetchRefreshTokenFn(config.fetchRequestToken ?? null));
}, [dispatch, config.fetchRequestToken]);
useEffect(() => {
if (loginStatus.status !== "uninitialized") {
return;
}
setupSdkAuth(config, dispatch);
}, [config, dispatch, loginStatus.status]);
useEffect(() => {
if (loginStatus.status === "validated") {
const fetchData = async () => {
dispatch(setLoginStatus({ status: "loading" }));
try {
const [userResponse, siteSettingsResponse] = await Promise.all([
dispatch(refreshCurrentUser()),
dispatch(refreshSiteSettings({})),
]);
});
match(loginStatus.status)
.with("uninitialized", () => {
setupSdkAuth(config, dispatch);
dispatch(setLoginStatus({ status: "validated" }));
})
.with("validated", async () => {
if (config.jwtProviderUri) {
await dispatch(getOrRefreshSession(config.jwtProviderUri))
.unwrap()
.then(response => {
console.log("getOrRefreshSession", response)
if (!response) {
handleServerError("error-fe-cannot-authenticate");
} else if (response instanceof Response) {
handleServerError("error-fe-received-response-object");
} else if (!(response instanceof Object)) {
handleServerError("error-fe-received-non-json-object");
} else {
dispatch(setLoginStatus({ status: "loading" }));
}
})
.catch(e => {
dispatch(
setLoginStatus({
status: "error",
error: e,
}),
);
});
}
})
.with("loading", async () => {
Promise.all([
dispatch(refreshCurrentUser()),
dispatch(refreshSiteSettings({})),
])
.then(([userResponse, siteSettingsResponse]) => {
console.log({
userResponse,siteSettingsResponse
})
if (
userResponse.meta.requestStatus === "rejected" ||
siteSettingsResponse.meta.requestStatus === "rejected"
) {
dispatch(
setLoginStatus({
status: "error",
error: new Error(COULD_NOT_AUTHENTICATE_MESSAGE),
}),
);
return;
handleServerError("error-fe-cannot-authenticate");
}
dispatch(setLoginStatus({ status: "success" }));
} catch (error) {
dispatch(
setLoginStatus({
status: "success",
}),
);
})
.catch(error => {
dispatch(
setLoginStatus({
status: "error",
error: new Error(COULD_NOT_AUTHENTICATE_MESSAGE),
error: error,
}),
);
}
};
fetchData();
}
}, [dispatch, loginStatus.status]);
});
})
.with("error", () => {})
.with("success", () => {})
.exhaustive();
};

View File

@@ -0,0 +1,118 @@
export type ErrorInfo = {
title: string;
description?: string;
link?: string;
};
type BackendErrorStatus =
| "error-embedding-sdk-disabled"
| "error-sso-disabled"
| "error-sso-jwt-disabled"
| "error-jwt-bad-unsigning"
| "error-jwt-based-authentication-not-available";
// FE auth issues
type FrontendErrorStatus =
| "error-fe-cannot-authenticate"
| "error-fe-bad-jwt-provider-uri"
| "error-fe-cannot-refresh-token"
| "error-fe-received-response-object"
| "error-fe-received-non-json-object";
// FE UI issues
type NotFoundStatus = "question-not-found" | "dashboard-not-found";
export type SdkErrorStatus =
| BackendErrorStatus
| FrontendErrorStatus
| NotFoundStatus
// please please please don't use this unless you have no idea what's going on
| "error-unknown";
const SDK_DISABLED_ERROR = {
title: "SDK Embedding is disabled.",
lebron: "james",
description: "Enable it in the Embedding settings",
} as const;
const JWT_VALIDATION_ERROR = {
title: "Couldn't validate JWT token",
description:
"Check that your JWT signing key matches the one in Authentication > JWT > Server settings",
} as const;
const SSO_DISABLED_ERROR = {
title: "SSO has not been enabled and/or configured",
} as const;
const JWT_SSO_DISABLED_ERROR = {
title: "JWT SSO has not been enabled and/or configured",
} as const;
const JWT_AUTH_UNAVAILABLE_ERROR = {
title:
"JWT-based authentication is a paid feature not currently available to your instance.",
description: "Please upgrade to use it.",
link: "metabase.com/upgrade/",
} as const;
const JWT_RESPONSE_OBJECT_ERROR = {
title: "Received Response object from JWT provider URI",
description:
"Ensure that you are returning a JSON object containing the JWT data",
} as const;
const JWT_NON_JSON_ERROR = {
title: "Received non-JSON response from JWT Provider URI",
description:
"Ensure that you are returning a JSON object containing the JWT data",
} as const;
const QUESTION_NOT_FOUND_ERROR = {
title: "Question not found",
description: "Ensure you have the right question ID.",
} as const;
const DASHBOARD_NOT_FOUND_ERROR = {
title: "Dashboard not found",
description: "Ensure you have the right dashboard ID.",
} as const;
const CANNOT_AUTHENTICATE_ERROR = {
title: "Cannot authenticate user.",
description: "Make sure you're logged in to your application.",
} as const;
const BAD_JWT_PROVIDER_URI_ERROR = {
title: "Bad JWT Provider URI",
description: "Make sure your JWT provider URI is correct.",
} as const;
const CANNOT_REFRESH_TOKEN_ERROR = {
title: "Cannot refresh token.",
description: "Ensure your backend is running and is connected to Metabase.",
} as const;
const UNKNOWN_ERROR = {
title: "Something went wrong",
} as const;
const ERROR_INFO: Record<SdkErrorStatus, ErrorInfo> = {
"error-embedding-sdk-disabled": SDK_DISABLED_ERROR,
"error-jwt-bad-unsigning": JWT_VALIDATION_ERROR,
"error-sso-disabled": SSO_DISABLED_ERROR,
"error-sso-jwt-disabled": JWT_SSO_DISABLED_ERROR,
"error-jwt-based-authentication-not-available": JWT_AUTH_UNAVAILABLE_ERROR,
"error-fe-received-response-object": JWT_RESPONSE_OBJECT_ERROR,
"error-fe-received-non-json-object": JWT_NON_JSON_ERROR,
"question-not-found": QUESTION_NOT_FOUND_ERROR,
"dashboard-not-found": DASHBOARD_NOT_FOUND_ERROR,
"error-fe-cannot-authenticate": CANNOT_AUTHENTICATE_ERROR,
"error-fe-bad-jwt-provider-uri": BAD_JWT_PROVIDER_URI_ERROR,
"error-fe-cannot-refresh-token": CANNOT_REFRESH_TOKEN_ERROR,
"error-unknown": UNKNOWN_ERROR,
} as const;
export const getErrorInfo = (status: SdkErrorStatus): ErrorInfo => {
return ERROR_INFO[status] || UNKNOWN_ERROR;
};

View File

@@ -0,0 +1,46 @@
import { getErrorInfo, SdkErrorStatus } from "embedding-sdk/lib/error/data";
interface MetabaseSdkErrorProps {
title: any;
description: any;
link: any;
statusCode: any;
}
export class MetabaseSdkError extends Error {
title: string;
description?: string;
link?: string;
statusCode: string;
constructor({ title, description, link, statusCode }: MetabaseSdkErrorProps) {
super(description);
this.name = "CustomError";
this.title = title;
this.description = description;
this.link = link;
this.statusCode = statusCode;
}
}
export const handleServerError = (statusCode: SdkErrorStatus) => {
const { title, description, link } = getErrorInfo(statusCode);
const error = new MetabaseSdkError({
title,
description,
link,
statusCode,
});
// Capture the stack trace and remove this function call
Error.captureStackTrace(error, handleServerError);
console.error(
`Error ${error.statusCode}: ${error.title}\n`,
`Description: ${error.description}\n`,
`More info: ${error.link}\n`,
error.stack,
);
throw error;
};

View File

@@ -15,6 +15,7 @@ import type { SdkUsageProblem } from "embedding-sdk/types/usage-problem";
import { createAsyncThunk } from "metabase/lib/redux";
import { getFetchRefreshTokenFn, getSessionTokenState } from "./selectors";
import { handleServerError } from "embedding-sdk/lib/error/error";
const SET_LOGIN_STATUS = "sdk/SET_LOGIN_STATUS";
const SET_METABASE_CLIENT_URL = "sdk/SET_METABASE_CLIENT_URL";
@@ -57,7 +58,7 @@ export const getOrRefreshSession = createAsyncThunk(
export const refreshTokenAsync = createAsyncThunk(
REFRESH_TOKEN,
async (url: string, { getState }): Promise<EmbeddingSessionToken | null> => {
async (url: string, { getState }): Promise<EmbeddingSessionToken | undefined> => {
// The SDK user can provide a custom function to refresh the token.
const getRefreshToken =
getFetchRefreshTokenFn(getState() as SdkStoreState) ??
@@ -66,14 +67,7 @@ export const refreshTokenAsync = createAsyncThunk(
try {
return await getRefreshToken(url);
} catch (errorCause) {
// As this function can be supplied by the SDK user,
// we have to handle possible errors in refreshing the token.
const error = new Error(t`failed to refresh the auth token`);
error.cause = errorCause;
setLoginStatus({ status: "error", error });
return null;
handleServerError("error-fe-cannot-refresh-token")
}
},
);

View File

@@ -5,4 +5,4 @@ export type EmbeddingSessionToken = {
export type FetchRequestTokenFn = (
url: string,
) => Promise<EmbeddingSessionToken | null>;
) => Promise<EmbeddingSessionToken>;

View File

@@ -362,7 +362,7 @@
[feature-name]
(ex-info (tru "{0} is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
feature-name)
{:status-code 402}))
{:status-code 402 :status "error-premium-feature-not-available"}))
(mu/defn assert-has-feature
"Check if an token with `feature` is present. If not, throw an error with a message using `feature-name`.

View File

@@ -8,14 +8,14 @@
(mt/with-model-cleanup [:model/CacheConfig]
(mt/with-premium-features #{}
(testing "Advanced caching requires premium token"
(is (= "Granular Caching is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
(mt/user-http-request :crowberto :put 402 "cache/"
{:model "question"
:model_id 123456789
:strategy {:type "nocache"}})))
(is (= "Granular Caching is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
(mt/user-http-request :crowberto :get 402 "cache/"
:model "question"))))
(mt/assert-has-premium-feature-error "Granular Caching"
(mt/user-http-request :crowberto :put 402 "cache/"
{:model "question"
:model_id 123456789
:strategy {:type "nocache"}}))
(mt/assert-has-premium-feature-error "Granular Caching"
(mt/user-http-request :crowberto :get 402 "cache/"
:model "question")))
(testing "Can operate on root settings though"
(is (mt/user-http-request :crowberto :put 200 "cache/"
{:model "root"

View File

@@ -302,9 +302,8 @@
(testing "PUT /api/permissions/graph"
(testing "make sure an error is thrown if the :sandboxes key is included in an OSS request"
(mt/with-premium-features #{}
(is (= "Sandboxes is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
(mt/user-http-request :crowberto :put 402 "permissions/graph"
(assoc (data-perms.graph/api-graph) :sandboxes [{:card_id 1}]))))))))
(mt/assert-has-premium-feature-error "Sandboxes" (mt/user-http-request :crowberto :put 402 "permissions/graph"
(assoc (data-perms.graph/api-graph) :sandboxes [{:card_id 1}])))))))
(deftest get-group-membership-test
(testing "GET /api/permissions/membership"

View File

@@ -1179,8 +1179,9 @@
(testing "error if doesn't have premium-features"
(mt/with-premium-features #{}
(is (= "Content Management or Official Collections is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
(mt/user-http-request :crowberto :get 402 "search" :q search-term :verified true)))))))))
(mt/assert-has-premium-feature-error
"Content Management or Official Collections"
(mt/user-http-request :crowberto :get 402 "search" :q search-term :verified true))))))))
(deftest created-at-api-test
(let [search-term "created-at-filtering"]

View File

@@ -294,7 +294,8 @@
[tu.public-setings
with-premium-features
with-additional-premium-features]
with-additional-premium-features
assert-has-premium-feature-error]
[u.random
random-name

View File

@@ -11,9 +11,9 @@
[features thunk]
(let [features (set (map name features))]
(testing (format "\nWith premium token features = %s" (pr-str features))
;; non-thread-local usages need to do both [[binding]] AND [[with-redefs]], because if a thread-local usage
;; happened already then the binding it establishes will shadow the value set by [[with-redefs]].
;; See [[with-premium-features-test]] below.
;; non-thread-local usages need to do both [[binding]] AND [[with-redefs]], because if a thread-local usage
;; happened already then the binding it establishes will shadow the value set by [[with-redefs]].
;; See [[with-premium-features-test]] below.
(let [thunk (^:once fn* []
(binding [premium-features/*token-features* (constantly features)]
(thunk)))]
@@ -51,5 +51,17 @@
use [[with-redefs]] instead of [[binding]])."
{:style/indent 1}
[features & body]
`(do-with-premium-features (set/union (premium-features/*token-features*) ~features)
(^:once fn* [] ~@body)))
`(do-with-premium-features
(set/union (premium-features/*token-features*) ~features)
(^:once fn* [] ~@body)))
(defn assert-has-premium-feature-error
[feature-name request]
(is
(partial=
{:cause (str feature-name " is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"),
:data {:status "error-premium-feature-not-available",
:status-code 402},
:message (str feature-name " is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"),
:status "error-premium-feature-not-available"}
request)))