Add functions to process the Saleor webhooks (#100)

* Add functions to process the saleor webhooks

Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>
This commit is contained in:
Krzysztof Wolski 2022-11-03 13:05:28 +01:00 committed by GitHub
parent 6e389db9be
commit e439438d8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1234 additions and 908 deletions

View file

@ -19,9 +19,9 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"next": "^12",
"react": ">=17", "react": ">=17",
"react-dom": ">=17", "react-dom": ">=17"
"next": "^12"
}, },
"dependencies": { "dependencies": {
"debug": "^4.3.4", "debug": "^4.3.4",
@ -29,22 +29,23 @@
"graphql": "^16.6.0", "graphql": "^16.6.0",
"jose": "^4.9.2", "jose": "^4.9.2",
"node-fetch": "^2.6.7", "node-fetch": "^2.6.7",
"raw-body": "^2.5.1",
"retes": "^0.33.0", "retes": "^0.33.0",
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-c8": "^0.23.2",
"node-mocks-http": "^1.11.0",
"next": "^12.3.0",
"@testing-library/dom": "^8.17.1", "@testing-library/dom": "^8.17.1",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@types/debug": "^4.1.7", "@types/debug": "^4.1.7",
"@types/node": "^18.7.15", "@types/node": "^18.7.15",
"@types/node-fetch": "^2.6.2", "@types/node-fetch": "^2.6.2",
"@types/react": "18.0.21",
"@types/react-dom": "^18.0.5",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.36.1", "@typescript-eslint/eslint-plugin": "^5.36.1",
"@typescript-eslint/parser": "^5.36.1", "@typescript-eslint/parser": "^5.36.1",
"@vitejs/plugin-react": "^2.1.0", "@vitejs/plugin-react": "^2.1.0",
"@vitest/coverage-c8": "^0.23.2",
"clean-publish": "^4.0.1", "clean-publish": "^4.0.1",
"eslint": "8.23.0", "eslint": "8.23.0",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
@ -57,18 +58,17 @@
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
"husky": "^8.0.1", "husky": "^8.0.1",
"jsdom": "^20.0.0", "jsdom": "^20.0.0",
"next": "^12.3.0",
"node-mocks-http": "^1.11.0",
"prettier": "2.7.1", "prettier": "2.7.1",
"react": "^18.2.0",
"react-dom": "18.2.0",
"release-it": "^15.4.1", "release-it": "^15.4.1",
"tsm": "^2.2.2", "tsm": "^2.2.2",
"tsup": "^6.2.3", "tsup": "^6.2.3",
"typescript": "^4.8.2", "typescript": "^4.8.2",
"vite": "^3.1.0", "vite": "^3.1.0",
"vitest": "^0.23.1", "vitest": "^0.23.1"
"watchlist": "^0.3.1",
"react": "^18.2.0",
"react-dom": "18.2.0",
"@types/react": "18.0.21",
"@types/react-dom": "^18.0.5"
}, },
"lint-staged": { "lint-staged": {
"*.{js,ts,tsx}": "eslint --cache --fix", "*.{js,ts,tsx}": "eslint --cache --fix",

File diff suppressed because it is too large Load diff

7
src/gql-ast-to-string.ts Normal file
View file

@ -0,0 +1,7 @@
import { ASTNode, print } from "graphql";
export const gqlAstToString = (ast: ASTNode) =>
print(ast) // convert AST to string
.replaceAll(/\n*/g, "") // remove new lines
.replaceAll(/\s{2,}/g, " ") // remove unnecessary multiple spaces
.trim(); // remove whitespace from beginning and end

View file

@ -1,2 +1,3 @@
export * from "./create-app-register-handler"; export * from "./create-app-register-handler";
export * from "./create-manifest-handler"; export * from "./create-manifest-handler";
export * from "./saleor-async-webhook";

View file

@ -0,0 +1,147 @@
import { NextApiRequest } from "next/types";
import { createMocks } from "node-mocks-http";
import rawBody from "raw-body";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { APL } from "../../APL";
import { processAsyncSaleorWebhook } from "./process-async-saleor-webhook";
vi.mock("./../../verify-signature", () => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
verifySignature: vi.fn((domain, signature) => {
if (signature !== "mocked_signature") {
throw new Error("Wrong signature");
}
}),
}));
vi.mock("raw-body", () => ({
default: vi.fn().mockResolvedValue("{}"),
}));
describe("processAsyncSaleorWebhook", () => {
let mockRequest: NextApiRequest;
const mockAPL: APL = {
get: async (domain: string) =>
domain === "example.com"
? {
domain: "example.com",
token: "mock-token",
}
: undefined,
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(),
isReady: vi.fn(),
isConfigured: vi.fn(),
};
beforeEach(() => {
// Create request method which passes all the tests
const { req } = createMocks({
headers: {
host: "some-saleor-host.cloud",
"x-forwarded-proto": "https",
"saleor-domain": "example.com",
"saleor-event": "product_updated",
"saleor-signature": "mocked_signature",
"content-length": "0", // is ignored by mocked raw-body
},
method: "POST",
// body can be skipped because we mock it with raw-body
});
mockRequest = req;
});
it("Process valid request", async () => {
await processAsyncSaleorWebhook({
req: mockRequest,
apl: mockAPL,
allowedEvent: "PRODUCT_UPDATED",
});
});
it("Throw error on non-POST request method", async () => {
mockRequest.method = "GET";
await expect(
processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" })
).rejects.toThrow("Wrong request method");
});
it("Throw error on missing domain header", async () => {
delete mockRequest.headers["saleor-domain"];
await expect(
processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" })
).rejects.toThrow("Missing saleor-domain header");
});
it("Throw error on missing event header", async () => {
delete mockRequest.headers["saleor-event"];
await expect(
processAsyncSaleorWebhook({
req: mockRequest,
apl: mockAPL,
allowedEvent: "PRODUCT_UPDATED",
})
).rejects.toThrow("Missing saleor-event header");
});
it("Throw error on mismatched event header", async () => {
mockRequest.headers["saleor-event"] = "different_event";
await expect(
processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" })
).rejects.toThrow("Wrong incoming request event: different_event. Expected: product_updated");
});
it("Throw error on missing signature header", async () => {
delete mockRequest.headers["saleor-signature"];
await expect(
processAsyncSaleorWebhook({
req: mockRequest,
apl: mockAPL,
allowedEvent: "PRODUCT_UPDATED",
})
).rejects.toThrow("Missing saleor-signature header");
});
it("Throw error on missing request body", async () => {
vi.mocked(rawBody).mockImplementationOnce(async () => {
throw new Error("Missing request body");
});
await expect(
processAsyncSaleorWebhook({
req: mockRequest,
apl: mockAPL,
allowedEvent: "PRODUCT_UPDATED",
})
).rejects.toThrow("Missing request body");
});
it("Throw error on not registered app", async () => {
mockRequest.headers["saleor-domain"] = "not-registered.example.com";
await expect(
processAsyncSaleorWebhook({
req: mockRequest,
apl: mockAPL,
allowedEvent: "PRODUCT_UPDATED",
})
).rejects.toThrow(
"Can't find auth data for domain not-registered.example.com. Please register the application"
);
});
it("Throw error on wrong signature", async () => {
mockRequest.headers["saleor-signature"] = "wrong_signature";
await expect(
processAsyncSaleorWebhook({
req: mockRequest,
apl: mockAPL,
allowedEvent: "PRODUCT_UPDATED",
})
).rejects.toThrow("Request signature check failed");
});
});

View file

@ -0,0 +1,148 @@
import { NextApiRequest } from "next";
import getRawBody from "raw-body";
import { APL } from "../../APL";
import { AuthData } from "../../APL/apl";
import { createDebug } from "../../debug";
import { getBaseUrl, getSaleorHeaders } from "../../headers";
import { verifySignature } from "../../verify-signature";
const debug = createDebug("processAsyncWebhook");
export type SaleorWebhookError =
| "OTHER"
| "MISSING_HOST_HEADER"
| "MISSING_DOMAIN_HEADER"
| "MISSING_EVENT_HEADER"
| "MISSING_PAYLOAD_HEADER"
| "MISSING_SIGNATURE_HEADER"
| "MISSING_REQUEST_BODY"
| "WRONG_EVENT"
| "NOT_REGISTERED"
| "SIGNATURE_VERIFICATION_FAILED"
| "WRONG_METHOD"
| "CANT_BE_PARSED";
export class WebhookError extends Error {
errorType: SaleorWebhookError = "OTHER";
constructor(message: string, errorType: SaleorWebhookError) {
super(message);
if (errorType) {
this.errorType = errorType;
}
Object.setPrototypeOf(this, WebhookError.prototype);
}
}
export type WebhookContext<T> = {
baseUrl: string;
event: string;
payload: T;
authData: AuthData;
};
interface ProcessSaleorWebhookArgs {
req: NextApiRequest;
apl: APL;
allowedEvent: string;
}
type ProcessAsyncSaleorWebhook = <T = unknown>(
props: ProcessSaleorWebhookArgs
) => Promise<WebhookContext<T>>;
/**
* Perform security checks on given request and return WebhookContext object.
* In case of validation issues, instance of the WebhookError will be thrown.
*
* @returns WebhookContext
*/
export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({
req,
apl,
allowedEvent,
}: ProcessSaleorWebhookArgs): Promise<WebhookContext<T>> => {
debug("Request processing started");
if (req.method !== "POST") {
debug("Wrong HTTP method");
throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD");
}
const { event, domain, signature } = getSaleorHeaders(req.headers);
const baseUrl = getBaseUrl(req.headers);
if (!baseUrl) {
debug("Missing host header");
throw new WebhookError("Missing host header", "MISSING_HOST_HEADER");
}
if (!domain) {
debug("Missing saleor-domain header");
throw new WebhookError("Missing saleor-domain header", "MISSING_DOMAIN_HEADER");
}
if (!event) {
debug("Missing saleor-event header");
throw new WebhookError("Missing saleor-event header", "MISSING_EVENT_HEADER");
}
const expected = allowedEvent.toLowerCase();
if (event !== expected) {
debug(`Wrong incoming request event: ${event}. Expected: ${expected}`);
throw new WebhookError(
`Wrong incoming request event: ${event}. Expected: ${expected}`,
"WRONG_EVENT"
);
}
if (!signature) {
debug("No signature");
throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER");
}
const rawBody = (
await getRawBody(req, {
length: req.headers["content-length"],
limit: "1mb",
})
).toString();
if (!rawBody) {
debug("Missing request body");
throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY");
}
let parsedBody: unknown;
try {
parsedBody = JSON.parse(rawBody);
} catch {
debug("Request body cannot be parsed");
throw new WebhookError("Request body cant be parsed", "CANT_BE_PARSED");
}
// Check if domain is installed in the app
const authData = await apl.get(domain);
if (!authData) {
debug("APL didn't found auth data for domain %s", domain);
throw new WebhookError(
`Can't find auth data for domain ${domain}. Please register the application`,
"NOT_REGISTERED"
);
}
// Payload signature check
// TODO: Since it require additional request, can we cache it's response?
try {
await verifySignature(domain, signature, rawBody);
} catch {
debug("Request signature check failed");
throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED");
}
return {
baseUrl,
event,
payload: parsedBody as T,
authData,
};
};

View file

@ -0,0 +1,75 @@
import { createMocks } from "node-mocks-http";
import { describe, expect, it, vi } from "vitest";
import { APL } from "../../APL";
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "./saleor-async-webhook";
const webhookPath = "api/webhooks/product-updated";
const baseUrl = "http://example.com";
describe("SaleorAsyncWebhook", () => {
const mockAPL: APL = {
get: async (domain: string) =>
domain === "example.com"
? {
domain: "example.com",
token: "mock-token",
}
: undefined,
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(),
isReady: vi.fn(),
isConfigured: vi.fn(),
};
const saleorAsyncWebhook = new SaleorAsyncWebhook({
apl: mockAPL,
asyncEvent: "PRODUCT_UPDATED",
webhookPath,
});
it("targetUrl should return full path to the webhook route based on given baseUrl", async () => {
expect(saleorAsyncWebhook.getTargetUrl(baseUrl)).toBe(`${baseUrl}/${webhookPath}`);
});
it("getWebhookManifest should return a valid manifest", async () => {
expect(saleorAsyncWebhook.getWebhookManifest(baseUrl)).toStrictEqual({
asyncEvents: ["PRODUCT_UPDATED"],
isActive: true,
name: "PRODUCT_UPDATED webhook",
targetUrl: "http://example.com/api/webhooks/product-updated",
});
});
it("Test createHandler which return success", async () => {
// prepare mocked context returned by mocked process function
vi.mock("./process-async-saleor-webhook", () => ({
processAsyncSaleorWebhook: vi.fn().mockResolvedValue({
baseUrl: "example.com",
event: "product_updated",
payload: { data: "test_payload" },
authData: { domain: "example.com", token: "token" },
}),
}));
// Test handler - will throw error if mocked context is not passed to it
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const testHandler: NextWebhookApiHandler = vi.fn().mockImplementation((req, res, context) => {
if (context.payload.data === "test_payload") {
res.status(200).end();
return;
}
throw new Error("Test payload has not been passed to handler function");
});
// We are mocking validation method, so empty mock requests will pass
const { req, res } = createMocks();
const wrappedHandler = saleorAsyncWebhook.createHandler(testHandler);
await wrappedHandler(req, res);
expect(res.statusCode).toBe(200);
// Check if test handler was used by the wrapper
expect(testHandler).toBeCalledTimes(1);
});
});

View file

@ -0,0 +1,140 @@
import { ASTNode } from "graphql";
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import { WebhookManifest } from "../..";
import { APL } from "../../APL";
import { createDebug } from "../../debug";
import { gqlAstToString } from "../../gql-ast-to-string";
import { WebhookEvent } from "../../types";
import {
processAsyncSaleorWebhook,
SaleorWebhookError,
WebhookContext,
WebhookError,
} from "./process-async-saleor-webhook";
const debug = createDebug("SaleorAsyncWebhook");
export interface WebhookManifestConfiguration {
name?: string;
webhookPath: string;
subscriptionQueryAst?: ASTNode;
query?: string;
asyncEvent: WebhookEvent;
isActive?: boolean;
apl: APL;
}
export const ErrorCodeMap: Record<SaleorWebhookError, number> = {
OTHER: 500,
MISSING_HOST_HEADER: 400,
MISSING_DOMAIN_HEADER: 400,
MISSING_EVENT_HEADER: 400,
MISSING_PAYLOAD_HEADER: 400,
MISSING_SIGNATURE_HEADER: 400,
MISSING_REQUEST_BODY: 400,
WRONG_EVENT: 400,
NOT_REGISTERED: 401,
SIGNATURE_VERIFICATION_FAILED: 401,
WRONG_METHOD: 405,
CANT_BE_PARSED: 400,
};
export type NextWebhookApiHandler<TPayload = unknown, TResp = unknown> = (
req: NextApiRequest,
res: NextApiResponse<TResp>,
ctx: WebhookContext<TPayload>
) => unknown | Promise<unknown>;
export class SaleorAsyncWebhook<TPayload = unknown> {
name: string;
webhookPath: string;
subscriptionQueryAst?: ASTNode;
query?: string;
asyncEvent: WebhookEvent;
isActive?: boolean;
apl: APL;
constructor({
name,
webhookPath,
subscriptionQueryAst,
query,
asyncEvent,
apl,
isActive = true,
}: WebhookManifestConfiguration) {
this.name = name || `${asyncEvent} webhook`;
this.subscriptionQueryAst = subscriptionQueryAst;
this.query = query;
this.webhookPath = webhookPath;
this.asyncEvent = asyncEvent;
this.isActive = isActive;
this.apl = apl;
}
/**
* Returns full URL to the webhook, based on provided baseUrl.
*
* @param baseUrl Base URL used by your application
*/
getTargetUrl(baseUrl: string) {
return new URL(this.webhookPath, baseUrl).href;
}
/**
* Returns synchronous event manifest for this webhook.
*
* @param baseUrl Base URL used by your application
* @returns WebhookManifest
*/
getWebhookManifest(baseUrl: string): WebhookManifest {
return {
name: this.name,
targetUrl: this.getTargetUrl(baseUrl),
asyncEvents: [this.asyncEvent],
isActive: this.isActive,
// Query can be provided as plaintext..
...(this.query && { query: this.query }),
// ...GQL AST which has to be stringified..
...(this.subscriptionQueryAst && { query: gqlAstToString(this.subscriptionQueryAst) }),
// or no query at all. In such case default webhook payload will be sent
};
}
/**
* Wraps provided function, to ensure incoming request comes from registered Saleor instance.
* Also provides additional `context` object containing typed payload and request properties.
*
* @param handlerFn NextApiHandler function which takes additional `context` argument
* @returns NextApiHandler
*/
createHandler(handlerFn: NextWebhookApiHandler<TPayload>): NextApiHandler {
return async (req, res) => {
await processAsyncSaleorWebhook<TPayload>({
req,
apl: this.apl,
allowedEvent: this.asyncEvent,
})
.then(async (context) => {
debug("Call handlerFn");
return handlerFn(req, res, context);
})
.catch((e) => {
if (e instanceof WebhookError) {
debug(e.message);
res.status(ErrorCodeMap[e.errorType] || 400).end();
return;
}
debug("Unexpected error during processing the webhook %O", e);
res.status(500).end();
});
};
}
}

View file

@ -16,3 +16,8 @@ export const getSaleorHeaders = (headers: {
signature: toStringOrUndefined(headers[SALEOR_SIGNATURE_HEADER]), signature: toStringOrUndefined(headers[SALEOR_SIGNATURE_HEADER]),
event: toStringOrUndefined(headers[SALEOR_EVENT_HEADER]), event: toStringOrUndefined(headers[SALEOR_EVENT_HEADER]),
}); });
export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
const { host, "x-forwarded-proto": protocol = "http" } = headers;
return `${protocol}://${host}`;
};

View file

@ -1,6 +1,9 @@
import { AppWebhook as WebhookManifest } from "./types";
export * from "./const"; export * from "./const";
export * from "./headers"; export * from "./headers";
export * from "./infer-webhooks"; export * from "./infer-webhooks";
export * from "./saleor-app"; export * from "./saleor-app";
export * from "./types"; export * from "./types";
export * from "./urls"; export * from "./urls";
export { WebhookManifest };

View file

@ -1,5 +1,6 @@
import { Middleware } from "retes"; import { Middleware } from "retes";
import { getBaseUrl } from "../headers";
import { createMiddlewareDebug } from "./middleware-debug"; import { createMiddlewareDebug } from "./middleware-debug";
const debug = createMiddlewareDebug("withBaseURL"); const debug = createMiddlewareDebug("withBaseURL");
@ -7,10 +8,10 @@ const debug = createMiddlewareDebug("withBaseURL");
export const withBaseURL: Middleware = (handler) => async (request) => { export const withBaseURL: Middleware = (handler) => async (request) => {
const { host, "x-forwarded-proto": protocol = "http" } = request.headers; const { host, "x-forwarded-proto": protocol = "http" } = request.headers;
debug("Middleware called with host: %s, protocol %s", host, request.headers["x-forwarded-proto"]); debug("Middleware called with host: %s, protocol %s", host, protocol);
request.context ??= {}; request.context ??= {};
request.context.baseURL = `${protocol}://${host}`; request.context.baseURL = getBaseUrl(request.headers);
debug("context.baseURL resolved to be: \"%s\"", request.context.baseURL); debug("context.baseURL resolved to be: \"%s\"", request.context.baseURL);

View file

@ -1,11 +1,10 @@
import crypto from "crypto"; import crypto from "crypto";
import * as jose from "jose";
import { Middleware } from "retes"; import { Middleware } from "retes";
import { Response } from "retes/response"; import { Response } from "retes/response";
import { SALEOR_DOMAIN_HEADER, SALEOR_SIGNATURE_HEADER } from "../const"; import { SALEOR_DOMAIN_HEADER, SALEOR_SIGNATURE_HEADER } from "../const";
import { getSaleorHeaders } from "../headers"; import { getSaleorHeaders } from "../headers";
import { getJwksUrl } from "../urls"; import { verifySignature } from "../verify-signature";
import { createMiddlewareDebug } from "./middleware-debug"; import { createMiddlewareDebug } from "./middleware-debug";
const debug = createMiddlewareDebug("withWebhookSignatureVerified"); const debug = createMiddlewareDebug("withWebhookSignatureVerified");
@ -62,26 +61,11 @@ export const withWebhookSignatureVerified =
}); });
} }
} else { } else {
const [header, , signature] = payloadSignature.split(".");
const jws = {
protected: header,
payload: request.rawBody,
signature,
};
const remoteJwks = jose.createRemoteJWKSet(
new URL(getJwksUrl(saleorDomain))
) as jose.FlattenedVerifyGetKey;
debug("Created remote JWKS");
try { try {
await jose.flattenedVerify(jws, remoteJwks); await verifySignature(saleorDomain, payloadSignature, request.rawBody);
debug("JWKS verified"); debug("JWKS verified");
} catch { } catch {
debug("JWKS verification failed"); debug("JWKS verification failed");
return Response.BadRequest({ return Response.BadRequest({
success: false, success: false,
message: `${ERROR_MESSAGE} Verification using public key has failed.`, message: `${ERROR_MESSAGE} Verification using public key has failed.`,

View file

@ -59,7 +59,9 @@ export interface AppWebhook {
name: string; name: string;
asyncEvents?: WebhookEvent[]; asyncEvents?: WebhookEvent[];
syncEvents?: WebhookEvent[]; syncEvents?: WebhookEvent[];
query: string; /** If query is not provided, the default webhook payload will be used */
query?: string;
/** The full URL of the endpoint where request will be sent */
targetUrl: string; targetUrl: string;
isActive?: boolean; isActive?: boolean;
} }

33
src/verify-signature.ts Normal file
View file

@ -0,0 +1,33 @@
import * as jose from "jose";
import { createDebug } from "./debug";
import { getJwksUrl } from "./urls";
const debug = createDebug("verify-signature");
/**
* Verify payload signature with public key of given `domain`
* https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#payload-signature
*/
export const verifySignature = async (domain: string, signature: string, rawBody: string) => {
const [header, , jwsSignature] = signature.split(".");
const jws: jose.FlattenedJWSInput = {
protected: header,
payload: rawBody,
signature: jwsSignature,
};
const remoteJwks = jose.createRemoteJWKSet(
new URL(getJwksUrl(domain))
) as jose.FlattenedVerifyGetKey;
debug("Created remote JWKS");
try {
await jose.flattenedVerify(jws, remoteJwks);
debug("JWKS verified");
} catch {
debug("JWKS verification failed");
throw new Error("JWKS verification failed");
}
};