@saleor/app-sdk
Version:
SDK for building great Saleor Apps
928 lines (916 loc) • 28.3 kB
JavaScript
import {
WebhookError,
WebhookErrorCodeMap
} from "./chunk-WR25JCBM.mjs";
import {
extractUserFromJwt
} from "./chunk-NHGUNOYT.mjs";
import {
SALEOR_API_URL_HEADER,
SALEOR_AUTHORIZATION_BEARER_HEADER,
SALEOR_EVENT_HEADER,
SALEOR_SCHEMA_VERSION_HEADER,
SALEOR_SIGNATURE_HEADER
} from "./chunk-FTAQRPFZ.mjs";
import {
parseSchemaVersion
} from "./chunk-DMVVCVUO.mjs";
import {
getJwksUrlFromSaleorApiUrl,
verifyJWT,
verifySignatureWithJwks
} from "./chunk-SAZABKLB.mjs";
import {
OTEL_CORE_SERVICE_NAME,
getOtelTracer
} from "./chunk-UCTHLSSD.mjs";
import {
createDebug
} from "./chunk-CPDLIPGD.mjs";
// src/auth/fetch-remote-jwks.ts
import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
import { SemanticAttributes } from "@opentelemetry/semantic-conventions";
var fetchRemoteJwks = async (saleorApiUrl) => {
const tracer = getOtelTracer();
return tracer.startActiveSpan(
"fetchRemoteJwks",
{
kind: SpanKind.CLIENT,
attributes: { saleorApiUrl, [SemanticAttributes.PEER_SERVICE]: OTEL_CORE_SERVICE_NAME }
},
async (span) => {
try {
const jwksResponse = await fetch(getJwksUrlFromSaleorApiUrl(saleorApiUrl));
const jwksText = await jwksResponse.text();
span.setStatus({ code: SpanStatusCode.OK });
return jwksText;
} catch (err) {
span.setStatus({
code: SpanStatusCode.ERROR
});
throw err;
} finally {
span.end();
}
}
);
};
// src/get-app-id.ts
var debug = createDebug("getAppId");
var getAppId = async ({
saleorApiUrl,
token
}) => {
try {
const response = await fetch(saleorApiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
query: `
{
app{
id
}
}
`
})
});
if (response.status !== 200) {
debug(`Could not get the app ID: Saleor API has response code ${response.status}`);
return void 0;
}
const body = await response.json();
const appId = body.data?.app?.id;
return appId;
} catch (e) {
debug("Could not get the app ID: %O", e);
return void 0;
}
};
// src/handlers/shared/saleor-request-processor.ts
var SaleorRequestProcessor = class {
constructor(adapter) {
this.adapter = adapter;
this.toStringOrUndefined = (value) => value ? value.toString() : void 0;
}
withMethod(methods) {
if (!methods.includes(this.adapter.method)) {
return {
body: "Method not allowed",
bodyType: "string",
status: 405
};
}
return null;
}
withSaleorApiUrlPresent() {
const { saleorApiUrl } = this.getSaleorHeaders();
if (!saleorApiUrl) {
return {
body: "Missing saleor-api-url header",
bodyType: "string",
status: 400
};
}
return null;
}
getSaleorHeaders() {
return {
authorizationBearer: this.toStringOrUndefined(
this.adapter.getHeader(SALEOR_AUTHORIZATION_BEARER_HEADER)
),
signature: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_SIGNATURE_HEADER)),
event: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_EVENT_HEADER)),
saleorApiUrl: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_API_URL_HEADER)),
/**
* Schema version must remain string. Since format is "x.x" like "3.20" for javascript it's a floating numer - so it's 3.2
* Saleor version 3.20 != 3.2.
* Semver must be compared as strings
*/
schemaVersion: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_SCHEMA_VERSION_HEADER))
};
}
};
// src/handlers/shared/validate-allow-saleor-urls.ts
var validateAllowSaleorUrls = (saleorApiUrl, allowedUrls) => {
if (!allowedUrls || allowedUrls.length === 0) {
return true;
}
for (const urlOrFn of allowedUrls) {
if (typeof urlOrFn === "string" && urlOrFn === saleorApiUrl) {
return true;
}
if (typeof urlOrFn === "function" && urlOrFn(saleorApiUrl)) {
return true;
}
}
return false;
};
// src/handlers/actions/register-action-handler.ts
var debug2 = createDebug("createAppRegisterHandler");
var RegisterCallbackError = class extends Error {
constructor(errorParams) {
super(errorParams.message);
this.status = 500;
if (errorParams.status) {
this.status = errorParams.status;
}
}
};
var createRegisterHandlerResponseBody = (success, error, statusCode) => ({
status: statusCode ?? (success ? 200 : 500),
body: {
success,
error
},
bodyType: "json"
});
var RegisterActionHandler = class {
constructor(adapter) {
this.adapter = adapter;
this.requestProcessor = new SaleorRequestProcessor(this.adapter);
this.createCallbackError = (params) => {
throw new RegisterCallbackError(params);
};
}
runPreChecks() {
const checksToRun = [
this.requestProcessor.withMethod(["POST"]),
this.requestProcessor.withSaleorApiUrlPresent()
];
for (const check of checksToRun) {
if (check) {
return check;
}
}
return null;
}
async handleAction(config) {
debug2("Request received");
const precheckResult = this.runPreChecks();
if (precheckResult) {
return precheckResult;
}
const saleorApiUrl = this.adapter.getHeader(SALEOR_API_URL_HEADER);
const authTokenResult = await this.parseRequestBody();
if (!authTokenResult.success) {
return authTokenResult.response;
}
const { authToken } = authTokenResult;
const handleOnRequestResult = await this.handleOnRequestStartCallback(config.onRequestStart, {
authToken,
saleorApiUrl
});
if (handleOnRequestResult) {
return handleOnRequestResult;
}
const saleorApiUrlValidationResult = this.handleSaleorApiUrlValidation({
saleorApiUrl,
allowedSaleorUrls: config.allowedSaleorUrls
});
if (saleorApiUrlValidationResult) {
return saleorApiUrlValidationResult;
}
const aplCheckResult = await this.checkAplIsConfigured(config.apl);
if (aplCheckResult) {
return aplCheckResult;
}
const getAppIdResult = await this.getAppIdAndHandleMissingAppId({
saleorApiUrl,
token: authToken
});
if (!getAppIdResult.success) {
return getAppIdResult.responseBody;
}
const { appId } = getAppIdResult;
const getJwksResult = await this.getJwksAndHandleMissingJwks({ saleorApiUrl });
if (!getJwksResult.success) {
return getJwksResult.responseBody;
}
const { jwks } = getJwksResult;
const authData = {
token: authToken,
saleorApiUrl,
appId,
jwks
};
const onRequestVerifiedErrorResponse = await this.handleOnRequestVerifiedCallback(
config.onRequestVerified,
authData
);
if (onRequestVerifiedErrorResponse) {
return onRequestVerifiedErrorResponse;
}
const aplSaveResponse = await this.saveAplAuthData({
apl: config.apl,
authData,
onAplSetFailed: config.onAplSetFailed,
onAuthAplSaved: config.onAuthAplSaved
});
return aplSaveResponse;
}
async parseRequestBody() {
let body;
try {
body = await this.adapter.getBody();
} catch (err) {
return {
success: false,
response: {
status: 400,
body: "Invalid request json.",
bodyType: "string"
}
};
}
const authToken = body?.auth_token;
if (!authToken) {
debug2("Found missing authToken param");
return {
success: false,
response: {
status: 400,
body: "Missing auth token.",
bodyType: "string"
}
};
}
return {
success: true,
authToken
};
}
async handleOnRequestStartCallback(onRequestStart, { authToken, saleorApiUrl }) {
if (onRequestStart) {
debug2('Calling "onRequestStart" hook');
try {
await onRequestStart(this.adapter.request, {
authToken,
saleorApiUrl,
respondWithError: this.createCallbackError
});
} catch (e) {
debug2('"onRequestStart" hook thrown error: %o', e);
return this.handleHookError(e);
}
}
return null;
}
handleSaleorApiUrlValidation({
saleorApiUrl,
allowedSaleorUrls
}) {
if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) {
debug2(
"Validation of URL %s against allowSaleorUrls param resolves to false, throwing",
saleorApiUrl
);
return createRegisterHandlerResponseBody(
false,
{
code: "SALEOR_URL_PROHIBITED",
message: "This app expects to be installed only in allowed Saleor instances"
},
403
);
}
return null;
}
async checkAplIsConfigured(apl) {
if (!apl.isConfigured) {
return null;
}
const { configured: aplConfigured } = await apl.isConfigured();
if (!aplConfigured) {
debug2("The APL has not been configured");
return createRegisterHandlerResponseBody(
false,
{
code: "APL_NOT_CONFIGURED",
message: "APL_NOT_CONFIGURED. App is configured properly. Check APL docs for help."
},
503
);
}
return null;
}
async getAppIdAndHandleMissingAppId({
saleorApiUrl,
token
}) {
const appId = await getAppId({ saleorApiUrl, token });
if (!appId) {
const responseBody = createRegisterHandlerResponseBody(
false,
{
code: "UNKNOWN_APP_ID",
message: `The auth data given during registration request could not be used to fetch app ID.
This usually means that App could not connect to Saleor during installation. Saleor URL that App tried to connect: ${saleorApiUrl}`
},
401
);
return { success: false, responseBody };
}
return { success: true, appId };
}
async getJwksAndHandleMissingJwks({ saleorApiUrl }) {
try {
const jwks = await fetchRemoteJwks(saleorApiUrl);
if (jwks) {
return { success: true, jwks };
}
} catch (err) {
}
const responseBody = createRegisterHandlerResponseBody(
false,
{
code: "JWKS_NOT_AVAILABLE",
message: "Can't fetch the remote JWKS."
},
401
);
return { success: false, responseBody };
}
async handleOnRequestVerifiedCallback(onRequestVerified, authData) {
if (onRequestVerified) {
debug2('Calling "onRequestVerified" hook');
try {
await onRequestVerified(this.adapter.request, {
authData,
respondWithError: this.createCallbackError
});
} catch (e) {
debug2('"onRequestVerified" hook thrown error: %o', e);
return this.handleHookError(e);
}
}
return null;
}
async saveAplAuthData({
apl,
onAplSetFailed,
onAuthAplSaved,
authData
}) {
try {
await apl.set(authData);
if (onAuthAplSaved) {
debug2('Calling "onAuthAplSaved" hook');
try {
await onAuthAplSaved(this.adapter.request, {
authData,
respondWithError: this.createCallbackError
});
} catch (e) {
debug2('"onAuthAplSaved" hook thrown error: %o', e);
return this.handleHookError(e);
}
}
} catch (aplError) {
debug2("There was an error during saving the auth data");
if (onAplSetFailed) {
debug2('Calling "onAuthAplFailed" hook');
try {
await onAplSetFailed(this.adapter.request, {
authData,
error: aplError,
respondWithError: this.createCallbackError
});
} catch (hookError) {
debug2('"onAuthAplFailed" hook thrown error: %o', hookError);
return this.handleHookError(hookError);
}
}
return createRegisterHandlerResponseBody(false, {
message: "Registration failed: could not save the auth data."
});
}
debug2("Register complete");
return createRegisterHandlerResponseBody(true);
}
/** Callbacks declared by users in configuration can throw an error
* It is caught here and converted into a response */
handleHookError(e) {
if (e instanceof RegisterCallbackError) {
return createRegisterHandlerResponseBody(
false,
{
code: "REGISTER_HANDLER_HOOK_ERROR",
message: e.message
},
e.status
);
}
return {
status: 500,
body: "Error during app installation",
bodyType: "string"
};
}
};
// src/handlers/actions/manifest-action-handler.ts
var debug3 = createDebug("create-manifest-handler");
var ManifestActionHandler = class {
constructor(adapter) {
this.adapter = adapter;
this.requestProcessor = new SaleorRequestProcessor(this.adapter);
}
async handleAction(options) {
const { schemaVersion } = this.requestProcessor.getSaleorHeaders();
const parsedSchemaVersion = parseSchemaVersion(schemaVersion) ?? void 0;
const baseURL = this.adapter.getBaseUrl();
debug3('Received request with schema version "%s" and base URL "%s"', schemaVersion, baseURL);
const invalidMethodResponse = this.requestProcessor.withMethod(["GET"]);
if (invalidMethodResponse) {
return invalidMethodResponse;
}
try {
const manifest = await options.manifestFactory({
appBaseUrl: baseURL,
request: this.adapter.request,
schemaVersion: parsedSchemaVersion
});
debug3("Executed manifest file");
return {
status: 200,
bodyType: "json",
body: manifest
};
} catch (e) {
debug3("Error while resolving manifest: %O", e);
return {
status: 500,
bodyType: "string",
body: "Error resolving manifest file."
};
}
}
};
// src/handlers/shared/protected-action-validator.ts
import { SpanKind as SpanKind2, SpanStatusCode as SpanStatusCode2 } from "@opentelemetry/api";
var ProtectedActionValidator = class {
constructor(adapter) {
this.adapter = adapter;
this.debug = createDebug("ProtectedActionValidator");
this.tracer = getOtelTracer();
this.requestProcessor = new SaleorRequestProcessor(this.adapter);
}
/** Validates received request if it's legitimate webhook request from Saleor
* returns ActionHandlerResult if request is invalid and must be terminated early
* */
async validateRequest(config) {
return this.tracer.startActiveSpan(
"processSaleorProtectedHandler",
{
kind: SpanKind2.INTERNAL,
attributes: {
requiredPermissions: config.requiredPermissions
}
},
async (span) => {
this.debug("Request processing started");
const { saleorApiUrl, authorizationBearer: token } = this.requestProcessor.getSaleorHeaders();
const baseUrl = this.adapter.getBaseUrl();
span.setAttribute("saleorApiUrl", saleorApiUrl ?? "");
if (!baseUrl) {
span.setStatus({
code: SpanStatusCode2.ERROR,
message: "Missing host header"
}).end();
this.debug("Missing host header");
return {
result: "failure",
value: {
bodyType: "string",
status: 400,
body: "Validation error: Missing host header"
}
};
}
if (!saleorApiUrl) {
span.setStatus({
code: SpanStatusCode2.ERROR,
message: "Missing saleor-api-url header"
}).end();
this.debug("Missing saleor-api-url header");
return {
result: "failure",
value: {
bodyType: "string",
status: 400,
body: "Validation error: Missing saleor-api-url header"
}
};
}
if (!token) {
span.setStatus({
code: SpanStatusCode2.ERROR,
message: "Missing authorization-bearer header"
}).end();
this.debug("Missing authorization-bearer header");
return {
result: "failure",
value: {
bodyType: "string",
status: 400,
body: "Validation error: Missing authorization-bearer header"
}
};
}
const authData = await config.apl.get(saleorApiUrl);
if (!authData) {
span.setStatus({
code: SpanStatusCode2.ERROR,
message: "APL didn't found auth data for API URL"
}).end();
this.debug("APL didn't found auth data for API URL %s", saleorApiUrl);
return {
result: "failure",
value: {
bodyType: "string",
status: 401,
body: `Validation error: Can't find auth data for saleorApiUrl ${saleorApiUrl}. Please register the application`
}
};
}
try {
await verifyJWT({
appId: authData.appId,
token,
saleorApiUrl,
requiredPermissions: config.requiredPermissions
});
} catch (e) {
span.setStatus({
code: SpanStatusCode2.ERROR,
message: "JWT verification failed"
}).end();
return {
result: "failure",
value: {
bodyType: "string",
status: 401,
body: "Validation error: JWT verification failed"
}
};
}
try {
const userJwtPayload = extractUserFromJwt(token);
span.end();
return {
result: "ok",
value: {
baseUrl,
authData,
user: userJwtPayload
}
};
} catch (err) {
span.setStatus({
code: SpanStatusCode2.ERROR,
message: "Error parsing user from JWT"
});
span.end();
return {
result: "failure",
value: {
bodyType: "string",
status: 500,
body: "Unexpected error: parsing user from JWT"
}
};
}
}
);
}
};
// src/gql-ast-to-string.ts
import { print } from "graphql";
var gqlAstToString = (ast) => print(ast).replaceAll(/\n*/g, "").replaceAll(/\s{2,}/g, " ").trim();
// src/handlers/shared/saleor-webhook-validator.ts
import { SpanKind as SpanKind3, SpanStatusCode as SpanStatusCode3 } from "@opentelemetry/api";
var SaleorWebhookValidator = class {
constructor(params) {
this.verifySignatureWithJwks = verifySignatureWithJwks.bind(this);
this.debug = createDebug("processProtectedHandler");
this.tracer = getOtelTracer();
if (params?.verifySignatureFn) {
this.verifySignatureWithJwks = params.verifySignatureFn;
}
}
async validateRequest(config) {
try {
const context = await this.validateRequestOrThrowError(config);
return {
result: "ok",
context
};
} catch (err) {
return {
result: "failure",
error: err
};
}
}
async validateRequestOrThrowError({
allowedEvent,
apl,
adapter,
requestProcessor
}) {
return this.tracer.startActiveSpan(
"processSaleorWebhook",
{
kind: SpanKind3.INTERNAL,
attributes: {
allowedEvent
}
},
async (span) => {
try {
this.debug("Request processing started");
if (adapter.method !== "POST") {
this.debug("Wrong HTTP method");
throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD");
}
const { event, signature, saleorApiUrl } = requestProcessor.getSaleorHeaders();
const baseUrl = adapter.getBaseUrl();
if (!baseUrl) {
this.debug("Missing host header");
throw new WebhookError("Missing host header", "MISSING_HOST_HEADER");
}
if (!saleorApiUrl) {
this.debug("Missing saleor-api-url header");
throw new WebhookError("Missing saleor-api-url header", "MISSING_API_URL_HEADER");
}
if (!event) {
this.debug("Missing saleor-event header");
throw new WebhookError("Missing saleor-event header", "MISSING_EVENT_HEADER");
}
const expected = allowedEvent.toLowerCase();
if (event !== expected) {
this.debug(`Wrong incoming request event: ${event}. Expected: ${expected}`);
throw new WebhookError(
`Wrong incoming request event: ${event}. Expected: ${expected}`,
"WRONG_EVENT"
);
}
if (!signature) {
this.debug("No signature");
throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER");
}
const rawBody = await adapter.getRawBody();
if (!rawBody) {
this.debug("Missing request body");
throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY");
}
let parsedBody;
try {
parsedBody = JSON.parse(rawBody);
} catch {
this.debug("Request body cannot be parsed");
throw new WebhookError("Request body can't be parsed", "CANT_BE_PARSED");
}
let parsedSchemaVersion = null;
try {
parsedSchemaVersion = parseSchemaVersion(parsedBody.version);
} catch {
this.debug("Schema version cannot be parsed");
}
const authData = await apl.get(saleorApiUrl);
if (!authData) {
this.debug("APL didn't found auth data for %s", saleorApiUrl);
throw new WebhookError(
`Can't find auth data for ${saleorApiUrl}. Please register the application`,
"NOT_REGISTERED"
);
}
try {
this.debug("Will verify signature with JWKS saved in AuthData");
if (!authData.jwks) {
throw new Error("JWKS not found in AuthData");
}
await this.verifySignatureWithJwks(authData.jwks, signature, rawBody);
} catch {
this.debug("Request signature check failed. Refresh the JWKS cache and check again");
const newJwks = await fetchRemoteJwks(authData.saleorApiUrl).catch((e) => {
this.debug(e);
throw new WebhookError(
"Fetching remote JWKS failed",
"SIGNATURE_VERIFICATION_FAILED"
);
});
this.debug("Fetched refreshed JWKS");
try {
this.debug(
"Second attempt to validate the signature JWKS, using fresh tokens from the API"
);
await this.verifySignatureWithJwks(newJwks, signature, rawBody);
this.debug("Verification successful - update JWKS in the AuthData");
await apl.set({ ...authData, jwks: newJwks });
} catch {
this.debug("Second attempt also ended with validation error. Reject the webhook");
throw new WebhookError(
"Request signature check failed",
"SIGNATURE_VERIFICATION_FAILED"
);
}
}
span.setStatus({
code: SpanStatusCode3.OK
});
return {
baseUrl,
event,
payload: parsedBody,
authData,
schemaVersion: parsedSchemaVersion
};
} catch (err) {
const message = err?.message ?? "Unknown error";
span.setStatus({
code: SpanStatusCode3.ERROR,
message
});
throw err;
} finally {
span.end();
}
}
);
}
};
// src/handlers/shared/generic-saleor-webhook.ts
var debug4 = createDebug("SaleorWebhook");
var GenericSaleorWebhook = class {
constructor(configuration) {
const { name, webhookPath, event, query, apl, isActive = true } = configuration;
this.name = name || `${event} webhook`;
this.query = query;
this.webhookPath = webhookPath;
this.event = event;
this.isActive = isActive;
this.apl = apl;
this.onError = configuration.onError;
this.formatErrorResponse = configuration.formatErrorResponse;
this.verifySignatureFn = configuration.verifySignatureFn ?? verifySignatureWithJwks;
this.webhookValidator = new SaleorWebhookValidator({
verifySignatureFn: this.verifySignatureFn
});
}
/** Gets webhook absolute URL based on baseUrl of app
* baseUrl is passed usually from manifest
* baseUrl can include it's own pathname (e.g. http://aws-lambda.com/prod -> has /prod pathname)
* that should be included in full webhook URL, e.g. http://my-webhook.com/prod/api/webhook/order-created */
getTargetUrl(baseUrl) {
const parsedBaseUrl = new URL(baseUrl);
const normalizedWebhookPath = this.webhookPath.replace(/^\//, "");
const fullPath = `${parsedBaseUrl.pathname}/${normalizedWebhookPath}`.replace("//", "/");
return new URL(fullPath, baseUrl).href;
}
/**
* Returns synchronous event manifest for this webhook.
*
* @param baseUrl Base URL used by your application
* @returns WebhookManifest
*/
getWebhookManifest(baseUrl) {
const manifestBase = {
query: typeof this.query === "string" ? this.query : gqlAstToString(this.query),
name: this.name,
targetUrl: this.getTargetUrl(baseUrl),
isActive: this.isActive
};
switch (this.eventType) {
case "async":
return {
...manifestBase,
asyncEvents: [this.event]
};
case "sync":
return {
...manifestBase,
syncEvents: [this.event]
};
default: {
throw new Error("Class extended incorrectly");
}
}
}
async prepareRequest(adapter) {
const requestProcessor = new SaleorRequestProcessor(adapter);
const validationResult = await this.webhookValidator.validateRequest({
allowedEvent: this.event,
apl: this.apl,
adapter,
requestProcessor
});
if (validationResult.result === "ok") {
return { result: "callHandler", context: validationResult.context };
}
const { error } = validationResult;
debug4(`Unexpected error during processing the webhook ${this.name}`);
if (error instanceof WebhookError) {
debug4(`Validation error: ${error.message}`);
if (this.onError) {
this.onError(error, adapter.request);
}
if (this.formatErrorResponse) {
const { code, body } = await this.formatErrorResponse(error, adapter.request);
return {
result: "sendResponse",
response: adapter.send({
status: code,
body,
bodyType: "string"
})
};
}
return {
result: "sendResponse",
response: adapter.send({
bodyType: "json",
body: {
error: {
type: error.errorType,
message: error.message
}
},
status: WebhookErrorCodeMap[error.errorType] || 400
})
};
}
debug4("Unexpected error: %O", error);
if (this.onError) {
this.onError(error, adapter.request);
}
if (this.formatErrorResponse) {
const { code, body } = await this.formatErrorResponse(error, adapter.request);
return {
result: "sendResponse",
response: adapter.send({
status: code,
body,
bodyType: "string"
})
};
}
return {
result: "sendResponse",
response: adapter.send({
status: 500,
body: "Unexpected error while handling request",
bodyType: "string"
})
};
}
};
export {
RegisterActionHandler,
ManifestActionHandler,
ProtectedActionValidator,
GenericSaleorWebhook
};