diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..1632975
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,13 @@
+{
+ "root": true,
+ "env": {
+ "node": true,
+ "es2021": true
+ },
+ "parserOptions": {
+ "ecmaVersion": "latest",
+ "sourceType": "module"
+ },
+ "extends": ["eslint:recommended", "plugin:node/recommended", "prettier"],
+ "ignorePatterns": ["docs"]
+}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..176a458
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b7c2699
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,24 @@
+name: CI
+
+on: push
+
+jobs:
+ lint-and-test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node-version: ['18.x', '20.x']
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Node.js v${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'npm'
+ cache-dependency-path: 'package.json'
+ - name: Install npm dependencies
+ run: npm install
+ - name: Run lint
+ run: npm run lint
+ - name: Run tests
+ run: npm test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cfe6ad8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+# npm
+/node_modules
+/package-lock.json
+
+# JSDoc
+/docs
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..7a2e17a
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,5 @@
+{
+ "proseWrap": "always",
+ "semi": false,
+ "singleQuote": true
+}
diff --git a/.release-it.json b/.release-it.json
new file mode 100644
index 0000000..b06260f
--- /dev/null
+++ b/.release-it.json
@@ -0,0 +1,11 @@
+{
+ "git": {
+ "commitMessage": "v${version}",
+ "tagName": "v${version}",
+ "tagAnnotation": "v${version}"
+ },
+ "github": {
+ "release": true,
+ "releaseName": "v${version}"
+ }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f8797a3
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,20 @@
+MIT License
+
+Copyright (c) 2023 Truepic
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2ebb4e3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,218 @@
+
+ Truepic Webhook Verifier for Node.js
+
+
+
+ Verify webhooks from Truepic Vision or Lens in your Node.js app
+
+
+This module verifies
+
+- the integrity of the data being received,
+- the authenticity of the sender (Truepic),
+- the authenticity of the receiver (you), and
+- the time between the request being sent and received to prevent replay
+ attacks.
+
+If you're not using Node.js, this also serves as a reference implementation with
+thorough documentation to make the translation into another language as painless
+as possible.
+
+## Installation
+
+```bash
+npm install @truepic/webhook-verifier
+```
+
+## Usage
+
+The `@truepic/webhook-verifier` module exports a default function that should be
+imported to begin:
+
+```js
+import verifyTruepicWebhook from '@truepic/webhook-verifier'
+```
+
+This `verifyTruepicWebhook` function (or whatever you imported it as) is then
+called with the following arguments:
+
+```js
+verifyTruepicWebhook({
+ url: 'The full URL that received the request and is registered with Truepic.',
+ secret: "The shared secret that's registered with Truepic.",
+ header: 'The value of the `truepic-signature` header from the request.',
+ body: 'The raw body (unparsed JSON) from the request.',
+ leewayMinutes:
+ 'The number of minutes allowed between the request being sent and received. Defaults to `5`.',
+})
+```
+
+A boolean `true` is returned if the webhook is verified in all of the ways
+described above. Otherwise, if anything fails to check out, a
+`TruepicWebhookVerifierError` is thrown with a message describing why (as much
+as possible).
+
+You should place this function call at the beginning of your webhook route
+handler. Exactly how this is done depends on the web framework that you're
+using. Below are a few examples for popular web frameworks that should be easy
+to adapt if you're using a different one.
+
+### Example: Express.js
+
+```js
+import verifyTruepicWebhook from '@truepic/webhook-verifier'
+import express from 'express'
+import { env } from 'node:process'
+
+const app = express()
+
+app.post(
+ '/truepic/webhook',
+ // This is important! We need the raw request body for `verifyTruepicWebhook`.
+ express.raw({
+ type: 'application/json',
+ }),
+ (req, res, next) => {
+ try {
+ verifyTruepicWebhook({
+ url: env.TRUEPIC_WEBHOOK_URL,
+ secret: env.TRUEPIC_WEBHOOK_SECRET,
+ header: req.header('truepic-signature'),
+ body: req.body.toString(),
+ })
+ } catch (error) {
+ // The request cannot be verified. We're simply logging a warning here,
+ // but you can handle however makes sense.
+ console.warn(error)
+
+ // Return OK so a (potential) bad actor doesn't gain any insight.
+ return res.sendStatus(200)
+ }
+
+ // Process the webhook now that it's verified...
+
+ res.sendStatus(200)
+ },
+)
+
+// The rest of your app...
+```
+
+### Example: Fastify
+
+```bash
+npm install fastify-raw-body
+```
+
+```js
+import verifyTruepicWebhook from '@truepic/webhook-verifier'
+import Fastify from 'fastify'
+import { env } from 'node:process'
+
+const app = Fastify({
+ logger: true,
+})
+
+// This is important! We need the raw request body for `verifyTruepicWebhook`.
+await app.register(import('fastify-raw-body'))
+
+app.post('/truepic/webhook', async (request) => {
+ try {
+ verifyTruepicWebhook({
+ url: env.TRUEPIC_WEBHOOK_URL,
+ secret: env.TRUEPIC_WEBHOOK_SECRET,
+ header: request.headers['truepic-signature'],
+ body: request.rawBody,
+ })
+ } catch (error) {
+ // The request cannot be verified. We're simply logging a warning here,
+ // but you can handle however makes sense.
+ request.log.warn(error)
+
+ // Return OK so a (potential) bad actor doesn't gain any insight.
+ return {}
+ }
+
+ // Process the webhook now that it's verified...
+
+ return {}
+})
+
+// The rest of your app...
+```
+
+## Development
+
+### Prerequisites
+
+The only prerequisite is a compatible version of Node.js (see `engines.node` in
+[`package.json`](package.json)).
+
+### Dependencies
+
+Install dependencies with npm:
+
+```bash
+npm install
+```
+
+### Tests
+
+The built-in Node.js [test runner](https://nodejs.org/docs/latest/api/test.html)
+and [assertions module](https://nodejs.org/docs/latest/api/assert.html) is used
+for testing.
+
+To run the tests:
+
+```bash
+npm test
+```
+
+During development, it's recommended to run the tests automatically on file
+change:
+
+```bash
+npm test -- --watch
+```
+
+### Docs
+
+[JSDoc](https://jsdoc.app/) is used to document the code.
+
+To generate the docs as HTML to the (git-ignored) `docs` directory:
+
+```bash
+npm run docs
+```
+
+### Code Style & Linting
+
+[Prettier](https://prettier.io/) is setup to enforce a consistent code style.
+It's highly recommended to
+[add an integration to your editor](https://prettier.io/docs/en/editors.html)
+that automatically formats on save.
+
+[ESLint](https://eslint.org/) is setup with the
+["recommended" rules](https://eslint.org/docs/latest/rules/) to enforce a level
+of code quality. It's also highly recommended to
+[add an integration to your editor](https://eslint.org/docs/latest/use/integrations#editors)
+that automatically formats on save.
+
+To run via the command line:
+
+```bash
+npm run lint
+```
+
+### Releasing
+
+When the `development` branch is ready for release,
+[Release It!](https://github.com/release-it/release-it) is used to orchestrate
+the release process:
+
+```bash
+npm run release
+```
+
+Once the release process is complete, merge the `development` branch into the
+`main` branch, which should always reflect the latest release.
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f531fbc
--- /dev/null
+++ b/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@truepic/webhook-verifier",
+ "version": "1.0.0",
+ "type": "module",
+ "description": "Verify webhooks from Truepic Vision or Lens in your Node.js app",
+ "homepage": "https://github.com/TRUEPIC/webhook-verifier-nodejs#readme",
+ "bugs": "https://github.com/TRUEPIC/webhook-verifier-nodejs/issues",
+ "license": "MIT",
+ "main": "./src/main.js",
+ "repository": "TRUEPIC/webhook-verifier-nodejs",
+ "scripts": {
+ "docs": "jsdoc src --recurse --destination docs",
+ "lint": "npm run lint:format && npm run lint:quality",
+ "lint:format": "prettier --check .",
+ "lint:format:fix": "prettier --write .",
+ "lint:quality": "eslint .",
+ "lint:quality:fix": "eslint --fix .",
+ "release": "release-it --only-version",
+ "test": "node --test"
+ },
+ "devDependencies": {
+ "eslint": "^8.55.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-node": "^11.1.0",
+ "jsdoc": "^4.0.2",
+ "prettier": "3.1.0",
+ "release-it": "^17.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/src/error.js b/src/error.js
new file mode 100644
index 0000000..3abdae3
--- /dev/null
+++ b/src/error.js
@@ -0,0 +1,22 @@
+/**
+ * The custom error thrown when verification fails.
+ *
+ * @memberof module:@truepic/webhook-verifier
+ * @extends Error
+ */
+class TruepicWebhookVerifierError extends Error {
+ /**
+ * Create an error for a failed verification.
+ *
+ * @param {string} message The description of what failed.
+ */
+ constructor(message) {
+ super(message)
+
+ Error.captureStackTrace(this, this.constructor)
+
+ this.name = this.constructor.name
+ }
+}
+
+export default TruepicWebhookVerifierError
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..42a8ef0
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,168 @@
+import { createHmac, timingSafeEqual } from 'node:crypto'
+import TruepicWebhookVerifierError from './error.js'
+
+/**
+ * Parse the `truepic-signature` header into timestamp and signature values.
+ *
+ * The header value looks like this:
+ *
+ * t=1634066973,s=6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU
+ *
+ * The `t` value is the timestamp of when the request was sent (in seconds), and
+ * the `s` value is the signature of the request.
+ *
+ * @private
+ * @param {string} header The value of the `truepic-signature` header from the request.
+ * @throws {TruepicWebhookVerifierError} If parsing fails.
+ * @returns {Object} The parsed `timestamp` and `signature` values.
+ */
+function parseHeader(header) {
+ if (!header?.length) {
+ throw new TruepicWebhookVerifierError('Header is missing or empty')
+ }
+
+ // Split the the header value on the comma (`,`). This should leave two parts:
+ // - t=1634066973
+ // - s=6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU
+ const [timestampParts, signatureParts] = header.split(',')
+
+ if (!timestampParts?.length || !signatureParts?.length) {
+ throw new TruepicWebhookVerifierError(
+ 'Header cannot be parsed into timestamp and signature',
+ )
+ }
+
+ // Split the timestamp (`t`) on the equals (`=`). This should leave two parts:
+ // - t
+ // - 1634066973
+ let [t, timestamp] = timestampParts.split('=')
+
+ if (t !== 't' || !timestamp?.length) {
+ throw new TruepicWebhookVerifierError('Timestamp is missing or empty')
+ }
+
+ // Cast and verify that the timestamp value is a number.
+ timestamp = Number(timestamp)
+
+ if (isNaN(timestamp)) {
+ throw new TruepicWebhookVerifierError('Timestamp is not a number')
+ }
+
+ // Split the signature (`s`) on the equals (`=`). This should leave two parts:
+ // - s
+ // - 6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU
+ const [s, signature] = signatureParts.split('=')
+
+ if (s !== 's' || !signature?.length) {
+ throw new TruepicWebhookVerifierError('Signature is missing or empty')
+ }
+
+ return { timestamp, signature }
+}
+
+/**
+ * Verify the timestamp to ensure the request is recent and not a potentially
+ * delayed replay attack. Some leeway is required in case the clocks on either
+ * end of the request aren't quite in sync.
+ *
+ * @private
+ * @param {Object} options
+ * @param {number} options.timestamp The timestamp parsed from the `truepic-signature` request header.
+ * @param {number} options.leewayMinutes The number of minutes allowed between the request being sent and received.
+ * @throws {TruepicWebhookVerifierError} If verification fails.
+ * @returns {true} If verification succeeds.
+ */
+function verifyTimestamp({ timestamp, leewayMinutes }) {
+ const diff = Math.abs(Date.now() - timestamp * 1000)
+ const diffMinutes = Math.ceil(diff / (1000 * 60))
+
+ if (diffMinutes > leewayMinutes) {
+ throw new TruepicWebhookVerifierError(
+ 'Timestamp is not within allowed window',
+ )
+ }
+
+ return true
+}
+
+/**
+ * Verify the signature to ensure the integrity of the data being received, the
+ * authenticity of the sender (Truepic), and the authenticity of the receiver
+ * (you).
+ *
+ * @private
+ * @param {Object} options
+ * @param {string} options.url The full URL that received the request and is registered with Truepic.
+ * @param {string} options.secret The shared secret that's registered with Truepic.
+ * @param {string} options.body The raw body (unparsed JSON) from the request.
+ * @param {number} options.timestamp The timestamp parsed from the `truepic-signature` request header.
+ * @param {string} options.signature The signature parsed from the `truepic-signature` request header.
+ * @throws {TruepicWebhookVerifierError} If verification fails.
+ * @returns {true} If verification succeeds.
+ */
+function verifySignature({ url, secret, body, timestamp, signature }) {
+ // Rebuild the signature (SHA-256, base64-encoded HMAC digest) with a secret
+ // that only Truepic and the intended receiver are privy to.
+ const comparisonSignature = createHmac('sha256', secret)
+
+ // Concatenate the full URL that received the request, timestamp parsed from
+ // the header, and raw body (unparsed JSON) from the request using a comma
+ // (`,`). It's important to use the raw body before it's parsed as JSON, as
+ // different languages/frameworks can parse/stringify JSON in subtly different
+ // ways, which can result in a different signature.
+ comparisonSignature.update([url, timestamp, body].join(','))
+
+ // Compare with a constant-time algorithm to prevent a timing attack.
+ const isEqual = timingSafeEqual(
+ Buffer.from(comparisonSignature.digest('base64'), 'base64'),
+ Buffer.from(signature, 'base64'),
+ )
+
+ if (!isEqual) {
+ throw new TruepicWebhookVerifierError('Signature is not valid')
+ }
+
+ return true
+}
+
+/**
+ * Verify a webhook from Truepic Vision or Lens.
+ *
+ * @memberof module:@truepic/webhook-verifier
+ * @param {Object} options
+ * @param {string} options.url The full URL that received the request and is registered with Truepic.
+ * @param {string} options.secret The shared secret that's registered with Truepic.
+ * @param {string} options.header The value of the `truepic-signature` header from the request.
+ * @param {string} options.body The raw body (unparsed JSON) from the request.
+ * @param {number} [options.leewayMinutes=5] The number of minutes allowed between the request being sent and received.
+ * @throws {TruepicWebhookVerifierError} If verification fails.
+ * @returns {true} If verification succeeds.
+ */
+function verifyTruepicWebhook({
+ url,
+ secret,
+ header,
+ body,
+ leewayMinutes = 5,
+}) {
+ const { timestamp, signature } = parseHeader(header)
+
+ verifyTimestamp({
+ timestamp,
+ leewayMinutes,
+ })
+
+ verifySignature({
+ url,
+ secret,
+ body,
+ timestamp,
+ signature,
+ })
+
+ return true
+}
+
+/** @module @truepic/webhook-verifier */
+export default verifyTruepicWebhook
+export { TruepicWebhookVerifierError }
diff --git a/src/main.test.js b/src/main.test.js
new file mode 100644
index 0000000..90a56a6
--- /dev/null
+++ b/src/main.test.js
@@ -0,0 +1,214 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+import verifyTruepicWebhook, { TruepicWebhookVerifierError } from './main.js'
+
+describe('verifyTruepicWebhook', () => {
+ // Successful values.
+ const url = 'http://localhost:3001/webhook'
+ const secret = 'secret'
+ const header = 't=1698259719,s=S9lwmAyba6aYa/Ts2jlJ6venPhSvlGjd0QdNsvi8iq8='
+ const body =
+ '{"type":"captures.created","data":{"id":"dd4b8e37-0e2e-47de-91d1-b3eb00aa9d36","type":"PHOTO","status":"WAITING","custom_data":null,"uploaded_by_ip_address":"::1","file_size":2878119,"file_hash":"fVEXbAR0bs0EqIYtJoCRUz067zCJWGp6yW+xwKMHPtw=","created_at":"2023-10-25T18:48:39.479Z","updated_at":"2023-10-25T18:48:39.479Z","processed_at":null,"url":"http://localhost:4566/lens-captures-development/dd4b8e37-0e2e-47de-91d1-b3eb00aa9d36.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=abc%2F20231025%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20231025T184839Z&X-Amz-Expires=36000&X-Amz-Signature=857b33be14592b1618093293d665a41c1043a5cb5c0fef31951a5390bcc8be03&X-Amz-SignedHeaders=host&x-id=GetObject"}}'
+ const leewayMinutes = 999999999
+
+ it('returns `true` if verification is successful', () => {
+ assert.strictEqual(
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header,
+ body,
+ leewayMinutes,
+ }),
+ true,
+ )
+ })
+
+ describe('throws a `TruepicWebhookVerifierError`', () => {
+ it('if the `header` is missing', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header: null,
+ body,
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError('Header is missing or empty'),
+ )
+ })
+
+ it('if the `header` is empty', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header: '',
+ body,
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError('Header is missing or empty'),
+ )
+ })
+
+ it('if the `header` cannot be parsed into timestamp and signature', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header: 'bad',
+ body,
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError(
+ 'Header cannot be parsed into timestamp and signature',
+ ),
+ )
+ })
+
+ it('if the `header` is missing the timestamp (`t`)', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header: 'b=bad,s=test',
+ body,
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError('Timestamp is missing or empty'),
+ )
+ })
+
+ it('if the `header` timestamp (`t`) is empty', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header: 't=,s=test',
+ body,
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError('Timestamp is missing or empty'),
+ )
+ })
+
+ it('if the `header` timestamp (`t`) is not a number', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header: 't=bad,s=test',
+ body,
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError('Timestamp is not a number'),
+ )
+ })
+
+ it('if the `header` is missing the signature (`s`)', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header: 't=123,b=bad',
+ body,
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError('Signature is missing or empty'),
+ )
+ })
+
+ it('if the `header` signature (`s`) is empty', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header: 't=123,s=',
+ body,
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError('Signature is missing or empty'),
+ )
+ })
+
+ it('if the timestamp is not within allowed window', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header,
+ body,
+ leewayMinutes: 5,
+ }),
+ new TruepicWebhookVerifierError(
+ 'Timestamp is not within allowed window',
+ ),
+ )
+ })
+
+ it('if the `url` is not where the request was sent', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url: 'http://bad/webhook',
+ secret,
+ header,
+ body,
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError('Signature is not valid'),
+ )
+ })
+
+ it('if the `timestamp` is not what was signed', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header: header.replace('t=1698259719', 't=1698259718'),
+ body,
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError('Signature is not valid'),
+ )
+ })
+
+ it('if the `body` is not what was signed', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret,
+ header,
+ body: '{"bad":"webhook"}',
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError('Signature is not valid'),
+ )
+ })
+
+ it('if the `secret` is not what was used to sign', () => {
+ assert.throws(
+ () =>
+ verifyTruepicWebhook({
+ url,
+ secret: 'bad',
+ header,
+ body,
+ leewayMinutes,
+ }),
+ new TruepicWebhookVerifierError('Signature is not valid'),
+ )
+ })
+ })
+})